diff --git a/.memory/worklog.json b/.memory/worklog.json index 08d6b0a..8127936 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,18 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "3ab1683", - "message": "auto-save 2026-05-14 17:15 (~1)", - "ts": "2026-05-14T17:15:35+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 17:15 (~1)", - "ts": "2026-05-14T09:16:14Z", - "type": "session-heartbeat" - }, { "files_changed": 1, "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 17:15 (~1)", @@ -3268,6 +3255,19 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: switch ad workflow to horizontal kanban", "files_changed": 1 + }, + { + "ts": "2026-05-17T11:55:45+08:00", + "type": "commit", + "message": "auto-save 2026-05-17 11:55 (~1, -1)", + "hash": "4a3110b", + "files_changed": 2 + }, + { + "ts": "2026-05-17T03:58:23Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-17 11:55 (~1, -1)", + "files_changed": 1 } ] } diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx new file mode 100644 index 0000000..9f9843c --- /dev/null +++ b/web/components/ad-recreation-board.tsx @@ -0,0 +1,1065 @@ +"use client" + +import { type ReactNode, type RefObject, useEffect, useRef, useState } from "react" +import { + 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 { + type FrameExtractQuality, + type FrameExtractTarget, + type GeneratedVideo, + type Job, + type KeyFrame, + type StoryboardScene, + addElement, + apiAssetUrl, + cutoutElement, + effectiveFrameUrl, + generatedImageUrl, + hasCutout, + representativeCutoutUrl, + updateStoryboard, + videoUrl, +} from "@/lib/api" +import { type NodeData } from "@/components/nodes" + +const TARGETS: Array<{ value: FrameExtractTarget; label: string }> = [ + { value: "balanced", label: "综合" }, + { value: "subject", label: "主体" }, + { value: "motion", label: "动作" }, + { value: "expression", label: "表情" }, + { value: "transition", label: "转场" }, + { value: "transparent_human", label: "骨架人" }, +] + +const QUALITIES: Array<{ value: FrameExtractQuality; label: string }> = [ + { value: "auto", label: "自动" }, + { value: "fast", label: "快速" }, + { value: "accurate", label: "精细" }, + { value: "ultra", label: "极准" }, +] + +const VIDEO_MODELS = [ + { value: "seedance", label: "Seedance" }, + { value: "kling", label: "Kling" }, + { value: "veo3", label: "Veo" }, +] as const + +type VideoModel = (typeof VIDEO_MODELS)[number]["value"] + +type DraftSegment = { + id: string + frameIndex: number | null + scene: StoryboardScene +} + +const controlClass = + "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-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, + subject: "", + product: "", + scene: "", + action: "", + reference_ids: [], +}) + +function statusTone(job: Job | null) { + if (!job) return { label: "等待素材", className: "border-white/10 text-white/50 bg-white/[0.03]" } + if (job.status === "failed") return { label: "失败", className: "border-rose-400/30 text-rose-200 bg-rose-500/10" } + if (["created", "downloading", "splitting", "transcribing"].includes(job.status)) { + return { label: "处理中", className: "border-cyan-300/30 text-cyan-100 bg-cyan-400/10" } + } + return { label: "可编辑", className: "border-emerald-300/30 text-emerald-100 bg-emerald-400/10" } +} + +function shortId(id?: string | null) { + return id ? id.slice(0, 8) : "-" +} + +function formatSeconds(raw?: number) { + if (!raw || Number.isNaN(raw)) return "0.0s" + return `${raw.toFixed(1)}s` +} + +function frameLabel(frame: KeyFrame, order: number) { + return `S${String(order + 1).padStart(2, "0")} · ${frame.timestamp.toFixed(1)}s` +} + +function videoPoster(job: Job, video: GeneratedVideo) { + return apiAssetUrl(video.poster_url) || (job.frames[0] ? effectiveFrameUrl(job.id, job.frames[0]) : "") +} + +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 orderedFrames(job: Job | null, selectedFrames: KeyFrame[]) { + if (!job) return [] + if (selectedFrames.length > 0) return selectedFrames + return [...job.frames].sort((a, b) => a.timestamp - b.timestamp) +} + +function countReadySegments(job: Job | null, drafts: DraftSegment[]) { + const frameStoryboards = job?.frames.filter((frame) => !!frame.storyboard).length ?? 0 + const draftCount = drafts.length + return frameStoryboards + draftCount +} + +export function AdRecreationBoard({ + data, + onGenerateVideo, +}: { + data: NodeData + onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise | void +}) { + const { job, jobs, activeJobId } = data + const [url, setUrl] = useState("") + const [selectedVideoIds, setSelectedVideoIds] = useState>(new Set()) + const [draftSegments, setDraftSegments] = useState([]) + const [elementBusyFrame, setElementBusyFrame] = useState(null) + const fileRef = useRef(null) + const selectedFrames = job + ? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp) + : [] + const framesForSegments = orderedFrames(job, selectedFrames) + const generatedVideos = job?.generated_videos ?? [] + const audioReady = !!job?.audio_script?.rewritten_text?.trim() + const readySegments = countReadySegments(job, draftSegments) + + useEffect(() => { + setDraftSegments([]) + setSelectedVideoIds(new Set()) + }, [activeJobId]) + + const submitUrl = () => { + const trimmed = url.trim() + if (!trimmed) return + data.onSubmitUrl(trimmed) + setUrl("") + } + + const selectAllFrames = () => { + if (!job) return + for (const frame of job.frames) { + if (!data.selectedFrames.has(frame.index)) data.onToggleFrame(frame.index) + } + } + + const clearFrameSelection = () => { + if (!job) return + for (const frame of job.frames) { + if (data.selectedFrames.has(frame.index)) data.onToggleFrame(frame.index) + } + } + + const addDraftSegment = () => { + setDraftSegments((prev) => [ + ...prev, + { + id: `draft-${Date.now()}-${prev.length}`, + frameIndex: null, + scene: emptyScene(), + }, + ]) + } + + const updateDraftSegment = (id: string, patch: Partial) => { + setDraftSegments((prev) => prev.map((draft) => draft.id === id ? { ...draft, ...patch } : draft)) + } + + const removeDraftSegment = (id: string) => { + setDraftSegments((prev) => prev.filter((draft) => draft.id !== id)) + } + + const toggleVideo = (videoId: string) => { + setSelectedVideoIds((prev) => { + const next = new Set(prev) + if (next.has(videoId)) next.delete(videoId) + else next.add(videoId) + return next + }) + } + + 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 ( +
+
+
+
+
+
feed ad storyboard board
+

信息流广告分镜生产板

+
+
+ + + + + +
+
+ +
+ + +
+
+
+
+
+ + 02 +
+

音频分镜生产板块

+

每张分镜卡从上到下对应:音频分镜文案、关键元素、视频生成。

+
+
+ data.onTranscribeAudio?.(job?.id)}> + + 解析音频 + + data.onOpenAudioStrip?.(job?.id)}> + 打开音轨 + + + + 追加分镜 + +
+
+ +
+
+
+ } title="音频文案依据" /> + +
+
+ {audioPreview(job)} +
+
+ + +
+
+ +
+
+ {job && framesForSegments.length > 0 ? framesForSegments.map((frame, order) => ( + video.frame_idx === frame.index)} + busy={elementBusyFrame === frame.index} + onToggleFrame={() => data.onToggleFrame(frame.index)} + onJobUpdate={data.onJobUpdate} + onGenerateElement={() => generateElementForFrame(frame)} + onGenerateVideo={onGenerateVideo} + onToggleVideo={toggleVideo} + onDeleteVideo={(videoId) => data.onDeleteVideo?.(videoId)} + /> + )) : null} + + {draftSegments.map((draft, index) => ( + updateDraftSegment(draft.id, patch)} + onRemove={() => removeDraftSegment(draft.id)} + onJobUpdate={data.onJobUpdate} + onGenerateVideo={onGenerateVideo} + /> + ))} + + {!job && } + {job && framesForSegments.length === 0 && draftSegments.length === 0 && ( + + )} +
+
+ +
+ +
+
+
+
+
+ ) +} + +function MaterialColumn({ + data, + jobs, + job, + activeJobId, + url, + setUrl, + fileRef, + onSubmitUrl, +}: { + data: NodeData + jobs: Job[] + job: Job | null + activeJobId: string | null + url: string + setUrl: (value: string) => void + fileRef: RefObject + onSubmitUrl: () => void +}) { + return ( +
+
+
+ + 01 +
+

素材输入

+

一个素材就是一次文件任务

+
+ +
+ setUrl(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") onSubmitUrl() }} + placeholder="粘贴 TK / 信息流视频链接" + className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/45 px-3 text-[13px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/60" + /> + + + { + const file = e.target.files?.[0] + if (file) data.onUploadFile(file) + e.currentTarget.value = "" + }} + /> +
+ +
+ {jobs.length ? jobs.map((item, index) => ( + data.onSwitchJob(item.id)} + onDelete={data.onDeleteJob ? () => data.onDeleteJob?.(item.id) : undefined} + /> + )) : ( + + )} +
+ + {job?.video_url && ( +
+ ) +} + +function FrameExtractControls({ + job, + data, + selectedFramesCount, + onSelectAllFrames, + onClearFrameSelection, +}: { + job: Job | null + data: NodeData + selectedFramesCount: number + onSelectAllFrames: () => void + onClearFrameSelection: () => void +}) { + return ( +
+
+ } title="抽帧供分镜使用" /> + +
+
+ + + job && data.onFrameCountChange(job.id, Number(e.target.value) || 12)} + disabled={!job} + className={`${controlClass} text-center`} + /> +
+
+ data.onAnalyze({ mode: job?.frames.length ? "append" : "replace" })}> + {data.analyzing ? : } + {job?.frames.length ? "追加抽帧" : "开始抽帧"} + + 全选分镜 + 清空选择 +
+
+ ) +} + +function StoryboardSegmentCard({ + job, + frame, + order, + selected, + selectedVideoIds, + videos, + busy, + onToggleFrame, + onJobUpdate, + onGenerateElement, + onGenerateVideo, + onToggleVideo, + onDeleteVideo, +}: { + job: Job + frame: KeyFrame + order: number + selected: boolean + selectedVideoIds: Set + videos: GeneratedVideo[] + busy: boolean + onToggleFrame: () => void + onJobUpdate: (job: Job) => void + onGenerateElement: () => void + onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise | void + onToggleVideo: (videoId: string) => void + onDeleteVideo?: (videoId: string) => void +}) { + const [scene, setScene] = useState(() => ({ ...emptyScene(), ...(frame.storyboard ?? {}) })) + const [model, setModel] = useState("seedance") + const [saving, setSaving] = useState(false) + const [generatingVideo, setGeneratingVideo] = useState(false) + const elements = frame.elements ?? [] + const generatedImages = frame.generated_images ?? [] + const objectNames = frame.description?.objects?.slice(0, 4).map((item) => item.name).filter(Boolean) ?? [] + const elementPreviews = elements + .map((element) => ({ element, src: representativeCutoutUrl(job.id, frame.index, element) })) + .filter((item): item is { element: typeof elements[number]; src: string } => !!item.src) + + useEffect(() => { + setScene({ ...emptyScene(), ...(frame.storyboard ?? {}) }) + }, [frame.index, frame.storyboard]) + + const patch = (next: Partial) => setScene((prev) => ({ ...prev, ...next })) + + const save = async () => { + setSaving(true) + try { + const updated = await updateStoryboard(job.id, frame.index, scene) + onJobUpdate(updated) + toast.success(`分镜 ${order + 1} 已保存`) + } catch (e) { + toast.error("保存失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setSaving(false) + } + } + + const generateVideo = async () => { + setGeneratingVideo(true) + try { + await save() + await onGenerateVideo(frame.index, scene, model) + } finally { + setGeneratingVideo(false) + } + } + + return ( +
+
+
+ +
+
分镜 {String(order + 1).padStart(2, "0")}
+

{frameLabel(frame, order)}

+

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

+
+
+ +
+ +
+ } title="音频分镜文案"> +
+