Files
20260512-skg-tk/web/components/ad-recreation-board.tsx
2026-05-17 22:25:07 +08:00

2892 lines
127 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import {
AlertTriangle, Check, ChevronDown, 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 FrameObject,
type GeneratedVideo,
type ImageRef,
type Job,
type KeyElement,
type KeyFrame,
type ProductViewAnalysisItem,
type ProductRefStateItem,
type StoryboardScriptRewriteSegment,
type StoryboardScene,
type SubjectAsset,
type SubjectKind,
addElement,
analyzeJob,
analyzeProductViews,
apiAssetUrl,
cutoutElement,
effectiveFrameUrl,
generateProductAngleAsset,
generateSubjectAssets,
generatedImageUrl,
hasCutout,
representativeCutoutUrl,
resolveImageRefUrl,
rewriteStoryboardScript,
saveProductRefs,
sourceAudioUrl,
updateStoryboard,
uploadStoryboardAsset,
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
}
type AudioFeature = {
loudness: number
}
type AudioFeatureStatus = "idle" | "loading" | "ready" | "failed"
type AudioStoryboardRow = {
index: number
start: number
end: number
source: string
role: string
skgCopy: string
visualPlan: string
referencePlan: string
keyElements: string
productIntegration: string
}
type ProductRefItem = ProductRefStateItem
const PRODUCT_VIEW_SLOTS = [
{ value: "front", label: "正面/外侧", hint: "整体 U 形轮廓、开口宽度、外壳主外观" },
{ value: "left_45", label: "佩戴者左 45", hint: "戴在脖子上时佩戴者左肩一侧的弧度、按钮/结构差异" },
{ value: "right_45", label: "佩戴者右 45", hint: "戴在脖子上时佩戴者右肩一侧的弧度、非对称细节" },
{ value: "side_thickness", label: "侧面厚度", hint: "机身厚度、后颈包裹体积" },
{ value: "inner_contacts", label: "贴颈内侧/触点", hint: "按摩触点、贴颈面、内侧皮肤接触位置" },
{ value: "back_bottom", label: "背面/底部", hint: "底面、背部闭合结构、补缺" },
] as const
const MAX_PRODUCT_REFS_PER_VIDEO = 6
const PRODUCT_BACKGROUND_LABELS: Record<string, string> = {
white: "白底",
black: "黑底",
simple: "纯色/简单",
complex: "复杂背景",
unknown: "背景未知",
}
const PRODUCT_USE_TAG_LABELS: Record<string, string> = {
hero_packshot: "主外观",
wearing_scale: "佩戴比例",
inner_contact: "触点",
side_thickness: "厚度",
asymmetry: "非对称",
button_detail: "按键",
back_bottom: "背底",
material_texture: "材质",
}
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 clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
async function decodeAudioFeatures(url: string, targetFrames = 640): Promise<AudioFeature[]> {
const res = await fetch(url)
if (!res.ok) throw new Error(`audio ${res.status}`)
const arrayBuffer = await res.arrayBuffer()
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!AudioContextClass) throw new Error("AudioContext unavailable")
const ctx = new AudioContextClass()
try {
const buffer = await ctx.decodeAudioData(arrayBuffer.slice(0))
const data = buffer.getChannelData(0)
const bucket = Math.max(1, Math.floor(data.length / targetFrames))
let maxLoudness = 0.01
const raw: Array<{ loudness: number }> = []
for (let i = 0; i < targetFrames; i++) {
const start = i * bucket
const end = Math.min(data.length, start + bucket)
let peak = 0
let sumSq = 0
for (let j = start; j < end; j++) {
const sample = data[j] || 0
const abs = Math.abs(sample)
peak = Math.max(peak, abs)
sumSq += sample * sample
}
const size = Math.max(end - start, 1)
const rms = Math.sqrt(sumSq / size)
const loudness = Math.max(rms, peak * 0.18)
raw.push({ loudness })
maxLoudness = Math.max(maxLoudness, loudness)
}
const sorted = raw.map((item) => item.loudness).sort((a, b) => a - b)
const floor = sorted[Math.floor(sorted.length * 0.12)] ?? 0
const ceiling = sorted[Math.floor(sorted.length * 0.985)] ?? maxLoudness
const range = Math.max(ceiling - floor, maxLoudness * 0.08, 0.001)
return raw.map((item) => ({
loudness: clampNumber(Math.pow(clampNumber((item.loudness - floor) / range, 0, 1), 1.18), 0.015, 1),
}))
} finally {
void ctx.close().catch(() => {})
}
}
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 "粘贴 TK 链接或上传视频后,系统会先下载视频;下载完成后自动提取音频文案。"
const source = job.audio_script?.source_text?.trim() || job.audio_script?.source_zh?.trim()
if (source) return source
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
}
function guessSubjectKind(name: string): SubjectKind {
return /人|人物|模特|骨架|身体|脸|手|person|people|human|body|face|hand|character/i.test(name)
? "living"
: "object"
}
function closestFrameForTime(frames: KeyFrame[], time: number) {
if (!frames.length) return null
const first = frames[0] as KeyFrame
return frames.reduce((best, frame) =>
Math.abs(frame.timestamp - time) < Math.abs(best.timestamp - time) ? frame : best,
first)
}
function isSimilarActorElement(element: KeyElement) {
const name = `${element.name_zh || ""} ${element.name_en || ""}`.toLowerCase()
return name.includes("相似主角") || name.includes("similar ad actor") || name.includes("similar actor")
}
function subjectAssetUrl(job: Job, asset: SubjectAsset) {
return apiAssetUrl(asset.url) || resolveImageRefUrl(job.id, { kind: "asset", frame_idx: 0, element_id: asset.id })
}
function buildFallbackScene(job: Job, frame: KeyFrame, order: number): StoryboardScene {
const frames = [...job.frames].sort((a, b) => a.timestamp - b.timestamp)
const nextFrame = frames.find((item) => item.timestamp > frame.timestamp) ?? null
const duration = Math.max(3.5, Math.min(7.5, Math.max(job.duration || 0, frames.length * 5) / Math.max(frames.length, 1)))
const audio = job.audio_script?.rewritten_text?.trim()
|| job.transcript?.slice(0, 4).map((item) => item.en || item.zh).filter(Boolean).join(" ")
|| "按原音频说话节奏改写为 SKG 产品介绍。"
const objects = frame.description?.objects?.slice(0, 5).map((item) => item.name).filter(Boolean).join("、")
return {
duration: Number(duration.toFixed(1)),
first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${order + 1} 首帧` },
last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${order + 1} 尾帧` } : null,
subject: objects ? `关键元素候选:${objects}` : "保留原视频最重要的主体动作和构图关系。",
scene: `${frame.description?.scene || `参考第 ${order + 1} 个关键画面规划 SKG 信息流广告分镜。`}\n音频节奏依据${audio.slice(0, 220)}`,
product: "把原素材里的产品/痛点转成 SKG 颈部/肩颈按摩仪表达,默认使用 SKG 四张产品角度图做产品真源。",
action: frame.description?.style
? `沿用原画面的讲话节奏、动作节点和 ${frame.description.style},突出使用前紧绷、使用后放松。`
: "沿用原视频的讲话节奏和动作节点,突出使用前紧绷、使用后放松。",
reference_ids: [],
}
}
function classifyAudioRole(text: string, index: number, total: number) {
const lower = text.toLowerCase()
if (index === 0) return "开场钩子"
if (index >= total - 2 || /discount|code|shipping|link|limited|sold out|grab|recommend|tiktok/.test(lower)) return "转化收口"
if (/can't|dont|don't|if |when |tired|stress|pain|crave|bloated|puffy|ready/.test(lower)) return "痛点推进"
if (/help|can |reduce|improve|relax|lower|stabilize|clear|less/.test(lower)) return "利益证明"
if (/use|try|apple|product|bottle|one month/.test(lower)) return "方案过渡"
return "节奏承接"
}
function buildSkgCopy(role: string, index: number) {
const variants: Record<string, string[]> = {
"开场钩子": [
"如果你也经常低头刷手机、久坐办公,肩颈紧绷可能已经在悄悄影响状态。",
"每天盯屏几个小时,脖子和肩膀的疲惫会比你想得更早出现。",
],
"痛点推进": [
"脖子发紧、肩膀沉、抬头不舒服,不一定要等到很难受才处理。",
"通勤、办公、带娃、刷手机叠在一起,肩颈很容易一直处在紧绷状态。",
],
"利益证明": [
"SKG 颈部按摩仪贴合后颈和肩颈两侧,把热敷感和揉按感带到真正紧的位置。",
"戴上后不用占手,工作间隙、居家放松、睡前都能快速进入舒缓节奏。",
],
"方案过渡": [
"这一镜把原片的讲解节奏换成 SKG 使用步骤:拿起、佩戴、贴合、放松。",
"让产品自然进入画面,重点不是硬推,而是把肩颈紧绷到放松的变化拍清楚。",
],
"转化收口": [
"如果你也想把肩颈放松变成日常习惯,可以先从这台 SKG 开始。",
"最后用清晰产品特写和轻松状态收住,让用户知道现在就可以入手。",
],
"节奏承接": [
"延续原片短句快节奏,把每一句都落到一个具体肩颈场景或产品动作。",
"这一句作为过渡,画面从痛点切到产品,让节奏继续往下走。",
],
}
const list = variants[role] ?? variants["节奏承接"]
return list[index % list.length]
}
function buildVisualPlan(role: string) {
if (role === "开场钩子") return "竖屏近景口播开场,人物轻揉脖子或转动肩颈,直接建立疲惫感。"
if (role === "痛点推进") return "沿用原视频的表情、手势和节奏,画面强调低头、久坐、肩颈紧绷。"
if (role === "利益证明") return "产品进入画面并佩戴到后颈,切到肩颈贴合、按键、热敷/揉按感的细节。"
if (role === "转化收口") return "产品清晰特写 + 人物放松表情收尾,保留信息流广告的快速行动感。"
return "保持原片同类构图和运镜,把画面内容替换成 SKG 肩颈放松场景。"
}
function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] {
if (!job?.transcript.length) return []
return job.transcript.map((segment, index) => {
const source = segment.zh?.trim() || segment.en?.trim() || "原音频文案待补充"
const role = classifyAudioRole(`${segment.en} ${segment.zh}`, index, job.transcript.length)
return {
index: segment.index,
start: segment.start,
end: segment.end,
source,
role,
skgCopy: buildSkgCopy(role, index),
visualPlan: buildVisualPlan(role),
referencePlan: `从原视频 ${segment.start.toFixed(1)}-${segment.end.toFixed(1)}s 定向抽 1-2 张参考帧。`,
keyElements: role === "利益证明" ? "佩戴动作、产品位置、手部按键、放松表情" : "口播构图、人物动作、表情节奏、场景光线",
productIntegration: "把原片产品/道具语境替换为 SKG 白色 U 形颈部按摩仪,产品必须外置佩戴在肩颈位置。",
}
})
}
function productRefKey(ref: ImageRef, index: number) {
return `${ref.kind}:${ref.frame_idx}:${ref.element_id ?? ""}:${ref.cutout_id ?? ""}:${index}`
}
function sameImageRef(a: ImageRef, b: ImageRef) {
return (
a.kind === b.kind &&
a.frame_idx === b.frame_idx &&
(a.element_id ?? "") === (b.element_id ?? "") &&
(a.cutout_id ?? "") === (b.cutout_id ?? "")
)
}
function productViewLabel(view: string) {
return PRODUCT_VIEW_SLOTS.find((slot) => slot.value === view)?.label ?? view
}
function productBackgroundLabel(background: string) {
return PRODUCT_BACKGROUND_LABELS[background] ?? PRODUCT_BACKGROUND_LABELS.unknown
}
function formatProductAssetSize(meta?: ImageRef["asset_meta"]) {
if (!meta?.width || !meta?.height) return "AI工作图"
return `${meta.width}x${meta.height}`
}
function defaultProductUseTags(view: string) {
const defaults: Record<string, string[]> = {
front: ["hero_packshot", "asymmetry"],
left_45: ["hero_packshot", "asymmetry", "button_detail"],
right_45: ["hero_packshot", "asymmetry", "button_detail"],
side_thickness: ["side_thickness", "wearing_scale"],
inner_contacts: ["inner_contact", "wearing_scale"],
back_bottom: ["back_bottom", "material_texture"],
}
return defaults[view] ?? ["hero_packshot"]
}
function normalizeProductUseTags(tags: string[] | undefined, view: string) {
const result: string[] = []
for (const tag of [...(tags ?? []), ...defaultProductUseTags(view)]) {
if (PRODUCT_USE_TAG_LABELS[tag] && !result.includes(tag)) result.push(tag)
}
return result.slice(0, 4)
}
function defaultProductLandmarks(view: string) {
const defaults: Record<string, string[]> = {
front: ["U形开口", "外壳主轮廓", "左右臂"],
left_45: ["佩戴者左侧臂", "侧边弧度", "按键/结构差异"],
right_45: ["佩戴者右侧臂", "侧边弧度", "按键/结构差异"],
side_thickness: ["机身厚度", "侧边轮廓", "佩戴比例"],
inner_contacts: ["贴颈内侧", "按摩触点", "皮肤接触面"],
back_bottom: ["背面/底部", "接口/底面", "材质细节"],
}
return defaults[view] ?? ["U形挂脖轮廓"]
}
function normalizeProductLandmarks(landmarks: string[] | undefined, view: string) {
const result: string[] = []
for (const item of [...(landmarks ?? []), ...defaultProductLandmarks(view)]) {
const text = item.trim()
if (text && !result.includes(text)) result.push(text)
}
return result.slice(0, 8)
}
function formatProductOrientation(orientation?: ProductViewAnalysisItem["orientation"]) {
if (!orientation) return ""
const parts = [
orientation.product_left ? `左=${orientation.product_left}` : "",
orientation.product_right ? `右=${orientation.product_right}` : "",
orientation.top ? `上=${orientation.top}` : "",
orientation.bottom ? `下=${orientation.bottom}` : "",
orientation.inner_side ? `内=${orientation.inner_side}` : "",
orientation.opening_direction ? `开口=${orientation.opening_direction}` : "",
].filter(Boolean)
return parts.join("")
}
function createProductRefItem(
ref: ImageRef,
index: number,
source: ProductRefItem["source"] = "upload",
view?: string,
note?: string,
background = "unknown",
useTags?: string[],
orientation?: ProductViewAnalysisItem["orientation"],
landmarks?: string[],
risk = "",
confidence?: number,
): ProductRefItem {
const slot = PRODUCT_VIEW_SLOTS[index] ?? PRODUCT_VIEW_SLOTS[PRODUCT_VIEW_SLOTS.length - 1]
const targetSlot = PRODUCT_VIEW_SLOTS.find((item) => item.value === view) ?? slot
return {
id: productRefKey(ref, index),
ref,
view: view ?? targetSlot.value,
background,
useTags: normalizeProductUseTags(useTags, view ?? targetSlot.value),
orientation,
landmarks: normalizeProductLandmarks(landmarks, view ?? targetSlot.value),
note: note ?? targetSlot.hint,
risk,
source,
assetMeta: ref.asset_meta,
confidence,
}
}
function normalizeStoredProductItem(item: ProductRefItem, index: number): ProductRefItem {
const ref = { ...item.ref, asset_meta: item.ref.asset_meta ?? item.assetMeta }
const restored = createProductRefItem(
ref,
index,
item.source ?? "upload",
item.view,
item.note,
item.background ?? "unknown",
item.useTags,
item.orientation,
item.landmarks,
item.risk ?? "",
item.confidence,
)
return {
...restored,
id: item.id || restored.id,
assetMeta: item.assetMeta ?? restored.assetMeta,
}
}
function productReferenceNotes(items: ProductRefItem[]) {
if (!items.length) return ""
return items
.map((item, index) => {
const tags = item.useTags.map((tag) => PRODUCT_USE_TAG_LABELS[tag]).filter(Boolean).join("/")
const orientation = formatProductOrientation(item.orientation)
const direction = orientation ? `;方向:${orientation}` : ""
const landmarks = item.landmarks.length ? `;结构:${item.landmarks.join("/")}` : ""
const risk = item.risk ? `;风险:${item.risk}` : ""
return `${index + 1}. ${productViewLabel(item.view)}${productBackgroundLabel(item.background)}${tags}${item.note || "无补充备注"}${direction}${landmarks}${risk}`
})
.join("")
}
function productPriorityForRow(row: AudioStoryboardRow) {
const viewPriorityByRole: Record<string, string[]> = {
"开场钩子": ["front", "left_45", "right_45", "side_thickness"],
"痛点推进": ["front", "side_thickness", "left_45", "right_45"],
"利益证明": ["inner_contacts", "side_thickness", "front", "left_45", "right_45", "back_bottom"],
"方案过渡": ["front", "left_45", "right_45", "inner_contacts", "side_thickness"],
"转化收口": ["front", "back_bottom", "left_45", "right_45", "inner_contacts"],
"节奏承接": ["front", "left_45", "right_45", "side_thickness"],
}
const tagPriorityByRole: Record<string, string[]> = {
"开场钩子": ["hero_packshot", "asymmetry", "side_thickness"],
"痛点推进": ["wearing_scale", "side_thickness", "hero_packshot"],
"利益证明": ["inner_contact", "wearing_scale", "button_detail", "side_thickness"],
"方案过渡": ["wearing_scale", "hero_packshot", "inner_contact"],
"转化收口": ["hero_packshot", "back_bottom", "asymmetry", "material_texture"],
"节奏承接": ["hero_packshot", "asymmetry", "side_thickness"],
}
return {
views: viewPriorityByRole[row.role] ?? viewPriorityByRole["节奏承接"],
tags: tagPriorityByRole[row.role] ?? tagPriorityByRole["节奏承接"],
}
}
function scoreProductItemForRow(row: AudioStoryboardRow, item: ProductRefItem, index: number) {
const priority = productPriorityForRow(row)
const viewRank = priority.views.indexOf(item.view)
const tagScore = item.useTags.reduce((sum, tag) => {
const rank = priority.tags.indexOf(tag)
return sum + (rank >= 0 ? 18 - rank * 3 : 0)
}, 0)
const backgroundScore = item.background === "complex" ? -8 : item.background === "unknown" ? -3 : 0
const riskScore = item.risk ? -10 : 0
const confidenceScore = Math.round((item.confidence ?? 0.5) * 10)
const rotationScore = -Math.abs((row.index % Math.max(1, index + 1)) - (index % 3))
return (viewRank >= 0 ? 30 - viewRank * 4 : 0) + tagScore + backgroundScore + riskScore + confidenceScore + rotationScore
}
function selectProductItemsForRow(row: AudioStoryboardRow, items: ProductRefItem[]) {
if (!items.length) return []
const picked: ProductRefItem[] = []
const pickedIds = new Set<string>()
const add = (item?: ProductRefItem) => {
if (!item || pickedIds.has(item.id) || picked.length >= MAX_PRODUCT_REFS_PER_VIDEO) return
picked.push(item)
pickedIds.add(item.id)
}
const priority = productPriorityForRow(row)
for (const view of priority.views) {
const matches = items
.map((item, index) => ({ item, score: scoreProductItemForRow(row, item, index) }))
.filter(({ item }) => item.view === view)
.sort((a, b) => b.score - a.score)
add(matches[0]?.item)
}
for (const tag of priority.tags) {
const matches = items
.map((item, index) => ({ item, score: scoreProductItemForRow(row, item, index) }))
.filter(({ item }) => item.useTags.includes(tag))
.sort((a, b) => b.score - a.score)
add(matches[0]?.item)
}
const ranked = items
.map((item, index) => ({ item, score: scoreProductItemForRow(row, item, index) }))
.sort((a, b) => b.score - a.score)
for (const { item } of ranked) {
add(item)
}
return picked
}
function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFrame, nextFrame?: KeyFrame | null, productItems: ProductRefItem[] = []): StoryboardScene {
const selectedProductItems = selectProductItemsForRow(row, productItems)
const productRefs = selectedProductItems.map((item) => item.ref)
const notes = productReferenceNotes(selectedProductItems)
const productGuidance = productItems.length
? `产品素材池共有 ${productItems.length} 张,本条只选用 ${selectedProductItems.length} 张最相关参考图,不要把未选素材混入本条画面。产品硬定义:这是套在脖子上的 U 形肩颈按摩仪,不是耳机、头戴设备或护颈枕。坐标系硬规则:左/右按佩戴者身体左右,不能按图片左右;上=靠近下巴/脸/颈部上沿,下=靠近锁骨/肩部下沿;内侧=贴颈皮肤/按摩触点,外侧=外壳/按键/Logo。所选图片只作为产品结构、角度、比例和细节参考不要照搬参考图的白底/黑底/棚拍背景。视角标注:${notes}。保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。`
: "未上传产品图时使用默认 SKG 产品图;生成前建议先建立同一产品素材池,锁定左右差异、厚度和佩戴比例。"
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,
product_images: productRefs,
product_image: productRefs[0] ?? null,
subject: row.keyElements,
scene: `${row.visualPlan}\n原音频依据${row.source}`,
product: `${row.productIntegration}\n${productGuidance}`,
action: row.skgCopy,
reference_ids: [],
}
}
export function AdRecreationBoard({
data,
onGenerateVideo,
}: {
data: NodeData
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
}) {
const { job, jobs, activeJobId } = data
const [url, setUrl] = useState("")
const [selectedVideoIds, setSelectedVideoIds] = useState<Set<string>>(new Set())
const [draftSegments, setDraftSegments] = useState<DraftSegment[]>([])
const [elementBusyFrame, setElementBusyFrame] = useState<number | null>(null)
const [sixViewBusyKey, setSixViewBusyKey] = useState<string | null>(null)
const [generatingAll, setGeneratingAll] = useState(false)
const fileRef = useRef<HTMLInputElement | null>(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?.source_text?.trim() || !!job?.transcript?.length
const readySegments = countReadySegments(job, draftSegments)
const transcriptCount = job?.transcript.length ?? 0
const backgroundReady = !!job?.audio_script?.background_audio_profile?.trim()
useEffect(() => {
setDraftSegments([])
setSelectedVideoIds(new Set())
}, [activeJobId])
const submitUrl = () => {
const trimmed = url.trim()
if (!trimmed) return
data.onSubmitUrl(trimmed)
setUrl("")
}
const startProduction = () => {
const trimmed = url.trim()
data.onStartProduction?.(trimmed || undefined)
if (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<DraftSegment>) => {
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, candidate?: FrameObject, withSixViews = true) => {
if (!job) return
setElementBusyFrame(frame.index)
const candidateName = candidate?.name?.trim()
try {
let workingJob = job
let workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? frame
const existing = workingFrame.elements?.find((item) =>
candidateName
? [item.name_zh, item.name_en].some((name) => name?.trim() === candidateName)
: true,
)
const sourceObject = candidate ?? workingFrame.description?.objects?.[0]
const name = candidateName || sourceObject?.name?.trim() || existing?.name_zh || existing?.name_en || "主体"
let element = existing
if (!element) {
workingJob = await addElement(job.id, frame.index, {
name_zh: name,
name_en: name,
position: sourceObject?.position,
source: "manual",
})
data.onJobUpdate(workingJob)
workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? workingFrame
element = workingFrame.elements?.[workingFrame.elements.length - 1]
}
if (!element) {
toast.success(`已登记元素:${name}`)
return
}
if (!hasCutout(element)) {
workingJob = await cutoutElement(job.id, frame.index, element.id)
data.onJobUpdate(workingJob)
workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? workingFrame
element = workingFrame.elements?.find((item) => item.id === element?.id) ?? element
}
if (withSixViews && !element.subject_assets?.length) {
setSixViewBusyKey(`${frame.index}:${element.id}`)
workingJob = await generateSubjectAssets(job.id, frame.index, element.id, {
subject_kind: guessSubjectKind(name),
background: "white",
size: "1024",
source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index),
})
data.onJobUpdate(workingJob)
}
toast.success(`已准备关键元素:${name}`)
} catch (e) {
toast.error("元素生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setElementBusyFrame(null)
setSixViewBusyKey(null)
}
}
const generateSixViewsForElement = async (frame: KeyFrame, element: KeyElement) => {
if (!job) return
setSixViewBusyKey(`${frame.index}:${element.id}`)
try {
const updated = await generateSubjectAssets(job.id, frame.index, element.id, {
subject_kind: guessSubjectKind(element.name_zh || element.name_en || "主体"),
background: "white",
size: "1024",
source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index),
})
data.onJobUpdate(updated)
toast.success(`6 视图已生成:${element.name_zh || element.name_en}`)
} catch (e) {
toast.error("6 视图生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSixViewBusyKey(null)
}
}
const generateAllVideos = async () => {
if (!job || framesForSegments.length === 0) return
setGeneratingAll(true)
try {
for (let order = 0; order < framesForSegments.length; order += 1) {
const frame = framesForSegments[order]
const scene = frame.storyboard ?? buildFallbackScene(job, frame, order)
if (!frame.storyboard) {
const updated = await updateStoryboard(job.id, frame.index, scene)
data.onJobUpdate(updated)
}
await onGenerateVideo(frame.index, scene, "seedance")
}
toast.success(`已提交 ${framesForSegments.length} 条分镜视频`)
} catch (e) {
toast.error("批量生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setGeneratingAll(false)
}
}
return (
<section className="relative z-20 h-screen w-screen overflow-hidden bg-black text-white">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_9%_0%,rgba(225,29,72,0.18),transparent_28%),radial-gradient(circle_at_60%_0%,rgba(14,165,233,0.08),transparent_26%)]" />
<div className="relative flex h-full flex-col px-4 py-4">
<header className="mb-3 flex items-center justify-between gap-4 rounded-lg border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="min-w-0">
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/40">feed ad recreation worksheet</div>
<h1 className="mt-1 text-[22px] font-semibold leading-tight text-white">广</h1>
</div>
<div className="grid min-w-[520px] grid-cols-5 gap-2 text-[11px] text-white/48">
<Metric label="素材" value={`${jobs.length}`} />
<Metric label="当前" value={shortId(activeJobId)} />
<Metric label="视频" value={job?.video_url ? "ready" : "-"} />
<Metric label="文案段" value={`${transcriptCount}`} />
<Metric label="背景音" value={backgroundReady ? "ready" : "-"} />
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)] gap-3">
<MaterialColumn
data={data}
jobs={jobs}
job={job}
activeJobId={activeJobId}
url={url}
setUrl={setUrl}
fileRef={fileRef}
onSubmitUrl={submitUrl}
onStartProduction={startProduction}
/>
<section className="flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.035] shadow-2xl">
<header className="shrink-0 border-b border-white/10 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-rose-500/12 text-rose-100"><Mic className="h-4 w-4" /></span>
<span className="font-mono text-[12px] text-white/36">02</span>
</div>
<h2 className="mt-2 text-[17px] font-semibold leading-tight text-white"></h2>
<p className="mt-1 text-[12px] text-white/42"></p>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-2">
<ActionButton disabled={!job?.video_url || job.status === "transcribing"} onClick={() => data.onTranscribeAudio?.(job?.id)}>
<Mic className="h-3.5 w-3.5" />
</ActionButton>
</div>
</div>
<div className="mt-3 grid grid-cols-1 items-start gap-3 xl:grid-cols-[minmax(0,1fr)_470px]">
<details className="group rounded-lg border border-white/10 bg-black/32 p-3">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3">
<SectionTitle icon={<FileText className="h-4 w-4" />} title="音频文案依据" />
<div className="flex items-center gap-2">
<span className="font-mono text-[11px] text-white/38">{transcriptCount ? `${transcriptCount}` : "待解析"}</span>
<StatusPill ready={audioReady} running={job?.status === "transcribing" || job?.audio_script?.status === "rewriting"} />
<ChevronDown className="h-4 w-4 text-white/38 transition group-open:rotate-180" />
</div>
</summary>
<div className="mt-3 max-h-24 overflow-y-auto rounded-md border border-white/10 bg-black/35 p-2 text-[12px] leading-relaxed text-white/62">
{audioPreview(job)}
</div>
</details>
<AudioIntakeStatus job={job} audioReady={audioReady} />
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<AudioIntakePanel
job={job}
selectedFrames={data.selectedFrames}
onToggleFrame={data.onToggleFrame}
onJobUpdate={data.onJobUpdate}
onAddFrame={data.onAddManualFrameForJob}
onDeleteFrame={data.onDeleteFrameForJob}
/>
<AudioStoryboardPlanPanel
job={job}
selectedFrames={data.selectedFrames}
onJobUpdate={data.onJobUpdate}
onGenerateVideo={onGenerateVideo}
/>
</div>
</section>
</div>
</div>
</section>
)
}
function MaterialColumn({
data,
jobs,
job,
activeJobId,
url,
setUrl,
fileRef,
onSubmitUrl,
onStartProduction,
}: {
data: NodeData
jobs: Job[]
job: Job | null
activeJobId: string | null
url: string
setUrl: (value: string) => void
fileRef: RefObject<HTMLInputElement | null>
onSubmitUrl: () => void
onStartProduction: () => void
}) {
return (
<section className="flex min-h-0 flex-col gap-3 rounded-lg border border-white/10 bg-white/[0.035] p-3 shadow-2xl">
<header className="shrink-0 border-b border-white/10 pb-3">
<div className="mb-2 flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-rose-500/12 text-rose-100"><Plus className="h-4 w-4" /></span>
<span className="font-mono text-[12px] text-white/36">01</span>
</div>
<h2 className="text-[15px] font-semibold leading-tight text-white"></h2>
<p className="mt-1 text-[12px] leading-snug text-white/42"></p>
</header>
<div className="flex gap-2">
<input
value={url}
onChange={(e) => 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"
/>
<button
type="button"
onClick={onStartProduction}
disabled={data.submitting || (!url.trim() && !job)}
className="inline-flex h-10 items-center justify-center rounded-md bg-rose-600 px-3 text-[13px] font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-white/10 bg-white/[0.06] text-white/75 transition hover:border-white/25 hover:bg-white/[0.1]"
aria-label="上传视频"
title="上传视频"
>
<Upload className="h-4 w-4" />
</button>
<input
ref={fileRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) data.onUploadFile(file)
e.currentTarget.value = ""
}}
/>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
{jobs.length ? jobs.map((item, index) => (
<MaterialCard
key={item.id}
job={item}
index={index}
active={item.id === activeJobId}
onClick={() => data.onSwitchJob(item.id)}
onDelete={data.onDeleteJob ? () => data.onDeleteJob?.(item.id) : undefined}
/>
)) : (
<EmptyState text="还没有素材。每导入一个链接或上传一个文件,就会新增一个素材任务。" />
)}
</div>
{job?.video_url && (
<video
src={videoUrl(job.id)}
controls
playsInline
className="aspect-video w-full rounded-lg border border-white/10 bg-black object-contain"
/>
)}
</section>
)
}
function AudioIntakeStatus({ job, audioReady }: { job: Job | null; audioReady: boolean }) {
const downloading = !!job && ["created", "downloading"].includes(job.status)
const audioRunning = !!job && (job.status === "transcribing" || job.audio_script?.status === "rewriting")
return (
<div className="rounded-lg border border-white/10 bg-black/32 p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<SectionTitle icon={<PanelRight className="h-4 w-4" />} title="当前步骤" />
<StatusPill ready={audioReady} running={downloading || audioRunning} />
</div>
<div className="grid grid-cols-4 gap-2 text-[11px] text-white/52">
<Requirement label="素材" ready={!!job} detail={job ? shortId(job.id) : "待输入"} />
<Requirement label="视频" ready={!!job?.video_url} detail={downloading ? "下载中" : job?.video_url ? "已就绪" : "待下载"} />
<Requirement label="音频" ready={!!job?.source_audio_url} detail={audioRunning ? "解析中" : job?.source_audio_url ? "已提取" : "待提取"} />
<Requirement label="文案" ready={audioReady} detail={audioReady ? `${job?.transcript.length ?? 0}` : "待解析"} />
</div>
<div className="mt-2 truncate rounded-md border border-white/10 bg-black/28 px-3 py-2 text-[11px] text-white/42" title={job?.message}>
{job?.message || "粘贴 TK 链接或上传视频后,点击开始进入下载和音频解析。"}
</div>
</div>
)
}
function AudioIntakePanel({
job,
selectedFrames,
onToggleFrame,
onJobUpdate,
onAddFrame,
onDeleteFrame,
}: {
job: Job | null
selectedFrames: Set<number>
onToggleFrame: (idx: number) => void
onJobUpdate: (job: Job) => void
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
}) {
const [currentTime, setCurrentTime] = useState(0)
const [mediaDuration, setMediaDuration] = useState(0)
const [audioFeatures, setAudioFeatures] = useState<AudioFeature[]>([])
const [audioFeatureStatus, setAudioFeatureStatus] = useState<AudioFeatureStatus>("idle")
const videoRef = useRef<HTMLVideoElement | null>(null)
const rowRefs = useRef<Record<number, HTMLDivElement | null>>({})
const syncFrameRef = useRef<number | null>(null)
const script = job?.audio_script
const audioSrcUrl = job ? apiAssetUrl(job.source_audio_url) || sourceAudioUrl(job.id) : ""
const profiles = [
{ label: "讲话人", value: script?.speaker_profile },
{ label: "节奏", value: script?.rhythm_profile },
{ label: "背景音", value: script?.background_audio_profile },
]
const processing = !!job && (job.status === "transcribing" || script?.status === "rewriting")
const timelineDuration = useMemo(() => {
if (!job) return 1
const lastTranscriptEnd = job.transcript.reduce((max, segment) => Math.max(max, segment.end || 0), 0)
return Math.max(
mediaDuration,
job.duration ?? 0,
lastTranscriptEnd,
1,
)
}, [job, mediaDuration])
const activeSegment = job?.transcript.find((segment) => currentTime >= segment.start && currentTime <= Math.max(segment.end, segment.start + 0.2))
useEffect(() => {
if (!job?.id || !audioSrcUrl) {
setAudioFeatures([])
setAudioFeatureStatus("idle")
return
}
setCurrentTime(0)
setMediaDuration(0)
setAudioFeatures([])
setAudioFeatureStatus("loading")
let cancelled = false
decodeAudioFeatures(audioSrcUrl)
.then((next) => {
if (!cancelled) {
setAudioFeatures(next)
setAudioFeatureStatus("ready")
}
})
.catch(() => {
if (!cancelled) {
setAudioFeatures([])
setAudioFeatureStatus("failed")
}
})
return () => { cancelled = true }
}, [audioSrcUrl, job?.id])
useEffect(() => {
if (activeSegment) rowRefs.current[activeSegment.index]?.scrollIntoView({ block: "nearest" })
}, [activeSegment?.index])
useEffect(() => {
return () => {
if (syncFrameRef.current !== null) cancelAnimationFrame(syncFrameRef.current)
}
}, [])
const stopFrameSync = () => {
if (syncFrameRef.current !== null) {
cancelAnimationFrame(syncFrameRef.current)
syncFrameRef.current = null
}
if (videoRef.current) setCurrentTime(videoRef.current.currentTime)
}
const startFrameSync = () => {
if (syncFrameRef.current !== null) cancelAnimationFrame(syncFrameRef.current)
const tick = () => {
const video = videoRef.current
if (!video || video.paused || video.ended) {
stopFrameSync()
return
}
setCurrentTime(video.currentTime)
syncFrameRef.current = requestAnimationFrame(tick)
}
syncFrameRef.current = requestAnimationFrame(tick)
}
const seekTo = (time: number) => {
const next = clampNumber(time, 0, timelineDuration)
if (videoRef.current) videoRef.current.currentTime = next
setCurrentTime(next)
}
if (!job) {
return <EmptyState text="先在左侧粘贴 TK 链接或上传本地视频。点击开始后,会先下载视频,再自动解析原音频文案、讲话人节奏和背景音。" />
}
const videoSrcUrl = apiAssetUrl(job.video_url) || videoUrl(job.id)
return (
<section className="rounded-lg border border-white/10 bg-black/28 p-2.5">
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<Film className="h-4 w-4" />} title="音频解析结果" />
<div className="flex items-center gap-2 font-mono text-[11px] text-white/38">
<span>{job.transcript.length} </span>
<span>{formatSeconds(job.duration)}</span>
</div>
</div>
<div className="mb-2 grid grid-cols-3 gap-2">
{profiles.map((item) => (
<ProfileTile key={item.label} label={item.label} value={item.value} running={processing} />
))}
</div>
<div className="grid gap-2 border-t border-white/8 pt-2">
<div className="rounded-md border border-white/10 bg-black/32 p-2">
<div className="mb-1 flex items-center justify-between text-[10px] text-white/40">
<span> / </span>
<span className="font-mono">{currentTime.toFixed(1)}s</span>
</div>
<AudioWaveform
features={audioFeatures}
status={audioFeatureStatus}
currentTime={currentTime}
duration={timelineDuration}
segments={job.transcript}
onSeek={seekTo}
/>
</div>
<div className="grid gap-2 xl:grid-cols-[340px_minmax(340px,1fr)_320px] 2xl:grid-cols-[410px_minmax(500px,1fr)_320px]">
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<Play className="h-4 w-4" />} title="原版视频" />
<span className="font-mono text-[11px] text-white/38">{currentTime.toFixed(1)}s / {formatSeconds(timelineDuration)}</span>
</div>
<div className="overflow-hidden rounded-md border border-white/10 bg-black/45">
{job.video_url ? (
<video
ref={videoRef}
controls
playsInline
className="h-[270px] w-full bg-black object-contain 2xl:h-[300px]"
src={videoSrcUrl}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onSeeked={(event) => setCurrentTime(event.currentTarget.currentTime)}
onPlay={startFrameSync}
onPlaying={startFrameSync}
onPause={stopFrameSync}
onEnded={stopFrameSync}
onLoadedMetadata={(event) => {
setMediaDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)
setCurrentTime(event.currentTarget.currentTime)
}}
/>
) : (
<div className="flex h-[270px] items-center justify-center text-[12px] text-white/38 2xl:h-[300px]"></div>
)}
</div>
</div>
<SourceReferenceBuildPanel
job={job}
selectedFrames={selectedFrames}
onToggleFrame={onToggleFrame}
onJobUpdate={onJobUpdate}
currentTime={currentTime}
duration={timelineDuration}
onAddFrame={onAddFrame}
onDeleteFrame={onDeleteFrame}
/>
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<FileText 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">{job.transcript.length} </span>
</div>
{job.transcript.length ? (
<div className="overflow-hidden rounded-md border border-white/10">
<div className="grid grid-cols-[82px_minmax(0,1fr)_minmax(0,1fr)] border-b border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold text-white/50">
<div></div>
<div></div>
<div></div>
</div>
<div className="max-h-[270px] overflow-y-auto 2xl:max-h-[300px]">
{job.transcript.map((segment) => {
const active = activeSegment?.index === segment.index
return (
<div
key={segment.index}
ref={(node) => { rowRefs.current[segment.index] = node }}
onClick={() => seekTo(segment.start)}
className={`grid cursor-pointer grid-cols-[82px_minmax(0,1fr)_minmax(0,1fr)] gap-3 border-b px-3 py-1.5 text-[11.5px] leading-snug transition last:border-b-0 ${
active
? "border-emerald-300/18 bg-emerald-300/[0.12] text-white"
: "border-white/8 text-white/64 hover:bg-white/[0.045]"
}`}
>
<div className={`font-mono text-[11px] ${active ? "text-emerald-100" : "text-white/38"}`}>{segment.start.toFixed(1)}-{segment.end.toFixed(1)}s</div>
<div className="truncate" title={segment.en}>{segment.en || <span className="text-white/30">-</span>}</div>
<div className="truncate" title={segment.zh}>{segment.zh || <span className="text-white/30"></span>}</div>
</div>
)
})}
</div>
</div>
) : (
<EmptyState text={processing ? "音频解析中,完成后这里会按时间列出原文案和中文翻译。" : "下载完成后会自动解析音频;也可以点击右上角“解析音频”手动重试。"} />
)}
</div>
</div>
</div>
</section>
)
}
function SourceReferenceBuildPanel({
job,
selectedFrames,
onToggleFrame,
onJobUpdate,
currentTime,
duration,
onAddFrame,
onDeleteFrame,
}: {
job: Job
selectedFrames: Set<number>
onToggleFrame: (idx: number) => void
onJobUpdate: (job: Job) => void
currentTime: number
duration: number
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
}) {
const [extracting, setExtracting] = useState(false)
const [manualBusy, setManualBusy] = useState(false)
const [subjectBusy, setSubjectBusy] = useState(false)
const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
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 manualFrameTime = clampNumber(currentTime, 0, Math.max(duration, 1))
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)
}
}
const addFrameAtCurrentTime = async () => {
if (!onAddFrame) return
setManualBusy(true)
try {
await onAddFrame(job.id, manualFrameTime)
} finally {
setManualBusy(false)
}
}
const deleteReferenceFrame = async (idx: number) => {
if (!onDeleteFrame) return
setDeletingFrame(idx)
try {
await onDeleteFrame(job.id, idx)
} finally {
setDeletingFrame(null)
}
}
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-[270px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2 2xl:h-[300px]">
<div className="grid grid-cols-3 gap-1.5">
<button
type="button"
onClick={() => void extractKeyframes()}
disabled={!job.video_url || extracting || job.status === "splitting"}
title="按之前的动作峰值逻辑抽帧,更偏向手势、表情变化、节奏点和镜头变化"
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 addFrameAtCurrentTime()}
disabled={!job.video_url || !onAddFrame || manualBusy || job.status === "splitting"}
title={`按当前播放位置手动抽帧:${manualFrameTime.toFixed(1)}s`}
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-emerald-300/35 hover:text-emerald-100 disabled:cursor-not-allowed disabled:opacity-35"
>
{manualBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
</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.map((frame, index) => {
const selected = selectedFrames.has(frame.index)
return (
<div
key={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`}
>
<button
type="button"
onClick={() => onToggleFrame(frame.index)}
className="absolute inset-0 cursor-pointer overflow-hidden focus:outline-none focus:ring-1 focus:ring-cyan-200/70"
>
<img src={effectiveFrameUrl(job.id, frame)} alt="" className="h-full w-full object-cover" />
</button>
<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>
{onDeleteFrame && (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
void deleteReferenceFrame(frame.index)
}}
disabled={deletingFrame === frame.index}
className="absolute bottom-1 right-1 inline-flex h-6 w-6 items-center justify-center rounded-full border border-rose-200/25 bg-black/78 text-rose-100 opacity-80 transition hover:border-rose-200/55 hover:bg-rose-500/25 hover:opacity-100 focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-rose-100/70 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={`删除关键帧 ${index + 1}`}
title="删除这张关键帧"
>
{deletingFrame === frame.index ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
</button>
)}
</div>
)
})}
{!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,
selectedFrames,
onJobUpdate,
onGenerateVideo,
}: {
job: Job | null
selectedFrames: Set<number>
onJobUpdate?: (job: Job) => void
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
}) {
const [videoBusyRow, setVideoBusyRow] = useState<number | null>(null)
const [productItems, setProductItems] = useState<ProductRefItem[]>([])
const [productUploading, setProductUploading] = useState(false)
const [productAnalyzing, setProductAnalyzing] = useState(false)
const [productAngleBusy, setProductAngleBusy] = useState<string | null>(null)
const [copyOverrides, setCopyOverrides] = useState<Record<number, string>>({})
const [authorIntent, setAuthorIntent] = useState("")
const [scriptRewriteBusy, setScriptRewriteBusy] = useState<"all" | number | null>(null)
const productFileRef = useRef<HTMLInputElement | null>(null)
const productPersistSeq = useRef(0)
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((job?.product_refs ?? []).map(normalizeStoredProductItem))
setCopyOverrides({})
setAuthorIntent("")
setScriptRewriteBusy(null)
}, [job?.id])
const persistProductItems = async (items: ProductRefItem[]) => {
if (!job) return
const seq = ++productPersistSeq.current
try {
const updated = await saveProductRefs(job.id, items)
if (seq === productPersistSeq.current) onJobUpdate?.(updated)
} catch (e) {
console.warn("产品素材池保存失败", e)
}
}
const setAndPersistProductItems = (items: ProductRefItem[]) => {
setProductItems(items)
void persistProductItems(items)
}
const copyForRow = (row: AudioStoryboardRow) => copyOverrides[row.index] ?? row.skgCopy
const patchRowCopy = (rowIndex: number, value: string) => {
setCopyOverrides((prev) => ({ ...prev, [rowIndex]: value }))
}
const rewriteSegmentForRow = (row: AudioStoryboardRow): StoryboardScriptRewriteSegment => ({
index: row.index,
start: row.start,
end: row.end,
role: row.role,
source: row.source,
current_text: copyForRow(row),
})
const referenceFrameForRow = (row: AudioStoryboardRow) =>
closestFrameForTime(rowReferencePool, clampNumber((row.start + row.end) / 2, 0, Math.max(job?.duration || row.end, row.end)))
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"
const buildAnalyzedProductItems = (refs: ImageRef[], analysisItems: ProductViewAnalysisItem[] = [], startIndex = 0) => refs.map((ref, index) => {
const analysis = analysisItems.find((item) => item.index === index)
const validView = analysis && PRODUCT_VIEW_SLOTS.some((slot) => slot.value === analysis.view) ? analysis.view : undefined
return createProductRefItem(
ref,
startIndex + index,
itemSourceForRef(ref),
validView,
analysis?.note,
analysis?.background ?? "unknown",
analysis?.use_tags,
analysis?.orientation,
analysis?.landmarks,
analysis?.risk ?? "",
analysis?.confidence,
)
})
const completeMissingProductAngles = async (seedItems: ProductRefItem[]) => {
if (!job || !seedItems.length) return seedItems
let working = seedItems
const failures: string[] = []
const missing = PRODUCT_VIEW_SLOTS
.filter((slot) => !working.some((item) => item.view === slot.value))
for (const slot of missing) {
setProductAngleBusy(slot.value)
try {
const ref = await generateProductAngleAsset(job.id, {
source_ref: working[0].ref,
target_view: slot.label,
note: slot.hint,
})
working = [
...working,
createProductRefItem(ref, working.length, "ai", slot.value, `AI 补齐:${slot.hint}`, "white", undefined, undefined, undefined, "", 1),
]
setAndPersistProductItems(working)
} catch (e) {
failures.push(`${slot.label}${e instanceof Error ? e.message : String(e)}`)
}
}
setProductAngleBusy(null)
if (failures.length) {
toast.warning(`部分产品视角自动补图失败:${failures.map((item) => item.split("")[0]).join("、")}`)
}
return working
}
const analyzeAndCompleteProductViews = async (refs: ImageRef[]) => {
if (!job || !refs.length) return
setProductAnalyzing(true)
const pending = refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref), undefined, "正在自动识别视角..."))
setAndPersistProductItems(pending)
try {
const analysis = await analyzeProductViews(job.id, refs)
const analyzed = buildAnalyzedProductItems(refs, analysis.items)
setAndPersistProductItems(analyzed)
const completed = await completeMissingProductAngles(analyzed)
toast.success(completed.length > analyzed.length ? "产品视角已自动识别并补齐缺失角度" : "产品视角已自动识别")
} catch (e) {
const fallback = refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref)))
setAndPersistProductItems(fallback)
await completeMissingProductAngles(fallback)
toast.warning("产品视角识别失败,已按默认顺序标注并尝试自动补图:" + (e instanceof Error ? e.message : String(e)))
} finally {
setProductAnalyzing(false)
setProductAngleBusy(null)
}
}
const analyzeUploadedProductRefs = async (refs: ImageRef[]) => {
if (!job || !refs.length) return
const baseItems = productItems
const startIndex = baseItems.length
setProductAnalyzing(true)
setAndPersistProductItems([
...baseItems,
...refs.map((ref, index) => createProductRefItem(ref, startIndex + index, "upload", undefined, "正在自动识别视角...")),
])
try {
const analysis = await analyzeProductViews(job.id, refs)
const newItems = buildAnalyzedProductItems(refs, analysis.items, startIndex)
const combined = [...baseItems, ...newItems]
setAndPersistProductItems(combined)
const completed = await completeMissingProductAngles(combined)
toast.success(completed.length > combined.length ? "新产品图已识别,并已补齐缺失角度" : "新产品图已自动识别")
} catch (e) {
const fallback = refs.map((ref, index) => createProductRefItem(ref, startIndex + index, "upload"))
const combined = [...baseItems, ...fallback]
setAndPersistProductItems(combined)
await completeMissingProductAngles(combined)
toast.warning("新产品图识别失败,已按默认顺序标注并尝试自动补图:" + (e instanceof Error ? e.message : String(e)))
} finally {
setProductAnalyzing(false)
setProductAngleBusy(null)
}
}
const uploadProductImages = async (files: FileList | null) => {
if (!job || !files?.length) return
const selected = Array.from(files)
setProductUploading(true)
try {
const refs = await Promise.all(selected.map((file) => uploadStoryboardAsset(job.id, file)))
await analyzeUploadedProductRefs(refs)
toast.success(`已上传 ${refs.length} 张产品图`)
} catch (e) {
toast.error("产品图上传失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setProductUploading(false)
}
}
const patchProductItem = (id: string, patch: Partial<ProductRefItem>) => {
setProductItems((prev) => {
const next = prev.map((item) => item.id === id ? { ...item, ...patch } : item)
void persistProductItems(next)
return next
})
}
const removeProductItem = (id: string) => {
setProductItems((prev) => {
const next = prev.filter((item) => item.id !== id)
void persistProductItems(next)
return next
})
}
const reanalyzeProductViews = async () => {
if (!productItems.length) return
await analyzeAndCompleteProductViews(productItems.map((item) => item.ref))
}
const applyScriptRewriteItems = (items: Array<{ index: number; text: string }>) => {
if (!items.length) return
setCopyOverrides((prev) => {
const next = { ...prev }
for (const item of items) {
if (item.text?.trim()) next[item.index] = item.text.trim()
}
return next
})
}
const rewriteSingleRow = async (row: AudioStoryboardRow) => {
if (!job) return
setScriptRewriteBusy(row.index)
try {
const result = await rewriteStoryboardScript(job.id, {
mode: "segment",
author_intent: authorIntent,
segments: [rewriteSegmentForRow(row)],
})
applyScriptRewriteItems(result.items)
toast.success(`分镜 ${row.index + 1} 已改写`)
} catch (e) {
toast.error("单段改写失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setScriptRewriteBusy(null)
}
}
const rewriteAllRows = async () => {
if (!job || !rows.length) return
setScriptRewriteBusy("all")
try {
const result = await rewriteStoryboardScript(job.id, {
mode: "all",
author_intent: authorIntent,
segments: rows.map(rewriteSegmentForRow),
})
applyScriptRewriteItems(result.items)
toast.success("整片新口播文案已改写")
} catch (e) {
toast.error("整片改写失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setScriptRewriteBusy(null)
}
}
const generateMissingProductAngle = async (slot: typeof PRODUCT_VIEW_SLOTS[number]) => {
if (!job || !productItems.length) return
const source = productItems[0]
setProductAngleBusy(slot.value)
try {
const ref = await generateProductAngleAsset(job.id, {
source_ref: source.ref,
target_view: slot.label,
note: slot.hint,
})
setProductItems((prev) => {
const next = [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, "white", undefined, undefined, undefined, "", 1)]
void persistProductItems(next)
return next
})
toast.success(`AI 已补全产品视角:${slot.label}`)
} catch (e) {
toast.error("AI 补角度失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setProductAngleBusy(null)
}
}
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)
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="信息流复刻分镜工作台" />
<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={(job.generated_videos?.length ?? 0) > 0} detail={`${job.generated_videos?.length ?? 0}`} />
</div>
</div>
<div className="mb-2 rounded-md border border-white/10 bg-black/24 p-2.5">
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<SectionTitle icon={<Package className="h-4 w-4" />} title="同一产品素材池 / 视角标注" />
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/42">{productItems.length ? `${productItems.length} 张素材` : "素材池不限量"}</span>
{(productAnalyzing || productAngleBusy) && (
<span className="inline-flex items-center gap-1 rounded-md border border-cyan-300/20 bg-cyan-300/[0.08] px-2 py-0.5 text-[10px] text-cyan-100/75">
<Loader2 className="h-3 w-3 animate-spin" />
{productAnalyzing ? "识别视角中" : `补图中:${productViewLabel(productAngleBusy ?? "")}`}
</span>
)}
</div>
<p className="mt-1 max-w-[760px] text-[11px] leading-snug text-white/42">
U // 1200-2000px 600px 1600pxJPEG 92 AI {MAX_PRODUCT_REFS_PER_VIDEO}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => void reanalyzeProductViews()}
disabled={!productItems.length || productAnalyzing || !!productAngleBusy}
className="inline-flex h-9 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2.5 text-[11px] font-semibold text-white/72 transition hover:border-white/25 hover:bg-white/[0.1] disabled:cursor-not-allowed disabled:opacity-40"
>
{productAnalyzing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => productFileRef.current?.click()}
disabled={!job || productUploading || productAnalyzing || !!productAngleBusy}
className="inline-flex h-9 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2.5 text-[11px] font-semibold text-white/72 transition hover:border-white/25 hover:bg-white/[0.1] disabled:cursor-not-allowed disabled:opacity-40"
>
{productUploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
</button>
<input
ref={productFileRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void uploadProductImages(event.currentTarget.files)
event.currentTarget.value = ""
}}
/>
</div>
</div>
<div className="grid max-h-[360px] gap-2 overflow-y-auto pr-1 md:grid-cols-2 2xl:grid-cols-3">
{productItems.map((item) => (
<ProductReferenceCard
key={item.id}
job={job}
item={item}
onPatch={(patch) => patchProductItem(item.id, patch)}
onRemove={() => removeProductItem(item.id)}
/>
))}
{PRODUCT_VIEW_SLOTS.filter((slot) => !productItems.some((item) => item.view === slot.value)).map((slot) => (
<MissingProductViewSlot
key={slot.value}
slot={slot}
canGenerate={!!productItems.length}
busy={productAngleBusy === slot.value}
blocked={productAnalyzing || !!productAngleBusy}
onGenerate={() => generateMissingProductAngle(slot)}
/>
))}
</div>
</div>
{rows.length ? (
<>
<div className="mb-2 grid gap-2 rounded-md border border-white/10 bg-black/24 p-2 xl:grid-cols-[minmax(0,1fr)_auto]">
<textarea
value={authorIntent}
onChange={(event) => setAuthorIntent(event.target.value)}
placeholder="作者想法:比如更像真实达人、前半段强调久坐低头、结尾弱化优惠、语气更轻松..."
className="min-h-[42px] resize-y rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[11px] leading-snug text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
/>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => void rewriteAllRows()}
disabled={scriptRewriteBusy !== null || !rows.length}
className="inline-flex h-9 items-center justify-center gap-1 rounded-md bg-white px-2.5 text-[11px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{scriptRewriteBusy === "all" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => setCopyOverrides({})}
disabled={scriptRewriteBusy !== null || !Object.keys(copyOverrides).length}
className="inline-flex h-9 items-center justify-center rounded-md border border-white/10 bg-white/[0.045] px-2.5 text-[11px] font-semibold text-white/60 transition hover:border-white/25 hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
>
稿
</button>
</div>
</div>
<div className="max-h-[560px] space-y-2 overflow-y-auto pr-1">
{rows.map((row) => {
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-[56px_140px_minmax(220px,0.75fr)_minmax(240px,0.8fr)_minmax(320px,1fr)] 2xl:grid-cols-[58px_170px_minmax(360px,0.8fr)_minmax(380px,1fr)_520px]"
>
<StoryboardPlanCell label="分镜">
<div className="font-mono text-[11px] text-white/40">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</div>
<div className="mt-1.5 inline-flex max-w-full rounded-md border border-emerald-300/15 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] leading-tight text-emerald-100/80">
{row.role}
</div>
</StoryboardPlanCell>
<StoryboardPlanCell label="原内容">
<p className="line-clamp-2 text-[10.5px] leading-snug" title={row.source}>{row.source}</p>
</StoryboardPlanCell>
<StoryboardPlanCell label="新口播文案">
<textarea
value={copyText}
onChange={(event) => patchRowCopy(row.index, event.target.value)}
className="min-h-[64px] w-full resize-y rounded border border-white/10 bg-black/32 px-2 py-1.5 text-[11px] leading-snug text-white/82 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
/>
<button
type="button"
onClick={() => void rewriteSingleRow(row)}
disabled={scriptRewriteBusy !== null}
className="mt-1 inline-flex h-6 w-full items-center justify-center gap-1 rounded border border-white/10 bg-white/[0.045] px-2 text-[10.5px] font-semibold text-white/62 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
>
{scriptRewriteBusy === row.index ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
</button>
</StoryboardPlanCell>
<StoryboardPlanCell label="画面规划 / 产品融入">
<p className="line-clamp-2" title={row.visualPlan}>{row.visualPlan}</p>
<p className="mt-1 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="生成视频" 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={() => 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" />}
</button>
</StoryboardPlanCell>
</article>
)
})}
</div>
</>
) : (
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成信息流复刻分镜工作台。先在原版视频旁抽参考 12 帧并选择主角参考,再按分镜生成视频候选。" />
)}
</section>
)
}
function ProductReferenceCard({
job,
item,
onPatch,
onRemove,
}: {
job: Job
item: ProductRefItem
onPatch: (patch: Partial<ProductRefItem>) => void
onRemove: () => void
}) {
const src = resolveImageRefUrl(job.id, item.ref)
const tagLabels = item.useTags.map((tag) => PRODUCT_USE_TAG_LABELS[tag]).filter(Boolean)
const assetWarnings = item.assetMeta?.warnings ?? []
const assetActions = item.assetMeta?.actions ?? []
const orientationText = formatProductOrientation(item.orientation)
const [previewPos, setPreviewPos] = useState<{ left: number; top: number } | null>(null)
function updatePreviewPosition(event: ReactMouseEvent<HTMLDivElement>) {
const margin = 16
const previewWidth = Math.min(380, window.innerWidth - margin * 2)
const previewHeight = previewWidth + 118
let left = event.clientX + 18
let top = event.clientY + 18
if (left + previewWidth > window.innerWidth - margin) {
left = event.clientX - previewWidth - 18
}
if (top + previewHeight > window.innerHeight - margin) {
top = window.innerHeight - previewHeight - margin
}
setPreviewPos({
left: Math.max(margin, left),
top: Math.max(margin, top),
})
}
const preview = previewPos && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed z-[9999] w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/15 bg-black/94 p-3 shadow-[0_28px_80px_rgba(0,0,0,0.72)]"
style={{ left: previewPos.left, top: previewPos.top }}
>
<img src={src} alt="" className="aspect-square w-full rounded-lg bg-white object-contain" />
<div className="mt-2 text-[11px] leading-snug text-white/68">
{productViewLabel(item.view)} · {productBackgroundLabel(item.background)} · {tagLabels.join(" / ")}
<br />
{item.note}
{orientationText ? <><br />{orientationText}</> : null}
{item.landmarks.length ? <><br />{item.landmarks.join(" / ")}</> : null}
{item.risk ? <><br />{item.risk}</> : null}
{assetWarnings.length ? <><br />{assetWarnings.join("")}</> : null}
</div>
</div>,
document.body,
)
: null
return (
<div className="grid min-w-0 grid-cols-[74px_minmax(0,1fr)_28px] gap-2 rounded-md border border-white/10 bg-black/26 p-2">
<div
className="relative h-[74px] w-[74px] rounded-md border border-white/10 bg-white"
onMouseEnter={updatePreviewPosition}
onMouseMove={updatePreviewPosition}
onMouseLeave={() => setPreviewPos(null)}
>
<img src={src} alt={productViewLabel(item.view)} className="h-full w-full rounded-md object-contain" />
{preview}
<span className="absolute left-1 top-1 rounded bg-black/70 px-1 text-[9px] text-white/75">{item.source === "ai" ? "AI" : "图"}</span>
</div>
<div className="min-w-0">
<div className="flex h-7 w-full items-center justify-between gap-2 rounded-md border border-white/10 bg-black/55 px-2 text-[11px] text-white/78">
<span className="truncate font-semibold">{productViewLabel(item.view)}</span>
<span className="shrink-0 text-[10px] text-white/34">
{item.source === "ai" ? "自动补图" : item.confidence != null ? `自动识别 ${Math.round(item.confidence * 100)}%` : "自动识别"}
</span>
</div>
<div className="mt-1 flex min-h-5 flex-wrap gap-1 overflow-hidden">
<span className="rounded border border-white/10 bg-white/[0.045] px-1.5 py-0.5 text-[9.5px] leading-none text-white/44">{formatProductAssetSize(item.assetMeta)}</span>
<span className="rounded border border-white/10 bg-white/[0.045] px-1.5 py-0.5 text-[9.5px] leading-none text-white/44">{productBackgroundLabel(item.background)}</span>
{tagLabels.slice(0, 3).map((tag) => (
<span key={tag} className="rounded border border-cyan-300/14 bg-cyan-300/[0.07] px-1.5 py-0.5 text-[9.5px] leading-none text-cyan-100/58">{tag}</span>
))}
{orientationText ? <span className="rounded border border-violet-300/14 bg-violet-300/[0.07] px-1.5 py-0.5 text-[9.5px] leading-none text-violet-100/60"></span> : null}
{item.landmarks.slice(0, 2).map((landmark) => (
<span key={landmark} className="rounded border border-white/10 bg-white/[0.045] px-1.5 py-0.5 text-[9.5px] leading-none text-white/44">{landmark}</span>
))}
{item.risk || assetWarnings.length ? <span className="rounded border border-amber-300/18 bg-amber-300/[0.08] px-1.5 py-0.5 text-[9.5px] leading-none text-amber-100/68"></span> : null}
{assetActions.length ? <span className="rounded border border-emerald-300/14 bg-emerald-300/[0.07] px-1.5 py-0.5 text-[9.5px] leading-none text-emerald-100/58"></span> : null}
</div>
<input
value={item.note}
onChange={(event) => onPatch({ note: event.target.value })}
placeholder="检查备注:佩戴者左/右、上/下、触点、尺寸比例"
className="mt-1 h-8 w-full rounded-md border border-white/10 bg-black/35 px-2 text-[11px] text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
/>
<div className="mt-1 truncate text-[10px] text-white/32">{item.ref.label || "产品参考图"}</div>
</div>
<button
type="button"
onClick={onRemove}
className="h-7 w-7 rounded-md border border-white/10 text-white/40 transition hover:border-rose-300/40 hover:text-rose-200"
aria-label="移除产品图"
>
<Trash2 className="mx-auto h-3.5 w-3.5" />
</button>
</div>
)
}
function MissingProductViewSlot({
slot,
canGenerate,
busy,
blocked,
onGenerate,
}: {
slot: typeof PRODUCT_VIEW_SLOTS[number]
canGenerate: boolean
busy: boolean
blocked: boolean
onGenerate: () => void
}) {
return (
<div className="min-h-[94px] rounded-md border border-dashed border-white/12 bg-black/20 p-2">
<div className="flex items-center justify-between gap-2">
<div className="text-[12px] font-semibold text-white/58">{slot.label}</div>
<span className="rounded border border-amber-300/18 bg-amber-300/[0.08] px-1.5 py-0.5 text-[10px] text-amber-100/72"></span>
</div>
<p className="mt-1 line-clamp-2 text-[10.5px] leading-snug text-white/34">{slot.hint}</p>
<p className="mt-1 text-[10px] leading-snug text-white/28">{canGenerate ? "自动补图失败时可重试。" : "上传后会自动识别并补齐。"}</p>
<button
type="button"
onClick={onGenerate}
disabled={!canGenerate || busy || blocked}
className="mt-2 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-[10.5px] font-semibold text-white/62 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
</button>
</div>
)
}
function StoryboardPlanCell({ label, children, className = "" }: { label: string; children: ReactNode; className?: string }) {
return (
<div className={`min-w-0 border-b border-white/8 p-2 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 StoryboardVideoSlots({ job, videos, enabled }: { job: Job; videos: GeneratedVideo[]; enabled: boolean }) {
const visible = videos.slice(0, 6)
const emptyCount = Math.max(0, 6 - visible.length)
return (
<div>
<div className="grid grid-cols-6 gap-1.5">
{visible.map((video) => (
<StoryboardVideoPreview key={video.id} job={job} video={video} className="aspect-[9/16] min-h-[86px] w-full" />
))}
{Array.from({ length: emptyCount }).map((_, index) => (
<div key={`empty-video-${index}`} className="flex aspect-[9/16] min-h-[86px] min-w-0 items-center justify-center rounded border border-dashed border-white/12 bg-black/25 px-1 text-center text-[9.5px] leading-tight text-white/26">
{enabled ? `候选 ${visible.length + index + 1}` : "先抽 12 帧"}
</div>
))}
</div>
{videos.length > 6 && (
<div className="mt-1 text-[10px] text-white/34"> {videos.length - 6} </div>
)}
</div>
)
}
function StoryboardVideoPreview({ job, video, className = "h-20 w-12" }: { job: Job; video: GeneratedVideo; className?: string }) {
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 shrink-0 overflow-hidden rounded border border-white/10 bg-black/45 ${className}`}
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,
currentTime,
duration,
segments,
onSeek,
}: {
features: AudioFeature[]
status: AudioFeatureStatus
currentTime: number
duration: number
segments: Array<{ start: number; end: number }>
onSeek: (time: number) => void
}) {
const pointerPct = clampNumber((currentTime / Math.max(duration, 1)) * 100, 0, 100)
const hasFeatures = features.length > 0
const { topPoints, bottomPoints, envelopePoints } = useMemo(() => {
const top = features.map((feature, index) => {
const x = features.length <= 1 ? 0 : (index / (features.length - 1)) * 100
return `${x.toFixed(2)},${(50 - feature.loudness * 32).toFixed(2)}`
}).join(" ")
const bottom = [...features].reverse().map((feature, index) => {
const originalIndex = features.length - 1 - index
const x = features.length <= 1 ? 0 : (originalIndex / (features.length - 1)) * 100
return `${x.toFixed(2)},${(50 + feature.loudness * 32).toFixed(2)}`
}).join(" ")
return { topPoints: top, bottomPoints: bottom, envelopePoints: `${top} ${bottom}` }
}, [features])
return (
<div
className="relative h-24 cursor-pointer overflow-hidden rounded-md border border-white/10 bg-black/35 px-2"
aria-label="音频响度波形"
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect()
onSeek(((event.clientX - rect.left) / Math.max(rect.width, 1)) * duration)
}}
>
<div className="absolute inset-y-2 left-2 right-2">
{!hasFeatures && (
<div className="absolute inset-0 flex items-center justify-center text-[11px] text-white/35">
{status === "failed" ? "audio.wav 解码失败" : status === "loading" ? "正在解码 audio.wav" : "等待音频文件"}
</div>
)}
<div className="absolute inset-x-0 top-1/2 h-px bg-white/14" />
{hasFeatures && (
<svg className="pointer-events-none absolute inset-0 h-full w-full overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
<polygon
points={envelopePoints}
fill="rgba(209,213,219,0.74)"
/>
<polyline
points={topPoints}
fill="none"
stroke="rgba(229,231,235,0.7)"
strokeWidth="0.55"
vectorEffect="non-scaling-stroke"
/>
<polyline
points={bottomPoints}
fill="none"
stroke="rgba(229,231,235,0.52)"
strokeWidth="0.55"
vectorEffect="non-scaling-stroke"
/>
</svg>
)}
</div>
{segments.map((segment, index) => (
<div
key={`${segment.start}-${index}`}
className="absolute inset-y-0 w-px bg-white/12"
style={{ left: `${clampNumber((segment.start / Math.max(duration, 1)) * 100, 0, 100)}%` }}
/>
))}
<div
className="pointer-events-none absolute inset-y-0 w-[2px] bg-emerald-200 shadow-[0_0_16px_rgba(110,231,183,0.85)] will-change-[left]"
style={{ left: `${pointerPct}%` }}
/>
</div>
)
}
function ProfileTile({ label, value, running }: { label: string; value?: string; running?: boolean }) {
return (
<div className="min-h-[74px] rounded-md border border-white/10 bg-black/35 p-2.5">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold text-white/48">{label}</span>
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin text-cyan-200" /> : value ? <Check className="h-3.5 w-3.5 text-emerald-200" /> : <Circle className="h-3.5 w-3.5 text-white/32" />}
</div>
<p className="max-h-[34px] overflow-hidden text-[11.5px] leading-snug text-white/62" title={value}>
{value || (running ? "模型分析中..." : "等待音频分析结果。")}
</p>
</div>
)
}
function FrameExtractControls({
job,
data,
selectedFramesCount,
onSelectAllFrames,
onClearFrameSelection,
}: {
job: Job | null
data: NodeData
selectedFramesCount: number
onSelectAllFrames: () => void
onClearFrameSelection: () => void
}) {
return (
<div className="rounded-lg border border-white/10 bg-black/32 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<SectionTitle icon={<Scissors className="h-4 w-4" />} title="抽帧供分镜使用" />
<StatusPill ready={!!job?.frames.length} running={data.analyzing || job?.status === "splitting"} />
</div>
<div className="grid grid-cols-[1fr_1fr_72px] gap-2">
<select
value={job ? data.frameTargets[job.id] ?? "transparent_human" : "balanced"}
onChange={(e) => job && data.onFrameTargetChange(job.id, e.target.value as FrameExtractTarget)}
disabled={!job}
className={controlClass}
>
{TARGETS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
<select
value={job ? data.frameQualities[job.id] ?? "auto" : "auto"}
onChange={(e) => job && data.onFrameQualityChange(job.id, e.target.value as FrameExtractQuality)}
disabled={!job}
className={controlClass}
>
{QUALITIES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
<input
type="number"
min={1}
max={20}
value={job ? data.frameCounts[job.id] ?? 12 : 12}
onChange={(e) => job && data.onFrameCountChange(job.id, Number(e.target.value) || 12)}
disabled={!job}
className={`${controlClass} text-center`}
/>
</div>
<div className="mt-2 flex flex-wrap gap-2">
<ActionButton disabled={!job || data.analyzing} onClick={() => data.onAnalyze({ mode: job?.frames.length ? "append" : "replace" })}>
{data.analyzing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
{job?.frames.length ? "追加抽帧" : "开始抽帧"}
</ActionButton>
<ActionButton disabled={!job?.frames.length} variant="ghost" onClick={onSelectAllFrames}></ActionButton>
<ActionButton disabled={!selectedFramesCount} variant="ghost" onClick={onClearFrameSelection}></ActionButton>
</div>
</div>
)
}
function StoryboardSegmentCard({
job,
frame,
order,
selected,
selectedVideoIds,
videos,
busy,
sixViewBusyKey,
onToggleFrame,
onJobUpdate,
onGenerateElement,
onGenerateSixViews,
onGenerateVideo,
onToggleVideo,
onDeleteVideo,
}: {
job: Job
frame: KeyFrame
order: number
selected: boolean
selectedVideoIds: Set<string>
videos: GeneratedVideo[]
busy: boolean
sixViewBusyKey: string | null
onToggleFrame: () => void
onJobUpdate: (job: Job) => void
onGenerateElement: (candidate?: FrameObject) => void
onGenerateSixViews: (element: KeyElement) => void
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
onToggleVideo: (videoId: string) => void
onDeleteVideo?: (videoId: string) => void
}) {
const [scene, setScene] = useState<StoryboardScene>(() => ({ ...emptyScene(), ...(frame.storyboard ?? {}) }))
const [model, setModel] = useState<VideoModel>("seedance")
const [saving, setSaving] = useState(false)
const [generatingVideo, setGeneratingVideo] = useState(false)
const elements = frame.elements ?? []
const generatedImages = frame.generated_images ?? []
const objectCandidates = frame.description?.objects?.slice(0, 8).filter((item) => item.name?.trim()) ?? []
const objectNames = objectCandidates.map((item) => item.name)
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<StoryboardScene>) => 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 (
<article className={`rounded-lg border p-3 transition ${selected ? "border-rose-400/60 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
<div className="mb-3 flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<button
type="button"
onClick={onToggleFrame}
className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black"
aria-label={selected ? "取消选中分镜" : "选中分镜"}
>
<img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, order)} className="h-full w-full object-cover" />
<span className="absolute right-1 top-1 rounded-full bg-black/70 p-0.5">
{selected ? <Check className="h-3.5 w-3.5 text-rose-200" /> : <Circle className="h-3.5 w-3.5 text-white/55" />}
</span>
</button>
<div>
<div className="font-mono text-[12px] text-white/48"> {String(order + 1).padStart(2, "0")}</div>
<h3 className="mt-1 text-[15px] font-semibold text-white">{frameLabel(frame, order)}</h3>
<p className="mt-1 max-w-[520px] line-clamp-2 text-[12px] leading-relaxed text-white/42">{frame.description?.scene || "等待生成新的分镜文字"}</p>
</div>
</div>
<label className="flex h-9 items-center gap-2 rounded-md border border-white/10 bg-black/35 px-2 text-[12px] text-white/50">
<input
type="number"
min={1}
step={0.5}
value={scene.duration || 5}
onChange={(e) => patch({ duration: Number(e.target.value) || 5 })}
className="w-14 bg-transparent text-center font-mono text-white outline-none"
/>
</label>
</div>
<div className="grid gap-3">
<SegmentBand icon={<FileText className="h-4 w-4" />} title="音频分镜文案">
<div className="grid gap-2 lg:grid-cols-3">
<textarea
value={scene.scene ?? ""}
onChange={(e) => patch({ scene: e.target.value })}
placeholder="新剧情:根据原音频和产品内容,这一镜要讲什么"
className={`${fieldClass} min-h-[72px]`}
/>
<textarea
value={scene.product ?? ""}
onChange={(e) => patch({ product: e.target.value })}
placeholder="产品融入SKG 产品在哪里出现,怎么被使用"
className={`${fieldClass} min-h-[72px]`}
/>
<textarea
value={scene.action ?? ""}
onChange={(e) => patch({ action: e.target.value })}
placeholder="动作 / 镜头:首尾变化、手部动作、运镜节奏"
className={`${fieldClass} min-h-[72px]`}
/>
</div>
<div className="mt-2 flex justify-end">
<ActionButton variant="ghost" disabled={saving} onClick={save}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</ActionButton>
</div>
</SegmentBand>
<SegmentBand icon={<Package className="h-4 w-4" />} title="每个分镜需要的关键元素">
<div className="grid gap-3 lg:grid-cols-[170px_minmax(0,1fr)_auto]">
<img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, order)} className="aspect-video w-full rounded-md border border-white/10 bg-black object-cover" />
<div className="min-w-0">
<div className="mb-2 flex items-center gap-2 text-[11px] text-white/44">
<ImageIcon className="h-3.5 w-3.5" />
<span>{elements.filter(hasCutout).length}/{elements.length || objectNames.length || 0} </span>
<span>{generatedImages.length} </span>
</div>
<div className="mb-2 flex flex-wrap gap-1">
{objectCandidates.length > 0 && objectCandidates.map((candidate) => (
<button
key={`${candidate.name}:${candidate.position ?? ""}`}
type="button"
onClick={() => onGenerateElement(candidate)}
disabled={busy}
className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-1 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
title="选择该元素并生成提取图 + 6 视图"
>
{candidate.name}
</button>
))}
{!objectCandidates.length && !elements.length && <span className="text-[11px] text-white/32"></span>}
</div>
{elements.length > 0 && (
<div className="mb-2 grid gap-1">
{elements.slice(0, 5).map((element) => {
const busySix = sixViewBusyKey === `${frame.index}:${element.id}`
return (
<div key={element.id} className="flex items-center justify-between gap-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5">
<span className="min-w-0 truncate text-[11px] text-white/62">{element.name_zh || element.name_en}</span>
<button
type="button"
onClick={() => onGenerateSixViews(element)}
disabled={busySix}
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-white/10 px-2 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
>
{busySix ? <Loader2 className="h-3 w-3 animate-spin" /> : <ImageIcon className="h-3 w-3" />}
{element.subject_assets?.length ? `${element.subject_assets.length}视图` : "6视图"}
</button>
</div>
)
})}
</div>
)}
{(elementPreviews.length > 0 || generatedImages.length > 0) && (
<div className="flex gap-1 overflow-x-auto pb-1">
{elementPreviews.slice(0, 6).map(({ element, src }) => (
<img key={element.id} src={src} alt={element.name_zh || element.name_en} className="h-14 w-14 shrink-0 rounded-md border border-white/10 bg-black object-cover" />
))}
{generatedImages.slice(0, 6).map((image) => (
<img key={image.id} src={generatedImageUrl(job.id, frame.index, image.id)} alt={image.prompt || "生成图"} className="h-14 w-14 shrink-0 rounded-md border border-white/10 bg-black object-cover" />
))}
</div>
)}
</div>
<ActionButton disabled={busy} variant="ghost" onClick={() => onGenerateElement()}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
+6
</ActionButton>
</div>
</SegmentBand>
<SegmentBand icon={<Film className="h-4 w-4" />} title="视频生成">
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="grid gap-2">
<select value={model} onChange={(e) => setModel(e.target.value as VideoModel)} className="h-10 rounded-md border border-white/10 bg-black/55 px-2 text-[12px] text-white outline-none">
{VIDEO_MODELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
<ActionButton disabled={generatingVideo} onClick={generateVideo}>
{generatingVideo ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
</ActionButton>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{videos.length > 0 ? videos.map((video) => (
<VideoCandidate
key={video.id}
job={job}
video={video}
selected={selectedVideoIds.has(video.id)}
onToggle={() => onToggleVideo(video.id)}
onDelete={() => onDeleteVideo?.(video.id)}
/>
)) : (
<EmptyState text="这里会出现本分镜生成出的候选视频。" />
)}
</div>
</div>
</SegmentBand>
</div>
</article>
)
}
function DraftSegmentCard({
draft,
order,
job,
onPatch,
onRemove,
onJobUpdate,
onGenerateVideo,
}: {
draft: DraftSegment
order: number
job: Job | null
onPatch: (patch: Partial<DraftSegment>) => void
onRemove: () => void
onJobUpdate: (job: Job) => void
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
}) {
const [model, setModel] = useState<VideoModel>("seedance")
const [saving, setSaving] = useState(false)
const [generatingVideo, setGeneratingVideo] = useState(false)
const boundFrame = draft.frameIndex !== null ? job?.frames.find((frame) => frame.index === draft.frameIndex) ?? null : null
const patchScene = (next: Partial<StoryboardScene>) => {
onPatch({ scene: { ...draft.scene, ...next } })
}
const save = async () => {
if (!job || draft.frameIndex === null) {
toast.info("先给草稿分镜绑定一个关键帧,再保存。")
return
}
setSaving(true)
try {
const updated = await updateStoryboard(job.id, draft.frameIndex, draft.scene)
onJobUpdate(updated)
toast.success(`草稿分镜 ${order + 1} 已保存到关键帧`)
} catch (e) {
toast.error("保存失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}
}
const generateVideo = async () => {
if (!job || draft.frameIndex === null) {
toast.info("先绑定关键帧,视频生成需要首帧参考。")
return
}
setGeneratingVideo(true)
try {
await save()
await onGenerateVideo(draft.frameIndex, draft.scene, model)
} finally {
setGeneratingVideo(false)
}
}
return (
<article className="rounded-lg border border-dashed border-cyan-300/22 bg-cyan-500/[0.045] p-3">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="font-mono text-[12px] text-cyan-100/50">稿 {String(order + 1).padStart(2, "0")}</div>
<h3 className="mt-1 text-[15px] font-semibold text-white"></h3>
<p className="mt-1 text-[12px] text-white/42"></p>
</div>
<button type="button" onClick={onRemove} className="h-9 w-9 rounded-md border border-white/10 text-white/45 transition hover:border-rose-300/40 hover:text-rose-200" aria-label="删除草稿分镜">
<Trash2 className="mx-auto h-4 w-4" />
</button>
</div>
<div className="grid gap-3">
<SegmentBand icon={<FileText className="h-4 w-4" />} title="音频分镜文案">
<div className="grid gap-2 lg:grid-cols-3">
<textarea value={draft.scene.scene ?? ""} onChange={(e) => patchScene({ scene: e.target.value })} placeholder="新剧情" className={`${fieldClass} min-h-[72px]`} />
<textarea value={draft.scene.product ?? ""} onChange={(e) => patchScene({ product: e.target.value })} placeholder="产品融入" className={`${fieldClass} min-h-[72px]`} />
<textarea value={draft.scene.action ?? ""} onChange={(e) => patchScene({ action: e.target.value })} placeholder="动作 / 镜头" className={`${fieldClass} min-h-[72px]`} />
</div>
<div className="mt-2 flex justify-between gap-2">
<label className="flex h-10 items-center gap-2 rounded-md border border-white/10 bg-black/35 px-2 text-[12px] text-white/50">
<input type="number" min={1} step={0.5} value={draft.scene.duration || 5} onChange={(e) => patchScene({ duration: Number(e.target.value) || 5 })} className="w-14 bg-transparent text-center font-mono text-white outline-none" />
</label>
<ActionButton variant="ghost" disabled={saving} onClick={save}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</ActionButton>
</div>
</SegmentBand>
<SegmentBand icon={<Package className="h-4 w-4" />} title="每个分镜需要的关键元素">
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
<select
value={draft.frameIndex ?? ""}
onChange={(e) => onPatch({ frameIndex: e.target.value ? Number(e.target.value) : null })}
disabled={!job?.frames.length}
className={controlClass}
>
<option value=""></option>
{job?.frames.map((frame, index) => <option key={frame.index} value={frame.index}>{frameLabel(frame, index)}</option>)}
</select>
{boundFrame ? (
<div className="flex items-center gap-3 rounded-md border border-white/10 bg-black/28 p-2">
<img src={effectiveFrameUrl(job!.id, boundFrame)} alt="绑定关键帧" className="h-16 w-24 rounded-md object-cover" />
<span className="text-[12px] text-white/52">{boundFrame.description?.scene || "已绑定关键帧,可保存并生成视频。"}</span>
</div>
) : (
<div className="rounded-md border border-dashed border-white/12 bg-black/25 p-3 text-[12px] text-white/38"></div>
)}
</div>
</SegmentBand>
<SegmentBand icon={<Film className="h-4 w-4" />} title="视频生成">
<div className="grid gap-2 lg:grid-cols-[220px_220px]">
<select value={model} onChange={(e) => setModel(e.target.value as VideoModel)} className="h-10 rounded-md border border-white/10 bg-black/55 px-2 text-[12px] text-white outline-none">
{VIDEO_MODELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
<ActionButton disabled={generatingVideo || draft.frameIndex === null} onClick={generateVideo}>
{generatingVideo ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
</ActionButton>
</div>
</SegmentBand>
</div>
</article>
)
}
function SegmentBand({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
return (
<section className="rounded-lg border border-white/10 bg-black/24 p-3">
<div className="mb-2">
<SectionTitle icon={icon} title={title} />
</div>
{children}
</section>
)
}
function ComposeSummary({
audioReady,
selectedVideoCount,
generatedVideoCount,
}: {
audioReady: boolean
selectedVideoCount: number
generatedVideoCount: number
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-black/35 px-3 py-2">
<div className="flex items-center gap-2">
<PanelRight className="h-4 w-4 text-rose-200" />
<div>
<div className="text-[13px] font-semibold text-white"></div>
<div className="text-[11px] text-white/40">广</div>
</div>
</div>
<div className="flex items-center gap-2 text-[11px] text-white/52">
<Requirement label="音频" ready={audioReady} detail={audioReady ? "已生成" : "待解析"} />
<Requirement label="候选" ready={generatedVideoCount > 0} detail={`${generatedVideoCount}`} />
<Requirement label="已选" ready={selectedVideoCount > 0} detail={`${selectedVideoCount}`} />
<button type="button" disabled className="inline-flex h-10 cursor-not-allowed items-center justify-center gap-2 rounded-md border border-white/10 bg-white/[0.04] px-3 text-[12px] font-semibold text-white/34">
<Film className="h-4 w-4" />
</button>
</div>
</div>
)
}
function MaterialCard({
job,
index,
active,
onClick,
onDelete,
}: {
job: Job
index: number
active: boolean
onClick: () => void
onDelete?: () => void
}) {
const tone = statusTone(job)
return (
<button
type="button"
onClick={onClick}
className={`group w-full rounded-lg border p-3 text-left transition ${active ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/28 hover:border-white/24 hover:bg-white/[0.045]"}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-[12px] text-white/78"> {String(index + 1).padStart(2, "0")}</div>
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-white/42">
<Link2 className="h-3 w-3" />
<span className="truncate">{job.url || shortId(job.id)}</span>
</div>
</div>
<span className={`shrink-0 rounded-md border px-2 py-1 text-[11px] ${tone.className}`}>{tone.label}</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px] text-white/44">
<Metric label="视频" value={job.video_url ? "ready" : "-"} compact />
<Metric label="文案" value={job.audio_script?.source_text || job.transcript.length ? "ready" : "-"} compact />
<Metric label="段落" value={`${job.transcript.length}`} compact />
</div>
{onDelete && (
<span
role="button"
tabIndex={0}
onClick={(event) => { event.stopPropagation(); onDelete() }}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
event.stopPropagation()
onDelete()
}
}}
className="mt-3 hidden h-8 items-center justify-center gap-1 rounded-md border border-white/10 text-[11px] text-white/50 transition hover:border-rose-300/40 hover:text-rose-200 group-hover:flex"
>
<Trash2 className="h-3.5 w-3.5" />
</span>
)}
</button>
)
}
function Metric({ label, value, compact }: { label: string; value: string; compact?: boolean }) {
return (
<div className={`rounded-md border border-white/10 bg-black/35 ${compact ? "px-2 py-1" : "px-2 py-1.5"}`}>
<div>{label}</div>
<div className="mt-0.5 truncate font-mono text-[12px] text-white/78">{value}</div>
</div>
)
}
function SectionTitle({ icon, title }: { icon: ReactNode; title: string }) {
return (
<h3 className="flex items-center gap-2 text-[13px] font-semibold text-white">
<span className="text-rose-200">{icon}</span>
{title}
</h3>
)
}
function StatusPill({ ready, running }: { ready: boolean; running?: boolean }) {
return (
<span className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] ${running ? "border-cyan-300/25 text-cyan-100 bg-cyan-400/10" : ready ? "border-emerald-300/25 text-emerald-100 bg-emerald-400/10" : "border-white/10 text-white/42 bg-white/[0.03]"}`}>
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : ready ? <Check className="h-3 w-3" /> : <Circle className="h-2.5 w-2.5" />}
{running ? "运行中" : ready ? "已就绪" : "待处理"}
</span>
)
}
function ActionButton({
children,
disabled,
onClick,
variant = "solid",
}: {
children: ReactNode
disabled?: boolean
onClick?: () => void
variant?: "solid" | "ghost"
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
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}
</button>
)
}
function EmptyState({ text }: { text: string }) {
return (
<div className="rounded-lg border border-dashed border-white/12 bg-black/25 px-3 py-8 text-center text-[12px] text-white/38">
{text}
</div>
)
}
function Requirement({ label, ready, detail }: { label: string; ready: boolean; detail: string }) {
return (
<div className="flex h-10 min-w-0 items-center gap-2 rounded-md border border-white/10 bg-black/28 px-2">
{ready ? <Check className="h-3.5 w-3.5 shrink-0 text-emerald-200" /> : <Circle className="h-3.5 w-3.5 shrink-0 text-white/38" />}
<span className="shrink-0 whitespace-nowrap">{label}</span>
<span className="min-w-0 truncate font-mono text-[11px] text-white/42">{detail}</span>
</div>
)
}
function VideoCandidate({
job,
video,
selected,
onToggle,
onDelete,
}: {
job: Job
video: GeneratedVideo
selected: boolean
onToggle: () => void
onDelete?: () => void
}) {
const src = videoSrc(video)
const poster = videoPoster(job, video)
const running = video.status === "queued" || video.status === "in_progress"
return (
<div className={`rounded-lg border p-2 transition ${selected ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
<div className="flex gap-2">
<button type="button" onClick={onToggle} className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black">
{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>
)}
<div className="absolute right-1 top-1 rounded-full bg-black/70 p-0.5">{selected ? <Check className="h-3 w-3 text-rose-200" /> : <Circle className="h-3 w-3 text-white/55" />}</div>
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="truncate font-mono text-[12px] text-white/80">{shortId(video.id)} · {video.model}</div>
<button type="button" onClick={onDelete} className="h-8 w-8 rounded-md border border-white/10 text-white/45 transition hover:border-rose-300/40 hover:text-rose-200" aria-label="删除片段">
<Trash2 className="mx-auto h-3.5 w-3.5" />
</button>
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-white/45">
{running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-200" /> : video.status === "failed" ? <AlertTriangle className="h-3 w-3 text-rose-200" /> : <Film className="h-3 w-3" />}
<span>{video.status}</span>
<span>{formatSeconds(video.duration)}</span>
<span>{video.progress}%</span>
</div>
{video.error && <div className="mt-1 line-clamp-2 text-[11px] text-rose-200/80">{video.error}</div>}
{src && video.status === "completed" && (
<a href={src} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-cyan-200 hover:text-cyan-100">
<Play className="h-3 w-3" />
</a>
)}
</div>
</div>
</div>
)
}