auto-save 2026-05-17 21:14 (~3)
This commit is contained in:
@@ -1197,20 +1197,199 @@ function AudioIntakePanel({
|
||||
)
|
||||
}
|
||||
|
||||
function SourceReferenceBuildPanel({
|
||||
job,
|
||||
selectedFrames,
|
||||
onToggleFrame,
|
||||
onJobUpdate,
|
||||
}: {
|
||||
job: Job
|
||||
selectedFrames: Set<number>
|
||||
onToggleFrame: (idx: number) => void
|
||||
onJobUpdate: (job: Job) => void
|
||||
}) {
|
||||
const [extracting, setExtracting] = useState(false)
|
||||
const [subjectBusy, setSubjectBusy] = useState(false)
|
||||
const frames = useMemo(() => [...job.frames].sort((a, b) => a.timestamp - b.timestamp), [job.frames])
|
||||
const selectedReferenceFrames = useMemo(
|
||||
() => frames.filter((frame) => selectedFrames.has(frame.index)),
|
||||
[frames, selectedFrames],
|
||||
)
|
||||
const actorSource = useMemo(() => {
|
||||
const pool = selectedReferenceFrames.length ? selectedReferenceFrames : frames
|
||||
for (const frame of pool) {
|
||||
const element = frame.elements?.find(isSimilarActorElement)
|
||||
if (element?.subject_assets?.length) return { frame, element }
|
||||
}
|
||||
for (const frame of pool) {
|
||||
const element = frame.elements?.find(isSimilarActorElement)
|
||||
if (element) return { frame, element }
|
||||
}
|
||||
return null
|
||||
}, [frames, selectedReferenceFrames])
|
||||
const actorAssets = actorSource?.element.subject_assets ?? []
|
||||
|
||||
const extractKeyframes = async () => {
|
||||
setExtracting(true)
|
||||
try {
|
||||
for (const frame of job.frames) {
|
||||
if (selectedFrames.has(frame.index)) onToggleFrame(frame.index)
|
||||
}
|
||||
const updated = await analyzeJob(job.id, 12, "motion", "replace", "accurate")
|
||||
onJobUpdate(updated)
|
||||
toast.info("已开始重新抽取 12 张关键帧,完成后在这里人工选择参考。")
|
||||
} catch (e) {
|
||||
toast.error("12 张关键帧抽取失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setExtracting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const generateSimilarActor = async () => {
|
||||
if (!selectedReferenceFrames.length) {
|
||||
toast.warning("请先从 12 张关键帧里选择主角参考帧。")
|
||||
return
|
||||
}
|
||||
const baseFrame = selectedReferenceFrames[0]
|
||||
if (!baseFrame) return
|
||||
setSubjectBusy(true)
|
||||
try {
|
||||
let workingJob = job
|
||||
let workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? baseFrame
|
||||
let element = workingFrame.elements?.find(isSimilarActorElement)
|
||||
if (!element) {
|
||||
workingJob = await addElement(job.id, baseFrame.index, {
|
||||
name_zh: "相似主角",
|
||||
name_en: "similar ad actor",
|
||||
position: "source-video main presenter selected from global keyframes",
|
||||
source: "manual",
|
||||
})
|
||||
onJobUpdate(workingJob)
|
||||
workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? workingFrame
|
||||
element = workingFrame.elements?.find(isSimilarActorElement)
|
||||
?? workingFrame.elements?.[workingFrame.elements.length - 1]
|
||||
}
|
||||
if (!element) throw new Error("similar actor element missing")
|
||||
|
||||
const updated = await generateSubjectAssets(job.id, baseFrame.index, element.id, {
|
||||
subject_kind: "living",
|
||||
subject_style: "source_actor",
|
||||
reconstruction_mode: "similar",
|
||||
background: "white",
|
||||
size: "1024",
|
||||
source_frame_indices: selectedReferenceFrames.slice(0, 12).map((frame) => frame.index),
|
||||
views: ["front", "back", "left", "right", "three_quarter_left", "three_quarter_right"],
|
||||
prompt: "Create a new similar information-feed ad presenter, not a replica of the source person. Keep the creator-ad energy, pose vocabulary, wardrobe category, shot readability, and commercial realism. White background six-view reference sheet output, consistent actor identity across views.",
|
||||
})
|
||||
onJobUpdate(updated)
|
||||
toast.success("相似主角 6 张白底视图已生成")
|
||||
} catch (e) {
|
||||
toast.error("相似主角重构失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSubjectBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="关键帧 / 相似主角" />
|
||||
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">
|
||||
{frames.length ? `${frames.length} 张` : "待抽帧"} · 已选 {selectedReferenceFrames.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-[238px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void extractKeyframes()}
|
||||
disabled={!job.video_url || extracting || job.status === "splitting"}
|
||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2 text-[10.5px] font-semibold text-white/66 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
{extracting || job.status === "splitting" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
||||
抽取 12 帧
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generateSimilarActor()}
|
||||
disabled={!selectedReferenceFrames.length || subjectBusy}
|
||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-white px-2 text-[10.5px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{subjectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
生成 6 视图
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-4 gap-1.5">
|
||||
{frames.slice(0, 12).map((frame, index) => {
|
||||
const selected = selectedFrames.has(frame.index)
|
||||
return (
|
||||
<button
|
||||
key={frame.index}
|
||||
type="button"
|
||||
onClick={() => onToggleFrame(frame.index)}
|
||||
className={`group relative h-12 overflow-hidden rounded border bg-black transition ${
|
||||
selected ? "border-emerald-300/70" : "border-white/10 hover:border-cyan-300/40"
|
||||
}`}
|
||||
title={`关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`}
|
||||
>
|
||||
<img src={effectiveFrameUrl(job.id, frame)} alt="" className="h-full w-full object-cover" />
|
||||
<span className="absolute left-1 top-1 rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>
|
||||
<span className="absolute right-1 top-1 rounded-full bg-black/72 p-0.5">
|
||||
{selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{!frames.length && (
|
||||
<div className="col-span-4 flex h-[106px] items-center justify-center rounded border border-dashed border-white/12 text-[11px] text-white/34">
|
||||
点击“抽取 12 帧”后,这里会展示原视频关键画面。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 border-t border-white/8 pt-2">
|
||||
<div className="mb-1.5 flex items-center justify-between text-[10px] text-white/36">
|
||||
<span>相似主角白底视图</span>
|
||||
<span>{actorAssets.length}/6</span>
|
||||
</div>
|
||||
{actorAssets.length ? (
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{actorAssets.slice(-6).map((asset) => (
|
||||
<a
|
||||
key={asset.id}
|
||||
href={subjectAssetUrl(job, asset)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="h-10 overflow-hidden rounded border border-white/10 bg-white"
|
||||
title={asset.label || asset.view}
|
||||
>
|
||||
<img src={subjectAssetUrl(job, asset)} alt={asset.label || asset.view} className="h-full w-full object-contain" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed border-white/12 px-2 py-2 text-[10.5px] leading-snug text-white/32">
|
||||
选择能代表主角状态的关键帧后,用图像模型生成“类似但不复刻”的 6 张白底视图。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AudioStoryboardPlanPanel({
|
||||
job,
|
||||
onAddFrame,
|
||||
onOpenFrame,
|
||||
selectedFrames,
|
||||
onJobUpdate,
|
||||
onGenerateVideo,
|
||||
}: {
|
||||
job: Job | null
|
||||
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
|
||||
onOpenFrame?: (idx: number) => void
|
||||
selectedFrames: Set<number>
|
||||
onJobUpdate?: (job: Job) => void
|
||||
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
||||
}) {
|
||||
const [busyRow, setBusyRow] = useState<number | null>(null)
|
||||
const [videoBusyRow, setVideoBusyRow] = useState<number | null>(null)
|
||||
const [productItems, setProductItems] = useState<ProductRefItem[]>([])
|
||||
const [productUploading, setProductUploading] = useState(false)
|
||||
@@ -1222,6 +1401,11 @@ function AudioStoryboardPlanPanel({
|
||||
const productFileRef = useRef<HTMLInputElement | null>(null)
|
||||
const rows = useMemo(() => buildAudioStoryboardRows(job), [job])
|
||||
const orderedFrames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job])
|
||||
const selectedReferenceFrames = useMemo(
|
||||
() => orderedFrames.filter((frame) => selectedFrames.has(frame.index)),
|
||||
[orderedFrames, selectedFrames],
|
||||
)
|
||||
const rowReferencePool = selectedReferenceFrames.length ? selectedReferenceFrames : orderedFrames
|
||||
|
||||
useEffect(() => {
|
||||
setProductItems([])
|
||||
@@ -1245,23 +1429,12 @@ function AudioStoryboardPlanPanel({
|
||||
current_text: copyForRow(row),
|
||||
})
|
||||
|
||||
const framesForRow = (row: AudioStoryboardRow) =>
|
||||
orderedFrames.filter((frame) => frame.timestamp >= row.start - 0.2 && frame.timestamp <= row.end + 0.2).slice(0, 3)
|
||||
const referenceFrameForRow = (row: AudioStoryboardRow) =>
|
||||
closestFrameForTime(rowReferencePool, clampNumber((row.start + row.end) / 2, 0, Math.max(job?.duration || row.end, row.end)))
|
||||
|
||||
const videosForRow = (refs: KeyFrame[]) => {
|
||||
const refIndices = new Set(refs.map((frame) => frame.index))
|
||||
return (job?.generated_videos ?? []).filter((video) => refIndices.has(video.frame_idx))
|
||||
}
|
||||
|
||||
const addReferenceFrame = async (row: AudioStoryboardRow) => {
|
||||
if (!job || !onAddFrame) return
|
||||
const t = clampNumber((row.start + row.end) / 2, 0, Math.max(job.duration || row.end, row.end))
|
||||
setBusyRow(row.index)
|
||||
try {
|
||||
await onAddFrame(job.id, t)
|
||||
} finally {
|
||||
setBusyRow(null)
|
||||
}
|
||||
const videosForFrame = (frame: KeyFrame | null) => {
|
||||
if (!frame) return []
|
||||
return (job?.generated_videos ?? []).filter((video) => video.frame_idx === frame.index)
|
||||
}
|
||||
|
||||
const itemSourceForRef = (ref: ImageRef) => productItems.find((item) => sameImageRef(item.ref, ref))?.source ?? "upload"
|
||||
@@ -1459,9 +1632,8 @@ function AudioStoryboardPlanPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const generateRowVideo = async (row: AudioStoryboardRow, refs: KeyFrame[]) => {
|
||||
if (!job || !refs.length || !onGenerateVideo) return
|
||||
const frame = refs[0]
|
||||
const generateRowVideo = async (row: AudioStoryboardRow, frame: KeyFrame | null) => {
|
||||
if (!job || !frame || !onGenerateVideo) return
|
||||
const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null
|
||||
const scene = buildStoryboardSceneFromAudioRow({ ...row, skgCopy: copyForRow(row) }, frame, nextFrame, productItems)
|
||||
setVideoBusyRow(row.index)
|
||||
@@ -1483,7 +1655,7 @@ function AudioStoryboardPlanPanel({
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="信息流复刻分镜工作台" />
|
||||
<p className="mt-1 text-[11px] leading-snug text-white/42">每条分镜纵向排列;行内从左到右完成原内容、新文案、画面/产品、参考帧和生成视频。</p>
|
||||
<p className="mt-1 text-[11px] leading-snug text-white/42">每条分镜纵向排列;行内完成原内容、新文案、画面/产品和视频候选。关键帧选择与相似主角重构在原版视频旁统一处理。</p>
|
||||
</div>
|
||||
<div className="grid shrink-0 grid-cols-3 gap-2 text-[11px] text-white/45">
|
||||
<Requirement label="分镜" ready={rows.length > 0} detail={rows.length ? `${rows.length} 条` : "待音频"} />
|
||||
@@ -1596,15 +1768,14 @@ function AudioStoryboardPlanPanel({
|
||||
</div>
|
||||
<div className="max-h-[560px] space-y-2 overflow-y-auto pr-1">
|
||||
{rows.map((row) => {
|
||||
const refs = framesForRow(row)
|
||||
const rowVideos = videosForRow(refs)
|
||||
const busy = busyRow === row.index
|
||||
const referenceFrame = referenceFrameForRow(row)
|
||||
const rowVideos = videosForFrame(referenceFrame)
|
||||
const generating = videoBusyRow === row.index
|
||||
const copyText = copyForRow(row)
|
||||
return (
|
||||
<article
|
||||
key={row.index}
|
||||
className="grid overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64 xl:grid-cols-[64px_minmax(96px,0.5fr)_minmax(132px,0.68fr)_minmax(176px,1fr)_minmax(128px,0.72fr)_230px]"
|
||||
className="grid overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64 xl:grid-cols-[64px_minmax(104px,0.54fr)_minmax(148px,0.72fr)_minmax(220px,1fr)_270px]"
|
||||
>
|
||||
<StoryboardPlanCell label="分镜">
|
||||
<div className="font-mono text-[11px] text-white/40">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</div>
|
||||
@@ -1642,45 +1813,15 @@ function AudioStoryboardPlanPanel({
|
||||
</p>
|
||||
</StoryboardPlanCell>
|
||||
|
||||
<StoryboardPlanCell label="参考帧 / 关键元素">
|
||||
{refs.length ? (
|
||||
<div className="mb-1.5 flex gap-1.5 overflow-x-auto pb-1">
|
||||
{refs.map((frame) => (
|
||||
<button
|
||||
key={frame.index}
|
||||
type="button"
|
||||
onClick={() => onOpenFrame?.(frame.index)}
|
||||
className="h-14 w-9 shrink-0 overflow-hidden rounded border border-white/10 bg-black/45 transition hover:border-cyan-300/40"
|
||||
title={`参考帧 ${frame.timestamp.toFixed(1)}s`}
|
||||
>
|
||||
<img src={effectiveFrameUrl(job.id, frame)} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-2 line-clamp-2 text-white/34" title={row.referencePlan}>{row.referencePlan}</p>
|
||||
)}
|
||||
<div className="line-clamp-2 text-[10px] text-white/38" title={row.keyElements}>
|
||||
<ImageIcon className="mr-1 inline h-3 w-3" />
|
||||
{row.keyElements}
|
||||
<StoryboardPlanCell label="生成视频" className="xl:border-r-0">
|
||||
<StoryboardVideoSlots job={job} videos={rowVideos} enabled={!!referenceFrame} />
|
||||
<div className="mt-1 truncate text-[10px] text-white/34" title={referenceFrame ? `参考 ${referenceFrame.timestamp.toFixed(1)}s` : row.referencePlan}>
|
||||
{referenceFrame ? `参考 ${referenceFrame.timestamp.toFixed(1)}s · 可多次生成候选` : "先在原版视频旁抽取 12 帧"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addReferenceFrame(row)}
|
||||
disabled={!onAddFrame || busy}
|
||||
className="mt-1.5 inline-flex h-7 w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2 text-[11px] text-white/70 transition hover:border-white/25 hover:bg-white/[0.1] disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
||||
{refs.length ? "补抽参考帧" : "抽参考帧"}
|
||||
</button>
|
||||
</StoryboardPlanCell>
|
||||
|
||||
<StoryboardPlanCell label="生成视频" className="xl:border-r-0">
|
||||
<StoryboardVideoSlots job={job} videos={rowVideos} enabled={refs.length > 0} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => generateRowVideo(row, refs)}
|
||||
disabled={!refs.length || !onGenerateVideo || generating}
|
||||
onClick={() => generateRowVideo(row, referenceFrame)}
|
||||
disabled={!referenceFrame || !onGenerateVideo || generating}
|
||||
className="mt-1.5 inline-flex h-8 w-full items-center justify-center gap-1 rounded-md bg-white px-2 text-[11px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{generating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||
@@ -1693,7 +1834,7 @@ function AudioStoryboardPlanPanel({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成信息流复刻分镜工作台。先看结构,再按分镜定向抽参考帧和生成视频。" />
|
||||
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成信息流复刻分镜工作台。先在原版视频旁抽取 12 张关键帧并选择主角参考,再按分镜生成视频候选。" />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
@@ -1862,7 +2003,7 @@ function StoryboardVideoSlots({ job, videos, enabled }: { job: Job; videos: Gene
|
||||
))}
|
||||
{Array.from({ length: emptyCount }).map((_, index) => (
|
||||
<div key={`empty-video-${index}`} className="flex h-[74px] min-w-0 items-center justify-center rounded border border-dashed border-white/12 bg-black/25 px-1 text-center text-[10px] leading-tight text-white/26">
|
||||
{enabled ? `候选 ${visible.length + index + 1}` : "先抽参考帧"}
|
||||
{enabled ? `候选 ${visible.length + index + 1}` : "先抽 12 帧"}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user