auto-save 2026-05-17 16:00 (~3)
This commit is contained in:
@@ -287,6 +287,19 @@ function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] {
|
||||
})
|
||||
}
|
||||
|
||||
function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFrame, nextFrame?: KeyFrame | null): StoryboardScene {
|
||||
return {
|
||||
duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || 4.5)).toFixed(1)),
|
||||
first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${row.index + 1} 参考帧` },
|
||||
last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${row.index + 1} 尾帧` } : null,
|
||||
subject: row.keyElements,
|
||||
scene: `${row.visualPlan}\n原音频依据:${row.source}`,
|
||||
product: row.productIntegration,
|
||||
action: row.skgCopy,
|
||||
reference_ids: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function AdRecreationBoard({
|
||||
data,
|
||||
onGenerateVideo,
|
||||
@@ -547,6 +560,8 @@ export function AdRecreationBoard({
|
||||
job={job}
|
||||
onAddFrame={data.onAddManualFrameForJob}
|
||||
onOpenFrame={data.onOpenFramePanel}
|
||||
onJobUpdate={data.onJobUpdate}
|
||||
onGenerateVideo={onGenerateVideo}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -885,18 +900,28 @@ function AudioStoryboardPlanPanel({
|
||||
job,
|
||||
onAddFrame,
|
||||
onOpenFrame,
|
||||
onJobUpdate,
|
||||
onGenerateVideo,
|
||||
}: {
|
||||
job: Job | null
|
||||
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
|
||||
onOpenFrame?: (idx: number) => void
|
||||
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 rows = useMemo(() => buildAudioStoryboardRows(job), [job])
|
||||
const orderedFrames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job])
|
||||
|
||||
const framesForRow = (row: AudioStoryboardRow) =>
|
||||
orderedFrames.filter((frame) => frame.timestamp >= row.start - 0.2 && frame.timestamp <= row.end + 0.2).slice(0, 3)
|
||||
|
||||
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))
|
||||
@@ -908,101 +933,176 @@ function AudioStoryboardPlanPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const generateRowVideo = async (row: AudioStoryboardRow, refs: KeyFrame[]) => {
|
||||
if (!job || !refs.length || !onGenerateVideo) return
|
||||
const frame = refs[0]
|
||||
const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null
|
||||
const scene = buildStoryboardSceneFromAudioRow(row, frame, nextFrame)
|
||||
setVideoBusyRow(row.index)
|
||||
try {
|
||||
const updated = await updateStoryboard(job.id, frame.index, scene)
|
||||
onJobUpdate?.(updated)
|
||||
await onGenerateVideo(frame.index, scene, "seedance")
|
||||
} catch (e) {
|
||||
toast.error("生成本条视频失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setVideoBusyRow(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!job) return null
|
||||
|
||||
return (
|
||||
<section className="mt-3 rounded-lg border border-white/10 bg-black/28 p-2.5">
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="SKG 分镜规划表" />
|
||||
<p className="mt-1 text-[11px] leading-snug text-white/42">先按音频内容规划产品分镜,再按每条分镜定向抽参考帧和关键元素。</p>
|
||||
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="信息流复刻分镜工作台" />
|
||||
<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} 条` : "待音频"} />
|
||||
<Requirement label="参考帧" ready={orderedFrames.length > 0} detail={orderedFrames.length ? `${orderedFrames.length} 张` : "待抽帧"} />
|
||||
<Requirement label="主线" ready={rows.length > 0} detail="先规划" />
|
||||
<Requirement label="生成" ready={(job.generated_videos?.length ?? 0) > 0} detail={`${job.generated_videos?.length ?? 0} 条`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.length ? (
|
||||
<div className="overflow-hidden rounded-md border border-white/10">
|
||||
<div className="grid grid-cols-[78px_86px_minmax(150px,0.9fr)_minmax(180px,1fr)_minmax(170px,1fr)_140px_112px] border-b border-white/10 bg-white/[0.04] px-3 py-2 text-[11px] font-semibold text-white/50">
|
||||
<div>时间</div>
|
||||
<div>结构</div>
|
||||
<div>原内容</div>
|
||||
<div>SKG 新文案</div>
|
||||
<div>画面规划 / 产品融入</div>
|
||||
<div>参考帧</div>
|
||||
<div>下一步</div>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-y-auto">
|
||||
{rows.map((row) => {
|
||||
const refs = framesForRow(row)
|
||||
const busy = busyRow === row.index
|
||||
return (
|
||||
<div
|
||||
key={row.index}
|
||||
className="grid grid-cols-[78px_86px_minmax(150px,0.9fr)_minmax(180px,1fr)_minmax(170px,1fr)_140px_112px] gap-3 border-b border-white/8 px-3 py-2 text-[11.5px] leading-snug text-white/64 last:border-b-0"
|
||||
>
|
||||
<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 generating = videoBusyRow === row.index
|
||||
return (
|
||||
<article
|
||||
key={row.index}
|
||||
className="grid overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11.5px] leading-snug text-white/64 xl:grid-cols-[82px_minmax(130px,0.8fr)_minmax(150px,1fr)_minmax(180px,1.1fr)_minmax(150px,0.9fr)_146px]"
|
||||
>
|
||||
<StoryboardPlanCell label="分镜">
|
||||
<div className="font-mono text-[11px] text-white/40">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</div>
|
||||
<div>
|
||||
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-emerald-100/75">{row.role}</span>
|
||||
<div className="mt-2 inline-flex max-w-full rounded-md border border-emerald-300/15 bg-emerald-300/[0.08] px-2 py-1 text-[11px] text-emerald-100/80">
|
||||
{row.role}
|
||||
</div>
|
||||
<div className="line-clamp-3" title={row.source}>{row.source}</div>
|
||||
<div className="line-clamp-3 text-white/78" title={row.skgCopy}>{row.skgCopy}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="line-clamp-2" title={row.visualPlan}>{row.visualPlan}</div>
|
||||
<div className="line-clamp-2 text-white/42" title={row.productIntegration}>
|
||||
<Package className="mr-1 inline h-3 w-3 text-rose-200/70" />
|
||||
{row.productIntegration}
|
||||
</StoryboardPlanCell>
|
||||
|
||||
<StoryboardPlanCell label="原内容">
|
||||
<p className="line-clamp-5" title={row.source}>{row.source}</p>
|
||||
</StoryboardPlanCell>
|
||||
|
||||
<StoryboardPlanCell label="新口播文案">
|
||||
<p className="line-clamp-5 text-white/82" title={row.skgCopy}>{row.skgCopy}</p>
|
||||
</StoryboardPlanCell>
|
||||
|
||||
<StoryboardPlanCell label="画面规划 / 产品融入">
|
||||
<p className="line-clamp-3" title={row.visualPlan}>{row.visualPlan}</p>
|
||||
<p className="mt-1.5 line-clamp-3 text-white/45" title={row.productIntegration}>
|
||||
<Package className="mr-1 inline h-3 w-3 text-rose-200/75" />
|
||||
{row.productIntegration}
|
||||
</p>
|
||||
</StoryboardPlanCell>
|
||||
|
||||
<StoryboardPlanCell label="参考帧 / 关键元素">
|
||||
{refs.length ? (
|
||||
<div className="mb-2 flex gap-1.5 overflow-x-auto pb-1">
|
||||
{refs.map((frame) => (
|
||||
<button
|
||||
key={frame.index}
|
||||
type="button"
|
||||
onClick={() => onOpenFrame?.(frame.index)}
|
||||
className="h-16 w-10 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-[10.5px] text-white/38" title={row.keyElements}>
|
||||
<ImageIcon className="mr-1 inline h-3 w-3" />
|
||||
{row.keyElements}
|
||||
</div>
|
||||
<div>
|
||||
{refs.length ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{refs.map((frame) => (
|
||||
<button
|
||||
key={frame.index}
|
||||
type="button"
|
||||
onClick={() => onOpenFrame?.(frame.index)}
|
||||
className="h-12 w-8 overflow-hidden rounded border border-white/10 bg-black/45"
|
||||
title={`参考帧 ${frame.timestamp.toFixed(1)}s`}
|
||||
>
|
||||
<img src={effectiveFrameUrl(job.id, frame)} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-white/32">{row.referencePlan}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addReferenceFrame(row)}
|
||||
disabled={!onAddFrame || busy}
|
||||
className="inline-flex h-8 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" />}
|
||||
抽参考帧
|
||||
</button>
|
||||
<div className="flex items-center gap-1 text-[10px] text-white/35">
|
||||
<ImageIcon className="h-3 w-3" />
|
||||
{refs.length ? "下一步提元素" : "先抽帧"}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addReferenceFrame(row)}
|
||||
disabled={!onAddFrame || busy}
|
||||
className="mt-2 inline-flex h-8 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">
|
||||
{rowVideos.length > 0 ? (
|
||||
<div className="mb-2 flex gap-1.5 overflow-x-auto pb-1">
|
||||
{rowVideos.map((video) => (
|
||||
<StoryboardVideoPreview key={video.id} job={job} video={video} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-2 flex h-20 items-center justify-center rounded-md border border-dashed border-white/12 bg-black/25 text-[11px] text-white/30">
|
||||
{refs.length ? "等待生成" : "先抽参考帧"}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => generateRowVideo(row, refs)}
|
||||
disabled={!refs.length || !onGenerateVideo || generating}
|
||||
className="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" />}
|
||||
生成本条
|
||||
</button>
|
||||
</StoryboardPlanCell>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成 SKG 分镜规划表。先看结构,再按分镜定向抽参考帧。" />
|
||||
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成信息流复刻分镜工作台。先看结构,再按分镜定向抽参考帧和生成视频。" />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StoryboardPlanCell({ label, children, className = "" }: { label: string; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`min-w-0 border-b border-white/8 p-2.5 xl:border-b-0 xl:border-r ${className}`}>
|
||||
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white/32">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StoryboardVideoPreview({ job, video }: { job: Job; video: GeneratedVideo }) {
|
||||
const src = videoSrc(video)
|
||||
const poster = videoPoster(job, video)
|
||||
const running = video.status === "queued" || video.status === "in_progress"
|
||||
return (
|
||||
<a
|
||||
href={src || undefined}
|
||||
target={src ? "_blank" : undefined}
|
||||
rel={src ? "noreferrer" : undefined}
|
||||
className="group relative h-20 w-12 shrink-0 overflow-hidden rounded border border-white/10 bg-black/45"
|
||||
title={`${video.model} · ${video.status}`}
|
||||
>
|
||||
{src && video.status === "completed" ? (
|
||||
<video src={src} poster={poster} muted playsInline className="h-full w-full object-cover" />
|
||||
) : poster ? (
|
||||
<img src={poster} alt={`片段 ${shortId(video.id)}`} className="h-full w-full object-cover opacity-80" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-white/30"><Film className="h-4 w-4" /></div>
|
||||
)}
|
||||
<span className="absolute bottom-1 left-1 right-1 truncate rounded bg-black/70 px-1 py-0.5 text-center font-mono text-[9px] text-white/62">
|
||||
{running ? "生成中" : video.status === "failed" ? "失败" : shortId(video.id)}
|
||||
</span>
|
||||
{running && <Loader2 className="absolute right-1 top-1 h-3 w-3 animate-spin text-cyan-100" />}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function AudioWaveform({
|
||||
features,
|
||||
status,
|
||||
|
||||
Reference in New Issue
Block a user