auto-save 2026-05-17 23:35 (~4)
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import {
|
||||
AlertTriangle, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Link2, Loader2,
|
||||
AlertTriangle, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2,
|
||||
Mic, Package, PanelRight, Play, Plus, Scissors, Sparkles, Trash2, Upload, Wand2,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type KeyFrame,
|
||||
type ProductViewAnalysisItem,
|
||||
type ProductRefStateItem,
|
||||
type RuntimeModels,
|
||||
type StoryboardScriptRewriteSegment,
|
||||
type StoryboardScene,
|
||||
type SubjectAsset,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
generateProductAngleAsset,
|
||||
generateSubjectAssets,
|
||||
generatedImageUrl,
|
||||
getRuntimeHealth,
|
||||
hasCutout,
|
||||
representativeCutoutUrl,
|
||||
resolveImageRefUrl,
|
||||
@@ -95,6 +97,13 @@ type AudioStoryboardRow = {
|
||||
type ProductRefItem = ProductRefStateItem
|
||||
type SubjectStyleMode = "transparent_human" | "source_actor"
|
||||
|
||||
type ModelTraceSpec = {
|
||||
title: string
|
||||
model: string
|
||||
chain: string[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
const PRODUCT_VIEW_SLOTS = [
|
||||
{ value: "front", label: "正面/外侧", hint: "整体 U 形轮廓、开口宽度、外壳主外观" },
|
||||
{ value: "left_45", label: "佩戴者左 45", hint: "戴在脖子上时佩戴者左肩一侧的弧度、按钮/结构差异" },
|
||||
@@ -285,6 +294,89 @@ function subjectAssetUrl(job: Job, asset: SubjectAsset) {
|
||||
return apiAssetUrl(asset.url) || resolveImageRefUrl(job.id, { kind: "asset", frame_idx: 0, element_id: asset.id })
|
||||
}
|
||||
|
||||
function modelValue(value?: string) {
|
||||
return value?.trim() || "待配置"
|
||||
}
|
||||
|
||||
function modelList(values: Array<string | undefined>) {
|
||||
return values.map(modelValue).filter((value, index, list) => value && list.indexOf(value) === index).join(" / ")
|
||||
}
|
||||
|
||||
function imageModelChain(models?: RuntimeModels) {
|
||||
return modelList(models?.image_fallbacks?.length ? models.image_fallbacks : [models?.image, "gemini-3.1-flash-image-preview", "gemini-2.5-flash-image"])
|
||||
}
|
||||
|
||||
function resolveVideoModelLabel(models: RuntimeModels | undefined, model: string) {
|
||||
const concrete = models?.video_aliases?.[model] || (model === models?.video ? models.video : "")
|
||||
return concrete && concrete !== model ? `${model} -> ${concrete}` : modelValue(concrete || model)
|
||||
}
|
||||
|
||||
function audioModelTrace(models?: RuntimeModels): ModelTraceSpec {
|
||||
return {
|
||||
title: "音频解析",
|
||||
model: modelList([models?.asr, models?.translate, models?.asr_fallback]),
|
||||
chain: [
|
||||
`ASR 转写:优先 ${modelValue(models?.asr)},失败后尝试本机 ${modelValue(models?.local_asr)},再回退 ${modelValue(models?.asr_fallback)}`,
|
||||
`字幕翻译:${modelValue(models?.translate)} 输出中文逐句时间轴`,
|
||||
`讲话人 / 节奏 / 背景音:${modelValue(models?.asr_fallback)} 读取 audio.wav 做多模态音频分析`,
|
||||
],
|
||||
note: "点击“解析音频”后触发;开始任务下载完成后也会自动走这条链路。",
|
||||
}
|
||||
}
|
||||
|
||||
function productModelTrace(models?: RuntimeModels): ModelTraceSpec {
|
||||
return {
|
||||
title: "产品视角识别 / 补图",
|
||||
model: modelList([models?.vision, models?.image]),
|
||||
chain: [
|
||||
`批量视角识别:${modelValue(models?.vision)} 一次读取同一产品多张图,标注视角、左右、上下、用途和风险`,
|
||||
`缺角度补图:${imageModelChain(models)} 按同一肩颈按摩仪结构补齐缺失视角`,
|
||||
"前端只保存标注和 AI 补图结果;后续生成视频时每条最多挑 6 张相关产品图",
|
||||
],
|
||||
note: "上传产品图、重新识别、缺视角重试都会使用这组模型链路。",
|
||||
}
|
||||
}
|
||||
|
||||
function similarSubjectModelTrace(models: RuntimeModels | undefined, subjectStyle: SubjectStyleMode): ModelTraceSpec {
|
||||
return {
|
||||
title: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似普通真人主体",
|
||||
model: imageModelChain(models),
|
||||
chain: [
|
||||
"参考帧策略:未勾选时使用全部关键帧,勾选后只使用已选关键帧",
|
||||
`主体类型:${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`,
|
||||
`图像生成:${imageModelChain(models)} 逐张生成正、背、左、右、左前 45、右前 45`,
|
||||
"身份锁定:六张必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致",
|
||||
],
|
||||
note: "这是生成类似主体,不是复制、抠出或复刻源视频人物身份。",
|
||||
}
|
||||
}
|
||||
|
||||
function scriptRewriteModelTrace(models?: RuntimeModels): ModelTraceSpec {
|
||||
return {
|
||||
title: "新口播文案改写",
|
||||
model: modelList([models?.audio_rewrite, models?.asr_fallback, models?.translate]),
|
||||
chain: [
|
||||
`主改写:${modelValue(models?.audio_rewrite)} 根据原文案、当前分镜、作者想法生成新口播`,
|
||||
`失败回退:依次尝试 ${modelValue(models?.asr_fallback)} 和 ${modelValue(models?.translate)}`,
|
||||
"返回结果只写入当前分镜文案编辑框;生成视频时再把当前文案写入分镜 action",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function videoModelTrace(models: RuntimeModels | undefined, model: string): ModelTraceSpec {
|
||||
return {
|
||||
title: "视频生成",
|
||||
model: resolveVideoModelLabel(models, model),
|
||||
chain: [
|
||||
`前端选择:${model}`,
|
||||
`后端解析:${resolveVideoModelLabel(models, model)}`,
|
||||
`服务商:${modelValue(models?.video_provider)} · ${modelValue(models?.video_base_url)}`,
|
||||
"输入:当前分镜文案、参考帧、产品素材、产品方向标注和画面规划",
|
||||
"输出:异步候选视频,完成后回填到对应分镜行",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -632,6 +724,7 @@ export function AdRecreationBoard({
|
||||
const [elementBusyFrame, setElementBusyFrame] = useState<number | null>(null)
|
||||
const [sixViewBusyKey, setSixViewBusyKey] = useState<string | null>(null)
|
||||
const [generatingAll, setGeneratingAll] = useState(false)
|
||||
const [runtimeModels, setRuntimeModels] = useState<RuntimeModels | undefined>()
|
||||
const fileRef = useRef<HTMLInputElement | null>(null)
|
||||
const selectedFrames = job
|
||||
? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp)
|
||||
@@ -648,6 +741,20 @@ export function AdRecreationBoard({
|
||||
setSelectedVideoIds(new Set())
|
||||
}, [activeJobId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
getRuntimeHealth()
|
||||
.then((health) => {
|
||||
if (!cancelled) setRuntimeModels(health.models)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("模型配置读取失败", error)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const submitUrl = () => {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return
|
||||
@@ -847,10 +954,13 @@ export function AdRecreationBoard({
|
||||
{job?.message || "下载源视频后解析音频,再抽参考帧并生成相似主体。"}
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton disabled={!job?.video_url || job.status === "transcribing"} onClick={() => data.onTranscribeAudio?.(job?.id)}>
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
解析音频
|
||||
</ActionButton>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModelTrace trace={audioModelTrace(runtimeModels)} compact />
|
||||
<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-2 grid grid-cols-1 gap-2 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
@@ -890,6 +1000,7 @@ export function AdRecreationBoard({
|
||||
selectedFrames={data.selectedFrames}
|
||||
onJobUpdate={data.onJobUpdate}
|
||||
onGenerateVideo={onGenerateVideo}
|
||||
runtimeModels={runtimeModels}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1258,6 +1369,7 @@ function AudioIntakePanel({
|
||||
onToggleFrame={onToggleFrame}
|
||||
onJobUpdate={onJobUpdate}
|
||||
onDeleteFrame={onDeleteFrame}
|
||||
runtimeModels={runtimeModels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1271,12 +1383,14 @@ function SourceReferenceBuildPanel({
|
||||
onToggleFrame,
|
||||
onJobUpdate,
|
||||
onDeleteFrame,
|
||||
runtimeModels,
|
||||
}: {
|
||||
job: Job
|
||||
selectedFrames: Set<number>
|
||||
onToggleFrame: (idx: number) => void
|
||||
onJobUpdate: (job: Job) => void
|
||||
onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
|
||||
runtimeModels?: RuntimeModels
|
||||
}) {
|
||||
const [extracting, setExtracting] = useState(false)
|
||||
const [subjectBusy, setSubjectBusy] = useState(false)
|
||||
@@ -1498,7 +1612,10 @@ function SourceReferenceBuildPanel({
|
||||
|
||||
<div className="mt-2 border-t border-white/8 pt-2">
|
||||
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2 text-[10px] text-white/36">
|
||||
<span>相似主体白底视图</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>相似主体白底视图</span>
|
||||
<ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectStyle)} compact />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
|
||||
<div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">
|
||||
{[
|
||||
@@ -1566,11 +1683,13 @@ function AudioStoryboardPlanPanel({
|
||||
selectedFrames,
|
||||
onJobUpdate,
|
||||
onGenerateVideo,
|
||||
runtimeModels,
|
||||
}: {
|
||||
job: Job | null
|
||||
selectedFrames: Set<number>
|
||||
onJobUpdate?: (job: Job) => void
|
||||
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
||||
runtimeModels?: RuntimeModels
|
||||
}) {
|
||||
const [videoBusyRow, setVideoBusyRow] = useState<number | null>(null)
|
||||
const [productItems, setProductItems] = useState<ProductRefItem[]>([])
|
||||
@@ -1881,6 +2000,7 @@ function AudioStoryboardPlanPanel({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionTitle icon={<Package className="h-4 w-4" />} title="同一产品素材池 / 视角标注" />
|
||||
<ModelTrace trace={productModelTrace(runtimeModels)} compact />
|
||||
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/42">{productItems.length ? `${productItems.length} 张素材` : "素材池不限量"}</span>
|
||||
{(productAnalyzing || productAngleBusy) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-cyan-300/20 bg-cyan-300/[0.08] px-2 py-0.5 text-[10px] text-cyan-100/75">
|
||||
@@ -1959,6 +2079,7 @@ function AudioStoryboardPlanPanel({
|
||||
className="min-h-[42px] resize-y rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[11px] leading-snug text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ModelTrace trace={scriptRewriteModelTrace(runtimeModels)} compact />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void rewriteAllRows()}
|
||||
@@ -2030,6 +2151,10 @@ function AudioStoryboardPlanPanel({
|
||||
<div className="mt-1 truncate text-[10px] text-white/34" title={referenceFrame ? `参考 ${referenceFrame.timestamp.toFixed(1)}s` : row.referencePlan}>
|
||||
{referenceFrame ? `参考 ${referenceFrame.timestamp.toFixed(1)}s · 可多次生成候选` : "先在关键帧区自动抽帧 12 张"}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-white/34">生视频模型</span>
|
||||
<ModelTrace trace={videoModelTrace(runtimeModels, "seedance")} compact />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => generateRowVideo(row, referenceFrame)}
|
||||
@@ -2037,7 +2162,7 @@ function AudioStoryboardPlanPanel({
|
||||
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" />}
|
||||
生成本条
|
||||
生成本条 · Seedance
|
||||
</button>
|
||||
</StoryboardPlanCell>
|
||||
</article>
|
||||
@@ -2366,6 +2491,79 @@ function ProfileTile({ label, value, running }: { label: string; value?: string;
|
||||
)
|
||||
}
|
||||
|
||||
function ModelTrace({ trace, compact = false }: { trace: ModelTraceSpec; compact?: boolean }) {
|
||||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
const toggle = () => {
|
||||
if (position) {
|
||||
setPosition(null)
|
||||
return
|
||||
}
|
||||
const rect = buttonRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const width = Math.min(380, window.innerWidth - 32)
|
||||
const height = 260
|
||||
let left = rect.right - width
|
||||
let top = rect.bottom + 8
|
||||
if (left < 16) left = 16
|
||||
if (left + width > window.innerWidth - 16) left = window.innerWidth - width - 16
|
||||
if (top + height > window.innerHeight - 16) top = Math.max(16, rect.top - height - 8)
|
||||
setPosition({ left, top })
|
||||
}
|
||||
|
||||
const popover = position && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
className="fixed z-[10000] w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/15 bg-black/95 p-3 text-white shadow-[0_24px_80px_rgba(0,0,0,0.75)]"
|
||||
style={{ left: position.left, top: position.top }}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-semibold text-white">{trace.title}</div>
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-cyan-100/80" title={trace.model}>{trace.model}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPosition(null)}
|
||||
className="h-6 w-6 rounded-md border border-white/10 text-white/45 transition hover:border-white/25 hover:text-white"
|
||||
aria-label="关闭模型链路"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<ol className="space-y-1.5">
|
||||
{trace.chain.map((item, index) => (
|
||||
<li key={`${trace.title}-${index}`} className="grid grid-cols-[18px_minmax(0,1fr)] gap-2 text-[11px] leading-snug text-white/66">
|
||||
<span className="flex h-[18px] w-[18px] items-center justify-center rounded-full border border-white/10 bg-white/[0.04] font-mono text-[9px] text-white/42">{index + 1}</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{trace.note ? <div className="mt-2 rounded-md border border-white/10 bg-white/[0.035] px-2 py-1.5 text-[10.5px] leading-snug text-white/42">{trace.note}</div> : null}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={`inline-flex min-w-0 items-center justify-center gap-1 rounded-md border border-cyan-300/18 bg-cyan-300/[0.065] text-cyan-50/76 transition hover:border-cyan-200/45 hover:bg-cyan-300/[0.12] ${compact ? "h-7 max-w-[260px] px-2 text-[10px]" : "h-8 max-w-[320px] px-2.5 text-[11px]"}`}
|
||||
title={`${trace.title} · ${trace.model}`}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="shrink-0">模型</span>
|
||||
<span className="min-w-0 truncate font-mono">{trace.model}</span>
|
||||
</button>
|
||||
{popover}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameExtractControls({
|
||||
job,
|
||||
data,
|
||||
|
||||
@@ -134,6 +134,45 @@ export interface GeneratedVideo {
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface RuntimeModels {
|
||||
asr?: string
|
||||
local_asr?: string
|
||||
asr_fallback?: string
|
||||
translate?: string
|
||||
rewrite?: string
|
||||
audio_rewrite?: string
|
||||
vision?: string
|
||||
image?: string
|
||||
image_fallbacks?: string[]
|
||||
minimax_tts?: string
|
||||
minimax_voice?: string
|
||||
minimax_voice_pool?: string[]
|
||||
minimax_configured?: boolean
|
||||
video?: string
|
||||
video_aliases?: Record<string, string>
|
||||
video_provider?: string
|
||||
video_base_url?: string
|
||||
video_configured?: boolean
|
||||
video_create_paths?: string[]
|
||||
}
|
||||
|
||||
export interface RuntimeHealth {
|
||||
ok: boolean
|
||||
llm_configured?: boolean
|
||||
auth_configured?: boolean
|
||||
base_url?: string
|
||||
models?: RuntimeModels
|
||||
}
|
||||
|
||||
export async function getRuntimeHealth(): Promise<RuntimeHealth> {
|
||||
const res = await fetch(`${API_BASE}/health`, { cache: "no-store" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`health ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// 把 ImageRef 解析成可显示的 src URL
|
||||
export function resolveImageRefUrl(jobId: string, ref: ImageRef): string {
|
||||
if (ref.kind === "keyframe") {
|
||||
|
||||
Reference in New Issue
Block a user