feat: make intake auto extract copy

This commit is contained in:
2026-05-19 15:48:27 +08:00
parent e03c5db3fd
commit 54979bc4e2
5 changed files with 259 additions and 54 deletions

View File

@@ -229,7 +229,7 @@ export default function Home() {
const created = await uploadJob(file)
addJob(created)
setProductionJobIds((prev) => new Set(prev).add(created.id))
toast.success(`已上传 ${created.id.slice(0, 8)},视频就绪后自动跑音频和抽帧`)
toast.success(`已上传 ${created.id.slice(0, 8)},视频就绪后自动提取音频文案`)
} catch (e) {
toast.error("上传失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -473,31 +473,13 @@ export default function Home() {
try {
const updated = await triggerTranscribe(target.id)
updateJobInList(updated)
toast.info("音频路已启动:字幕、讲话人、节奏和背景音同步解析")
toast.info("文案路已启动:字幕、讲话人、节奏和背景音同步解析")
} catch (e) {
autoTriggeredRef.current.delete(audioKey)
toast.error("音频解析启动失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const visualKey = `${target.id}:visual`
const hasVisualResult = target.frames.length > 0
const visualRunning = target.status === "splitting"
if (!hasVisualResult && !visualRunning && !autoTriggeredRef.current.has(visualKey)) {
autoTriggeredRef.current.add(visualKey)
const frameTarget = frameTargets[target.id] ?? "motion"
const frameCount = frameCounts[target.id] ?? 12
const frameQuality = frameQualities[target.id] ?? "accurate"
try {
const updated = await analyzeJob(target.id, frameCount, frameTarget, "replace", frameQuality)
updateJobInList(updated)
toast.info(`视觉路已启动:${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张参考帧`)
} catch (e) {
autoTriggeredRef.current.delete(visualKey)
toast.error("视觉抽帧启动失败:" + (e instanceof Error ? e.message : String(e)))
}
}
}, [frameCounts, frameQualities, frameTargets, updateJobInList])
}, [updateJobInList])
const ensureDefaultProductRefs = useCallback(async (jobId: string) => {
const cached = defaultProductRefsByJob[jobId]
@@ -577,21 +559,20 @@ export default function Home() {
}
if (!created && target.status === "failed") {
autoTriggeredRef.current.delete(`${target.id}:audio`)
autoTriggeredRef.current.delete(`${target.id}:visual`)
}
if (!created && target.status === "failed" && !target.video_url) {
try {
target = await retryJobDownload(target.id)
updateJobInList(target)
toast.info("已重新提交下载;下载完成后会自动跑音频文案路和视觉抽帧路")
toast.info("已重新提交下载;下载完成后会自动跑音频文案路")
} catch (e) {
toast.error("重新下载失败:" + (e instanceof Error ? e.message : String(e)))
return
}
}
setProductionJobIds((prev) => new Set(prev).add(target.id))
if (target.video_url) toast.success("已进入并行素材分析:音频文案路和视觉抽帧路会同步推进")
else toast.success("已进入并行素材分析:下载完成后自动音频文案路和视觉抽帧路")
if (target.video_url) toast.success("已进入第一步:自动提取音频文案")
else toast.success("已进入第一步:下载完成后自动提取音频文案")
void startProductionLanesForJob(target)
}, [handleSubmit, job, startProductionLanesForJob, updateJobInList])

View File

@@ -61,6 +61,7 @@ import {
sourceAudioUrl,
subjectTemplateImageUrl,
updateElement,
updateAudioScript,
updateStoryboard,
uploadStoryboardAsset,
translateText,
@@ -1836,13 +1837,6 @@ export function AdRecreationBoard({
}
}, [])
const submitUrl = () => {
const trimmed = url.trim()
if (!trimmed) return
data.onSubmitUrl(trimmed)
setUrl("")
}
const startProduction = () => {
const trimmed = url.trim()
data.onStartProduction?.(trimmed || undefined)
@@ -2079,8 +2073,8 @@ export function AdRecreationBoard({
url={url}
setUrl={setUrl}
fileRef={fileRef}
onSubmitUrl={submitUrl}
onStartProduction={startProduction}
runtimeModels={runtimeModels}
/>
<section className="skg-board-panel flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.035] shadow-2xl">
@@ -2090,10 +2084,10 @@ export function AdRecreationBoard({
<div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[#d6b36a]/18 bg-[#d6b36a]/10 text-[#f2d58a]"><Mic className="h-3.5 w-3.5" /></span>
<WorkflowStepBadge step={workflow.source} compact />
<h2 className="text-[15px] font-semibold leading-tight text-white"></h2>
<h2 className="text-[15px] font-semibold leading-tight text-white"></h2>
</div>
<div className="mt-1 truncate text-[11px] text-white/38" title={statusMessage}>
{statusMessage || "下载源视频后解析音频,再抽参考帧并生成相似主体。"}
{statusMessage || "导入素材后自动下载并提取音频文案;抽帧参考保留为手动工具。"}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
@@ -2111,7 +2105,6 @@ export function AdRecreationBoard({
<Requirement label="视频" ready={!!job?.video_url} detail={job?.status === "downloading" ? "下载中" : job?.video_url ? "已就绪" : "待下载"} />
<Requirement label="音频" ready={!!job?.source_audio_url} detail={audioRunning ? "解析中" : job?.source_audio_url ? "已提取" : "待提取"} />
<Requirement label="文案" ready={audioReady} detail={audioReady ? `${transcriptCount}` : "待解析"} />
<Requirement label="参考帧" ready={visualReady} detail={visualRunning ? "抽帧中" : visualReady ? `${job?.frames.length ?? 0}` : "待抽帧"} />
</div>
<details className="group rounded-md border border-white/10 bg-black/28 p-2">
@@ -2179,8 +2172,8 @@ function MaterialColumn({
url,
setUrl,
fileRef,
onSubmitUrl,
onStartProduction,
runtimeModels,
}: {
data: NodeData
step: WorkflowStep
@@ -2190,12 +2183,12 @@ function MaterialColumn({
url: string
setUrl: (value: string) => void
fileRef: RefObject<HTMLInputElement | null>
onSubmitUrl: () => void
onStartProduction: () => void
runtimeModels?: RuntimeModels
}) {
const actionLabel = !url.trim() && job?.status === "failed"
? job.video_url ? "重新解析" : "重新下载"
: "开始分析"
? job.video_url ? "重新提文案" : "重新下载"
: "导入并提文案"
return (
<section className="skg-board-panel flex min-h-0 flex-col gap-3 rounded-lg border border-white/10 bg-white/[0.035] p-3 shadow-2xl">
<header className="shrink-0 border-b border-white/10 pb-3">
@@ -2204,14 +2197,14 @@ function MaterialColumn({
<WorkflowStepBadge step={step} compact />
</div>
<h2 className="text-[15px] font-semibold leading-tight text-white"></h2>
<p className="mt-1 text-[12px] leading-snug text-white/42"></p>
<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() }}
onKeyDown={(e) => { if (e.key === "Enter") onStartProduction() }}
placeholder="粘贴 TK / 信息流视频链接"
className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/45 px-3 text-[13px] text-white outline-none placeholder:text-white/28 focus:border-[#d6b36a]/60"
/>
@@ -2245,6 +2238,13 @@ function MaterialColumn({
/>
</div>
<MaterialScriptEditor
job={job}
onJobUpdate={data.onJobUpdate}
onTranscribe={() => data.onTranscribeAudio?.(job?.id)}
runtimeModels={runtimeModels}
/>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
{jobs.length ? jobs.map((item, index) => (
<MaterialCard
@@ -2256,13 +2256,167 @@ function MaterialColumn({
onDelete={data.onDeleteJob ? () => data.onDeleteJob?.(item.id) : undefined}
/>
)) : (
<EmptyState text="还没有素材。每导入一个链接或上传一个文件,就会新增一个素材任务。" />
<EmptyState text="还没有素材。每导入一个链接或上传一个文件,就会新增一个素材任务,并自动提取音频文案。" />
)}
</div>
</section>
)
}
function MaterialScriptEditor({
job,
onJobUpdate,
onTranscribe,
runtimeModels,
}: {
job: Job | null
onJobUpdate: (job: Job) => void
onTranscribe?: () => Promise<void> | void
runtimeModels?: RuntimeModels
}) {
const script = job?.audio_script
const [draftZh, setDraftZh] = useState("")
const [draftEn, setDraftEn] = useState("")
const [saving, setSaving] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const lastOptimizedZh = useRef("")
useEffect(() => {
const nextZh = script?.rewritten_text_zh?.trim() || script?.source_zh?.trim() || ""
const nextEn = script?.rewritten_text?.trim() || script?.source_text?.trim() || ""
setDraftZh(nextZh)
setDraftEn(nextEn)
lastOptimizedZh.current = nextZh
}, [job?.id, script?.rewritten_text, script?.rewritten_text_zh, script?.source_text, script?.source_zh])
const processing = !!job && (
job.status === "created" ||
job.status === "downloading" ||
job.status === "transcribing" ||
script?.status === "rewriting"
)
const hasScript = !!(script?.source_text?.trim() || script?.source_zh?.trim() || draftEn.trim() || draftZh.trim())
const canSave = !!job && !!(draftEn.trim() || draftZh.trim())
const saveDraft = async (nextEn = draftEn, nextZh = draftZh, quiet = false) => {
if (!job) return false
setSaving(true)
try {
const updated = await updateAudioScript(job.id, {
rewritten_text: nextEn.trim(),
rewritten_text_zh: nextZh.trim(),
})
onJobUpdate(updated)
if (!quiet) toast.success("文案已保存到当前素材")
return true
} catch (e) {
toast.error("保存文案失败:" + (e instanceof Error ? e.message : String(e)))
return false
} finally {
setSaving(false)
}
}
const optimizeChinese = async () => {
if (!job) return
const zh = draftZh.trim()
if (!zh || !containsCjk(zh) || zh === lastOptimizedZh.current) return
setOptimizing(true)
try {
const english = await translateText(zh, "en")
const nextEn = english.trim() || draftEn
setDraftEn(nextEn)
lastOptimizedZh.current = zh
const saved = await saveDraft(nextEn, zh, true)
if (saved) toast.success("中文已同步优化成英文")
} catch (e) {
toast.error("中文优化英文失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setOptimizing(false)
}
}
return (
<section className="rounded-md border border-white/10 bg-black/30 p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<SectionTitle icon={<FileText className="h-4 w-4" />} title="文案确认稿" />
<div className="flex shrink-0 items-center gap-1.5">
{runtimeModels ? <ModelTrace trace={audioModelTrace(runtimeModels)} compact /> : null}
<button
type="button"
onClick={() => onTranscribe?.()}
disabled={!job?.video_url || processing}
className="inline-flex h-7 items-center gap-1 rounded-md border border-white/10 bg-white/[0.04] px-2 text-[10.5px] font-semibold text-white/58 transition hover:border-white/20 hover:bg-white/[0.07] disabled:cursor-not-allowed disabled:opacity-40"
>
{processing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Mic className="h-3 w-3" />}
</button>
</div>
</div>
{!job ? (
<div className="rounded border border-dashed border-white/10 bg-black/22 p-3 text-[11px] leading-relaxed text-white/38">
广
</div>
) : !hasScript ? (
<div className="rounded border border-white/10 bg-black/22 p-3 text-[11px] leading-relaxed text-white/42">
{processing ? "正在下载 / 提取音频 / 生成文案,完成后会自动出现在这里。" : job.video_url ? "视频已就绪,等待音频文案。可点“重提”手动启动。" : "等待视频下载完成后自动提取音频文案。"}
</div>
) : (
<div className="space-y-2">
<label className="block">
<div className="mb-1 flex items-center justify-between text-[10.5px] text-white/44">
<span>稿</span>
<span>{optimizing ? "正在优化英文" : "改完中文会自动同步英文"}</span>
</div>
<textarea
value={draftZh}
onChange={(e) => setDraftZh(e.target.value)}
onBlur={() => void optimizeChinese()}
rows={4}
className="min-h-[86px] w-full resize-y rounded-md border border-white/10 bg-black/45 px-2.5 py-2 text-[12px] leading-relaxed text-white outline-none placeholder:text-white/25 focus:border-[#d6b36a]/55"
placeholder="这里改中文文案"
/>
</label>
<label className="block">
<div className="mb-1 text-[10.5px] text-white/44">稿 prompt </div>
<textarea
value={draftEn}
onChange={(e) => setDraftEn(e.target.value)}
rows={3}
className="min-h-[72px] w-full resize-y rounded-md border border-white/10 bg-black/45 px-2.5 py-2 font-mono text-[11px] leading-relaxed text-white/76 outline-none placeholder:text-white/25 focus:border-[#d6b36a]/55"
placeholder="English copy for generation"
/>
</label>
<div className="flex items-center justify-between gap-2">
<details className="group min-w-0 flex-1 rounded border border-white/10 bg-black/22 px-2 py-1.5">
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 text-[10.5px] text-white/44">
<span></span>
<ChevronDown className="h-3.5 w-3.5 transition group-open:rotate-180" />
</summary>
<div className="mt-1 max-h-24 overflow-y-auto text-[10.5px] leading-relaxed text-white/48">
{script?.source_zh ? <p>{script.source_zh}</p> : null}
{script?.source_text ? <p className="mt-1 font-mono text-white/34">{script.source_text}</p> : null}
</div>
</details>
<button
type="button"
disabled={!canSave || saving || optimizing}
onClick={() => void saveDraft()}
className="skg-primary-action inline-flex h-8 shrink-0 items-center gap-1.5 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-45"
>
{saving || optimizing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</button>
</div>
</div>
)}
</section>
)
}
function AudioIntakePanel({
job,
selectedFrames,

View File

@@ -1044,6 +1044,25 @@ export async function triggerTranscribe(id: string): Promise<Job> {
return res.json()
}
export async function updateAudioScript(
id: string,
body: Partial<Pick<AudioScript,
"source_text" | "source_zh" | "rewritten_text" | "rewritten_text_zh" |
"speaker_profile" | "rhythm_profile" | "background_audio_profile"
>>,
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${id}/audio-script`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`updateAudioScript ${res.status} ${txt.slice(0, 200)}`)
}
return res.json()
}
export async function analyzeJob(
id: string,
frames = 12,