auto-save 2026-05-14 10:36 (~5)
This commit is contained in:
@@ -1,26 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "7a31e86",
|
|
||||||
"message": "auto-save 2026-05-13 01:24 (~1)",
|
|
||||||
"ts": "2026-05-13T01:25:10+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "473e24c",
|
|
||||||
"message": "auto-save 2026-05-13 01:30 (~1)",
|
|
||||||
"ts": "2026-05-13T01:31:04+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "3009c0a",
|
|
||||||
"message": "auto-save 2026-05-13 01:36 (~1)",
|
|
||||||
"ts": "2026-05-13T01:36:58+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"files_changed": 1,
|
"files_changed": 1,
|
||||||
"hash": "ab6f035",
|
"hash": "ab6f035",
|
||||||
@@ -3313,6 +3292,25 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 4 项未提交变更 · 最近提交:auto-save 2026-05-14 10:25 (~13)",
|
"message": "Codex 会话活跃 · 最近命令:codex · 4 项未提交变更 · 最近提交:auto-save 2026-05-14 10:25 (~13)",
|
||||||
"files_changed": 4
|
"files_changed": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-14T10:31:25+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-14 10:31 (~4)",
|
||||||
|
"hash": "1ebe11f",
|
||||||
|
"files_changed": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-14T02:36:09Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 10:31 (~4)",
|
||||||
|
"files_changed": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-14T02:38:38Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Codex 会话活跃 · 最近命令:codex · 5 项未提交变更 · 最近提交:auto-save 2026-05-14 10:31 (~4)",
|
||||||
|
"files_changed": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -570,7 +570,7 @@
|
|||||||
<h3>前端核心</h3>
|
<h3>前端核心</h3>
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态:jobs、activeJobId、selectedFrames、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。</td></tr>
|
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态:jobs、activeJobId、按 job 隔离的 selectedFrames/详情面板状态、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。</td></tr>
|
||||||
<tr><td><code>web/components/nodes/index.tsx</code></td><td>DAG 节点定义:Input、VisualLab、Audio、Compose,以及画布工作面板 KeyframePanel / VideoFramePanel;旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。</td></tr>
|
<tr><td><code>web/components/nodes/index.tsx</code></td><td>DAG 节点定义:Input、VisualLab、Audio、Compose,以及画布工作面板 KeyframePanel / VideoFramePanel;旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。</td></tr>
|
||||||
<tr><td><code>web/components/lightbox.tsx</code></td><td>关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。</td></tr>
|
<tr><td><code>web/components/lightbox.tsx</code></td><td>关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。</td></tr>
|
||||||
<tr><td><code>web/components/product-library-picker.tsx</code></td><td>SKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 <code>asset</code>。</td></tr>
|
<tr><td><code>web/components/product-library-picker.tsx</code></td><td>SKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 <code>asset</code>。</td></tr>
|
||||||
@@ -819,7 +819,7 @@ SubjectAsset {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="tag blue">输入 Input</span></td>
|
<td><span class="tag blue">输入 Input</span></td>
|
||||||
<td>创建/上传任务,显示视频就绪;每个视频缩略图上方都有绑定自己的自动抽帧快捷工具条,默认只露出目标和抽帧按钮,张数/自动精度收进设置;也可在视频抽帧侧边面板内自动抽帧。多个视频抽帧可先后入队。</td>
|
<td>创建/上传任务,显示视频就绪;每个视频缩略图上方都有绑定自己的自动抽帧快捷工具条,默认只露出目标和抽帧按钮,张数/自动精度收进设置;也可在视频抽帧侧边面板内自动抽帧。多个视频抽帧可先后入队,切换 active 视频不会清空其他视频已选帧或关闭它们的异步生成回写。</td>
|
||||||
<td>不要自动一路跑到 ASR 或生图;用户需要控制解析节奏。</td>
|
<td>不要自动一路跑到 ASR 或生图;用户需要控制解析节奏。</td>
|
||||||
<td><code>page.tsx</code>、<code>InputNode</code>、<code>VideoFramePanelNode</code>、<code>api/main.py</code></td>
|
<td><code>page.tsx</code>、<code>InputNode</code>、<code>VideoFramePanelNode</code>、<code>api/main.py</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -837,7 +837,7 @@ SubjectAsset {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="tag gray">Audio / ASR / Rewrite</span></td>
|
<td><span class="tag gray">Audio / ASR / Rewrite</span></td>
|
||||||
<td>独立声音文案轨:从 <code>audio.wav</code> 提取原始口播、翻译中文、改写成 SKG 产品语境口播;MiniMax T2A 配置后生成配音 mp3。主画布的 <code>AudioNode</code> 只展示模型链路、改写稿和配音播放器。</td>
|
<td>独立声音文案轨:从 <code>audio.wav</code> 提取原始口播、翻译中文、改写成 SKG 产品语境口播;MiniMax T2A 配置后生成配音 mp3。主画布的 <code>AudioNode</code> 用“改前 · 原音频 / 改后 · SKG 口播”摘要展示,侧栏 Rewrite 展开后显示完整逐段 ASR/翻译、改写稿、产品依据和配音播放器。</td>
|
||||||
<td>不要阻断视觉素材管线。</td>
|
<td>不要阻断视觉素材管线。</td>
|
||||||
<td><code>AudioNode</code>、<code>ASRNode</code>、<code>TranslateNode</code>、<code>RewriteNode</code>、<code>pipeline_transcribe</code>、<code>AudioScript</code></td>
|
<td><code>AudioNode</code>、<code>ASRNode</code>、<code>TranslateNode</code>、<code>RewriteNode</code>、<code>pipeline_transcribe</code>、<code>AudioScript</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -914,6 +914,30 @@ SubjectAsset {
|
|||||||
<h2>变更记录</h2>
|
<h2>变更记录</h2>
|
||||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||||
<div class="changelog">
|
<div class="changelog">
|
||||||
|
<article class="change">
|
||||||
|
<header>
|
||||||
|
<h3>2026-05-14 · 音频结果改为改前/改后对照展示</h3>
|
||||||
|
<span class="tag gray">Audio</span>
|
||||||
|
<span class="tag violet">UI</span>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<p><strong>问题:</strong>音频识别成功后只显示改写文案,用户看不到它和原音频之间的变化关系,难以判断“是不是把参考视频转成我们自己的话”。</p>
|
||||||
|
<p><strong>改动:</strong><code>AudioNode</code> 增加轻量对照摘要:改前显示原音频识别/翻译预览,改后显示 SKG 口播;侧栏 <code>Rewrite</code> 面板改为完整审核视图,先列原音频逐段 ASR/翻译,再列改写稿、产品卖点依据和 MiniMax 配音播放器。</p>
|
||||||
|
<p><strong>影响:</strong><code>web/components/nodes/index.tsx</code>、<code>web/components/dashboard.tsx</code>、<code>docs/source-analysis.html</code>。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="change">
|
||||||
|
<header>
|
||||||
|
<h3>2026-05-14 · 多视频工作流状态按 job 隔离</h3>
|
||||||
|
<span class="tag violet">Canvas</span>
|
||||||
|
<span class="tag blue">Job State</span>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<p><strong>问题:</strong>同时上传多个视频后,前端把已选关键帧和关键帧详情面板作为全局状态保存;切换 active 视频会清空选中帧,或者让详情面板指向另一个视频的同序号帧,容易误以为生图/自动化被终止或串任务。</p>
|
||||||
|
<p><strong>改动:</strong><code>web/app/page.tsx</code> 将 <code>selectedFrames</code> 和 <code>expandedFrame</code> 改为按 <code>jobId</code> 存储。切换视频只改变当前视图,不清空其他视频的选择;重新抽帧、删帧、手动加帧只清理或更新对应 job。异步生图、生视频、产品融合返回后按返回的 <code>job.id</code> 写回 <code>jobs[]</code>,不会落到切换后的 active job。轮询条件也把 <code>audio_script.status=rewriting</code> 纳入运行态,保证音频改写/配音阶段切换视频后仍继续刷新。</p>
|
||||||
|
<p><strong>影响:</strong><code>web/app/page.tsx</code>、<code>docs/source-analysis.html</code>。后端轮询本来已经覆盖所有运行中的 job,这轮主要修正前端 UI 工作上下文。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<article class="change">
|
<article class="change">
|
||||||
<header>
|
<header>
|
||||||
<h3>2026-05-14 · 生视频接入 SKG 豆包网关</h3>
|
<h3>2026-05-14 · 生视频接入 SKG 豆包网关</h3>
|
||||||
|
|||||||
178
web/app/page.tsx
178
web/app/page.tsx
@@ -103,8 +103,13 @@ export default function Home() {
|
|||||||
const [frameTargets, setFrameTargets] = useState<Record<string, FrameExtractTarget>>({})
|
const [frameTargets, setFrameTargets] = useState<Record<string, FrameExtractTarget>>({})
|
||||||
const [frameCounts, setFrameCounts] = useState<Record<string, number>>({})
|
const [frameCounts, setFrameCounts] = useState<Record<string, number>>({})
|
||||||
const [frameQualities, setFrameQualities] = useState<Record<string, FrameExtractQuality>>({})
|
const [frameQualities, setFrameQualities] = useState<Record<string, FrameExtractQuality>>({})
|
||||||
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
|
const [selectedFramesByJob, setSelectedFramesByJob] = useState<Record<string, number[]>>({})
|
||||||
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
|
const [expandedFrameByJob, setExpandedFrameByJob] = useState<Record<string, number | null>>({})
|
||||||
|
const selectedFrames = useMemo(
|
||||||
|
() => new Set(activeJobId ? selectedFramesByJob[activeJobId] ?? [] : []),
|
||||||
|
[activeJobId, selectedFramesByJob],
|
||||||
|
)
|
||||||
|
const expandedFrame = activeJobId ? expandedFrameByJob[activeJobId] ?? null : null
|
||||||
const [framePanelScale, setFramePanelScale] = useState(1)
|
const [framePanelScale, setFramePanelScale] = useState(1)
|
||||||
const [framePanelDock, setFramePanelDock] = useState<CanvasPanelDock>("left")
|
const [framePanelDock, setFramePanelDock] = useState<CanvasPanelDock>("left")
|
||||||
const framePanelPinned = framePanelDock !== "canvas"
|
const framePanelPinned = framePanelDock !== "canvas"
|
||||||
@@ -112,28 +117,32 @@ export default function Home() {
|
|||||||
const [videoPanelScale, setVideoPanelScale] = useState(1)
|
const [videoPanelScale, setVideoPanelScale] = useState(1)
|
||||||
const [videoPanelDock, setVideoPanelDock] = useState<CanvasPanelDock>("left")
|
const [videoPanelDock, setVideoPanelDock] = useState<CanvasPanelDock>("left")
|
||||||
const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0)
|
const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0)
|
||||||
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
|
|
||||||
const [workbenchOpen, setWorkbenchOpen] = useState(false)
|
|
||||||
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
|
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
|
||||||
const flowRef = useRef<any>(null)
|
const flowRef = useRef<any>(null)
|
||||||
const lastVideoPanelFocusKey = useRef("")
|
const lastVideoPanelFocusKey = useRef("")
|
||||||
|
|
||||||
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
|
const setSelectedFramesForJob = useCallback((jobId: string, updater: Set<number> | ((prev: Set<number>) => Set<number>)) => {
|
||||||
const setJob = useCallback((updater: Job | ((prev: Job | null) => Job | null) | null) => {
|
setSelectedFramesByJob((prev) => {
|
||||||
|
const current = new Set(prev[jobId] ?? [])
|
||||||
|
const next = typeof updater === "function" ? updater(current) : updater
|
||||||
|
return { ...prev, [jobId]: [...next].sort((a, b) => a - b) }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearWorkflowStateForJob = useCallback((jobId: string) => {
|
||||||
|
setSelectedFramesByJob((prev) => ({ ...prev, [jobId]: [] }))
|
||||||
|
setExpandedFrameByJob((prev) => ({ ...prev, [jobId]: null }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateJobInList = useCallback((updated: Job) => {
|
||||||
setJobs((prev) => {
|
setJobs((prev) => {
|
||||||
const current = prev.find((j) => j.id === activeJobId) ?? null
|
const idx = prev.findIndex((j) => j.id === updated.id)
|
||||||
const next = typeof updater === "function" ? (updater as (p: Job | null) => Job | null)(current) : updater
|
if (idx < 0) return [...prev, updated]
|
||||||
if (!next) return prev
|
|
||||||
const idx = prev.findIndex((j) => j.id === next.id)
|
|
||||||
if (idx < 0) {
|
|
||||||
setActiveJobId(next.id)
|
|
||||||
return [...prev, next]
|
|
||||||
}
|
|
||||||
const arr = [...prev]
|
const arr = [...prev]
|
||||||
arr[idx] = next
|
arr[idx] = updated
|
||||||
return arr
|
return arr
|
||||||
})
|
})
|
||||||
}, [activeJobId])
|
}, [])
|
||||||
|
|
||||||
// 新增 job + 设为 active
|
// 新增 job + 设为 active
|
||||||
const addJob = useCallback((j: Job) => {
|
const addJob = useCallback((j: Job) => {
|
||||||
@@ -143,13 +152,11 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleSwitchJob = useCallback((id: string) => {
|
const handleSwitchJob = useCallback((id: string) => {
|
||||||
setActiveJobId(id)
|
setActiveJobId(id)
|
||||||
setSelectedFrames(new Set())
|
|
||||||
}, [])
|
}, [])
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (url: string) => {
|
const handleSubmit = useCallback(async (url: string) => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setSelectedFrames(new Set())
|
|
||||||
try {
|
try {
|
||||||
const created = await createJob(url)
|
const created = await createJob(url)
|
||||||
addJob(created)
|
addJob(created)
|
||||||
@@ -163,7 +170,6 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleUpload = useCallback(async (file: File) => {
|
const handleUpload = useCallback(async (file: File) => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setSelectedFrames(new Set())
|
|
||||||
try {
|
try {
|
||||||
toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`)
|
toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`)
|
||||||
const created = await uploadJob(file)
|
const created = await uploadJob(file)
|
||||||
@@ -185,7 +191,7 @@ export default function Home() {
|
|||||||
const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace")
|
const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace")
|
||||||
setActiveJobId(jobId)
|
setActiveJobId(jobId)
|
||||||
setAnalyzing(true)
|
setAnalyzing(true)
|
||||||
if (mode === "replace") setSelectedFrames(new Set())
|
if (mode === "replace") clearWorkflowStateForJob(jobId)
|
||||||
try {
|
try {
|
||||||
await analyzeJob(jobId, frameCount, frameTarget, mode, frameQuality)
|
await analyzeJob(jobId, frameCount, frameTarget, mode, frameQuality)
|
||||||
toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}:${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张`)
|
toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}:${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张`)
|
||||||
@@ -200,7 +206,7 @@ export default function Home() {
|
|||||||
} finally {
|
} finally {
|
||||||
setAnalyzing(false)
|
setAnalyzing(false)
|
||||||
}
|
}
|
||||||
}, [jobs, frameCounts, frameQualities, frameTargets])
|
}, [jobs, frameCounts, frameQualities, frameTargets, clearWorkflowStateForJob])
|
||||||
|
|
||||||
const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => {
|
const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => {
|
||||||
if (!job) return
|
if (!job) return
|
||||||
@@ -222,13 +228,13 @@ export default function Home() {
|
|||||||
const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => {
|
const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => {
|
||||||
try {
|
try {
|
||||||
const updated = await addManualFrame(jobId, t)
|
const updated = await addManualFrame(jobId, t)
|
||||||
setJobs((prev) => prev.map((item) => item.id === updated.id ? updated : item))
|
updateJobInList(updated)
|
||||||
setActiveJobId(updated.id)
|
setActiveJobId((prev) => prev ?? updated.id)
|
||||||
toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length} 张`)
|
toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length} 张`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
}
|
}
|
||||||
}, [])
|
}, [updateJobInList])
|
||||||
|
|
||||||
const handleAddManualFrame = useCallback(async (t: number) => {
|
const handleAddManualFrame = useCallback(async (t: number) => {
|
||||||
if (!job) return
|
if (!job) return
|
||||||
@@ -247,18 +253,43 @@ export default function Home() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleToggleFrame = useCallback((idx: number) => {
|
const handleToggleFrame = useCallback((idx: number) => {
|
||||||
setSelectedFrames((prev) => {
|
if (!activeJobId) return
|
||||||
|
setSelectedFramesForJob(activeJobId, (prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(idx)) next.delete(idx)
|
if (next.has(idx)) next.delete(idx)
|
||||||
else next.add(idx)
|
else next.add(idx)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [activeJobId, setSelectedFramesForJob])
|
||||||
|
|
||||||
const handleOpenFramePanel = useCallback((idx: number) => {
|
const handleOpenFramePanel = useCallback((idx: number) => {
|
||||||
|
if (!activeJobId) return
|
||||||
if (expandedFrame === null) setFramePanelDock("left")
|
if (expandedFrame === null) setFramePanelDock("left")
|
||||||
setExpandedFrame(idx)
|
setExpandedFrameByJob((prev) => ({ ...prev, [activeJobId]: idx }))
|
||||||
}, [expandedFrame])
|
}, [activeJobId, expandedFrame])
|
||||||
|
|
||||||
|
const handleCloseExpandedFrame = useCallback(() => {
|
||||||
|
if (!activeJobId) return
|
||||||
|
setExpandedFrameByJob((prev) => ({ ...prev, [activeJobId]: null }))
|
||||||
|
}, [activeJobId])
|
||||||
|
|
||||||
|
const handleOpenStoryboard = useCallback((idx: number) => {
|
||||||
|
if (!activeJobId) return
|
||||||
|
setSelectedFramesForJob(activeJobId, (prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(idx)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [activeJobId, setSelectedFramesForJob])
|
||||||
|
|
||||||
|
const handleOpenWorkbench = useCallback((idx?: number) => {
|
||||||
|
if (!activeJobId || typeof idx !== "number") return
|
||||||
|
setSelectedFramesForJob(activeJobId, (prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(idx)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [activeJobId, setSelectedFramesForJob])
|
||||||
|
|
||||||
const handleFramePanelScaleChange = useCallback((scale: number) => {
|
const handleFramePanelScaleChange = useCallback((scale: number) => {
|
||||||
setFramePanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2)))))
|
setFramePanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2)))))
|
||||||
@@ -268,21 +299,20 @@ export default function Home() {
|
|||||||
const wasActive = activeJobId === jobId
|
const wasActive = activeJobId === jobId
|
||||||
try {
|
try {
|
||||||
const updated = await deleteFrame(jobId, idx)
|
const updated = await deleteFrame(jobId, idx)
|
||||||
setJobs((prev) => prev.map((item) => item.id === updated.id ? updated : item))
|
updateJobInList(updated)
|
||||||
setActiveJobId(updated.id)
|
setSelectedFramesForJob(jobId, (prev) => {
|
||||||
setSelectedFrames((prev) => {
|
|
||||||
if (!wasActive) return new Set()
|
|
||||||
if (!prev.has(idx)) return prev
|
if (!prev.has(idx)) return prev
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(idx)
|
next.delete(idx)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
if (!wasActive || expandedFrame === idx) setExpandedFrame(null)
|
setExpandedFrameByJob((prev) => prev[jobId] === idx ? { ...prev, [jobId]: null } : prev)
|
||||||
|
if (wasActive) setActiveJobId(updated.id)
|
||||||
toast.success(`分镜 ${idx + 1} 已删除`)
|
toast.success(`分镜 ${idx + 1} 已删除`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
}
|
}
|
||||||
}, [activeJobId, expandedFrame])
|
}, [activeJobId, setSelectedFramesForJob, updateJobInList])
|
||||||
|
|
||||||
const handleDeleteFrame = useCallback(async (idx: number) => {
|
const handleDeleteFrame = useCallback(async (idx: number) => {
|
||||||
if (!activeJobId) return
|
if (!activeJobId) return
|
||||||
@@ -293,23 +323,23 @@ export default function Home() {
|
|||||||
if (!activeJobId) return
|
if (!activeJobId) return
|
||||||
try {
|
try {
|
||||||
const updated = await deleteGeneratedImage(activeJobId, frameIdx, genId)
|
const updated = await deleteGeneratedImage(activeJobId, frameIdx, genId)
|
||||||
setJob(updated)
|
updateJobInList(updated)
|
||||||
toast.success("生成图已删除")
|
toast.success("生成图已删除")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
}
|
}
|
||||||
}, [activeJobId, setJob])
|
}, [activeJobId, updateJobInList])
|
||||||
|
|
||||||
const handleDeleteVideo = useCallback(async (videoId: string) => {
|
const handleDeleteVideo = useCallback(async (videoId: string) => {
|
||||||
if (!activeJobId) return
|
if (!activeJobId) return
|
||||||
try {
|
try {
|
||||||
const updated = await deleteGeneratedVideo(activeJobId, videoId)
|
const updated = await deleteGeneratedVideo(activeJobId, videoId)
|
||||||
setJob(updated)
|
updateJobInList(updated)
|
||||||
toast.success("视频任务已删除")
|
toast.success("视频任务已删除")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
}
|
}
|
||||||
}, [activeJobId, setJob])
|
}, [activeJobId, updateJobInList])
|
||||||
|
|
||||||
const handleDeleteJob = useCallback(async (jobId: string) => {
|
const handleDeleteJob = useCallback(async (jobId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -321,13 +351,17 @@ export default function Home() {
|
|||||||
if (activeJobId === jobId) {
|
if (activeJobId === jobId) {
|
||||||
const fallback = next[idx] ?? next[idx - 1] ?? next[next.length - 1] ?? null
|
const fallback = next[idx] ?? next[idx - 1] ?? next[next.length - 1] ?? null
|
||||||
setActiveJobId(fallback?.id ?? null)
|
setActiveJobId(fallback?.id ?? null)
|
||||||
setSelectedFrames(new Set())
|
|
||||||
setExpandedFrame(null)
|
|
||||||
setStoryboardFrame(null)
|
|
||||||
setWorkbenchOpen(false)
|
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
setSelectedFramesByJob((prev) => {
|
||||||
|
const { [jobId]: _removed, ...rest } = prev
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
setExpandedFrameByJob((prev) => {
|
||||||
|
const { [jobId]: _removed, ...rest } = prev
|
||||||
|
return rest
|
||||||
|
})
|
||||||
toast.success("输入视频已删除")
|
toast.success("输入视频已删除")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
@@ -338,12 +372,12 @@ export default function Home() {
|
|||||||
if (!activeJobId) return
|
if (!activeJobId) return
|
||||||
try {
|
try {
|
||||||
const updated = await deleteCutout(activeJobId, frameIdx, elementId, cutoutId)
|
const updated = await deleteCutout(activeJobId, frameIdx, elementId, cutoutId)
|
||||||
setJob(updated)
|
updateJobInList(updated)
|
||||||
toast.success("元素提取图已删除")
|
toast.success("元素提取图已删除")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
}
|
}
|
||||||
}, [activeJobId, setJob])
|
}, [activeJobId, updateJobInList])
|
||||||
|
|
||||||
const handleCopyImage = useCallback((ref: ImageRef) => {
|
const handleCopyImage = useCallback((ref: ImageRef) => {
|
||||||
setClipboard(ref)
|
setClipboard(ref)
|
||||||
@@ -439,13 +473,13 @@ export default function Home() {
|
|||||||
model,
|
model,
|
||||||
size: "720x1280",
|
size: "720x1280",
|
||||||
})
|
})
|
||||||
setJob(updated)
|
updateJobInList(updated)
|
||||||
void navigator.clipboard?.writeText(prompt).catch(() => {})
|
void navigator.clipboard?.writeText(prompt).catch(() => {})
|
||||||
toast.success("视频任务已进入 Video Gen 节点")
|
toast.success("视频任务已进入 Video Gen 节点")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
}
|
}
|
||||||
}, [job, selectedFrames, setJob])
|
}, [job, selectedFrames, updateJobInList])
|
||||||
|
|
||||||
const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => {
|
const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => {
|
||||||
if (!job) return
|
if (!job) return
|
||||||
@@ -495,13 +529,13 @@ export default function Home() {
|
|||||||
model: "seedance",
|
model: "seedance",
|
||||||
size: "720x1280",
|
size: "720x1280",
|
||||||
})
|
})
|
||||||
setJob(updated)
|
updateJobInList(updated)
|
||||||
void navigator.clipboard?.writeText(prompt).catch(() => {})
|
void navigator.clipboard?.writeText(prompt).catch(() => {})
|
||||||
toast.success("产品融合视频已进入 Video Gen 队列")
|
toast.success("产品融合视频已进入 Video Gen 队列")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("产品融合生成失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("产品融合生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
}
|
}
|
||||||
}, [job, setJob])
|
}, [job, updateJobInList])
|
||||||
|
|
||||||
// 启动恢复:URL ?job=xxx,yyy 优先;否则从后端拉全部历史(按 mtime 倒序,最新放末尾)
|
// 启动恢复:URL ?job=xxx,yyy 优先;否则从后端拉全部历史(按 mtime 倒序,最新放末尾)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -540,23 +574,27 @@ export default function Home() {
|
|||||||
window.history.replaceState({}, "", url.toString())
|
window.history.replaceState({}, "", url.toString())
|
||||||
}, [jobs.length])
|
}, [jobs.length])
|
||||||
|
|
||||||
// 恢复已保存的分镜选择:刷新页面后,已有 storyboard 的帧仍应出现在顶部编排栏。
|
// 恢复已保存的分镜选择:每个视频自己的 storyboard 帧仍保留在自己的编排上下文里。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!job || job.frames.length === 0) return
|
if (jobs.length === 0) return
|
||||||
const persisted = job.frames.filter((f) => !!f.storyboard).map((f) => f.index)
|
setSelectedFramesByJob((prev) => {
|
||||||
if (persisted.length === 0) return
|
|
||||||
setSelectedFrames((prev) => {
|
|
||||||
let changed = false
|
let changed = false
|
||||||
const next = new Set(prev)
|
const nextByJob = { ...prev }
|
||||||
for (const idx of persisted) {
|
for (const item of jobs) {
|
||||||
if (!next.has(idx)) {
|
const persisted = item.frames.filter((f) => !!f.storyboard).map((f) => f.index)
|
||||||
next.add(idx)
|
if (persisted.length === 0) continue
|
||||||
changed = true
|
const next = new Set(nextByJob[item.id] ?? [])
|
||||||
|
for (const idx of persisted) {
|
||||||
|
if (!next.has(idx)) {
|
||||||
|
next.add(idx)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
nextByJob[item.id] = [...next].sort((a, b) => a - b)
|
||||||
}
|
}
|
||||||
return changed ? next : prev
|
return changed ? nextByJob : prev
|
||||||
})
|
})
|
||||||
}, [job?.id, job?.frames])
|
}, [jobs])
|
||||||
|
|
||||||
// 轮询 Job:任一视频在下载 / 抽帧 / 生视频时都继续轮询,支持多个抽帧任务排队。
|
// 轮询 Job:任一视频在下载 / 抽帧 / 生视频时都继续轮询,支持多个抽帧任务排队。
|
||||||
const prevStatusRef = useRef<string | null>(null)
|
const prevStatusRef = useRef<string | null>(null)
|
||||||
@@ -572,7 +610,8 @@ export default function Home() {
|
|||||||
const runningIds = jobs
|
const runningIds = jobs
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
|
const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
|
||||||
return runningVideo || !TERMINAL.includes(item.status)
|
const runningAudio = item.audio_script?.status === "rewriting"
|
||||||
|
return runningVideo || runningAudio || !TERMINAL.includes(item.status)
|
||||||
})
|
})
|
||||||
.map((item) => item.id)
|
.map((item) => item.id)
|
||||||
|
|
||||||
@@ -593,7 +632,7 @@ export default function Home() {
|
|||||||
}, [
|
}, [
|
||||||
job?.id,
|
job?.id,
|
||||||
job?.status,
|
job?.status,
|
||||||
jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"),
|
jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"),
|
||||||
])
|
])
|
||||||
|
|
||||||
const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(() => new Set(loadNodePins()))
|
const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(() => new Set(loadNodePins()))
|
||||||
@@ -631,12 +670,12 @@ export default function Home() {
|
|||||||
onFrameCountChange: handleFrameCountChange,
|
onFrameCountChange: handleFrameCountChange,
|
||||||
onFrameQualityChange: handleFrameQualityChange,
|
onFrameQualityChange: handleFrameQualityChange,
|
||||||
onToggleFrame: handleToggleFrame,
|
onToggleFrame: handleToggleFrame,
|
||||||
onExpandFrame: setExpandedFrame,
|
onExpandFrame: handleOpenFramePanel,
|
||||||
onOpenFramePanel: handleOpenFramePanel,
|
onOpenFramePanel: handleOpenFramePanel,
|
||||||
onFramePanelScaleChange: handleFramePanelScaleChange,
|
onFramePanelScaleChange: handleFramePanelScaleChange,
|
||||||
onFramePanelPinnedChange: (pinned: boolean) => setFramePanelDock(pinned ? "left" : "canvas"),
|
onFramePanelPinnedChange: (pinned: boolean) => setFramePanelDock(pinned ? "left" : "canvas"),
|
||||||
onFramePanelDockChange: setFramePanelDock,
|
onFramePanelDockChange: setFramePanelDock,
|
||||||
onCloseExpandedFrame: () => setExpandedFrame(null),
|
onCloseExpandedFrame: handleCloseExpandedFrame,
|
||||||
onAddManualFrame: handleAddManualFrame,
|
onAddManualFrame: handleAddManualFrame,
|
||||||
onAddManualFrameForJob: handleAddManualFrameForJob,
|
onAddManualFrameForJob: handleAddManualFrameForJob,
|
||||||
onOpenVideoPanel: handleOpenVideoPanel,
|
onOpenVideoPanel: handleOpenVideoPanel,
|
||||||
@@ -644,24 +683,21 @@ export default function Home() {
|
|||||||
onVideoPanelScaleChange: handleVideoPanelScaleChange,
|
onVideoPanelScaleChange: handleVideoPanelScaleChange,
|
||||||
onVideoPanelDockChange: setVideoPanelDock,
|
onVideoPanelDockChange: setVideoPanelDock,
|
||||||
onSwitchJob: handleSwitchJob,
|
onSwitchJob: handleSwitchJob,
|
||||||
onJobUpdate: setJob as any,
|
onJobUpdate: updateJobInList,
|
||||||
onDeleteJob: handleDeleteJob,
|
onDeleteJob: handleDeleteJob,
|
||||||
onDeleteFrame: handleDeleteFrame,
|
onDeleteFrame: handleDeleteFrame,
|
||||||
onDeleteFrameForJob: handleDeleteFrameForJob,
|
onDeleteFrameForJob: handleDeleteFrameForJob,
|
||||||
onDeleteGenerated: handleDeleteGenerated,
|
onDeleteGenerated: handleDeleteGenerated,
|
||||||
onDeleteVideo: handleDeleteVideo,
|
onDeleteVideo: handleDeleteVideo,
|
||||||
onDeleteCutout: handleDeleteCutout,
|
onDeleteCutout: handleDeleteCutout,
|
||||||
onOpenStoryboard: (idx: number) => setStoryboardFrame(idx),
|
onOpenStoryboard: handleOpenStoryboard,
|
||||||
onOpenWorkbench: (idx?: number) => {
|
onOpenWorkbench: handleOpenWorkbench,
|
||||||
if (typeof idx === "number") setStoryboardFrame(idx)
|
|
||||||
setWorkbenchOpen(true)
|
|
||||||
},
|
|
||||||
clipboard,
|
clipboard,
|
||||||
onCopyImage: handleCopyImage,
|
onCopyImage: handleCopyImage,
|
||||||
onGenerateProductFusionVideo: handleGenerateProductFusionVideo,
|
onGenerateProductFusionVideo: handleGenerateProductFusionVideo,
|
||||||
pinnedNodes,
|
pinnedNodes,
|
||||||
onToggleNodePin: handleToggleNodePin,
|
onToggleNodePin: handleToggleNodePin,
|
||||||
}), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, clipboard, handleCopyImage, handleGenerateProductFusionVideo, pinnedNodes, handleToggleNodePin])
|
}), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleCloseExpandedFrame, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, updateJobInList, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleOpenStoryboard, handleOpenWorkbench, clipboard, handleCopyImage, handleGenerateProductFusionVideo, pinnedNodes, handleToggleNodePin])
|
||||||
|
|
||||||
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
||||||
const savedSizes = useMemo(() => loadNodeSizes(), [])
|
const savedSizes = useMemo(() => loadNodeSizes(), [])
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
|||||||
const hasZh = job?.transcript.some((s) => s.zh) ?? false
|
const hasZh = job?.transcript.some((s) => s.zh) ?? false
|
||||||
const hasAudioRewrite = !!job?.audio_script?.rewritten_text?.trim()
|
const hasAudioRewrite = !!job?.audio_script?.rewritten_text?.trim()
|
||||||
const isAudioRewriting = job?.audio_script?.status === "rewriting"
|
const isAudioRewriting = job?.audio_script?.status === "rewriting"
|
||||||
|
const audioCompareRows = job?.transcript.slice(0, 8) ?? []
|
||||||
const isFailed = job?.status === "failed"
|
const isFailed = job?.status === "failed"
|
||||||
|
|
||||||
const colState: Record<string, ColState> = {
|
const colState: Record<string, ColState> = {
|
||||||
@@ -594,23 +595,50 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
|||||||
{/* ---- Rewrite — Kanban ---- */}
|
{/* ---- Rewrite — Kanban ---- */}
|
||||||
{key === "rewrite" && (
|
{key === "rewrite" && (
|
||||||
<>
|
<>
|
||||||
<KanbanCard tone="green" tags={["产品信息"]} title="SKG 产品卖点">
|
<KanbanCard tone="green" tags={["对照", "改前"]} title="原音频识别">
|
||||||
<div className="text-[12px] text-[var(--text-soft)] leading-relaxed">
|
{audioCompareRows.length > 0 ? (
|
||||||
{job?.audio_script?.product_brief || "等待音频转写完成后,按默认 SKG 放松产品卖点生成口播。"}
|
<div className="space-y-2">
|
||||||
</div>
|
{audioCompareRows.map((s) => (
|
||||||
|
<div key={s.index} className="rounded-md border border-white/10 bg-black/20 px-2.5 py-2">
|
||||||
|
<div className="mb-1 text-[10px] font-mono text-[var(--text-faint)]">
|
||||||
|
{s.start.toFixed(1)}s → {s.end.toFixed(1)}s
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] leading-relaxed text-[var(--text-strong)]">
|
||||||
|
{s.zh || <span className="text-[var(--text-faint)] italic">中文翻译中…</span>}
|
||||||
|
</div>
|
||||||
|
{s.en && (
|
||||||
|
<div className="mt-1.5 text-[11px] leading-relaxed text-[var(--text-soft)]">
|
||||||
|
{s.en}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[11px] text-[var(--text-soft)]">音频识别完成后,这里显示原始 ASR 和中文翻译。</div>
|
||||||
|
)}
|
||||||
|
<div className="kanban-meta">参考视频原话,不直接用于成片</div>
|
||||||
</KanbanCard>
|
</KanbanCard>
|
||||||
<KanbanCard tone="green" tags={["改写"]} title={job?.audio_script?.rewrite_model || "gemini-2.5-pro"}>
|
|
||||||
|
<KanbanCard tone="green" tags={["对照", "改后"]} title={job?.audio_script?.rewrite_model || "SKG 口播改写"}>
|
||||||
{job?.audio_script?.rewritten_text ? (
|
{job?.audio_script?.rewritten_text ? (
|
||||||
<div className="text-[13px] text-[var(--text-strong)] leading-relaxed">
|
<div className="rounded-md border border-emerald-400/25 bg-emerald-400/10 px-3 py-2.5 text-[13px] text-[var(--text-strong)] leading-relaxed">
|
||||||
{job.audio_script.rewritten_text}
|
{job.audio_script.rewritten_text}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[11px] text-[var(--text-soft)]">
|
<div className="text-[11px] text-[var(--text-soft)]">
|
||||||
{isAudioRewriting ? "正在生成 SKG 口播文案…" : "转录完成后自动生成 SKG 口播文案"}
|
{isAudioRewriting ? "正在把原音频转成 SKG 口播文案…" : "转录完成后自动生成 SKG 口播文案"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="kanban-meta">ASR + 翻译 + SKG 卖点转化</div>
|
<div className="kanban-meta">用于后续 TTS、字幕和视频生成 prompt</div>
|
||||||
</KanbanCard>
|
</KanbanCard>
|
||||||
|
|
||||||
|
<KanbanCard tone="green" tags={["产品依据"]} title="SKG 产品卖点">
|
||||||
|
<div className="text-[12px] text-[var(--text-soft)] leading-relaxed">
|
||||||
|
{job?.audio_script?.product_brief || "等待音频转写完成后,按默认 SKG 放松产品卖点生成口播。"}
|
||||||
|
</div>
|
||||||
|
</KanbanCard>
|
||||||
|
|
||||||
<KanbanCard tone="green" tags={["配音"]} title={job?.audio_script?.voice_model || "MiniMax T2A"}>
|
<KanbanCard tone="green" tags={["配音"]} title={job?.audio_script?.voice_model || "MiniMax T2A"}>
|
||||||
{job?.audio_script?.voice_url ? (
|
{job?.audio_script?.voice_url ? (
|
||||||
<audio controls className="h-8 w-full" src={apiAssetUrl(job.audio_script.voice_url)} />
|
<audio controls className="h-8 w-full" src={apiAssetUrl(job.audio_script.voice_url)} />
|
||||||
|
|||||||
@@ -2108,6 +2108,11 @@ export function AudioNode({ data, selected }: any) {
|
|||||||
const voiceUrl = apiAssetUrl(audioScript?.voice_url)
|
const voiceUrl = apiAssetUrl(audioScript?.voice_url)
|
||||||
const hasASR = transcript.length > 0
|
const hasASR = transcript.length > 0
|
||||||
const isRewriting = audioScript?.status === "rewriting"
|
const isRewriting = audioScript?.status === "rewriting"
|
||||||
|
const originalPreview = transcript
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((s) => (s.zh || s.en).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
const status: NodeStatus = !job
|
const status: NodeStatus = !job
|
||||||
? "pending"
|
? "pending"
|
||||||
: job.status === "transcribing" || isRewriting
|
: job.status === "transcribing" || isRewriting
|
||||||
@@ -2132,9 +2137,20 @@ export function AudioNode({ data, selected }: any) {
|
|||||||
{audioScript?.rewrite_model || "AUDIO_REWRITE_MODEL"} → {audioScript?.voice_model || "MiniMax T2A"}
|
{audioScript?.rewrite_model || "AUDIO_REWRITE_MODEL"} → {audioScript?.voice_model || "MiniMax T2A"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{rewrittenText && (
|
{(originalPreview || rewrittenText) && (
|
||||||
<div className="rounded-md border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-2 text-[11.5px] leading-relaxed text-[var(--text-strong)] break-words">
|
<div className="grid gap-2">
|
||||||
{rewrittenText}
|
{originalPreview && (
|
||||||
|
<div className="rounded-md border border-white/10 bg-white/[0.04] px-2.5 py-2">
|
||||||
|
<div className="mb-1 text-[9.5px] uppercase tracking-widest text-[var(--text-faint)]">改前 · 原音频</div>
|
||||||
|
<div className="line-clamp-3 text-[11px] leading-relaxed text-[var(--text-soft)] break-words">{originalPreview}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rewrittenText && (
|
||||||
|
<div className="rounded-md border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-2">
|
||||||
|
<div className="mb-1 text-[9.5px] uppercase tracking-widest text-emerald-200/80">改后 · SKG 口播</div>
|
||||||
|
<div className="line-clamp-4 text-[11.5px] leading-relaxed text-[var(--text-strong)] break-words">{rewrittenText}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{voiceUrl && (
|
{voiceUrl && (
|
||||||
|
|||||||
Reference in New Issue
Block a user