From 201abc60d13de8ec6fbbedfd4dbfaba29841e271 Mon Sep 17 00:00:00 2001 From: kang Date: Sun, 17 May 2026 11:23:22 +0800 Subject: [PATCH] auto-save 2026-05-17 11:23 (~3) --- .memory/worklog.json | 38 +- web/app/page.tsx | 37 +- web/components/ad-recreation-board.tsx | 820 ++++++++++++++++--------- 3 files changed, 565 insertions(+), 330 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 948c8a2..0504d17 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,24 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "08ee1cb", - "message": "auto-save 2026-05-14 16:53 (~1)", - "ts": "2026-05-14T16:53:29+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 16:53 (~1)", - "ts": "2026-05-14T08:56:14Z", - "type": "session-heartbeat" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 16:53 (~1)", - "ts": "2026-05-14T08:58:42Z", - "type": "session-heartbeat" - }, { "files_changed": 1, "hash": "10ef888", @@ -3269,6 +3250,25 @@ "message": "auto-save 2026-05-17 11:01 (~5)", "hash": "31b8738", "files_changed": 5 + }, + { + "ts": "2026-05-17T11:05:39+08:00", + "type": "commit", + "message": "refactor: replace flow nodes with ad recreation board", + "hash": "2e19f4b", + "files_changed": 3 + }, + { + "ts": "2026-05-17T03:08:23Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: replace flow nodes with ad recreation board", + "files_changed": 1 + }, + { + "ts": "2026-05-17T03:18:23Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: replace flow nodes with ad recreation board", + "files_changed": 1 } ] } diff --git a/web/app/page.tsx b/web/app/page.tsx index 1a320d2..bdef743 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1007,40 +1007,11 @@ export default function Home() {
- - {/* 右区:暂时清空,只保留无限画布能力,后续再定义要承载的内容。 */} -
-
- -
-
- {clientReady ? ( - { flowRef.current = instance }} - colorMode={resolvedTheme === "light" ? "light" : "dark"} - fitView - fitViewOptions={{ padding: 0.12 }} - minZoom={0.2} - maxZoom={1.5} - nodesDraggable={false} - nodesConnectable={false} - elementsSelectable={false} - proOptions={{ hideAttribution: true }} - > - - - - ) : ( -
- )} -
- {clientReady && setAudioStripJobId(null)} />} -
- +
+ +
+ {clientReady && setAudioStripJobId(null)} />} -
) diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 451af3a..5a79925 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -2,8 +2,8 @@ import { type ReactNode, useEffect, useRef, useState } from "react" import { - AlertTriangle, Check, Circle, Film, Image as ImageIcon, Loader2, - Mic, Play, Plus, Scissors, Trash2, Upload, Wand2, + AlertTriangle, Check, Circle, Film, FileText, Image as ImageIcon, Link2, Loader2, + Mic, Package, PanelRight, Play, Plus, Scissors, Sparkles, Trash2, Upload, Wand2, } from "lucide-react" import { toast } from "sonner" import { @@ -13,8 +13,13 @@ import { type Job, type KeyFrame, type StoryboardScene, + addElement, apiAssetUrl, + cutoutElement, effectiveFrameUrl, + generatedImageUrl, + hasCutout, + representativeCutoutUrl, updateStoryboard, videoUrl, } from "@/lib/api" @@ -43,10 +48,10 @@ const VIDEO_MODELS = [ ] as const const controlClass = - "h-9 rounded-md border border-white/10 bg-black/55 px-2 text-[12px] text-white outline-none transition focus:border-cyan-300/60 disabled:cursor-not-allowed disabled:opacity-40" + "h-10 rounded-md border border-white/10 bg-black/55 px-3 text-[12px] text-white outline-none transition focus:border-cyan-300/60 disabled:cursor-not-allowed disabled:opacity-40" const fieldClass = - "w-full resize-y rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[12px] leading-relaxed text-white outline-none transition placeholder:text-white/28 focus:border-cyan-300/60" + "w-full resize-y rounded-md border border-white/10 bg-black/35 px-3 py-2 text-[12px] leading-relaxed text-white outline-none transition placeholder:text-white/28 focus:border-cyan-300/60" const emptyScene = (): StoryboardScene => ({ duration: 5, @@ -87,6 +92,20 @@ function videoSrc(video: GeneratedVideo) { return apiAssetUrl(video.url) } +function audioPreview(job: Job | null) { + if (!job) return "导入素材后,先解析音频,再把产品内容改写成新的分镜文字。" + const rewritten = job.audio_script?.rewritten_text?.trim() + if (rewritten) return rewritten + if (job.transcript?.length) return job.transcript.slice(0, 5).map((item) => item.en || item.zh).join(" ") + return "暂无音频文案。解析后这里会作为新剧情和分镜文字的依据。" +} + +function storyboardFrames(job: Job | null, selectedFrames: KeyFrame[]) { + if (!job) return [] + if (selectedFrames.length > 0) return selectedFrames + return [...job.frames].sort((a, b) => a.timestamp - b.timestamp) +} + export function AdRecreationBoard({ data, onGenerateVideo, @@ -97,13 +116,15 @@ export function AdRecreationBoard({ const { job, jobs, activeJobId } = data const [url, setUrl] = useState("") const [selectedVideoIds, setSelectedVideoIds] = useState>(new Set()) + const [elementBusyFrame, setElementBusyFrame] = useState(null) const fileRef = useRef(null) - const tone = statusTone(job) const selectedFrames = job ? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp) : [] + const framesForStoryboard = storyboardFrames(job, selectedFrames) const generatedVideos = job?.generated_videos ?? [] const audioReady = !!job?.audio_script?.rewritten_text?.trim() + const tone = statusTone(job) const submitUrl = () => { const trimmed = url.trim() @@ -135,230 +156,380 @@ export function AdRecreationBoard({ }) } + const generateElementForFrame = async (frame: KeyFrame) => { + if (!job) return + setElementBusyFrame(frame.index) + try { + const existing = frame.elements?.[0] + if (existing) { + const updated = await cutoutElement(job.id, frame.index, existing.id) + data.onJobUpdate(updated) + toast.success(`已生成元素:${existing.name_zh || existing.name_en || "主体"}`) + return + } + + const firstObject = frame.description?.objects?.[0] + const name = firstObject?.name?.trim() || "主体" + const added = await addElement(job.id, frame.index, { + name_zh: name, + name_en: name, + position: firstObject?.position, + source: "manual", + }) + const latestFrame = added.frames.find((item) => item.index === frame.index) + const newElement = latestFrame?.elements?.[latestFrame.elements.length - 1] + if (!newElement) { + data.onJobUpdate(added) + toast.success(`已登记元素:${name}`) + return + } + const updated = await cutoutElement(job.id, frame.index, newElement.id) + data.onJobUpdate(updated) + toast.success(`已生成元素:${name}`) + } catch (e) { + toast.error("元素生成失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setElementBusyFrame(null) + } + } + return ( - + ) } -function Metric({ label, value }: { label: string; value: string }) { +function BoardColumn({ + icon, + step, + title, + subtitle, + children, +}: { + icon: ReactNode + step: string + title: string + subtitle: string + children: ReactNode +}) { return ( -
+
+
+
+
+ {icon} + {step} +
+
+

{title}

+

{subtitle}

+
+ {children} +
+ ) +} + +function MaterialCard({ + job, + index, + active, + onClick, + onDelete, +}: { + job: Job + index: number + active: boolean + onClick: () => void + onDelete?: () => void +}) { + const tone = statusTone(job) + return ( + + ) +} + +function Metric({ label, value, compact }: { label: string; value: string; compact?: boolean }) { + return ( +
{label}
{value}
@@ -367,37 +538,19 @@ function Metric({ label, value }: { label: string; value: string }) { function SectionTitle({ icon, title }: { icon: ReactNode; title: string }) { return ( -

+

{icon} {title} -

+ ) } -function WorkflowCard({ - icon, - title, - ready, - running, - children, -}: { - icon: ReactNode - title: string - ready: boolean - running?: boolean - children: ReactNode -}) { +function StatusPill({ ready, running }: { ready: boolean; running?: boolean }) { return ( -
-
- - - {running ? : ready ? : } - {running ? "运行中" : ready ? "已就绪" : "待处理"} - -
- {children} -
+ + {running ? : ready ? : } + {running ? "运行中" : ready ? "已就绪" : "待处理"} + ) } @@ -417,7 +570,7 @@ function ActionButton({ type="button" disabled={disabled} onClick={onClick} - className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-md px-3 text-[12px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 ${variant === "solid" ? "bg-white text-black hover:bg-white/90" : "border border-white/10 bg-white/[0.04] text-white/72 hover:border-white/25 hover:text-white"}`} + className={`inline-flex h-10 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 text-[12px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 ${variant === "solid" ? "bg-white text-black hover:bg-white/90" : "border border-white/10 bg-white/[0.04] text-white/72 hover:border-white/25 hover:text-white"}`} > {children} @@ -426,7 +579,7 @@ function ActionButton({ function EmptyState({ text }: { text: string }) { return ( -
+
{text}
) @@ -436,19 +589,19 @@ function SceneRow({ job, frame, order, + selected, + onToggle, onJobUpdate, - onGenerateVideo, }: { job: Job frame: KeyFrame order: number + selected: boolean + onToggle: () => void onJobUpdate: (job: Job) => void - onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise | void }) { const [scene, setScene] = useState(() => ({ ...emptyScene(), ...(frame.storyboard ?? {}) })) - const [model, setModel] = useState<(typeof VIDEO_MODELS)[number]["value"]>("seedance") const [saving, setSaving] = useState(false) - const [generating, setGenerating] = useState(false) useEffect(() => { setScene({ ...emptyScene(), ...(frame.storyboard ?? {}) }) @@ -469,65 +622,176 @@ function SceneRow({ } } - const generate = async () => { - setGenerating(true) - try { - await save() - await onGenerateVideo(frame.index, scene, model) - } finally { - setGenerating(false) - } - } - return ( -
-
- {frameLabel(frame, +
+
+
{frameLabel(frame, order)}
- +
-
{frame.description?.scene || "未识别画面内容"}
+

{frame.description?.scene || "等待生成新的分镜文字"}