7215 lines
341 KiB
TypeScript
7215 lines
341 KiB
TypeScript
"use client"
|
||
|
||
import { type MouseEvent as ReactMouseEvent, 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
|
||
}
|
||
type FilmstripHoverPreview = {
|
||
src: string
|
||
time: number
|
||
left: number
|
||
top: number
|
||
width: number
|
||
height: number
|
||
active: boolean
|
||
selected: boolean
|
||
busy: boolean
|
||
}
|
||
|
||
const FILMSTRIP_DRAG_TYPE = "application/x-skg-filmstrip-time"
|
||
const SOURCE_KEYFRAME_DRAG_TYPE = "application/x-skg-source-keyframe"
|
||
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]"]
|
||
const FILMSTRIP_VERTICAL_OFFSET_CLASSES = ["translate-y-0", "translate-y-2", "-translate-y-1.5", "translate-y-1", "-translate-y-2"]
|
||
const FILMSTRIP_HOVER_SCALE = 4.8
|
||
|
||
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 SubjectPipelineViewMode = "all" | "common"
|
||
type SubjectProfileMode = "random" | "manual"
|
||
type SubjectProfileFieldKey = "gender" | "age" | "wardrobe" | "region_ethnicity" | "skin_tone" | "body" | "hair" | "mood"
|
||
type SubjectProfileDraft = Record<SubjectProfileFieldKey, string>
|
||
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<StoryboardScene["visual_mode"]>
|
||
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "subjectDescriptionZh" | "skgCopy" | "skgCopyZh" | "sceneOneLine" | "sceneOneLineZh" | "actionOneLine" | "actionOneLineZh" | "visualPlan" | "visualPlanZh" | "firstFramePlan" | "firstFramePlanZh" | "lastFramePlan" | "lastFramePlanZh" | "productIntegration" | "productIntegrationZh" | "productPlacement" | "productPlacementZh">>
|
||
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<string, string> = {
|
||
white: "白底",
|
||
black: "黑底",
|
||
simple: "纯色/简单",
|
||
complex: "复杂背景",
|
||
unknown: "背景未知",
|
||
}
|
||
|
||
const PRODUCT_USE_TAG_LABELS: Record<string, string> = {
|
||
hero_packshot: "主外观",
|
||
wearing_scale: "佩戴比例",
|
||
inner_contact: "触点",
|
||
side_thickness: "厚度",
|
||
asymmetry: "非对称",
|
||
button_detail: "按键",
|
||
back_bottom: "背底",
|
||
material_texture: "材质",
|
||
}
|
||
|
||
const ROLE_LABELS_ZH: Record<AudioStoryboardRole, string> = {
|
||
hook: "开场钩子",
|
||
pain: "痛点推进",
|
||
proof: "利益证明",
|
||
solution: "方案过渡",
|
||
cta: "转化收口",
|
||
bridge: "节奏承接",
|
||
}
|
||
|
||
const ROLE_LABELS_EN: Record<AudioStoryboardRole, string> = {
|
||
hook: "hook",
|
||
pain: "pain build",
|
||
proof: "benefit proof",
|
||
solution: "solution transition",
|
||
cta: "conversion close",
|
||
bridge: "rhythm bridge",
|
||
}
|
||
|
||
const PRODUCT_VIEW_PROMPT_LABELS: Record<string, string> = {
|
||
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<string, string> = {
|
||
white: "white background",
|
||
black: "black background",
|
||
simple: "simple solid background",
|
||
complex: "complex background",
|
||
unknown: "unknown background",
|
||
}
|
||
|
||
const PRODUCT_USE_TAG_PROMPT_LABELS: Record<string, string> = {
|
||
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<SubjectProfileFieldKey, string> = {
|
||
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<AudioFeature[]> {
|
||
const res = await fetch(url)
|
||
if (!res.ok) throw new Error(`audio ${res.status}`)
|
||
const arrayBuffer = await res.arrayBuffer()
|
||
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||
if (!AudioContextClass) throw new Error("AudioContext unavailable")
|
||
const ctx = new AudioContextClass()
|
||
try {
|
||
const buffer = await ctx.decodeAudioData(arrayBuffer.slice(0))
|
||
const data = buffer.getChannelData(0)
|
||
const bucket = Math.max(1, Math.floor(data.length / targetFrames))
|
||
let maxLoudness = 0.01
|
||
const raw: Array<{ loudness: number }> = []
|
||
for (let i = 0; i < targetFrames; i++) {
|
||
const start = i * bucket
|
||
const end = Math.min(data.length, start + bucket)
|
||
let peak = 0
|
||
let sumSq = 0
|
||
for (let j = start; j < end; j++) {
|
||
const sample = data[j] || 0
|
||
const abs = Math.abs(sample)
|
||
peak = Math.max(peak, abs)
|
||
sumSq += sample * sample
|
||
}
|
||
const size = Math.max(end - start, 1)
|
||
const rms = Math.sqrt(sumSq / size)
|
||
const loudness = Math.max(rms, peak * 0.18)
|
||
raw.push({ loudness })
|
||
maxLoudness = Math.max(maxLoudness, loudness)
|
||
}
|
||
const sorted = raw.map((item) => item.loudness).sort((a, b) => a - b)
|
||
const floor = sorted[Math.floor(sorted.length * 0.12)] ?? 0
|
||
const ceiling = sorted[Math.floor(sorted.length * 0.985)] ?? maxLoudness
|
||
const range = Math.max(ceiling - floor, maxLoudness * 0.08, 0.001)
|
||
return raw.map((item) => ({
|
||
loudness: clampNumber(Math.pow(clampNumber((item.loudness - floor) / range, 0, 1), 1.18), 0.015, 1),
|
||
}))
|
||
} finally {
|
||
void ctx.close().catch(() => {})
|
||
}
|
||
}
|
||
|
||
function waitForMediaEvent(target: HTMLMediaElement, eventName: string, timeoutMs = 12000) {
|
||
return new Promise<void>((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<void>((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<FilmstripPreviewFrame[]> {
|
||
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<WorkflowStepId, WorkflowStep>)
|
||
}
|
||
|
||
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<number>()
|
||
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<string | undefined>) {
|
||
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<AudioStoryboardRole, string[]> = {
|
||
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<AudioStoryboardRole, string[]> = {
|
||
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<string, string[]> = {
|
||
front: ["hero_packshot", "asymmetry"],
|
||
left_45: ["hero_packshot", "asymmetry", "button_detail"],
|
||
right_45: ["hero_packshot", "asymmetry", "button_detail"],
|
||
side_thickness: ["side_thickness", "wearing_scale"],
|
||
inner_contacts: ["inner_contact", "wearing_scale"],
|
||
back_bottom: ["back_bottom", "material_texture"],
|
||
}
|
||
return defaults[view] ?? ["hero_packshot"]
|
||
}
|
||
|
||
function normalizeProductUseTags(tags: string[] | undefined, view: string) {
|
||
const result: string[] = []
|
||
for (const tag of [...(tags ?? []), ...defaultProductUseTags(view)]) {
|
||
if (PRODUCT_USE_TAG_LABELS[tag] && !result.includes(tag)) result.push(tag)
|
||
}
|
||
return result.slice(0, 4)
|
||
}
|
||
|
||
function defaultProductLandmarks(view: string) {
|
||
const defaults: Record<string, string[]> = {
|
||
front: ["U形开口", "外壳主轮廓", "左右臂"],
|
||
left_45: ["佩戴者左侧臂", "侧边弧度", "按键/结构差异"],
|
||
right_45: ["佩戴者右侧臂", "侧边弧度", "按键/结构差异"],
|
||
side_thickness: ["机身厚度", "侧边轮廓", "佩戴比例"],
|
||
inner_contacts: ["贴颈内侧", "按摩触点", "皮肤接触面"],
|
||
back_bottom: ["背面/底部", "接口/底面", "材质细节"],
|
||
}
|
||
return defaults[view] ?? ["U形挂脖轮廓"]
|
||
}
|
||
|
||
function normalizeProductLandmarks(landmarks: string[] | undefined, view: string) {
|
||
const result: string[] = []
|
||
for (const item of [...(landmarks ?? []), ...defaultProductLandmarks(view)]) {
|
||
const text = item.trim()
|
||
if (text && !result.includes(text)) result.push(text)
|
||
}
|
||
return result.slice(0, 8)
|
||
}
|
||
|
||
function formatProductOrientation(orientation?: ProductViewAnalysisItem["orientation"]) {
|
||
if (!orientation) return ""
|
||
const parts = [
|
||
orientation.product_left ? `左=${orientation.product_left}` : "",
|
||
orientation.product_right ? `右=${orientation.product_right}` : "",
|
||
orientation.top ? `上=${orientation.top}` : "",
|
||
orientation.bottom ? `下=${orientation.bottom}` : "",
|
||
orientation.inner_side ? `内=${orientation.inner_side}` : "",
|
||
orientation.opening_direction ? `开口=${orientation.opening_direction}` : "",
|
||
].filter(Boolean)
|
||
return parts.join(";")
|
||
}
|
||
|
||
function createProductRefItem(
|
||
ref: ImageRef,
|
||
index: number,
|
||
source: ProductRefItem["source"] = "upload",
|
||
view?: string,
|
||
note?: string,
|
||
background = "unknown",
|
||
useTags?: string[],
|
||
orientation?: ProductViewAnalysisItem["orientation"],
|
||
landmarks?: string[],
|
||
risk = "",
|
||
confidence?: number,
|
||
): ProductRefItem {
|
||
const slot = PRODUCT_VIEW_SLOTS[index] ?? PRODUCT_VIEW_SLOTS[PRODUCT_VIEW_SLOTS.length - 1]
|
||
const targetSlot = PRODUCT_VIEW_SLOTS.find((item) => item.value === view) ?? slot
|
||
return {
|
||
id: productRefKey(ref, index),
|
||
ref,
|
||
view: view ?? targetSlot.value,
|
||
background,
|
||
useTags: normalizeProductUseTags(useTags, view ?? targetSlot.value),
|
||
orientation,
|
||
landmarks: normalizeProductLandmarks(landmarks, view ?? targetSlot.value),
|
||
note: note ?? targetSlot.hint,
|
||
risk,
|
||
source,
|
||
assetMeta: ref.asset_meta,
|
||
confidence,
|
||
}
|
||
}
|
||
|
||
const PRODUCT_ANGLE_REFERENCE_PRIORITY: Record<string, string[]> = {
|
||
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<string, ProductRefItem>()
|
||
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<AudioStoryboardRole, string[]> = {
|
||
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<AudioStoryboardRole, string[]> = {
|
||
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<string>()
|
||
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<string, string> = {
|
||
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<string, string> = {
|
||
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> | void
|
||
}) {
|
||
const { job, jobs, activeJobId } = data
|
||
const [url, setUrl] = useState("")
|
||
const [selectedVideoIds, setSelectedVideoIds] = useState<Set<string>>(new Set())
|
||
const [draftSegments, setDraftSegments] = useState<DraftSegment[]>([])
|
||
const [elementBusyFrame, setElementBusyFrame] = useState<number | null>(null)
|
||
const [sixViewBusyKey, setSixViewBusyKey] = useState<string | null>(null)
|
||
const [generatingAll, setGeneratingAll] = useState(false)
|
||
const [runtimeModels, setRuntimeModels] = useState<RuntimeModels | undefined>()
|
||
const [boardTheme, setBoardTheme] = useState<BoardThemeMode>("dark")
|
||
const [libraryOpen, setLibraryOpen] = useState(false)
|
||
const fileRef = useRef<HTMLInputElement | null>(null)
|
||
const selectedFrames = job
|
||
? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp)
|
||
: []
|
||
const framesForSegments = orderedFrames(job, selectedFrames)
|
||
const generatedVideos = job?.generated_videos ?? []
|
||
const audioReady = !!job?.audio_script?.source_text?.trim() || !!job?.transcript?.length
|
||
const readySegments = countReadySegments(job, draftSegments)
|
||
const transcriptCount = job?.transcript.length ?? 0
|
||
const backgroundReady = !!job?.audio_script?.background_audio_profile?.trim()
|
||
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<DraftSegment>) => {
|
||
setDraftSegments((prev) => prev.map((draft) => draft.id === id ? { ...draft, ...patch } : draft))
|
||
}
|
||
|
||
const removeDraftSegment = (id: string) => {
|
||
setDraftSegments((prev) => prev.filter((draft) => draft.id !== id))
|
||
}
|
||
|
||
const toggleVideo = (videoId: string) => {
|
||
setSelectedVideoIds((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(videoId)) next.delete(videoId)
|
||
else next.add(videoId)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const generateElementForFrame = async (frame: KeyFrame, candidate?: FrameObject, withSixViews = true) => {
|
||
if (!job) return
|
||
setElementBusyFrame(frame.index)
|
||
const candidateName = candidate?.name?.trim()
|
||
try {
|
||
let workingJob = job
|
||
let workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? frame
|
||
const existing = workingFrame.elements?.find((item) =>
|
||
candidateName
|
||
? [item.name_zh, item.name_en].some((name) => name?.trim() === candidateName)
|
||
: true,
|
||
)
|
||
const sourceObject = candidate ?? workingFrame.description?.objects?.[0]
|
||
const name = candidateName || sourceObject?.name?.trim() || existing?.name_zh || existing?.name_en || "主体"
|
||
let element = existing
|
||
|
||
if (!element) {
|
||
workingJob = await addElement(job.id, frame.index, {
|
||
name_zh: name,
|
||
name_en: name,
|
||
position: sourceObject?.position,
|
||
source: "manual",
|
||
})
|
||
data.onJobUpdate(workingJob)
|
||
workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? workingFrame
|
||
element = workingFrame.elements?.[workingFrame.elements.length - 1]
|
||
}
|
||
|
||
if (!element) {
|
||
toast.success(`已登记元素:${name}`)
|
||
return
|
||
}
|
||
|
||
if (!hasCutout(element)) {
|
||
workingJob = await cutoutElement(job.id, frame.index, element.id)
|
||
data.onJobUpdate(workingJob)
|
||
workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? workingFrame
|
||
element = workingFrame.elements?.find((item) => item.id === element?.id) ?? element
|
||
}
|
||
|
||
if (withSixViews && !element.subject_assets?.length) {
|
||
setSixViewBusyKey(`${frame.index}:${element.id}`)
|
||
workingJob = await generateSubjectAssets(job.id, frame.index, element.id, {
|
||
subject_kind: guessSubjectKind(name),
|
||
background: "white",
|
||
size: 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 (
|
||
<section className={`skg-board-theme ${boardTheme === "light" ? "skg-board-theme--light" : ""} relative z-20 h-screen w-screen overflow-hidden bg-black text-white`}>
|
||
<div className="skg-board-ambient pointer-events-none absolute inset-0" />
|
||
<div className="relative z-10 flex h-full flex-col px-4 py-4">
|
||
<header className="skg-board-topbar mb-3 flex items-center justify-between gap-4 rounded-lg border border-white/10 bg-white/[0.04] px-4 py-3">
|
||
<div className="skg-board-brand">
|
||
<div className="skg-board-brand__logo-chip" aria-hidden="true">
|
||
<img className="skg-board-brand__logo" src="/skg-logo-black.svg" alt="" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="skg-board-brand__system">未来健康 · 营销内容工作台</div>
|
||
<h1 className="skg-board-brand__title">营销内容工作台 · TK 二创</h1>
|
||
<p className="skg-board-brand__subtitle">信息流广告复刻生产线</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setLibraryOpen(true)}
|
||
className="skg-secondary-action inline-flex h-10 items-center gap-1.5 px-3 text-[11px] font-semibold transition"
|
||
title="打开全局资源中心"
|
||
>
|
||
<BookOpen className="h-3.5 w-3.5" />
|
||
资源库
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={toggleBoardTheme}
|
||
className="skg-board-theme-toggle skg-secondary-action inline-flex h-10 items-center gap-1.5 px-3 text-[11px] font-semibold transition"
|
||
title={boardTheme === "dark" ? "切换到明亮模式" : "切换到暗色模式"}
|
||
>
|
||
{boardTheme === "dark" ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
|
||
{boardTheme === "dark" ? "明亮" : "暗色"}
|
||
</button>
|
||
<div className="grid min-w-[520px] grid-cols-5 gap-2 text-[11px]">
|
||
<Metric label="素材" value={`${jobs.length}`} />
|
||
<Metric label="当前" value={shortId(activeJobId)} />
|
||
<Metric label="视频" value={job?.video_url ? "ready" : "-"} />
|
||
<Metric label="文案段" value={`${transcriptCount}`} />
|
||
<Metric label="背景音" value={backgroundReady ? "ready" : "-"} />
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)] gap-3">
|
||
<MaterialColumn
|
||
data={data}
|
||
step={workflow.input}
|
||
jobs={jobs}
|
||
job={job}
|
||
activeJobId={activeJobId}
|
||
url={url}
|
||
setUrl={setUrl}
|
||
fileRef={fileRef}
|
||
onSubmitUrl={submitUrl}
|
||
onStartProduction={startProduction}
|
||
/>
|
||
|
||
<section className="skg-board-panel flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.035] shadow-2xl">
|
||
<header className="shrink-0 border-b border-white/10 p-3">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[#d6b36a]/18 bg-[#d6b36a]/10 text-[#f2d58a]"><Mic className="h-3.5 w-3.5" /></span>
|
||
<WorkflowStepBadge step={workflow.source} compact />
|
||
<h2 className="text-[15px] font-semibold leading-tight text-white">视频拆解</h2>
|
||
</div>
|
||
<div className="mt-1 truncate text-[11px] text-white/38" title={statusMessage}>
|
||
{statusMessage || "下载源视频后解析音频,再抽参考帧并生成相似主体。"}
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<ModelTrace trace={audioModelTrace(runtimeModels)} compact />
|
||
<ActionButton disabled={!job?.video_url || audioRunning} onClick={() => data.onTranscribeAudio?.(job?.id)}>
|
||
<Mic className="h-3.5 w-3.5" />
|
||
解析音频
|
||
</ActionButton>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||
<AudioIntakePanel
|
||
job={job}
|
||
selectedFrames={data.selectedFrames}
|
||
onToggleFrame={data.onToggleFrame}
|
||
onJobUpdate={data.onJobUpdate}
|
||
onAddFrame={data.onAddManualFrameForJob}
|
||
onDeleteFrame={data.onDeleteFrameForJob}
|
||
runtimeModels={runtimeModels}
|
||
/>
|
||
<AudioStoryboardPlanPanel
|
||
job={job}
|
||
selectedFrames={data.selectedFrames}
|
||
onJobUpdate={data.onJobUpdate}
|
||
onDeleteVideo={data.onDeleteVideo}
|
||
runtimeModels={runtimeModels}
|
||
productStep={workflow.product}
|
||
scriptStep={workflow.script}
|
||
sceneStep={workflow.scene}
|
||
videoStep={workflow.video}
|
||
/>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
<LibraryDrawer
|
||
open={libraryOpen}
|
||
currentJobId={job?.id}
|
||
onClose={() => setLibraryOpen(false)}
|
||
onApplyAsset={applyLibraryAsset}
|
||
/>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
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<HTMLInputElement | null>
|
||
onSubmitUrl: () => void
|
||
onStartProduction: () => void
|
||
}) {
|
||
const actionLabel = !url.trim() && job?.status === "failed"
|
||
? job.video_url ? "重新解析" : "重新下载"
|
||
: "开始分析"
|
||
return (
|
||
<section className="skg-board-panel flex min-h-0 flex-col gap-3 rounded-lg border border-white/10 bg-white/[0.035] p-3 shadow-2xl">
|
||
<header className="shrink-0 border-b border-white/10 pb-3">
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-[#d6b36a]/18 bg-[#d6b36a]/10 text-[#f2d58a]"><Plus className="h-4 w-4" /></span>
|
||
<WorkflowStepBadge step={step} compact />
|
||
</div>
|
||
<h2 className="text-[15px] font-semibold leading-tight text-white">素材输入</h2>
|
||
<p className="mt-1 text-[12px] leading-snug text-white/42">一个素材就是一次文件任务</p>
|
||
</header>
|
||
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={url}
|
||
onChange={(e) => setUrl(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === "Enter") onSubmitUrl() }}
|
||
placeholder="粘贴 TK / 信息流视频链接"
|
||
className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/45 px-3 text-[13px] text-white outline-none placeholder:text-white/28 focus:border-[#d6b36a]/60"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={onStartProduction}
|
||
disabled={data.submitting || (!url.trim() && !job)}
|
||
className="skg-primary-action inline-flex h-10 items-center justify-center px-3 text-[13px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-45"
|
||
>
|
||
{actionLabel}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileRef.current?.click()}
|
||
className="skg-secondary-action inline-flex h-10 w-10 items-center justify-center transition disabled:cursor-not-allowed disabled:opacity-45"
|
||
aria-label="上传视频"
|
||
title="上传视频"
|
||
>
|
||
<Upload className="h-4 w-4" />
|
||
</button>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="video/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) data.onUploadFile(file)
|
||
e.currentTarget.value = ""
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
|
||
{jobs.length ? jobs.map((item, index) => (
|
||
<MaterialCard
|
||
key={item.id}
|
||
job={item}
|
||
index={index}
|
||
active={item.id === activeJobId}
|
||
onClick={() => data.onSwitchJob(item.id)}
|
||
onDelete={data.onDeleteJob ? () => data.onDeleteJob?.(item.id) : undefined}
|
||
/>
|
||
)) : (
|
||
<EmptyState text="还没有素材。每导入一个链接或上传一个文件,就会新增一个素材任务。" />
|
||
)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function AudioIntakePanel({
|
||
job,
|
||
selectedFrames,
|
||
onToggleFrame,
|
||
onJobUpdate,
|
||
onAddFrame,
|
||
onDeleteFrame,
|
||
runtimeModels,
|
||
}: {
|
||
job: Job | null
|
||
selectedFrames: Set<number>
|
||
onToggleFrame: (idx: number) => void
|
||
onJobUpdate: (job: Job) => void
|
||
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
|
||
onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
|
||
runtimeModels?: RuntimeModels
|
||
}) {
|
||
const [currentTime, setCurrentTime] = useState(0)
|
||
const [mediaDuration, setMediaDuration] = useState(0)
|
||
const [audioFeatures, setAudioFeatures] = useState<AudioFeature[]>([])
|
||
const [audioFeatureStatus, setAudioFeatureStatus] = useState<AudioFeatureStatus>("idle")
|
||
const [manualBusy, setManualBusy] = useState(false)
|
||
const [extracting, setExtracting] = useState(false)
|
||
const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
|
||
const [waveHoverTime, setWaveHoverTime] = useState<number | null>(null)
|
||
const [filmstripDensity, setFilmstripDensity] = useState<FilmstripDensitySeconds>(2)
|
||
const [filmstripPreviews, setFilmstripPreviews] = useState<FilmstripPreviewFrame[]>([])
|
||
const [filmstripStatus, setFilmstripStatus] = useState<FilmstripStatus>("idle")
|
||
const [filmstripDragTime, setFilmstripDragTime] = useState<number | null>(null)
|
||
const [filmstripBusyTime, setFilmstripBusyTime] = useState<number | null>(null)
|
||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||
const rowRefs = useRef<Record<number, HTMLDivElement | null>>({})
|
||
const syncFrameRef = useRef<number | null>(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 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 <EmptyState text="先在左侧粘贴 TK 链接或上传本地视频。点击开始后,会先下载视频,再自动解析原音频文案、讲话人节奏和背景音。" />
|
||
}
|
||
|
||
return (
|
||
<section className="rounded-lg border border-white/10 bg-black/28 p-2.5">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<SectionTitle icon={<Film className="h-4 w-4" />} title="源视频工作区" />
|
||
<div className="flex items-center gap-2 font-mono text-[11px] text-white/38">
|
||
<span>{job.transcript.length} 段</span>
|
||
<span>{formatSeconds(job.duration)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-2 border-t border-white/8 pt-2">
|
||
<div className="grid gap-2">
|
||
<div className="grid gap-3 xl:grid-cols-[430px_minmax(0,1fr)] 2xl:grid-cols-[460px_minmax(0,1fr)]">
|
||
<div className="min-w-0 space-y-2">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<SectionTitle icon={<Play className="h-4 w-4" />} title="原版视频" />
|
||
<span className="font-mono text-[11px] text-white/38">{currentTime.toFixed(1)}s</span>
|
||
</div>
|
||
<div className="relative mx-auto aspect-[9/16] h-[450px] overflow-hidden rounded-md border border-white/10 bg-black 2xl:h-[510px]">
|
||
{job.video_url ? (
|
||
<video
|
||
ref={videoRef}
|
||
controls
|
||
playsInline
|
||
className="h-full w-full bg-black object-contain"
|
||
src={videoSrcUrl}
|
||
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||
onSeeked={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||
onPlay={startFrameSync}
|
||
onPlaying={startFrameSync}
|
||
onPause={stopFrameSync}
|
||
onEnded={stopFrameSync}
|
||
onLoadedMetadata={(event) => {
|
||
setMediaDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)
|
||
setCurrentTime(event.currentTarget.currentTime)
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="flex h-full items-center justify-center text-[12px] text-white/38">等待原视频</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => void addFrameAtCurrentTime()}
|
||
disabled={!job.video_url || !onAddFrame || manualBusy || job.status === "splitting"}
|
||
title={`按当前播放位置手动抽帧:${currentTime.toFixed(1)}s`}
|
||
className="absolute right-2 top-2 inline-flex h-7 items-center justify-center gap-1 rounded-md border border-emerald-200/30 bg-black/78 px-2 text-[10.5px] font-semibold text-emerald-100 shadow-lg backdrop-blur transition hover:border-emerald-100/65 hover:bg-emerald-300/18 disabled:cursor-not-allowed disabled:opacity-35"
|
||
>
|
||
{manualBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||
当前点抽帧
|
||
</button>
|
||
</div>
|
||
<TranscriptTimelinePanel
|
||
job={job}
|
||
processing={processing}
|
||
activeSegmentIndex={activeSegment?.index ?? null}
|
||
rowRefs={rowRefs}
|
||
onSeek={seekTo}
|
||
/>
|
||
</div>
|
||
|
||
<div className="min-w-0 space-y-2">
|
||
<div className="relative z-40 overflow-visible rounded-md border border-white/10 bg-black/32 p-2">
|
||
<div className="mb-1 flex items-center justify-end gap-3 text-[10px] text-white/40">
|
||
<FilmstripDensityControls
|
||
density={filmstripDensity}
|
||
onDensityChange={setFilmstripDensity}
|
||
/>
|
||
<div className="flex items-center gap-2 font-mono">
|
||
<span>当前 {currentTime.toFixed(1)}s</span>
|
||
<span>总 {formatSeconds(timelineDuration)}</span>
|
||
<span>{waveTimeHint}</span>
|
||
</div>
|
||
</div>
|
||
<AudioWaveform
|
||
features={audioFeatures}
|
||
status={audioFeatureStatus}
|
||
currentTime={currentTime}
|
||
hoverTime={waveHoverTime}
|
||
duration={timelineDuration}
|
||
segments={job.transcript}
|
||
onSeek={seekTo}
|
||
onHoverTimeChange={setWaveHoverTime}
|
||
/>
|
||
<TimelineFilmstrip
|
||
frames={filmstripPreviews}
|
||
status={filmstripStatus}
|
||
density={filmstripDensity}
|
||
duration={timelineDuration}
|
||
currentTime={currentTime}
|
||
hoverTime={waveHoverTime}
|
||
selectedTimes={frames.map((frame) => frame.timestamp)}
|
||
busyTime={filmstripBusyTime}
|
||
onSeek={seekTo}
|
||
onDragStart={setFilmstripDragTime}
|
||
onDragEnd={() => setFilmstripDragTime(null)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="min-w-0">
|
||
<SourceSubjectPipeline
|
||
job={job}
|
||
frames={frames}
|
||
selectedFrames={selectedFrames}
|
||
extracting={extracting}
|
||
deletingFrame={deletingFrame}
|
||
onToggleFrame={onToggleFrame}
|
||
onExtract={() => void extractKeyframes()}
|
||
onDeleteFrame={onDeleteFrame ? (idx) => void deleteReferenceFrame(idx) : undefined}
|
||
onJobUpdate={onJobUpdate}
|
||
runtimeModels={runtimeModels}
|
||
filmstripDragging={filmstripDragTime !== null}
|
||
onDropFilmstripFrame={(time) => void addFilmstripFrame(time)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function TranscriptTimelinePanel({
|
||
job,
|
||
processing,
|
||
activeSegmentIndex,
|
||
rowRefs,
|
||
onSeek,
|
||
}: {
|
||
job: Job
|
||
processing: boolean
|
||
activeSegmentIndex: number | null
|
||
rowRefs: { current: Record<number, HTMLDivElement | null> }
|
||
onSeek: (time: number) => void
|
||
}) {
|
||
return (
|
||
<div className="min-w-0">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<SectionTitle icon={<FileText className="h-4 w-4" />} title="逐句时间轴" />
|
||
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">{job.transcript.length} 段</span>
|
||
</div>
|
||
{job.transcript.length ? (
|
||
<div className="overflow-hidden rounded-md border border-white/10">
|
||
<div className="grid grid-cols-[68px_minmax(0,1fr)] border-b border-white/10 bg-white/[0.04] px-2.5 py-1.5 text-[11px] font-semibold text-white/50">
|
||
<div>时间</div>
|
||
<div>原文 / 中文</div>
|
||
</div>
|
||
<div className="max-h-[252px] overflow-y-auto 2xl:max-h-[306px]">
|
||
{job.transcript.map((segment) => {
|
||
const active = activeSegmentIndex === segment.index
|
||
return (
|
||
<div
|
||
key={segment.index}
|
||
ref={(node) => { rowRefs.current[segment.index] = node }}
|
||
onClick={() => onSeek(segment.start)}
|
||
className={`grid cursor-pointer grid-cols-[68px_minmax(0,1fr)] items-start gap-2 border-b px-2.5 py-1.5 text-[11.5px] leading-snug transition last:border-b-0 ${
|
||
active
|
||
? "border-emerald-300/18 bg-emerald-300/[0.12] text-white"
|
||
: "border-white/8 text-white/64 hover:bg-white/[0.045]"
|
||
}`}
|
||
>
|
||
<div className={`pt-0.5 font-mono text-[10px] leading-tight ${active ? "text-emerald-100" : "text-white/38"}`}>{segment.start.toFixed(1)}-{segment.end.toFixed(1)}s</div>
|
||
<div className="min-w-0">
|
||
<div className="line-clamp-2 break-words" title={segment.en}>{segment.en || <span className="text-white/30">-</span>}</div>
|
||
<div className={`mt-0.5 line-clamp-2 break-words text-[11px] ${active ? "text-emerald-50/80" : "text-white/42"}`} title={segment.zh}>{segment.zh || <span className="text-white/30">翻译中</span>}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<EmptyState text={processing ? "音频解析中,完成后这里会按时间列出原文案和中文翻译。" : "下载完成后会自动解析音频;也可以点击右上角“解析音频”手动重试。"} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TimelineFilmstrip({
|
||
frames,
|
||
status,
|
||
density,
|
||
duration,
|
||
currentTime,
|
||
hoverTime,
|
||
selectedTimes,
|
||
busyTime,
|
||
onSeek,
|
||
onDragStart,
|
||
onDragEnd,
|
||
}: {
|
||
frames: FilmstripPreviewFrame[]
|
||
status: FilmstripStatus
|
||
density: FilmstripDensitySeconds
|
||
duration: number
|
||
currentTime: number
|
||
hoverTime: number | null
|
||
selectedTimes: number[]
|
||
busyTime: number | null
|
||
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)
|
||
const [hoverPreview, setHoverPreview] = useState<FilmstripHoverPreview | null>(null)
|
||
|
||
const showHoverPreview = (
|
||
event: ReactMouseEvent<HTMLDivElement>,
|
||
frame: FilmstripPreviewFrame,
|
||
active: boolean,
|
||
selected: boolean,
|
||
busy: boolean,
|
||
) => {
|
||
const tile = event.currentTarget.querySelector("[data-filmstrip-tile]")
|
||
const rect = (tile instanceof HTMLElement ? tile : event.currentTarget).getBoundingClientRect()
|
||
const width = rect.width * FILMSTRIP_HOVER_SCALE
|
||
const height = rect.height * FILMSTRIP_HOVER_SCALE
|
||
const margin = 10
|
||
const left = clampNumber(rect.left + rect.width / 2 - width / 2, margin, Math.max(margin, window.innerWidth - width - margin))
|
||
const top = clampNumber(rect.bottom - height - 12, margin, Math.max(margin, window.innerHeight - height - margin))
|
||
setHoverPreview({
|
||
src: frame.src,
|
||
time: frame.time,
|
||
left,
|
||
top,
|
||
width,
|
||
height,
|
||
active,
|
||
selected,
|
||
busy,
|
||
})
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!frames.length) setHoverPreview(null)
|
||
}, [frames.length])
|
||
|
||
return (
|
||
<div className="relative z-[80] mt-1 overflow-visible pt-0.5">
|
||
<div className="relative h-[172px] overflow-visible">
|
||
{status === "loading" ? (
|
||
<div className="absolute inset-x-0 top-12 flex h-[72px] items-center justify-center gap-2 rounded-md border border-dashed border-white/12 text-[11px] text-white/40">
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
正在生成临时胶片
|
||
</div>
|
||
) : status === "failed" ? (
|
||
<div className="absolute inset-x-0 top-12 flex h-[72px] items-center justify-center rounded-md border border-dashed border-rose-200/20 text-[11px] text-rose-100/68">
|
||
胶片预览生成失败,可继续用当前点抽帧。
|
||
</div>
|
||
) : frames.length ? (
|
||
<div className="absolute bottom-7 left-0 right-0 top-1 overflow-visible">
|
||
{hoverPct !== null && (
|
||
<div
|
||
className="pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-cyan-100/55"
|
||
style={{ left: `${hoverPct}%` }}
|
||
/>
|
||
)}
|
||
<div
|
||
className="pointer-events-none absolute bottom-0 top-0 z-10 w-[2px] bg-emerald-200 shadow-[0_0_16px_rgba(110,231,183,0.8)]"
|
||
style={{ left: `${pointerPct}%` }}
|
||
/>
|
||
{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 verticalClass = FILMSTRIP_VERTICAL_OFFSET_CLASSES[index % FILMSTRIP_VERTICAL_OFFSET_CLASSES.length]
|
||
const framePct = clampNumber((frame.time / Math.max(duration, 1)) * 100, 0, 100)
|
||
return (
|
||
<div
|
||
key={`${frame.time}-${index}`}
|
||
draggable={!busy}
|
||
onMouseEnter={(event) => showHoverPreview(event, frame, active, selected, busy)}
|
||
onMouseMove={(event) => showHoverPreview(event, frame, active, selected, busy)}
|
||
onMouseLeave={() => setHoverPreview(null)}
|
||
onDragStart={(event) => {
|
||
setHoverPreview(null)
|
||
event.dataTransfer.setData(FILMSTRIP_DRAG_TYPE, frame.time.toFixed(2))
|
||
event.dataTransfer.effectAllowed = "copy"
|
||
onDragStart(frame.time)
|
||
}}
|
||
onDragEnd={onDragEnd}
|
||
className={`absolute bottom-[58px] z-20 -translate-x-1/2 ${verticalClass} ${tiltClass} origin-bottom cursor-grab transition-transform duration-150 will-change-transform hover:z-[90] hover:-translate-y-1 hover:rotate-0 active:cursor-grabbing`}
|
||
style={{ left: `${framePct}%` }}
|
||
title={`${frame.time.toFixed(1)}s · 拖到关键帧库才选取`}
|
||
>
|
||
<div className="absolute left-1/2 top-full h-4 w-px -translate-x-1/2 bg-white/18" />
|
||
<div data-filmstrip-tile className="h-[72px] w-[42px]">
|
||
<MediaAssetTile
|
||
src={frame.src}
|
||
alt={`胶片 ${frame.time.toFixed(1)}s`}
|
||
label="临时胶片"
|
||
meta={`${frame.time.toFixed(1)}s`}
|
||
className={`h-full w-full rounded-md shadow-[0_10px_26px_rgba(0,0,0,0.36)] ${
|
||
active ? "ring-1 ring-[#d6b36a]/75" : ""
|
||
}`}
|
||
mediaClassName="bg-black"
|
||
objectFit="contain"
|
||
disablePreview
|
||
selected={selected}
|
||
onClick={() => onSeek(frame.time)}
|
||
title="点击跳到该时间点,拖入关键帧库才正式选取"
|
||
topRight={busy ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : selected ? <Check className="h-3 w-3 text-emerald-200" /> : undefined}
|
||
bottom={<span className="block rounded bg-black/74 px-1 py-0.5 text-center font-mono text-[9px] text-white/68">{frame.time.toFixed(1)}s</span>}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="absolute inset-x-0 top-12 flex h-[72px] items-center justify-center rounded-md border border-dashed border-white/12 text-[11px] text-white/34">
|
||
等待原视频生成临时胶片
|
||
</div>
|
||
)}
|
||
<div className="pointer-events-none absolute bottom-2 left-0 right-0 flex items-center justify-between font-mono text-[9.5px] text-white/30">
|
||
<span>0s</span>
|
||
<span>{formatSeconds(duration)}</span>
|
||
</div>
|
||
</div>
|
||
{hoverPreview && typeof document !== "undefined"
|
||
? createPortal(
|
||
<div
|
||
className="pointer-events-none fixed z-[10000]"
|
||
style={{
|
||
left: hoverPreview.left,
|
||
top: hoverPreview.top,
|
||
width: hoverPreview.width,
|
||
height: hoverPreview.height,
|
||
}}
|
||
>
|
||
<MediaAssetTile
|
||
src={hoverPreview.src}
|
||
alt={`胶片 ${hoverPreview.time.toFixed(1)}s`}
|
||
label="临时胶片"
|
||
meta={`${hoverPreview.time.toFixed(1)}s`}
|
||
className={`h-full w-full rounded-xl shadow-[0_24px_70px_rgba(0,0,0,0.45)] ${
|
||
hoverPreview.active ? "ring-1 ring-[#d6b36a]/80" : ""
|
||
}`}
|
||
mediaClassName="bg-black"
|
||
objectFit="contain"
|
||
disablePreview
|
||
selected={hoverPreview.selected}
|
||
topRight={hoverPreview.busy ? <Loader2 className="h-6 w-6 animate-spin text-cyan-100" /> : hoverPreview.selected ? <Check className="h-6 w-6 text-emerald-200" /> : undefined}
|
||
bottom={<span className="block rounded-md bg-black/74 px-2 py-1 text-center font-mono text-[42px] leading-none text-white/68">{hoverPreview.time.toFixed(1)}s</span>}
|
||
/>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
: null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function FilmstripDensityControls({
|
||
density,
|
||
onDensityChange,
|
||
}: {
|
||
density: FilmstripDensitySeconds
|
||
onDensityChange: (density: FilmstripDensitySeconds) => void
|
||
}) {
|
||
return (
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
{FILMSTRIP_DENSITIES.map((item) => (
|
||
<button
|
||
key={item.value}
|
||
type="button"
|
||
onClick={() => onDensityChange(item.value)}
|
||
aria-label={`胶片密度:${item.detail}`}
|
||
className={`h-6 rounded-md border px-2 text-[10.5px] font-semibold transition ${
|
||
density === item.value
|
||
? "border-[#d6b36a]/70 bg-[#d6b36a]/18 text-[#f7df9a]"
|
||
: "border-white/10 bg-white/[0.035] text-white/48 hover:border-white/22 hover:text-white/72"
|
||
}`}
|
||
title={item.detail}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SourceSubjectPipeline({
|
||
job,
|
||
frames,
|
||
selectedFrames,
|
||
extracting,
|
||
deletingFrame,
|
||
onToggleFrame,
|
||
onExtract,
|
||
onDeleteFrame,
|
||
onJobUpdate,
|
||
runtimeModels,
|
||
filmstripDragging,
|
||
onDropFilmstripFrame,
|
||
}: {
|
||
job: Job
|
||
frames: KeyFrame[]
|
||
selectedFrames: Set<number>
|
||
extracting: boolean
|
||
deletingFrame: number | null
|
||
onToggleFrame: (idx: number) => void
|
||
onExtract: () => void
|
||
onDeleteFrame?: (idx: number) => void
|
||
onJobUpdate: (job: Job) => void
|
||
runtimeModels?: RuntimeModels
|
||
filmstripDragging?: boolean
|
||
onDropFilmstripFrame?: (time: number) => void
|
||
}) {
|
||
const [referenceDropActive, setReferenceDropActive] = useState(false)
|
||
const [conversionDropActive, setConversionDropActive] = useState(false)
|
||
const [conversionFrameIndices, setConversionFrameIndices] = useState<number[]>([])
|
||
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
|
||
const [subjectViewMode, setSubjectViewMode] = useState<SubjectPipelineViewMode>("all")
|
||
const [subjectDirection, setSubjectDirection] = useState("")
|
||
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; viewCount: number; sourceCount: number; profileLabel: string } | null>(null)
|
||
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
|
||
const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
|
||
const subjectBusy = !!subjectBusyFor
|
||
const selectedSubjectViews = subjectViewMode === "common"
|
||
? COMMON_SUBJECT_VIEW_VALUES
|
||
: SUBJECT_ASSET_VIEWS.map((view) => view.value)
|
||
const conversionFrames = useMemo(
|
||
() => conversionFrameIndices
|
||
.map((index) => frames.find((frame) => frame.index === index))
|
||
.filter((frame): frame is KeyFrame => !!frame),
|
||
[conversionFrameIndices, frames],
|
||
)
|
||
const actorSource = useMemo(
|
||
() => findSimilarActorSource(conversionFrames.length ? conversionFrames : frames, frames),
|
||
[conversionFrames, frames],
|
||
)
|
||
const visibleActorAssets = useMemo(() => {
|
||
const latestByView = new Map<string, SubjectAsset>()
|
||
for (const asset of actorSource?.element.subject_assets ?? []) {
|
||
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)
|
||
})
|
||
}, [actorSource])
|
||
|
||
useEffect(() => {
|
||
setConversionFrameIndices([])
|
||
setLastSubjectProfile(null)
|
||
setSubjectBusyFor(null)
|
||
setSubjectAssetBusy(null)
|
||
}, [job.id])
|
||
|
||
useEffect(() => {
|
||
setConversionFrameIndices((current) => current.filter((index) => frames.some((frame) => frame.index === index)))
|
||
}, [frames])
|
||
|
||
const buildSubjectProfileForRequest = () => {
|
||
const resolved = resolveSubjectProfile("random", randomSubjectProfileDraft())
|
||
setLastSubjectProfile(resolved)
|
||
return resolved
|
||
}
|
||
|
||
const generateSubjectPack = async (sourceIndices = conversionFrameIndices) => {
|
||
if (subjectBusyFor) {
|
||
toast.warning("主体套图正在生成中,完成后再重生。")
|
||
return
|
||
}
|
||
const sourceFrames = sourceIndices
|
||
.map((index) => frames.find((frame) => frame.index === index))
|
||
.filter((frame): frame is KeyFrame => !!frame)
|
||
if (!sourceFrames.length) {
|
||
toast.warning("先把参考帧拖到转换层。")
|
||
return
|
||
}
|
||
const baseFrame = sourceFrames[0]
|
||
const requestJobId = job.id
|
||
const requestProfile = buildSubjectProfileForRequest()
|
||
setSubjectBusyFor({
|
||
jobId: requestJobId,
|
||
jobLabel: shortId(requestJobId),
|
||
viewCount: selectedSubjectViews.length,
|
||
sourceCount: sourceFrames.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: subjectStyle === "transparent_human" ? "参考创新透明骨架主体" : "参考创新广告主角",
|
||
name_en: subjectStyle === "transparent_human" ? "reference inspired transparent skeleton humanoid subject" : "reference inspired ad actor",
|
||
position: "generated from conversion layer reference frames",
|
||
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("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: sourceFrames.slice(0, 8).map((frame) => frame.index),
|
||
views: selectedSubjectViews,
|
||
subject_profile: requestProfile.payload,
|
||
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, null, 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 addConversionFrame = (frame: KeyFrame) => {
|
||
const existed = conversionFrameIndices.includes(frame.index)
|
||
const next = existed
|
||
? conversionFrameIndices
|
||
: [...conversionFrameIndices, frame.index].slice(0, 6)
|
||
setConversionFrameIndices(next)
|
||
if (existed) {
|
||
toast.info("这张参考帧已经在转换层。")
|
||
return
|
||
}
|
||
toast.info(`已加入转换层:${frame.timestamp.toFixed(1)}s,开始生成主体套图。`)
|
||
void generateSubjectPack(next)
|
||
}
|
||
|
||
const removeConversionFrame = (frameIndex: number) => {
|
||
setConversionFrameIndices((current) => current.filter((index) => index !== frameIndex))
|
||
}
|
||
|
||
const regenerateSubjectAsset = async (asset: SubjectAsset) => {
|
||
if (!actorSource) return
|
||
const sourceIndices = asset.source_frame_indices?.length
|
||
? asset.source_frame_indices
|
||
: conversionFrames.map((frame) => frame.index)
|
||
if (!sourceIndices.length) {
|
||
toast.warning("转换层没有参考帧,不能重生。")
|
||
return
|
||
}
|
||
setSubjectAssetBusy(`regen:${asset.id}`)
|
||
try {
|
||
const requestProfile = lastSubjectProfile ?? buildSubjectProfileForRequest()
|
||
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],
|
||
subject_profile: requestProfile.payload,
|
||
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, null, 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)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-2 xl:grid-cols-[150px_minmax(210px,0.75fr)_minmax(0,1.25fr)] 2xl:grid-cols-[170px_minmax(240px,0.8fr)_minmax(0,1.3fr)]">
|
||
<div className="min-w-0">
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="参考帧池" />
|
||
<button
|
||
type="button"
|
||
onClick={onExtract}
|
||
disabled={!job.video_url || extracting || job.status === "splitting"}
|
||
title="自动按动作峰值抽 12 张参考帧"
|
||
className="skg-primary-action inline-flex h-7 items-center justify-center gap-1 px-2 text-[10px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{extracting || job.status === "splitting" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
||
12
|
||
</button>
|
||
</div>
|
||
<div
|
||
className={`rounded-md border p-1.5 transition ${
|
||
filmstripDragging
|
||
? referenceDropActive
|
||
? "border-[#d6b36a]/80 bg-[#d6b36a]/12 ring-1 ring-[#d6b36a]/45"
|
||
: "border-[#d6b36a]/45 bg-[#d6b36a]/[0.065]"
|
||
: "border-white/10 bg-black/32"
|
||
}`}
|
||
onDragEnter={(event) => {
|
||
if (!onDropFilmstripFrame) return
|
||
event.preventDefault()
|
||
setReferenceDropActive(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
|
||
setReferenceDropActive(false)
|
||
}}
|
||
onDrop={(event) => {
|
||
if (!onDropFilmstripFrame) return
|
||
event.preventDefault()
|
||
setReferenceDropActive(false)
|
||
const raw = event.dataTransfer.getData(FILMSTRIP_DRAG_TYPE)
|
||
const time = Number(raw)
|
||
if (Number.isFinite(time)) onDropFilmstripFrame(time)
|
||
}}
|
||
>
|
||
<div className="mb-1 flex items-center justify-between gap-2 text-[9.5px] text-white/30">
|
||
<span>{frames.length} 张</span>
|
||
<span>{filmstripDragging ? "松手加入" : "拖到转换层"}</span>
|
||
</div>
|
||
<div className="flex max-h-[410px] flex-col gap-1 overflow-y-auto pr-0.5 2xl:max-h-[500px]">
|
||
{frames.map((frame, index) => {
|
||
const selected = selectedFrames.has(frame.index)
|
||
return (
|
||
<div
|
||
key={frame.index}
|
||
draggable
|
||
onDragStart={(event) => {
|
||
event.dataTransfer.setData(SOURCE_KEYFRAME_DRAG_TYPE, String(frame.index))
|
||
event.dataTransfer.effectAllowed = "copy"
|
||
}}
|
||
className="relative"
|
||
>
|
||
<MediaAssetTile
|
||
src={effectiveFrameUrl(job.id, frame)}
|
||
alt={`参考帧 ${index + 1}`}
|
||
label={`参考帧 ${String(index + 1).padStart(2, "0")}`}
|
||
meta={`${frame.timestamp.toFixed(1)}s`}
|
||
className="mx-auto aspect-[9/16] w-[72px] 2xl:w-[80px]"
|
||
objectFit="contain"
|
||
previewObjectFit="contain"
|
||
previewPlacement="left"
|
||
previewMaxWidth={320}
|
||
previewClassName="p-2"
|
||
selected={selected || conversionFrameIndices.includes(frame.index)}
|
||
title={`${selected ? "已选 · 点击取消" : "点击选择"} · 拖到转换层生成主体套图`}
|
||
onClick={() => onToggleFrame(frame.index)}
|
||
topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
|
||
topRight={<span className="rounded-full bg-black/72 p-0.5">{conversionFrameIndices.includes(frame.index) ? <Sparkles className="h-3 w-3 text-[#f5d98e]" /> : selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}</span>}
|
||
onDelete={onDeleteFrame ? () => onDeleteFrame(frame.index) : undefined}
|
||
deleting={deletingFrame === frame.index}
|
||
deleteLabel={`删除参考帧 ${index + 1}`}
|
||
/>
|
||
</div>
|
||
)
|
||
})}
|
||
{!frames.length && (
|
||
<div className="flex h-28 items-center justify-center rounded border border-dashed border-white/12 px-2 text-center text-[10.5px] leading-snug text-white/34">
|
||
自动抽帧,或从上方胶片拖入。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-w-0">
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<SectionTitle icon={<Wand2 className="h-4 w-4" />} title="转换层" />
|
||
<ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectStyle)} compact />
|
||
</div>
|
||
<div
|
||
className={`min-h-[410px] rounded-md border p-2 transition 2xl:min-h-[500px] ${
|
||
conversionDropActive ? "border-[#d6b36a]/80 bg-[#d6b36a]/12 ring-1 ring-[#d6b36a]/45" : "border-white/10 bg-black/32"
|
||
}`}
|
||
onDragEnter={(event) => {
|
||
if (!Array.from(event.dataTransfer.types).includes(SOURCE_KEYFRAME_DRAG_TYPE)) return
|
||
event.preventDefault()
|
||
setConversionDropActive(true)
|
||
}}
|
||
onDragOver={(event) => {
|
||
if (!Array.from(event.dataTransfer.types).includes(SOURCE_KEYFRAME_DRAG_TYPE)) return
|
||
event.preventDefault()
|
||
event.dataTransfer.dropEffect = "copy"
|
||
}}
|
||
onDragLeave={(event) => {
|
||
const next = event.relatedTarget as Node | null
|
||
if (next && event.currentTarget.contains(next)) return
|
||
setConversionDropActive(false)
|
||
}}
|
||
onDrop={(event) => {
|
||
event.preventDefault()
|
||
setConversionDropActive(false)
|
||
const frameIndex = Number(event.dataTransfer.getData(SOURCE_KEYFRAME_DRAG_TYPE))
|
||
const frame = frames.find((item) => item.index === frameIndex)
|
||
if (frame) addConversionFrame(frame)
|
||
}}
|
||
>
|
||
<div className="mb-2 rounded-md border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] px-2.5 py-2 text-[10px] leading-snug text-white/62">
|
||
拖入 1-2 张参考帧后自动生成主体套图;这里做参考创新,不抠原图。
|
||
</div>
|
||
<div className="mb-2 flex max-h-[224px] flex-col gap-1.5 overflow-y-auto pr-0.5 2xl:max-h-[286px]">
|
||
{conversionFrames.map((frame, index) => (
|
||
<div key={frame.index} className="relative">
|
||
<MediaAssetTile
|
||
src={effectiveFrameUrl(job.id, frame)}
|
||
alt={`转换参考 ${index + 1}`}
|
||
label={`转换参考 ${index + 1}`}
|
||
meta={`${frame.timestamp.toFixed(1)}s`}
|
||
className="mx-auto aspect-[9/16] w-[72px] 2xl:w-[80px]"
|
||
objectFit="contain"
|
||
disablePreview
|
||
topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeConversionFrame(frame.index)}
|
||
className="absolute right-1 top-1 z-20 inline-flex h-5 w-5 items-center justify-center rounded-full border border-rose-100/35 bg-black/78 text-rose-100 transition hover:border-rose-100/70 hover:bg-rose-500/25"
|
||
aria-label="移出转换层"
|
||
title="移出转换层"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
{!conversionFrames.length ? (
|
||
<div className="flex h-28 items-center justify-center rounded border border-dashed border-white/12 px-3 text-center text-[10.5px] leading-snug text-white/34">
|
||
把左侧参考帧拖到这里。
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="grid grid-cols-2 gap-1">
|
||
{[
|
||
{ value: "transparent_human" as const, label: "透明骨架" },
|
||
{ value: "source_actor" as const, label: "真人" },
|
||
].map((item) => (
|
||
<button
|
||
key={item.value}
|
||
type="button"
|
||
onClick={() => setSubjectStyle(item.value)}
|
||
className={`h-8 rounded-md border px-2 text-[10.5px] font-semibold transition ${
|
||
subjectStyle === item.value ? "border-white bg-white text-black" : "border-white/10 bg-black/24 text-white/45 hover:text-white"
|
||
}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-1">
|
||
{[
|
||
{ value: "all" as const, label: `完整 ${SUBJECT_ASSET_VIEWS.length}` },
|
||
{ value: "common" as const, label: `常用 ${COMMON_SUBJECT_VIEW_VALUES.length}` },
|
||
].map((item) => (
|
||
<button
|
||
key={item.value}
|
||
type="button"
|
||
onClick={() => setSubjectViewMode(item.value)}
|
||
className={`h-8 rounded-md border px-2 text-[10.5px] font-semibold transition ${
|
||
subjectViewMode === item.value ? "border-white bg-white text-black" : "border-white/10 bg-black/24 text-white/45 hover:text-white"
|
||
}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<input
|
||
value={subjectDirection}
|
||
onChange={(event) => setSubjectDirection(event.target.value)}
|
||
placeholder="统一方向:更年轻 / 更高级 / 运动感"
|
||
className="h-9 w-full rounded-md border border-white/10 bg-black/35 px-2.5 text-[11px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => void generateSubjectPack()}
|
||
disabled={!conversionFrames.length || subjectBusy || !selectedSubjectViews.length}
|
||
className="skg-primary-action inline-flex h-9 w-full items-center justify-center gap-1 px-3 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{subjectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||
{subjectBusyFor ? `生成中 · ${subjectBusyFor.sourceCount} 参考` : `生成 ${selectedSubjectViews.length} 张主体套图`}
|
||
</button>
|
||
{lastSubjectProfile ? (
|
||
<div className="rounded border border-cyan-200/16 bg-cyan-300/[0.055] px-2 py-1.5 text-[9.5px] leading-snug text-cyan-50/62">
|
||
上次锁定人设:{lastSubjectProfile.summary}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-w-0">
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="主体元素" />
|
||
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[10px] text-white/42">
|
||
{visibleActorAssets.length ? `${visibleActorAssets.length} 张` : "待生成"}
|
||
</span>
|
||
</div>
|
||
<div className="min-h-[410px] rounded-md border border-white/10 bg-black/32 p-2 2xl:min-h-[500px]">
|
||
{subjectBusyFor ? (
|
||
<div className="mb-2 rounded-md border border-cyan-200/20 bg-cyan-300/[0.07] px-2.5 py-2 text-[10px] leading-snug text-cyan-50/70">
|
||
正在生成 {subjectBusyFor.viewCount} 张主体元素;参考帧 {subjectBusyFor.sourceCount} 张。
|
||
<span className="mt-1 block text-cyan-50/58">主体设定:{subjectBusyFor.profileLabel}</span>
|
||
</div>
|
||
) : null}
|
||
{visibleActorAssets.length ? (
|
||
<div className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-2 2xl:grid-cols-[repeat(auto-fill,minmax(88px,1fr))]">
|
||
{visibleActorAssets.map((asset) => {
|
||
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
|
||
return (
|
||
<MediaAssetTile
|
||
key={asset.id}
|
||
src={subjectAssetUrl(job, asset)}
|
||
href={subjectAssetUrl(job, asset)}
|
||
alt={asset.label || asset.view}
|
||
label={asset.label || subjectViewLabel(asset.view)}
|
||
meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
|
||
className="aspect-[9/16] bg-white"
|
||
objectFit="contain"
|
||
title={asset.label || subjectViewLabel(asset.view)}
|
||
actions={[{
|
||
key: "regen",
|
||
label: "重新生成这一张",
|
||
icon: <RefreshCw className="h-3 w-3" />,
|
||
tone: "cyan",
|
||
busy: busyMode === "regen",
|
||
disabled: !!subjectAssetBusy || subjectBusy,
|
||
onClick: () => void regenerateSubjectAsset(asset),
|
||
}]}
|
||
onDelete={() => void deleteActorAsset(asset)}
|
||
deleting={busyMode === "delete"}
|
||
deleteDisabled={!!subjectAssetBusy || subjectBusy}
|
||
deleteLabel="删除这一张"
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="flex h-40 items-center justify-center rounded border border-dashed border-white/12 px-3 text-center text-[10.5px] leading-snug text-white/34">
|
||
转换层生成完成后,这里会展示可用于后续分镜的主体套图。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SourceKeyframePicker({
|
||
job,
|
||
frames,
|
||
selectedFrames,
|
||
selectedReferenceFrames,
|
||
extracting,
|
||
deletingFrame,
|
||
onToggleFrame,
|
||
onExtract,
|
||
onDeleteFrame,
|
||
filmstripDragging,
|
||
onDropFilmstripFrame,
|
||
}: {
|
||
job: Job
|
||
frames: KeyFrame[]
|
||
selectedFrames: Set<number>
|
||
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 (
|
||
<div className="min-w-0">
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="关键帧" />
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[10px] text-white/42">
|
||
已选 {selectedReferenceFrames.length || "全部"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={onExtract}
|
||
disabled={!job.video_url || extracting || job.status === "splitting"}
|
||
title="自动按动作峰值抽 12 张参考帧,更偏向手势、表情变化、节奏点和镜头变化"
|
||
className="skg-primary-action inline-flex h-7 items-center justify-center gap-1 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{extracting || job.status === "splitting" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
||
自动 12 张
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className={`min-h-[205px] rounded-md border p-1.5 transition 2xl:min-h-[260px] ${
|
||
filmstripDragging
|
||
? dropActive
|
||
? "border-[#d6b36a]/80 bg-[#d6b36a]/12 ring-1 ring-[#d6b36a]/45"
|
||
: "border-[#d6b36a]/45 bg-[#d6b36a]/[0.065]"
|
||
: "border-white/10 bg-black/32"
|
||
}`}
|
||
onDragEnter={(event) => {
|
||
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)
|
||
}}
|
||
>
|
||
<div className="mb-1 flex items-center justify-between gap-2">
|
||
<span className="text-[10px] text-white/34">参考帧池</span>
|
||
<span className="text-[9.5px] text-white/28">{filmstripDragging ? "松手加入关键帧" : "拖入胶片选帧,悬停放大"}</span>
|
||
</div>
|
||
<div className="grid max-h-[178px] grid-cols-[repeat(auto-fill,minmax(38px,1fr))] gap-1 overflow-y-auto pr-0.5 2xl:max-h-[232px]">
|
||
{frames.map((frame, index) => {
|
||
const selected = selectedFrames.has(frame.index)
|
||
return (
|
||
<MediaAssetTile
|
||
key={frame.index}
|
||
src={effectiveFrameUrl(job.id, frame)}
|
||
alt={`关键帧 ${index + 1}`}
|
||
label={`参考帧 ${String(index + 1).padStart(2, "0")}`}
|
||
meta={`${frame.timestamp.toFixed(1)}s`}
|
||
className="aspect-[9/16]"
|
||
objectFit="contain"
|
||
selected={selected}
|
||
title={`${selected ? "已选 · 点击取消" : "点击选择"} · 关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`}
|
||
onClick={() => onToggleFrame(frame.index)}
|
||
topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
|
||
topRight={<span className="rounded-full bg-black/72 p-0.5">{selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}</span>}
|
||
onDelete={onDeleteFrame ? () => onDeleteFrame(frame.index) : undefined}
|
||
deleting={deletingFrame === frame.index}
|
||
deleteLabel={`删除关键帧 ${index + 1}`}
|
||
/>
|
||
)
|
||
})}
|
||
{!frames.length && (
|
||
<div className="col-span-full flex h-[106px] items-center justify-center rounded border border-dashed border-white/12 px-2 text-center text-[10.5px] leading-snug text-white/34">
|
||
自动抽帧或在原版视频上用当前点抽帧。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SourceReferenceBuildPanel({
|
||
job,
|
||
selectedFrames,
|
||
onJobUpdate,
|
||
runtimeModels,
|
||
}: {
|
||
job: Job
|
||
selectedFrames: Set<number>
|
||
onJobUpdate: (job: Job) => void
|
||
runtimeModels?: RuntimeModels
|
||
}) {
|
||
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; viewCount: number; profileLabel: string } | null>(null)
|
||
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
|
||
const [subjectMode, setSubjectMode] = useState<SubjectMode>("source_similar")
|
||
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
|
||
const [subjectViewMode, setSubjectViewMode] = useState<SubjectViewMode>("all")
|
||
const [customSubjectViews, setCustomSubjectViews] = useState<string[]>(COMMON_SUBJECT_VIEW_VALUES)
|
||
const [subjectDirection, setSubjectDirection] = useState("")
|
||
const [subjectProfileMode, setSubjectProfileMode] = useState<SubjectProfileMode>("random")
|
||
const [subjectProfileDraft, setSubjectProfileDraft] = useState<SubjectProfileDraft>({ ...DEFAULT_SUBJECT_PROFILE_DRAFT })
|
||
const [randomProfileDraft, setRandomProfileDraft] = useState<SubjectProfileDraft>(() => randomSubjectProfileDraft())
|
||
const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
|
||
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
|
||
const [selectedCharacterId, setSelectedCharacterId] = useState("")
|
||
const [subjectTemplateLibrary, setSubjectTemplateLibrary] = useState<SubjectTemplateItem[]>([])
|
||
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<string, SubjectAsset>()
|
||
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 (
|
||
<div className="min-w-0">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="相似主体 / 主体模板" />
|
||
<div className="flex items-center gap-2">
|
||
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">
|
||
{referenceCountLabel}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="rounded-md border border-white/10 bg-black/32 p-2">
|
||
<div className="rounded-md border border-white/10 bg-black/28 p-2.5">
|
||
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
|
||
<div>
|
||
<div className="text-[11px] font-semibold text-white/72">主体模板库</div>
|
||
<div className="mt-0.5 text-[9.5px] text-white/34">先决定是否用模板,再选择内置或数据库主体;源视频相似不再混在模板网格里。</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void loadSubjectTemplateLibrary()}
|
||
disabled={templateLibraryBusy}
|
||
className="skg-secondary-action inline-flex h-7 items-center gap-1 px-2 text-[10px] font-semibold transition disabled:cursor-wait disabled:opacity-50"
|
||
>
|
||
{templateLibraryBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||
数据库 {subjectTemplateLibrary.length} 套
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mb-2 grid gap-1.5 sm:grid-cols-2">
|
||
{[
|
||
{ value: "template" as const, label: "用模板生成", desc: "从内置形象或数据库模板延展新主体" },
|
||
{ value: "source_similar" as const, label: "不用模板(从源视频关键帧创新)", desc: "只读取源视频角色文字特征,不上传参考图做复制" },
|
||
].map((item) => (
|
||
<button
|
||
key={item.value}
|
||
type="button"
|
||
onClick={() => setSubjectMode(item.value)}
|
||
className={`flex min-h-[48px] items-start gap-2 rounded-md border px-2.5 py-2 text-left transition ${
|
||
subjectMode === item.value
|
||
? "border-[#d6b36a]/65 bg-[#d6b36a]/12 text-[#f5d98e]"
|
||
: "border-white/10 bg-black/24 text-white/50 hover:border-[#d6b36a]/35 hover:text-white/78"
|
||
}`}
|
||
>
|
||
<span className="mt-0.5 shrink-0">{subjectMode === item.value ? <Check className="h-3.5 w-3.5" /> : <Circle className="h-3.5 w-3.5" />}</span>
|
||
<span className="min-w-0">
|
||
<span className="block text-[11px] font-semibold">{item.label}</span>
|
||
<span className="mt-0.5 block text-[9.5px] leading-snug opacity-65">{item.desc}</span>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{subjectMode === "template" ? (
|
||
<div>
|
||
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-2">
|
||
{subjectTemplateLibrary.map((template) => {
|
||
const preview = characterPreviewImage(template)
|
||
const active = subjectMode === "template" && selectedSubjectTemplateId === template.id
|
||
return (
|
||
<button
|
||
key={template.id}
|
||
type="button"
|
||
onClick={() => {
|
||
setSubjectMode("template")
|
||
setSelectedSubjectTemplateId(template.id)
|
||
setSelectedCharacterId("")
|
||
setSubjectStyle(template.subject_style || "transparent_human")
|
||
}}
|
||
className={`group relative rounded-md border p-1.5 text-left transition ${
|
||
active ? "border-[#d6b36a]/75 bg-[#d6b36a]/12 text-[#f5d98e]" : "border-white/10 bg-black/24 text-white/58 hover:border-[#d6b36a]/35 hover:text-white/82"
|
||
}`}
|
||
>
|
||
{active ? <span className="absolute right-2 top-2 z-10 rounded-full bg-[#d6b36a] p-0.5 text-black"><Check className="h-3 w-3" /></span> : null}
|
||
<span className="block aspect-[4/5] overflow-hidden rounded border border-white/10 bg-white">
|
||
{preview ? <img src={subjectTemplateImageUrl(preview.filename)} alt={template.name} className="h-full w-full object-cover" /> : null}
|
||
</span>
|
||
<span className="mt-1 block truncate text-[10.5px] font-semibold">{template.name}</span>
|
||
<span className="mt-0.5 block truncate text-[9px] opacity-58">数据库 · {template.images.length} 图</span>
|
||
</button>
|
||
)
|
||
})}
|
||
{characterLibrary.map((character) => {
|
||
const preview = characterPreviewImage(character)
|
||
const active = subjectMode === "template" && selectedCharacterId === character.id
|
||
return (
|
||
<button
|
||
key={character.id}
|
||
type="button"
|
||
onClick={() => {
|
||
setSubjectMode("template")
|
||
setSelectedCharacterId(character.id)
|
||
setSelectedSubjectTemplateId("")
|
||
setSubjectStyle("transparent_human")
|
||
}}
|
||
className={`group relative rounded-md border p-1.5 text-left transition ${
|
||
active ? "border-[#d6b36a]/75 bg-[#d6b36a]/12 text-[#f5d98e]" : "border-white/10 bg-black/24 text-white/58 hover:border-[#d6b36a]/35 hover:text-white/82"
|
||
}`}
|
||
>
|
||
{active ? <span className="absolute right-2 top-2 z-10 rounded-full bg-[#d6b36a] p-0.5 text-black"><Check className="h-3 w-3" /></span> : null}
|
||
<span className="block aspect-[4/5] overflow-hidden rounded border border-white/10 bg-white">
|
||
{preview ? <img src={characterLibraryImageUrl(preview.filename)} alt={character.name} className="h-full w-full object-cover" /> : null}
|
||
</span>
|
||
<span className="mt-1 block truncate text-[10.5px] font-semibold">{character.name}</span>
|
||
<span className="mt-0.5 block truncate text-[9px] opacity-58">内置 · {character.images.length} 图</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-md border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] px-2.5 py-2 text-[10px] leading-snug text-white/62">
|
||
当前跳过模板库:本次只用源视频关键帧的文字化主体特征生成创新主体。模板卡已收起,避免占用生成结果区域。
|
||
</div>
|
||
)}
|
||
|
||
{subjectMode === "template" && (selectedSubjectTemplate?.images?.length || selectedCharacter?.images?.length) ? (
|
||
<div className="mt-2 flex gap-1.5 overflow-x-auto pb-0.5">
|
||
{(selectedSubjectTemplate?.images ?? selectedCharacter?.images ?? []).slice(0, 10).map((image) => (
|
||
<div key={image.id} className="h-16 w-12 shrink-0 overflow-hidden rounded border border-white/10 bg-white" title={image.label}>
|
||
<img
|
||
src={selectedSubjectTemplate ? subjectTemplateImageUrl(image.filename) : characterLibraryImageUrl(image.filename)}
|
||
alt={image.label}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="my-2 h-px bg-white/10" />
|
||
<div className="grid gap-2 lg:grid-cols-[1fr_1.6fr_auto]">
|
||
<input
|
||
value={templateDraftName}
|
||
onChange={(event) => 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"
|
||
/>
|
||
<input
|
||
value={templateDraftNote}
|
||
onChange={(event) => 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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => void saveGeneratedSubjectTemplate()}
|
||
disabled={!visibleActorAssets.length || !templateDraftName.trim() || templateSaveBusy}
|
||
title={!visibleActorAssets.length ? "先生成主体视图" : !templateDraftName.trim() ? "先填写模板名称" : "保存到主体模板库"}
|
||
className="skg-secondary-action inline-flex h-8 items-center justify-center gap-1 px-3 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-35"
|
||
>
|
||
{templateSaveBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||
保存到主体库
|
||
</button>
|
||
</div>
|
||
<div className="mt-1 text-[9px] text-white/32">{templateSaveHint}</div>
|
||
<div className="mt-2 rounded-md border border-white/10 bg-black/24 p-2">
|
||
<div className="mb-1 flex items-center justify-between gap-2">
|
||
<span className="text-[9.5px] font-semibold text-white/48">主体 brief 预览 / 首尾帧文字依据</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => void saveSubjectBriefDraft()}
|
||
disabled={!actorSource || subjectBriefBusy || !subjectBriefDraft.trim()}
|
||
className="inline-flex h-6 items-center gap-1 rounded border border-white/10 bg-white/[0.045] px-2 text-[9.5px] font-semibold text-white/52 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
|
||
>
|
||
{subjectBriefBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||
保存 brief
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
value={subjectBriefDraft}
|
||
onChange={(event) => setSubjectBriefDraft(event.target.value)}
|
||
placeholder="生成主体视图后,后端会用视觉模型反推出主体 brief;这里也会显示所选模板的 prompt_brief。"
|
||
className="min-h-[58px] w-full resize-y rounded border border-white/10 bg-black/35 px-2 py-1.5 text-[10px] leading-snug text-white/68 outline-none placeholder:text-white/25 focus:border-cyan-300/45"
|
||
/>
|
||
<div className="mt-1 text-[9px] text-white/30">
|
||
首尾帧后续只使用这段主体 brief,不再把 5 张主体图拼成参考图上传;产品图仍作为结构硬参考。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-2 rounded-md border border-white/10 bg-black/28 p-2.5">
|
||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||
<div>
|
||
<div className="text-[11px] font-semibold text-white/72">生成主体视图</div>
|
||
<div className="mt-0.5 text-[9.5px] text-white/34">{templateSourceLabel}</div>
|
||
</div>
|
||
<ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectStyle)} compact />
|
||
</div>
|
||
{subjectBusyFor ? (
|
||
<div className="mb-2 rounded-md border border-cyan-200/20 bg-cyan-300/[0.07] px-2.5 py-2 text-[10px] leading-snug text-cyan-50/70">
|
||
正在为素材 {subjectBusyFor.jobLabel} 生成 {subjectBusyFor.viewCount} 张主体视图;本次请求已锁定素材、参考帧、模式和视图数量,切换其他模块不会改变生成目标,完成后会回写到该素材。
|
||
<span className="mt-1 block text-cyan-50/58">主体设定:{subjectBusyFor.profileLabel}</span>
|
||
</div>
|
||
) : null}
|
||
|
||
{visibleActorAssets.length ? (
|
||
<div className="mb-2 grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-2">
|
||
{visibleActorAssets.map((asset) => {
|
||
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
|
||
return (
|
||
<MediaAssetTile
|
||
key={asset.id}
|
||
src={subjectAssetUrl(job, asset)}
|
||
href={subjectAssetUrl(job, asset)}
|
||
alt={asset.label || asset.view}
|
||
label={asset.label || asset.view || "主体视图预览"}
|
||
meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
|
||
className="aspect-[9/16] w-20 bg-white 2xl:w-24"
|
||
objectFit="contain"
|
||
title={asset.label || asset.view}
|
||
actions={[{
|
||
key: "regen",
|
||
label: "重新生成这一张",
|
||
icon: <RefreshCw className="h-3 w-3" />,
|
||
tone: "cyan",
|
||
busy: busyMode === "regen",
|
||
disabled: !!subjectAssetBusy,
|
||
onClick: () => void regenerateSubjectAsset(asset),
|
||
}]}
|
||
onDelete={() => void deleteActorAsset(asset)}
|
||
deleting={busyMode === "delete"}
|
||
deleteDisabled={!!subjectAssetBusy}
|
||
deleteLabel="删除这一张"
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mb-2 rounded-md border border-white/10 bg-black/24 p-2">
|
||
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
|
||
<div>
|
||
<div className="text-[10.5px] font-semibold text-white/70">主体设定</div>
|
||
<div className="mt-0.5 max-w-3xl text-[9.5px] leading-snug text-white/34">
|
||
默认随机组合,点击生成时会锁定一套性别、年龄、着装、地域人种、肤色、体型、发型和气质,保证整组视图是同一个人设。
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
<div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">
|
||
{[
|
||
{ value: "random" as const, label: "随机组合" },
|
||
{ value: "manual" as const, label: "手动指定" },
|
||
].map((item) => (
|
||
<button
|
||
key={item.value}
|
||
type="button"
|
||
onClick={() => setSubjectProfileMode(item.value)}
|
||
className={`h-7 rounded px-2 text-[10px] font-semibold transition ${
|
||
subjectProfileMode === item.value ? "bg-white text-black" : "text-white/45 hover:text-white"
|
||
}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{subjectProfileMode === "random" ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setRandomProfileDraft(randomSubjectProfileDraft())}
|
||
className="inline-flex h-8 items-center gap-1 rounded-md border border-white/10 bg-white/[0.045] px-2 text-[10px] font-semibold text-white/56 transition hover:border-cyan-300/35 hover:text-cyan-100"
|
||
>
|
||
<RefreshCw className="h-3 w-3" />
|
||
换一组
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded border border-cyan-200/16 bg-cyan-300/[0.055] px-2 py-1.5 text-[9.5px] leading-snug text-cyan-50/64">
|
||
{subjectProfileMode === "random" ? "当前随机预览:" : "当前手动设定:"}{subjectProfilePreview.summary}
|
||
{lastSubjectProfile ? (
|
||
<span className="mt-1 block text-cyan-50/44">上次生成锁定:{lastSubjectProfile.summary}</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{subjectProfileMode === "manual" ? (
|
||
<div className="mt-2 grid gap-1.5 sm:grid-cols-2 xl:grid-cols-4">
|
||
{SUBJECT_PROFILE_CATEGORIES.map((category) => (
|
||
<label key={category.key} className="min-w-0">
|
||
<span className="mb-1 block text-[9px] font-semibold text-white/40">{category.label}</span>
|
||
<select
|
||
value={subjectProfileDraft[category.key]}
|
||
onChange={(event) => {
|
||
const value = event.target.value
|
||
setSubjectProfileDraft((current) => ({ ...current, [category.key]: value }))
|
||
}}
|
||
className="h-8 w-full rounded-md border border-white/10 bg-black/45 px-2 text-[10.5px] text-white outline-none transition focus:border-cyan-300/50"
|
||
>
|
||
{category.options.map((option) => (
|
||
<option key={option.value} value={option.value}>{option.label}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="grid gap-2 xl:grid-cols-[auto_auto_minmax(220px,1fr)_auto] xl:items-start">
|
||
<div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">
|
||
{[
|
||
{ value: "transparent_human" as const, label: "透明骨架" },
|
||
{ value: "source_actor" as const, label: "真人" },
|
||
].map((item) => (
|
||
<button
|
||
key={item.value}
|
||
type="button"
|
||
onClick={() => setSubjectStyle(item.value)}
|
||
className={`h-8 rounded px-2.5 text-[10.5px] font-semibold transition ${
|
||
subjectStyle === item.value ? "bg-white text-black" : "text-white/45 hover:text-white"
|
||
}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex flex-wrap rounded-md border border-white/10 bg-black/28 p-0.5">
|
||
{[
|
||
{ value: "all" as const, label: `全部 ${SUBJECT_ASSET_VIEWS.length}` },
|
||
{ value: "common" as const, label: `常用 ${COMMON_SUBJECT_VIEW_VALUES.length}` },
|
||
{ value: "custom" as const, label: "自定义" },
|
||
].map((item) => (
|
||
<button
|
||
key={item.value}
|
||
type="button"
|
||
onClick={() => setSubjectViewMode(item.value)}
|
||
className={`h-8 rounded px-2.5 text-[10.5px] font-semibold transition ${
|
||
subjectViewMode === item.value ? "bg-white text-black" : "text-white/45 hover:text-white"
|
||
}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<input
|
||
value={subjectDirection}
|
||
onChange={(event) => setSubjectDirection(event.target.value)}
|
||
placeholder="统一方向:如年轻女性 / 更运动 / 更高级"
|
||
className="h-9 rounded-md border border-white/10 bg-black/35 px-2.5 text-[11px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => void generateSimilarActor()}
|
||
disabled={!frames.length || subjectBusy || templateRequired || !selectedSubjectViews.length}
|
||
className="skg-primary-action inline-flex h-9 min-w-[170px] items-center justify-center gap-1 px-3 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{subjectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||
{subjectBusyFor ? `生成中 · ${subjectBusyFor.jobLabel}` : generationCtaLabel}
|
||
</button>
|
||
</div>
|
||
|
||
{subjectViewMode === "custom" ? (
|
||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||
{SUBJECT_ASSET_VIEWS.map((view) => {
|
||
const checked = customSubjectViews.includes(view.value)
|
||
return (
|
||
<button
|
||
key={view.value}
|
||
type="button"
|
||
onClick={() => setCustomSubjectViews((current) =>
|
||
current.includes(view.value)
|
||
? current.filter((item) => item !== view.value)
|
||
: [...current, view.value],
|
||
)}
|
||
className={`h-7 rounded-md border px-2 text-[10px] font-semibold transition ${
|
||
checked ? "border-cyan-200/60 bg-cyan-300/12 text-cyan-50" : "border-white/10 bg-black/24 text-white/45 hover:border-cyan-200/28 hover:text-white/75"
|
||
}`}
|
||
>
|
||
{checked ? "✓ " : ""}{view.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
) : null}
|
||
|
||
{!visibleActorAssets.length ? (
|
||
<div className="mt-2 rounded border border-dashed border-white/12 px-2 py-2 text-[10.5px] leading-snug text-white/32">
|
||
{subjectMode === "template"
|
||
? "先选主体模板,再生成新主体视图;模板只作为文字化创意方向,不再作为强参考图复制。"
|
||
: "直接使用关键帧的文字化主体特征生成创新主体;后端不会上传源图给生图端点。"}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function AudioStoryboardPlanPanel({
|
||
job,
|
||
selectedFrames,
|
||
onJobUpdate,
|
||
onDeleteVideo,
|
||
runtimeModels,
|
||
productStep,
|
||
scriptStep,
|
||
sceneStep,
|
||
videoStep,
|
||
}: {
|
||
job: Job | null
|
||
selectedFrames: Set<number>
|
||
onJobUpdate?: (job: Job) => void
|
||
onDeleteVideo?: (videoId: string) => void
|
||
runtimeModels?: RuntimeModels
|
||
productStep: WorkflowStep
|
||
scriptStep: WorkflowStep
|
||
sceneStep: WorkflowStep
|
||
videoStep: WorkflowStep
|
||
}) {
|
||
const [storyboardSaveBusyRow, setStoryboardSaveBusyRow] = useState<number | null>(null)
|
||
const [batchStoryboardSaveBusy, setBatchStoryboardSaveBusy] = useState(false)
|
||
const [endpointFrameBusy, setEndpointFrameBusy] = useState<string | null>(null)
|
||
const [productItems, setProductItems] = useState<ProductRefItem[]>([])
|
||
const [productUploading, setProductUploading] = useState(false)
|
||
const [productAnalyzing, setProductAnalyzing] = useState(false)
|
||
const [productAngleBusy, setProductAngleBusy] = useState<string | null>(null)
|
||
const [copyOverrides, setCopyOverrides] = useState<Record<number, string>>({})
|
||
const [copyZhOverrides, setCopyZhOverrides] = useState<Record<number, string>>({})
|
||
const [planOverrides, setPlanOverrides] = useState<Record<number, RowPlanPatch>>({})
|
||
const [authorIntent, setAuthorIntent] = useState("")
|
||
const [showChineseMirror, setShowChineseMirror] = useState(true)
|
||
const [scriptRewriteBusy, setScriptRewriteBusy] = useState<"all" | number | null>(null)
|
||
const [quickVideoBusyRow, setQuickVideoBusyRow] = useState<number | null>(null)
|
||
const [batchCardBusy, setBatchCardBusy] = useState(false)
|
||
const [advancedRows, setAdvancedRows] = useState<Set<number>>(new Set())
|
||
const [refineDialog, setRefineDialog] = useState<{ rowIndex: number; frameIndex: number | null } | null>(null)
|
||
const [refineFeedback, setRefineFeedback] = useState("")
|
||
const [refineBusy, setRefineBusy] = useState(false)
|
||
const [refinePreview, setRefinePreview] = useState<RefineStoryboardResult["items"] | null>(null)
|
||
const [panelOpen, setPanelOpen] = useState<Record<"product" | "batch", boolean>>({ product: true, batch: true })
|
||
const [rowSectionOpen, setRowSectionOpen] = useState<Record<string, boolean>>({})
|
||
const [rowVideoCounts, setRowVideoCounts] = useState<Record<number, number>>({})
|
||
const [batchVideoCount, setBatchVideoCount] = useState(4)
|
||
const [autoOptimizingField, setAutoOptimizingField] = useState<string | null>(null)
|
||
const [autoOptimizedChinese, setAutoOptimizedChinese] = useState<Record<string, string>>({})
|
||
const productFileRef = useRef<HTMLInputElement | null>(null)
|
||
const productPersistSeq = useRef(0)
|
||
const rows = useMemo(() => buildAudioStoryboardRows(job), [job])
|
||
const orderedFrames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job])
|
||
const selectedReferenceFrames = useMemo(
|
||
() => orderedFrames.filter((frame) => selectedFrames.has(frame.index)),
|
||
[orderedFrames, selectedFrames],
|
||
)
|
||
const rowReferencePool = selectedReferenceFrames.length ? selectedReferenceFrames : orderedFrames
|
||
const similarActorSource = useMemo(
|
||
() => findSimilarActorSource(selectedReferenceFrames, orderedFrames),
|
||
[selectedReferenceFrames, orderedFrames],
|
||
)
|
||
const subjectRefs = useMemo(() => subjectAssetRefsForPlanning(similarActorSource), [similarActorSource])
|
||
|
||
const rowSectionKey = (rowIndex: number, section: "fields" | "videos") => `${rowIndex}:${section}`
|
||
const isRowSectionOpen = (rowIndex: number, section: "fields" | "videos", defaultOpen: boolean) =>
|
||
rowSectionOpen[rowSectionKey(rowIndex, section)] ?? defaultOpen
|
||
const toggleRowSection = (rowIndex: number, section: "fields" | "videos", defaultOpen: boolean) => {
|
||
const key = rowSectionKey(rowIndex, section)
|
||
setRowSectionOpen((prev) => ({ ...prev, [key]: !(prev[key] ?? defaultOpen) }))
|
||
}
|
||
|
||
useEffect(() => {
|
||
setProductItems((job?.product_refs ?? []).map(normalizeStoredProductItem))
|
||
setCopyOverrides({})
|
||
setPlanOverrides({})
|
||
setAuthorIntent("")
|
||
setScriptRewriteBusy(null)
|
||
setQuickVideoBusyRow(null)
|
||
setBatchCardBusy(false)
|
||
setAdvancedRows(new Set())
|
||
setRefineDialog(null)
|
||
setRefineFeedback("")
|
||
setRefinePreview(null)
|
||
setPanelOpen({ product: true, batch: true })
|
||
setRowSectionOpen({})
|
||
setRowVideoCounts({})
|
||
setBatchVideoCount(4)
|
||
setAutoOptimizingField(null)
|
||
setAutoOptimizedChinese({})
|
||
}, [job?.id])
|
||
|
||
const persistProductItems = async (items: ProductRefItem[]) => {
|
||
if (!job) return
|
||
const seq = ++productPersistSeq.current
|
||
try {
|
||
const updated = await saveProductRefs(job.id, items)
|
||
if (seq === productPersistSeq.current) onJobUpdate?.(updated)
|
||
} catch (e) {
|
||
console.warn("产品素材池保存失败", e)
|
||
}
|
||
}
|
||
|
||
const setAndPersistProductItems = (items: ProductRefItem[]) => {
|
||
setProductItems(items)
|
||
void persistProductItems(items)
|
||
}
|
||
|
||
const copyForRow = (row: AudioStoryboardRow) => copyOverrides[row.index] ?? row.skgCopy
|
||
const copyZhForRow = (row: AudioStoryboardRow) => copyZhOverrides[row.index] ?? row.skgCopyZh
|
||
|
||
const patchRowCopy = (rowIndex: number, value: string) => {
|
||
setCopyOverrides((prev) => ({ ...prev, [rowIndex]: value }))
|
||
}
|
||
|
||
const patchRowCopyZh = (rowIndex: number, value: string) => {
|
||
setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: value }))
|
||
}
|
||
|
||
const patchRowPlan = (rowIndex: number, patch: RowPlanPatch) => {
|
||
setPlanOverrides((prev) => ({ ...prev, [rowIndex]: { ...(prev[rowIndex] ?? {}), ...patch } }))
|
||
}
|
||
|
||
const videoCountForRow = (rowIndex: number) => clampVideoCount(rowVideoCounts[rowIndex] ?? batchVideoCount)
|
||
|
||
const patchRowVideoCount = (rowIndex: number, value: number) => {
|
||
setRowVideoCounts((prev) => ({ ...prev, [rowIndex]: clampVideoCount(value) }))
|
||
}
|
||
|
||
const savePromptToLibrary = async (
|
||
category: "scene_desc" | "video_desc" | "subject_desc" | "skg_script" | "product_angle",
|
||
name: string,
|
||
promptEn: string,
|
||
promptZh = "",
|
||
) => {
|
||
if (!job) return
|
||
const text = promptEn.trim()
|
||
if (!text) {
|
||
toast.warning("没有可保存的提示词内容。")
|
||
return
|
||
}
|
||
try {
|
||
await createPromptLibraryItem({
|
||
category,
|
||
name,
|
||
prompt_en: text,
|
||
prompt_zh: promptZh.trim(),
|
||
tags: ["工作台保存"],
|
||
source_job_id: job.id,
|
||
})
|
||
toast.success("提示词已保存到资源库")
|
||
} catch (error) {
|
||
toast.error("提示词保存失败:" + (error instanceof Error ? error.message : String(error)))
|
||
}
|
||
}
|
||
|
||
const applyVisualMode = (rowIndex: number, mode: StoryboardVisualMode) => {
|
||
const defaults = visualModeDefaults(mode)
|
||
const row = rows.find((item) => item.index === rowIndex)
|
||
patchRowPlan(rowIndex, {
|
||
visualMode: mode,
|
||
needsProduct: defaults.needsProduct,
|
||
needsSubject: defaults.needsSubject,
|
||
subjectDescription: row ? buildSubjectDescription(row.role, mode) : "",
|
||
subjectDescriptionZh: row ? buildSubjectDescriptionZh(row.role, mode) : "",
|
||
productPlacement: defaults.productPlacement,
|
||
productPlacementZh: visualModeDefaults(mode, "zh").productPlacement,
|
||
})
|
||
}
|
||
|
||
const referenceFrameForRow = (row: AudioStoryboardRow) =>
|
||
closestFrameForTime(rowReferencePool, clampNumber((row.start + row.end) / 2, 0, Math.max(job?.duration || row.end, row.end)))
|
||
|
||
const legacyRowIndexForFrame = (frameIndex: number) => {
|
||
for (const item of rows) {
|
||
if (referenceFrameForRow(item)?.index === frameIndex) return item.index
|
||
}
|
||
return null
|
||
}
|
||
|
||
const planForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => {
|
||
const legacyRowIndex = frame ? legacyRowIndexForFrame(frame.index) : null
|
||
const savedPatch = storyboardSceneBelongsToRow(frame?.storyboard, row.index, legacyRowIndex)
|
||
? savedScenePatch(frame?.storyboard)
|
||
: {}
|
||
return applyPlanPatch(applyPlanPatch(row, savedPatch), planOverrides[row.index])
|
||
}
|
||
|
||
const rewriteSegmentForRow = (row: AudioStoryboardRow): StoryboardScriptRewriteSegment => ({
|
||
index: row.index,
|
||
start: row.start,
|
||
end: row.end,
|
||
role: row.role,
|
||
source: row.source,
|
||
current_text: copyForRow(row),
|
||
})
|
||
|
||
const videosForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => {
|
||
if (!frame) return []
|
||
const legacyRowIndex = legacyRowIndexForFrame(frame.index)
|
||
return (job?.generated_videos ?? []).filter((video) => {
|
||
if (video.frame_idx !== frame.index) return false
|
||
if (typeof video.storyboard_row_idx === "number") return video.storyboard_row_idx === row.index
|
||
return legacyRowIndex === row.index
|
||
})
|
||
}
|
||
|
||
const selectedVideoIdForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => {
|
||
if (!frame?.storyboard) return ""
|
||
const legacyRowIndex = legacyRowIndexForFrame(frame.index)
|
||
return storyboardSceneBelongsToRow(frame.storyboard, row.index, legacyRowIndex)
|
||
? frame.storyboard.selected_video_id ?? ""
|
||
: ""
|
||
}
|
||
|
||
const quickInputForRow = (row: AudioStoryboardRow, frame: KeyFrame | null): QuickStoryboardPlanInput => ({
|
||
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,
|
||
subject_brief: row.needsSubject ? subjectBriefForEndpoint(row, subjectRefs) : "",
|
||
duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || frame?.storyboard?.duration || 4.5)).toFixed(1)),
|
||
visual_mode: row.visualMode,
|
||
needs_product: row.needsProduct,
|
||
needs_subject: row.needsSubject,
|
||
})
|
||
|
||
const buildSceneForPlannedRow = (
|
||
row: AudioStoryboardRow,
|
||
frame: KeyFrame,
|
||
quickPlan?: StoryboardScene | null,
|
||
selectedVideoId?: string,
|
||
): StoryboardScene => {
|
||
const selectedSubjectRefs = row.needsSubject ? selectSubjectRefsForRow(row, subjectRefs) : []
|
||
const legacyRowIndex = legacyRowIndexForFrame(frame.index)
|
||
const savedSceneForRow = storyboardSceneBelongsToRow(frame.storyboard, row.index, legacyRowIndex)
|
||
? frame.storyboard
|
||
: null
|
||
const base = buildStoryboardSceneFromAudioRow(row, frame, productItems, selectedSubjectRefs, {
|
||
firstImage: savedSceneForRow?.first_image ?? endpointAssetRef(frame, "first_frame"),
|
||
lastImage: savedSceneForRow?.last_image ?? endpointAssetRef(frame, "last_frame"),
|
||
})
|
||
const savedSelectedVideoId = selectedVideoIdForRow(row, frame)
|
||
if (!quickPlan) {
|
||
return { ...base, selected_video_id: selectedVideoId ?? savedSelectedVideoId ?? base.selected_video_id ?? "" }
|
||
}
|
||
return {
|
||
...base,
|
||
duration: quickPlan.duration || base.duration,
|
||
visual_mode: quickPlan.visual_mode ?? base.visual_mode,
|
||
needs_product: quickPlan.needs_product ?? base.needs_product,
|
||
needs_subject: quickPlan.needs_subject ?? base.needs_subject,
|
||
subject_brief: quickPlan.subject_brief || base.subject_brief,
|
||
skg_copy_en: quickPlan.skg_copy_en || base.skg_copy_en,
|
||
skg_copy_zh: quickPlan.skg_copy_zh || base.skg_copy_zh,
|
||
scene_one_line_en: quickPlan.scene_one_line_en || base.scene_one_line_en,
|
||
scene_one_line_zh: quickPlan.scene_one_line_zh || base.scene_one_line_zh,
|
||
action_one_line_en: quickPlan.action_one_line_en || base.action_one_line_en,
|
||
action_one_line_zh: quickPlan.action_one_line_zh || base.action_one_line_zh,
|
||
first_frame_plan: quickPlan.first_frame_plan || base.first_frame_plan,
|
||
last_frame_plan: quickPlan.last_frame_plan || base.last_frame_plan,
|
||
product_placement: quickPlan.product_placement || base.product_placement,
|
||
subject: quickPlan.subject || base.subject,
|
||
scene: quickPlan.scene || base.scene,
|
||
product: quickPlan.product || base.product,
|
||
action: quickPlan.action || base.action,
|
||
selected_video_id: selectedVideoId ?? savedSelectedVideoId ?? base.selected_video_id ?? "",
|
||
}
|
||
}
|
||
|
||
const promptForStoryboardScene = (scene: StoryboardScene) => [
|
||
"Create one vertical 9:16 short-form SKG ad video clip.",
|
||
`English voice-over line: ${scene.skg_copy_en || scene.action || ""}`,
|
||
`Scene: ${scene.scene_one_line_en || scene.scene || ""}`,
|
||
`Subject + product + action: ${scene.action_one_line_en || scene.action || ""}`,
|
||
`First frame intent: ${scene.first_frame_plan || ""}`,
|
||
`Last frame intent: ${scene.last_frame_plan || ""}`,
|
||
`Product placement: ${scene.product_placement || scene.product || ""}`,
|
||
`Subject brief: ${scene.subject_brief || scene.subject || ""}`,
|
||
"Keep motion natural, creator-ad style, premium wellness lighting, no subtitles, no platform UI, no watermark, no medical treatment claims.",
|
||
].filter((line) => line.trim()).join("\n")
|
||
|
||
const drawVideosForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, count = 4, quiet = false) => {
|
||
if (!job || !frame) {
|
||
if (!quiet) toast.warning("这条分镜还没有参考帧,先完成抽帧。")
|
||
return false
|
||
}
|
||
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
|
||
setQuickVideoBusyRow(row.index)
|
||
try {
|
||
const expandedPlan = await quickPlanStoryboard(job.id, frame.index, quickInputForRow(plannedRow, frame))
|
||
const scene = buildSceneForPlannedRow(plannedRow, frame, expandedPlan)
|
||
const saved = await updateStoryboard(job.id, frame.index, scene)
|
||
onJobUpdate?.(saved)
|
||
const updated = await generateStoryboardVideo(job.id, frame.index, {
|
||
prompt: promptForStoryboardScene(scene),
|
||
duration: scene.duration || 4,
|
||
count,
|
||
storyboard_row_idx: row.index,
|
||
first_image: scene.first_image ?? null,
|
||
last_image: scene.last_image ?? null,
|
||
product_images: scene.product_images ?? [],
|
||
subject_images: scene.subject_images ?? [],
|
||
subject_image: scene.subject_image ?? null,
|
||
scene_image: scene.scene_image ?? null,
|
||
product_image: scene.product_image ?? null,
|
||
action_image: scene.action_image ?? null,
|
||
model: "seedance",
|
||
size: "720x1280",
|
||
})
|
||
onJobUpdate?.(updated)
|
||
if (!quiet) toast.success(`分镜 ${row.index + 1} 已提交 ${count} 条视频候选`)
|
||
return true
|
||
} catch (e) {
|
||
if (!quiet) toast.error("视频候选生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||
return false
|
||
} finally {
|
||
setQuickVideoBusyRow(null)
|
||
}
|
||
}
|
||
|
||
const selectVideoForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, videoId: string) => {
|
||
if (!job || !frame) return
|
||
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
|
||
try {
|
||
const legacyRowIndex = legacyRowIndexForFrame(frame.index)
|
||
const savedSceneForRow = storyboardSceneBelongsToRow(frame.storyboard, row.index, legacyRowIndex)
|
||
? frame.storyboard
|
||
: null
|
||
const scene = buildSceneForPlannedRow(plannedRow, frame, savedSceneForRow, videoId)
|
||
const updated = await updateStoryboard(job.id, frame.index, scene)
|
||
onJobUpdate?.(updated)
|
||
toast.success(`分镜 ${row.index + 1} 已选用该视频`)
|
||
} catch (e) {
|
||
toast.error("选用视频失败:" + (e instanceof Error ? e.message : String(e)))
|
||
}
|
||
}
|
||
|
||
const clearVideosForRow = (videos: GeneratedVideo[]) => {
|
||
if (!videos.length) return
|
||
for (const video of videos) onDeleteVideo?.(video.id)
|
||
toast.success(`已清空 ${videos.length} 个候选`)
|
||
}
|
||
|
||
const itemSourceForRef = (ref: ImageRef) => productItems.find((item) => sameImageRef(item.ref, ref))?.source ?? "upload"
|
||
|
||
const buildAnalyzedProductItems = (refs: ImageRef[], analysisItems: ProductViewAnalysisItem[] = [], startIndex = 0) => refs.map((ref, index) => {
|
||
const analysis = analysisItems.find((item) => item.index === index)
|
||
const validView = analysis && PRODUCT_VIEW_SLOTS.some((slot) => slot.value === analysis.view) ? analysis.view : undefined
|
||
return createProductRefItem(
|
||
ref,
|
||
startIndex + index,
|
||
itemSourceForRef(ref),
|
||
validView,
|
||
analysis?.note,
|
||
analysis?.background ?? "unknown",
|
||
analysis?.use_tags,
|
||
analysis?.orientation,
|
||
analysis?.landmarks,
|
||
analysis?.risk ?? "",
|
||
analysis?.confidence,
|
||
)
|
||
})
|
||
|
||
const completeMissingProductAngles = async (seedItems: ProductRefItem[]) => {
|
||
if (!job || !seedItems.length) return seedItems
|
||
let working = seedItems
|
||
const failures: string[] = []
|
||
const missing = PRODUCT_VIEW_SLOTS
|
||
.filter((slot) => !working.some((item) => item.view === slot.value))
|
||
|
||
for (const slot of missing) {
|
||
setProductAngleBusy(slot.value)
|
||
try {
|
||
const references = selectProductAngleReferenceItems(working, slot.value)
|
||
const ref = await generateProductAngleAsset(job.id, {
|
||
source_ref: references[0].ref,
|
||
source_refs: references.map((item) => item.ref),
|
||
source_notes: productAngleSourceNotes(references),
|
||
target_view: slot.label,
|
||
note: `${slot.hint};请综合这些同产品参考图补目标视角,不要只照抄某一张。`,
|
||
})
|
||
working = [
|
||
...working,
|
||
createProductRefItem(ref, working.length, "ai", slot.value, `AI 补齐:${slot.hint}`, "white", undefined, undefined, undefined, "", 1),
|
||
]
|
||
setAndPersistProductItems(working)
|
||
} catch (e) {
|
||
failures.push(`${slot.label}:${e instanceof Error ? e.message : String(e)}`)
|
||
}
|
||
}
|
||
|
||
setProductAngleBusy(null)
|
||
if (failures.length) {
|
||
toast.warning(`部分产品视角自动补图失败:${failures.map((item) => item.split(":")[0]).join("、")}`)
|
||
}
|
||
return working
|
||
}
|
||
|
||
const analyzeAndCompleteProductViews = async (refs: ImageRef[]) => {
|
||
if (!job || !refs.length) return
|
||
setProductAnalyzing(true)
|
||
const pending = refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref), undefined, "正在自动识别视角..."))
|
||
setAndPersistProductItems(pending)
|
||
try {
|
||
const analysis = await analyzeProductViews(job.id, refs)
|
||
const analyzed = buildAnalyzedProductItems(refs, analysis.items)
|
||
setAndPersistProductItems(analyzed)
|
||
const completed = await completeMissingProductAngles(analyzed)
|
||
toast.success(completed.length > analyzed.length ? "产品视角已自动识别并补齐缺失角度" : "产品视角已自动识别")
|
||
} catch (e) {
|
||
const fallback = refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref)))
|
||
setAndPersistProductItems(fallback)
|
||
await completeMissingProductAngles(fallback)
|
||
toast.warning("产品视角识别失败,已按默认顺序标注并尝试自动补图:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setProductAnalyzing(false)
|
||
setProductAngleBusy(null)
|
||
}
|
||
}
|
||
|
||
const analyzeUploadedProductRefs = async (refs: ImageRef[]) => {
|
||
if (!job || !refs.length) return
|
||
const baseItems = productItems
|
||
const startIndex = baseItems.length
|
||
setProductAnalyzing(true)
|
||
setAndPersistProductItems([
|
||
...baseItems,
|
||
...refs.map((ref, index) => createProductRefItem(ref, startIndex + index, "upload", undefined, "正在自动识别视角...")),
|
||
])
|
||
try {
|
||
const analysis = await analyzeProductViews(job.id, refs)
|
||
const newItems = buildAnalyzedProductItems(refs, analysis.items, startIndex)
|
||
const combined = [...baseItems, ...newItems]
|
||
setAndPersistProductItems(combined)
|
||
const completed = await completeMissingProductAngles(combined)
|
||
toast.success(completed.length > combined.length ? "新产品图已识别,并已补齐缺失角度" : "新产品图已自动识别")
|
||
} catch (e) {
|
||
const fallback = refs.map((ref, index) => createProductRefItem(ref, startIndex + index, "upload"))
|
||
const combined = [...baseItems, ...fallback]
|
||
setAndPersistProductItems(combined)
|
||
await completeMissingProductAngles(combined)
|
||
toast.warning("新产品图识别失败,已按默认顺序标注并尝试自动补图:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setProductAnalyzing(false)
|
||
setProductAngleBusy(null)
|
||
}
|
||
}
|
||
|
||
const uploadProductImages = async (files: FileList | null) => {
|
||
if (!job || !files?.length) return
|
||
const selected = Array.from(files)
|
||
setProductUploading(true)
|
||
try {
|
||
const refs = await Promise.all(selected.map((file) => uploadStoryboardAsset(job.id, file)))
|
||
await analyzeUploadedProductRefs(refs)
|
||
toast.success(`已上传 ${refs.length} 张产品图`)
|
||
} catch (e) {
|
||
toast.error("产品图上传失败:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setProductUploading(false)
|
||
}
|
||
}
|
||
|
||
const patchProductItem = (id: string, patch: Partial<ProductRefItem>) => {
|
||
setProductItems((prev) => {
|
||
const next = prev.map((item) => item.id === id ? { ...item, ...patch } : item)
|
||
void persistProductItems(next)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const removeProductItem = (id: string) => {
|
||
setProductItems((prev) => {
|
||
const next = prev.filter((item) => item.id !== id)
|
||
void persistProductItems(next)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const reanalyzeProductViews = async () => {
|
||
if (!productItems.length) return
|
||
await analyzeAndCompleteProductViews(productItems.map((item) => item.ref))
|
||
}
|
||
|
||
const applyScriptRewriteItems = (items: Array<{ index: number; text: string; text_zh?: string }>) => {
|
||
if (!items.length) return
|
||
setCopyOverrides((prev) => {
|
||
const next = { ...prev }
|
||
for (const item of items) {
|
||
if (item.text?.trim()) next[item.index] = item.text.trim()
|
||
}
|
||
return next
|
||
})
|
||
setCopyZhOverrides((prev) => {
|
||
const next = { ...prev }
|
||
for (const item of items) {
|
||
if (item.text_zh?.trim()) next[item.index] = item.text_zh.trim()
|
||
}
|
||
return next
|
||
})
|
||
}
|
||
|
||
const rewriteSingleRow = async (row: AudioStoryboardRow) => {
|
||
if (!job) return
|
||
setScriptRewriteBusy(row.index)
|
||
try {
|
||
const result = await rewriteStoryboardScript(job.id, {
|
||
mode: "segment",
|
||
author_intent: authorIntent,
|
||
segments: [rewriteSegmentForRow(row)],
|
||
})
|
||
applyScriptRewriteItems(result.items)
|
||
toast.success(`分镜 ${row.index + 1} 已改写`)
|
||
} catch (e) {
|
||
toast.error("单段改写失败:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setScriptRewriteBusy(null)
|
||
}
|
||
}
|
||
|
||
const rewriteAllRows = async () => {
|
||
if (!job || !rows.length) return
|
||
setScriptRewriteBusy("all")
|
||
try {
|
||
const result = await rewriteStoryboardScript(job.id, {
|
||
mode: "all",
|
||
author_intent: authorIntent,
|
||
segments: rows.map(rewriteSegmentForRow),
|
||
})
|
||
applyScriptRewriteItems(result.items)
|
||
toast.success("整片新口播文案已改写")
|
||
} catch (e) {
|
||
toast.error("整片改写失败:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setScriptRewriteBusy(null)
|
||
}
|
||
}
|
||
|
||
const generateMissingProductAngle = async (slot: typeof PRODUCT_VIEW_SLOTS[number]) => {
|
||
if (!job || !productItems.length) return
|
||
const references = selectProductAngleReferenceItems(productItems, slot.value)
|
||
setProductAngleBusy(slot.value)
|
||
try {
|
||
const ref = await generateProductAngleAsset(job.id, {
|
||
source_ref: references[0].ref,
|
||
source_refs: references.map((item) => item.ref),
|
||
source_notes: productAngleSourceNotes(references),
|
||
target_view: slot.label,
|
||
note: `${slot.hint};请综合这些同产品参考图补目标视角,不要只照抄某一张。`,
|
||
})
|
||
setProductItems((prev) => {
|
||
const next = [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, "white", undefined, undefined, undefined, "", 1)]
|
||
void persistProductItems(next)
|
||
return next
|
||
})
|
||
toast.success(`AI 已补全产品视角:${slot.label}`)
|
||
} catch (e) {
|
||
toast.error("AI 补角度失败:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setProductAngleBusy(null)
|
||
}
|
||
}
|
||
|
||
const saveRowStoryboardDraft = async (row: AudioStoryboardRow, frame: KeyFrame) => {
|
||
if (!job) return
|
||
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
|
||
const scene = buildSceneForPlannedRow(plannedRow, frame, frame.storyboard)
|
||
const updated = await updateStoryboard(job.id, frame.index, scene)
|
||
onJobUpdate?.(updated)
|
||
}
|
||
|
||
const generateEndpointFrameForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, role: "first_frame" | "last_frame") => {
|
||
if (!job || !frame) return
|
||
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) }
|
||
const selectedSubjectRefs = plannedRow.needsSubject ? selectSubjectRefsForRow(plannedRow, subjectRefs, role) : []
|
||
const subjectBrief = plannedRow.needsSubject ? subjectBriefForEndpoint(plannedRow, subjectRefs) : ""
|
||
if (plannedRow.needsProduct && !productItems.length) {
|
||
toast.warning("本条需要产品,请先上传并识别产品素材池")
|
||
return
|
||
}
|
||
const selectedProductItems = plannedRow.needsProduct ? selectProductItemsForRow(plannedRow, productItems, "endpoint", role) : []
|
||
const busyKey = `${row.index}:${role}`
|
||
setEndpointFrameBusy(busyKey)
|
||
try {
|
||
await saveRowStoryboardDraft(plannedRow, frame)
|
||
const rawPrompt = buildEndpointFramePrompt(plannedRow, role, selectedProductItems, subjectBrief)
|
||
const prompt = await ensureEnglishForModel(rawPrompt)
|
||
const englishSubjectBrief = await ensureEnglishForModel(subjectBrief)
|
||
const updated = await generateSceneAsset(job.id, frame.index, {
|
||
size: SUBJECT_ASSET_SIZE,
|
||
scene_mode: "similar",
|
||
scene_style: "premium_product",
|
||
asset_role: role,
|
||
prompt,
|
||
subject_brief: englishSubjectBrief,
|
||
product_images: selectedProductItems.map((item) => item.ref),
|
||
source_frame_indices: [],
|
||
})
|
||
const updatedFrame = updated.frames.find((item) => item.index === frame.index) ?? frame
|
||
const generatedRef = endpointAssetRef(updatedFrame, role)
|
||
const scene = buildStoryboardSceneFromAudioRow(plannedRow, updatedFrame, productItems, selectedSubjectRefs, {
|
||
firstImage: role === "first_frame" ? generatedRef : endpointAssetRef(updatedFrame, "first_frame"),
|
||
lastImage: role === "last_frame" ? generatedRef : endpointAssetRef(updatedFrame, "last_frame"),
|
||
})
|
||
const saved = await updateStoryboard(job.id, frame.index, scene)
|
||
onJobUpdate?.(saved)
|
||
toast.success(`分镜 ${row.index + 1} ${role === "first_frame" ? "首帧" : "尾帧"}已生成`)
|
||
} catch (e) {
|
||
toast.error(`${role === "first_frame" ? "首帧" : "尾帧"}生成失败:` + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setEndpointFrameBusy(null)
|
||
}
|
||
}
|
||
|
||
const clearEndpointFrameForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, role: "first_frame" | "last_frame") => {
|
||
if (!job || !frame) return
|
||
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) }
|
||
const busyKey = `${row.index}:clear_${role}`
|
||
setEndpointFrameBusy(busyKey)
|
||
try {
|
||
const selectedSubjectRefs = plannedRow.needsSubject ? selectSubjectRefsForRow(plannedRow, subjectRefs, role) : []
|
||
const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, productItems, selectedSubjectRefs, {
|
||
firstImage: role === "first_frame" ? null : endpointAssetRef(frame, "first_frame"),
|
||
lastImage: role === "last_frame" ? null : endpointAssetRef(frame, "last_frame"),
|
||
})
|
||
const updated = await updateStoryboard(job.id, frame.index, scene)
|
||
onJobUpdate?.(updated)
|
||
toast.success(`${role === "first_frame" ? "首帧" : "尾帧"}已从本条规划移除`)
|
||
} catch (e) {
|
||
toast.error("移除首尾帧失败:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setEndpointFrameBusy(null)
|
||
}
|
||
}
|
||
|
||
const saveSingleRowStoryboardDraft = async (row: AudioStoryboardRow, frame: KeyFrame | null) => {
|
||
if (!job || !frame) return
|
||
setStoryboardSaveBusyRow(row.index)
|
||
try {
|
||
await saveRowStoryboardDraft(row, frame)
|
||
toast.success("已保存本条三字段规划")
|
||
} catch (e) {
|
||
toast.error("保存本条规划失败:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setStoryboardSaveBusyRow(null)
|
||
}
|
||
}
|
||
|
||
const saveAllStoryboardDrafts = async (quiet = false) => {
|
||
if (!job || !rows.length) return { ok: 0, failed: 0 }
|
||
const jobsToSubmit = rows
|
||
.map((row) => ({ row: planForRow(row, referenceFrameForRow(row)), frame: referenceFrameForRow(row) }))
|
||
.filter((item): item is { row: AudioStoryboardRow; frame: KeyFrame } => !!item.frame)
|
||
if (!jobsToSubmit.length) {
|
||
if (!quiet) toast.warning("先完成前置抽帧,让每条分镜有可保存的承载位置")
|
||
return { ok: 0, failed: rows.length }
|
||
}
|
||
setBatchStoryboardSaveBusy(true)
|
||
let ok = 0
|
||
let failed = 0
|
||
try {
|
||
for (const item of jobsToSubmit) {
|
||
setStoryboardSaveBusyRow(item.row.index)
|
||
try {
|
||
await saveRowStoryboardDraft(item.row, item.frame)
|
||
ok += 1
|
||
} catch (e) {
|
||
failed += 1
|
||
console.warn("批量保存分镜规划失败", item.row.index, e)
|
||
}
|
||
}
|
||
if (!quiet) {
|
||
if (failed) toast.warning(`已保存 ${ok} 条规划,${failed} 条失败`)
|
||
else toast.success(`已保存全部 ${ok} 条分镜规划`)
|
||
}
|
||
} finally {
|
||
setStoryboardSaveBusyRow(null)
|
||
setBatchStoryboardSaveBusy(false)
|
||
}
|
||
return { ok, failed }
|
||
}
|
||
|
||
const batchDrawAllRows = async () => {
|
||
if (!job || !rows.length) return
|
||
const count = clampVideoCount(batchVideoCount)
|
||
setBatchCardBusy(true)
|
||
let submitted = 0
|
||
let failed = 0
|
||
try {
|
||
for (const row of rows) {
|
||
const frame = referenceFrameForRow(row)
|
||
const ok = await drawVideosForRow(row, frame, count, true)
|
||
if (ok) submitted += 1
|
||
else failed += 1
|
||
}
|
||
if (failed) toast.warning(`整片已排队 ${submitted} 条分镜,${failed} 条失败或缺少参考帧`)
|
||
else toast.success(`整片视频候选已按行排队:${submitted} 条分镜 × 每条 ${count} 个候选`)
|
||
} catch (e) {
|
||
toast.error("整片视频候选生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setBatchCardBusy(false)
|
||
}
|
||
}
|
||
|
||
const openRefineForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => {
|
||
setRefineDialog({ rowIndex: row.index, frameIndex: frame?.index ?? null })
|
||
setRefineFeedback("")
|
||
setRefinePreview(null)
|
||
}
|
||
|
||
const applyRefineItems = (rowIndex: number, items: RefineStoryboardResult["items"]) => {
|
||
setCopyOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_en }))
|
||
setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_zh }))
|
||
patchRowPlan(rowIndex, {
|
||
skgCopy: items.skg_copy_en,
|
||
skgCopyZh: items.skg_copy_zh,
|
||
sceneOneLine: items.scene_one_line_en,
|
||
sceneOneLineZh: items.scene_one_line_zh,
|
||
actionOneLine: items.action_one_line_en,
|
||
actionOneLineZh: items.action_one_line_zh,
|
||
})
|
||
}
|
||
|
||
const applyFieldRefineItems = (rowIndex: number, field: CompactStoryboardFieldKind, items: RefineStoryboardResult["items"]) => {
|
||
if (field === "copy") {
|
||
setCopyOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_en }))
|
||
setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_zh }))
|
||
patchRowPlan(rowIndex, { skgCopy: items.skg_copy_en, skgCopyZh: items.skg_copy_zh })
|
||
return
|
||
}
|
||
if (field === "scene") {
|
||
patchRowPlan(rowIndex, {
|
||
sceneOneLine: items.scene_one_line_en,
|
||
sceneOneLineZh: items.scene_one_line_zh,
|
||
visualPlan: items.scene_one_line_en,
|
||
visualPlanZh: items.scene_one_line_zh,
|
||
})
|
||
return
|
||
}
|
||
patchRowPlan(rowIndex, {
|
||
actionOneLine: items.action_one_line_en,
|
||
actionOneLineZh: items.action_one_line_zh,
|
||
subjectDescription: items.action_one_line_en,
|
||
subjectDescriptionZh: items.action_one_line_zh,
|
||
})
|
||
}
|
||
|
||
const patchTranslatedFieldFallback = (rowIndex: number, field: CompactStoryboardFieldKind, zhValue: string, english: string) => {
|
||
if (field === "copy") {
|
||
setCopyOverrides((prev) => ({ ...prev, [rowIndex]: english }))
|
||
setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: zhValue }))
|
||
patchRowPlan(rowIndex, { skgCopy: english, skgCopyZh: zhValue })
|
||
return
|
||
}
|
||
if (field === "scene") {
|
||
patchRowPlan(rowIndex, { sceneOneLine: english, sceneOneLineZh: zhValue, visualPlan: english, visualPlanZh: zhValue })
|
||
return
|
||
}
|
||
patchRowPlan(rowIndex, { actionOneLine: english, actionOneLineZh: zhValue, subjectDescription: english, subjectDescriptionZh: zhValue })
|
||
}
|
||
|
||
const optimizeEnglishFromChinese = async (
|
||
row: AudioStoryboardRow,
|
||
frame: KeyFrame | null,
|
||
field: CompactStoryboardFieldKind,
|
||
zhValue: string,
|
||
) => {
|
||
if (!job || !frame) return
|
||
const trimmedZh = zhValue.trim()
|
||
if (!trimmedZh || !containsCjk(trimmedZh)) return
|
||
const cacheKey = `${row.index}:${field}`
|
||
if (autoOptimizedChinese[cacheKey] === trimmedZh) return
|
||
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
|
||
const currentPlan = quickInputForRow(plannedRow, frame)
|
||
if (field === "copy") currentPlan.skg_copy_zh = trimmedZh
|
||
if (field === "scene") currentPlan.scene_one_line_zh = trimmedZh
|
||
if (field === "action") currentPlan.action_one_line_zh = trimmedZh
|
||
setAutoOptimizingField(cacheKey)
|
||
try {
|
||
const result = await refineStoryboard(job.id, frame.index, {
|
||
current_plan: currentPlan,
|
||
user_feedback: `用户刚修改了「${storyboardFieldLabel(field)}」的中文字段。请把这个中文作为该字段的最新意思,优化对应英文主字段,让它适合英文视频生成 prompt 和短视频口播/动作描述。其他两个字段只做必要的轻微润色,不要改变语义。`,
|
||
})
|
||
applyFieldRefineItems(row.index, field, result.items)
|
||
setAutoOptimizedChinese((prev) => ({ ...prev, [cacheKey]: trimmedZh }))
|
||
if (result.error) toast.warning(`中文已更新,英文使用兜底优化:${result.error}`)
|
||
else toast.success(`${storyboardFieldLabel(field)}已按中文优化英文`)
|
||
} catch (e) {
|
||
try {
|
||
const english = await translateText(trimmedZh, "en")
|
||
patchTranslatedFieldFallback(row.index, field, trimmedZh, english)
|
||
setAutoOptimizedChinese((prev) => ({ ...prev, [cacheKey]: trimmedZh }))
|
||
toast.success(`${storyboardFieldLabel(field)}已翻译成英文`)
|
||
} catch {
|
||
toast.error("中文优化英文失败:" + (e instanceof Error ? e.message : String(e)))
|
||
}
|
||
} finally {
|
||
setAutoOptimizingField(null)
|
||
}
|
||
}
|
||
|
||
const submitRefine = async () => {
|
||
if (!job || !refineDialog) return
|
||
const row = rows.find((item) => item.index === refineDialog.rowIndex)
|
||
const frame = refineDialog.frameIndex == null ? null : job.frames.find((item) => item.index === refineDialog.frameIndex) ?? null
|
||
if (!row || !frame) return
|
||
const feedback = refineFeedback.trim()
|
||
if (!feedback) {
|
||
toast.warning("先写一句你想怎么改。")
|
||
return
|
||
}
|
||
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
|
||
const currentPlan = refinePreview
|
||
? {
|
||
...quickInputForRow(plannedRow, frame),
|
||
skg_copy_en: refinePreview.skg_copy_en,
|
||
skg_copy_zh: refinePreview.skg_copy_zh,
|
||
scene_one_line_en: refinePreview.scene_one_line_en,
|
||
scene_one_line_zh: refinePreview.scene_one_line_zh,
|
||
action_one_line_en: refinePreview.action_one_line_en,
|
||
action_one_line_zh: refinePreview.action_one_line_zh,
|
||
}
|
||
: quickInputForRow(plannedRow, frame)
|
||
setRefineBusy(true)
|
||
try {
|
||
const result = await refineStoryboard(job.id, frame.index, {
|
||
current_plan: currentPlan,
|
||
user_feedback: feedback,
|
||
})
|
||
setRefinePreview(result.items)
|
||
if (result.error) toast.warning(`AI 改写已回退:${result.error}`)
|
||
} catch (e) {
|
||
toast.error("AI 改写失败:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setRefineBusy(false)
|
||
}
|
||
}
|
||
|
||
const closeRefineDialog = () => {
|
||
setRefineDialog(null)
|
||
setRefineFeedback("")
|
||
setRefinePreview(null)
|
||
setRefineBusy(false)
|
||
}
|
||
|
||
if (!job) return null
|
||
|
||
return (
|
||
<section className="mt-3 rounded-lg border border-white/10 bg-black/28 p-2.5">
|
||
<div className="mb-2 flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="信息流复刻分镜工作台" />
|
||
<WorkflowStepBadge step={scriptStep} compact />
|
||
</div>
|
||
<p className="mt-1 text-[11px] leading-snug text-white/42">每条分镜纵向排列;行内完成原内容、新文案、画面/产品和视频候选。关键帧选择与相似主体重构在源视频工作区下方统一处理。</p>
|
||
</div>
|
||
<div className="grid shrink-0 grid-cols-3 gap-2 text-[11px] text-white/45">
|
||
<Requirement label="分镜" ready={rows.length > 0} detail={rows.length ? `${rows.length} 条` : "待音频"} />
|
||
<Requirement label="参考帧" ready={orderedFrames.length > 0} detail={orderedFrames.length ? `${orderedFrames.length} 张` : "待抽帧"} />
|
||
<Requirement label="候选" ready={(job.generated_videos?.length ?? 0) > 0} detail={`${job.generated_videos?.length ?? 0} 条历史`} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mb-2 rounded-md border border-white/10 bg-black/24 p-2.5">
|
||
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<SectionTitle icon={<Package className="h-4 w-4" />} title="同一产品素材池 / 视角标注" />
|
||
<WorkflowStepBadge step={productStep} compact />
|
||
<ModelTrace trace={productModelTrace(runtimeModels)} compact />
|
||
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/42">{productItems.length ? `${productItems.length} 张素材` : "素材池不限量"}</span>
|
||
{(productAnalyzing || productAngleBusy) && (
|
||
<span className="inline-flex items-center gap-1 rounded-md border border-cyan-300/20 bg-cyan-300/[0.08] px-2 py-0.5 text-[10px] text-cyan-100/75">
|
||
<Loader2 className="h-3 w-3 animate-spin" />
|
||
{productAnalyzing ? "识别视角中" : `补图中:${productViewLabel(productAngleBusy ?? "")}`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{panelOpen.product ? (
|
||
<p className="mt-1 max-w-[760px] text-[11px] leading-snug text-white/42">
|
||
产品固定按“套在脖子上的 U 形肩颈按摩仪”识别;左/右按佩戴者身体左右,上/下按佩戴方向,不按图片左右。推荐原图长边 1200-2000px、短边至少 600px;系统会生成最长边 1600px、JPEG 92 的 AI 工作副本。每条视频只挑最多 {MAX_PRODUCT_REFS_PER_VIDEO} 张相关产品图。
|
||
</p>
|
||
) : (
|
||
<p className="mt-1 text-[11px] text-white/34">已折叠 · {productItems.length ? `${productItems.length} 张产品素材` : "还没有产品素材"}</p>
|
||
)}
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPanelOpen((prev) => ({ ...prev, product: !prev.product }))}
|
||
className="skg-secondary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition"
|
||
>
|
||
<ChevronDown className={`h-3.5 w-3.5 transition ${panelOpen.product ? "rotate-180" : ""}`} />
|
||
{panelOpen.product ? "收起" : "展开"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void reanalyzeProductViews()}
|
||
disabled={!productItems.length || productAnalyzing || !!productAngleBusy}
|
||
className="skg-secondary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{productAnalyzing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
|
||
重新识别并补图
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => productFileRef.current?.click()}
|
||
disabled={!job || productUploading || productAnalyzing || !!productAngleBusy}
|
||
className="skg-secondary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{productUploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||
上传产品图
|
||
</button>
|
||
<input
|
||
ref={productFileRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
void uploadProductImages(event.currentTarget.files)
|
||
event.currentTarget.value = ""
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{panelOpen.product ? (
|
||
<div className="grid max-h-[360px] gap-2 overflow-y-auto pr-1 md:grid-cols-2 2xl:grid-cols-3">
|
||
{productItems.map((item) => (
|
||
<ProductReferenceCard
|
||
key={item.id}
|
||
job={job}
|
||
item={item}
|
||
onPatch={(patch) => patchProductItem(item.id, patch)}
|
||
onRemove={() => removeProductItem(item.id)}
|
||
/>
|
||
))}
|
||
{PRODUCT_VIEW_SLOTS.filter((slot) => !productItems.some((item) => item.view === slot.value)).map((slot) => (
|
||
<MissingProductViewSlot
|
||
key={slot.value}
|
||
slot={slot}
|
||
canGenerate={!!productItems.length}
|
||
busy={productAngleBusy === slot.value}
|
||
blocked={productAnalyzing || !!productAngleBusy}
|
||
onGenerate={() => generateMissingProductAngle(slot)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{rows.length ? (
|
||
<>
|
||
<div className="mb-2 rounded-md border border-white/10 bg-black/24 p-2">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<SectionTitle icon={<Wand2 className="h-4 w-4" />} title="批量控制 / 作者想法" />
|
||
<ModelTrace trace={scriptRewriteModelTrace(runtimeModels)} compact />
|
||
{!panelOpen.batch ? <span className="truncate text-[11px] text-white/34">{authorIntent.trim() || "已折叠"}</span> : null}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPanelOpen((prev) => ({ ...prev, batch: !prev.batch }))}
|
||
className="skg-secondary-action inline-flex h-8 items-center justify-center gap-1 px-2 text-[11px] font-semibold transition"
|
||
>
|
||
<ChevronDown className={`h-3.5 w-3.5 transition ${panelOpen.batch ? "rotate-180" : ""}`} />
|
||
{panelOpen.batch ? "收起" : "展开"}
|
||
</button>
|
||
</div>
|
||
{panelOpen.batch ? (
|
||
<div className="mt-2 grid gap-2 xl:grid-cols-[minmax(0,1fr)_auto]">
|
||
<textarea
|
||
value={authorIntent}
|
||
onChange={(event) => setAuthorIntent(event.target.value)}
|
||
placeholder="作者想法:比如更像真实达人、前半段强调久坐低头、结尾弱化优惠、语气更轻松..."
|
||
className="min-h-[42px] resize-y rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[11px] leading-snug text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
|
||
/>
|
||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void savePromptToLibrary("skg_script", "作者想法", authorIntent)}
|
||
disabled={!authorIntent.trim()}
|
||
className="skg-secondary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-35"
|
||
title="保存作者想法到提示词库"
|
||
>
|
||
<BookOpen className="h-3.5 w-3.5" />
|
||
想法入库
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowChineseMirror((value) => !value)}
|
||
className="skg-secondary-action inline-flex h-9 items-center justify-center px-2.5 text-[11px] font-semibold transition"
|
||
>
|
||
{showChineseMirror ? "收起中文" : "显示中文"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void rewriteAllRows()}
|
||
disabled={scriptRewriteBusy !== null || !rows.length}
|
||
className="skg-primary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{scriptRewriteBusy === "all" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
|
||
整片改写
|
||
</button>
|
||
<label className="inline-flex h-9 items-center gap-1 rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] font-semibold text-white/52">
|
||
每行
|
||
<select
|
||
value={batchVideoCount}
|
||
onChange={(event) => setBatchVideoCount(clampVideoCount(Number(event.target.value)))}
|
||
className="h-6 rounded border border-white/10 bg-black/45 px-1 text-center font-mono text-[11px] text-white/78 outline-none focus:border-cyan-300/45"
|
||
>
|
||
{STORYBOARD_VIDEO_COUNT_OPTIONS.map((count) => (
|
||
<option key={count} value={count}>{count}</option>
|
||
))}
|
||
</select>
|
||
条
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => void batchDrawAllRows()}
|
||
disabled={batchCardBusy || batchStoryboardSaveBusy || !rows.length || !orderedFrames.length}
|
||
className="skg-primary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{batchCardBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||
整片排队生成({rows.length}×{batchVideoCount}条)
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setCopyOverrides({})
|
||
setCopyZhOverrides({})
|
||
}}
|
||
disabled={scriptRewriteBusy !== null || !Object.keys(copyOverrides).length}
|
||
className="skg-secondary-action inline-flex h-9 items-center justify-center px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-35"
|
||
>
|
||
还原初稿
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void saveAllStoryboardDrafts()}
|
||
disabled={batchStoryboardSaveBusy || !rows.length || !orderedFrames.length}
|
||
className="skg-primary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{batchStoryboardSaveBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||
保存全部
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="max-h-[560px] space-y-2 overflow-y-auto pr-1">
|
||
{rows.map((row) => {
|
||
const referenceFrame = referenceFrameForRow(row)
|
||
const plannedRow = planForRow(row, referenceFrame)
|
||
const rowVideos = videosForRow(row, referenceFrame)
|
||
const savingStoryboard = storyboardSaveBusyRow === row.index
|
||
const copyText = copyForRow(row)
|
||
const copyZhText = copyZhForRow(row)
|
||
const selectedProductCount = plannedRow.needsProduct ? selectProductItemsForRow(plannedRow, productItems, "endpoint").length : 0
|
||
const endpointSubjectBrief = plannedRow.needsSubject ? subjectBriefForEndpoint(plannedRow, subjectRefs) : ""
|
||
const fieldsOpen = isRowSectionOpen(row.index, "fields", true)
|
||
const videosOpen = isRowSectionOpen(row.index, "videos", false)
|
||
const rowVideoCount = videoCountForRow(row.index)
|
||
return (
|
||
<article
|
||
key={row.index}
|
||
className="overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64"
|
||
>
|
||
<div className="border-b border-white/8 p-2.5">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-semibold text-white/82">分镜 {row.index + 1}</span>
|
||
<span className="font-mono text-[10.5px] text-white/42">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</span>
|
||
<span className="rounded-md border border-emerald-300/15 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] leading-tight text-emerald-100/80">
|
||
{ROLE_LABELS_ZH[row.role]}
|
||
</span>
|
||
{referenceFrame ? (
|
||
<span className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-0.5 text-[10px] text-white/38">帧 {referenceFrame.index + 1}</span>
|
||
) : (
|
||
<span className="rounded-md border border-amber-300/18 bg-amber-300/[0.07] px-1.5 py-0.5 text-[10px] text-amber-100/70">待抽帧</span>
|
||
)}
|
||
</div>
|
||
<p className="mt-1 line-clamp-1 text-[10.5px] text-white/32" title={row.source}>{row.source}</p>
|
||
</div>
|
||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => openRefineForRow(plannedRow, referenceFrame)}
|
||
disabled={!referenceFrame}
|
||
className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-violet-300/18 bg-violet-300/[0.07] px-2 text-[10.5px] font-semibold text-violet-100/75 transition hover:border-violet-300/45 hover:text-violet-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||
>
|
||
<Wand2 className="h-3.5 w-3.5" />
|
||
AI 改写
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => patchRowVideoCount(row.index, Math.max(1, rowVideoCount - 1))}
|
||
disabled={rowVideoCount <= 1}
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/[0.045] text-white/58 transition hover:border-white/25 hover:text-white/82 disabled:cursor-not-allowed disabled:opacity-30"
|
||
aria-label="减少本行生成数量"
|
||
>
|
||
-
|
||
</button>
|
||
<label className="inline-flex h-8 items-center gap-1 rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] font-semibold text-white/52">
|
||
本行
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={12}
|
||
value={rowVideoCount}
|
||
onChange={(event) => patchRowVideoCount(row.index, Number(event.target.value) || 1)}
|
||
className="h-5 w-10 rounded border border-white/10 bg-black/45 text-center font-mono text-[11px] text-white/82 outline-none focus:border-cyan-300/45"
|
||
/>
|
||
条
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => patchRowVideoCount(row.index, Math.min(12, rowVideoCount + 1))}
|
||
disabled={rowVideoCount >= 12}
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/[0.045] text-white/58 transition hover:border-white/25 hover:text-white/82 disabled:cursor-not-allowed disabled:opacity-30"
|
||
aria-label="增加本行生成数量"
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
|
||
disabled={!referenceFrame || quickVideoBusyRow !== null}
|
||
className="skg-primary-action inline-flex h-8 items-center justify-center gap-1 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{quickVideoBusyRow === row.index ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||
生成 {rowVideoCount} 条
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setAdvancedRows((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(row.index)) next.delete(row.index)
|
||
else next.add(row.index)
|
||
return next
|
||
})}
|
||
className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.045] px-2 text-[10.5px] font-semibold text-white/58 transition hover:border-white/25 hover:text-white/82"
|
||
>
|
||
高级
|
||
<ChevronDown className={`h-3.5 w-3.5 transition ${advancedRows.has(row.index) ? "rotate-180" : ""}`} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleRowSection(row.index, "fields", true)}
|
||
className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.045] px-2 text-[10.5px] font-semibold text-white/58 transition hover:border-white/25 hover:text-white/82"
|
||
>
|
||
{fieldsOpen ? "收起" : "展开"}
|
||
<ChevronDown className={`h-3.5 w-3.5 transition ${fieldsOpen ? "rotate-180" : ""}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{fieldsOpen ? (
|
||
<div className="mt-2 grid gap-3 xl:grid-cols-[minmax(280px,0.82fr)_minmax(420px,1.18fr)]">
|
||
<div className="grid min-w-0 gap-1.5">
|
||
<CompactStoryboardField
|
||
label="文案"
|
||
value={copyText}
|
||
zhValue={copyZhText}
|
||
showChinese={showChineseMirror}
|
||
optimizing={autoOptimizingField === `${row.index}:copy`}
|
||
onChange={(value) => patchRowCopy(row.index, value)}
|
||
onChangeZh={(value) => patchRowCopyZh(row.index, value)}
|
||
onChineseCommit={(value) => void optimizeEnglishFromChinese(plannedRow, referenceFrame, "copy", value)}
|
||
onSave={() => void savePromptToLibrary("skg_script", `分镜 ${row.index + 1} 文案`, copyText, copyZhText)}
|
||
onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")}
|
||
/>
|
||
<CompactStoryboardField
|
||
label="场景一句话"
|
||
value={plannedRow.sceneOneLine}
|
||
zhValue={plannedRow.sceneOneLineZh}
|
||
showChinese={showChineseMirror}
|
||
optimizing={autoOptimizingField === `${row.index}:scene`}
|
||
onChange={(value) => patchRowPlan(row.index, { sceneOneLine: value, visualPlan: value })}
|
||
onChangeZh={(value) => patchRowPlan(row.index, { sceneOneLineZh: value, visualPlanZh: value })}
|
||
onChineseCommit={(value) => void optimizeEnglishFromChinese(plannedRow, referenceFrame, "scene", value)}
|
||
onSave={() => void savePromptToLibrary("scene_desc", `分镜 ${row.index + 1} 场景一句话`, plannedRow.sceneOneLine, plannedRow.sceneOneLineZh)}
|
||
onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")}
|
||
/>
|
||
<CompactStoryboardField
|
||
label="人物 + 产品 + 动作"
|
||
value={plannedRow.actionOneLine}
|
||
zhValue={plannedRow.actionOneLineZh}
|
||
showChinese={showChineseMirror}
|
||
optimizing={autoOptimizingField === `${row.index}:action`}
|
||
onChange={(value) => patchRowPlan(row.index, { actionOneLine: value, subjectDescription: value })}
|
||
onChangeZh={(value) => patchRowPlan(row.index, { actionOneLineZh: value, subjectDescriptionZh: value })}
|
||
onChineseCommit={(value) => void optimizeEnglishFromChinese(plannedRow, referenceFrame, "action", value)}
|
||
onSave={() => void savePromptToLibrary("video_desc", `分镜 ${row.index + 1} 人物产品动作`, plannedRow.actionOneLine, plannedRow.actionOneLineZh)}
|
||
onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")}
|
||
/>
|
||
</div>
|
||
<StoryboardVideoSlots
|
||
job={job}
|
||
videos={rowVideos}
|
||
enabled={!!referenceFrame}
|
||
selectedVideoId={selectedVideoIdForRow(row, referenceFrame)}
|
||
busy={quickVideoBusyRow === row.index}
|
||
count={rowVideoCount}
|
||
onCountChange={(count) => patchRowVideoCount(row.index, count)}
|
||
onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
|
||
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
|
||
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
|
||
onClear={() => clearVideosForRow(rowVideos)}
|
||
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
|
||
onDeleteVideo={onDeleteVideo}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{advancedRows.has(row.index) ? (
|
||
<div className="grid xl:grid-cols-[54px_120px_minmax(170px,0.48fr)_minmax(420px,1.2fr)_360px] 2xl:grid-cols-[56px_140px_280px_minmax(560px,1fr)_420px]">
|
||
<StoryboardPlanCell label="分镜">
|
||
<div className="font-mono text-[11px] text-white/40">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</div>
|
||
<div className="mt-1.5 inline-flex max-w-full rounded-md border border-emerald-300/15 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] leading-tight text-emerald-100/80">
|
||
{ROLE_LABELS_ZH[row.role]}
|
||
</div>
|
||
</StoryboardPlanCell>
|
||
|
||
<StoryboardPlanCell label="原内容">
|
||
<p className="line-clamp-2 text-[10.5px] leading-snug" title={row.source}>{row.source}</p>
|
||
{showChineseMirror && row.sourceZh ? (
|
||
<p className="mt-1 line-clamp-2 text-[10px] leading-snug text-white/34" title={row.sourceZh}>中:{row.sourceZh}</p>
|
||
) : null}
|
||
</StoryboardPlanCell>
|
||
|
||
<StoryboardPlanCell label={`${scriptStep.no} 新口播文案`}>
|
||
<textarea
|
||
value={copyText}
|
||
onChange={(event) => patchRowCopy(row.index, event.target.value)}
|
||
className="min-h-[64px] w-full resize-y rounded border border-white/10 bg-black/32 px-2 py-1.5 text-[11px] leading-snug text-white/82 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
|
||
/>
|
||
{showChineseMirror && copyZhText ? (
|
||
<p className="mt-1 line-clamp-2 text-[10px] leading-snug text-white/34" title={copyZhText}>中:{copyZhText}</p>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
onClick={() => void rewriteSingleRow(row)}
|
||
disabled={scriptRewriteBusy !== null}
|
||
className="mt-1 inline-flex h-6 w-full items-center justify-center gap-1 rounded border border-white/10 bg-white/[0.045] px-2 text-[10.5px] font-semibold text-white/62 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
|
||
>
|
||
{scriptRewriteBusy === row.index ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||
单段改写
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void savePromptToLibrary("skg_script", `分镜 ${row.index + 1} 新口播`, copyText, copyZhText)}
|
||
disabled={!copyText.trim()}
|
||
className="mt-1 inline-flex h-6 w-full items-center justify-center gap-1 rounded border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] px-2 text-[10.5px] font-semibold text-[#f1d78e]/72 transition hover:border-[#d6b36a]/42 hover:text-[#f1d78e] disabled:cursor-not-allowed disabled:opacity-35"
|
||
>
|
||
<BookOpen className="h-3 w-3" />
|
||
文案入库
|
||
</button>
|
||
</StoryboardPlanCell>
|
||
|
||
<StoryboardPlanCell label={`${sceneStep.no} 画面规划 / 产品融入`}>
|
||
<div className="grid gap-1.5">
|
||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-1.5">
|
||
<select
|
||
value={plannedRow.visualMode}
|
||
onChange={(event) => applyVisualMode(row.index, event.target.value as StoryboardVisualMode)}
|
||
className="h-7 min-w-0 rounded border border-white/10 bg-black/45 px-1.5 text-[10.5px] text-white/76 outline-none focus:border-cyan-300/50"
|
||
title={VISUAL_MODE_OPTIONS.find((item) => item.value === plannedRow.visualMode)?.description}
|
||
>
|
||
{VISUAL_MODE_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>{option.label}</option>
|
||
))}
|
||
</select>
|
||
<label className="inline-flex h-7 items-center gap-1 rounded border border-white/10 bg-white/[0.045] px-1.5 text-[10px] text-white/55">
|
||
<input
|
||
type="checkbox"
|
||
checked={plannedRow.needsSubject}
|
||
onChange={(event) => patchRowPlan(row.index, { needsSubject: event.target.checked })}
|
||
className="h-3 w-3 accent-cyan-300"
|
||
/>
|
||
人物
|
||
</label>
|
||
<label className="inline-flex h-7 items-center gap-1 rounded border border-white/10 bg-white/[0.045] px-1.5 text-[10px] text-white/55">
|
||
<input
|
||
type="checkbox"
|
||
checked={plannedRow.needsProduct}
|
||
onChange={(event) => patchRowPlan(row.index, { needsProduct: event.target.checked })}
|
||
className="h-3 w-3 accent-cyan-300"
|
||
/>
|
||
产品
|
||
</label>
|
||
</div>
|
||
<textarea
|
||
value={plannedRow.visualPlan}
|
||
onChange={(event) => patchRowPlan(row.index, { visualPlan: event.target.value })}
|
||
placeholder="画面规划"
|
||
className="min-h-[42px] w-full resize-y rounded border border-white/10 bg-black/32 px-2 py-1.5 text-[10.5px] leading-snug text-white/76 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
|
||
/>
|
||
{showChineseMirror && plannedRow.visualPlanZh ? (
|
||
<p className="-mt-1 line-clamp-2 text-[10px] leading-snug text-white/32" title={plannedRow.visualPlanZh}>中:{plannedRow.visualPlanZh}</p>
|
||
) : null}
|
||
{plannedRow.needsSubject && (
|
||
<>
|
||
<textarea
|
||
value={plannedRow.subjectDescription}
|
||
onChange={(event) => patchRowPlan(row.index, { subjectDescription: event.target.value })}
|
||
placeholder="人物描述:主体身份、姿态、情绪、需要用哪些视角"
|
||
className="min-h-[42px] w-full resize-y rounded border border-[#d6b36a]/14 bg-[#d6b36a]/[0.045] px-2 py-1.5 text-[10.5px] leading-snug text-white/78 outline-none placeholder:text-white/25 focus:border-[#d6b36a]/50"
|
||
/>
|
||
{showChineseMirror && plannedRow.subjectDescriptionZh ? (
|
||
<p className="-mt-1 line-clamp-2 text-[10px] leading-snug text-white/34" title={plannedRow.subjectDescriptionZh}>中:{plannedRow.subjectDescriptionZh}</p>
|
||
) : null}
|
||
</>
|
||
)}
|
||
<div className="grid gap-1 md:grid-cols-2">
|
||
<textarea
|
||
value={plannedRow.firstFramePlan}
|
||
onChange={(event) => patchRowPlan(row.index, { firstFramePlan: event.target.value })}
|
||
placeholder="首帧:视频开始画面"
|
||
className="min-h-[48px] w-full resize-y rounded border border-emerald-300/12 bg-emerald-300/[0.04] px-2 py-1.5 text-[10.5px] leading-snug text-emerald-50/78 outline-none placeholder:text-white/25 focus:border-emerald-300/50"
|
||
/>
|
||
<textarea
|
||
value={plannedRow.lastFramePlan}
|
||
onChange={(event) => patchRowPlan(row.index, { lastFramePlan: event.target.value })}
|
||
placeholder="尾帧:视频结束画面"
|
||
className="min-h-[48px] w-full resize-y rounded border border-cyan-300/12 bg-cyan-300/[0.04] px-2 py-1.5 text-[10.5px] leading-snug text-cyan-50/78 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
|
||
/>
|
||
</div>
|
||
{showChineseMirror && (plannedRow.firstFramePlanZh || plannedRow.lastFramePlanZh) ? (
|
||
<div className="-mt-1 grid gap-1 md:grid-cols-2">
|
||
<p className="line-clamp-2 text-[10px] leading-snug text-emerald-100/34" title={plannedRow.firstFramePlanZh}>中:{plannedRow.firstFramePlanZh}</p>
|
||
<p className="line-clamp-2 text-[10px] leading-snug text-cyan-100/34" title={plannedRow.lastFramePlanZh}>中:{plannedRow.lastFramePlanZh}</p>
|
||
</div>
|
||
) : null}
|
||
<textarea
|
||
value={plannedRow.productPlacement}
|
||
onChange={(event) => patchRowPlan(row.index, { productPlacement: event.target.value })}
|
||
placeholder="产品出现方式:不出现 / 首帧出现 / 尾帧出现 / 全程佩戴 / 产品特写"
|
||
className="min-h-[38px] w-full resize-y rounded border border-white/10 bg-black/32 px-2 py-1.5 text-[10.5px] leading-snug text-white/68 outline-none placeholder:text-white/25 focus:border-rose-300/45"
|
||
/>
|
||
{showChineseMirror && plannedRow.productPlacementZh ? (
|
||
<p className="-mt-1 line-clamp-2 text-[10px] leading-snug text-white/32" title={plannedRow.productPlacementZh}>中:{plannedRow.productPlacementZh}</p>
|
||
) : null}
|
||
<div className="grid grid-cols-3 gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => void savePromptToLibrary("scene_desc", `分镜 ${row.index + 1} 画面规划`, plannedRow.visualPlan, plannedRow.visualPlanZh)}
|
||
className="h-6 rounded border border-white/10 bg-white/[0.04] px-1 text-[10px] text-white/52 transition hover:border-[#d6b36a]/32 hover:text-[#f1d78e]"
|
||
>
|
||
画面入库
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void savePromptToLibrary("subject_desc", `分镜 ${row.index + 1} 主体描述`, plannedRow.subjectDescription, plannedRow.subjectDescriptionZh)}
|
||
disabled={!plannedRow.needsSubject}
|
||
className="h-6 rounded border border-white/10 bg-white/[0.04] px-1 text-[10px] text-white/52 transition hover:border-[#d6b36a]/32 hover:text-[#f1d78e] disabled:opacity-35"
|
||
>
|
||
主体入库
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void savePromptToLibrary("product_angle", `分镜 ${row.index + 1} 产品出现`, plannedRow.productPlacement, plannedRow.productPlacementZh)}
|
||
disabled={!plannedRow.needsProduct}
|
||
className="h-6 rounded border border-white/10 bg-white/[0.04] px-1 text-[10px] text-white/52 transition hover:border-[#d6b36a]/32 hover:text-[#f1d78e] disabled:opacity-35"
|
||
>
|
||
产品入库
|
||
</button>
|
||
</div>
|
||
<div className="grid gap-1.5 md:grid-cols-[minmax(0,1fr)_88px_88px]">
|
||
<div className="rounded border border-white/10 bg-black/24 px-2 py-1.5 text-[10px] leading-snug text-white/42">
|
||
<div className="mb-1 flex items-center justify-between gap-2">
|
||
<span className="text-white/54">{sceneStep.no} 首尾帧闸门</span>
|
||
<span className={endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame") ? "text-emerald-100/75" : "text-amber-100/72"}>
|
||
{endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame") ? "可进入视频候选" : "先看图再生视频"}
|
||
</span>
|
||
</div>
|
||
<p>
|
||
关键帧只用于前置主体重构;这里用相似主体视图{plannedRow.needsProduct ? " + 产品素材池" : ""}生成首尾帧。
|
||
</p>
|
||
</div>
|
||
<EndpointFrameSlot
|
||
job={job}
|
||
frame={referenceFrame}
|
||
role="first_frame"
|
||
subjectBrief={endpointSubjectBrief}
|
||
busy={endpointFrameBusy === `${row.index}:first_frame`}
|
||
deleting={endpointFrameBusy === `${row.index}:clear_first_frame`}
|
||
disabled={!referenceFrame || (plannedRow.needsProduct && !productItems.length)}
|
||
onGenerate={() => void generateEndpointFrameForRow(plannedRow, referenceFrame, "first_frame")}
|
||
onDelete={() => void clearEndpointFrameForRow(plannedRow, referenceFrame, "first_frame")}
|
||
/>
|
||
<EndpointFrameSlot
|
||
job={job}
|
||
frame={referenceFrame}
|
||
role="last_frame"
|
||
subjectBrief={endpointSubjectBrief}
|
||
busy={endpointFrameBusy === `${row.index}:last_frame`}
|
||
deleting={endpointFrameBusy === `${row.index}:clear_last_frame`}
|
||
disabled={!referenceFrame || (plannedRow.needsProduct && !productItems.length)}
|
||
onGenerate={() => void generateEndpointFrameForRow(plannedRow, referenceFrame, "last_frame")}
|
||
onDelete={() => void clearEndpointFrameForRow(plannedRow, referenceFrame, "last_frame")}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2 text-[10px] text-white/34">
|
||
<span title={endpointSubjectBrief || "本条不传主体 brief"}>
|
||
{plannedRow.needsSubject ? "依据:主体 brief" : "本条不传主体"} · {plannedRow.needsProduct ? `${selectedProductCount || 0} 张产品参考` : "本条不传产品图"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => patchRowPlan(row.index, {
|
||
...visualModeDefaults(plannedRow.visualMode),
|
||
subjectDescription: buildSubjectDescription(plannedRow.role, plannedRow.visualMode),
|
||
subjectDescriptionZh: buildSubjectDescriptionZh(plannedRow.role, plannedRow.visualMode),
|
||
productPlacementZh: visualModeDefaults(plannedRow.visualMode, "zh").productPlacement,
|
||
})}
|
||
className="rounded border border-white/10 px-1.5 py-0.5 text-white/42 transition hover:border-white/25 hover:text-white/72"
|
||
>
|
||
重置类型
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</StoryboardPlanCell>
|
||
|
||
<StoryboardPlanCell label={`${videoStep.no} 视频候选 / 待生成`} className="xl:border-r-0">
|
||
<StoryboardVideoSlots
|
||
job={job}
|
||
videos={rowVideos}
|
||
enabled={!!referenceFrame}
|
||
expanded={videosOpen}
|
||
selectedVideoId={selectedVideoIdForRow(row, referenceFrame)}
|
||
busy={quickVideoBusyRow === row.index}
|
||
count={rowVideoCount}
|
||
onCountChange={(count) => patchRowVideoCount(row.index, count)}
|
||
onToggleExpanded={() => toggleRowSection(row.index, "videos", false)}
|
||
onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
|
||
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
|
||
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
|
||
onClear={() => clearVideosForRow(rowVideos)}
|
||
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
|
||
onDeleteVideo={onDeleteVideo}
|
||
/>
|
||
<div className="mt-1 flex items-center justify-between gap-2">
|
||
<span className="text-[10px] text-white/34">高级首尾帧</span>
|
||
<span className="rounded border border-emerald-300/18 bg-emerald-300/[0.07] px-1.5 py-0.5 text-[10px] text-emerald-100/70">视频接口自动兜底</span>
|
||
</div>
|
||
<div className="mt-1 rounded border border-cyan-300/12 bg-cyan-300/[0.045] px-2 py-1 text-[10px] leading-snug text-cyan-100/62">
|
||
不需要客户先生成首尾帧;这里保留给老手手动调首帧/尾帧 prompt 或资产。
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void saveSingleRowStoryboardDraft(plannedRow, referenceFrame)}
|
||
disabled={!referenceFrame || savingStoryboard}
|
||
className="skg-primary-action mt-1.5 inline-flex h-8 w-full items-center justify-center gap-1 px-2 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{savingStoryboard ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||
保存本条规划
|
||
</button>
|
||
</StoryboardPlanCell>
|
||
</div>
|
||
) : null}
|
||
</article>
|
||
)
|
||
})}
|
||
</div>
|
||
{refineDialog ? (() => {
|
||
const dialogRow = rows.find((item) => item.index === refineDialog.rowIndex)
|
||
const dialogFrame = refineDialog.frameIndex == null ? null : job.frames.find((item) => item.index === refineDialog.frameIndex) ?? null
|
||
const plannedDialogRow = dialogRow && dialogFrame ? { ...planForRow(dialogRow, dialogFrame), skgCopy: copyForRow(dialogRow), skgCopyZh: copyZhForRow(dialogRow) } : null
|
||
if (!dialogRow || !dialogFrame || !plannedDialogRow) return null
|
||
const quickButtons = ["更兴奋", "更舒缓", "产品再露", "主体更近", "镜头更动", "背景更暗", "节奏更快"]
|
||
return (
|
||
<div className="fixed inset-0 z-[9000] flex items-center justify-center bg-black/72 p-4">
|
||
<div className="w-full max-w-3xl rounded-lg border border-white/14 bg-[#080b0f] p-3 shadow-[0_26px_90px_rgba(0,0,0,0.72)]">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-[14px] font-semibold text-white">让 AI 帮你改这条分镜</div>
|
||
<div className="mt-0.5 text-[11px] text-white/42">分镜 {dialogRow.index + 1} · 先预览,不会直接覆盖。</div>
|
||
</div>
|
||
<button type="button" onClick={closeRefineDialog} className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/48 transition hover:text-white">
|
||
关闭
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
value={refineFeedback}
|
||
onChange={(event) => setRefineFeedback(event.target.value)}
|
||
placeholder="你想怎么改?例如:让这条更兴奋一点,但产品露出更自然。"
|
||
className="min-h-[72px] w-full resize-y rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[12px] leading-relaxed text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
|
||
/>
|
||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||
{quickButtons.map((item) => (
|
||
<button
|
||
key={item}
|
||
type="button"
|
||
onClick={() => setRefineFeedback((prev) => prev ? `${prev},${item}` : item)}
|
||
className="rounded-md border border-white/10 bg-white/[0.045] px-2 py-1 text-[10.5px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100"
|
||
>
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{refinePreview ? (
|
||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||
<div className="rounded-md border border-white/10 bg-black/24 p-2">
|
||
<div className="mb-1 text-[11px] font-semibold text-white/58">改前</div>
|
||
<p className="text-[11px] leading-snug text-white/60">文案:{plannedDialogRow.skgCopy}</p>
|
||
<p className="mt-1 text-[11px] leading-snug text-white/44">场景:{plannedDialogRow.sceneOneLine}</p>
|
||
<p className="mt-1 text-[11px] leading-snug text-white/44">动作:{plannedDialogRow.actionOneLine}</p>
|
||
</div>
|
||
<div className="rounded-md border border-emerald-300/18 bg-emerald-300/[0.06] p-2">
|
||
<div className="mb-1 text-[11px] font-semibold text-emerald-100/72">改后预览</div>
|
||
<p className="text-[11px] leading-snug text-white/76">文案:{refinePreview.skg_copy_en}</p>
|
||
<p className="mt-1 text-[11px] leading-snug text-white/56">场景:{refinePreview.scene_one_line_en}</p>
|
||
<p className="mt-1 text-[11px] leading-snug text-white/56">动作:{refinePreview.action_one_line_en}</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-3 flex items-center justify-end gap-2">
|
||
<button type="button" onClick={closeRefineDialog} className="skg-secondary-action inline-flex h-8 items-center px-3 text-[11px] font-semibold">
|
||
取消
|
||
</button>
|
||
{refinePreview ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => void submitRefine()}
|
||
disabled={refineBusy}
|
||
className="skg-secondary-action inline-flex h-8 items-center gap-1 px-3 text-[11px] font-semibold disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{refineBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
|
||
再改一次
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
applyRefineItems(dialogRow.index, refinePreview)
|
||
closeRefineDialog()
|
||
}}
|
||
className="skg-primary-action inline-flex h-8 items-center gap-1 px-3 text-[11px] font-semibold"
|
||
>
|
||
<Check className="h-3.5 w-3.5" />
|
||
应用
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => void submitRefine()}
|
||
disabled={refineBusy}
|
||
className="skg-primary-action inline-flex h-8 items-center gap-1 px-3 text-[11px] font-semibold disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{refineBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
|
||
让 AI 改
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})() : null}
|
||
</>
|
||
) : (
|
||
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成三字段分镜,并支持单条或整片批量生成 4 条视频候选。" />
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function ProductReferenceCard({
|
||
job,
|
||
item,
|
||
onPatch,
|
||
onRemove,
|
||
}: {
|
||
job: Job
|
||
item: ProductRefItem
|
||
onPatch: (patch: Partial<ProductRefItem>) => void
|
||
onRemove: () => void
|
||
}) {
|
||
const src = resolveImageRefUrl(job.id, item.ref)
|
||
const tagLabels = item.useTags.map((tag) => PRODUCT_USE_TAG_LABELS[tag]).filter(Boolean)
|
||
const assetWarnings = item.assetMeta?.warnings ?? []
|
||
const assetActions = item.assetMeta?.actions ?? []
|
||
const orientationText = formatProductOrientation(item.orientation)
|
||
const saveProductToLibrary = async () => {
|
||
if (!src) return
|
||
try {
|
||
const response = await fetch(src)
|
||
if (!response.ok) throw new Error(`fetch ${response.status}`)
|
||
const blob = await response.blob()
|
||
const file = new File([blob], `${item.view || "product"}.jpg`, { type: blob.type || "image/jpeg" })
|
||
await createAssetLibraryItem("products", {
|
||
name: item.note || productViewLabel(item.view),
|
||
name_zh: item.note || productViewLabel(item.view),
|
||
note: item.note,
|
||
tags: ["产品素材池", productViewLabel(item.view)],
|
||
source_job_id: job.id,
|
||
product_type: "neck_and_shoulder_massager",
|
||
views: [item.view],
|
||
}, [file])
|
||
toast.success("产品图已保存到素材库")
|
||
} catch (error) {
|
||
toast.error("保存到素材库失败:" + (error instanceof Error ? error.message : String(error)))
|
||
}
|
||
}
|
||
const previewDetail = (
|
||
<>
|
||
{productViewLabel(item.view)} · {productBackgroundLabel(item.background)} · {tagLabels.join(" / ") || "用途待标注"}
|
||
<br />
|
||
{item.note || "无备注"}
|
||
{orientationText ? <><br />方向:{orientationText}</> : null}
|
||
{item.landmarks.length ? <><br />结构:{item.landmarks.join(" / ")}</> : null}
|
||
{item.risk ? <><br />风险:{item.risk}</> : null}
|
||
{assetWarnings.length ? <><br />规格:{assetWarnings.join(";")}</> : null}
|
||
</>
|
||
)
|
||
|
||
return (
|
||
<div className="grid min-w-0 grid-cols-[74px_minmax(0,1fr)_28px] gap-2 rounded-md border border-white/10 bg-black/26 p-2">
|
||
<MediaAssetTile
|
||
src={src}
|
||
alt={productViewLabel(item.view)}
|
||
label={productViewLabel(item.view)}
|
||
meta={formatProductAssetSize(item.assetMeta)}
|
||
previewDetail={previewDetail}
|
||
className="h-[74px] w-[74px] bg-white"
|
||
objectFit="contain"
|
||
topLeft={<span className="rounded bg-black/70 px-1 text-[9px] text-white/75">{item.source === "ai" ? "AI" : "图"}</span>}
|
||
/>
|
||
<div className="min-w-0">
|
||
<div className="flex h-7 w-full items-center justify-between gap-2 rounded-md border border-white/10 bg-black/55 px-2 text-[11px] text-white/78">
|
||
<span className="truncate font-semibold">{productViewLabel(item.view)}</span>
|
||
<span className="shrink-0 text-[10px] text-white/34">
|
||
{item.source === "ai" ? "自动补图" : item.confidence != null ? `自动识别 ${Math.round(item.confidence * 100)}%` : "自动识别"}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 flex min-h-5 flex-wrap gap-1 overflow-hidden">
|
||
<span className="rounded border border-white/10 bg-white/[0.045] px-1.5 py-0.5 text-[9.5px] leading-none text-white/44">{formatProductAssetSize(item.assetMeta)}</span>
|
||
<span className="rounded border border-white/10 bg-white/[0.045] px-1.5 py-0.5 text-[9.5px] leading-none text-white/44">{productBackgroundLabel(item.background)}</span>
|
||
{tagLabels.slice(0, 3).map((tag) => (
|
||
<span key={tag} className="rounded border border-cyan-300/14 bg-cyan-300/[0.07] px-1.5 py-0.5 text-[9.5px] leading-none text-cyan-100/58">{tag}</span>
|
||
))}
|
||
{orientationText ? <span className="rounded border border-violet-300/14 bg-violet-300/[0.07] px-1.5 py-0.5 text-[9.5px] leading-none text-violet-100/60">方向已识别</span> : null}
|
||
{item.landmarks.slice(0, 2).map((landmark) => (
|
||
<span key={landmark} className="rounded border border-white/10 bg-white/[0.045] px-1.5 py-0.5 text-[9.5px] leading-none text-white/44">{landmark}</span>
|
||
))}
|
||
{item.risk || assetWarnings.length ? <span className="rounded border border-amber-300/18 bg-amber-300/[0.08] px-1.5 py-0.5 text-[9.5px] leading-none text-amber-100/68">需留意</span> : null}
|
||
{assetActions.length ? <span className="rounded border border-emerald-300/14 bg-emerald-300/[0.07] px-1.5 py-0.5 text-[9.5px] leading-none text-emerald-100/58">已标准化</span> : null}
|
||
</div>
|
||
<input
|
||
value={item.note}
|
||
onChange={(event) => onPatch({ note: event.target.value })}
|
||
placeholder="检查备注:佩戴者左/右、上/下、触点、尺寸比例"
|
||
className="mt-1 h-8 w-full rounded-md border border-white/10 bg-black/35 px-2 text-[11px] text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
|
||
/>
|
||
<div className="mt-1 truncate text-[10px] text-white/32">{item.ref.label || "产品参考图"}</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onRemove}
|
||
className="h-7 w-7 rounded-md border border-white/10 text-white/40 transition hover:border-rose-300/40 hover:text-rose-200"
|
||
aria-label="移除产品图"
|
||
>
|
||
<Trash2 className="mx-auto h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void saveProductToLibrary()}
|
||
className="col-start-3 h-7 w-7 rounded-md border border-[#d6b36a]/22 text-[#f1d78e]/70 transition hover:border-[#d6b36a]/55 hover:text-[#f1d78e]"
|
||
aria-label="保存产品图到素材库"
|
||
title="保存到素材库"
|
||
>
|
||
<BookOpen className="mx-auto h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MissingProductViewSlot({
|
||
slot,
|
||
canGenerate,
|
||
busy,
|
||
blocked,
|
||
onGenerate,
|
||
}: {
|
||
slot: typeof PRODUCT_VIEW_SLOTS[number]
|
||
canGenerate: boolean
|
||
busy: boolean
|
||
blocked: boolean
|
||
onGenerate: () => void
|
||
}) {
|
||
return (
|
||
<div className="min-h-[94px] rounded-md border border-dashed border-white/12 bg-black/20 p-2">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="text-[12px] font-semibold text-white/58">{slot.label}</div>
|
||
<span className="rounded border border-amber-300/18 bg-amber-300/[0.08] px-1.5 py-0.5 text-[10px] text-amber-100/72">缺视角</span>
|
||
</div>
|
||
<p className="mt-1 line-clamp-2 text-[10.5px] leading-snug text-white/34">{slot.hint}</p>
|
||
<p className="mt-1 text-[10px] leading-snug text-white/28">{canGenerate ? "自动补图失败时可重试。" : "上传后会自动识别并补齐。"}</p>
|
||
<button
|
||
type="button"
|
||
onClick={onGenerate}
|
||
disabled={!canGenerate || busy || blocked}
|
||
className="mt-2 inline-flex h-7 w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2 text-[10.5px] font-semibold text-white/62 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
|
||
>
|
||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
|
||
重试补图
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StoryboardPlanCell({ label, children, className = "" }: { label: string; children: ReactNode; className?: string }) {
|
||
return (
|
||
<div className={`min-w-0 border-b border-white/8 p-2 xl:border-b-0 xl:border-r ${className}`}>
|
||
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white/32">{label}</div>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CompactStoryboardField({
|
||
label,
|
||
value,
|
||
zhValue,
|
||
showChinese,
|
||
onChange,
|
||
onChangeZh,
|
||
onChineseCommit,
|
||
onSave,
|
||
onPick,
|
||
optimizing = false,
|
||
}: {
|
||
label: string
|
||
value: string
|
||
zhValue?: string
|
||
showChinese: boolean
|
||
onChange: (value: string) => void
|
||
onChangeZh?: (value: string) => void
|
||
onChineseCommit?: (value: string) => void
|
||
onSave?: () => void
|
||
onPick?: () => void
|
||
optimizing?: boolean
|
||
}) {
|
||
return (
|
||
<div className="min-w-0 rounded-md border border-white/10 bg-black/28 p-1.5">
|
||
<div className="mb-1 flex items-center justify-between gap-2">
|
||
<span className="text-[11px] font-semibold text-white/68">{label}</span>
|
||
<span className="flex items-center gap-1">
|
||
{optimizing ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100/80" /> : null}
|
||
<button
|
||
type="button"
|
||
onClick={onSave}
|
||
disabled={!value.trim()}
|
||
className="inline-flex h-6 w-6 items-center justify-center rounded border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] text-[#f1d78e]/70 transition hover:border-[#d6b36a]/45 hover:text-[#f1d78e] disabled:cursor-not-allowed disabled:opacity-30"
|
||
title="保存到提示词库"
|
||
aria-label="保存到提示词库"
|
||
>
|
||
<BookOpen className="h-3 w-3" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onPick}
|
||
className="inline-flex h-6 w-6 items-center justify-center rounded border border-white/10 bg-white/[0.045] text-white/45 transition hover:border-cyan-300/35 hover:text-cyan-100"
|
||
title="从提示词库选用"
|
||
aria-label="从提示词库选用"
|
||
>
|
||
<PanelRight className="h-3 w-3" />
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<textarea
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
className="min-h-[40px] w-full resize-y rounded border border-white/10 bg-black/34 px-2 py-1.5 text-[11px] leading-snug text-white/82 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
|
||
/>
|
||
{showChinese ? (
|
||
<textarea
|
||
value={zhValue ?? ""}
|
||
onChange={(event) => onChangeZh?.(event.target.value)}
|
||
onBlur={(event) => onChineseCommit?.(event.currentTarget.value)}
|
||
placeholder="改中文后自动优化英文"
|
||
className="mt-1 min-h-[28px] w-full resize-none rounded border border-white/8 bg-black/22 px-2 py-1 text-[10px] leading-snug text-white/46 outline-none placeholder:text-white/22 focus:border-cyan-300/35"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StoryboardVideoSlots({
|
||
job,
|
||
videos,
|
||
enabled,
|
||
selectedVideoId = "",
|
||
busy = false,
|
||
count = 4,
|
||
onCountChange,
|
||
onDraw,
|
||
onReroll,
|
||
onRegenerate,
|
||
onClear,
|
||
onSelect,
|
||
onDeleteVideo,
|
||
}: {
|
||
job: Job
|
||
videos: GeneratedVideo[]
|
||
enabled: boolean
|
||
expanded?: boolean
|
||
selectedVideoId?: string
|
||
busy?: boolean
|
||
count?: number
|
||
onCountChange?: (count: number) => void
|
||
onToggleExpanded?: () => void
|
||
onDraw?: () => void
|
||
onReroll?: () => void
|
||
onRegenerate?: () => void
|
||
onClear?: () => void
|
||
onSelect?: (videoId: string) => void
|
||
onDeleteVideo?: (videoId: string) => void
|
||
}) {
|
||
const visible = videos
|
||
const runningCount = videos.filter((video) => video.status === "queued" || video.status === "in_progress").length
|
||
const selectedVideo = selectedVideoId ? videos.find((video) => video.id === selectedVideoId) : null
|
||
const targetCount = clampVideoCount(count)
|
||
const emptyCount = visible.length ? 0 : Math.max(1, targetCount)
|
||
return (
|
||
<div className="min-w-0 rounded-md border border-white/10 bg-black/24 p-2">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||
<Film className="h-3.5 w-3.5 text-cyan-100/65" />
|
||
<span className="text-[11px] font-semibold text-white/66">视频候选横向轨</span>
|
||
<span className="shrink-0 text-[10px] text-white/34">
|
||
{videos.length ? `${videos.length} 条${runningCount ? ` · ${runningCount} 生成中` : ""}` : enabled ? "待生成" : "待抽帧"}
|
||
</span>
|
||
{selectedVideo ? <span className="rounded border border-emerald-300/20 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] text-emerald-100/72">已选 {shortId(selectedVideo.id)}</span> : null}
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
<label className="inline-flex h-7 items-center gap-1 rounded-md border border-white/10 bg-black/36 px-1.5 text-[10px] font-semibold text-white/48">
|
||
生成
|
||
<select
|
||
value={targetCount}
|
||
onChange={(event) => onCountChange?.(Number(event.target.value))}
|
||
disabled={!onCountChange}
|
||
className="h-5 rounded border border-white/10 bg-black/45 px-1 text-center font-mono text-[10.5px] text-white/78 outline-none disabled:opacity-45"
|
||
>
|
||
{STORYBOARD_VIDEO_COUNT_OPTIONS.map((option) => (
|
||
<option key={option} value={option}>{option}</option>
|
||
))}
|
||
</select>
|
||
条
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={videos.length ? onReroll : onDraw}
|
||
disabled={!enabled || busy}
|
||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-cyan-300/20 bg-cyan-300/[0.07] px-2 text-[10px] font-semibold text-cyan-100/70 transition hover:border-cyan-300/45 hover:text-cyan-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||
>
|
||
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||
{videos.length ? `再生成 ${targetCount} 条` : `生成 ${targetCount} 条`}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onClear}
|
||
disabled={!videos.length}
|
||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.04] px-2 text-[10px] font-semibold text-white/46 transition hover:border-rose-300/35 hover:text-rose-100 disabled:cursor-not-allowed disabled:opacity-30"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
清空
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="mt-2 flex min-h-[170px] items-stretch gap-2 overflow-x-auto pb-1">
|
||
{visible.map((video) => (
|
||
<StoryboardVideoPreview
|
||
key={video.id}
|
||
job={job}
|
||
video={video}
|
||
selected={selectedVideoId === video.id}
|
||
className="h-[168px] w-[94px]"
|
||
onSelect={onSelect ? () => onSelect(video.id) : undefined}
|
||
onRegenerate={onRegenerate}
|
||
onDelete={onDeleteVideo ? () => onDeleteVideo(video.id) : undefined}
|
||
/>
|
||
))}
|
||
{Array.from({ length: emptyCount }).map((_, index) => (
|
||
<div key={`empty-video-${index}`} className="flex h-[168px] w-[94px] shrink-0 items-center justify-center rounded-md border border-dashed border-white/12 bg-black/25 px-2 text-center text-[9.5px] leading-tight text-white/26">
|
||
{enabled ? `待生成 ${index + 1}` : "待抽帧"}
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={videos.length ? onReroll : onDraw}
|
||
disabled={!enabled || busy}
|
||
className="flex h-[168px] w-[94px] shrink-0 flex-col items-center justify-center gap-2 rounded-md border border-cyan-300/18 bg-cyan-300/[0.055] px-2 text-center text-[10.5px] font-semibold text-cyan-100/72 transition hover:border-cyan-300/48 hover:text-cyan-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||
>
|
||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||
{videos.length ? `追加 ${targetCount} 条` : `生成 ${targetCount} 条`}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EndpointFrameSlot({
|
||
job,
|
||
frame,
|
||
role,
|
||
subjectBrief,
|
||
busy,
|
||
deleting,
|
||
disabled,
|
||
onGenerate,
|
||
onDelete,
|
||
}: {
|
||
job: Job
|
||
frame: KeyFrame | null
|
||
role: "first_frame" | "last_frame"
|
||
subjectBrief?: string
|
||
busy: boolean
|
||
deleting?: boolean
|
||
disabled: boolean
|
||
onGenerate: () => void
|
||
onDelete?: () => void
|
||
}) {
|
||
const ref = endpointAssetRef(frame, role)
|
||
const src = ref ? resolveImageRefUrl(job.id, ref) : ""
|
||
const label = role === "first_frame" ? "首帧" : "尾帧"
|
||
const saveEndpointFrameToLibrary = async () => {
|
||
if (!src) return
|
||
try {
|
||
const response = await fetch(src)
|
||
if (!response.ok) throw new Error(`fetch ${response.status}`)
|
||
const blob = await response.blob()
|
||
const file = new File([blob], `${role}.jpg`, { type: blob.type || "image/jpeg" })
|
||
await createAssetLibraryItem("scenes", {
|
||
name: `${label} · ${shortId(job.id)}`,
|
||
name_zh: `${label} · ${shortId(job.id)}`,
|
||
note: subjectBrief || `${label}首尾帧资产`,
|
||
tags: [label, "首尾帧"],
|
||
source_job_id: job.id,
|
||
asset_role: role,
|
||
aspect_ratio: "9:16",
|
||
}, [file])
|
||
toast.success(`${label}已保存到素材库`)
|
||
} catch (error) {
|
||
toast.error("保存首尾帧到素材库失败:" + (error instanceof Error ? error.message : String(error)))
|
||
}
|
||
}
|
||
return (
|
||
<div className="overflow-hidden rounded border border-white/10 bg-black/32">
|
||
<div className="flex h-6 items-center justify-between gap-1 border-b border-white/10 px-1.5 text-[9.5px] text-white/42">
|
||
<span>{label}</span>
|
||
<span className="flex items-center gap-1">
|
||
{src ? (
|
||
<button type="button" onClick={() => void saveEndpointFrameToLibrary()} className="inline-flex h-4 w-4 items-center justify-center rounded border border-[#d6b36a]/20 bg-[#d6b36a]/8 text-[#f1d78e]/72" title="保存到素材库">
|
||
<BookOpen className="h-3 w-3" />
|
||
</button>
|
||
) : null}
|
||
<span
|
||
title={subjectBrief?.trim() ? subjectBrief : "本条没有主体 brief,生成时只按画面规划和产品参考执行。"}
|
||
className="inline-flex h-4 w-4 items-center justify-center rounded border border-white/10 bg-white/[0.045] text-white/45"
|
||
>
|
||
<Info className="h-3 w-3" />
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<MediaAssetTile
|
||
src={src}
|
||
href={src || undefined}
|
||
alt={`${label}资产`}
|
||
label={`${label}资产`}
|
||
className="aspect-[9/16] min-h-[112px] border-0"
|
||
objectFit="contain"
|
||
busy={busy}
|
||
emptyText={`先生成${label}`}
|
||
onDelete={src && onDelete ? onDelete : undefined}
|
||
deleting={deleting}
|
||
deleteLabel={`移除${label}`}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={onGenerate}
|
||
disabled={disabled || busy}
|
||
className="flex h-7 w-full items-center justify-center gap-1 border-t border-white/10 bg-white/[0.045] px-1 text-[10px] font-semibold text-white/62 transition hover:bg-white/[0.09] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
|
||
>
|
||
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
|
||
{src ? `重生${label}` : `生成${label}`}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StoryboardVideoPreview({
|
||
job,
|
||
video,
|
||
className = "h-20 w-12",
|
||
selected = false,
|
||
onSelect,
|
||
onRegenerate,
|
||
onDelete,
|
||
}: {
|
||
job: Job
|
||
video: GeneratedVideo
|
||
className?: string
|
||
selected?: boolean
|
||
onSelect?: () => void
|
||
onRegenerate?: () => void
|
||
onDelete?: () => void
|
||
}) {
|
||
const src = videoSrc(video)
|
||
const poster = videoPoster(job, video)
|
||
const running = video.status === "queued" || video.status === "in_progress"
|
||
return (
|
||
<MediaAssetTile
|
||
kind="video"
|
||
src={src && video.status === "completed" ? src : undefined}
|
||
poster={poster}
|
||
href={onSelect ? undefined : src || undefined}
|
||
alt={`片段 ${shortId(video.id)}`}
|
||
label={`${shortId(video.id)} · ${video.model}`}
|
||
meta={video.status}
|
||
className={`shrink-0 bg-black/45 ${className}`}
|
||
objectFit="cover"
|
||
selected={selected}
|
||
onClick={onSelect}
|
||
title={`${video.model} · ${video.status}`}
|
||
bottom={<span className="block truncate rounded bg-black/70 px-1 py-0.5 text-center font-mono text-[9px] text-white/62">{running ? "生成中" : video.status === "failed" ? "失败" : shortId(video.id)}</span>}
|
||
topLeft={selected ? <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-400 text-black"><Check className="h-3 w-3" /></span> : undefined}
|
||
topRight={running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : undefined}
|
||
actions={onRegenerate ? [{ key: "regen", label: "重生一个候选", icon: <RefreshCw className="h-3 w-3" />, onClick: onRegenerate, tone: "cyan" }] : []}
|
||
onDelete={onDelete}
|
||
deleteLabel="删除这个视频候选"
|
||
/>
|
||
)
|
||
}
|
||
|
||
function AudioWaveform({
|
||
features,
|
||
status,
|
||
currentTime,
|
||
hoverTime,
|
||
duration,
|
||
segments,
|
||
onSeek,
|
||
onHoverTimeChange,
|
||
}: {
|
||
features: AudioFeature[]
|
||
status: AudioFeatureStatus
|
||
currentTime: number
|
||
hoverTime: number | null
|
||
duration: number
|
||
segments: Array<{ start: number; end: number }>
|
||
onSeek: (time: number) => void
|
||
onHoverTimeChange?: (time: number | null) => 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)
|
||
const hasFeatures = features.length > 0
|
||
const { topPoints, bottomPoints, envelopePoints } = useMemo(() => {
|
||
const top = features.map((feature, index) => {
|
||
const x = features.length <= 1 ? 0 : (index / (features.length - 1)) * 100
|
||
return `${x.toFixed(2)},${(50 - feature.loudness * 32).toFixed(2)}`
|
||
}).join(" ")
|
||
const bottom = [...features].reverse().map((feature, index) => {
|
||
const originalIndex = features.length - 1 - index
|
||
const x = features.length <= 1 ? 0 : (originalIndex / (features.length - 1)) * 100
|
||
return `${x.toFixed(2)},${(50 + feature.loudness * 32).toFixed(2)}`
|
||
}).join(" ")
|
||
return { topPoints: top, bottomPoints: bottom, envelopePoints: `${top} ${bottom}` }
|
||
}, [features])
|
||
return (
|
||
<div
|
||
className="relative h-24 cursor-pointer overflow-hidden rounded-md border border-white/10 bg-black/35 px-2"
|
||
aria-label="音频响度波形"
|
||
onClick={(event) => {
|
||
const rect = event.currentTarget.getBoundingClientRect()
|
||
onSeek(((event.clientX - rect.left) / Math.max(rect.width, 1)) * duration)
|
||
}}
|
||
onMouseMove={(event) => {
|
||
const rect = event.currentTarget.getBoundingClientRect()
|
||
onHoverTimeChange?.(clampNumber(((event.clientX - rect.left) / Math.max(rect.width, 1)) * duration, 0, duration))
|
||
}}
|
||
onMouseLeave={() => onHoverTimeChange?.(null)}
|
||
>
|
||
<div className="absolute inset-y-2 left-2 right-2">
|
||
{!hasFeatures && (
|
||
<div className="absolute inset-0 flex items-center justify-center text-[11px] text-white/35">
|
||
{status === "failed" ? "audio.wav 解码失败" : status === "loading" ? "正在解码 audio.wav" : "等待音频文件"}
|
||
</div>
|
||
)}
|
||
<div className="absolute inset-x-0 top-1/2 h-px bg-white/14" />
|
||
{hasFeatures && (
|
||
<svg className="pointer-events-none absolute inset-0 h-full w-full overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||
<polygon
|
||
points={envelopePoints}
|
||
fill="rgba(209,213,219,0.74)"
|
||
/>
|
||
<polyline
|
||
points={topPoints}
|
||
fill="none"
|
||
stroke="rgba(229,231,235,0.7)"
|
||
strokeWidth="0.55"
|
||
vectorEffect="non-scaling-stroke"
|
||
/>
|
||
<polyline
|
||
points={bottomPoints}
|
||
fill="none"
|
||
stroke="rgba(229,231,235,0.52)"
|
||
strokeWidth="0.55"
|
||
vectorEffect="non-scaling-stroke"
|
||
/>
|
||
</svg>
|
||
)}
|
||
</div>
|
||
{segments.map((segment, index) => (
|
||
<div
|
||
key={`${segment.start}-${index}`}
|
||
className="absolute inset-y-0 w-px bg-white/12"
|
||
style={{ left: `${clampNumber((segment.start / Math.max(duration, 1)) * 100, 0, 100)}%` }}
|
||
/>
|
||
))}
|
||
{hoverPct !== null && (
|
||
<div
|
||
className="pointer-events-none absolute inset-y-0 w-px bg-cyan-100/70"
|
||
style={{ left: `${hoverPct}%` }}
|
||
/>
|
||
)}
|
||
<div
|
||
className="pointer-events-none absolute inset-y-0 w-[2px] bg-emerald-200 shadow-[0_0_16px_rgba(110,231,183,0.85)] will-change-[left]"
|
||
style={{ left: `${pointerPct}%` }}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ModelTrace({ trace, compact = false }: { trace: ModelTraceSpec; compact?: boolean }) {
|
||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
||
const buttonRef = useRef<HTMLButtonElement | null>(null)
|
||
|
||
const toggle = () => {
|
||
if (position) {
|
||
setPosition(null)
|
||
return
|
||
}
|
||
const rect = buttonRef.current?.getBoundingClientRect()
|
||
if (!rect) return
|
||
const width = Math.min(380, window.innerWidth - 32)
|
||
const height = 260
|
||
let left = rect.right - width
|
||
let top = rect.bottom + 8
|
||
if (left < 16) left = 16
|
||
if (left + width > window.innerWidth - 16) left = window.innerWidth - width - 16
|
||
if (top + height > window.innerHeight - 16) top = Math.max(16, rect.top - height - 8)
|
||
setPosition({ left, top })
|
||
}
|
||
|
||
const popover = position && typeof document !== "undefined"
|
||
? createPortal(
|
||
<div
|
||
className="fixed z-[10000] w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/15 bg-black/95 p-3 text-white shadow-[0_24px_80px_rgba(0,0,0,0.75)]"
|
||
style={{ left: position.left, top: position.top }}
|
||
>
|
||
<div className="mb-2 flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-[12px] font-semibold text-white">{trace.title}</div>
|
||
<div className="mt-1 truncate font-mono text-[11px] text-cyan-100/80" title={trace.model}>{trace.model}</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPosition(null)}
|
||
className="h-6 w-6 rounded-md border border-white/10 text-white/45 transition hover:border-white/25 hover:text-white"
|
||
aria-label="关闭模型链路"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<ol className="space-y-1.5">
|
||
{trace.chain.map((item, index) => (
|
||
<li key={`${trace.title}-${index}`} className="grid grid-cols-[18px_minmax(0,1fr)] gap-2 text-[11px] leading-snug text-white/66">
|
||
<span className="flex h-[18px] w-[18px] items-center justify-center rounded-full border border-white/10 bg-white/[0.04] font-mono text-[9px] text-white/42">{index + 1}</span>
|
||
<span>{item}</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
{trace.note ? <div className="mt-2 rounded-md border border-white/10 bg-white/[0.035] px-2 py-1.5 text-[10.5px] leading-snug text-white/42">{trace.note}</div> : null}
|
||
</div>,
|
||
document.body,
|
||
)
|
||
: null
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
ref={buttonRef}
|
||
type="button"
|
||
onClick={toggle}
|
||
className={`inline-flex min-w-0 items-center justify-center gap-1 rounded-md border border-cyan-300/18 bg-cyan-300/[0.065] text-cyan-50/76 transition hover:border-cyan-200/45 hover:bg-cyan-300/[0.12] ${compact ? "h-7 max-w-[260px] px-2 text-[10px]" : "h-8 max-w-[320px] px-2.5 text-[11px]"}`}
|
||
title={`${trace.title} · ${trace.model}`}
|
||
>
|
||
<Info className="h-3.5 w-3.5 shrink-0" />
|
||
<span className="shrink-0">模型</span>
|
||
<span className="min-w-0 truncate font-mono">{trace.model}</span>
|
||
</button>
|
||
{popover}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function FrameExtractControls({
|
||
job,
|
||
data,
|
||
selectedFramesCount,
|
||
onSelectAllFrames,
|
||
onClearFrameSelection,
|
||
}: {
|
||
job: Job | null
|
||
data: NodeData
|
||
selectedFramesCount: number
|
||
onSelectAllFrames: () => void
|
||
onClearFrameSelection: () => void
|
||
}) {
|
||
return (
|
||
<div className="rounded-lg border border-white/10 bg-black/32 p-3">
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<SectionTitle icon={<Scissors className="h-4 w-4" />} title="抽帧供分镜使用" />
|
||
<StatusPill ready={!!job?.frames.length} running={data.analyzing || job?.status === "splitting"} />
|
||
</div>
|
||
<div className="grid grid-cols-[1fr_1fr_72px] gap-2">
|
||
<select
|
||
value={job ? data.frameTargets[job.id] ?? "transparent_human" : "balanced"}
|
||
onChange={(e) => job && data.onFrameTargetChange(job.id, e.target.value as FrameExtractTarget)}
|
||
disabled={!job}
|
||
className={controlClass}
|
||
>
|
||
{TARGETS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||
</select>
|
||
<select
|
||
value={job ? data.frameQualities[job.id] ?? "auto" : "auto"}
|
||
onChange={(e) => job && data.onFrameQualityChange(job.id, e.target.value as FrameExtractQuality)}
|
||
disabled={!job}
|
||
className={controlClass}
|
||
>
|
||
{QUALITIES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||
</select>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={20}
|
||
value={job ? data.frameCounts[job.id] ?? 12 : 12}
|
||
onChange={(e) => job && data.onFrameCountChange(job.id, Number(e.target.value) || 12)}
|
||
disabled={!job}
|
||
className={`${controlClass} text-center`}
|
||
/>
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
<ActionButton disabled={!job || data.analyzing} onClick={() => data.onAnalyze({ mode: job?.frames.length ? "append" : "replace" })}>
|
||
{data.analyzing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
||
{job?.frames.length ? "追加抽帧" : "开始抽帧"}
|
||
</ActionButton>
|
||
<ActionButton disabled={!job?.frames.length} variant="ghost" onClick={onSelectAllFrames}>全选分镜</ActionButton>
|
||
<ActionButton disabled={!selectedFramesCount} variant="ghost" onClick={onClearFrameSelection}>清空选择</ActionButton>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StoryboardSegmentCard({
|
||
job,
|
||
frame,
|
||
order,
|
||
selected,
|
||
selectedVideoIds,
|
||
videos,
|
||
busy,
|
||
sixViewBusyKey,
|
||
onToggleFrame,
|
||
onJobUpdate,
|
||
onGenerateElement,
|
||
onGenerateSixViews,
|
||
onGenerateVideo,
|
||
onToggleVideo,
|
||
onDeleteVideo,
|
||
}: {
|
||
job: Job
|
||
frame: KeyFrame
|
||
order: number
|
||
selected: boolean
|
||
selectedVideoIds: Set<string>
|
||
videos: GeneratedVideo[]
|
||
busy: boolean
|
||
sixViewBusyKey: string | null
|
||
onToggleFrame: () => void
|
||
onJobUpdate: (job: Job) => void
|
||
onGenerateElement: (candidate?: FrameObject) => void
|
||
onGenerateSixViews: (element: KeyElement) => void
|
||
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
||
onToggleVideo: (videoId: string) => void
|
||
onDeleteVideo?: (videoId: string) => void
|
||
}) {
|
||
const [scene, setScene] = useState<StoryboardScene>(() => ({ ...emptyScene(), ...(frame.storyboard ?? {}) }))
|
||
const [model, setModel] = useState<VideoModel>("seedance")
|
||
const [saving, setSaving] = useState(false)
|
||
const [generatingVideo, setGeneratingVideo] = useState(false)
|
||
const elements = frame.elements ?? []
|
||
const generatedImages = frame.generated_images ?? []
|
||
const objectCandidates = frame.description?.objects?.slice(0, 8).filter((item) => item.name?.trim()) ?? []
|
||
const objectNames = objectCandidates.map((item) => item.name)
|
||
const elementPreviews = elements
|
||
.map((element) => ({ element, src: representativeCutoutUrl(job.id, frame.index, element) }))
|
||
.filter((item): item is { element: typeof elements[number]; src: string } => !!item.src)
|
||
|
||
useEffect(() => {
|
||
setScene({ ...emptyScene(), ...(frame.storyboard ?? {}) })
|
||
}, [frame.index, frame.storyboard])
|
||
|
||
const patch = (next: Partial<StoryboardScene>) => setScene((prev) => ({ ...prev, ...next }))
|
||
|
||
const save = async () => {
|
||
setSaving(true)
|
||
try {
|
||
const updated = await updateStoryboard(job.id, frame.index, scene)
|
||
onJobUpdate(updated)
|
||
toast.success(`分镜 ${order + 1} 已保存`)
|
||
} catch (e) {
|
||
toast.error("保存失败:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const generateVideo = async () => {
|
||
setGeneratingVideo(true)
|
||
try {
|
||
await save()
|
||
await onGenerateVideo(frame.index, scene, model)
|
||
} finally {
|
||
setGeneratingVideo(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<article className={`rounded-lg border p-3 transition ${selected ? "border-rose-400/60 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
|
||
<div className="mb-3 flex items-start justify-between gap-3">
|
||
<div className="flex items-start gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onToggleFrame}
|
||
className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black"
|
||
aria-label={selected ? "取消选中分镜" : "选中分镜"}
|
||
>
|
||
<img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, order)} className="h-full w-full object-cover" />
|
||
<span className="absolute right-1 top-1 rounded-full bg-black/70 p-0.5">
|
||
{selected ? <Check className="h-3.5 w-3.5 text-rose-200" /> : <Circle className="h-3.5 w-3.5 text-white/55" />}
|
||
</span>
|
||
</button>
|
||
<div>
|
||
<div className="font-mono text-[12px] text-white/48">分镜 {String(order + 1).padStart(2, "0")}</div>
|
||
<h3 className="mt-1 text-[15px] font-semibold text-white">{frameLabel(frame, order)}</h3>
|
||
<p className="mt-1 max-w-[520px] line-clamp-2 text-[12px] leading-relaxed text-white/42">{frame.description?.scene || "等待生成新的分镜文字"}</p>
|
||
</div>
|
||
</div>
|
||
<label className="flex h-9 items-center gap-2 rounded-md border border-white/10 bg-black/35 px-2 text-[12px] text-white/50">
|
||
秒
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
step={0.5}
|
||
value={scene.duration || 5}
|
||
onChange={(e) => patch({ duration: Number(e.target.value) || 5 })}
|
||
className="w-14 bg-transparent text-center font-mono text-white outline-none"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="grid gap-3">
|
||
<SegmentBand icon={<FileText className="h-4 w-4" />} title="音频分镜文案">
|
||
<div className="grid gap-2 lg:grid-cols-3">
|
||
<textarea
|
||
value={scene.scene ?? ""}
|
||
onChange={(e) => patch({ scene: e.target.value })}
|
||
placeholder="新剧情:根据原音频和产品内容,这一镜要讲什么"
|
||
className={`${fieldClass} min-h-[72px]`}
|
||
/>
|
||
<textarea
|
||
value={scene.product ?? ""}
|
||
onChange={(e) => patch({ product: e.target.value })}
|
||
placeholder="产品融入:SKG 产品在哪里出现,怎么被使用"
|
||
className={`${fieldClass} min-h-[72px]`}
|
||
/>
|
||
<textarea
|
||
value={scene.action ?? ""}
|
||
onChange={(e) => patch({ action: e.target.value })}
|
||
placeholder="动作 / 镜头:首尾变化、手部动作、运镜节奏"
|
||
className={`${fieldClass} min-h-[72px]`}
|
||
/>
|
||
</div>
|
||
<div className="mt-2 flex justify-end">
|
||
<ActionButton variant="ghost" disabled={saving} onClick={save}>
|
||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||
保存分镜文字
|
||
</ActionButton>
|
||
</div>
|
||
</SegmentBand>
|
||
|
||
<SegmentBand icon={<Package className="h-4 w-4" />} title="每个分镜需要的关键元素">
|
||
<div className="grid gap-3 lg:grid-cols-[170px_minmax(0,1fr)_auto]">
|
||
<img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, order)} className="aspect-video w-full rounded-md border border-white/10 bg-black object-cover" />
|
||
<div className="min-w-0">
|
||
<div className="mb-2 flex items-center gap-2 text-[11px] text-white/44">
|
||
<ImageIcon className="h-3.5 w-3.5" />
|
||
<span>{elements.filter(hasCutout).length}/{elements.length || objectNames.length || 0} 元素</span>
|
||
<span>{generatedImages.length} 张生成图</span>
|
||
</div>
|
||
<div className="mb-2 flex flex-wrap gap-1">
|
||
{objectCandidates.length > 0 && objectCandidates.map((candidate) => (
|
||
<button
|
||
key={`${candidate.name}:${candidate.position ?? ""}`}
|
||
type="button"
|
||
onClick={() => onGenerateElement(candidate)}
|
||
disabled={busy}
|
||
className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-1 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||
title="选择该元素并生成提取图 + 高清视图"
|
||
>
|
||
{candidate.name}
|
||
</button>
|
||
))}
|
||
{!objectCandidates.length && !elements.length && <span className="text-[11px] text-white/32">暂无识别元素</span>}
|
||
</div>
|
||
{elements.length > 0 && (
|
||
<div className="mb-2 grid gap-1">
|
||
{elements.slice(0, 5).map((element) => {
|
||
const busySix = sixViewBusyKey === `${frame.index}:${element.id}`
|
||
return (
|
||
<div key={element.id} className="flex items-center justify-between gap-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5">
|
||
<span className="min-w-0 truncate text-[11px] text-white/62">{element.name_zh || element.name_en}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onGenerateSixViews(element)}
|
||
disabled={busySix}
|
||
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-white/10 px-2 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{busySix ? <Loader2 className="h-3 w-3 animate-spin" /> : <ImageIcon className="h-3 w-3" />}
|
||
{element.subject_assets?.length ? `${element.subject_assets.length}视图` : "高清视图"}
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
{(elementPreviews.length > 0 || generatedImages.length > 0) && (
|
||
<div className="flex gap-1 overflow-x-auto pb-1">
|
||
{elementPreviews.slice(0, 6).map(({ element, src }) => (
|
||
<img key={element.id} src={src} alt={element.name_zh || element.name_en} className="h-14 w-14 shrink-0 rounded-md border border-white/10 bg-black object-cover" />
|
||
))}
|
||
{generatedImages.slice(0, 6).map((image) => (
|
||
<img key={image.id} src={generatedImageUrl(job.id, frame.index, image.id)} alt={image.prompt || "生成图"} className="h-14 w-14 shrink-0 rounded-md border border-white/10 bg-black object-cover" />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<ActionButton disabled={busy} variant="ghost" onClick={() => onGenerateElement()}>
|
||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||
提取+高清视图
|
||
</ActionButton>
|
||
</div>
|
||
</SegmentBand>
|
||
|
||
<SegmentBand icon={<Film className="h-4 w-4" />} title="视频生成">
|
||
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||
<div className="grid gap-2">
|
||
<select value={model} onChange={(e) => setModel(e.target.value as VideoModel)} className="h-10 rounded-md border border-white/10 bg-black/55 px-2 text-[12px] text-white outline-none">
|
||
{VIDEO_MODELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||
</select>
|
||
<ActionButton disabled={generatingVideo} onClick={generateVideo}>
|
||
{generatingVideo ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||
生成本分镜视频
|
||
</ActionButton>
|
||
</div>
|
||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||
{videos.length > 0 ? videos.map((video) => (
|
||
<VideoCandidate
|
||
key={video.id}
|
||
job={job}
|
||
video={video}
|
||
selected={selectedVideoIds.has(video.id)}
|
||
onToggle={() => onToggleVideo(video.id)}
|
||
onDelete={() => onDeleteVideo?.(video.id)}
|
||
/>
|
||
)) : (
|
||
<EmptyState text="这里会出现本分镜生成出的候选视频。" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
</SegmentBand>
|
||
</div>
|
||
</article>
|
||
)
|
||
}
|
||
|
||
function DraftSegmentCard({
|
||
draft,
|
||
order,
|
||
job,
|
||
onPatch,
|
||
onRemove,
|
||
onJobUpdate,
|
||
onGenerateVideo,
|
||
}: {
|
||
draft: DraftSegment
|
||
order: number
|
||
job: Job | null
|
||
onPatch: (patch: Partial<DraftSegment>) => void
|
||
onRemove: () => void
|
||
onJobUpdate: (job: Job) => void
|
||
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
||
}) {
|
||
const [model, setModel] = useState<VideoModel>("seedance")
|
||
const [saving, setSaving] = useState(false)
|
||
const [generatingVideo, setGeneratingVideo] = useState(false)
|
||
const boundFrame = draft.frameIndex !== null ? job?.frames.find((frame) => frame.index === draft.frameIndex) ?? null : null
|
||
|
||
const patchScene = (next: Partial<StoryboardScene>) => {
|
||
onPatch({ scene: { ...draft.scene, ...next } })
|
||
}
|
||
|
||
const save = async () => {
|
||
if (!job || draft.frameIndex === null) {
|
||
toast.info("先给草稿分镜绑定一个关键帧,再保存。")
|
||
return
|
||
}
|
||
setSaving(true)
|
||
try {
|
||
const updated = await updateStoryboard(job.id, draft.frameIndex, draft.scene)
|
||
onJobUpdate(updated)
|
||
toast.success(`草稿分镜 ${order + 1} 已保存到关键帧`)
|
||
} catch (e) {
|
||
toast.error("保存失败:" + (e instanceof Error ? e.message : String(e)))
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const generateVideo = async () => {
|
||
if (!job || draft.frameIndex === null) {
|
||
toast.info("先绑定关键帧,视频生成需要首帧参考。")
|
||
return
|
||
}
|
||
setGeneratingVideo(true)
|
||
try {
|
||
await save()
|
||
await onGenerateVideo(draft.frameIndex, draft.scene, model)
|
||
} finally {
|
||
setGeneratingVideo(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<article className="rounded-lg border border-dashed border-cyan-300/22 bg-cyan-500/[0.045] p-3">
|
||
<div className="mb-3 flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="font-mono text-[12px] text-cyan-100/50">草稿分镜 {String(order + 1).padStart(2, "0")}</div>
|
||
<h3 className="mt-1 text-[15px] font-semibold text-white">追加音频分镜</h3>
|
||
<p className="mt-1 text-[12px] text-white/42">可先写分镜,后面再绑定关键帧和生成视频。</p>
|
||
</div>
|
||
<button type="button" onClick={onRemove} className="h-9 w-9 rounded-md border border-white/10 text-white/45 transition hover:border-rose-300/40 hover:text-rose-200" aria-label="删除草稿分镜">
|
||
<Trash2 className="mx-auto h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid gap-3">
|
||
<SegmentBand icon={<FileText className="h-4 w-4" />} title="音频分镜文案">
|
||
<div className="grid gap-2 lg:grid-cols-3">
|
||
<textarea value={draft.scene.scene ?? ""} onChange={(e) => patchScene({ scene: e.target.value })} placeholder="新剧情" className={`${fieldClass} min-h-[72px]`} />
|
||
<textarea value={draft.scene.product ?? ""} onChange={(e) => patchScene({ product: e.target.value })} placeholder="产品融入" className={`${fieldClass} min-h-[72px]`} />
|
||
<textarea value={draft.scene.action ?? ""} onChange={(e) => patchScene({ action: e.target.value })} placeholder="动作 / 镜头" className={`${fieldClass} min-h-[72px]`} />
|
||
</div>
|
||
<div className="mt-2 flex justify-between gap-2">
|
||
<label className="flex h-10 items-center gap-2 rounded-md border border-white/10 bg-black/35 px-2 text-[12px] text-white/50">
|
||
秒
|
||
<input type="number" min={1} step={0.5} value={draft.scene.duration || 5} onChange={(e) => patchScene({ duration: Number(e.target.value) || 5 })} className="w-14 bg-transparent text-center font-mono text-white outline-none" />
|
||
</label>
|
||
<ActionButton variant="ghost" disabled={saving} onClick={save}>
|
||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||
保存到关键帧
|
||
</ActionButton>
|
||
</div>
|
||
</SegmentBand>
|
||
|
||
<SegmentBand icon={<Package className="h-4 w-4" />} title="每个分镜需要的关键元素">
|
||
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||
<select
|
||
value={draft.frameIndex ?? ""}
|
||
onChange={(e) => onPatch({ frameIndex: e.target.value ? Number(e.target.value) : null })}
|
||
disabled={!job?.frames.length}
|
||
className={controlClass}
|
||
>
|
||
<option value="">绑定关键帧</option>
|
||
{job?.frames.map((frame, index) => <option key={frame.index} value={frame.index}>{frameLabel(frame, index)}</option>)}
|
||
</select>
|
||
{boundFrame ? (
|
||
<div className="flex items-center gap-3 rounded-md border border-white/10 bg-black/28 p-2">
|
||
<img src={effectiveFrameUrl(job!.id, boundFrame)} alt="绑定关键帧" className="h-16 w-24 rounded-md object-cover" />
|
||
<span className="text-[12px] text-white/52">{boundFrame.description?.scene || "已绑定关键帧,可保存并生成视频。"}</span>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-md border border-dashed border-white/12 bg-black/25 p-3 text-[12px] text-white/38">暂未绑定关键帧,关键元素和视频生成会在绑定后可用。</div>
|
||
)}
|
||
</div>
|
||
</SegmentBand>
|
||
|
||
<SegmentBand icon={<Film className="h-4 w-4" />} title="视频生成">
|
||
<div className="grid gap-2 lg:grid-cols-[220px_220px]">
|
||
<select value={model} onChange={(e) => setModel(e.target.value as VideoModel)} className="h-10 rounded-md border border-white/10 bg-black/55 px-2 text-[12px] text-white outline-none">
|
||
{VIDEO_MODELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||
</select>
|
||
<ActionButton disabled={generatingVideo || draft.frameIndex === null} onClick={generateVideo}>
|
||
{generatingVideo ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||
生成本分镜视频
|
||
</ActionButton>
|
||
</div>
|
||
</SegmentBand>
|
||
</div>
|
||
</article>
|
||
)
|
||
}
|
||
|
||
function SegmentBand({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
|
||
return (
|
||
<section className="rounded-lg border border-white/10 bg-black/24 p-3">
|
||
<div className="mb-2">
|
||
<SectionTitle icon={icon} title={title} />
|
||
</div>
|
||
{children}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function ComposeSummary({
|
||
audioReady,
|
||
selectedVideoCount,
|
||
generatedVideoCount,
|
||
}: {
|
||
audioReady: boolean
|
||
selectedVideoCount: number
|
||
generatedVideoCount: number
|
||
}) {
|
||
return (
|
||
<div className="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-black/35 px-3 py-2">
|
||
<div className="flex items-center gap-2">
|
||
<PanelRight className="h-4 w-4 text-rose-200" />
|
||
<div>
|
||
<div className="text-[13px] font-semibold text-white">完整视频合成</div>
|
||
<div className="text-[11px] text-white/40">音频和已选分镜视频将合成完整广告,接口待接入。</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[11px] text-white/52">
|
||
<Requirement label="音频" ready={audioReady} detail={audioReady ? "已生成" : "待解析"} />
|
||
<Requirement label="候选" ready={generatedVideoCount > 0} detail={`${generatedVideoCount}`} />
|
||
<Requirement label="已选" ready={selectedVideoCount > 0} detail={`${selectedVideoCount}`} />
|
||
<button type="button" disabled className="inline-flex h-10 cursor-not-allowed items-center justify-center gap-2 rounded-md border border-white/10 bg-white/[0.04] px-3 text-[12px] font-semibold text-white/34">
|
||
<Film className="h-4 w-4" />
|
||
合成完整视频
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MaterialCard({
|
||
job,
|
||
index,
|
||
active,
|
||
onClick,
|
||
onDelete,
|
||
}: {
|
||
job: Job
|
||
index: number
|
||
active: boolean
|
||
onClick: () => void
|
||
onDelete?: () => void
|
||
}) {
|
||
const tone = statusTone(job)
|
||
const errorText = formatJobError(job.error)
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={`group w-full rounded-lg border p-3 text-left transition ${active ? "border-[#d6b36a]/55 bg-[#d6b36a]/10 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" : "border-white/10 bg-black/28 hover:border-[#d6b36a]/28 hover:bg-white/[0.045]"}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="font-mono text-[12px] text-white/78">素材 {String(index + 1).padStart(2, "0")}</div>
|
||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-white/42">
|
||
<Link2 className="h-3 w-3" />
|
||
<span className="truncate">{job.url || shortId(job.id)}</span>
|
||
</div>
|
||
</div>
|
||
<span className={`shrink-0 rounded-md border px-2 py-1 text-[11px] ${tone.className}`}>{tone.label}</span>
|
||
</div>
|
||
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px] text-white/44">
|
||
<Metric label="视频" value={job.video_url ? "ready" : "-"} compact />
|
||
<Metric label="文案" value={job.audio_script?.source_text || job.transcript.length ? "ready" : "-"} compact />
|
||
<Metric label="段落" value={`${job.transcript.length}`} compact />
|
||
</div>
|
||
{job.status === "failed" && errorText && (
|
||
<div className="mt-2 flex gap-1.5 rounded-md border border-rose-300/18 bg-rose-500/[0.08] px-2 py-1.5 text-[11px] leading-snug text-rose-100/82">
|
||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||
<span className="line-clamp-3">{errorText}</span>
|
||
</div>
|
||
)}
|
||
{onDelete && (
|
||
<span
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={(event) => { event.stopPropagation(); onDelete() }}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
onDelete()
|
||
}
|
||
}}
|
||
className="mt-3 hidden h-8 items-center justify-center gap-1 rounded-md border border-white/10 text-[11px] text-white/50 transition hover:border-[#d6b36a]/40 hover:text-[#f2d58a] group-hover:flex"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
删除素材
|
||
</span>
|
||
)}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function Metric({ label, value, compact }: { label: string; value: string; compact?: boolean }) {
|
||
return (
|
||
<div className={`skg-stat-card ${compact ? "px-2 py-1" : "px-2.5 py-1.5"}`}>
|
||
<div className="skg-stat-card__label">{label}</div>
|
||
<div className="skg-stat-card__value mt-0.5 truncate font-mono text-[13px] font-semibold">{value}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const workflowStatusLabels: Record<WorkflowStepStatus, string> = {
|
||
blocked: "未解锁",
|
||
pending: "待处理",
|
||
running: "运行中",
|
||
ready: "已通过",
|
||
paused: "已暂停",
|
||
}
|
||
|
||
function workflowStatusClass(status: WorkflowStepStatus) {
|
||
if (status === "ready") return "border-[#8fb071]/32 bg-[#8fb071]/12 text-[#d7efbc]"
|
||
if (status === "running") return "border-[#d6b36a]/34 bg-[#d6b36a]/12 text-[#f5d98e]"
|
||
if (status === "paused") return "border-[#d6b36a]/25 bg-[#d6b36a]/08 text-[#e8c77a]/78"
|
||
if (status === "blocked") return "border-white/8 bg-white/[0.025] text-white/32"
|
||
return "border-white/10 bg-white/[0.035] text-white/45"
|
||
}
|
||
|
||
function workflowStatusIcon(status: WorkflowStepStatus) {
|
||
if (status === "running") return <Loader2 className="h-3 w-3 animate-spin" />
|
||
if (status === "ready") return <Check className="h-3 w-3" />
|
||
if (status === "paused") return <AlertTriangle className="h-3 w-3" />
|
||
return <Circle className="h-2.5 w-2.5" />
|
||
}
|
||
|
||
function WorkflowOrderBar({ steps }: { steps: WorkflowStep[] }) {
|
||
return (
|
||
<div className="skg-board-panel mb-3 shrink-0 rounded-lg border border-white/10 bg-white/[0.035] p-2">
|
||
<div className="mb-1.5 flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2 text-[11px] font-semibold text-white/66">
|
||
<PanelRight className="h-3.5 w-3.5 text-[#f2d58a]" />
|
||
流程顺序
|
||
</div>
|
||
<div className="truncate text-[10.5px] text-white/36">编号按解锁条件判定,悬停每步可查看完整判断逻辑。</div>
|
||
</div>
|
||
<div className="grid grid-cols-[repeat(9,minmax(116px,1fr))] gap-1.5 overflow-x-auto">
|
||
{steps.map((step) => (
|
||
<div
|
||
key={step.id}
|
||
title={`判定:${step.judge}`}
|
||
className={`min-h-[58px] rounded-md border px-2 py-1.5 ${workflowStatusClass(step.status)}`}
|
||
>
|
||
<div className="flex items-center justify-between gap-1">
|
||
<div className="min-w-0 truncate">
|
||
<span className="mr-1 font-mono text-[11px]">{step.no}</span>
|
||
<span className="text-[11px] font-semibold">{step.title}</span>
|
||
</div>
|
||
{workflowStatusIcon(step.status)}
|
||
</div>
|
||
<div className="mt-1 truncate text-[10px] opacity-80">{step.detail}</div>
|
||
<div className="mt-0.5 truncate text-[9.5px] opacity-60">判定:{step.judge}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function WorkflowStepBadge({ step, compact }: { step: WorkflowStep; compact?: boolean }) {
|
||
return (
|
||
<span
|
||
title={`判定:${step.judge}`}
|
||
className={`inline-flex shrink-0 items-center gap-1 rounded-md border font-mono ${workflowStatusClass(step.status)} ${compact ? "px-1.5 py-0.5 text-[10.5px]" : "px-2 py-1 text-[11px]"}`}
|
||
>
|
||
<span>{step.no}</span>
|
||
{!compact && <span className="font-sans">{step.title}</span>}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function SectionTitle({ icon, title }: { icon: ReactNode; title: string }) {
|
||
return (
|
||
<h3 className="flex items-center gap-2 text-[13px] font-semibold text-white">
|
||
<span className="text-[#f2d58a]">{icon}</span>
|
||
{title}
|
||
</h3>
|
||
)
|
||
}
|
||
|
||
function WorkflowStatusPill({ status }: { status: WorkflowStepStatus }) {
|
||
return (
|
||
<span className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] ${workflowStatusClass(status)}`}>
|
||
{workflowStatusIcon(status)}
|
||
{workflowStatusLabels[status]}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function StatusPill({ ready, running }: { ready: boolean; running?: boolean }) {
|
||
return <WorkflowStatusPill status={stepStatus({ ready, running })} />
|
||
}
|
||
|
||
function ActionButton({
|
||
children,
|
||
disabled,
|
||
onClick,
|
||
variant = "solid",
|
||
}: {
|
||
children: ReactNode
|
||
disabled?: boolean
|
||
onClick?: () => void
|
||
variant?: "solid" | "ghost"
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
className={`inline-flex h-10 cursor-pointer items-center justify-center gap-1.5 px-3 text-[12px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 ${variant === "solid" ? "skg-primary-action" : "skg-secondary-action"}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function EmptyState({ text }: { text: string }) {
|
||
return (
|
||
<div className="skg-empty-state px-3 py-8 text-center text-[12px]">
|
||
<div className="skg-empty-character" aria-hidden="true">
|
||
<AnimatedLoginCharacters mood="idle" eyeOffset={{ x: 0, y: 0 }} />
|
||
</div>
|
||
<div>{text}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Requirement({ label, ready, detail }: { label: string; ready: boolean; detail: string }) {
|
||
return (
|
||
<div className="flex h-7 min-w-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/24 px-2">
|
||
{ready ? <Check className="h-3 w-3 shrink-0 text-emerald-200" /> : <Circle className="h-3 w-3 shrink-0 text-white/38" />}
|
||
<span className="shrink-0 whitespace-nowrap">{label}</span>
|
||
<span className="min-w-0 truncate font-mono text-[10.5px] text-white/42">{detail}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PipelineLane({ step }: { step: WorkflowStep }) {
|
||
return (
|
||
<div className="flex min-h-9 items-center justify-between gap-2 rounded-md border border-white/10 bg-black/24 px-2.5 py-1.5">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[11px] font-semibold text-white/64">
|
||
<span className="mr-1 font-mono text-white/38">{step.no}</span>
|
||
{step.title}
|
||
</div>
|
||
<div className="mt-0.5 truncate text-[10px] text-white/34" title={`判定:${step.judge}`}>{step.detail}</div>
|
||
</div>
|
||
<WorkflowStatusPill status={step.status} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function VideoCandidate({
|
||
job,
|
||
video,
|
||
selected,
|
||
onToggle,
|
||
onDelete,
|
||
}: {
|
||
job: Job
|
||
video: GeneratedVideo
|
||
selected: boolean
|
||
onToggle: () => void
|
||
onDelete?: () => void
|
||
}) {
|
||
const src = videoSrc(video)
|
||
const poster = videoPoster(job, video)
|
||
const running = video.status === "queued" || video.status === "in_progress"
|
||
return (
|
||
<div className={`rounded-lg border p-2 transition ${selected ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
|
||
<div className="flex gap-2">
|
||
<button type="button" onClick={onToggle} className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black">
|
||
{src && video.status === "completed" ? (
|
||
<video src={src} poster={poster} muted playsInline className="h-full w-full object-cover" />
|
||
) : poster ? (
|
||
<img src={poster} alt={`片段 ${shortId(video.id)}`} className="h-full w-full object-cover opacity-80" />
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center text-white/30"><Film className="h-4 w-4" /></div>
|
||
)}
|
||
<div className="absolute right-1 top-1 rounded-full bg-black/70 p-0.5">{selected ? <Check className="h-3 w-3 text-rose-200" /> : <Circle className="h-3 w-3 text-white/55" />}</div>
|
||
</button>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="truncate font-mono text-[12px] text-white/80">{shortId(video.id)} · {video.model}</div>
|
||
<button type="button" onClick={onDelete} className="h-8 w-8 rounded-md border border-white/10 text-white/45 transition hover:border-rose-300/40 hover:text-rose-200" aria-label="删除片段">
|
||
<Trash2 className="mx-auto h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
<div className="mt-1 flex items-center gap-2 text-[11px] text-white/45">
|
||
{running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-200" /> : video.status === "failed" ? <AlertTriangle className="h-3 w-3 text-rose-200" /> : <Film className="h-3 w-3" />}
|
||
<span>{video.status}</span>
|
||
<span>{formatSeconds(video.duration)}</span>
|
||
<span>{video.progress}%</span>
|
||
</div>
|
||
{video.error && <div className="mt-1 line-clamp-2 text-[11px] text-rose-200/80">{video.error}</div>}
|
||
{src && video.status === "completed" && (
|
||
<a href={src} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-cyan-200 hover:text-cyan-100">
|
||
<Play className="h-3 w-3" />
|
||
预览片段
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|