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

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