fix: allow retrying failed source analysis

This commit is contained in:
2026-05-18 16:49:51 +08:00
parent 07384c5e19
commit 061eb7d867
6 changed files with 73 additions and 6 deletions

View File

@@ -58,6 +58,7 @@
GET /health GET /health
GET /documents GET /documents
POST /jobs POST /jobs
POST /jobs/{id}/download/retry
POST /jobs/upload POST /jobs/upload
GET /jobs GET /jobs
GET /jobs/{id} GET /jobs/{id}
@@ -99,6 +100,7 @@ POST /jobs/{id}/frames/{idx}/storyboard/video
## 最近变更 ## 最近变更
- 2026-05-18TK 链接下载新增 `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER` 支持;受限视频失败时前端提示上传 MP4 或配置后端 cookies 登录态。 - 2026-05-18TK 链接下载新增 `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER` 支持;受限视频失败时前端提示上传 MP4 或配置后端 cookies 登录态。
- 2026-05-18素材输入端失败任务支持重新下载 / 重新解析;选中失败且无 `video_url` 的 TK 素材时调用后端重试接口,已有视频的失败任务会清掉自动触发标记并重新跑音频/视觉路。
- 2026-05-18清理个人语音通道残留`/health`、前端类型、环境模板和文档不再暴露相关字段或配置。 - 2026-05-18清理个人语音通道残留`/health`、前端类型、环境模板和文档不再暴露相关字段或配置。
- 2026-05-18新增后端数据库层SQLite 默认落在 `APP_DB_URL` / `DATABASE_URL``JOBS_DIR/app.db``/documents` 返回文档归类列表,`/health.database` 返回 DB 状态。 - 2026-05-18新增后端数据库层SQLite 默认落在 `APP_DB_URL` / `DATABASE_URL``JOBS_DIR/app.db``/documents` 返回文档归类列表,`/health.database` 返回 DB 状态。
- 2026-05-18`VISION_MODEL``REWRITE_MODEL``AUDIO_REWRITE_MODEL` 切到 GPT 默认模型 `gpt-4o`,并加旧 Gemini 环境变量归一化保护。 - 2026-05-18`VISION_MODEL``REWRITE_MODEL``AUDIO_REWRITE_MODEL` 切到 GPT 默认模型 `gpt-4o`,并加旧 Gemini 环境变量归一化保护。

View File

@@ -3214,6 +3214,31 @@ async def create_job(req: CreateJobReq, bg: BackgroundTasks) -> Job:
return job return job
@app.post("/jobs/{job_id}/download/retry", response_model=Job)
async def retry_job_download(job_id: str, bg: BackgroundTasks) -> Job:
job = JOBS.get(job_id)
if not job:
raise HTTPException(404, "job not found")
if job.source_kind == "upload" or job.url.startswith("upload://"):
raise HTTPException(409, "uploaded videos cannot be redownloaded; upload the file again")
if job.status in {"downloading", "splitting", "transcribing"}:
raise HTTPException(409, f"job is busy: {job.status}")
mp4 = job_dir(job_id) / "source.mp4"
if mp4.exists() and mp4.stat().st_size == 0:
mp4.unlink()
update(
job,
status="downloading",
progress=1,
error="",
message="重新提交下载…",
video_url="",
)
bg.add_task(pipeline_download, job_id)
return job
@app.post("/jobs/upload", response_model=Job) @app.post("/jobs/upload", response_model=Job)
async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(...)) -> Job: async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(...)) -> Job:
if not file.filename: if not file.filename:

File diff suppressed because one or more lines are too long

View File

@@ -19,7 +19,7 @@ import { AdRecreationBoard } from "@/components/ad-recreation-board"
import { import {
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage, addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, describeFrame, updateStoryboard, copyProductLibraryAsset, deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, describeFrame, updateStoryboard, copyProductLibraryAsset,
formatJobError, formatJobError, retryJobDownload,
type Job, type ImageRef, type KeyFrame, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget, type Job, type ImageRef, type KeyFrame, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
} from "@/lib/api" } from "@/lib/api"
import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target" import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target"
@@ -574,15 +574,30 @@ export default function Home() {
const handleStartProduction = useCallback(async (inputUrl?: string) => { const handleStartProduction = useCallback(async (inputUrl?: string) => {
const trimmed = inputUrl?.trim() const trimmed = inputUrl?.trim()
const created = trimmed ? await handleSubmit(trimmed) : undefined const created = trimmed ? await handleSubmit(trimmed) : undefined
const target = created ?? job let target = created ?? job
if (!target) { if (!target) {
toast.info("先粘贴视频链接或选择一个素材任务") toast.info("先粘贴视频链接或选择一个素材任务")
return return
} }
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("已重新提交下载;下载完成后会自动跑音频文案路和视觉抽帧路")
} catch (e) {
toast.error("重新下载失败:" + (e instanceof Error ? e.message : String(e)))
return
}
}
setProductionJobIds((prev) => new Set(prev).add(target.id)) setProductionJobIds((prev) => new Set(prev).add(target.id))
toast.success("已进入并行素材分析:下载完成后自动跑音频文案路和视觉抽帧路") if (target.video_url) toast.success("已进入并行素材分析:音频文案路和视觉抽帧路会同步推进")
else toast.success("已进入并行素材分析:下载完成后自动跑音频文案路和视觉抽帧路")
void startProductionLanesForJob(target) void startProductionLanesForJob(target)
}, [handleSubmit, job, startProductionLanesForJob]) }, [handleSubmit, job, startProductionLanesForJob, updateJobInList])
useEffect(() => { useEffect(() => {
if (productionJobIds.size === 0) return if (productionJobIds.size === 0) return

View File

@@ -1451,6 +1451,9 @@ function MaterialColumn({
onSubmitUrl: () => void onSubmitUrl: () => void
onStartProduction: () => void onStartProduction: () => void
}) { }) {
const actionLabel = !url.trim() && job?.status === "failed"
? job.video_url ? "重新解析" : "重新下载"
: "开始分析"
return ( return (
<section className="flex min-h-0 flex-col gap-3 rounded-lg border border-white/10 bg-white/[0.035] p-3 shadow-2xl"> <section className="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"> <header className="shrink-0 border-b border-white/10 pb-3">
@@ -1476,7 +1479,7 @@ function MaterialColumn({
disabled={data.submitting || (!url.trim() && !job)} disabled={data.submitting || (!url.trim() && !job)}
className="inline-flex h-10 items-center justify-center rounded-md bg-rose-600 px-3 text-[13px] font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-45" className="inline-flex h-10 items-center justify-center rounded-md bg-rose-600 px-3 text-[13px] font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-45"
> >
{actionLabel}
</button> </button>
<button <button
type="button" type="button"

View File

@@ -661,6 +661,15 @@ export async function createJob(tkUrl: string): Promise<Job> {
return res.json() return res.json()
} }
export async function retryJobDownload(id: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${id}/download/retry`, { method: "POST" })
if (!res.ok) {
const text = await res.text().catch(() => "")
throw apiError("retryJobDownload", res.status, text)
}
return res.json()
}
export async function uploadJob(file: File): Promise<Job> { export async function uploadJob(file: File): Promise<Job> {
const fd = new FormData() const fd = new FormData()
fd.append("file", file) fd.append("file", file)