"use client" import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react" import { createPortal } from "react-dom" import { AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Sparkles, Sun, Trash2, Upload, Wand2, } from "lucide-react" import { toast } from "sonner" import { type FrameExtractQuality, type FrameExtractTarget, type FrameObject, type GeneratedVideo, type ImageRef, type AssetLibraryItem, type AssetLibraryKind, type CharacterLibraryItem, type SubjectTemplateItem, type Job, type KeyElement, type KeyFrame, type ProductViewAnalysisItem, type ProductRefStateItem, type QuickStoryboardPlanInput, type RefineStoryboardResult, type RuntimeModels, type StoryboardScriptRewriteSegment, type StoryboardScene, type SubjectAsset, type SubjectProfilePreference, type SubjectKind, addElement, analyzeJob, analyzeProductViews, apiAssetUrl, characterLibraryImageUrl, createAssetLibraryItem, createPromptLibraryItem, cutoutElement, deleteSubjectAsset, effectiveFrameUrl, formatJobError, generateSceneAsset, generateProductAngleAsset, generateStoryboardVideo, generateSubjectAssets, generatedImageUrl, getJob, getRuntimeHealth, hasCutout, listCharacterLibrary, listSubjectTemplates, representativeCutoutUrl, resolveImageRefUrl, refineStoryboard, quickPlanStoryboard, rewriteStoryboardScript, saveSubjectTemplate, saveProductRefs, sourceAudioUrl, subjectTemplateImageUrl, updateElement, updateStoryboard, uploadStoryboardAsset, translateText, videoUrl, } from "@/lib/api" import { type NodeData } from "@/components/nodes" import { MediaAssetTile } from "@/components/media-asset-tile" import { AnimatedLoginCharacters } from "@/components/login/animated-login-characters" import { LibraryDrawer } from "@/components/resource-library/library-drawer" 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 BoardThemeMode = "dark" | "light" type AudioStoryboardRole = "hook" | "pain" | "proof" | "solution" | "cta" | "bridge" const BOARD_THEME_STORAGE_KEY = "skg-board-theme" type DraftSegment = { id: string frameIndex: number | null scene: StoryboardScene } type AudioFeature = { loudness: number } type AudioFeatureStatus = "idle" | "loading" | "ready" | "failed" type FilmstripDensitySeconds = 1 | 2 | 5 type FilmstripStatus = "idle" | "loading" | "ready" | "failed" type FilmstripPreviewFrame = { time: number src: string } const FILMSTRIP_DRAG_TYPE = "application/x-skg-filmstrip-time" const FILMSTRIP_DENSITIES: Array<{ value: FilmstripDensitySeconds; label: string; detail: string }> = [ { value: 5, label: "低", detail: "5s/张" }, { value: 2, label: "中", detail: "2s/张" }, { value: 1, label: "高", detail: "1s/张" }, ] const FILMSTRIP_TILT_CLASSES = ["-rotate-[8deg]", "-rotate-[6deg]", "-rotate-[9deg]"] type AudioStoryboardRow = { index: number start: number end: number source: string sourceZh: string role: AudioStoryboardRole visualMode: StoryboardVisualMode needsProduct: boolean needsSubject: boolean subjectDescription: string subjectDescriptionZh: string skgCopy: string skgCopyZh: string sceneOneLine: string sceneOneLineZh: string actionOneLine: string actionOneLineZh: string visualPlan: string visualPlanZh: string firstFramePlan: string firstFramePlanZh: string lastFramePlan: string lastFramePlanZh: string referencePlan: string keyElements: string keyElementsZh: string productIntegration: string productIntegrationZh: string productPlacement: string productPlacementZh: string } type ProductRefItem = ProductRefStateItem type SubjectPlanningRef = ImageRef & { view: string; roleHint: string; consensusBrief?: string } type SubjectStyleMode = "transparent_human" | "source_actor" type SubjectMode = "template" | "source_similar" type SubjectViewMode = "all" | "common" | "custom" type SubjectProfileMode = "random" | "manual" type SubjectProfileFieldKey = "gender" | "age" | "wardrobe" | "region_ethnicity" | "skin_tone" | "body" | "hair" | "mood" type SubjectProfileDraft = Record type SubjectProfileOption = { value: string; label: string; prompt: string } type SubjectProfileCategory = { key: SubjectProfileFieldKey; label: string; options: SubjectProfileOption[] } type ResolvedSubjectProfile = { mode: SubjectProfileMode values: SubjectProfileDraft summary: string promptSummary: string payload: SubjectProfilePreference } type StoryboardVisualMode = NonNullable type RowPlanPatch = Partial> type WorkflowStepId = "input" | "source" | "audio" | "visual" | "subject" | "product" | "script" | "scene" | "video" type WorkflowStepStatus = "blocked" | "pending" | "running" | "ready" | "paused" type WorkflowStep = { id: WorkflowStepId no: string title: string detail: string judge: string status: WorkflowStepStatus } const VISUAL_MODE_OPTIONS: Array<{ value: StoryboardVisualMode; label: string; description: string }> = [ { value: "person_only", label: "人物/情绪", description: "只拍人物、状态、痛点或口播,不强制露产品。" }, { value: "person_product", label: "人物+产品", description: "人物佩戴、拿起、调整或使用 SKG 产品。" }, { value: "product_only", label: "产品特写", description: "只拍产品、包装、功能细节或 hero packshot。" }, { value: "environment", label: "场景过渡", description: "只做空间、生活方式、转场或情绪氛围。" }, ] const SUBJECT_ASSET_VIEWS = [ { value: "front", label: "正面" }, { value: "three_quarter_left", label: "左前45" }, { value: "left", label: "左侧" }, { value: "back", label: "背面" }, { value: "right", label: "右侧" }, { value: "three_quarter_right", label: "右前45" }, { value: "bust_front", label: "肩颈正近" }, { value: "bust_left_45", label: "肩颈左近" }, { value: "bust_right_45", label: "肩颈右近" }, { value: "back_neck_detail", label: "后颈肩背" }, ] as const const SUBJECT_VIEW_ORDER = [ ...SUBJECT_ASSET_VIEWS.map((view) => view.value), "bust", "back_detail", ] const COMMON_SUBJECT_VIEW_VALUES = ["front", "three_quarter_left", "three_quarter_right", "bust_front"] const SUBJECT_ASSET_SIZE = "2048" as const const SUBJECT_PROFILE_CATEGORIES: SubjectProfileCategory[] = [ { key: "gender", label: "性别表现", options: [ { value: "random", label: "随机", prompt: "gender presentation selected randomly for this request" }, { value: "female", label: "女性", prompt: "female-presenting commercial ad subject" }, { value: "male", label: "男性", prompt: "male-presenting commercial ad subject" }, { value: "neutral", label: "中性", prompt: "androgynous or gender-neutral commercial ad subject" }, ], }, { key: "age", label: "年龄段", options: [ { value: "random", label: "随机", prompt: "age range selected randomly for this request" }, { value: "young_adult", label: "年轻成人", prompt: "young adult, fresh short-video creator energy" }, { value: "adult", label: "成熟成人", prompt: "adult, polished and reliable commercial look" }, { value: "middle_aged", label: "中年", prompt: "middle-aged adult, credible wellness and work-stress context" }, { value: "senior", label: "银发", prompt: "active senior adult, friendly wellness lifestyle context" }, ], }, { key: "wardrobe", label: "着装风格", options: [ { value: "random", label: "随机", prompt: "wardrobe style selected randomly for this request" }, { value: "athleisure", label: "运动休闲", prompt: "clean athleisure outfit with visible neck and shoulders" }, { value: "office", label: "通勤职场", prompt: "simple office-casual outfit, clean neckline, no bulky collar" }, { value: "home_loungewear", label: "居家舒适", prompt: "soft home loungewear, relaxed wellness ad mood" }, { value: "premium_minimal", label: "高级极简", prompt: "premium minimal styling, refined neutral clothing, clear shoulder line" }, { value: "street_casual", label: "街头日常", prompt: "modern street-casual styling, creator-ad friendly" }, { value: "wellness", label: "健康护理", prompt: "wellness-care styling, clean and professional but not medical" }, ], }, { key: "region_ethnicity", label: "地域人种", options: [ { value: "random", label: "随机", prompt: "regional and ethnic appearance selected randomly for this request" }, { value: "east_asian", label: "东亚", prompt: "East Asian appearance cues, contemporary commercial styling" }, { value: "southeast_asian", label: "东南亚", prompt: "Southeast Asian appearance cues, warm creator-ad styling" }, { value: "south_asian", label: "南亚", prompt: "South Asian appearance cues, clear commercial readability" }, { value: "black", label: "黑人/非洲裔", prompt: "Black or African-diaspora appearance cues, polished ad styling" }, { value: "white", label: "白人/欧美", prompt: "White or European appearance cues, contemporary lifestyle ad styling" }, { value: "latino", label: "拉丁裔", prompt: "Latino appearance cues, energetic lifestyle ad styling" }, { value: "middle_eastern", label: "中东", prompt: "Middle Eastern appearance cues, premium lifestyle ad styling" }, { value: "mixed_global", label: "混合国际化", prompt: "mixed or globally ambiguous appearance cues, international ad campaign feel" }, ], }, { key: "skin_tone", label: "肤色", options: [ { value: "random", label: "随机", prompt: "skin tone selected randomly for this request" }, { value: "fair", label: "白皙", prompt: "fair skin tone" }, { value: "light", label: "浅肤色", prompt: "light skin tone" }, { value: "medium", label: "中等肤色", prompt: "medium skin tone" }, { value: "tan", label: "小麦/棕肤", prompt: "tan or warm brown skin tone" }, { value: "deep", label: "深肤色", prompt: "deep skin tone" }, ], }, { key: "body", label: "体型比例", options: [ { value: "random", label: "随机", prompt: "body proportion selected randomly for this request" }, { value: "slim", label: "偏瘦", prompt: "slim body proportion with clear neck and shoulder silhouette" }, { value: "average", label: "自然匀称", prompt: "average natural body proportion, believable short-video creator" }, { value: "athletic", label: "运动型", prompt: "athletic body proportion, wellness and mobility context" }, { value: "soft", label: "亲和微胖", prompt: "soft approachable body proportion, friendly lifestyle realism" }, { value: "broad_shoulder", label: "肩颈明显", prompt: "slightly broader shoulder line, useful for neck-and-shoulder product placement" }, ], }, { key: "hair", label: "发型", options: [ { value: "random", label: "随机", prompt: "hair style selected randomly for this request" }, { value: "short", label: "短发", prompt: "short tidy hair that does not cover the neck" }, { value: "shoulder_length", label: "齐肩发", prompt: "shoulder-length hair kept away from the neck placement area" }, { value: "ponytail", label: "马尾/束发", prompt: "ponytail or tied-back hair, neck and shoulders clearly visible" }, { value: "curly", label: "卷发", prompt: "curly hair controlled away from shoulder product placement area" }, { value: "buzz", label: "极短发", prompt: "very short hair, clean neck silhouette" }, { value: "business_neat", label: "利落商务", prompt: "neat business hairstyle, polished creator-ad look" }, ], }, { key: "mood", label: "气质场景", options: [ { value: "random", label: "随机", prompt: "commercial mood selected randomly for this request" }, { value: "energetic", label: "开场钩子", prompt: "energetic short-video hook performance" }, { value: "premium_calm", label: "高级克制", prompt: "premium calm product-ad presence" }, { value: "friendly_creator", label: "亲和达人", prompt: "friendly creator speaking-to-camera energy" }, { value: "wellness_pro", label: "健康专业", prompt: "wellness professional credibility without medical or hospital cues" }, { value: "urban_commute", label: "通勤疲惫", prompt: "urban commute and office fatigue context" }, { value: "home_relax", label: "居家放松", prompt: "home relaxation and stress-relief context" }, ], }, ] const DEFAULT_SUBJECT_PROFILE_DRAFT: SubjectProfileDraft = SUBJECT_PROFILE_CATEGORIES.reduce((acc, category) => { acc[category.key] = "random" return acc }, {} as SubjectProfileDraft) type ModelTraceSpec = { title: string model: string chain: string[] note?: string } 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 MAX_PRODUCT_REFS_PER_ENDPOINT = 2 const MAX_SUBJECT_REFS_PER_ENDPOINT = 5 const PRODUCT_BACKGROUND_LABELS: Record = { white: "白底", black: "黑底", simple: "纯色/简单", complex: "复杂背景", unknown: "背景未知", } const PRODUCT_USE_TAG_LABELS: Record = { hero_packshot: "主外观", wearing_scale: "佩戴比例", inner_contact: "触点", side_thickness: "厚度", asymmetry: "非对称", button_detail: "按键", back_bottom: "背底", material_texture: "材质", } const ROLE_LABELS_ZH: Record = { hook: "开场钩子", pain: "痛点推进", proof: "利益证明", solution: "方案过渡", cta: "转化收口", bridge: "节奏承接", } const ROLE_LABELS_EN: Record = { hook: "hook", pain: "pain build", proof: "benefit proof", solution: "solution transition", cta: "conversion close", bridge: "rhythm bridge", } const PRODUCT_VIEW_PROMPT_LABELS: Record = { front: "front / outer shell", left_45: "wearer's left 45-degree view", right_45: "wearer's right 45-degree view", side_thickness: "side thickness view", inner_contacts: "inner neck-contact pads", back_bottom: "back / bottom structure", } const PRODUCT_BACKGROUND_PROMPT_LABELS: Record = { white: "white background", black: "black background", simple: "simple solid background", complex: "complex background", unknown: "unknown background", } const PRODUCT_USE_TAG_PROMPT_LABELS: Record = { hero_packshot: "hero packshot", wearing_scale: "wearing scale", inner_contact: "inner contact pads", side_thickness: "side thickness", asymmetry: "left-right asymmetry", button_detail: "button detail", back_bottom: "back/bottom structure", material_texture: "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, skg_copy_en: "", skg_copy_zh: "", scene_one_line_en: "", scene_one_line_zh: "", action_one_line_en: "", action_one_line_zh: "", selected_video_id: "", 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-[#d6b36a]/34 text-[#f5d98e] bg-[#d6b36a]/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 containsCjk(text: string) { return /[\u3400-\u9fff]/.test(text) } type CompactStoryboardFieldKind = "copy" | "scene" | "action" const STORYBOARD_VIDEO_COUNT_OPTIONS = [1, 2, 4, 6, 8, 12] function storyboardFieldLabel(kind: CompactStoryboardFieldKind) { if (kind === "copy") return "文案" if (kind === "scene") return "场景一句话" return "人物 + 产品 + 动作" } function clampVideoCount(value: number) { return Math.round(clampNumber(Number.isFinite(value) ? value : 4, 1, 12)) } async function ensureEnglishForModel(text: string) { const trimmed = text.trim() if (!trimmed || !containsCjk(trimmed)) return trimmed try { return await translateText(trimmed, "en") } catch { return trimmed } } function subjectProfileOption(category: SubjectProfileCategory, value: string) { return category.options.find((option) => option.value === value) ?? category.options[0] } function randomSubjectProfileDraft(): SubjectProfileDraft { return SUBJECT_PROFILE_CATEGORIES.reduce((acc, category) => { const concrete = category.options.filter((option) => option.value !== "random") const picked = concrete[Math.floor(Math.random() * concrete.length)] ?? category.options[0] acc[category.key] = picked.value return acc }, {} as SubjectProfileDraft) } function resolveSubjectProfile( mode: SubjectProfileMode, draft: SubjectProfileDraft, options: { randomizeRandomValues?: boolean } = {}, ): ResolvedSubjectProfile { const values = { ...DEFAULT_SUBJECT_PROFILE_DRAFT } const labelParts: string[] = [] const promptParts: string[] = [] const promptLabelByKey: Record = { gender: "gender presentation", age: "age range", wardrobe: "wardrobe style", region_ethnicity: "regional or ethnic appearance cues", skin_tone: "skin tone", body: "body proportion", hair: "hair style", mood: "commercial mood", } for (const category of SUBJECT_PROFILE_CATEGORIES) { const rawValue = draft[category.key] || "random" let option = subjectProfileOption(category, rawValue) if (option.value === "random" && options.randomizeRandomValues) { const concrete = category.options.filter((item) => item.value !== "random") option = concrete[Math.floor(Math.random() * concrete.length)] ?? option } values[category.key] = option.value labelParts.push(`${category.label}:${option.label}`) promptParts.push(`${promptLabelByKey[category.key]}: ${option.prompt}`) } const summary = labelParts.join(" / ") const promptSummary = promptParts.join("; ") return { mode, values, summary, promptSummary, payload: { mode, gender: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[0], values.gender).prompt, age: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[1], values.age).prompt, wardrobe: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[2], values.wardrobe).prompt, region_ethnicity: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[3], values.region_ethnicity).prompt, skin_tone: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[4], values.skin_tone).prompt, body: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[5], values.body).prompt, hair: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[6], values.hair).prompt, mood: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[7], values.mood).prompt, resolved_summary: summary, prompt_summary: promptSummary, }, } } 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 { 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 waitForMediaEvent(target: HTMLMediaElement, eventName: string, timeoutMs = 12000) { return new Promise((resolve, reject) => { const timer = window.setTimeout(() => { cleanup() reject(new Error(`${eventName} timeout`)) }, timeoutMs) const cleanup = () => { window.clearTimeout(timer) target.removeEventListener(eventName, onReady) target.removeEventListener("error", onError) } const onReady = () => { cleanup() resolve() } const onError = () => { cleanup() reject(new Error("video load failed")) } target.addEventListener(eventName, onReady, { once: true }) target.addEventListener("error", onError, { once: true }) }) } function waitForVideoSeek(video: HTMLVideoElement, time: number) { return new Promise((resolve, reject) => { const timer = window.setTimeout(() => { cleanup() reject(new Error("seek timeout")) }, 8000) const cleanup = () => { window.clearTimeout(timer) video.removeEventListener("seeked", onSeeked) video.removeEventListener("error", onError) } const onSeeked = () => { cleanup() resolve() } const onError = () => { cleanup() reject(new Error("video seek failed")) } video.addEventListener("seeked", onSeeked, { once: true }) video.addEventListener("error", onError, { once: true }) video.currentTime = time }) } async function captureVideoFilmstrip( url: string, duration: number, step: FilmstripDensitySeconds, shouldCancel: () => boolean, ): Promise { if (!url || duration <= 0) return [] const video = document.createElement("video") video.muted = true video.playsInline = true video.preload = "auto" video.src = url video.load() if (video.readyState < 1) await waitForMediaEvent(video, "loadedmetadata") const sourceDuration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : duration const usableDuration = Math.max(Math.min(duration || sourceDuration, sourceDuration), 0.1) const times: number[] = [] for (let time = 0; time < usableDuration; time += step) { times.push(clampNumber(time + 0.08, 0, Math.max(usableDuration - 0.05, 0))) } if (!times.length) times.push(0.05) const canvas = document.createElement("canvas") canvas.width = 96 canvas.height = 170 const ctx = canvas.getContext("2d") if (!ctx) throw new Error("canvas unavailable") const frames: FilmstripPreviewFrame[] = [] for (const time of times) { if (shouldCancel()) break await waitForVideoSeek(video, time) if (shouldCancel()) break ctx.fillStyle = "#050505" ctx.fillRect(0, 0, canvas.width, canvas.height) const vw = video.videoWidth || canvas.width const vh = video.videoHeight || canvas.height const scale = Math.min(canvas.width / vw, canvas.height / vh) const dw = vw * scale const dh = vh * scale ctx.drawImage(video, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh) frames.push({ time: Number(time.toFixed(2)), src: canvas.toDataURL("image/jpeg", 0.68) }) } video.removeAttribute("src") video.load() return frames } 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 countSubjectAssetViews(job: Job | null) { if (!job) return 0 return job.frames.reduce((sum, frame) => sum + (frame.elements ?? []).reduce((inner, element) => inner + (element.subject_assets?.length ?? 0), 0), 0) } function countEndpointFramePairs(job: Job | null) { if (!job) return 0 return job.frames.filter((frame) => endpointAssetRef(frame, "first_frame") && endpointAssetRef(frame, "last_frame")).length } function stepStatus({ ready, running, blocked, paused }: { ready?: boolean; running?: boolean; blocked?: boolean; paused?: boolean }): WorkflowStepStatus { if (running) return "running" if (ready) return "ready" if (paused) return "paused" if (blocked) return "blocked" return "pending" } function buildWorkflowSteps({ job, submitting, audioReady, audioRunning, transcriptCount, visualReady, visualRunning, subjectAssetCount, productAssetCount, endpointFramePairCount, generatedVideoCount, }: { job: Job | null submitting: boolean audioReady: boolean audioRunning: boolean transcriptCount: number visualReady: boolean visualRunning: boolean subjectAssetCount: number productAssetCount: number endpointFramePairCount: number generatedVideoCount: number }): WorkflowStep[] { const hasSourceVideo = !!job?.video_url const downloading = !!job && ["created", "downloading"].includes(job.status) const storyboardReady = transcriptCount > 0 const endpointTargetCount = Math.max(transcriptCount, 0) return [ { id: "input", no: "01", title: "素材输入", detail: job ? `当前 ${shortId(job.id)}` : "待链接/上传", judge: "有当前素材任务即通过;输入框只负责创建或切换任务。", status: stepStatus({ ready: !!job, running: submitting }), }, { id: "source", no: "02", title: "源视频下载", detail: hasSourceVideo ? "源视频已就绪" : downloading ? "下载中" : "待下载", judge: "job.video_url 存在即通过;created/downloading 视为运行中。", status: stepStatus({ ready: hasSourceVideo, running: downloading, blocked: !job }), }, { id: "audio", no: "03", title: "音频文案", detail: audioReady ? `${transcriptCount} 段字幕` : "待转写/分析", judge: "audio_script.source_text 有内容,或 transcript 逐句时间轴有内容即通过。", status: stepStatus({ ready: audioReady, running: audioRunning, blocked: !hasSourceVideo }), }, { id: "visual", no: "04", title: "抽帧参考", detail: visualReady ? `${job?.frames.length ?? 0} 张参考帧` : "待抽帧", judge: "job.frames.length 大于 0 即通过;这些帧只做主体重构证据。", status: stepStatus({ ready: visualReady, running: visualRunning, blocked: !hasSourceVideo }), }, { id: "subject", no: "05", title: "相似主体", detail: subjectAssetCount ? `${subjectAssetCount} 张白底视图` : "待生成主体", judge: "关键帧里存在 subject_assets 即通过;生成的是类似创新主体,不复刻原人。", status: stepStatus({ ready: subjectAssetCount > 0, blocked: !visualReady }), }, { id: "product", no: "06", title: "产品素材池", detail: productAssetCount ? `${productAssetCount} 张产品图` : "待上传/识别", judge: "product_refs 有记录即通过;不限量,但每条视频后续最多挑 6 张相关图。", status: stepStatus({ ready: productAssetCount > 0, blocked: !job }), }, { id: "script", no: "07", title: "分镜文案", detail: storyboardReady ? `${transcriptCount} 条分镜` : "待音频时间轴", judge: "逐句时间轴生成后进入分镜;新口播可按单段或整片改写。", status: stepStatus({ ready: storyboardReady, running: audioRunning, blocked: !audioReady }), }, { id: "scene", no: "08", title: "三字段规划", detail: storyboardReady ? `${transcriptCount} 条紧凑分镜` : "待分镜", judge: "客户默认只看文案、场景一句话、人物+产品+动作;首尾帧藏在高级模式和后端内部。", status: stepStatus({ ready: storyboardReady, blocked: !storyboardReady }), }, { id: "video", no: "09", title: "视频候选", detail: generatedVideoCount ? `${generatedVideoCount} 条候选` : "可生成 4 条", judge: "单条默认生成 4 条视频候选;整片一键批量生成后台提交,失败行可单独重试。", status: generatedVideoCount > 0 ? "ready" : stepStatus({ ready: false, blocked: !storyboardReady }), }, ] } function workflowStepMap(steps: WorkflowStep[]) { return steps.reduce((acc, step) => { acc[step.id] = step return acc }, {} as Record) } 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 zh = element.name_zh || "" const en = (element.name_en || "").toLowerCase() const combined = `${zh} ${en}`.toLowerCase() const zhSimilarSubject = zh.includes("相似") && (zh.includes("主体") || zh.includes("主角") || zh.includes("人物")) const enSimilarSubject = en.includes("similar") && (en.includes("subject") || en.includes("actor") || en.includes("humanoid") || en.includes("character")) return ( zhSimilarSubject || enSimilarSubject || combined.includes("相似主角") || combined.includes("相似主体") || combined.includes("similar ad actor") || combined.includes("similar actor") || combined.includes("similar subject") || combined.includes("transparent skeleton humanoid subject") ) } function findSimilarActorSource(preferredFrames: KeyFrame[], allFrames: KeyFrame[]) { const pools = [preferredFrames, allFrames] const seen = new Set() for (const needsAssets of [true, false]) { for (const pool of pools) { const frames = [...pool].filter((frame) => { if (seen.has(frame.index)) return false seen.add(frame.index) return true }).reverse() for (const frame of frames) { const elements = [...(frame.elements || [])].reverse() const element = elements.find((item) => isSimilarActorElement(item) && (!needsAssets || !!item.subject_assets?.length)) if (element) return { frame, element } } } seen.clear() } return null } type SubjectTemplatePromptSource = { name: string; sourceLabel: string } | null function buildSimilarSubjectPrompt( subjectStyle: SubjectStyleMode, direction: string, selectedTemplate?: SubjectTemplatePromptSource, subjectProfile?: ResolvedSubjectProfile | null, ) { const base = [ "Create a new similar but non-identical information-feed ad subject from the selected reference frames.", "Treat all selected frames as evidence for ONE same subject, not multiple different subjects.", "Lock one consistent character bible before generating: same gender presentation, age range, body proportions, head shape, material, silhouette, commercial style, and visual identity across the full multi-view set.", "If the user direction asks to change gender, age, or style, apply that single change uniformly to every view; never mix male/female, young/old, or multiple style identities inside one set.", "Keep the pose vocabulary, camera-readability, creator-ad energy, and commercial clarity, but do not copy the exact source identity, face, watermark, captions, platform UI, or pixels.", "This is for SKG neck-and-shoulder wearable massage device videos: keep neck, collarbone, shoulders, side neck, upper back, shoulder blades, and product placement area clean and visible.", "Output high-definition assets suitable for downstream video generation.", ] if (selectedTemplate) { base.push( `Creative subject template selected: ${selectedTemplate.name} (${selectedTemplate.sourceLabel}).`, "Use the template images as planned creative direction only; generate an innovative variation, not a duplicate of that subject pack.", ) } if (subjectProfile?.promptSummary) { base.push( `Locked subject casting and styling profile for this request: ${subjectProfile.promptSummary}.`, "Apply this one profile uniformly to every generated view; do not randomize gender, age, region, skin tone, body type, hair, wardrobe, or mood differently between views.", ) } if (subjectStyle === "transparent_human") { base.push( "The subject must be a transparent humanoid: transparent or translucent skin/body shell wrapping a clean visible white skeleton inside the same body.", "Keep transparent skin, visible spine, rib cage, pelvis, arm bones, leg bones, and a friendly non-horror wellness advertising look consistent in every view.", "Do not generate a normal opaque human, skeleton-only character, medical anatomy, organs, blood, gore, surgery, hospital, or horror imagery.", ) } else { base.push( "The subject must be a normal believable commercial ad actor, not a transparent or skeleton character.", "Keep wardrobe category, age range, gender presentation, body proportion, and creator-ad styling consistent in every view.", ) } const trimmed = direction.trim() if (trimmed) base.push(`User unified subject direction: ${trimmed}`) base.push("Output separate pure white background multi-view assets; each image is one view of the same unified subject.") return base.join(" ") } function subjectAssetUrl(job: Job, asset: SubjectAsset) { return apiAssetUrl(asset.url) || resolveImageRefUrl(job.id, { kind: "asset", frame_idx: 0, element_id: asset.id }) } function characterPreviewImage(character?: { primary_image?: string; images?: Array<{ id: string; view?: string; filename: string; label?: string }> } | null) { if (!character?.images?.length) return null return character.images.find((image) => image.id === character.primary_image) ?? character.images.find((image) => image.view === "front") ?? character.images[0] } function modelValue(value?: string) { return value?.trim() || "待配置" } function modelList(values: Array) { return values.map(modelValue).filter((value, index, list) => value && list.indexOf(value) === index).join(" / ") } function imageModelChain(models?: RuntimeModels) { return modelList([models?.image || "gpt-image-2"]) } function subjectImageModelChain(models?: RuntimeModels) { return modelList([models?.subject_image || "gpt-image-2"]) } function resolveVideoModelLabel(models: RuntimeModels | undefined, model: string) { const concrete = models?.video_aliases?.[model] || (model === models?.video ? models.video : "") return concrete && concrete !== model ? `${model} -> ${concrete}` : modelValue(concrete || model) } function audioModelTrace(models?: RuntimeModels): ModelTraceSpec { const remoteState = models?.asr_remote_enabled === false ? "已关闭" : "启用" const localState = models?.asr_local_fallback_enabled === false ? "关闭" : "启用" const localModel = models?.faster_whisper ? `faster-whisper ${models.faster_whisper}` : modelValue(models?.local_asr) return { title: "音频解析", model: modelList([models?.asr, models?.translate, models?.asr_fallback]), chain: [ `ASR 转写:远端 ${remoteState},模型 ${modelValue(models?.asr)}${models?.asr_language ? `,语言 ${models.asr_language}` : ""};本机转写 ${localState},使用 ${localModel};多模态兜底${models?.asr_audio_fallback_enabled === false ? "关闭" : `为 ${modelValue(models?.asr_fallback)}`},并拒绝假字幕/重复时间轴`, `字幕翻译:${modelValue(models?.translate)} 按 ASR 段落输出中文;失败时保留原文时间轴,中文可为空`, `讲话人 / 节奏 / 背景音:${modelValue(models?.asr_fallback)} 读取 audio.wav + 转写时间轴做多模态分析;失败时用本地时长/段落估算兜底`, ], note: "点击“解析音频”后触发;开始任务下载完成后也会自动走这条链路。", } } function productModelTrace(models?: RuntimeModels): ModelTraceSpec { return { title: "产品视角识别 / 补图", model: modelList([models?.product_view, models?.image]), chain: [ `批量视角识别:${modelValue(models?.product_view)} 多图读取同一产品素材,标注视角、佩戴者左右、上下、内外侧、用途和风险`, "识别兜底:批量失败会按单图重试;仍失败或文件缺失时写入本地默认视角,并在 risk/note 标明兜底原因", `缺角度补图:${imageModelChain(models)} 走 /images/edits,最多读取 6 张已上传参考图补齐缺失视角;失败保留重试入口,不自动换模型`, "前端只保存标注和 AI 补图结果;后续首尾帧/视频规划每条最多挑 6 张相关产品图", ], note: "上传产品图、重新识别、缺视角重试都会使用这组模型链路。", } } function similarSubjectModelTrace(models: RuntimeModels | undefined, subjectStyle: SubjectStyleMode): ModelTraceSpec { return { title: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似普通真人主体", model: modelList([models?.vision, models?.subject_image]), chain: [ `视觉 brief:${modelValue(models?.vision)} 把关键帧/模板图转成非身份化文字 brief;失败时继续用用户方向和模板文字`, `主体类型:${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`, "主体设定:前端把随机组合或手动选择的性别、年龄、着装、地域人种、肤色、体型、发型和气质锁定为结构化 profile", `图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`, "身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致", ], note: "这是生成类似但创新的主体,不是复制、抠出或复刻源视频人物身份;内置形象也只作为方向参考。", } } function scriptRewriteModelTrace(models?: RuntimeModels): ModelTraceSpec { return { title: "新口播文案改写", model: modelList([models?.audio_rewrite, models?.asr_fallback, models?.translate]), chain: [ `主改写:${modelValue(models?.audio_rewrite)} 根据原文案、当前分镜、作者想法生成新口播`, `模型回退:依次尝试 ${modelValue(models?.asr_fallback)} 和 ${modelValue(models?.translate)};全部失败时用本地模板保留分镜可编辑`, "返回结果只写入当前分镜文案编辑框;点击保存规划后才写入 frame.storyboard.action", ], } } function videoModelTrace(models: RuntimeModels | undefined, model: string): ModelTraceSpec { return { title: "视频生成", model: resolveVideoModelLabel(models, model), chain: [ `前端选择:${model}`, `后端解析:${resolveVideoModelLabel(models, model)}`, `服务商:${modelValue(models?.video_provider)} · ${modelValue(models?.video_base_url)}`, "当前主工作台暂停直接提交视频;旧入口误触也会被页面层保护", "开放后输入会包含已确认首尾帧、当前分镜文案、产品素材、相似主体资产和画面规划", "输出为异步候选视频,完成后回填到对应分镜行;Sora 已停用", ], } } function buildFallbackScene(job: Job, frame: KeyFrame, order: number): StoryboardScene { const frames = [...job.frames].sort((a, b) => a.timestamp - b.timestamp) 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(" ") || "Rewrite the original audio pacing into a new SKG product introduction." const objects = frame.description?.objects?.slice(0, 5).map((item) => item.name).filter(Boolean).join("、") return { duration: Number(duration.toFixed(1)), first_image: null, last_image: null, subject: objects ? `Key element candidates: ${objects}` : "Keep the source video's most important subject motion and composition relationship.", scene: `${frame.description?.scene || `Plan SKG information-feed ad scene ${order + 1} from the audio segment.`}\nAudio pacing reference: ${audio.slice(0, 220)}`, product: "Convert the source product or pain-point context into SKG neck-and-shoulder massager expression. Use the uploaded SKG product angles as product truth.", action: frame.description?.style ? `Keep the source speaking rhythm, action beats, and ${frame.description.style}; show tension before use and relaxed comfort after use.` : "Keep the source speaking rhythm and action beats; show tension before use and relaxed comfort after use.", reference_ids: [], } } function classifyAudioRole(text: string, index: number, total: number): AudioStoryboardRole { const lower = text.toLowerCase() if (index === 0) return "hook" if (index >= total - 2 || /discount|code|shipping|link|limited|sold out|grab|recommend|tiktok/.test(lower)) return "cta" if (/can't|dont|don't|if |when |tired|stress|pain|crave|bloated|puffy|ready/.test(lower)) return "pain" if (/help|can |reduce|improve|relax|lower|stabilize|clear|less/.test(lower)) return "proof" if (/use|try|apple|product|bottle|one month/.test(lower)) return "solution" return "bridge" } function buildSkgCopy(role: AudioStoryboardRole, index: number) { const variants: Record = { hook: [ "If you spend hours looking down at your phone or working at a desk, your neck and shoulders may already be carrying that tension.", "A few hours on screens can make your neck and shoulders feel tired faster than you expect.", ], pain: [ "That tight neck, heavy shoulder feeling, and uncomfortable head lift are signs you should not wait to deal with it.", "Commuting, desk work, parenting, and phone scrolling can keep your neck and shoulders tense all day.", ], proof: [ "The SKG neck-and-shoulder massager sits around the back of your neck and shoulders, bringing warmth and kneading-like comfort right where you feel tight.", "Wear it hands-free between work, at home, or before bed to settle into a calmer relaxation rhythm.", ], solution: [ "This beat turns the source explanation into a clear SKG routine: pick it up, wear it, adjust the fit, and relax.", "Let the product enter naturally, and show the change from neck tension to a more relaxed state.", ], cta: [ "If you want neck-and-shoulder relaxation to become part of your daily routine, this SKG massager is an easy place to start.", "Close with a clear product detail and a relaxed expression so viewers know exactly what to try next.", ], bridge: [ "Keep the source video's short, fast rhythm, but anchor each line in a specific neck-and-shoulder moment or product action.", "Use this line as a bridge from the pain point into the SKG routine without slowing the pace.", ], } const list = variants[role] ?? variants.bridge return list[index % list.length] } function buildSkgCopyZh(role: AudioStoryboardRole, index: number) { const variants: Record = { hook: [ "如果你也经常低头刷手机、久坐办公,肩颈紧绷可能已经在悄悄影响状态。", "每天盯屏几个小时,脖子和肩膀的疲惫会比你想得更早出现。", ], pain: [ "脖子发紧、肩膀沉、抬头不舒服,不一定要等到很难受才处理。", "通勤、办公、带娃、刷手机叠在一起,肩颈很容易一直处在紧绷状态。", ], proof: [ "SKG 颈部按摩仪贴合后颈和肩颈两侧,把热敷感和揉按感带到真正紧的位置。", "戴上后不用占手,工作间隙、居家放松、睡前都能快速进入舒缓节奏。", ], solution: [ "这一镜把原片的讲解节奏换成 SKG 使用步骤:拿起、佩戴、贴合、放松。", "让产品自然进入画面,重点不是硬推,而是把肩颈紧绷到放松的变化拍清楚。", ], cta: [ "如果你也想把肩颈放松变成日常习惯,可以先从这台 SKG 开始。", "最后用清晰产品特写和轻松状态收住,让用户知道现在就可以入手。", ], bridge: [ "延续原片短句快节奏,把每一句都落到一个具体肩颈场景或产品动作。", "这一句作为过渡,画面从痛点切到产品,让节奏继续往下走。", ], } const list = variants[role] ?? variants.bridge return list[index % list.length] } function buildVisualPlan(role: AudioStoryboardRole) { if (role === "hook") return "Vertical close-up creator opening. The subject gently rubs the neck or rotates the shoulders to establish fatigue immediately." if (role === "pain") return "Keep the source expression, gesture rhythm, and fast pacing while emphasizing phone posture, desk sitting, and neck-and-shoulder tension." if (role === "proof") return "Bring the product into frame and place it around the back of the neck, then cut to fit, button, warmth, and kneading-comfort details." if (role === "cta") return "End with a clean product detail plus a relaxed expression, keeping the quick action feeling of a feed ad." return "Keep the source-style composition and camera movement, but replace the content with an SKG neck-and-shoulder relaxation scene." } function buildVisualPlanZh(role: AudioStoryboardRole) { if (role === "hook") return "竖屏近景口播开场,人物轻揉脖子或转动肩颈,直接建立疲惫感。" if (role === "pain") return "沿用原视频的表情、手势和节奏,画面强调低头、久坐、肩颈紧绷。" if (role === "proof") return "产品进入画面并佩戴到后颈,切到肩颈贴合、按键、热敷/揉按感的细节。" if (role === "cta") return "产品清晰特写 + 人物放松表情收尾,保留信息流广告的快速行动感。" return "保持原片同类构图和运镜,把画面内容替换成 SKG 肩颈放松场景。" } function visualModeDefaults(mode: StoryboardVisualMode, language: "en" | "zh" = "en") { if (mode === "person_only") { return { needsProduct: false, needsSubject: true, productPlacement: language === "zh" ? "本条不出现产品,只用人物状态、痛点或口播承接节奏;不要硬插 SKG 产品。" : "Do not show the product in this beat. Use the subject's state, pain point, or voice-over performance to carry the rhythm; do not force in the SKG product.", } } if (mode === "product_only") { return { needsProduct: true, needsSubject: false, productPlacement: language === "zh" ? "只展示 SKG 肩颈按摩仪本体、佩戴角度或功能细节;不要强行加入人物。" : "Show only the SKG neck-and-shoulder massager, wearing angle, or functional detail; do not force a main character into this beat.", } } if (mode === "environment") { return { needsProduct: false, needsSubject: false, productPlacement: language === "zh" ? "本条作为场景/情绪/节奏过渡,不出现产品和人物主体;只保留空间、光线和运动节奏。" : "Use this beat as a scene, mood, or pacing transition. Do not show the product or main subject; keep only space, light, and motion rhythm.", } } return { needsProduct: true, needsSubject: true, productPlacement: language === "zh" ? "SKG 肩颈按摩仪作为外置佩戴产品出现,围绕拿起、佩戴、调整、按键或放松状态展开。" : "Show the SKG neck-and-shoulder massager as an external wearable product, built around picking it up, wearing it, adjusting it, pressing controls, or relaxing with it.", } } function visualModeForRole(role: AudioStoryboardRole): StoryboardVisualMode { if (role === "hook" || role === "pain") return "person_only" if (role === "cta") return "product_only" if (role === "bridge") return "environment" return "person_product" } function buildFirstFramePlan(role: AudioStoryboardRole) { if (role === "hook") return "Close-up subject looking at camera or working with head down, one hand lightly touching the back of the neck, with no product visible yet." if (role === "pain") return "Preserve the source action rhythm while making neck tension, looking down, neck rubbing, or desk-sitting posture clear." if (role === "proof") return "The subject picks up or prepares to wear the SKG neck-and-shoulder massager; product position is clear but the action has just started." if (role === "solution") return "Move from the pain state into picking up the product or bringing it toward the neck and shoulders, ready to begin use." if (role === "cta") return "Clean product close-up or stable worn-product frame, leaving a strong visual focus for the conversion close." return "Start from the current source sentence's composition to carry the rhythm without forcing a subject change." } function buildFirstFramePlanZh(role: AudioStoryboardRole) { if (role === "hook") return "人物近景看向镜头或低头办公,手轻扶后颈,画面先不露产品。" if (role === "pain") return "保留原片人物动作节奏,肩颈紧绷、低头、揉脖子或久坐状态明确。" if (role === "proof") return "人物拿起或准备佩戴 SKG 肩颈按摩仪,产品位置清晰但动作刚开始。" if (role === "solution") return "人物从痛点状态切到拿起产品/靠近肩颈,准备进入使用动作。" if (role === "cta") return "产品干净特写或佩戴完成后的稳定画面,留出转化收口的视觉焦点。" return "按原视频当前句的构图启动,先承接节奏,不强行改变镜头主体。" } function buildLastFramePlan(role: AudioStoryboardRole) { if (role === "hook") return "The subject lifts the head or becomes more focused, leaving room for the product or solution to enter in the next beat." if (role === "pain") return "Amplify the tense state into a clear stopping point, ready to cut into the product solution." if (role === "proof") return "The product is correctly worn around the back of the neck and shoulders, the subject looks more relaxed, and product scale is stable." if (role === "solution") return "The product fits against the neck and shoulders, hand adjustment is complete, and the frame can move into functional detail or relaxation." if (role === "cta") return "Hold a stable product or worn-product frame with clean composition, ready for purchase or action-call continuation." return "Advance the action slightly and hold a stable endpoint that connects naturally to the next sentence." } function buildLastFramePlanZh(role: AudioStoryboardRole) { if (role === "hook") return "人物抬头或表情更集中,给下一镜产品或方案进入留出空间。" if (role === "pain") return "紧绷状态被放大到一个明确停点,准备切入产品解决方案。" if (role === "proof") return "产品已正确佩戴在后颈/肩颈位置,人物放松,产品比例稳定。" if (role === "solution") return "产品贴合肩颈,手部调整完成,画面自然进入功能细节或放松状态。" if (role === "cta") return "产品或佩戴状态稳定收住,画面干净,适合后续接购买/行动号召。" return "动作小幅推进并稳定停住,保留与下一句衔接的方向感。" } function buildSubjectDescription(role: AudioStoryboardRole, visualMode: StoryboardVisualMode) { if (visualMode === "product_only" || visualMode === "environment") return "" const base = "Consistent similar subject: a friendly transparent or semi-transparent humanoid with visible clean white skeleton inside, commercial not horror, with neck, collarbone, and upper-back areas clear for wearing a neck-and-shoulder massager." if (role === "hook") return `${base} Front or upper-body creator speaking state, with a pain-point or curious expression that grabs attention quickly.` if (role === "pain") return `${base} Neck-and-shoulder tension, looking down, desk posture, or rubbing the neck; make the neck line, shoulders, and upper back readable.` if (role === "proof") return `${base} Relaxed state while wearing or about to wear the product, prioritizing neck-and-shoulder close-up, side, and back-neck angles.` if (role === "solution") return `${base} Hands adjust the product or show wearable fit naturally; product placement must not hide important anatomy or device structure.` if (role === "cta") return `${base} Stable, relaxed, clean ending state using front, three-quarter, or stable worn-product framing.` return `${base} Keep one consistent subject identity, material, body type, gender presentation, and commercial mood across the whole video.` } function buildSubjectDescriptionZh(role: AudioStoryboardRole, visualMode: StoryboardVisualMode) { if (visualMode === "product_only" || visualMode === "environment") return "" const base = "统一相似主体:透明或半透明皮肤包裹可见白色骨架的人形,广告感、非恐怖、肩颈/锁骨/上背区域清晰,适合佩戴肩颈按摩仪。" if (role === "hook") return `${base} 正面或半身口播状态,表情有痛点或好奇感,能快速抓住注意。` if (role === "pain") return `${base} 肩颈紧绷、低头久坐或按揉脖子的状态,重点看清脖子、肩线和上背。` if (role === "proof") return `${base} 产品佩戴或即将佩戴的放松状态,优先肩颈近景、侧面和后颈肩背角度。` if (role === "solution") return `${base} 手部调整产品或展示佩戴贴合感,人物姿态自然,产品位置不能挡住关键结构。` if (role === "cta") return `${base} 状态稳定、放松、干净收尾,可用正面/三分之二视角或产品佩戴后的稳定状态。` return `${base} 保持与整片一致的主体身份、材质、体型、性别表现和广告气质。` } function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] { if (!job?.transcript.length) return [] return job.transcript.map((segment, index) => { const source = segment.en?.trim() || segment.zh?.trim() || "Source audio script pending." const sourceZh = segment.zh?.trim() || segment.en?.trim() || "原音频文案待补充" const role = classifyAudioRole(`${segment.en} ${segment.zh}`, index, job.transcript.length) const visualMode = visualModeForRole(role) const defaults = visualModeDefaults(visualMode) const defaultsZh = visualModeDefaults(visualMode, "zh") const keyElements = role === "proof" ? "wearing action, product position, hand pressing the control, relaxed expression" : "creator framing, subject gesture, facial rhythm, scene lighting" const keyElementsZh = role === "proof" ? "佩戴动作、产品位置、手部按键、放松表情" : "口播构图、人物动作、表情节奏、场景光线" return { index: segment.index, start: segment.start, end: segment.end, source, sourceZh, role, visualMode, needsProduct: defaults.needsProduct, needsSubject: defaults.needsSubject, subjectDescription: buildSubjectDescription(role, visualMode), subjectDescriptionZh: buildSubjectDescriptionZh(role, visualMode), skgCopy: buildSkgCopy(role, index), skgCopyZh: buildSkgCopyZh(role, index), sceneOneLine: buildVisualPlan(role), sceneOneLineZh: buildVisualPlanZh(role), actionOneLine: `${buildSubjectDescription(role, visualMode) || "Product-forward SKG short-video beat."} ${defaults.productPlacement}`, actionOneLineZh: `${buildSubjectDescriptionZh(role, visualMode) || "以 SKG 产品为主的短视频镜头。"}${defaultsZh.productPlacement ? ` ${defaultsZh.productPlacement}` : ""}`, visualPlan: buildVisualPlan(role), visualPlanZh: buildVisualPlanZh(role), firstFramePlan: buildFirstFramePlan(role), firstFramePlanZh: buildFirstFramePlanZh(role), lastFramePlan: buildLastFramePlan(role), lastFramePlanZh: buildLastFramePlanZh(role), referencePlan: `Extract 1-2 targeted reference frames from source video ${segment.start.toFixed(1)}-${segment.end.toFixed(1)}s.`, keyElements, keyElementsZh, productIntegration: "Replace the source product or prop context with the SKG white U-shaped neck-and-shoulder massager. The product must be worn externally around the neck and shoulders.", productIntegrationZh: "把原片产品/道具语境替换为 SKG 白色 U 形颈部按摩仪,产品必须外置佩戴在肩颈位置。", productPlacement: defaults.productPlacement, productPlacementZh: defaultsZh.productPlacement, } }) } 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 = { 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 = { 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, } } const PRODUCT_ANGLE_REFERENCE_PRIORITY: Record = { front: ["front", "left_45", "right_45", "side_thickness", "inner_contacts", "back_bottom"], left_45: ["left_45", "front", "side_thickness", "right_45", "inner_contacts", "back_bottom"], right_45: ["right_45", "front", "side_thickness", "left_45", "inner_contacts", "back_bottom"], side_thickness: ["side_thickness", "left_45", "right_45", "front", "inner_contacts", "back_bottom"], inner_contacts: ["inner_contacts", "side_thickness", "front", "left_45", "right_45", "back_bottom"], back_bottom: ["back_bottom", "side_thickness", "inner_contacts", "left_45", "right_45", "front"], } function productAngleReferenceScore(item: ProductRefItem, targetView: string) { const priority = PRODUCT_ANGLE_REFERENCE_PRIORITY[targetView] ?? PRODUCT_VIEW_SLOTS.map((slot) => slot.value) const rank = priority.indexOf(item.view) let score = rank === -1 ? 0 : 90 - rank * 12 if (item.source === "upload" || item.source === "library") score += 28 if (item.source === "ai") score -= 18 if (item.confidence) score += Math.round(item.confidence * 14) if (item.useTags.includes("asymmetry")) score += 8 if (targetView === "side_thickness" && item.useTags.includes("side_thickness")) score += 16 if (targetView === "inner_contacts" && item.useTags.includes("inner_contact")) score += 16 if (targetView === "back_bottom" && item.useTags.includes("back_bottom")) score += 16 if (item.risk) score -= 10 return score } function selectProductAngleReferenceItems(items: ProductRefItem[], targetView: string) { const unique = new Map() for (const item of items) { if (!unique.has(item.id)) unique.set(item.id, item) } return [...unique.values()] .sort((a, b) => productAngleReferenceScore(b, targetView) - productAngleReferenceScore(a, targetView)) .slice(0, 6) } function productAngleSourceNotes(items: ProductRefItem[]) { return items.map((item, index) => { const parts = [ `ref${index + 1}`, `view=${productViewLabel(item.view)}`, `source=${item.source}`, item.note ? `note=${item.note}` : "", formatProductOrientation(item.orientation), item.landmarks?.length ? `landmarks=${item.landmarks.join("/")}` : "", item.risk ? `risk=${item.risk}` : "", ].filter(Boolean) return parts.join(";") }) } 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_PROMPT_LABELS[tag] ?? tag).filter(Boolean).join(", ") const orientation = formatProductOrientation(item.orientation) const direction = orientation ? `; orientation: ${orientation}` : "" const landmarks = item.landmarks.length ? `; structural landmarks: ${item.landmarks.join(", ")}` : "" const risk = item.risk ? `; risk: ${item.risk}` : "" return `${index + 1}. ${PRODUCT_VIEW_PROMPT_LABELS[item.view] ?? item.view} | ${PRODUCT_BACKGROUND_PROMPT_LABELS[item.background] ?? item.background} | ${tags || "general product reference"}: ${item.note || "no extra note"}${direction}${landmarks}${risk}` }) .join("; ") } function savedScenePatch(scene?: StoryboardScene | null): RowPlanPatch { if (!scene) return {} return { visualMode: scene.visual_mode, needsProduct: scene.needs_product, needsSubject: scene.needs_subject, skgCopy: scene.skg_copy_en, skgCopyZh: scene.skg_copy_zh, sceneOneLine: scene.scene_one_line_en, sceneOneLineZh: scene.scene_one_line_zh, actionOneLine: scene.action_one_line_en, actionOneLineZh: scene.action_one_line_zh, subjectDescription: scene.subject?.split("\n").find((line) => line.trim() && !line.startsWith("Subject source") && !line.startsWith("No main subject") && !line.startsWith("主体真源") && !line.startsWith("本条不需要"))?.trim(), visualPlan: scene.scene?.split("\n").find((line) => line.trim() && !line.startsWith("Visual mode") && !line.startsWith("First-frame plan") && !line.startsWith("Last-frame plan") && !line.startsWith("Source audio reference") && !line.startsWith("镜头类型") && !line.startsWith("首帧规划") && !line.startsWith("尾帧规划") && !line.startsWith("原音频依据"))?.trim(), firstFramePlan: scene.first_frame_plan, lastFramePlan: scene.last_frame_plan, productIntegration: scene.product?.split("\n").find((line) => line.trim() && !line.startsWith("Product requirement") && !line.startsWith("Product placement") && !line.startsWith("Product reference pool") && !line.startsWith("No product") && !line.startsWith("This beat") && !line.startsWith("产品需求") && !line.startsWith("产品出现方式") && !line.startsWith("产品素材池") && !line.startsWith("未上传产品图") && !line.startsWith("本条规划"))?.trim(), productPlacement: scene.product_placement, } } function storyboardSceneBelongsToRow(scene: StoryboardScene | null | undefined, rowIndex: number, legacyRowIndex?: number | null) { if (!scene) return false if (typeof scene.storyboard_row_idx === "number") return scene.storyboard_row_idx === rowIndex return legacyRowIndex != null && legacyRowIndex === rowIndex } function applyPlanPatch(row: AudioStoryboardRow, patch?: RowPlanPatch): AudioStoryboardRow { if (!patch) return row return { ...row, visualMode: patch.visualMode ?? row.visualMode, needsProduct: patch.needsProduct ?? row.needsProduct, needsSubject: patch.needsSubject ?? row.needsSubject, skgCopy: patch.skgCopy ?? row.skgCopy, skgCopyZh: patch.skgCopyZh ?? row.skgCopyZh, sceneOneLine: patch.sceneOneLine ?? row.sceneOneLine, sceneOneLineZh: patch.sceneOneLineZh ?? row.sceneOneLineZh, actionOneLine: patch.actionOneLine ?? row.actionOneLine, actionOneLineZh: patch.actionOneLineZh ?? row.actionOneLineZh, subjectDescription: patch.subjectDescription ?? row.subjectDescription, subjectDescriptionZh: patch.subjectDescriptionZh ?? row.subjectDescriptionZh, visualPlan: patch.visualPlan ?? row.visualPlan, visualPlanZh: patch.visualPlanZh ?? row.visualPlanZh, firstFramePlan: patch.firstFramePlan ?? row.firstFramePlan, firstFramePlanZh: patch.firstFramePlanZh ?? row.firstFramePlanZh, lastFramePlan: patch.lastFramePlan ?? row.lastFramePlan, lastFramePlanZh: patch.lastFramePlanZh ?? row.lastFramePlanZh, productIntegration: patch.productIntegration ?? row.productIntegration, productIntegrationZh: patch.productIntegrationZh ?? row.productIntegrationZh, productPlacement: patch.productPlacement ?? row.productPlacement, productPlacementZh: patch.productPlacementZh ?? row.productPlacementZh, } } function productPriorityForRow(row: AudioStoryboardRow) { const viewPriorityByRole: Record = { hook: ["front", "left_45", "right_45", "side_thickness"], pain: ["front", "side_thickness", "left_45", "right_45"], proof: ["inner_contacts", "side_thickness", "front", "left_45", "right_45", "back_bottom"], solution: ["front", "left_45", "right_45", "inner_contacts", "side_thickness"], cta: ["front", "back_bottom", "left_45", "right_45", "inner_contacts"], bridge: ["front", "left_45", "right_45", "side_thickness"], } const tagPriorityByRole: Record = { hook: ["hero_packshot", "asymmetry", "side_thickness"], pain: ["wearing_scale", "side_thickness", "hero_packshot"], proof: ["inner_contact", "wearing_scale", "button_detail", "side_thickness"], solution: ["wearing_scale", "hero_packshot", "inner_contact"], cta: ["hero_packshot", "back_bottom", "asymmetry", "material_texture"], bridge: ["hero_packshot", "asymmetry", "side_thickness"], } return { views: viewPriorityByRole[row.role] ?? viewPriorityByRole.bridge, tags: tagPriorityByRole[row.role] ?? tagPriorityByRole.bridge, } } function endpointProductPriority(row: AudioStoryboardRow, role?: "first_frame" | "last_frame") { const text = `${row.role} ${row.visualMode} ${row.visualPlan} ${row.firstFramePlan} ${row.lastFramePlan} ${row.productIntegration} ${row.productPlacement} ${role ?? ""}`.toLowerCase() const views = ["front"] const tags = ["hero_packshot", "wearing_scale"] const add = (view: string, tag?: string) => { if (!views.includes(view)) views.push(view) if (tag && !tags.includes(tag)) tags.push(tag) } if (/back neck|neck back|upper back|back view|back side|shoulder blade|last frame|worn|wearing complete|fit complete|后颈|肩背|背面|背部|后背|上背|尾帧|佩戴完成|贴合完成/.test(text)) add("back_bottom", "back_bottom") if (/side|profile|thickness|volume|left side|right side|45|adjust|pick up|bring.*neck|toward.*shoulder|侧面|侧身|厚度|侧厚|体积|左侧|右侧|调整|拿起|靠近肩颈/.test(text)) add("side_thickness", "side_thickness") if (/inner|contact pad|massage head|touching skin|neck contact|skin contact|内侧|触点|按摩头|贴颈|接触|皮肤接触/.test(text)) add("inner_contacts", "inner_contact") if (/wearing scale|upper body|worn on human|neck|shoulder|collarbone|佩戴比例|上身|真人佩戴|脖子|肩颈|锁骨/.test(text)) add("left_45", "wearing_scale") if (/button|control|switch|logo|按键|按钮|控制|开关/.test(text)) add("right_45", "button_detail") return { views, tags } } function endpointProductMaxForRow(row: AudioStoryboardRow, role?: "first_frame" | "last_frame") { const text = `${row.visualPlan} ${row.firstFramePlan} ${row.lastFramePlan} ${row.productIntegration} ${row.productPlacement} ${role ?? ""}`.toLowerCase() return /side|profile|thickness|back neck|upper back|back view|inner|contact pad|massage head|neck contact|close-up|closeup|button|control|worn|wearing complete|侧面|侧身|厚度|侧厚|后颈|肩背|背面|背部|内侧|触点|按摩头|贴颈|特写|近景|按键|按钮|佩戴完成|上背/.test(text) ? MAX_PRODUCT_REFS_PER_ENDPOINT : 1 } function scoreProductItem(row: AudioStoryboardRow, item: ProductRefItem, index: number, priority: { views: string[]; tags: string[] }) { 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[], mode: "video" | "endpoint" = "video", role?: "first_frame" | "last_frame", ) { if (!items.length) return [] const picked: ProductRefItem[] = [] const pickedIds = new Set() const maxItems = mode === "endpoint" ? endpointProductMaxForRow(row, role) : MAX_PRODUCT_REFS_PER_VIDEO const priority = mode === "endpoint" ? endpointProductPriority(row, role) : productPriorityForRow(row) const add = (item?: ProductRefItem) => { if (!item || pickedIds.has(item.id) || picked.length >= maxItems) return picked.push(item) pickedIds.add(item.id) } for (const view of priority.views) { const matches = items .map((item, index) => ({ item, score: scoreProductItem(row, item, index, priority) })) .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: scoreProductItem(row, item, index, priority) })) .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: scoreProductItem(row, item, index, priority) })) .sort((a, b) => b.score - a.score) for (const { item } of ranked) { add(item) } return picked } function subjectViewLabel(view: string) { return SUBJECT_ASSET_VIEWS.find((item) => item.value === view)?.label ?? view } function subjectViewRoleHint(view: string) { const hints: Record = { front: "正面口播、开场、情绪表达、转化收口", three_quarter_left: "左前45度、口播、佩戴前动作、自然转身", three_quarter_right: "右前45度、口播、佩戴前动作、自然转身", left: "左侧、肩颈侧面、佩戴动作、产品厚度与位置", right: "右侧、肩颈侧面、佩戴动作、产品厚度与位置", back: "背面、后颈肩背、产品佩戴落位", bust_front: "肩颈正面近景、痛点表情、佩戴比例", bust_left_45: "肩颈左前近景、手部调整、佩戴贴合", bust_right_45: "肩颈右前近景、手部调整、佩戴贴合", back_neck_detail: "后颈肩背特写、触点位置、产品贴合", } return hints[view] ?? "主体参考视角" } function subjectViewPromptHint(view: string) { const hints: Record = { front: "front speaking shot, opening hook, expression, conversion close", three_quarter_left: "left three-quarter angle, talking, pre-wear motion, natural turn", three_quarter_right: "right three-quarter angle, talking, pre-wear motion, natural turn", left: "left side, neck-and-shoulder side profile, wearing action, product thickness and position", right: "right side, neck-and-shoulder side profile, wearing action, product thickness and position", back: "back view, back neck and upper shoulders, product placement landing", bust_front: "front neck-and-shoulder close-up, pain-point expression, wearing scale", bust_left_45: "left three-quarter neck-and-shoulder close-up, hand adjustment, wearable fit", bust_right_45: "right three-quarter neck-and-shoulder close-up, hand adjustment, wearable fit", back_neck_detail: "back-neck and upper-back detail, contact-pad position, product fit", } return hints[view] ?? "subject reference view" } function subjectDescriptionForRow(row: AudioStoryboardRow, subjectRefs: SubjectPlanningRef[]) { const trimmed = row.subjectDescription.trim() if (trimmed) return trimmed const labels = subjectRefs.slice(0, 4).map((ref) => ref.label || subjectViewLabel(ref.view)).join(", ") return [ "Consistent similar subject: use the generated subject view pack as the character truth, maintaining one identity, body proportion, material, age range, gender presentation, and commercial mood.", labels ? `Available subject views: ${labels}.` : "", "If this beat needs a subject but lacks a specific description, default to a friendly transparent skin shell with visible white skeleton, non-horror, with clear neck and shoulder area for wearable product placement.", ].filter(Boolean).join("") } function subjectPriorityForRow(row: AudioStoryboardRow, role?: "first_frame" | "last_frame") { const text = `${row.role} ${row.visualMode} ${row.subjectDescription} ${row.visualPlan} ${row.firstFramePlan} ${row.lastFramePlan} ${row.productPlacement}`.toLowerCase() if (/back neck|upper back|shoulder blade|back view|fit|worn|wearing complete|correctly worn|后颈|肩背|上背|背面|背部|贴合|佩戴完成|已正确佩戴/.test(text)) { return ["back_neck_detail", "back", "bust_left_45", "bust_right_45", "left", "right", "bust_front", "three_quarter_left", "three_quarter_right", "front"] } if (/side|left|right|45|adjust|pick up|prepare to wear|toward.*neck|hand|侧面|左侧|右侧|调整|拿起|准备佩戴|靠近肩颈|手部/.test(text)) { return ["bust_left_45", "bust_right_45", "left", "right", "three_quarter_left", "three_quarter_right", "bust_front", "front", "back_neck_detail", "back"] } if (/close-up|closeup|upper-body|bust|neck|shoulder|collarbone|rubbing.*neck|looking down|tense|tension|近景|半身|肩颈|锁骨|脖子|揉脖子|低头|紧绷/.test(text)) { return ["bust_front", "bust_left_45", "bust_right_45", "front", "three_quarter_left", "three_quarter_right", "left", "right", "back_neck_detail", "back"] } if (role === "last_frame" && row.needsProduct) { return ["back_neck_detail", "back", "bust_left_45", "bust_right_45", "bust_front", "left", "right", "front", "three_quarter_left", "three_quarter_right"] } return ["front", "three_quarter_left", "three_quarter_right", "bust_front", "left", "right", "bust_left_45", "bust_right_45", "back_neck_detail", "back"] } function selectSubjectRefsForRow(row: AudioStoryboardRow, refs: SubjectPlanningRef[], role?: "first_frame" | "last_frame") { if (!row.needsSubject || !refs.length) return [] const priority = subjectPriorityForRow(row, role) return refs .map((ref, index) => { const rank = priority.indexOf(ref.view) const labelText = `${ref.label || ""} ${ref.roleHint}`.toLowerCase() const closeupScore = /neck|shoulder|back neck|close-up|closeup|fit|wear|佩戴|肩颈|后颈|近景|贴合/.test(row.visualPlan + row.firstFramePlan + row.lastFramePlan + row.productPlacement) && /bust|neck|close-up|closeup|近景|肩颈|后颈/.test(`${ref.view} ${labelText}`) ? 12 : 0 return { ref, score: (rank >= 0 ? 100 - rank * 8 : 0) + closeupScore - index } }) .sort((a, b) => b.score - a.score) .slice(0, MAX_SUBJECT_REFS_PER_ENDPOINT) .map((item) => item.ref) } function subjectReferenceNotes(refs: SubjectPlanningRef[]) { return refs.map((ref, index) => `${index + 1}. ${ref.label || subjectViewLabel(ref.view)} | ${subjectViewPromptHint(ref.view)}`).join("; ") } function subjectAssetRefsForPlanning(source: { frame: KeyFrame; element: KeyElement } | null): SubjectPlanningRef[] { if (!source) return [] return (source.element.subject_assets ?? []).slice(0, 10).map((asset) => ({ kind: "asset", frame_idx: source.frame.index, element_id: asset.id, cutout_id: asset.id, label: asset.label || asset.view || "相似主体视图", view: asset.view, roleHint: subjectViewRoleHint(asset.view), consensusBrief: source.element.subject_consensus_brief || "", })) } function subjectBriefForEndpoint(row: AudioStoryboardRow, refs: SubjectPlanningRef[]) { const storedBrief = refs.find((ref) => ref.consensusBrief?.trim())?.consensusBrief?.trim() if (storedBrief) return storedBrief const manualBrief = row.subjectDescription.trim() if (manualBrief) return manualBrief if (row.needsSubject) return subjectDescriptionForRow(row, refs) return "" } function endpointAssetRef(frame: KeyFrame | null, role: "first_frame" | "last_frame"): ImageRef | null { if (!frame) return null const key = role === "first_frame" ? "first_image" : "last_image" if (frame.storyboard && Object.prototype.hasOwnProperty.call(frame.storyboard, key)) { const saved = role === "first_frame" ? frame.storyboard.first_image : frame.storyboard.last_image return saved && saved.kind !== "keyframe" ? saved : null } const asset = [...(frame.scene_assets ?? [])].reverse().find((item) => item.asset_role === role) if (!asset) return null return { kind: "asset", frame_idx: frame.index, element_id: asset.id, cutout_id: asset.id, label: asset.label || (role === "first_frame" ? "首帧" : "尾帧"), } } function buildEndpointFramePrompt(row: AudioStoryboardRow, role: "first_frame" | "last_frame", selectedProductItems: ProductRefItem[], subjectBrief: string) { const target = role === "first_frame" ? row.firstFramePlan : row.lastFramePlan const opposite = role === "first_frame" ? row.lastFramePlan : row.firstFramePlan const productNotes = selectedProductItems.length ? productReferenceNotes(selectedProductItems) : "" return [ `Storyboard beat ${row.index + 1}, ${role === "first_frame" ? "first frame" : "last frame"}.`, `New English voice-over line: ${row.skgCopy}`, `Narrative role: ${ROLE_LABELS_EN[row.role]}.`, `Visual mode: ${row.visualMode}.`, `Target endpoint frame to generate now: ${target}`, `Opposite endpoint continuity reference: ${opposite}`, `Overall visual plan: ${row.visualPlan}`, row.needsSubject ? `Subject identity brief: ${subjectBrief || "Subject brief is missing. Keep one unified commercial ad subject with clear neck-and-shoulder area for product placement."}. Use only this text identity brief; no subject reference image is uploaded. The subject may freely change pose, framing, expression, gesture, and environment for this shot, but must not become a different character. Do not copy the original source-video person or keyframe.` : "This beat does not need a main character. If people appear, they should only be partial hands, back-view background figures, or environmental figures; do not generate the transparent skeleton main subject.", row.needsProduct ? `Product integration: ${row.productPlacement}. ${row.productIntegration}. This request provides ${selectedProductItems.length} rigid reference image(s) of the same SKG neck-and-shoulder massager: ${productNotes}. The product is a U-shaped wearable device worn around the neck and shoulders. Preserve realistic wearable scale, left-right asymmetry, button placement, contact pads, side thickness, and neck-contact position.` : "Do not show the product in this beat. Do not force-generate an SKG product, package, white-background product image, or random merchandise.", "Output one single 9:16 high-definition endpoint frame. No contact sheet, no multiple views, no subtitles, no platform UI, no watermark. The image must work as a clear first/last frame for downstream video generation.", ].join("\n") } function buildStoryboardSceneFromAudioRow( row: AudioStoryboardRow, frame: KeyFrame, productItems: ProductRefItem[] = [], subjectRefs: SubjectPlanningRef[] = [], endpointRefs: { firstImage?: ImageRef | null; lastImage?: ImageRef | null } = {}, ): StoryboardScene { const selectedProductItems = row.needsProduct ? selectProductItemsForRow(row, productItems) : [] const productRefs = selectedProductItems.map((item) => item.ref) const notes = productReferenceNotes(selectedProductItems) const subjectDescription = subjectDescriptionForRow(row, subjectRefs) const subjectNotes = subjectReferenceNotes(subjectRefs) const subjectBrief = subjectBriefForEndpoint(row, subjectRefs) const productGuidance = !row.needsProduct ? "This beat is planned without product visibility or without product as the visual subject. Do not force-insert an SKG product, package, white-background product render, or incorrect merchandise during video generation." : productItems.length ? `The product pool has ${productItems.length} image(s); this beat selects only the ${selectedProductItems.length} most relevant reference image(s). Do not mix unselected assets into this shot. Rigid product definition: this is a U-shaped neck-and-shoulder wearable massager, not headphones, a headset, or a neck pillow. Coordinate rule: left/right refer to the wearer's body, not the image; top means closer to chin/face/upper neck, bottom means closer to collarbone/shoulders; inner means skin-contact side and massage pads, outer means shell/buttons/logo. Selected images are only product structure, angle, scale, and detail references; do not copy the white/black/studio background. View notes: ${notes}. Preserve left-right asymmetry; do not mirror the two sides. The shoulder-neck product size must match realistic wearing scale, not earphone-small and not neck-pillow-large.` : "No product images are uploaded. Use the default SKG product concept only if needed, and preferably establish a same-product pool before generation to lock left-right differences, thickness, and wearing scale." return { duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || 4.5)).toFixed(1)), first_image: endpointRefs.firstImage ?? null, last_image: endpointRefs.lastImage ?? null, visual_mode: row.visualMode, needs_product: row.needsProduct, needs_subject: row.needsSubject, storyboard_row_idx: row.index, subject_brief: row.needsSubject ? subjectBrief : "", skg_copy_en: row.skgCopy, skg_copy_zh: row.skgCopyZh, scene_one_line_en: row.sceneOneLine, scene_one_line_zh: row.sceneOneLineZh, action_one_line_en: row.actionOneLine, action_one_line_zh: row.actionOneLineZh, selected_video_id: frame.storyboard?.storyboard_row_idx === row.index ? frame.storyboard?.selected_video_id ?? "" : "", first_frame_plan: row.firstFramePlan, last_frame_plan: row.lastFramePlan, product_placement: row.productPlacement, product_images: productRefs, product_image: productRefs[0] ?? null, subject_images: row.needsSubject ? subjectRefs : [], subject_image: row.needsSubject ? subjectRefs[0] ?? null : null, subject: row.needsSubject ? `${subjectDescription}\nSubject action and visual elements: ${row.keyElements}\nSubject source: select ${subjectRefs.length} generated similar-subject view(s) according to this shot's need; ${subjectNotes}. Source keyframes are only used for upstream subject extraction and must not be used as direct endpoint-frame references.` : "No main character or similar-subject reference is needed for this beat. If people appear, they should be background or partial-body context, not the main subject.", scene: `Visual mode: ${row.visualMode}\n${row.visualPlan}\nFirst-frame plan: ${row.firstFramePlan}\nLast-frame plan: ${row.lastFramePlan}\nSource audio reference: ${row.source}`, product: `Product requirement: ${row.needsProduct ? "product reference required" : "no product required for this beat"}\nProduct placement: ${row.productPlacement}\n${row.needsProduct ? row.productIntegration : "This beat focuses on emotion, subject state, space, or pacing transition and should not show the product."}\n${productGuidance}`, action: `${row.skgCopy}\nContinuity action: transition naturally from the first-frame plan to the last-frame plan. The visual mode and product/subject requirements must not change mid-clip.`, reference_ids: [], } } export function AdRecreationBoard({ data, onGenerateVideo, }: { data: NodeData onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise | void }) { const { job, jobs, activeJobId } = data const [url, setUrl] = useState("") const [selectedVideoIds, setSelectedVideoIds] = useState>(new Set()) const [draftSegments, setDraftSegments] = useState([]) const [elementBusyFrame, setElementBusyFrame] = useState(null) const [sixViewBusyKey, setSixViewBusyKey] = useState(null) const [generatingAll, setGeneratingAll] = useState(false) const [runtimeModels, setRuntimeModels] = useState() const [boardTheme, setBoardTheme] = useState("dark") const [libraryOpen, setLibraryOpen] = useState(false) const fileRef = useRef(null) const selectedFrames = job ? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp) : [] const framesForSegments = orderedFrames(job, selectedFrames) const generatedVideos = job?.generated_videos ?? [] const audioReady = !!job?.audio_script?.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() const audioRunning = job?.status === "transcribing" || job?.audio_script?.status === "rewriting" const visualRunning = job?.status === "splitting" const visualReady = (job?.frames.length ?? 0) > 0 const subjectAssetCount = countSubjectAssetViews(job) const productAssetCount = job?.product_refs?.length ?? 0 const endpointFramePairCount = countEndpointFramePairs(job) const workflowSteps = buildWorkflowSteps({ job, submitting: data.submitting, audioReady, audioRunning, transcriptCount, visualReady, visualRunning, subjectAssetCount, productAssetCount, endpointFramePairCount, generatedVideoCount: generatedVideos.length, }) const workflow = workflowStepMap(workflowSteps) const statusMessage = job?.message?.startsWith("视频生成已提交") ? "视频候选已提交;当前默认按紧凑三字段生成候选,首尾帧细节自动处理。" : job?.message useEffect(() => { setDraftSegments([]) setSelectedVideoIds(new Set()) }, [activeJobId]) useEffect(() => { try { const saved = window.localStorage.getItem(BOARD_THEME_STORAGE_KEY) if (saved === "light" || saved === "dark") setBoardTheme(saved) } catch { // Ignore storage failures; dark mode remains the product default. } }, []) useEffect(() => { let cancelled = false getRuntimeHealth() .then((health) => { if (!cancelled) setRuntimeModels(health.models) }) .catch((error) => { console.warn("模型配置读取失败", error) }) return () => { cancelled = true } }, []) 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 toggleBoardTheme = () => { setBoardTheme((current) => { const next: BoardThemeMode = current === "dark" ? "light" : "dark" try { window.localStorage.setItem(BOARD_THEME_STORAGE_KEY, next) } catch { // Ignore storage failures; the in-memory theme still switches. } return next }) } 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 applyLibraryAsset = async ( kind: AssetLibraryKind, ref: ImageRef, target: "copy_only" | "product_pool", item: AssetLibraryItem, ) => { if (!job) return if (target === "product_pool" && kind === "products") { const existing = job.product_refs ?? [] const next = [ ...existing, createProductRefItem(ref, existing.length, "library", "front", item.note || item.name || "素材库产品图"), ] const updated = await saveProductRefs(job.id, next) data.onJobUpdate(updated) return } toast.success("素材已复制到当前 job;需要入产品池时请选择“应用到产品素材池”。") } const addDraftSegment = () => { setDraftSegments((prev) => [ ...prev, { id: `draft-${Date.now()}-${prev.length}`, frameIndex: null, scene: emptyScene(), }, ]) } const updateDraftSegment = (id: string, patch: Partial) => { setDraftSegments((prev) => prev.map((draft) => draft.id === id ? { ...draft, ...patch } : draft)) } const removeDraftSegment = (id: string) => { setDraftSegments((prev) => prev.filter((draft) => draft.id !== id)) } const toggleVideo = (videoId: string) => { setSelectedVideoIds((prev) => { const next = new Set(prev) if (next.has(videoId)) next.delete(videoId) else next.add(videoId) return next }) } const generateElementForFrame = async (frame: KeyFrame, 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: SUBJECT_ASSET_SIZE, 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: SUBJECT_ASSET_SIZE, source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index), }) data.onJobUpdate(updated) toast.success(`高清视图已生成:${element.name_zh || element.name_en}`) } catch (e) { toast.error("高清视图生成失败:" + (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 (
未来健康 · 营销内容工作台

营销内容工作台 · TK 二创

信息流广告复刻生产线

视频拆解

{statusMessage || "下载源视频后解析音频,再抽参考帧并生成相似主体。"}
data.onTranscribeAudio?.(job?.id)}> 解析音频
setLibraryOpen(false)} onApplyAsset={applyLibraryAsset} />
) } function MaterialColumn({ data, step, jobs, job, activeJobId, url, setUrl, fileRef, onSubmitUrl, onStartProduction, }: { data: NodeData step: WorkflowStep jobs: Job[] job: Job | null activeJobId: string | null url: string setUrl: (value: string) => void fileRef: RefObject onSubmitUrl: () => void onStartProduction: () => void }) { const actionLabel = !url.trim() && job?.status === "failed" ? job.video_url ? "重新解析" : "重新下载" : "开始分析" return (

素材输入

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

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-[#d6b36a]/60" /> { const file = e.target.files?.[0] if (file) data.onUploadFile(file) e.currentTarget.value = "" }} />
{jobs.length ? jobs.map((item, index) => ( data.onSwitchJob(item.id)} onDelete={data.onDeleteJob ? () => data.onDeleteJob?.(item.id) : undefined} /> )) : ( )}
) } function AudioIntakePanel({ job, selectedFrames, onToggleFrame, onJobUpdate, onAddFrame, onDeleteFrame, runtimeModels, }: { job: Job | null selectedFrames: Set onToggleFrame: (idx: number) => void onJobUpdate: (job: Job) => void onAddFrame?: (jobId: string, t: number) => Promise | void onDeleteFrame?: (jobId: string, idx: number) => Promise | void runtimeModels?: RuntimeModels }) { const [currentTime, setCurrentTime] = useState(0) const [mediaDuration, setMediaDuration] = useState(0) const [audioFeatures, setAudioFeatures] = useState([]) const [audioFeatureStatus, setAudioFeatureStatus] = useState("idle") const [manualBusy, setManualBusy] = useState(false) const [extracting, setExtracting] = useState(false) const [deletingFrame, setDeletingFrame] = useState(null) const [waveHoverTime, setWaveHoverTime] = useState(null) const [filmstripDensity, setFilmstripDensity] = useState(2) const [filmstripPreviews, setFilmstripPreviews] = useState([]) const [filmstripStatus, setFilmstripStatus] = useState("idle") const [filmstripDragTime, setFilmstripDragTime] = useState(null) const [filmstripBusyTime, setFilmstripBusyTime] = useState(null) const videoRef = useRef(null) const rowRefs = useRef>({}) const syncFrameRef = useRef(null) const audioSrcUrl = job ? apiAssetUrl(job.source_audio_url) || sourceAudioUrl(job.id) : "" const videoSrcUrl = job ? apiAssetUrl(job.video_url) || videoUrl(job.id) : "" const processing = !!job && (job.status === "transcribing" || job.audio_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)) const frames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job]) const selectedReferenceFrames = useMemo( () => frames.filter((frame) => selectedFrames.has(frame.index)), [frames, selectedFrames], ) const waveTimeHint = waveHoverTime !== null ? `指针停点 ${waveHoverTime.toFixed(1)}s` : activeSegment ? `当前句 ${activeSegment.start.toFixed(1)}-${activeSegment.end.toFixed(1)}s` : "指针 -" 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(() => { if (!job?.id || !job.video_url || !videoSrcUrl || timelineDuration <= 0) { setFilmstripPreviews([]) setFilmstripStatus("idle") return } let cancelled = false setFilmstripPreviews([]) setFilmstripStatus("loading") captureVideoFilmstrip(videoSrcUrl, timelineDuration, filmstripDensity, () => cancelled) .then((frames) => { if (!cancelled) { setFilmstripPreviews(frames) setFilmstripStatus(frames.length ? "ready" : "idle") } }) .catch(() => { if (!cancelled) { setFilmstripPreviews([]) setFilmstripStatus("failed") } }) return () => { cancelled = true } }, [filmstripDensity, job?.id, job?.video_url, timelineDuration, videoSrcUrl]) 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) } const addFrameAtCurrentTime = async () => { if (!job || !onAddFrame) return const next = clampNumber(currentTime, 0, timelineDuration) setManualBusy(true) try { await onAddFrame(job.id, next) } finally { setManualBusy(false) } } const addFilmstripFrame = async (time: number) => { if (!job || !onAddFrame) return const next = clampNumber(time, 0, timelineDuration) const duplicate = frames.find((frame) => Math.abs(frame.timestamp - next) < 0.45) if (duplicate) { toast.warning(`附近已有关键帧:${duplicate.timestamp.toFixed(1)}s`) return } setFilmstripBusyTime(next) try { await onAddFrame(job.id, next) toast.success(`已加入关键帧:${next.toFixed(1)}s`) } finally { setFilmstripBusyTime(null) setFilmstripDragTime(null) } } const extractKeyframes = async () => { if (!job) return 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 deleteReferenceFrame = async (idx: number) => { if (!job || !onDeleteFrame) return setDeletingFrame(idx) try { await onDeleteFrame(job.id, idx) } finally { setDeletingFrame(null) } } if (!job) { return } return (
} title="源视频工作区" />
{job.transcript.length} 段 {formatSeconds(job.duration)}
} title="原版视频" /> {currentTime.toFixed(1)}s
{job.video_url ? (
当前 {currentTime.toFixed(1)}s 总 {formatSeconds(timelineDuration)} {waveTimeHint}
frame.timestamp)} busyTime={filmstripBusyTime} onDensityChange={setFilmstripDensity} onSeek={seekTo} onDragStart={setFilmstripDragTime} onDragEnd={() => setFilmstripDragTime(null)} />
void extractKeyframes()} onDeleteFrame={onDeleteFrame ? (idx) => void deleteReferenceFrame(idx) : undefined} filmstripDragging={filmstripDragTime !== null} onDropFilmstripFrame={(time) => void addFilmstripFrame(time)} />
} title="逐句时间轴" /> {job.transcript.length} 段
{job.transcript.length ? (
时间
原文 / 中文
{job.transcript.map((segment) => { const active = activeSegment?.index === segment.index return (
{ rowRefs.current[segment.index] = node }} onClick={() => seekTo(segment.start)} className={`grid cursor-pointer grid-cols-[76px_minmax(0,1fr)] gap-2 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]" }`} >
{segment.start.toFixed(1)}-{segment.end.toFixed(1)}s
{segment.en || -}
{segment.zh || 翻译中}
) })}
) : ( )}
) } function TimelineFilmstrip({ frames, status, density, duration, currentTime, hoverTime, selectedTimes, busyTime, onDensityChange, onSeek, onDragStart, onDragEnd, }: { frames: FilmstripPreviewFrame[] status: FilmstripStatus density: FilmstripDensitySeconds duration: number currentTime: number hoverTime: number | null selectedTimes: number[] busyTime: number | null onDensityChange: (density: FilmstripDensitySeconds) => void onSeek: (time: number) => void onDragStart: (time: number) => void onDragEnd: () => void }) { const pointerPct = clampNumber((currentTime / Math.max(duration, 1)) * 100, 0, 100) const hoverPct = hoverTime === null ? null : clampNumber((hoverTime / Math.max(duration, 1)) * 100, 0, 100) return (
{FILMSTRIP_DENSITIES.map((item) => ( ))}
{status === "loading" ? (
正在生成临时胶片
) : status === "failed" ? (
胶片预览生成失败,可继续用当前点抽帧。
) : frames.length ? (
{hoverPct !== null && (
)}
{frames.map((frame, index) => { const selected = selectedTimes.some((time) => Math.abs(time - frame.time) < 0.45) const active = Math.abs(currentTime - frame.time) <= Math.max(density * 0.45, 0.45) const busy = busyTime !== null && Math.abs(busyTime - frame.time) < 0.45 const tiltClass = FILMSTRIP_TILT_CLASSES[index % FILMSTRIP_TILT_CLASSES.length] const framePct = clampNumber((frame.time / Math.max(duration, 1)) * 100, 0, 100) return (
{ event.dataTransfer.setData(FILMSTRIP_DRAG_TYPE, frame.time.toFixed(2)) event.dataTransfer.effectAllowed = "copy" onDragStart(frame.time) }} onDragEnd={onDragEnd} className={`absolute bottom-[20px] z-20 -translate-x-1/2 ${tiltClass} origin-bottom cursor-grab transition-transform duration-150 hover:z-50 hover:-translate-y-3 hover:rotate-0 hover:scale-[2.45] active:cursor-grabbing`} style={{ left: `${framePct}%` }} title={`${frame.time.toFixed(1)}s · 拖到关键帧库才选取`} >
onSeek(frame.time)} title="点击跳到该时间点,拖入关键帧库才正式选取" topRight={busy ? : selected ? : undefined} bottom={{frame.time.toFixed(1)}s} />
) })}
) : (
等待原视频生成临时胶片
)}
0s {formatSeconds(duration)}
) } function SourceKeyframePicker({ job, frames, selectedFrames, selectedReferenceFrames, extracting, deletingFrame, onToggleFrame, onExtract, onDeleteFrame, filmstripDragging, onDropFilmstripFrame, }: { job: Job frames: KeyFrame[] selectedFrames: Set selectedReferenceFrames: KeyFrame[] extracting: boolean deletingFrame: number | null onToggleFrame: (idx: number) => void onExtract: () => void onDeleteFrame?: (idx: number) => void filmstripDragging?: boolean onDropFilmstripFrame?: (time: number) => void }) { const [dropActive, setDropActive] = useState(false) return (
} title="关键帧" />
已选 {selectedReferenceFrames.length || "全部"}
{ if (!onDropFilmstripFrame) return event.preventDefault() setDropActive(true) }} onDragOver={(event) => { if (!onDropFilmstripFrame) return event.preventDefault() event.dataTransfer.dropEffect = "copy" }} onDragLeave={(event) => { const next = event.relatedTarget as Node | null if (next && event.currentTarget.contains(next)) return setDropActive(false) }} onDrop={(event) => { if (!onDropFilmstripFrame) return event.preventDefault() setDropActive(false) const raw = event.dataTransfer.getData(FILMSTRIP_DRAG_TYPE) const time = Number(raw) if (Number.isFinite(time)) onDropFilmstripFrame(time) }} >
参考帧池 {filmstripDragging ? "松手加入关键帧" : "拖入胶片选帧,悬停放大"}
{frames.map((frame, index) => { const selected = selectedFrames.has(frame.index) return ( onToggleFrame(frame.index)} topLeft={{String(index + 1).padStart(2, "0")}} topRight={{selected ? : }} onDelete={onDeleteFrame ? () => onDeleteFrame(frame.index) : undefined} deleting={deletingFrame === frame.index} deleteLabel={`删除关键帧 ${index + 1}`} /> ) })} {!frames.length && (
自动抽帧或在原版视频上用当前点抽帧。
)}
) } function SourceReferenceBuildPanel({ job, selectedFrames, onJobUpdate, runtimeModels, }: { job: Job selectedFrames: Set onJobUpdate: (job: Job) => void runtimeModels?: RuntimeModels }) { const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; viewCount: number; profileLabel: string } | null>(null) const [subjectAssetBusy, setSubjectAssetBusy] = useState(null) const [subjectMode, setSubjectMode] = useState("source_similar") const [subjectStyle, setSubjectStyle] = useState("transparent_human") const [subjectViewMode, setSubjectViewMode] = useState("all") const [customSubjectViews, setCustomSubjectViews] = useState(COMMON_SUBJECT_VIEW_VALUES) const [subjectDirection, setSubjectDirection] = useState("") const [subjectProfileMode, setSubjectProfileMode] = useState("random") const [subjectProfileDraft, setSubjectProfileDraft] = useState({ ...DEFAULT_SUBJECT_PROFILE_DRAFT }) const [randomProfileDraft, setRandomProfileDraft] = useState(() => randomSubjectProfileDraft()) const [lastSubjectProfile, setLastSubjectProfile] = useState(null) const [characterLibrary, setCharacterLibrary] = useState([]) const [selectedCharacterId, setSelectedCharacterId] = useState("") const [subjectTemplateLibrary, setSubjectTemplateLibrary] = useState([]) const [selectedSubjectTemplateId, setSelectedSubjectTemplateId] = useState("") const [templateLibraryBusy, setTemplateLibraryBusy] = useState(false) const [templateSaveBusy, setTemplateSaveBusy] = useState(false) const [templateDraftName, setTemplateDraftName] = useState("") const [templateDraftNote, setTemplateDraftNote] = useState("") const [subjectBriefDraft, setSubjectBriefDraft] = useState("") const [subjectBriefBusy, setSubjectBriefBusy] = useState(false) 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 subjectReferenceFrames = useMemo( () => selectedReferenceFrames.length ? selectedReferenceFrames : frames, [frames, selectedReferenceFrames], ) const actorSource = useMemo(() => { return findSimilarActorSource(subjectReferenceFrames, frames) }, [frames, subjectReferenceFrames]) const actorAssets = actorSource?.element.subject_assets ?? [] const selectedCharacter = useMemo( () => characterLibrary.find((character) => character.id === selectedCharacterId) ?? null, [characterLibrary, selectedCharacterId], ) const selectedSubjectTemplate = useMemo( () => subjectTemplateLibrary.find((template) => template.id === selectedSubjectTemplateId) ?? null, [subjectTemplateLibrary, selectedSubjectTemplateId], ) const selectedTemplatePrompt = subjectMode === "template" && selectedSubjectTemplate ? { name: selectedSubjectTemplate.name, sourceLabel: "数据库主体模板" } : subjectMode === "template" && selectedCharacter ? { name: selectedCharacter.name, sourceLabel: "内置策划形象" } : null const selectedSubjectViews = useMemo(() => { if (subjectViewMode === "common") return COMMON_SUBJECT_VIEW_VALUES if (subjectViewMode === "custom") return customSubjectViews.length ? customSubjectViews : COMMON_SUBJECT_VIEW_VALUES return SUBJECT_ASSET_VIEWS.map((view) => view.value) }, [customSubjectViews, subjectViewMode]) const subjectProfilePreview = useMemo(() => { return subjectProfileMode === "random" ? resolveSubjectProfile("random", randomProfileDraft) : resolveSubjectProfile("manual", subjectProfileDraft) }, [randomProfileDraft, subjectProfileDraft, subjectProfileMode]) const visibleActorAssets = useMemo(() => { const latestByView = new Map() for (const asset of actorAssets) { const current = latestByView.get(asset.view) if (!current || (asset.created_at || 0) >= (current.created_at || 0)) { latestByView.set(asset.view, asset) } } return [...latestByView.values()].sort((a, b) => { const ai = SUBJECT_VIEW_ORDER.indexOf(a.view) const bi = SUBJECT_VIEW_ORDER.indexOf(b.view) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) }) }, [actorAssets]) const referenceCountLabel = selectedReferenceFrames.length ? `已选 ${selectedReferenceFrames.length} 张参考帧` : frames.length ? `默认使用全部 ${frames.length} 张参考帧` : "待抽帧" const templateSaveHint = visibleActorAssets.length ? templateDraftName.trim() ? "保存后会进入主体模板库,后续任务可直接复用" : "先给这套主体命名,再保存到主体模板库" : "先生成本次主体视图,再决定是否入库" const templateSourceLabel = subjectMode === "template" && selectedSubjectTemplate ? `${selectedSubjectTemplate.name} · 数据库模板` : subjectMode === "template" && selectedCharacter ? `${selectedCharacter.name} · 模板参考` : "源视频关键帧 · 相似创新" const templateRequired = subjectMode === "template" && !selectedSubjectTemplate && !selectedCharacter const subjectBusy = !!subjectBusyFor const generationCtaLabel = subjectMode === "template" ? `用模板生成 ${selectedSubjectViews.length} 张主体视图` : `从源视频创新生成 ${selectedSubjectViews.length} 张主体视图` const currentSubjectBrief = actorSource?.element.subject_consensus_brief?.trim() || selectedSubjectTemplate?.prompt_brief?.trim() || selectedCharacter?.prompt_brief?.trim() || "" const buildSubjectProfileForRequest = () => { if (subjectProfileMode === "random") { const randomized = randomSubjectProfileDraft() setRandomProfileDraft(randomized) const resolved = resolveSubjectProfile("random", randomized) setLastSubjectProfile(resolved) return resolved } const resolved = resolveSubjectProfile("manual", subjectProfileDraft, { randomizeRandomValues: true }) setLastSubjectProfile(resolved) return resolved } const loadSubjectTemplateLibrary = async (silent = false) => { setTemplateLibraryBusy(true) try { const items = await listSubjectTemplates() setSubjectTemplateLibrary(items) } catch (e) { if (!silent) toast.error("主体模板库读取失败:" + (e instanceof Error ? e.message : String(e))) } finally { setTemplateLibraryBusy(false) } } useEffect(() => { let cancelled = false Promise.allSettled([listCharacterLibrary(), listSubjectTemplates()]) .then(([characters, templates]) => { if (cancelled) return if (characters.status === "fulfilled") setCharacterLibrary(characters.value) else toast.error("内置形象读取失败:" + (characters.reason instanceof Error ? characters.reason.message : String(characters.reason))) if (templates.status === "fulfilled") setSubjectTemplateLibrary(templates.value) else toast.error("主体模板库读取失败:" + (templates.reason instanceof Error ? templates.reason.message : String(templates.reason))) }) return () => { cancelled = true } }, []) useEffect(() => { setTemplateDraftName("") setTemplateDraftNote("") setLastSubjectProfile(null) }, [job.id]) useEffect(() => { setSubjectBriefDraft(currentSubjectBrief) }, [actorSource?.element.id, currentSubjectBrief]) const generateSimilarActor = async () => { if (!frames.length) { toast.warning("请先自动抽帧 12 张,或在原版视频上手动补帧。") return } if (templateRequired) { toast.warning("请先选择一个内置或数据库主体模板。") return } const baseFrame = subjectReferenceFrames[0] if (!baseFrame) return const requestJobId = job.id const requestProfile = buildSubjectProfileForRequest() setSubjectBusyFor({ jobId: requestJobId, jobLabel: shortId(requestJobId), viewCount: selectedSubjectViews.length, profileLabel: requestProfile.summary, }) 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(requestJobId, baseFrame.index, { name_zh: selectedTemplatePrompt ? `相似透明骨架主体 · ${selectedTemplatePrompt.name}` : subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似广告主角", name_en: selectedTemplatePrompt ? `similar innovative transparent skeleton humanoid subject based on ${selectedTemplatePrompt.name}` : subjectStyle === "transparent_human" ? "similar transparent skeleton humanoid subject" : "similar ad actor", position: "source-video main subject 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 subject element missing") const updated = await generateSubjectAssets(requestJobId, baseFrame.index, element.id, { subject_kind: "living", subject_style: subjectStyle, reconstruction_mode: "similar", background: "white", size: SUBJECT_ASSET_SIZE, source_frame_indices: subjectReferenceFrames.slice(0, 12).map((frame) => frame.index), views: selectedSubjectViews, character_id: subjectMode === "template" ? selectedCharacterId : "", subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "", subject_profile: requestProfile.payload, prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile), replace_views: true, }) onJobUpdate(updated) toast.success(`相似主体 ${selectedSubjectViews.length} 张高清白底图已生成`) } catch (e) { try { onJobUpdate(await getJob(requestJobId)) } catch { /* keep original error visible */ } toast.error("相似主体重构失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubjectBusyFor(null) } } const regenerateSubjectAsset = async (asset: SubjectAsset) => { if (!actorSource) return setSubjectAssetBusy(`regen:${asset.id}`) try { const requestProfile = lastSubjectProfile ?? resolveSubjectProfile( subjectProfileMode, subjectProfileMode === "random" ? randomProfileDraft : subjectProfileDraft, { randomizeRandomValues: subjectProfileMode === "manual" }, ) const sourceIndices = asset.source_frame_indices?.length ? asset.source_frame_indices : subjectReferenceFrames.slice(0, 12).map((frame) => frame.index) const updated = await generateSubjectAssets(job.id, actorSource.frame.index, actorSource.element.id, { subject_kind: "living", subject_style: subjectStyle, reconstruction_mode: "similar", background: asset.background || "white", size: SUBJECT_ASSET_SIZE, source_frame_indices: sourceIndices, views: [asset.view], character_id: subjectMode === "template" ? selectedCharacterId : "", subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "", subject_profile: requestProfile.payload, prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile), replace_views: true, }) onJobUpdate(updated) toast.success("已重新生成这张主体视图") } catch (e) { toast.error("单张主体视图重生失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubjectAssetBusy(null) } } const deleteActorAsset = async (asset: SubjectAsset) => { if (!actorSource) return setSubjectAssetBusy(`delete:${asset.id}`) try { const updated = await deleteSubjectAsset(job.id, actorSource.frame.index, actorSource.element.id, asset.id) onJobUpdate(updated) toast.success("主体视图已删除") } catch (e) { toast.error("主体视图删除失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubjectAssetBusy(null) } } const saveSubjectBriefDraft = async () => { if (!actorSource) { toast.warning("先生成本次主体视图,才能把 brief 绑定到主体元素。") return } setSubjectBriefBusy(true) try { const updated = await updateElement(job.id, actorSource.frame.index, actorSource.element.id, { subject_consensus_brief: subjectBriefDraft.trim(), }) onJobUpdate(updated) toast.success("主体 brief 已保存,后续首尾帧会使用这段文字依据") } catch (e) { toast.error("主体 brief 保存失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubjectBriefBusy(false) } } const saveGeneratedSubjectTemplate = async () => { if (!actorSource || !visibleActorAssets.length) { toast.warning("请先生成相似主体视图。") return } const name = templateDraftName.trim() if (!name) { toast.warning("请先给这套主体模板命名。") return } setTemplateSaveBusy(true) try { const item = await saveSubjectTemplate(job.id, { name, note: templateDraftNote.trim(), frame_idx: actorSource.frame.index, element_id: actorSource.element.id, asset_ids: visibleActorAssets.map((asset) => asset.id), subject_style: subjectStyle, }) setSubjectTemplateLibrary((items) => [item, ...items.filter((template) => template.id !== item.id)]) setSelectedSubjectTemplateId(item.id) setSelectedCharacterId("") setTemplateDraftName("") setTemplateDraftNote("") toast.success("已保存到主体模板库") } catch (e) { toast.error("保存主体模板失败:" + (e instanceof Error ? e.message : String(e))) } finally { setTemplateSaveBusy(false) } } return (
} title="相似主体 / 主体模板" />
{referenceCountLabel}
主体模板库
先决定是否用模板,再选择内置或数据库主体;源视频相似不再混在模板网格里。
{[ { value: "template" as const, label: "用模板生成", desc: "从内置形象或数据库模板延展新主体" }, { value: "source_similar" as const, label: "不用模板(从源视频关键帧创新)", desc: "只读取源视频角色文字特征,不上传参考图做复制" }, ].map((item) => ( ))}
{subjectMode === "template" ? (
{subjectTemplateLibrary.map((template) => { const preview = characterPreviewImage(template) const active = subjectMode === "template" && selectedSubjectTemplateId === template.id return ( ) })} {characterLibrary.map((character) => { const preview = characterPreviewImage(character) const active = subjectMode === "template" && selectedCharacterId === character.id return ( ) })}
) : (
当前跳过模板库:本次只用源视频关键帧的文字化主体特征生成创新主体。模板卡已收起,避免占用生成结果区域。
)} {subjectMode === "template" && (selectedSubjectTemplate?.images?.length || selectedCharacter?.images?.length) ? (
{(selectedSubjectTemplate?.images ?? selectedCharacter?.images ?? []).slice(0, 10).map((image) => (
{image.label}
))}
) : null}
setTemplateDraftName(event.target.value)} placeholder={visibleActorAssets.length ? "模板名称" : "生成主体视图后可命名保存"} className="h-8 rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50" /> setTemplateDraftNote(event.target.value)} placeholder="保存为主体模板备注:适用广告、人物风格、禁用点" className="h-8 rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50" />
{templateSaveHint}
主体 brief 预览 / 首尾帧文字依据