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