auto-save 2026-05-17 23:35 (~4)

This commit is contained in:
2026-05-17 23:35:20 +08:00
parent 44136f58b7
commit 970bc56cc2
4 changed files with 268 additions and 27 deletions

View File

@@ -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,

View File

@@ -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") {