diff --git a/.memory/worklog.json b/.memory/worklog.json index 8b396af..08d6b0a 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,31 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "237531f", - "message": "auto-save 2026-05-14 17:04 (~1)", - "ts": "2026-05-14T17:04:31+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 17:04 (~1)", - "ts": "2026-05-14T09:06:14Z", - "type": "session-heartbeat" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 17:04 (~1)", - "ts": "2026-05-14T09:08:43Z", - "type": "session-heartbeat" - }, - { - "files_changed": 1, - "hash": "96e0203", - "message": "auto-save 2026-05-14 17:09 (~1)", - "ts": "2026-05-14T17:10:04+08:00", - "type": "commit" - }, { "files_changed": 1, "hash": "3ab1683", @@ -3269,6 +3243,31 @@ "message": "auto-save 2026-05-17 11:23 (~3)", "hash": "201abc6", "files_changed": 3 + }, + { + "ts": "2026-05-17T11:27:46+08:00", + "type": "commit", + "message": "refactor: switch ad workflow to horizontal kanban", + "hash": "0203a09", + "files_changed": 6 + }, + { + "ts": "2026-05-17T03:28:23Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: switch ad workflow to horizontal kanban", + "files_changed": 1 + }, + { + "ts": "2026-05-17T03:38:23Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: switch ad workflow to horizontal kanban", + "files_changed": 1 + }, + { + "ts": "2026-05-17T03:48:23Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: switch ad workflow to horizontal kanban", + "files_changed": 1 } ] } diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx deleted file mode 100644 index a450895..0000000 --- a/web/components/ad-recreation-board.tsx +++ /dev/null @@ -1,852 +0,0 @@ -"use client" - -import { type ReactNode, 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 - -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 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, -}: { - 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 [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 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() - 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 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 remake board
-

信息流广告横向生产看板

-
-
- - - - - -
-
- -
-
- } - step="01" - title="素材输入" - subtitle="一个素材就是一次文件任务" - > -
- setUrl(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") submitUrl() }} - 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 && ( -
- - } - step="02" - title="音频解析 / 新分镜文案" - subtitle="按产品内容改写,分镜自上而下排列" - > -
-
- } title="音频文案" /> - -
-
- data.onTranscribeAudio?.(job?.id)}> - - 解析音频 - - data.onOpenAudioStrip?.(job?.id)}> - 打开音轨 - -
-
- {audioPreview(job)} -
-
- -
- {job && framesForStoryboard.length > 0 ? framesForStoryboard.map((frame, order) => ( - data.onToggleFrame(frame.index)} - onJobUpdate={data.onJobUpdate} - /> - )) : ( - - )} -
-
- - } - step="03" - title="视频关键元素 / 抽帧生成" - subtitle="关键帧横向展开,直接生成元素和片段" - > -
-
- } 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 ? "追加抽帧" : "开始抽帧"} - - 全选分镜 - 清空选择 -
-
- -
-
- {job?.frames.length ? job.frames.map((frame, index) => ( - data.onToggleFrame(frame.index)} - onGenerateElement={() => generateElementForFrame(frame)} - onGenerateVideo={onGenerateVideo} - /> - )) : ( -
- -
- )} -
-
-
- - } - step="04" - title="视频合成" - subtitle="音频和候选视频合成完整广告" - > -
- } title="合成输入" /> -
- - 0} detail={`已选 ${selectedVideoIds.size}`} /> - -
- -
- -
- {job && generatedVideos.length > 0 ? generatedVideos.map((video) => ( - toggleVideo(video.id)} - onDelete={() => data.onDeleteVideo?.(video.id)} - /> - )) : ( - - )} -
-
-
-
-
-
- ) -} - -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}
-
- ) -} - -function SectionTitle({ icon, title }: { icon: ReactNode; title: string }) { - return ( -

- {icon} - {title} -

- ) -} - -function StatusPill({ ready, running }: { ready: boolean; running?: boolean }) { - return ( - - {running ? : ready ? : } - {running ? "运行中" : ready ? "已就绪" : "待处理"} - - ) -} - -function ActionButton({ - children, - disabled, - onClick, - variant = "solid", -}: { - children: ReactNode - disabled?: boolean - onClick?: () => void - variant?: "solid" | "ghost" -}) { - return ( - - ) -} - -function EmptyState({ text }: { text: string }) { - return ( -
- {text} -
- ) -} - -function SceneRow({ - job, - frame, - order, - selected, - onToggle, - onJobUpdate, -}: { - job: Job - frame: KeyFrame - order: number - selected: boolean - onToggle: () => void - onJobUpdate: (job: Job) => void -}) { - const [scene, setScene] = useState(() => ({ ...emptyScene(), ...(frame.storyboard ?? {}) })) - const [saving, setSaving] = useState(false) - - 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) - } - } - - return ( -
-
- -
-
-
{frameLabel(frame, order)}
- -
-

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

-
-
-
-