feat: make intake auto extract copy
This commit is contained in:
37
api/main.py
37
api/main.py
@@ -3785,6 +3785,16 @@ class RewriteStoryboardScriptReq(BaseModel):
|
||||
segments: list[ScriptRewriteSegmentReq] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateAudioScriptReq(BaseModel):
|
||||
source_text: str | None = None
|
||||
source_zh: str | None = None
|
||||
rewritten_text: str | None = None
|
||||
rewritten_text_zh: str | None = None
|
||||
speaker_profile: str | None = None
|
||||
rhythm_profile: str | None = None
|
||||
background_audio_profile: str | None = None
|
||||
|
||||
|
||||
_TRANSLATION_CACHE: dict[str, str] = {}
|
||||
|
||||
|
||||
@@ -4272,6 +4282,33 @@ async def trigger_transcribe(job_id: str, bg: BackgroundTasks) -> Job:
|
||||
return job_with_artifacts(job)
|
||||
|
||||
|
||||
@app.patch("/jobs/{job_id}/audio-script", response_model=Job)
|
||||
def update_audio_script(job_id: str, req: UpdateAudioScriptReq) -> Job:
|
||||
job = JOBS.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "job not found")
|
||||
audio_script = job.audio_script or AudioScript()
|
||||
patch: dict[str, str] = {}
|
||||
for field in (
|
||||
"source_text",
|
||||
"source_zh",
|
||||
"rewritten_text",
|
||||
"rewritten_text_zh",
|
||||
"speaker_profile",
|
||||
"rhythm_profile",
|
||||
"background_audio_profile",
|
||||
):
|
||||
value = getattr(req, field)
|
||||
if value is not None:
|
||||
patch[field] = str(value).strip()
|
||||
if not patch:
|
||||
return job_with_artifacts(job)
|
||||
patch["status"] = "completed"
|
||||
patch["created_at"] = audio_script.created_at or time.time()
|
||||
update(job, audio_script=audio_script.model_copy(update=patch))
|
||||
return job_with_artifacts(job)
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/video.mp4")
|
||||
def get_video(job_id: str):
|
||||
p = job_dir(job_id) / "source.mp4"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user