diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 7827b7b..fede4c0 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,17 @@ # Development Log +## 2026-08-24 字幕审核、异步渲染与自动流水线整合(PR 4) + +- 全自动流水线在切片后创建原片/切片字幕草稿,并停在 `pending_subtitle_review`;不会继续生成文案或发送任务。 +- 用户必须选择“审核并批量烧录”或“跳过字幕,使用原片继续”。决定写入任务配置,恢复流水线后不会静默切换视频来源。 +- 字幕批量烧录接入 `workflow_jobs` 持久化队列,固定引用已审核 revision,逐条保存 checkpoint;重启只复用文件存在且验证通过的结果。 +- FFmpeg 渲染支持真实进度、取消、无进展超时、临时文件和原子替换;优先尝试可用的 NVENC,失败回退 `libx264`,输出必须通过 H.264、`yuv420p`、音轨和时长校验。 +- 旧 active 字幕成片只在新结果验证成功后切换;失败、取消或进程异常不会覆盖旧文件,精确临时文件会被清理。 +- AI 纠错按所选 cue 分批创建 `ai_suggestion` revision,仅接受文字和断句建议;差异需人工选择,接受后再生成新的人工 revision,不自动覆盖 active 版本。 +- 发送中心新增字幕来源证据门禁:自动使用字幕时必须同时满足 revision 已审核、渲染已验证、文件存在;明确跳过时只使用原片。 +- 新增第四阶段专项测试,覆盖暂停/恢复、显式跳过、审核门禁、checkpoint、取消清理、NVENC 回退、验证失败、AI 建议与发布就绪证据。 +- 验证通过:Ruff、Python 编译、两个 JavaScript 语法检查、定向测试 `143 passed`、完整测试 `500 passed`;本机 9:16、16:9、1:1 三种画幅真实 FFmpeg 烧录与 FFprobe 校验通过。 + ## 2026-08-23 字幕数据层与专业编辑器重构(PR 3) - 新增 `subtitle_tracks / subtitle_revisions / subtitle_cues`,以原片主时间轴为事实源;每次人工编辑、导入或同步都创建不可变 revision,渲染任务固定引用 revision。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 74d3e6c..528b504 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -5,17 +5,17 @@ - [x] PR 1:模式必选、已有文件入口、媒体/磁盘预检、持久化重型 Job、转写断点与词级时间戳。 - [x] PR 2:`long_live_talk` 5 分钟重叠窗口、每小时覆盖、全局配额、去重、90% 覆盖门禁和窗口级恢复。 - [x] PR 3:统一字幕 track/revision/cue、pysubs2 导入导出、wavesurfer 波形与专业编辑器。 -- [ ] PR 4:字幕审核暂停、AI 建议 revision、异步批量烧录、NVENC 回退和发送中心门禁。 +- [x] PR 4:字幕审核暂停、AI 建议 revision、异步批量烧录、NVENC 回退和发送中心门禁。 -当前验证重点:PR 3 完成专项、页面和全量回归后进入自动流水线整合;异步批量烧录、AI 建议 revision 和发送中心审核门禁仍属于 PR 4,不能把 PR 3 的旧同步烧录兼容入口当成最终自动流程。 +当前验证重点:四个开发阶段均已落地。合并时必须按 PR 1 → PR 2 → PR 3 → PR 4 的顺序;每个 PR 只合并到它标明的前序分支,最终再进入 `master`,不要跳过中间数据库与接口变更。 -### PR 3 人工检查 +### PR 4 人工检查 -1. 打开任一已有转写和切片的任务字幕工作台,先选“原片主字幕”,确认视频、波形和数千行虚拟列表可滚动。 -2. 修改文字或毫秒时间,等待状态显示新 revision 已保存;刷新页面后修改仍在,旧 revision 仍可从 API 查询。 -3. 切换切片字幕轨,确认时间从 0 开始;人工修改后更新原片,切片应显示待同步而不是被覆盖。 -4. 分别导入、导出 SRT/VTT/ASS;ASS 按规范以 10ms 为单位,SRT/VTT 与数据库保持毫秒精度。 -5. PR 4 完成前不要用本页做自动批量烧录或真实发送;当前“自动加字幕”仍是旧同步兼容入口。 +1. 新建一个低风险全自动测试任务并完成切片;任务应停在“字幕待审核”,发送中心不能提前出现该任务。 +2. 打开字幕工作台,修改并审核字幕后点击“审核并批量烧录”;观察队列进度,完成后确认带字幕视频可播放,任务随后恢复生成文案。 +3. 另建测试任务,在暂停页明确点击“跳过字幕,使用原片继续”;发送中心应显示原片来源,不应误用旧字幕文件。 +4. 选中少量 cue 请求 AI 建议时会调用已配置的文本 AI 并产生额度;先检查 Provider,再确认差异只包含文字,人工接受前 active revision 不变。 +5. 取消一条正在烧录的测试任务,确认状态进入已取消、旧 active 字幕仍可播放、刷新后可以重试。不要用真实待发布内容做这一项。 ## 2026-08-23 v2.1.0 主线同步后检查 diff --git a/app/db/database.py b/app/db/database.py index 53c4841..65f9745 100644 --- a/app/db/database.py +++ b/app/db/database.py @@ -44,6 +44,12 @@ def init_db() -> None: settings.data_dir / "backups", "subtitle-editor-rebuild", ) + if _requires_subtitle_auto_schema_migration(settings.database_path): + create_schema_migration_backup( + settings.database_path, + settings.data_dir / "backups", + "subtitle-auto-workflow", + ) with get_connection() as connection: connection.executescript( @@ -181,17 +187,23 @@ def init_db() -> None: task_id TEXT NOT NULL, output_clip_id TEXT NOT NULL, revision_id TEXT, + workflow_job_id TEXT, style_preset_id TEXT, status TEXT NOT NULL DEFAULT 'pending', subtitle_file_path TEXT, output_file_path TEXT, error_message TEXT, + validation_status TEXT NOT NULL DEFAULT 'legacy_unverified', + validation_json TEXT NOT NULL DEFAULT '{}', + encoder TEXT NOT NULL DEFAULT '', + verified_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(task_id) REFERENCES tasks(id), FOREIGN KEY(output_clip_id) REFERENCES output_clip(id), FOREIGN KEY(revision_id) REFERENCES subtitle_revisions(id), + FOREIGN KEY(workflow_job_id) REFERENCES workflow_jobs(id), FOREIGN KEY(style_preset_id) REFERENCES subtitle_style_presets(id) ); @@ -561,6 +573,26 @@ def _requires_subtitle_editor_schema_migration(database_path) -> bool: ) +def _requires_subtitle_auto_schema_migration(database_path) -> bool: + """已有字幕任务缺少异步渲染验证字段时,迁移前先备份。""" + if not database_path.exists() or database_path.stat().st_size == 0: + return False + connection = None + try: + connection = sqlite3.connect(f"{database_path.resolve().as_uri()}?mode=ro", uri=True, timeout=10) + columns = {row[1] for row in connection.execute("PRAGMA table_info(subtitle_jobs)").fetchall()} + finally: + if connection: + connection.close() + return bool(columns) and not { + "workflow_job_id", + "validation_status", + "validation_json", + "encoder", + "verified_at", + } <= columns + + def _get_table_columns(connection: sqlite3.Connection, table_name: str) -> set[str]: rows = connection.execute(f"PRAGMA table_info({table_name})").fetchall() return {row["name"] for row in rows} @@ -584,6 +616,7 @@ def _create_indexes(connection: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_clip_feedback_task_clip ON clip_feedback(task_id, clip_candidate_id)", # 字幕任务(按任务、输出切片、状态) "CREATE INDEX IF NOT EXISTS idx_subtitle_jobs_task_output_status ON subtitle_jobs(task_id, output_clip_id, status)", + "CREATE INDEX IF NOT EXISTS idx_subtitle_jobs_workflow_job ON subtitle_jobs(workflow_job_id)", "CREATE INDEX IF NOT EXISTS idx_subtitle_tracks_task_type ON subtitle_tracks(task_id, track_type, is_active)", "CREATE UNIQUE INDEX IF NOT EXISTS uq_subtitle_tracks_active_source ON subtitle_tracks(task_id) WHERE track_type = 'source' AND is_active = 1", "CREATE INDEX IF NOT EXISTS idx_subtitle_revisions_track_created ON subtitle_revisions(track_id, created_at)", @@ -724,10 +757,12 @@ def _migrate_tasks_table(connection: sqlite3.Connection) -> None: UPDATE tasks SET status = 'failed' WHERE status IN ('失败', 'failed'); UPDATE tasks SET status = 'pending_video' WHERE status NOT IN ( 'CREATED', 'PREPARING_SOURCE', 'TRANSCRIBING', 'AI_ANALYZING', - 'CLIP_SELECTING', 'VIDEO_CUTTING', 'METADATA_GENERATING', + 'CLIP_SELECTING', 'VIDEO_CUTTING', 'SUBTITLE_DRAFTING', + 'PENDING_SUBTITLE_REVIEW', 'METADATA_GENERATING', 'SCHEDULE_CREATING', 'PUBLISH_JOB_CREATING', 'READY_TO_PUBLISH', 'COMPLETED', 'FAILED_PREPARING_SOURCE', 'FAILED_TRANSCRIBING', 'FAILED_AI_ANALYZING', 'FAILED_CLIP_SELECTING', 'FAILED_VIDEO_CUTTING', + 'FAILED_SUBTITLE_DRAFTING', 'FAILED_METADATA_GENERATING', 'FAILED_SCHEDULE_CREATING', 'FAILED_PUBLISH_JOB_CREATING', 'pending_video', 'pending_processing', 'audio_extracting', 'transcribing', @@ -901,11 +936,16 @@ def _migrate_subtitle_jobs_table(connection: sqlite3.Connection) -> None: "task_id": "ALTER TABLE subtitle_jobs ADD COLUMN task_id TEXT", "output_clip_id": "ALTER TABLE subtitle_jobs ADD COLUMN output_clip_id TEXT", "revision_id": "ALTER TABLE subtitle_jobs ADD COLUMN revision_id TEXT", + "workflow_job_id": "ALTER TABLE subtitle_jobs ADD COLUMN workflow_job_id TEXT", "style_preset_id": "ALTER TABLE subtitle_jobs ADD COLUMN style_preset_id TEXT", "status": "ALTER TABLE subtitle_jobs ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'", "subtitle_file_path": "ALTER TABLE subtitle_jobs ADD COLUMN subtitle_file_path TEXT", "output_file_path": "ALTER TABLE subtitle_jobs ADD COLUMN output_file_path TEXT", "error_message": "ALTER TABLE subtitle_jobs ADD COLUMN error_message TEXT", + "validation_status": "ALTER TABLE subtitle_jobs ADD COLUMN validation_status TEXT NOT NULL DEFAULT 'legacy_unverified'", + "validation_json": "ALTER TABLE subtitle_jobs ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'", + "encoder": "ALTER TABLE subtitle_jobs ADD COLUMN encoder TEXT NOT NULL DEFAULT ''", + "verified_at": "ALTER TABLE subtitle_jobs ADD COLUMN verified_at TEXT", "created_at": "ALTER TABLE subtitle_jobs ADD COLUMN created_at TEXT NOT NULL DEFAULT ''", "updated_at": "ALTER TABLE subtitle_jobs ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''", "is_active": "ALTER TABLE subtitle_jobs ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1", diff --git a/app/models/subtitle.py b/app/models/subtitle.py index 47b3a20..25867ec 100644 --- a/app/models/subtitle.py +++ b/app/models/subtitle.py @@ -64,6 +64,15 @@ class SubtitleAIRevisionRequest(BaseModel): instructions: str = Field(default="", max_length=2000) +class SubtitleAISuggestionAcceptRequest(BaseModel): + base_revision_id: str = Field(min_length=1, max_length=64) + cue_ids: list[str] = Field(min_length=1, max_length=500) + + +class SubtitleTaskRenderRequest(BaseModel): + approve_active_revisions: bool = True + + class SubtitleStyleExtendedUpdate(BaseModel): font_family: str = Field(default="Microsoft YaHei", min_length=1, max_length=120) font_size: int = Field(default=42, ge=12, le=160) diff --git a/app/models/task.py b/app/models/task.py index de3c1fd..adf8156 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -11,6 +11,8 @@ class TaskStatus(str, Enum): AI_ANALYZING = "AI_ANALYZING" CLIP_SELECTING = "CLIP_SELECTING" VIDEO_CUTTING = "VIDEO_CUTTING" + SUBTITLE_DRAFTING = "SUBTITLE_DRAFTING" + PENDING_SUBTITLE_REVIEW = "PENDING_SUBTITLE_REVIEW" METADATA_GENERATING = "METADATA_GENERATING" SCHEDULE_CREATING = "SCHEDULE_CREATING" PUBLISH_JOB_CREATING = "PUBLISH_JOB_CREATING" @@ -21,6 +23,7 @@ class TaskStatus(str, Enum): FAILED_AI_ANALYZING = "FAILED_AI_ANALYZING" FAILED_CLIP_SELECTING = "FAILED_CLIP_SELECTING" FAILED_VIDEO_CUTTING = "FAILED_VIDEO_CUTTING" + FAILED_SUBTITLE_DRAFTING = "FAILED_SUBTITLE_DRAFTING" FAILED_METADATA_GENERATING = "FAILED_METADATA_GENERATING" FAILED_SCHEDULE_CREATING = "FAILED_SCHEDULE_CREATING" FAILED_PUBLISH_JOB_CREATING = "FAILED_PUBLISH_JOB_CREATING" diff --git a/app/routers/subtitles.py b/app/routers/subtitles.py index 3fb2c59..68346d6 100644 --- a/app/routers/subtitles.py +++ b/app/routers/subtitles.py @@ -6,12 +6,23 @@ from app.models.subtitle import ( SubtitleApproveRequest, + SubtitleAISuggestionAcceptRequest, + SubtitleAIRevisionRequest, SubtitleOperationsRequest, SubtitleRevisionCreate, + SubtitleTaskRenderRequest, SubtitleSyncRequest, ) +from app.services import job_service +from app.services.subtitle_ai_service import generate_subtitle_suggestions +from app.services.subtitle_auto_workflow_service import ( + enqueue_task_subtitle_render, + prepare_task_subtitle_review, + skip_task_subtitles_and_resume, +) from app.services.subtitle_data_service import ( SubtitleRevisionConflict, + accept_suggestion_revision, apply_revision_operations, approve_revision, create_manual_revision, @@ -40,6 +51,37 @@ def generate_source_track(task_id: str, payload: SubtitleSyncRequest) -> dict: return _call(lambda: {"track": ensure_source_track(task_id, force=payload.force)}) +@router.post("/tasks/{task_id}/prepare-review") +def prepare_review(task_id: str) -> dict: + return _call(lambda: prepare_task_subtitle_review(task_id)) + + +@router.post("/tasks/{task_id}/approve-and-render") +def approve_and_render(task_id: str, payload: SubtitleTaskRenderRequest) -> dict: + return _call( + lambda: enqueue_task_subtitle_render( + task_id, + approve_active_revisions=payload.approve_active_revisions, + continue_pipeline=True, + ) + ) + + +@router.post("/tasks/{task_id}/skip-and-resume") +def skip_and_resume(task_id: str) -> dict: + return _call(lambda: skip_task_subtitles_and_resume(task_id)) + + +@router.get("/tasks/{task_id}/jobs") +def list_subtitle_jobs(task_id: str) -> dict: + jobs = [ + job + for job in job_service.list_jobs(task_id=task_id) + if job.get("job_type") == job_service.JOB_TYPE_SUBTITLE + ] + return {"task_id": task_id, "jobs": jobs, "count": len(jobs)} + + @router.get("/tracks/{track_id}") def read_track(track_id: str) -> dict: return _call(lambda: {"track": get_track(track_id)}) @@ -109,6 +151,36 @@ def sync_source(track_id: str, payload: SubtitleSyncRequest) -> dict: return _call(lambda: {"track": sync_clip_track(track_id, force=payload.force)}) +@router.post("/tracks/{track_id}/ai-suggestions") +def ai_suggestions(track_id: str, payload: SubtitleAIRevisionRequest) -> dict: + return _call( + lambda: generate_subtitle_suggestions( + track_id, + revision_id=payload.revision_id, + cue_ids=payload.cue_ids, + instructions=payload.instructions, + ) + ) + + +@router.post("/tracks/{track_id}/ai-suggestions/{suggestion_revision_id}/accept") +def accept_ai_suggestions( + track_id: str, + suggestion_revision_id: str, + payload: SubtitleAISuggestionAcceptRequest, +) -> dict: + return _call( + lambda: { + "revision": accept_suggestion_revision( + track_id, + suggestion_revision_id=suggestion_revision_id, + base_revision_id=payload.base_revision_id, + cue_ids=payload.cue_ids, + ) + } + ) + + @router.post("/tracks/{track_id}/import") async def import_subtitle( track_id: str, diff --git a/app/routers/tasks.py b/app/routers/tasks.py index 531488b..2f7ba41 100644 --- a/app/routers/tasks.py +++ b/app/routers/tasks.py @@ -441,7 +441,14 @@ async def resume_auto_pipeline(task_id: str, background_tasks: BackgroundTasks) @router.post("/{task_id}/output-clips/{output_clip_id}/subtitles") async def render_output_clip_subtitles(task_id: str, output_clip_id: str) -> dict: try: - return task_service.render_subtitles_for_output_clip(task_id, output_clip_id) + from app.services.subtitle_auto_workflow_service import enqueue_task_subtitle_render + + return enqueue_task_subtitle_render( + task_id, + output_clip_ids=[output_clip_id], + approve_active_revisions=False, + continue_pipeline=False, + ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except RuntimeError as exc: diff --git a/app/services/ai/ai_clip_analyzer.py b/app/services/ai/ai_clip_analyzer.py index 1e37621..d9bf4a7 100644 --- a/app/services/ai/ai_clip_analyzer.py +++ b/app/services/ai/ai_clip_analyzer.py @@ -624,6 +624,11 @@ def _loads_ai_json(raw_text: str) -> Any: raise AIAnalysisError("JSON 解析失败:AI 没有返回可识别的 JSON 内容") +def loads_ai_json(raw_text: str) -> Any: + """供不同 AI 工作流复用同一套容错 JSON 解析。""" + return _loads_ai_json(raw_text) + + def _iter_json_candidates(raw_text: str) -> list[str]: cleaned = (raw_text or "").strip().lstrip("\ufeff") candidates: list[str] = [] @@ -886,5 +891,6 @@ def result_to_jsonable(result: AIClipAnalysisResult) -> dict[str, Any]: "build_provider", "build_remote_provider", "inspect_local_analysis_plan", + "loads_ai_json", "result_to_jsonable", ] diff --git a/app/services/auto_publish_service.py b/app/services/auto_publish_service.py index f7868ec..d36739d 100644 --- a/app/services/auto_publish_service.py +++ b/app/services/auto_publish_service.py @@ -10,6 +10,7 @@ from app.services.publish_copy_rules import PUBLISH_COPY_RULE_VERSION from app.services.publish_service import DEFAULT_BILIBILI_TID, USER_REMOVED_ERROR_CODE, get_publish_job from app.services.publish_domain import AUTO_PUBLISH_PLATFORMS, validate_publish_mode, validate_target_platform +from app.services.storage_service import resolve_video_file_path from app.services.task_service import _now_iso @@ -18,18 +19,29 @@ def platforms_for_task(task: dict) -> list[str]: return list(AUTO_PUBLISH_PLATFORMS) -def create_auto_publish_jobs(task: dict, scheduled_items: list[dict]) -> dict: +def create_auto_publish_jobs( + task: dict, + scheduled_items: list[dict], + *, + subtitle_delivery_mode: str, +) -> dict: """为全自动流水线生成发布任务。 本轮只创建任务记录,不调用平台 API,也不启动 opencli 发送。 """ + if subtitle_delivery_mode not in {"subtitled", "original"}: + raise ValueError("字幕交付模式必须是 subtitled 或 original") created_ids: list[str] = [] skipped_ids: list[str] = [] now = _now_iso() with get_connection() as connection: for item in scheduled_items: output_clip = item["output_clip"] + video_source, video_file_path, subtitle_evidence = _resolve_auto_video_source( + output_clip, + subtitle_delivery_mode, + ) metadata = item["metadata"] cover = item.get("cover") or {} platform = validate_target_platform(metadata["platform"]) @@ -104,6 +116,8 @@ def create_auto_publish_jobs(task: dict, scheduled_items: list[dict]) -> dict: "cover_time_seconds": cover_time_seconds, "risk_flags": metadata.get("risk_flags") or [], "publish_mode": publish_mode, + "subtitle_delivery_mode": subtitle_delivery_mode, + **subtitle_evidence, "note": "全自动流水线已直接创建最终发布任务,可在发送中心设置排期。", } connection.execute( @@ -117,7 +131,7 @@ def create_auto_publish_jobs(task: dict, scheduled_items: list[dict]) -> dict: schedule_timezone, timezone, status, audit_status, error_message, last_error, provider_response, publish_result, max_attempts, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, 'original', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'public', + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'public', 'time', ?, 1, ?, 'original', '', ?, ?, ?, ?, ?, 'not_submitted', '', '', ?, '', ?, ?, ?) """, ( @@ -128,8 +142,9 @@ def create_auto_publish_jobs(task: dict, scheduled_items: list[dict]) -> dict: account_id, platform, publish_mode, - output_clip.get("output_file_path") or "", - output_clip.get("output_file_path") or "", + video_source, + video_file_path, + video_file_path, metadata.get("title") or "精彩片段", metadata.get("caption") or "", metadata.get("caption") or "", @@ -159,3 +174,34 @@ def create_auto_publish_jobs(task: dict, scheduled_items: list[dict]) -> dict: "created_count": len(created_ids), "skipped_count": len(skipped_ids), } + + +def _resolve_auto_video_source(output_clip: dict, delivery_mode: str) -> tuple[str, str, dict]: + if delivery_mode == "original": + raw_path = str(output_clip.get("output_file_path") or "") + path = resolve_video_file_path(raw_path) if raw_path else None + if not path or not path.exists() or not path.is_file(): + raise ValueError("原片切片文件不存在,不能创建发布任务") + return "original", raw_path, {"subtitle_skip_confirmed": True} + + raw_path = str(output_clip.get("subtitled_output_file_path") or "") + path = resolve_video_file_path(raw_path) if raw_path else None + if ( + output_clip.get("subtitle_status") != "completed" + or output_clip.get("subtitle_validation_status") != "verified" + or output_clip.get("subtitle_revision_status") != "approved" + or not path + or not path.exists() + or not path.is_file() + ): + raise ValueError("字幕成片尚未同时通过 revision 审核和 FFprobe 验证,不能进入发送中心") + return ( + "subtitled", + raw_path, + { + "subtitle_revision_id": output_clip.get("subtitle_revision_id") or "", + "subtitle_revision_status": output_clip.get("subtitle_revision_status") or "", + "subtitle_validation_status": output_clip.get("subtitle_validation_status") or "", + "subtitle_verified_at": output_clip.get("subtitle_verified_at") or "", + }, + ) diff --git a/app/services/job_worker.py b/app/services/job_worker.py index 91a928e..1a0bf40 100644 --- a/app/services/job_worker.py +++ b/app/services/job_worker.py @@ -47,6 +47,8 @@ def execute_job(job_id: str, *, lease_owner: str | None = None, already_claimed: _execute_transcript(job_id, task_id, job.get("payload_json") or {}) elif job_type == job_service.JOB_TYPE_AUTO_PIPELINE: _execute_auto_pipeline(job_id, task_id, job.get("payload_json") or {}) + elif job_type == job_service.JOB_TYPE_SUBTITLE: + _execute_subtitle(job_id, task_id, job.get("payload_json") or {}) else: raise ValueError(f"暂不支持的 job 类型:{job_type}") except Exception as exc: @@ -124,6 +126,22 @@ def _execute_auto_pipeline(job_id: str, task_id: str, payload: dict) -> None: job_service.mark_job_completed(job_id, result) +def _execute_subtitle(job_id: str, task_id: str, payload: dict) -> None: + from app.services.subtitle_auto_workflow_service import execute_subtitle_render_job + from app.services.subtitle_workflow_service import SubtitleRenderCancelled + + job_service.update_job_progress(job_id, 3, "正在核对已审核的字幕 revision") + try: + result = execute_subtitle_render_job(job_id, task_id, payload) + except SubtitleRenderCancelled: + job_service.mark_job_cancelled(job_id, "字幕批量烧录已取消") + return + if job_service.is_cancel_requested(job_id): + job_service.mark_job_cancelled(job_id, "字幕批量烧录已取消") + return + job_service.mark_job_completed(job_id, result) + + class WorkflowJobRunner: """应用生命周期内的单 worker 线程。""" @@ -153,6 +171,7 @@ def _run(self) -> None: self._run_job_subprocess(job["id"]) def _run_job_subprocess(self, job_id: str) -> None: + job_before_start = job_service.get_job(job_id) or {} process = popen_process_group( [sys.executable, "-m", "app.services.job_worker_process", job_id, self.owner], cwd=str(settings.project_root), @@ -162,15 +181,27 @@ def _run_job_subprocess(self, job_id: str) -> None: last_heartbeat = 0.0 last_progress_at = time.monotonic() previous_progress: tuple[int, str] | None = None - no_progress_timeout = max( - 900, - settings.ffmpeg_audio_extract_timeout, - settings.ffmpeg_cut_timeout, - settings.volcengine_asr_timeout_seconds, + no_progress_timeout = ( + settings.ffmpeg_subtitle_timeout + if job_before_start.get("job_type") == job_service.JOB_TYPE_SUBTITLE + else max( + 900, + settings.ffmpeg_audio_extract_timeout, + settings.ffmpeg_cut_timeout, + settings.volcengine_asr_timeout_seconds, + ) ) while process.poll() is None: if self._stop_event.wait(1): terminate_process_tree(process) + if job_before_start.get("job_type") == job_service.JOB_TYPE_SUBTITLE: + from app.services.subtitle_auto_workflow_service import cleanup_interrupted_subtitle_job + + cleanup_interrupted_subtitle_job( + job_id, + status="queued", + message="应用停止,等待恢复字幕烧录", + ) job_service.release_job_lease(job_id, self.owner) return job = job_service.get_job(job_id) @@ -179,6 +210,10 @@ def _run_job_subprocess(self, job_id: str) -> None: return if job_service.is_cancel_requested(job_id): terminate_process_tree(process) + if job.get("job_type") == job_service.JOB_TYPE_SUBTITLE: + from app.services.subtitle_auto_workflow_service import cleanup_interrupted_subtitle_job + + cleanup_interrupted_subtitle_job(job_id, status="cancelled", message="用户已取消字幕烧录") job_service.mark_job_cancelled(job_id, "任务已取消,子进程树已终止") return progress_state = (int(job.get("progress") or 0), str(job.get("message") or "")) @@ -187,6 +222,14 @@ def _run_job_subprocess(self, job_id: str) -> None: last_progress_at = time.monotonic() if time.monotonic() - last_progress_at > no_progress_timeout: terminate_process_tree(process) + if job.get("job_type") == job_service.JOB_TYPE_SUBTITLE: + from app.services.subtitle_auto_workflow_service import cleanup_interrupted_subtitle_job + + cleanup_interrupted_subtitle_job( + job_id, + status="failed", + message=f"字幕烧录连续 {no_progress_timeout} 秒没有进展", + ) job_service.mark_job_failed(job_id, f"任务连续 {no_progress_timeout} 秒没有进展,已终止子进程树") return if time.monotonic() - last_heartbeat >= 20: @@ -194,6 +237,14 @@ def _run_job_subprocess(self, job_id: str) -> None: last_heartbeat = time.monotonic() final_job = job_service.get_job(job_id) if process.returncode != 0 and final_job and final_job.get("status") == job_service.JOB_STATUS_RUNNING: + if final_job.get("job_type") == job_service.JOB_TYPE_SUBTITLE: + from app.services.subtitle_auto_workflow_service import cleanup_interrupted_subtitle_job + + cleanup_interrupted_subtitle_job( + job_id, + status="failed", + message=f"字幕 Job 子进程异常退出,退出码:{process.returncode}", + ) job_service.mark_job_failed(job_id, f"Job 子进程异常退出,退出码:{process.returncode}") elif process.returncode == 0 and final_job and final_job.get("status") == job_service.JOB_STATUS_RUNNING: job_service.mark_job_failed(job_id, "Job 子进程已退出但没有写入终态") diff --git a/app/services/pipeline_engine.py b/app/services/pipeline_engine.py index 64b7387..4158630 100644 --- a/app/services/pipeline_engine.py +++ b/app/services/pipeline_engine.py @@ -30,6 +30,7 @@ TaskStatus.AI_ANALYZING, TaskStatus.CLIP_SELECTING, TaskStatus.VIDEO_CUTTING, + TaskStatus.SUBTITLE_DRAFTING, TaskStatus.METADATA_GENERATING, TaskStatus.SCHEDULE_CREATING, TaskStatus.PUBLISH_JOB_CREATING, @@ -41,6 +42,7 @@ TaskStatus.AI_ANALYZING: TaskStatus.FAILED_AI_ANALYZING, TaskStatus.CLIP_SELECTING: TaskStatus.FAILED_CLIP_SELECTING, TaskStatus.VIDEO_CUTTING: TaskStatus.FAILED_VIDEO_CUTTING, + TaskStatus.SUBTITLE_DRAFTING: TaskStatus.FAILED_SUBTITLE_DRAFTING, TaskStatus.METADATA_GENERATING: TaskStatus.FAILED_METADATA_GENERATING, TaskStatus.SCHEDULE_CREATING: TaskStatus.FAILED_SCHEDULE_CREATING, TaskStatus.PUBLISH_JOB_CREATING: TaskStatus.FAILED_PUBLISH_JOB_CREATING, @@ -52,6 +54,7 @@ TaskStatus.FAILED_AI_ANALYZING.value: TaskStatus.AI_ANALYZING, TaskStatus.FAILED_CLIP_SELECTING.value: TaskStatus.CLIP_SELECTING, TaskStatus.FAILED_VIDEO_CUTTING.value: TaskStatus.VIDEO_CUTTING, + TaskStatus.FAILED_SUBTITLE_DRAFTING.value: TaskStatus.SUBTITLE_DRAFTING, TaskStatus.FAILED_METADATA_GENERATING.value: TaskStatus.METADATA_GENERATING, TaskStatus.FAILED_SCHEDULE_CREATING.value: TaskStatus.SCHEDULE_CREATING, TaskStatus.FAILED_PUBLISH_JOB_CREATING.value: TaskStatus.PUBLISH_JOB_CREATING, @@ -106,6 +109,7 @@ def run( TaskStatus.AI_ANALYZING: self._run_ai_analysis, TaskStatus.CLIP_SELECTING: self._select_clips, TaskStatus.VIDEO_CUTTING: self._cut_video, + TaskStatus.SUBTITLE_DRAFTING: self._prepare_subtitle_drafts, TaskStatus.METADATA_GENERATING: self._generate_metadata, TaskStatus.SCHEDULE_CREATING: self._create_schedule, TaskStatus.PUBLISH_JOB_CREATING: self._create_publish_jobs, @@ -128,6 +132,14 @@ def run( job_service.heartbeat_job(job_id, str(job["lease_owner"])) task_service.update_task_status(task_id, step) context[step.value] = handlers[step](task_id, context) + if step == TaskStatus.SUBTITLE_DRAFTING: + summary = self._write_task_summary(task_id, TaskStatus.PENDING_SUBTITLE_REVIEW.value, "") + return { + "status": "pending_subtitle_review", + "message": "字幕草稿已生成,等待人工审核后再继续。", + "summary_path": summary["summary_path"], + "task": task_service.get_task(task_id, include_video_probe=False), + } except Exception as exc: failed_status = FAILED_BY_STEP[step] error = str(exc) or f"{step.value} 失败" @@ -147,7 +159,7 @@ def run( append_task_log(task_id, "发布任务已创建,进入待人工确认发布状态") summary = self._write_task_summary(task_id, "ready_to_publish", "") task_service.update_task_status(task_id, TaskStatus.COMPLETED) - append_task_log(task_id, "全自动流水线完成。本轮已跳过字幕烧录,只保留原视频裁切片段。") + append_task_log(task_id, "全自动流水线完成,发布内容已按用户确认的字幕交付方式创建。") return { "status": "completed", "summary_path": summary["summary_path"], @@ -310,7 +322,7 @@ def _select_clips(self, task_id: str, context: dict) -> dict: return payload def _cut_video(self, task_id: str, context: dict) -> dict: - append_task_log(task_id, "全自动模式:开始原视频裁切,本轮明确跳过字幕烧录") + append_task_log(task_id, "全自动模式:开始原视频裁切,完成后生成字幕草稿") result = task_service.process_task_video_cuts(task_id, sync_publish_jobs=False) output_clips = task_service.list_output_clips(task_id) success = [clip for clip in output_clips if clip.get("status") == "completed" and clip.get("file_exists")] @@ -321,6 +333,12 @@ def _cut_video(self, task_id: str, context: dict) -> dict: append_task_log(task_id, f"全自动原片切割完成:成功 {len(success)} 条,失败 {len(failed)} 条") return {"success_count": len(success), "failed_count": len(failed), "failed": failed} + def _prepare_subtitle_drafts(self, task_id: str, context: dict) -> dict: + from app.services.subtitle_auto_workflow_service import prepare_task_subtitle_review + + del context + return prepare_task_subtitle_review(task_id) + def _generate_metadata(self, task_id: str, context: dict) -> dict: task = self._get_task(task_id) config = context["config"] @@ -376,7 +394,14 @@ def _create_publish_jobs(self, task_id: str, context: dict) -> dict: scheduled_items = schedule_result.get("scheduled_items") or [] if not scheduled_items: raise ValueError("没有可创建发布任务的排期记录") - result = create_auto_publish_jobs(task, scheduled_items) + delivery_mode = str(context["config"].get("subtitle_delivery_mode") or "") + if delivery_mode not in {"subtitled", "original"}: + raise ValueError("尚未确认字幕交付方式,不能创建发布任务") + result = create_auto_publish_jobs( + task, + scheduled_items, + subtitle_delivery_mode=delivery_mode, + ) append_task_log( task_id, f"全自动发布任务创建完成:新增 {result['created_count']} 条,跳过已有 {result['skipped_count']} 条", @@ -484,6 +509,11 @@ def _write_task_summary(self, task_id: str, status: str, error: str) -> dict: if error: failures.append(error) failures.extend(output_counts["errors"]) + next_step = ( + "打开字幕工作台审核草稿;确认后批量烧录,或明确跳过字幕继续使用原片。" + if status == TaskStatus.PENDING_SUBTITLE_REVIEW.value + else "打开发送中心核对内容、账号与北京时间;确认后可立即发送或设置排期。" + ) summary = { "task_id": task_id, "task_name": task.get("task_name") or "", @@ -501,10 +531,11 @@ def _write_task_summary(self, task_id: str, status: str, error: str) -> dict: "publish_job_count": publish_job_count, "need_review_clips": need_review, "failures": failures, - "next_step": "打开发送中心核对内容、账号与北京时间;确认后可立即发送或设置排期。", - "subtitle_note": "v2.1 全自动模式继续跳过加字幕、字幕样式渲染和字幕烧录。", + "next_step": next_step, + "subtitle_note": "切片后必须人工确认字幕交付方式;只有已审核并验证的字幕成片才会自动进入发送中心。", } summary_path = paths["analysis_path"].parent / "task_summary.json" + summary_path.parent.mkdir(parents=True, exist_ok=True) summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") summary["summary_path"] = str(summary_path) return summary diff --git a/app/services/publish_readiness.py b/app/services/publish_readiness.py index f47a8f1..c95387e 100644 --- a/app/services/publish_readiness.py +++ b/app/services/publish_readiness.py @@ -94,6 +94,54 @@ def _content_issues(job: dict[str, Any], platform: str, publish_mode: str) -> li return issues +def _subtitle_delivery_issues(job: dict[str, Any]) -> list[dict[str, Any]]: + provider_payload = job.get("provider_payload") if isinstance(job.get("provider_payload"), dict) else {} + delivery_mode = str( + job.get("subtitle_delivery_mode") + or provider_payload.get("subtitle_delivery_mode") + or "" + ) + video_source = str(job.get("video_source") or "original") + if delivery_mode == "original" and video_source != "original": + return [ + _issue( + "subtitle_delivery_mismatch", + "该任务已明确选择原片继续,当前视频源不一致", + "complete_content", + ) + ] + if delivery_mode == "subtitled" and video_source != "subtitled": + return [ + _issue( + "subtitle_review_required", + "该任务要求使用审核后的字幕成片,当前仍是原片", + "complete_content", + ) + ] + if video_source != "subtitled": + return [] + revision_id = str(job.get("subtitle_revision_id") or provider_payload.get("subtitle_revision_id") or "") + revision_status = str( + job.get("subtitle_revision_status") + or provider_payload.get("subtitle_revision_status") + or "" + ) + validation_status = str( + job.get("subtitle_validation_status") + or provider_payload.get("subtitle_validation_status") + or "" + ) + if not revision_id or revision_status != "approved" or validation_status != "verified": + return [ + _issue( + "subtitle_not_verified", + "带字幕成片缺少已审核 revision 或 FFprobe 验证证据", + "complete_content", + ) + ] + return [] + + def build_send_readiness( job: dict[str, Any], *, @@ -175,6 +223,7 @@ def build_send_readiness( ) ) + issues.extend(_subtitle_delivery_issues(job)) issues.extend(_content_issues(job, platform, resolved_mode)) if resolved_mode == "local_browser" and worker_available is False: diff --git a/app/services/publish_service.py b/app/services/publish_service.py index 7660f7c..25e5df0 100644 --- a/app/services/publish_service.py +++ b/app/services/publish_service.py @@ -28,6 +28,7 @@ PublishJobTargetUpdate, PublishPlatformConfigUpdate, PublishSendJobUpdate, + TaskStatus, ) from app.services.ai.ai_clip_analyzer import build_provider from app.services.ai.base import AIProviderError @@ -1129,11 +1130,16 @@ def _get_output_clip_for_publish(task_id: str, output_clip_id: str) -> dict | No clip_candidates.title AS clip_title, clip_candidates.cover_time_seconds AS ai_cover_time_seconds, subtitle_jobs.status AS subtitle_status, - subtitle_jobs.output_file_path AS subtitled_output_file_path + subtitle_jobs.output_file_path AS subtitled_output_file_path, + subtitle_jobs.revision_id AS subtitle_revision_id, + subtitle_jobs.validation_status AS subtitle_validation_status, + subtitle_jobs.verified_at AS subtitle_verified_at, + subtitle_revisions.status AS subtitle_revision_status FROM output_clip JOIN tasks ON tasks.id = output_clip.task_id LEFT JOIN clip_candidates ON clip_candidates.id = output_clip.clip_candidate_id LEFT JOIN subtitle_jobs ON subtitle_jobs.output_clip_id = output_clip.id AND subtitle_jobs.is_active = 1 + LEFT JOIN subtitle_revisions ON subtitle_revisions.id = subtitle_jobs.revision_id WHERE output_clip.task_id = ? AND output_clip.id = ? AND output_clip.is_active = 1 """, (task_id, output_clip_id), @@ -1155,11 +1161,16 @@ def _get_output_clip_by_id(output_clip_id: str) -> dict | None: clip_candidates.title AS clip_title, clip_candidates.cover_time_seconds AS ai_cover_time_seconds, subtitle_jobs.status AS subtitle_status, - subtitle_jobs.output_file_path AS subtitled_output_file_path + subtitle_jobs.output_file_path AS subtitled_output_file_path, + subtitle_jobs.revision_id AS subtitle_revision_id, + subtitle_jobs.validation_status AS subtitle_validation_status, + subtitle_jobs.verified_at AS subtitle_verified_at, + subtitle_revisions.status AS subtitle_revision_status FROM output_clip JOIN tasks ON tasks.id = output_clip.task_id LEFT JOIN clip_candidates ON clip_candidates.id = output_clip.clip_candidate_id LEFT JOIN subtitle_jobs ON subtitle_jobs.output_clip_id = output_clip.id AND subtitle_jobs.is_active = 1 + LEFT JOIN subtitle_revisions ON subtitle_revisions.id = subtitle_jobs.revision_id WHERE output_clip.id = ? AND output_clip.is_active = 1 """, (output_clip_id,), @@ -1193,11 +1204,16 @@ def _list_completed_publish_clips(task_id: str | None = None) -> list[dict]: clip_candidates.duration_seconds, clip_candidates.cover_time_seconds AS ai_cover_time_seconds, subtitle_jobs.status AS subtitle_status, - subtitle_jobs.output_file_path AS subtitled_output_file_path + subtitle_jobs.output_file_path AS subtitled_output_file_path, + subtitle_jobs.revision_id AS subtitle_revision_id, + subtitle_jobs.validation_status AS subtitle_validation_status, + subtitle_jobs.verified_at AS subtitle_verified_at, + subtitle_revisions.status AS subtitle_revision_status FROM output_clip JOIN tasks ON tasks.id = output_clip.task_id LEFT JOIN clip_candidates ON clip_candidates.id = output_clip.clip_candidate_id LEFT JOIN subtitle_jobs ON subtitle_jobs.output_clip_id = output_clip.id AND subtitle_jobs.is_active = 1 + LEFT JOIN subtitle_revisions ON subtitle_revisions.id = subtitle_jobs.revision_id WHERE tasks.is_deleted = 0 AND output_clip.status = 'completed' AND output_clip.is_active = 1 {where_task} ORDER BY output_clip.created_at DESC @@ -1217,8 +1233,8 @@ def _get_completed_publish_clip_by_output(output_clip_id: str) -> dict | None: def _resolve_publish_video_path(output_clip: dict, video_source: str) -> tuple[str, Path]: if video_source == "subtitled": raw_path = (output_clip.get("subtitled_output_file_path") or "").strip() - if output_clip.get("subtitle_status") != "completed" or not raw_path: - raise ValueError("这条切片还没有生成带字幕成片,不能选择“带字幕成片”。") + if not _subtitle_publish_ready(output_clip) or not raw_path: + raise ValueError("带字幕成片尚未同时通过 revision 审核和 FFprobe 验证,不能选择。") else: raw_path = (output_clip.get("output_file_path") or "").strip() if output_clip.get("output_status") != "completed" or not raw_path: @@ -1611,6 +1627,10 @@ def _insert_opencli_job( "output_status": item.get("output_status") or "completed", "subtitle_status": item.get("subtitle_status"), "subtitled_output_file_path": item.get("subtitled_output_file_path"), + "subtitle_revision_id": item.get("subtitle_revision_id"), + "subtitle_revision_status": item.get("subtitle_revision_status"), + "subtitle_validation_status": item.get("subtitle_validation_status"), + "subtitle_verified_at": item.get("subtitle_verified_at"), }, video_source, ) @@ -1684,7 +1704,11 @@ def _insert_opencli_job( cover_file_path, settings.app_timezone, settings.app_timezone, - _publish_provider_payload(metadata, cover), + _publish_provider_payload( + metadata, + cover, + existing=_subtitle_publish_evidence(item, video_source), + ), settings.publish_scheduler_max_retry_count, now, now, @@ -1901,11 +1925,34 @@ def _find_inheritable_publish_job(item: dict, platform: str) -> dict: def _preferred_video_source(item: dict, prefer_subtitled: bool) -> str: raw_path = str(item.get("subtitled_output_file_path") or "").strip() path = resolve_video_file_path(raw_path) if raw_path else None - if prefer_subtitled and item.get("subtitle_status") == "completed" and path and path.exists(): + if prefer_subtitled and _subtitle_publish_ready(item) and path and path.exists(): return "subtitled" return "original" +def _subtitle_publish_ready(item: dict) -> bool: + return bool( + item.get("subtitle_status") == "completed" + and item.get("subtitle_validation_status") == "verified" + and item.get("subtitle_revision_status") == "approved" + and item.get("subtitle_revision_id") + ) + + +def _subtitle_publish_evidence(item: dict, video_source: str) -> dict: + if video_source != "subtitled": + return {} + if not _subtitle_publish_ready(item): + raise ValueError("字幕成片缺少审核或验证证据") + return { + "subtitle_delivery_mode": "subtitled", + "subtitle_revision_id": item.get("subtitle_revision_id") or "", + "subtitle_revision_status": item.get("subtitle_revision_status") or "", + "subtitle_validation_status": item.get("subtitle_validation_status") or "", + "subtitle_verified_at": item.get("subtitle_verified_at") or "", + } + + def _update_preparation_video_source(job: dict, item: dict, video_source: str) -> dict: from app.services.publish_repository import PublishRepository @@ -1938,7 +1985,14 @@ def _update_preparation_video_source(job: dict, item: dict, video_source: str) - cover_mode, cover_time_seconds, cover_file_path, - _publish_provider_payload({}, cover, existing=job.get("provider_payload") or {}), + _publish_provider_payload( + {}, + cover, + existing={ + **(job.get("provider_payload") or {}), + **_subtitle_publish_evidence(item, video_source), + }, + ), now, job["id"], ), @@ -2018,11 +2072,13 @@ def sync_task_publish_jobs( ) -> dict: with get_connection() as connection: task = connection.execute( - "SELECT id, platform FROM tasks WHERE id = ? AND COALESCE(is_deleted, 0) = 0", + "SELECT id, platform, auto_mode, status FROM tasks WHERE id = ? AND COALESCE(is_deleted, 0) = 0", (task_id,), ).fetchone() if not task: raise ValueError("任务不存在") + if bool(task["auto_mode"]) and task["status"] == TaskStatus.PENDING_SUBTITLE_REVIEW.value: + raise ValueError("自动流水线正在等待字幕审核,请先批量烧录或明确跳过字幕,再进入发送中心") items = _list_completed_publish_clips(task_id) if not items: raise ValueError("当前任务还没有可同步的激活切片") @@ -2370,7 +2426,11 @@ def _list_missing_publish_cover_jobs(platform: str | None = None) -> list[dict]: output_clip.status AS output_status, clip_candidates.cover_time_seconds AS ai_cover_time_seconds, subtitle_jobs.status AS subtitle_status, - subtitle_jobs.output_file_path AS subtitled_output_file_path + subtitle_jobs.output_file_path AS subtitled_output_file_path, + subtitle_jobs.revision_id AS subtitle_revision_id, + subtitle_jobs.validation_status AS subtitle_validation_status, + subtitle_jobs.verified_at AS subtitle_verified_at, + subtitle_revisions.status AS subtitle_revision_status FROM publish_jobs JOIN output_clip ON output_clip.id = publish_jobs.output_clip_id JOIN tasks ON tasks.id = publish_jobs.task_id @@ -2378,6 +2438,7 @@ def _list_missing_publish_cover_jobs(platform: str | None = None) -> list[dict]: LEFT JOIN subtitle_jobs ON subtitle_jobs.output_clip_id = output_clip.id AND subtitle_jobs.is_active = 1 + LEFT JOIN subtitle_revisions ON subtitle_revisions.id = subtitle_jobs.revision_id WHERE publish_jobs.status IN ('DRAFT', 'WAITING', 'SCHEDULED') AND TRIM(COALESCE(publish_jobs.cover_file_path, '')) = '' AND output_clip.is_active = 1 @@ -4513,14 +4574,19 @@ def get_publish_center_context(*, focus_task_id: str = "") -> dict: generated=True, ) original_available = bool(original_path and original_path.exists() and original_path.is_file()) - subtitled_available = bool(subtitled_path and subtitled_path.exists() and subtitled_path.is_file()) + subtitled_available = bool( + _subtitle_publish_ready(item) + and subtitled_path + and subtitled_path.exists() + and subtitled_path.is_file() + ) normalized_item = { **item, "default_title": default_title, "default_tags": format_douyin_tags(_fallback_tags(item), generated=True), "original_available": original_available, "subtitled_available": subtitled_available, - "subtitle_status_label": "已加字幕" if item.get("subtitle_status") == "completed" else "未加字幕", + "subtitle_status_label": "已审核并验证" if subtitled_available else "字幕未就绪", "video_media_url": _video_media_url(item["task_id"], item["output_clip_id"], "original"), } publish_items.append(normalized_item) diff --git a/app/services/subtitle_ai_service.py b/app/services/subtitle_ai_service.py new file mode 100644 index 0000000..98efa7c --- /dev/null +++ b/app/services/subtitle_ai_service.py @@ -0,0 +1,104 @@ +"""字幕 AI 纠错建议:只生成非 active 文字建议 revision。""" + +from __future__ import annotations + +import json +from typing import Any + +from app.services.ai.ai_clip_analyzer import build_provider, loads_ai_json +from app.services.subtitle_data_service import ( + create_suggestion_revision, + get_revision, + get_suggestion_diff, + get_track, +) + + +AI_BATCH_SIZE = 50 +ALLOWED_SUGGESTION_KEYS = {"cue_id", "suggested_text", "reason"} + + +def generate_subtitle_suggestions( + track_id: str, + *, + revision_id: str, + cue_ids: list[str], + instructions: str = "", + provider: Any | None = None, +) -> dict[str, Any]: + track = get_track(track_id) + if track.get("active_revision_id") != revision_id: + raise ValueError("当前字幕版本已经变化,请刷新后重新生成 AI 建议") + revision = get_revision(revision_id, include_cues=True) + if revision["track_id"] != track_id: + raise ValueError("revision 不属于当前字幕轨") + selected_ids = list(dict.fromkeys(str(value) for value in cue_ids if str(value))) + if not selected_ids: + raise ValueError("请先勾选需要 AI 纠错的字幕") + if len(selected_ids) > 500: + raise ValueError("单次最多选择 500 条字幕") + cue_map = {str(cue["id"]): cue for cue in revision["cues"]} + if not set(selected_ids) <= cue_map.keys(): + raise ValueError("选中的 cue 不属于当前 revision") + + resolved_provider = provider or build_provider() + suggestions: dict[str, str] = {} + for offset in range(0, len(selected_ids), AI_BATCH_SIZE): + batch_ids = selected_ids[offset : offset + AI_BATCH_SIZE] + prompt = _build_prompt([cue_map[cue_id] for cue_id in batch_ids], instructions) + payload = loads_ai_json(resolved_provider.generate_json(prompt)) + suggestions.update(_validate_suggestion_payload(payload, allowed_ids=set(batch_ids))) + + suggestion_revision = create_suggestion_revision( + track_id, + base_revision_id=revision_id, + suggested_text_by_cue_id=suggestions, + note=f"AI 字幕纠错建议 · {getattr(resolved_provider, 'name', 'AI')}", + ) + return { + "revision": suggestion_revision, + "base_revision_id": revision_id, + "provider": getattr(resolved_provider, "name", "AI"), + "diff": get_suggestion_diff(track_id, suggestion_revision["id"]), + } + + +def _build_prompt(cues: list[dict[str, Any]], instructions: str) -> str: + compact = [ + { + "cue_id": str(cue["id"]), + "text": str(cue.get("text") or ""), + } + for cue in cues + ] + extra = instructions.strip() or "修正明显错别字、同音误识别、标点和便于阅读的 cue 内换行" + return ( + "你是中文视频字幕校对员。只输出严格 JSON,不要解释。\n" + "你只能修改 suggested_text 的文字、标点和 cue 内换行;禁止增删 cue,禁止修改时间、说话人或 cue_id。\n" + "没有必要修改的 cue 也必须原样返回。换行请直接使用 JSON 字符串中的 \\n。\n" + f"额外要求:{extra}\n" + "输出结构:{\"suggestions\":[{\"cue_id\":\"...\",\"suggested_text\":\"...\",\"reason\":\"简短原因\"}]}\n" + f"字幕:{json.dumps(compact, ensure_ascii=False, separators=(',', ':'))}" + ) + + +def _validate_suggestion_payload(payload: Any, *, allowed_ids: set[str]) -> dict[str, str]: + if not isinstance(payload, dict) or not isinstance(payload.get("suggestions"), list): + raise ValueError("AI 字幕建议缺少 suggestions 数组") + resolved: dict[str, str] = {} + for item in payload["suggestions"]: + if not isinstance(item, dict): + raise ValueError("AI 字幕建议条目格式无效") + forbidden = set(item) - ALLOWED_SUGGESTION_KEYS + if forbidden: + raise ValueError(f"AI 字幕建议包含禁止字段:{', '.join(sorted(forbidden))}") + cue_id = str(item.get("cue_id") or "") + if cue_id not in allowed_ids or cue_id in resolved: + raise ValueError("AI 字幕建议包含未知或重复 cue_id") + text = str(item.get("suggested_text") or "").strip() + if not text or len(text) > 4000: + raise ValueError("AI 字幕建议文字为空或过长") + resolved[cue_id] = text + if set(resolved) != allowed_ids: + raise ValueError("AI 字幕建议没有完整返回所选 cue") + return resolved diff --git a/app/services/subtitle_auto_workflow_service.py b/app/services/subtitle_auto_workflow_service.py new file mode 100644 index 0000000..3664d78 --- /dev/null +++ b/app/services/subtitle_auto_workflow_service.py @@ -0,0 +1,291 @@ +"""字幕审核暂停、批量烧录与自动流水线恢复。""" + +from __future__ import annotations + +import json +from typing import Any + +from app.db.database import get_connection +from app.models.task import TaskStatus +from app.services import job_service +from app.services.storage_service import get_artifact_paths, resolve_video_file_path +from app.services.subtitle_data_service import ( + approve_revision, + ensure_clip_track, + ensure_source_track, + get_revision, +) +from app.services.subtitle_workflow_service import ( + SubtitleRenderCancelled, + render_subtitles_for_output_clip, +) +from app.services.task_log_service import append_task_log + + +def prepare_task_subtitle_review(task_id: str) -> dict[str, Any]: + """从原片主时间轴生成所有成功切片的字幕草稿。""" + from app.services import task_service + + task = task_service.get_task(task_id, include_video_probe=False) + if not task: + raise ValueError("任务不存在") + source_track = ensure_source_track(task_id) + output_clips = [ + item + for item in task_service.list_output_clips(task_id) + if item.get("status") == "completed" and item.get("file_exists") + ] + if not output_clips: + raise ValueError("没有可生成字幕草稿的成功切片") + tracks = [ensure_clip_track(task_id, item["id"]) for item in output_clips] + task_service.update_task_status(task_id, TaskStatus.PENDING_SUBTITLE_REVIEW) + append_task_log(task_id, f"字幕草稿已生成:{len(tracks)} 条,流水线暂停等待人工审核") + return { + "status": "pending_subtitle_review", + "source_track_id": source_track["id"], + "track_count": len(tracks), + "tracks": tracks, + } + + +def enqueue_task_subtitle_render( + task_id: str, + *, + output_clip_ids: list[str] | None = None, + approve_active_revisions: bool = False, + continue_pipeline: bool = False, +) -> dict[str, Any]: + from app.services import task_service + + task = task_service.get_task(task_id, include_video_probe=False) + if not task: + raise ValueError("任务不存在") + if continue_pipeline and task.get("status") != TaskStatus.PENDING_SUBTITLE_REVIEW.value: + raise ValueError("当前任务不在字幕审核暂停状态") + requested = set(output_clip_ids or []) + output_clips = [ + item + for item in task_service.list_output_clips(task_id) + if item.get("status") == "completed" + and item.get("file_exists") + and (not requested or item["id"] in requested) + ] + if requested and {item["id"] for item in output_clips} != requested: + raise ValueError("部分切片不存在或尚未生成完成") + if not output_clips: + raise ValueError("没有可烧录字幕的成功切片") + + items = [] + for output_clip in output_clips: + track = ensure_clip_track(task_id, output_clip["id"]) + revision_id = str(track.get("active_revision_id") or "") + if not revision_id: + raise ValueError(f"{output_clip.get('output_file_name') or output_clip['id']} 没有字幕 revision") + revision = get_revision(revision_id, include_cues=True) + if int(revision.get("cue_count") or 0) <= 0: + raise ValueError(f"{output_clip.get('output_file_name') or output_clip['id']} 没有可烧录的字幕内容") + if approve_active_revisions: + revision = approve_revision(track["id"], revision_id) + if revision.get("status") != "approved": + raise ValueError(f"{output_clip.get('output_file_name') or output_clip['id']} 的字幕尚未审核") + items.append( + { + "output_clip_id": output_clip["id"], + "revision_id": revision_id, + "output_file_name": output_clip.get("output_file_name") or output_clip["id"], + } + ) + + job, created = job_service.create_or_get_active_job( + task_id=task_id, + job_type=job_service.JOB_TYPE_SUBTITLE, + payload={ + "items": items, + "continue_pipeline": continue_pipeline, + "subtitle_delivery_mode": "subtitled", + }, + ) + if continue_pipeline and not bool((job.get("payload_json") or {}).get("continue_pipeline")): + raise ValueError("已有单条字幕任务正在运行,请等待完成或取消后再启动批量烧录") + if continue_pipeline: + _set_subtitle_delivery_mode(task_id, "subtitled") + append_task_log( + task_id, + f"字幕批量烧录{'已加入队列' if created else '已在队列中'}:{len(items)} 条", + ) + return { + "status": job["status"], + "message": "字幕批量烧录已加入持久化队列" if created else "已有字幕烧录任务正在排队或运行", + "job": job, + "job_id": job["id"], + "created": created, + "item_count": len(items), + } + + +def skip_task_subtitles_and_resume(task_id: str) -> dict[str, Any]: + from app.services import task_service + + task = task_service.get_task(task_id, include_video_probe=False) + if not task: + raise ValueError("任务不存在") + if not task.get("auto_mode"): + raise ValueError("只有全自动任务需要执行字幕跳过决策") + if task.get("status") != TaskStatus.PENDING_SUBTITLE_REVIEW.value: + raise ValueError("当前任务不在字幕审核暂停状态") + active_subtitle_jobs = [ + job + for job in job_service.list_jobs(task_id=task_id) + if job.get("job_type") == job_service.JOB_TYPE_SUBTITLE + and job.get("status") in {job_service.JOB_STATUS_QUEUED, job_service.JOB_STATUS_RUNNING} + ] + if active_subtitle_jobs: + raise ValueError("字幕烧录仍在运行,请先取消并等待停止后再选择跳过字幕") + _set_subtitle_delivery_mode(task_id, "original") + job, created = _enqueue_pipeline_resume(task_id) + append_task_log(task_id, "用户已明确跳过字幕,后续发布内容将使用原片切片") + return { + "status": job["status"], + "message": "已明确跳过字幕,流水线恢复任务已加入队列" if created else "流水线恢复任务已经在队列中", + "job": job, + "job_id": job["id"], + "created": created, + } + + +def execute_subtitle_render_job(job_id: str, task_id: str, payload: dict[str, Any]) -> dict[str, Any]: + items = payload.get("items") or [] + if not isinstance(items, list) or not items: + raise ValueError("字幕 Job 没有待渲染条目") + job = job_service.get_job(job_id) or {} + checkpoint = job.get("checkpoint_json") if isinstance(job.get("checkpoint_json"), dict) else {} + completed = dict(checkpoint.get("completed") or {}) + total = len(items) + for index, item in enumerate(items): + output_clip_id = str(item.get("output_clip_id") or "") + revision_id = str(item.get("revision_id") or "") + if not output_clip_id or not revision_id: + raise ValueError("字幕 Job 条目缺少 output_clip_id 或 revision_id") + if _checkpoint_result_is_valid(task_id, output_clip_id, revision_id, completed.get(output_clip_id)): + continue + if job_service.is_cancel_requested(job_id): + raise SubtitleRenderCancelled("用户已取消字幕批量烧录") + item_start = 5 + round(index / total * 88) + item_end = 5 + round((index + 1) / total * 88) + job_service.update_job_progress( + job_id, + item_start, + f"正在烧录第 {index + 1}/{total} 条:{item.get('output_file_name') or output_clip_id}", + ) + result = render_subtitles_for_output_clip( + task_id, + output_clip_id, + revision_id=revision_id, + workflow_job_id=job_id, + progress_start=item_start, + progress_end=item_end, + ) + completed[output_clip_id] = { + "revision_id": revision_id, + "subtitle_job_id": (result.get("job") or {}).get("id") or "", + "output_file_path": (result.get("job") or {}).get("output_file_path") or "", + } + job_service.update_job_checkpoint( + job_id, + {"completed": completed, "completed_count": len(completed), "total_count": total}, + ) + + resume_job = None + if payload.get("continue_pipeline"): + resume_job, _ = _enqueue_pipeline_resume(task_id) + append_task_log(task_id, "字幕成片全部验证通过,已排队恢复自动文案与发送中心流程") + return { + "task_id": task_id, + "completed_count": len(completed), + "total_count": total, + "completed": completed, + "resume_job_id": resume_job["id"] if resume_job else "", + } + + +def cleanup_interrupted_subtitle_job(workflow_job_id: str, *, status: str, message: str) -> None: + """父 Worker 强制终止子进程后,修正从属字幕记录并清理精确临时文件。""" + from app.services.task_service import _now_iso + + now = _now_iso() + with get_connection() as connection: + row = connection.execute( + "SELECT task_id FROM workflow_jobs WHERE id = ?", + (workflow_job_id,), + ).fetchone() + connection.execute( + """ + UPDATE subtitle_jobs + SET status = ?, error_message = ?, updated_at = ? + WHERE workflow_job_id = ? AND status = 'processing' AND is_active = 0 + """, + (status, message, now, workflow_job_id), + ) + connection.commit() + if not row: + return + directory = get_artifact_paths(str(row["task_id"]))["subtitled_dir"] + if directory.exists(): + for path in directory.glob(f"*.{workflow_job_id}.part.mp4"): + if path.is_file(): + path.unlink(missing_ok=True) + + +def _checkpoint_result_is_valid( + task_id: str, + output_clip_id: str, + revision_id: str, + checkpoint: Any, +) -> bool: + if not isinstance(checkpoint, dict) or checkpoint.get("revision_id") != revision_id: + return False + subtitle_job_id = str(checkpoint.get("subtitle_job_id") or "") + if not subtitle_job_id: + return False + with get_connection() as connection: + row = connection.execute( + """ + SELECT output_file_path FROM subtitle_jobs + WHERE id = ? AND task_id = ? AND output_clip_id = ? AND revision_id = ? + AND status = 'completed' AND validation_status = 'verified' + """, + (subtitle_job_id, task_id, output_clip_id, revision_id), + ).fetchone() + path = resolve_video_file_path(row["output_file_path"]) if row else None + return bool(path and path.exists() and path.is_file()) + + +def _set_subtitle_delivery_mode(task_id: str, mode: str) -> None: + if mode not in {"subtitled", "original"}: + raise ValueError("字幕交付模式无效") + from app.services.task_service import _now_iso + + now = _now_iso() + with get_connection() as connection: + row = connection.execute("SELECT auto_config_json FROM tasks WHERE id = ?", (task_id,)).fetchone() + if not row: + raise ValueError("任务不存在") + try: + config = json.loads(row["auto_config_json"] or "{}") + except json.JSONDecodeError: + config = {} + config["subtitle_delivery_mode"] = mode + config["subtitle_decided_at"] = now + connection.execute( + "UPDATE tasks SET auto_config_json = ?, updated_at = ? WHERE id = ?", + (json.dumps(config, ensure_ascii=False), now, task_id), + ) + connection.commit() + + +def _enqueue_pipeline_resume(task_id: str) -> tuple[dict[str, Any], bool]: + return job_service.create_or_get_active_job( + task_id=task_id, + job_type=job_service.JOB_TYPE_AUTO_PIPELINE, + payload={"retry": False, "start_step": TaskStatus.METADATA_GENERATING.value}, + ) diff --git a/app/services/subtitle_data_service.py b/app/services/subtitle_data_service.py index 64f8f99..85893de 100644 --- a/app/services/subtitle_data_service.py +++ b/app/services/subtitle_data_service.py @@ -478,9 +478,11 @@ def get_revision_cues( def approve_revision(track_id: str, revision_id: str) -> dict[str, Any]: get_track(track_id) - revision = get_revision(revision_id) + revision = get_revision(revision_id, include_cues=True) if revision["track_id"] != track_id: raise ValueError("revision 不属于当前字幕轨") + if int((revision.get("quality") or {}).get("error_count") or 0) > 0: + raise ValueError("当前字幕仍有时间重叠错误,请修正后再审核") now = _now_iso() with get_connection() as connection: connection.execute("BEGIN IMMEDIATE") @@ -496,6 +498,129 @@ def approve_revision(track_id: str, revision_id: str) -> dict[str, Any]: return get_revision(revision_id, include_cues=True) +def create_suggestion_revision( + track_id: str, + *, + base_revision_id: str, + suggested_text_by_cue_id: dict[str, str], + note: str = "AI 字幕纠错建议", +) -> dict[str, Any]: + """保存非 active 建议版本;服务端只接受文字变化。""" + track = get_track(track_id) + if track.get("active_revision_id") != base_revision_id: + raise SubtitleRevisionConflict("字幕已产生新版本,请刷新后重新生成 AI 建议") + base_revision = get_revision(base_revision_id) + if base_revision["track_id"] != track_id: + raise ValueError("基础 revision 不属于当前字幕轨") + base_cues = _fetch_all_revision_cues(base_revision_id) + known_ids = {str(cue["id"]) for cue in base_cues} + unknown = set(suggested_text_by_cue_id) - known_ids + if unknown: + raise ValueError("AI 建议包含不属于当前 revision 的 cue") + suggestion_cues = [] + for cue in base_cues: + cue_id = str(cue["id"]) + text = str(suggested_text_by_cue_id.get(cue_id, cue["text"]) or "").strip() + if not text: + raise ValueError("AI 建议不能把字幕文字清空") + suggestion_cues.append( + { + "start_ms": int(cue["start_ms"]), + "end_ms": int(cue["end_ms"]), + "text": text, + "confidence": cue.get("confidence"), + "speaker": cue.get("speaker") or "", + "source_cue_id": cue_id, + } + ) + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + revision = _insert_revision_with_connection( + connection, + track_id, + suggestion_cues, + origin="ai_suggestion", + parent_revision_id=base_revision_id, + status="suggested", + note=note, + activate=False, + ) + connection.commit() + return get_revision(revision["id"], include_cues=True) + + +def get_suggestion_diff(track_id: str, suggestion_revision_id: str) -> list[dict[str, Any]]: + suggestion = get_revision(suggestion_revision_id, include_cues=True) + if suggestion["track_id"] != track_id or suggestion.get("origin") != "ai_suggestion": + raise ValueError("AI 建议 revision 不属于当前字幕轨") + base_revision_id = str(suggestion.get("parent_revision_id") or "") + if not base_revision_id: + raise ValueError("AI 建议缺少基础 revision") + base_cues = {str(cue["id"]): cue for cue in _fetch_all_revision_cues(base_revision_id)} + changes = [] + for cue in suggestion["cues"]: + source_id = str(cue.get("source_cue_id") or "") + base = base_cues.get(source_id) + if not base or str(base.get("text") or "") == str(cue.get("text") or ""): + continue + changes.append( + { + "cue_id": source_id, + "start_ms": int(base["start_ms"]), + "end_ms": int(base["end_ms"]), + "original_text": str(base.get("text") or ""), + "suggested_text": str(cue.get("text") or ""), + } + ) + return changes + + +def accept_suggestion_revision( + track_id: str, + *, + suggestion_revision_id: str, + base_revision_id: str, + cue_ids: Iterable[str], +) -> dict[str, Any]: + """选择接受 AI 文字建议,并生成新的人工草稿 revision。""" + track = get_track(track_id) + if track.get("active_revision_id") != base_revision_id: + raise SubtitleRevisionConflict("字幕已产生新版本,请刷新后再接受 AI 建议") + suggestion = get_revision(suggestion_revision_id, include_cues=True) + if ( + suggestion["track_id"] != track_id + or suggestion.get("origin") != "ai_suggestion" + or suggestion.get("parent_revision_id") != base_revision_id + ): + raise ValueError("AI 建议与当前字幕版本不匹配") + selected = {str(value) for value in cue_ids if str(value)} + if not selected: + raise ValueError("请至少选择一条 AI 建议") + suggested_text = { + str(cue.get("source_cue_id") or ""): str(cue.get("text") or "") + for cue in suggestion["cues"] + } + base_cues = _fetch_all_revision_cues(base_revision_id) + known_ids = {str(cue["id"]) for cue in base_cues} + if not selected <= known_ids: + raise ValueError("选中的 AI 建议不属于当前字幕版本") + merged = [] + for cue in base_cues: + cue_id = str(cue["id"]) + merged.append( + { + **cue, + "text": suggested_text.get(cue_id, cue["text"]) if cue_id in selected else cue["text"], + } + ) + return create_manual_revision( + track_id, + base_revision_id=base_revision_id, + cues=merged, + note=f"接受 {len(selected)} 条 AI 字幕建议", + ) + + def import_subtitle_text( track_id: str, *, diff --git a/app/services/subtitle_workflow_service.py b/app/services/subtitle_workflow_service.py index d604c2f..c70972a 100644 --- a/app/services/subtitle_workflow_service.py +++ b/app/services/subtitle_workflow_service.py @@ -6,10 +6,13 @@ import json import shutil import subprocess +from functools import lru_cache from pathlib import Path from typing import Any from uuid import uuid4 +from app.core.config import settings +from app.services.managed_process_service import popen_process_group, terminate_process_tree from app.services.storage_service import get_artifact_paths, resolve_video_file_path # ---------- 字幕字体常量 ---------- @@ -27,9 +30,11 @@ SUBTITLE_STATUS_LABELS = { "pending": "待加字幕", + "queued": "字幕排队中", "processing": "字幕生成中", "completed": "已加字幕", "failed": "字幕失败", + "cancelled": "字幕已取消", } DEFAULT_SPEAKER_STYLES = { "主播": {"font_color": "#ffffff"}, @@ -186,6 +191,7 @@ def _create_subtitle_job( error_message: str = "", is_active: int = 0, revision_id: str | None = None, + workflow_job_id: str | None = None, ) -> dict: """创建新的字幕任务记录(不再 upsert,每次生成都创建新记录)""" from app.db.database import get_connection @@ -197,17 +203,18 @@ def _create_subtitle_job( connection.execute( """ INSERT INTO subtitle_jobs ( - id, task_id, output_clip_id, revision_id, style_preset_id, status, + id, task_id, output_clip_id, revision_id, workflow_job_id, style_preset_id, status, subtitle_file_path, output_file_path, error_message, is_active, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( job_id, task_id, output_clip_id, revision_id, + workflow_job_id, "default", status, subtitle_file_path, @@ -220,7 +227,7 @@ def _create_subtitle_job( ) connection.commit() return {"id": job_id, "task_id": task_id, "output_clip_id": output_clip_id, - "revision_id": revision_id, "status": status, + "revision_id": revision_id, "workflow_job_id": workflow_job_id, "status": status, "subtitle_file_path": subtitle_file_path, "output_file_path": output_file_path, "error_message": error_message, "is_active": is_active} @@ -331,11 +338,14 @@ def _write_ass_file( paths = get_artifact_paths(task_id) paths["subtitled_dir"].mkdir(parents=True, exist_ok=True) - subtitle_path = paths["subtitled_dir"] / f"{Path(output_clip.get('output_file_name') or output_clip['id']).stem}.ass" track = ensure_clip_track(task_id, output_clip["id"]) selected_revision_id = revision_id or track.get("active_revision_id") if not selected_revision_id: raise ValueError("切片字幕轨没有可渲染的 revision") + subtitle_path = paths["subtitled_dir"] / ( + f"{Path(output_clip.get('output_file_name') or output_clip['id']).stem}" + f"_{selected_revision_id[:10]}.ass" + ) content = serialize_revision_to_ass(track["id"], selected_revision_id) subtitle_path.write_text(content, encoding="utf-8") return subtitle_path @@ -355,7 +365,20 @@ def _ffmpeg_subtitles_filter(subtitle_path: Path) -> str: # ---------- 字幕烧录入口 ---------- -def render_subtitles_for_output_clip(task_id: str, output_clip_id: str) -> dict: + +class SubtitleRenderCancelled(RuntimeError): + pass + + +def render_subtitles_for_output_clip( + task_id: str, + output_clip_id: str, + *, + revision_id: str | None = None, + workflow_job_id: str | None = None, + progress_start: int = 5, + progress_end: int = 95, +) -> dict: from app.services.task_log_service import append_task_log from app.services.task_service import get_output_clip, get_task # noqa: F811 @@ -371,16 +394,24 @@ def render_subtitles_for_output_clip(task_id: str, output_clip_id: str) -> dict: if not shutil.which("ffmpeg"): raise RuntimeError("FFmpeg 不可用,无法生成字幕视频") - from app.services.subtitle_data_service import ensure_clip_track + from app.services.subtitle_data_service import ensure_clip_track, get_revision style = get_default_subtitle_style() track = ensure_clip_track(task_id, output_clip_id) - revision_id = track.get("active_revision_id") - if not revision_id: + selected_revision_id = revision_id or track.get("active_revision_id") + if not selected_revision_id: raise ValueError("切片字幕轨没有可渲染的 revision") + revision = get_revision(selected_revision_id) + if revision["track_id"] != track["id"]: + raise ValueError("待渲染 revision 不属于当前切片字幕轨") + if revision.get("status") != "approved": + raise ValueError("只有已审核的字幕 revision 才能烧录") paths = get_artifact_paths(task_id) paths["subtitled_dir"].mkdir(parents=True, exist_ok=True) - output_path = paths["subtitled_dir"] / f"{input_path.stem}_subtitled.mp4" + render_token = uuid4().hex[:10] + output_path = paths["subtitled_dir"] / f"{input_path.stem}_subtitled_{render_token}.mp4" + temp_owner = workflow_job_id or render_token + temporary_path = output_path.with_name(f".{output_path.stem}.{temp_owner}.part.mp4") # === 版本化:创建新的字幕 job,不覆盖旧的 === job = _create_subtitle_job( @@ -388,7 +419,8 @@ def render_subtitles_for_output_clip(task_id: str, output_clip_id: str) -> dict: output_clip_id, "processing", is_active=0, - revision_id=revision_id, + revision_id=selected_revision_id, + workflow_job_id=workflow_job_id, ) append_task_log(task_id, f"开始自动加字幕:{input_path.name}") @@ -397,23 +429,33 @@ def render_subtitles_for_output_clip(task_id: str, output_clip_id: str) -> dict: task_id, output_clip, style, - revision_id=revision_id, + revision_id=selected_revision_id, ) - command = [ - "ffmpeg", - "-y", - "-i", - str(input_path), - "-vf", - _ffmpeg_subtitles_filter(subtitle_path), - "-c:a", - "copy", - str(output_path), - ] - result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace") - if result.returncode != 0: - raise RuntimeError(result.stderr.strip() or "FFmpeg 字幕生成失败") + source_probe = _probe_media(input_path) + encoder, audio_mode = _render_with_fallback( + input_path, + subtitle_path, + temporary_path, + workflow_job_id=workflow_job_id, + duration_seconds=float(source_probe.get("duration") or 0), + has_audio=bool(source_probe.get("has_audio")), + source_audio_codec=str(source_probe.get("audio_codec") or ""), + progress_start=progress_start, + progress_end=progress_end, + ) + validation = _validate_rendered_media( + temporary_path, + source_duration=float(source_probe.get("duration") or 0), + source_has_audio=bool(source_probe.get("has_audio")), + ) + temporary_path.replace(output_path) + except SubtitleRenderCancelled as exc: + temporary_path.unlink(missing_ok=True) + _update_subtitle_job_status(job["id"], "cancelled", error_message=str(exc)) + append_task_log(task_id, f"字幕烧录已取消:{input_path.name}") + raise except Exception as exc: + temporary_path.unlink(missing_ok=True) error = str(exc) # 失败时:标记当前 job 为 failed,不激活,旧字幕保持 active _update_subtitle_job_status(job["id"], "failed", error_message=error) @@ -421,16 +463,29 @@ def render_subtitles_for_output_clip(task_id: str, output_clip_id: str) -> dict: raise # 成功:更新 job 信息并切换为 active - _update_subtitle_job_status(job["id"], "completed") - # 用 subtitle_file_path 和 output_file_path 更新记录 + # 验证成功后才写入最终路径并激活,失败时旧 active 字幕成片保持不变。 from app.db.database import get_connection from app.services.task_service import _now_iso now = _now_iso() with get_connection() as connection: connection.execute( - "UPDATE subtitle_jobs SET subtitle_file_path = ?, output_file_path = ?, updated_at = ? WHERE id = ?", - (str(subtitle_path), str(output_path), now, job["id"]), + """ + UPDATE subtitle_jobs + SET status = 'completed', subtitle_file_path = ?, output_file_path = ?, + error_message = '', validation_status = 'verified', validation_json = ?, + encoder = ?, verified_at = ?, updated_at = ? + WHERE id = ? + """, + ( + str(subtitle_path), + str(output_path), + json.dumps({**validation, "audio_mode": audio_mode}, ensure_ascii=False), + encoder, + now, + now, + job["id"], + ), ) connection.commit() @@ -446,3 +501,209 @@ def render_subtitles_for_output_clip(task_id: str, output_clip_id: str) -> dict: "output_clip": get_output_clip(task_id, output_clip_id), "media_url": f"/media/tasks/{task_id}/subtitled-clips/{output_clip_id}", } + + +def _probe_media(path: Path) -> dict[str, Any]: + ffprobe = shutil.which("ffprobe") + if not ffprobe: + raise RuntimeError("FFprobe 不可用,无法验证字幕成片") + try: + result = subprocess.run( + [ + ffprobe, + "-v", + "error", + "-show_streams", + "-show_format", + "-of", + "json", + str(path), + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=settings.ffprobe_timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError("FFprobe 验证字幕视频超时") from exc + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "FFprobe 无法读取字幕视频") + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError("FFprobe 返回了无效 JSON") from exc + streams = payload.get("streams") or [] + video = next((item for item in streams if item.get("codec_type") == "video"), {}) + audio = next((item for item in streams if item.get("codec_type") == "audio"), {}) + duration = (payload.get("format") or {}).get("duration") or video.get("duration") or 0 + return { + "duration": float(duration or 0), + "video_codec": str(video.get("codec_name") or ""), + "pixel_format": str(video.get("pix_fmt") or ""), + "has_audio": bool(audio), + "audio_codec": str(audio.get("codec_name") or ""), + } + + +@lru_cache(maxsize=8) +def _ffmpeg_has_encoder(name: str) -> bool: + try: + result = subprocess.run( + ["ffmpeg", "-hide_banner", "-encoders"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=20, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 and name in result.stdout + + +def _render_with_fallback( + input_path: Path, + subtitle_path: Path, + temporary_path: Path, + *, + workflow_job_id: str | None, + duration_seconds: float, + has_audio: bool, + source_audio_codec: str, + progress_start: int, + progress_end: int, +) -> tuple[str, str]: + copy_safe_codecs = {"aac", "mp3", "ac3", "eac3", "alac"} + preferred_audio = "copy" if not has_audio or source_audio_codec in copy_safe_codecs else "aac" + attempts: list[tuple[str, str]] = [] + if _ffmpeg_has_encoder("h264_nvenc"): + attempts.append(("h264_nvenc", preferred_audio)) + attempts.append(("libx264", preferred_audio)) + if preferred_audio == "copy" and has_audio: + attempts.append(("libx264", "aac")) + errors = [] + for encoder, audio_mode in attempts: + temporary_path.unlink(missing_ok=True) + command = _build_ffmpeg_render_command( + input_path, + subtitle_path, + temporary_path, + encoder=encoder, + audio_mode=audio_mode, + ) + try: + _run_ffmpeg_progress( + command, + workflow_job_id=workflow_job_id, + duration_seconds=duration_seconds, + progress_start=progress_start, + progress_end=progress_end, + ) + return encoder, audio_mode + except SubtitleRenderCancelled: + raise + except RuntimeError as exc: + errors.append(f"{encoder}/{audio_mode}:{exc}") + raise RuntimeError(";".join(errors) or "FFmpeg 字幕烧录失败") + + +def _build_ffmpeg_render_command( + input_path: Path, + subtitle_path: Path, + output_path: Path, + *, + encoder: str, + audio_mode: str, +) -> list[str]: + video_args = ( + ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "23"] + if encoder == "h264_nvenc" + else ["-c:v", "libx264", "-preset", "medium", "-crf", "20"] + ) + audio_args = ["-c:a", "copy"] if audio_mode == "copy" else ["-c:a", "aac", "-b:a", "192k"] + return [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-i", str(input_path), + "-map", "0:v:0", "-map", "0:a:0?", + "-vf", _ffmpeg_subtitles_filter(subtitle_path), + *video_args, "-pix_fmt", "yuv420p", *audio_args, + "-movflags", "+faststart", "-progress", "pipe:1", "-nostats", + str(output_path), + ] + + +def _run_ffmpeg_progress( + command: list[str], + *, + workflow_job_id: str | None, + duration_seconds: float, + progress_start: int, + progress_end: int, +) -> None: + from app.services import job_service + + process = popen_process_group( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + output_tail: list[str] = [] + assert process.stdout is not None + try: + for raw_line in process.stdout: + line = raw_line.strip() + if line: + output_tail.append(line) + output_tail = output_tail[-40:] + if workflow_job_id and job_service.is_cancel_requested(workflow_job_id): + terminate_process_tree(process) + raise SubtitleRenderCancelled("用户已取消字幕烧录") + if workflow_job_id and line.startswith("out_time_ms=") and duration_seconds > 0: + try: + processed_seconds = int(line.split("=", 1)[1]) / 1_000_000 + except ValueError: + continue + ratio = max(0.0, min(1.0, processed_seconds / duration_seconds)) + progress = round(progress_start + (progress_end - progress_start) * ratio) + job_service.update_job_progress(workflow_job_id, progress, "正在烧录并验证字幕成片") + return_code = process.wait(timeout=10) + finally: + if process.poll() is None: + terminate_process_tree(process) + if return_code != 0: + raise RuntimeError("\n".join(output_tail[-8:]) or f"FFmpeg 退出码 {return_code}") + + +def _validate_rendered_media( + path: Path, + *, + source_duration: float, + source_has_audio: bool, +) -> dict[str, Any]: + if not path.exists() or path.stat().st_size <= 0: + raise RuntimeError("字幕临时输出为空") + probe = _probe_media(path) + if probe["video_codec"] != "h264": + raise RuntimeError(f"字幕成片视频编码不是 H.264:{probe['video_codec'] or '未知'}") + if probe["pixel_format"] != "yuv420p": + raise RuntimeError(f"字幕成片像素格式不是 yuv420p:{probe['pixel_format'] or '未知'}") + if source_has_audio and not probe["has_audio"]: + raise RuntimeError("原切片包含音频,但字幕成片缺少音轨") + tolerance = max(1.5, source_duration * 0.03) + if source_duration > 0 and abs(probe["duration"] - source_duration) > tolerance: + raise RuntimeError("字幕成片时长与原切片不一致") + return { + "video_codec": probe["video_codec"], + "pixel_format": probe["pixel_format"], + "has_audio": probe["has_audio"], + "audio_codec": probe["audio_codec"], + "duration_seconds": probe["duration"], + "size_bytes": path.stat().st_size, + } diff --git a/app/services/task_lifecycle_service.py b/app/services/task_lifecycle_service.py index 1026884..15b6e80 100644 --- a/app/services/task_lifecycle_service.py +++ b/app/services/task_lifecycle_service.py @@ -30,6 +30,7 @@ class TaskDeletionConflictError(RuntimeError): TaskStatus.AI_ANALYZING.value, TaskStatus.CLIP_SELECTING.value, TaskStatus.VIDEO_CUTTING.value, + TaskStatus.SUBTITLE_DRAFTING.value, TaskStatus.METADATA_GENERATING.value, TaskStatus.SCHEDULE_CREATING.value, TaskStatus.PUBLISH_JOB_CREATING.value, diff --git a/app/services/task_query_service.py b/app/services/task_query_service.py index 9cb420d..1b7b824 100644 --- a/app/services/task_query_service.py +++ b/app/services/task_query_service.py @@ -106,13 +106,20 @@ def _batch_all_output_clips(task_ids: list[str]) -> dict[str, list[dict]]: clip_candidates.enabled AS clip_enabled, subtitle_jobs.id AS subtitle_job_id, subtitle_jobs.status AS subtitle_status, + subtitle_jobs.revision_id AS subtitle_revision_id, subtitle_jobs.subtitle_file_path, subtitle_jobs.output_file_path AS subtitled_output_file_path, subtitle_jobs.error_message AS subtitle_error_message, + subtitle_jobs.validation_status AS subtitle_validation_status, + subtitle_jobs.validation_json AS subtitle_validation_json, + subtitle_jobs.encoder AS subtitle_encoder, + subtitle_jobs.verified_at AS subtitle_verified_at, + subtitle_revisions.status AS subtitle_revision_status, subtitle_jobs.updated_at AS subtitle_updated_at FROM output_clip LEFT JOIN clip_candidates ON clip_candidates.id = output_clip.clip_candidate_id - LEFT JOIN subtitle_jobs ON subtitle_jobs.output_clip_id = output_clip.id + LEFT JOIN subtitle_jobs ON subtitle_jobs.output_clip_id = output_clip.id AND subtitle_jobs.is_active = 1 + LEFT JOIN subtitle_revisions ON subtitle_revisions.id = subtitle_jobs.revision_id WHERE output_clip.task_id IN ({placeholders}) AND output_clip.is_active = 1 ORDER BY CASE WHEN output_clip.output_file_name IS NULL OR output_clip.output_file_name = '' THEN 1 ELSE 0 END, @@ -148,6 +155,14 @@ def _batch_all_output_clips(task_ids: list[str]) -> dict[str, list[dict]]: "clip_end_seconds": clip_end_seconds, "subtitle_status": subtitle_status, "subtitle_status_label": SUBTITLE_STATUS_LABELS.get(subtitle_status, subtitle_status), + "subtitle_publish_ready": bool( + subtitle_status == "completed" + and output.get("subtitle_validation_status") == "verified" + and output.get("subtitle_revision_status") == "approved" + and subtitled_path + and subtitled_path.exists() + and subtitled_path.is_file() + ), "subtitle_stage": SUBTITLE_STATUS_LABELS.get(subtitle_status, subtitle_status), "subtitled_file_exists": bool(subtitled_path and subtitled_path.exists() and subtitled_path.is_file()), "subtitled_media_url": f"/media/tasks/{output['task_id']}/subtitled-clips/{output['id']}", @@ -177,7 +192,15 @@ def get_dashboard_context() -> dict: for task in tasks if task["status"] in {TaskStatus.pending_video.value, TaskStatus.pending_processing.value} ) - review_count = sum(1 for task in tasks if task["status"] == TaskStatus.pending_review.value) + review_count = sum( + 1 + for task in tasks + if task["status"] + in { + TaskStatus.pending_review.value, + TaskStatus.PENDING_SUBTITLE_REVIEW.value, + } + ) completed_count = sum( 1 for task in tasks @@ -192,7 +215,7 @@ def get_dashboard_context() -> dict: "stats": [ {"label": "今日新增任务", "value": today_count, "note": "来自 SQLite", "tone": "blue"}, {"label": "待处理", "value": pending_count, "note": "可继续推进", "tone": "amber"}, - {"label": "待检查", "value": review_count, "note": "AI 结果可生成切片", "tone": "purple"}, + {"label": "待检查", "value": review_count, "note": "包含选片和字幕审核", "tone": "purple"}, {"label": "已切片任务", "value": completed_count, "note": f"输出 {output_clip_count} 条切片", "tone": "green"}, {"label": "待加字幕", "value": ready_for_subtitle_count, "note": "切片后工作流", "tone": "blue"}, {"label": "待推送", "value": ready_for_subtitle_count, "note": "需字幕和发布确认", "tone": "red"}, diff --git a/app/services/task_service.py b/app/services/task_service.py index 8e3b6fe..caf6fb3 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -113,6 +113,7 @@ "AI 分析", "自动选片", "原片切割", + "字幕审核", "标题文案", "发送队列", "发布任务", @@ -127,6 +128,8 @@ TaskStatus.AI_ANALYZING.value: "AI 分析中", TaskStatus.CLIP_SELECTING.value: "自动选片中", TaskStatus.VIDEO_CUTTING.value: "原片切割中", + TaskStatus.SUBTITLE_DRAFTING.value: "生成字幕草稿中", + TaskStatus.PENDING_SUBTITLE_REVIEW.value: "字幕待审核", TaskStatus.METADATA_GENERATING.value: "生成标题文案中", TaskStatus.SCHEDULE_CREATING.value: "准备发送队列中", TaskStatus.PUBLISH_JOB_CREATING.value: "创建发布任务中", @@ -137,6 +140,7 @@ TaskStatus.FAILED_AI_ANALYZING.value: "AI 分析失败", TaskStatus.FAILED_CLIP_SELECTING.value: "自动选片失败", TaskStatus.FAILED_VIDEO_CUTTING.value: "原片切割失败", + TaskStatus.FAILED_SUBTITLE_DRAFTING.value: "字幕草稿生成失败", TaskStatus.FAILED_METADATA_GENERATING.value: "标题文案生成失败", TaskStatus.FAILED_SCHEDULE_CREATING.value: "发送队列准备失败", TaskStatus.FAILED_PUBLISH_JOB_CREATING.value: "发布任务创建失败", @@ -160,9 +164,11 @@ TaskStatus.AI_ANALYZING.value: 45, TaskStatus.CLIP_SELECTING.value: 58, TaskStatus.VIDEO_CUTTING.value: 70, - TaskStatus.METADATA_GENERATING.value: 80, - TaskStatus.SCHEDULE_CREATING.value: 88, - TaskStatus.PUBLISH_JOB_CREATING.value: 94, + TaskStatus.SUBTITLE_DRAFTING.value: 75, + TaskStatus.PENDING_SUBTITLE_REVIEW.value: 78, + TaskStatus.METADATA_GENERATING.value: 84, + TaskStatus.SCHEDULE_CREATING.value: 90, + TaskStatus.PUBLISH_JOB_CREATING.value: 95, TaskStatus.READY_TO_PUBLISH.value: 100, TaskStatus.COMPLETED.value: 100, TaskStatus.FAILED_PREPARING_SOURCE.value: 8, @@ -170,9 +176,10 @@ TaskStatus.FAILED_AI_ANALYZING.value: 45, TaskStatus.FAILED_CLIP_SELECTING.value: 58, TaskStatus.FAILED_VIDEO_CUTTING.value: 70, - TaskStatus.FAILED_METADATA_GENERATING.value: 80, - TaskStatus.FAILED_SCHEDULE_CREATING.value: 88, - TaskStatus.FAILED_PUBLISH_JOB_CREATING.value: 94, + TaskStatus.FAILED_SUBTITLE_DRAFTING.value: 75, + TaskStatus.FAILED_METADATA_GENERATING.value: 84, + TaskStatus.FAILED_SCHEDULE_CREATING.value: 90, + TaskStatus.FAILED_PUBLISH_JOB_CREATING.value: 95, TaskStatus.pending_video.value: 0, TaskStatus.pending_processing.value: 5, TaskStatus.audio_extracting.value: 20, @@ -193,6 +200,7 @@ TaskStatus.AI_ANALYZING.value, TaskStatus.CLIP_SELECTING.value, TaskStatus.VIDEO_CUTTING.value, + TaskStatus.SUBTITLE_DRAFTING.value, TaskStatus.METADATA_GENERATING.value, TaskStatus.SCHEDULE_CREATING.value, TaskStatus.PUBLISH_JOB_CREATING.value, @@ -207,6 +215,7 @@ TaskStatus.FAILED_AI_ANALYZING.value, TaskStatus.FAILED_CLIP_SELECTING.value, TaskStatus.FAILED_VIDEO_CUTTING.value, + TaskStatus.FAILED_SUBTITLE_DRAFTING.value, TaskStatus.FAILED_METADATA_GENERATING.value, TaskStatus.FAILED_SCHEDULE_CREATING.value, TaskStatus.FAILED_PUBLISH_JOB_CREATING.value, @@ -254,22 +263,25 @@ def get_task_workflow_steps(task: dict) -> list[dict[str, str]]: TaskStatus.AI_ANALYZING.value: 4, TaskStatus.CLIP_SELECTING.value: 5, TaskStatus.VIDEO_CUTTING.value: 6, - TaskStatus.METADATA_GENERATING.value: 7, - TaskStatus.SCHEDULE_CREATING.value: 8, - TaskStatus.PUBLISH_JOB_CREATING.value: 9, - TaskStatus.READY_TO_PUBLISH.value: 10, - TaskStatus.COMPLETED.value: 10, + TaskStatus.SUBTITLE_DRAFTING.value: 7, + TaskStatus.PENDING_SUBTITLE_REVIEW.value: 7, + TaskStatus.METADATA_GENERATING.value: 8, + TaskStatus.SCHEDULE_CREATING.value: 9, + TaskStatus.PUBLISH_JOB_CREATING.value: 10, + TaskStatus.READY_TO_PUBLISH.value: 11, + TaskStatus.COMPLETED.value: 11, TaskStatus.FAILED_PREPARING_SOURCE.value: 2, TaskStatus.FAILED_TRANSCRIBING.value: 3, TaskStatus.FAILED_AI_ANALYZING.value: 4, TaskStatus.FAILED_CLIP_SELECTING.value: 5, TaskStatus.FAILED_VIDEO_CUTTING.value: 6, - TaskStatus.FAILED_METADATA_GENERATING.value: 7, - TaskStatus.FAILED_SCHEDULE_CREATING.value: 8, - TaskStatus.FAILED_PUBLISH_JOB_CREATING.value: 9, + TaskStatus.FAILED_SUBTITLE_DRAFTING.value: 7, + TaskStatus.FAILED_METADATA_GENERATING.value: 8, + TaskStatus.FAILED_SCHEDULE_CREATING.value: 9, + TaskStatus.FAILED_PUBLISH_JOB_CREATING.value: 10, TaskStatus.pending_review.value: 5, - TaskStatus.completed.value: 10, - TaskStatus.completed_with_errors.value: 10, + TaskStatus.completed.value: 11, + TaskStatus.completed_with_errors.value: 11, TaskStatus.failed.value: 1, } failed_statuses = { @@ -278,6 +290,7 @@ def get_task_workflow_steps(task: dict) -> list[dict[str, str]]: TaskStatus.FAILED_AI_ANALYZING.value, TaskStatus.FAILED_CLIP_SELECTING.value, TaskStatus.FAILED_VIDEO_CUTTING.value, + TaskStatus.FAILED_SUBTITLE_DRAFTING.value, TaskStatus.FAILED_METADATA_GENERATING.value, TaskStatus.FAILED_SCHEDULE_CREATING.value, TaskStatus.FAILED_PUBLISH_JOB_CREATING.value, @@ -606,6 +619,8 @@ def get_task_live_status(task_id: str) -> dict: if auto_mode: if status in AUTO_PIPELINE_FAILED_STATUSES: primary_action = "retry" + elif status == TaskStatus.PENDING_SUBTITLE_REVIEW.value: + primary_action = "subtitle_review" elif status in { TaskStatus.READY_TO_PUBLISH.value, TaskStatus.COMPLETED.value, @@ -927,6 +942,13 @@ def sync_reviewed_clips_to_publish_center( task_id: str, payloads: list[ClipCandidateBatchItem], ) -> dict: + with get_connection() as connection: + task = connection.execute( + "SELECT auto_mode, status FROM tasks WHERE id = ? AND COALESCE(is_deleted, 0) = 0", + (task_id,), + ).fetchone() + if task and bool(task["auto_mode"]) and task["status"] == TaskStatus.PENDING_SUBTITLE_REVIEW.value: + raise ValueError("自动流水线正在等待字幕审核,请先完成字幕决定,再同步发送中心") save_result = update_clip_candidates_batch(task_id, payloads) needs_regeneration = bool(save_result["changed_count"]) or not _active_outputs_match_enabled_candidates(task_id) @@ -1070,13 +1092,20 @@ def list_output_clips(task_id: str) -> list[dict]: clip_candidates.enabled AS clip_enabled, subtitle_jobs.id AS subtitle_job_id, subtitle_jobs.status AS subtitle_status, + subtitle_jobs.revision_id AS subtitle_revision_id, subtitle_jobs.subtitle_file_path, subtitle_jobs.output_file_path AS subtitled_output_file_path, subtitle_jobs.error_message AS subtitle_error_message, + subtitle_jobs.validation_status AS subtitle_validation_status, + subtitle_jobs.validation_json AS subtitle_validation_json, + subtitle_jobs.encoder AS subtitle_encoder, + subtitle_jobs.verified_at AS subtitle_verified_at, + subtitle_revisions.status AS subtitle_revision_status, subtitle_jobs.updated_at AS subtitle_updated_at FROM output_clip LEFT JOIN clip_candidates ON clip_candidates.id = output_clip.clip_candidate_id LEFT JOIN subtitle_jobs ON subtitle_jobs.output_clip_id = output_clip.id AND subtitle_jobs.is_active = 1 + LEFT JOIN subtitle_revisions ON subtitle_revisions.id = subtitle_jobs.revision_id WHERE output_clip.task_id = ? AND output_clip.is_active = 1 ORDER BY CASE WHEN output_clip.output_file_name IS NULL OR output_clip.output_file_name = '' THEN 1 ELSE 0 END, @@ -1112,6 +1141,14 @@ def list_output_clips(task_id: str) -> list[dict]: "clip_end_seconds": clip_end_seconds, "subtitle_status": subtitle_status, "subtitle_status_label": SUBTITLE_STATUS_LABELS.get(subtitle_status, subtitle_status), + "subtitle_publish_ready": bool( + subtitle_status == "completed" + and output.get("subtitle_validation_status") == "verified" + and output.get("subtitle_revision_status") == "approved" + and subtitled_path + and subtitled_path.exists() + and subtitled_path.is_file() + ), "subtitle_stage": SUBTITLE_STATUS_LABELS.get(subtitle_status, subtitle_status), "subtitled_file_exists": bool(subtitled_path and subtitled_path.exists() and subtitled_path.is_file()), "subtitled_media_url": f"/media/tasks/{task_id}/subtitled-clips/{output['id']}", @@ -1138,8 +1175,9 @@ def get_output_clip(task_id: str, output_clip_id: str) -> dict | None: with get_connection() as connection: subtitle_row = connection.execute( """ - SELECT * + SELECT subtitle_jobs.*, subtitle_revisions.status AS subtitle_revision_status FROM subtitle_jobs + LEFT JOIN subtitle_revisions ON subtitle_revisions.id = subtitle_jobs.revision_id WHERE task_id = ? AND output_clip_id = ? AND is_active = 1 """, (task_id, output_clip_id), @@ -1152,6 +1190,12 @@ def get_output_clip(task_id: str, output_clip_id: str) -> dict | None: "subtitle_file_path": subtitle_row["subtitle_file_path"], "subtitled_output_file_path": subtitle_row["output_file_path"], "subtitle_error_message": subtitle_row["error_message"], + "subtitle_revision_id": subtitle_row["revision_id"], + "subtitle_revision_status": subtitle_row["subtitle_revision_status"], + "subtitle_validation_status": subtitle_row["validation_status"], + "subtitle_validation_json": subtitle_row["validation_json"], + "subtitle_encoder": subtitle_row["encoder"], + "subtitle_verified_at": subtitle_row["verified_at"], "subtitle_status_label": SUBTITLE_STATUS_LABELS.get(subtitle_row["status"], subtitle_row["status"]), } ) diff --git a/app/static/css/styles.css b/app/static/css/styles.css index 6b19cdf..afb5b26 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -5957,3 +5957,60 @@ td a, .subtitle-cue-time-inputs { display: none; } .subtitle-cue-issues { grid-column: 2 / -1; } } + +.subtitle-review-gate { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 24px; + align-items: center; + margin-bottom: 24px; + border-color: rgba(0, 122, 255, 0.24); + background: linear-gradient(135deg, rgba(0, 122, 255, 0.08), rgba(255, 255, 255, 0.88)); +} +.subtitle-review-gate h2, +.subtitle-ai-suggestion-panel h3 { margin: 4px 0 8px; } +.subtitle-batch-job { + grid-column: 1 / -1; + padding-top: 16px; + border-top: 1px solid var(--line); +} +.subtitle-batch-job p { margin: 8px 0; } +.subtitle-ai-suggestion-panel { + margin: 14px 0; + padding: 16px; + border: 1px solid rgba(0, 122, 255, 0.2); + border-radius: 18px; + background: rgba(245, 249, 255, 0.92); +} +.subtitle-ai-diff-list { + display: grid; + gap: 8px; + max-height: 360px; + overflow: auto; +} +.subtitle-ai-diff-row { + display: grid; + grid-template-columns: auto 110px minmax(0, 1fr) auto minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; +} +.subtitle-ai-diff-time { + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.subtitle-ai-diff-before { color: #8e4b4b; text-decoration: line-through; } +.subtitle-ai-diff-after { color: #126b42; white-space: pre-wrap; } +.subtitle-ai-diff-arrow { color: var(--muted); } + +@media (max-width: 900px) { + .subtitle-review-gate { grid-template-columns: 1fr; } + .subtitle-ai-diff-row { grid-template-columns: auto 1fr; } + .subtitle-ai-diff-time, + .subtitle-ai-diff-before, + .subtitle-ai-diff-arrow, + .subtitle-ai-diff-after { grid-column: 2; } +} diff --git a/app/static/js/app.js b/app/static/js/app.js index 864f5d1..1fa2359 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1878,7 +1878,11 @@ function renderTaskLiveActions(data) { const reviewAction = taskLiveActions.querySelector("[data-live-review-action]"); if (reviewAction) reviewAction.hidden = !actions.review; const syncAction = taskLiveActions.querySelector("[data-live-sync-action]"); - if (syncAction) syncAction.hidden = Number(data.counts?.outputs || 0) <= 0; + if (syncAction) { + syncAction.hidden = primaryAction === "subtitle_review" || Number(data.counts?.outputs || 0) <= 0; + } + const subtitleSkip = taskLiveActions.querySelector("[data-live-subtitle-skip]"); + if (subtitleSkip) subtitleSkip.hidden = primaryAction !== "subtitle_review"; } function renderTaskLiveStatus(data) { @@ -2021,8 +2025,8 @@ document.querySelectorAll("[data-render-subtitle]").forEach((button) => { const errorNode = card.querySelector("[data-subtitle-error]"); const originalText = button.textContent; button.disabled = true; - button.textContent = "加字幕中..."; - if (statusNode) statusNode.textContent = "字幕生成中"; + button.textContent = "正在入队..."; + if (statusNode) statusNode.textContent = "字幕排队中"; if (errorNode) errorNode.textContent = ""; try { @@ -2031,9 +2035,10 @@ document.querySelectorAll("[data-render-subtitle]").forEach((button) => { if (!response.ok) { throw new Error(data.detail || "自动加字幕失败"); } - if (statusNode) statusNode.textContent = data.output_clip?.subtitle_status_label || "已加字幕"; - if (errorNode) errorNode.textContent = data.message || "自动加字幕完成。"; - window.setTimeout(() => window.location.reload(), 700); + if (errorNode) errorNode.textContent = data.message || "字幕任务已加入队列。"; + button.textContent = "后台烧录中"; + await pollSubtitleWorkflowJob(data.job_id, statusNode, errorNode); + window.location.reload(); } catch (error) { if (statusNode) statusNode.textContent = "字幕失败"; if (errorNode) errorNode.textContent = `自动加字幕失败:${error.message}`; @@ -2044,6 +2049,43 @@ document.querySelectorAll("[data-render-subtitle]").forEach((button) => { }); }); +async function pollSubtitleWorkflowJob(jobId, statusNode, messageNode) { + if (!jobId) throw new Error("后台没有返回字幕 job id"); + while (true) { + const response = await fetch(`/api/tasks/jobs/${encodeURIComponent(jobId)}`); + const job = await response.json(); + if (!response.ok) throw new Error(job.detail || "读取字幕任务状态失败"); + if (statusNode) statusNode.textContent = `${job.status_label || job.status} · ${Number(job.progress || 0)}%`; + if (messageNode) messageNode.textContent = job.message || "字幕任务处理中"; + if (job.status === "completed") return job; + if (job.status === "failed" || job.status === "cancelled") { + throw new Error(job.error_message || job.message || "字幕任务未完成"); + } + await new Promise((resolve) => window.setTimeout(resolve, 1500)); + } +} + +document.querySelectorAll("[data-live-subtitle-skip]").forEach((button) => { + button.addEventListener("click", async () => { + if (!window.confirm("确认跳过字幕吗?后续发送中心将明确使用原片切片。")) return; + const taskId = button.dataset.taskId; + button.disabled = true; + const originalText = button.textContent; + button.textContent = "正在恢复流水线..."; + try { + const data = await apiFetch(`/api/subtitles/tasks/${encodeURIComponent(taskId)}/skip-and-resume`, { + method: "POST", + }); + if (taskLiveNote) taskLiveNote.textContent = data.message || "已跳过字幕并恢复流水线"; + startTaskLiveStatusPolling(true); + } catch (error) { + window.alert(`跳过字幕失败:${summarizeErrorMessage(error.message)}`); + button.disabled = false; + button.textContent = originalText; + } + }); +}); + const cutEditModal = document.querySelector("#cut-edit-modal"); const closeCutEditButton = document.querySelector("#close-cut-edit"); const cutEditVideo = document.querySelector("#cut-edit-video"); diff --git a/app/static/js/subtitle-editor.js b/app/static/js/subtitle-editor.js index 6a4bfd5..0caffc8 100644 --- a/app/static/js/subtitle-editor.js +++ b/app/static/js/subtitle-editor.js @@ -28,12 +28,19 @@ shift: root.querySelector("#subtitle-shift"), undo: root.querySelector("#subtitle-undo"), redo: root.querySelector("#subtitle-redo"), + aiSuggest: root.querySelector("#subtitle-ai-suggest"), save: root.querySelector("#subtitle-save-now"), importFile: root.querySelector("#subtitle-import-file"), exports: ["srt", "vtt", "ass"].map((format) => [ format, root.querySelector(`#subtitle-export-${format}`), ]), + aiPanel: root.querySelector("#subtitle-ai-suggestion-panel"), + aiSummary: root.querySelector("#subtitle-ai-suggestion-summary"), + aiDiffList: root.querySelector("#subtitle-ai-diff-list"), + aiSelectAll: root.querySelector("#subtitle-ai-select-all"), + aiAccept: root.querySelector("#subtitle-ai-accept"), + aiClose: root.querySelector("#subtitle-ai-close"), }; const ROW_HEIGHT = 132; @@ -57,6 +64,21 @@ dirty: false, changeVersion: 0, requestToken: 0, + suggestion: null, + }; + + const batch = { + root: document.querySelector("#subtitle-review-gate"), + approve: document.querySelector("#subtitle-batch-approve-render"), + skip: document.querySelector("#subtitle-skip-and-resume"), + panel: document.querySelector("#subtitle-batch-job"), + bar: document.querySelector("#subtitle-batch-progress-bar"), + label: document.querySelector("#subtitle-batch-progress-label"), + message: document.querySelector("#subtitle-batch-message"), + cancel: document.querySelector("#subtitle-batch-cancel"), + retry: document.querySelector("#subtitle-batch-retry"), + jobId: "", + pollTimer: null, }; function setStatus(message, tone = "blue") { @@ -93,6 +115,7 @@ state.cues = snapshot.map((cue) => ({ ...cue })); state.selectedIds.clear(); state.currentCueId = null; + updateAiButton(); markChanged(); applySearch(); updateCurrentCue(); @@ -173,6 +196,7 @@ } function renderVirtualRows() { + updateAiButton(); if (!state.visibleIndices.length) { elements.viewport.innerHTML = '
没有匹配的字幕行
'; return; @@ -215,6 +239,7 @@ row.querySelector("[data-cue-select]").addEventListener("change", (event) => { if (event.target.checked) state.selectedIds.add(cue.id); else state.selectedIds.delete(cue.id); + updateAiButton(); renderVirtualRows(); }); row.querySelector("[data-cue-seek]").addEventListener("click", () => selectCue(cue, true)); @@ -354,6 +379,7 @@ state.revision = first.revision; state.cues = cues; state.selectedIds.clear(); + state.suggestion = null; state.currentCueId = null; state.undo = []; state.redo = []; @@ -362,6 +388,7 @@ elements.track.value = trackId; elements.video.src = state.track.media_url; elements.approve.disabled = !state.revision; + updateAiButton(); elements.save.disabled = true; elements.exports.forEach(([, button]) => { button.disabled = !state.revision; }); renderRevisionMeta(); @@ -448,6 +475,7 @@ state.cues = (payload.revision.cues || []).map((cue) => ({ ...cue })); state.dirty = false; state.selectedIds.clear(); + updateAiButton(); elements.save.disabled = true; applySearch(); setStatus(`Revision ${state.revision.revision_number} 已保存`, "green"); @@ -470,6 +498,11 @@ return state.cues.filter((cue) => state.selectedIds.has(cue.id)); } + function updateAiButton() { + if (!elements.aiSuggest) return; + elements.aiSuggest.disabled = !state.revision || state.selectedIds.size === 0 || state.selectedIds.size > 500; + } + elements.track.addEventListener("change", () => loadTrack(elements.track.value)); elements.list.addEventListener("scroll", renderVirtualRows, { passive: true }); elements.video.addEventListener("timeupdate", updateCurrentCue); @@ -500,6 +533,7 @@ if (!state.selectedIds.size) return setStatus("请先勾选要删除的字幕行", "amber"); mutate(() => { state.cues = state.cues.filter((cue) => !state.selectedIds.has(cue.id)); }); state.selectedIds.clear(); + updateAiButton(); }); elements.merge.addEventListener("click", () => { @@ -566,6 +600,81 @@ } }); + elements.aiSuggest?.addEventListener("click", async () => { + if (state.dirty) await saveRevision(false); + const cueIds = [...state.selectedIds]; + if (state.dirty || !state.revision || !cueIds.length) return; + elements.aiSuggest.disabled = true; + setStatus(`正在分批生成 ${cueIds.length} 条 AI 建议…`, "blue"); + try { + const payload = await api(`/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/ai-suggestions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ revision_id: state.revision.id, cue_ids: cueIds, instructions: "" }), + }); + state.suggestion = payload; + renderAiSuggestionDiff(); + setStatus(`AI 返回 ${payload.diff?.length || 0} 条文字差异,尚未应用`, "green"); + } catch (error) { + setStatus(`AI 建议失败:${error.message}`, "red"); + } finally { + updateAiButton(); + } + }); + + function renderAiSuggestionDiff() { + const diff = state.suggestion?.diff || []; + elements.aiPanel.hidden = false; + elements.aiSummary.textContent = diff.length + ? `共 ${diff.length} 条文字差异。建议版本不会覆盖当前人工版本,请勾选后接受。` + : "AI 没有建议修改;当前字幕保持不变。"; + elements.aiDiffList.innerHTML = diff.length + ? diff.map((item) => ` + + `).join("") + : '

没有差异可接受。

'; + elements.aiAccept.disabled = diff.length === 0; + } + + elements.aiSelectAll?.addEventListener("click", () => { + elements.aiDiffList.querySelectorAll("[data-ai-diff-cue]").forEach((input) => { input.checked = true; }); + }); + + elements.aiClose?.addEventListener("click", () => { + elements.aiPanel.hidden = true; + state.suggestion = null; + }); + + elements.aiAccept?.addEventListener("click", async () => { + if (!state.suggestion || !state.revision) return; + const cueIds = [...elements.aiDiffList.querySelectorAll("[data-ai-diff-cue]:checked")].map((input) => input.value); + if (!cueIds.length) return setStatus("请至少勾选一条 AI 文字建议", "amber"); + elements.aiAccept.disabled = true; + try { + const payload = await api( + `/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/ai-suggestions/${encodeURIComponent(state.suggestion.revision.id)}/accept`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ base_revision_id: state.suggestion.base_revision_id, cue_ids: cueIds }), + }, + ); + state.suggestion = null; + elements.aiPanel.hidden = true; + await loadTrack(state.track.id); + setStatus(`已接受 ${cueIds.length} 条建议并创建人工草稿 Revision ${payload.revision.revision_number}`, "green"); + } catch (error) { + setStatus(`接受 AI 建议失败:${error.message}`, "red"); + elements.aiAccept.disabled = false; + } + }); + elements.importFile.addEventListener("change", async () => { const file = elements.importFile.files?.[0]; if (!file || !state.track) return; @@ -587,6 +696,101 @@ window.location.href = `/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/export?format_name=${format}&revision_id=${encodeURIComponent(state.revision.id)}`; })); + function renderBatchJob(job) { + if (!batch.panel || !job) return; + batch.panel.hidden = false; + batch.jobId = job.id; + const progress = Math.max(0, Math.min(100, Number(job.progress || 0))); + batch.bar.style.width = `${progress}%`; + batch.label.textContent = `${job.status_label || job.status} · ${progress}%`; + batch.message.textContent = job.error_message || job.message || "字幕任务状态已更新"; + const active = job.status === "queued" || job.status === "running"; + batch.cancel.hidden = !active; + batch.retry.hidden = !(job.status === "failed" || job.status === "cancelled"); + batch.approve.disabled = active; + batch.skip.disabled = active; + } + + async function pollBatchJob(jobId) { + window.clearTimeout(batch.pollTimer); + try { + const job = await api(`/api/tasks/jobs/${encodeURIComponent(jobId)}`); + renderBatchJob(job); + if (job.status === "queued" || job.status === "running") { + batch.pollTimer = window.setTimeout(() => pollBatchJob(jobId), 1500); + } else if (job.status === "completed") { + batch.message.textContent = "字幕成片全部验证通过,自动流水线已恢复。即将返回任务详情。"; + window.setTimeout(() => { window.location.href = `/tasks/${encodeURIComponent(state.taskId)}`; }, 1600); + } + } catch (error) { + batch.message.textContent = `读取字幕 Job 失败:${error.message}`; + batch.pollTimer = window.setTimeout(() => pollBatchJob(jobId), 3000); + } + } + + batch.approve?.addEventListener("click", async () => { + if (state.dirty) await saveRevision(false); + if (state.dirty) return; + if (!window.confirm("确认审核所有切片的当前字幕版本并批量烧录吗?全部验证通过后流水线会自动继续。")) return; + batch.approve.disabled = true; + batch.skip.disabled = true; + try { + const payload = await api(`/api/subtitles/tasks/${encodeURIComponent(state.taskId)}/approve-and-render`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approve_active_revisions: true }), + }); + renderBatchJob(payload.job); + pollBatchJob(payload.job_id); + } catch (error) { + batch.message.textContent = `批量烧录未启动:${error.message}`; + batch.panel.hidden = false; + batch.approve.disabled = false; + batch.skip.disabled = false; + } + }); + + batch.skip?.addEventListener("click", async () => { + if (!window.confirm("确认跳过字幕并使用原片继续吗?该选择会写入发布任务,不能静默改回字幕版。")) return; + batch.skip.disabled = true; + try { + await api(`/api/subtitles/tasks/${encodeURIComponent(state.taskId)}/skip-and-resume`, { method: "POST" }); + window.location.href = `/tasks/${encodeURIComponent(state.taskId)}`; + } catch (error) { + batch.message.textContent = `跳过字幕失败:${error.message}`; + batch.panel.hidden = false; + batch.skip.disabled = false; + } + }); + + batch.cancel?.addEventListener("click", async () => { + if (!batch.jobId || !window.confirm("确认取消当前字幕烧录吗?已完成并验证的切片会保留,可稍后重试缺失部分。")) return; + const payload = await api(`/api/tasks/jobs/${encodeURIComponent(batch.jobId)}/cancel`, { method: "POST" }); + renderBatchJob(payload.job); + pollBatchJob(batch.jobId); + }); + + batch.retry?.addEventListener("click", async () => { + if (!batch.jobId) return; + const payload = await api(`/api/tasks/jobs/${encodeURIComponent(batch.jobId)}/retry`, { method: "POST" }); + renderBatchJob(payload.job); + pollBatchJob(batch.jobId); + }); + + async function loadLatestBatchJob() { + if (!batch.root) return; + try { + const payload = await api(`/api/subtitles/tasks/${encodeURIComponent(state.taskId)}/jobs`); + const latest = (payload.jobs || [])[0]; + if (latest) { + renderBatchJob(latest); + if (latest.status === "queued" || latest.status === "running") pollBatchJob(latest.id); + } + } catch (_error) { + // 页面仍可编辑字幕;Job 状态读取失败时由用户再次点击触发明确错误。 + } + } + window.addEventListener("beforeunload", (event) => { if (!state.dirty) return; event.preventDefault(); @@ -594,4 +798,5 @@ }); loadTracks(); + loadLatestBatchJob(); })(); diff --git a/app/templates/subtitle_workflow.html b/app/templates/subtitle_workflow.html index b79a790..7ca0e38 100644 --- a/app/templates/subtitle_workflow.html +++ b/app/templates/subtitle_workflow.html @@ -16,7 +16,9 @@

{{ task.title }} · 字幕工作台

返回字幕任务列表 返回片段审核 {% if output_clips %} + {% if not (task.auto_mode and task.status == "PENDING_SUBTITLE_REVIEW") %} + {% endif %} 查看本任务发送内容 {% endif %} @@ -38,6 +40,29 @@

{{ task.title }} · 字幕工作台

{% endfor %} +{% if task.auto_mode and task.status == "PENDING_SUBTITLE_REVIEW" %} +
+
+

Review Gate

+

自动流水线已在字幕审核处暂停

+

请先检查和编辑字幕。点击“审核并批量烧录”会批准每条切片的当前版本,全部成片验证通过后才恢复发送中心流程。

+
+
+ + +
+ +
+{% endif %} +
@@ -104,9 +129,26 @@

原片主时间轴 · 切片继承编辑器

+
+ +
质量检查会提示重叠、间隔、时长、行数与中文阅读速度,但不会自动改字。
@@ -410,6 +452,6 @@

还没有进入字幕流程的切片

- + {% endif %} {% endblock %} diff --git a/app/templates/task_detail.html b/app/templates/task_detail.html index 192fde3..c834fe9 100644 --- a/app/templates/task_detail.html +++ b/app/templates/task_detail.html @@ -18,10 +18,12 @@

任务详情 · {{ task.title }}

{% if task.auto_mode %} + 前往字幕审核 + 前往发送中心 - + 检查候选片段 - + {% elif task.transcript_exists %} @@ -48,10 +50,10 @@

任务详情 · {{ task.title }}

data-auto-pipeline-monitor data-task-id="{{ task.id }}" data-status="{{ task.status }}" - data-running="{{ 'true' if task.status in ['CREATED', 'PREPARING_SOURCE', 'TRANSCRIBING', 'AI_ANALYZING', 'CLIP_SELECTING', 'VIDEO_CUTTING', 'METADATA_GENERATING', 'SCHEDULE_CREATING', 'PUBLISH_JOB_CREATING'] else 'false' }}" + data-running="{{ 'true' if task.status in ['CREATED', 'PREPARING_SOURCE', 'TRANSCRIBING', 'AI_ANALYZING', 'CLIP_SELECTING', 'VIDEO_CUTTING', 'SUBTITLE_DRAFTING', 'METADATA_GENERATING', 'SCHEDULE_CREATING', 'PUBLISH_JOB_CREATING'] else 'false' }}" > 全自动模式已接管这个任务。 -

系统会自动推进音频提取、转写、AI 选片和视频切割;状态概览与运行日志每 3 秒自动更新,不会刷新整张页面。只有失败或历史任务中断时才需要点击重试/继续。

+

系统会自动推进音频提取、转写、AI 选片和视频切割;切片后会生成字幕草稿并暂停,只有你明确审核烧录或跳过字幕后才会进入发送中心。

{% endif %} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dc8dbb6..94e95ee 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,23 @@ # 系统架构 +## 2026-08-24:字幕审核与交付证据链 + +```text +切片完成 +→ source/clip 字幕草稿 +→ pending_subtitle_review(自动流水线暂停) + ├─ 明确跳过 → delivery_mode=original → 恢复元数据/发送任务 + └─ 审核 revision → workflow_jobs:subtitle → 临时渲染 + → FFprobe 验证 → 原子激活 → delivery_mode=subtitled + → 恢复元数据/发送任务 +``` + +- `subtitle_auto_workflow_service.py` 负责暂停点、交付决定、批量 checkpoint、恢复流水线及父进程异常清理。 +- `job_worker.py` 仍采用单重型 Job 子进程;字幕 Job 与转写/切片共享 lease、heartbeat、取消和人工重试接口。 +- 渲染尝试顺序为可用 `h264_nvenc`、`libx264`,音频可安全复制时优先复制,否则转 AAC;最终统一验证 H.264、`yuv420p`、音轨、时长和文件大小。 +- 发布任务保存 `subtitle_delivery_mode` 及 revision/验证证据。自动字幕来源只有在审核、验证和文件三项同时成立时可进入发送中心。 +- AI 纠错独立于自动流水线:Provider 返回的内容只能映射既有 cue 的文本,不允许改变时间、说话人或未知 cue;建议 revision 必须人工接受。 + ## 2026-08-23:统一字幕架构 ```text @@ -14,7 +32,7 @@ clip subtitle_track → immutable subtitle_revision → SRT/VTT/ASS/编辑器 - 原片轨只保存一条 active track;内容变化产生新 revision。切片轨记录来源 track/revision,未人工编辑时可同步,人工编辑后只进入 `pending_sync`。 - revision 内容不可原地更新;审核只改变 revision 状态,渲染必须固定引用 revision id,避免编辑过程中改变已排队输出。 - `pysubs2` 负责字幕格式与 ASS,不再手写固定分辨率 ASS。`wavesurfer.js` 仅消费服务端 peaks 与媒体流,不读取六小时完整音频到浏览器内存。 -- 当前同步 FFmpeg 烧录仅作为旧 API 兼容;持久化渲染队列、取消、重试、NVENC 回退与发送中心门禁在 PR 4 接入。 +- 单条兼容入口和批量入口都只负责创建持久化字幕 Job;HTTP 请求不再同步等待 FFmpeg。批量入口额外负责全自动流水线恢复。 ## 2026-08-23:长直播选片层 diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index f878e93..40f3192 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -1,5 +1,21 @@ # 数据库结构说明 +## 2026-08-24:字幕自动流水线字段 + +`subtitle_jobs` 在原有不可变 revision 引用上增加: + +| 字段 | 说明 | +| --- | --- | +| `workflow_job_id` | 所属持久化字幕 Job,用于取消、恢复和精确清理临时文件 | +| `validation_status` | `pending / verified`;旧记录默认为未验证,不能自动发布 | +| `validation_json` | FFprobe 的编码、像素格式、音轨、时长和音频处理证据 | +| `encoder` | 实际成功的 `h264_nvenc` 或 `libx264` | +| `verified_at` | 输出验证通过时间 | + +- 全自动字幕交付决定保存在 `tasks.auto_config_json.subtitle_delivery_mode`,值仅允许 `original / subtitled`,并记录 `subtitle_decided_at`。 +- `workflow_jobs.payload_json` 固定保存每个 output clip 与 revision 的组合;`checkpoint_json.completed` 只复用对应字幕 job 仍为 `completed + verified` 且文件存在的条目。 +- 缺少新字段的已有数据库先创建 `subtitle-auto-workflow` SQLite 在线备份,再执行幂等列/索引迁移;旧字幕文件和 job 不删除,但未验证旧记录不会获得自动发布资格。 + ## 2026-08-23:字幕 revision 数据结构 - `output_clip` 新增 `source_start_ms / source_end_ms / source_duration_ms / source_fingerprint / snapshot_source`。新切片使用 `cut_commit`,旧切片第一次使用时可从候选边界生成 `legacy_inferred`;之后候选修改不会漂移已保存边界。 diff --git a/docs/SUBTITLE_AND_PUBLISH_PLAN.md b/docs/SUBTITLE_AND_PUBLISH_PLAN.md index 82a4c0c..05e9283 100644 --- a/docs/SUBTITLE_AND_PUBLISH_PLAN.md +++ b/docs/SUBTITLE_AND_PUBLISH_PLAN.md @@ -1,10 +1,18 @@ # 字幕与一键推送功能计划书 +## 2026-08-24 PR 4 实现状态 + +- 自动流水线已在字幕草稿后暂停,人工可审核并批量烧录,或明确跳过后使用原片恢复流程。 +- AI 纠错已使用建议 revision 和差异接受流程,不自动覆盖人工版本,也不允许修改时间或说话人。 +- 字幕烧录已改为持久化异步 Job,具备真实进度、取消、重试、checkpoint、临时输出、原子替换、NVENC 回退和 FFprobe 验证。 +- 发送中心已增加审核与验证证据门禁;自动任务不会把未审核或未验证的字幕成片用于发布。 +- 第一版仍不包含自动说话人分离、翻译、卡拉 OK、游戏画面多模态选片或绕过平台验证。 + ## 2026-08-23 PR 3 实现状态 - 已完成统一原片/切片 track、不可变 revision、毫秒 cue、切片边界快照、人工编辑保护。 - 已完成 pysubs2 SRT/VTT/ASS、动态 ASS 样式、本地 wavesurfer 波形、虚拟列表和专业编辑操作。 -- 尚未完成自动流水线审核暂停、AI 纠错建议、持久化批量渲染、NVENC 回退和发送中心审核门禁;这些仅在 PR 4 实现。 +- 自动流水线审核暂停、AI 建议、持久化批量渲染、NVENC 回退和发送中心审核门禁已在 PR 4 完成。 ## 1. 最终目标 diff --git a/docs/TASK_FLOW.md b/docs/TASK_FLOW.md index 1fe2b59..39ec666 100644 --- a/docs/TASK_FLOW.md +++ b/docs/TASK_FLOW.md @@ -1,12 +1,23 @@ # 任务状态流转 +## 2026-08-24:自动字幕审核与恢复(PR 4) + +1. `video_cutting` 成功后进入 `subtitle_drafting`,为原片及成功切片准备字幕轨,然后写入 `pending_subtitle_review` 并结束当前流水线 Job。 +2. “审核并批量烧录”对 active revision 运行质量检查;字幕重叠属于阻断错误,其他中文质量项保持告警。审核后创建 `subtitle` 类型的持久化 Job。 +3. 字幕 Job 按固定 revision 串行处理切片,每条成功后写 checkpoint。失败或重启只重跑未验证条目;取消会终止 FFmpeg 进程树并保留旧 active 成片。 +4. 全部输出经 FFprobe 验证后创建从 `metadata_generating` 开始的恢复 Job;发送任务固定为 `subtitled` 来源。 +5. “跳过字幕”必须由用户在暂停状态明确操作,写入 `original` 决定后同样从元数据步骤恢复。运行中的字幕 Job 未停止前不允许跳过。 +6. 发送准备检查再次验证交付决定、revision 审核状态、渲染验证状态和文件存在性;任何证据缺失都不会进入实际发布。 + +新增主任务状态:`subtitle_drafting`、`pending_subtitle_review`、`failed_subtitle_drafting`。暂停状态不是失败,也不会每 3 秒继续轮询;页面提供字幕审核和显式跳过操作。 + ## 2026-08-23:字幕编辑数据流(PR 3) 1. 完成转写后,从成功的结构化 checkpoint 创建原片主字幕 revision;旧任务没有 checkpoint 时兼容读取完整 `transcript.md`。 2. 切片完成时固化原片起止毫秒快照;首次进入字幕工作台时按快照截取主字幕并换算为切片局部时间。 3. 编辑、导入、拆分、合并、位移和替换均创建新 revision;自动保存使用当前 active revision 做乐观并发校验,冲突时返回 409,不覆盖他人或新版本。 4. 未人工编辑的切片可跟随原片新 revision;人工切片只标记 `pending_sync`,用户明确强制同步前保留其 active revision。 -5. 审核将指定 revision 标记为 `approved`。PR 3 仍保留单条同步烧录兼容入口;PR 4 才会让自动流水线暂停审核并异步批量烧录。 +5. 审核将指定 revision 标记为 `approved`;单条和批量烧录都通过持久化 Job 异步执行,自动任务在审核点暂停并等待用户决定。 ## 2026-08-23:长直播高光流程 diff --git a/docs/UI_REFERENCE.md b/docs/UI_REFERENCE.md index 7c7bf9d..7459e3b 100644 --- a/docs/UI_REFERENCE.md +++ b/docs/UI_REFERENCE.md @@ -1,5 +1,14 @@ # UI 参考说明 +## 2026-08-24 更新:字幕审核暂停与批量 Job + +- 全自动任务详情在切片后显示“字幕待审核”主操作;状态卡隐藏处理中提示,明确说明未确认前不会创建发送任务。 +- 页面同时提供“进入字幕审核”和“跳过字幕,使用原片继续”。跳过是有确认提示的显式决定,不使用默认值。 +- 字幕工作台顶部新增自动流程门禁卡:可审核所有 active revision 并批量烧录,也可明确跳过;运行时展示 Job 状态、百分比、当前条目、取消和失败重试。 +- AI 建议入口作用于勾选 cue,差异面板并排显示原文、建议和理由;用户可逐条勾选后接受,未接受内容不会写入 active revision。 +- 单条“自动加字幕”改为入队并轮询状态,不再让页面等待同步 FFmpeg;完成后刷新带字幕预览。 +- 小屏下门禁操作、Job 进度和差异行按单列排列,继续使用现有 Apple 风格卡片、蓝色强调色和原生 HTML/CSS/JavaScript。 + ## 2026-08-23 更新:专业字幕编辑器 - 字幕任务页顶部新增“原片主时间轴 · 切片继承编辑器”,轨下拉明确区分原片和每条切片,并显示人工版、待同步、revision 与审核状态。 diff --git a/docs/agent_tasks/2026-08-23-subtitle-auto-workflow.md b/docs/agent_tasks/2026-08-23-subtitle-auto-workflow.md new file mode 100644 index 0000000..05ad8f0 --- /dev/null +++ b/docs/agent_tasks/2026-08-23-subtitle-auto-workflow.md @@ -0,0 +1,67 @@ +# PR4 执行任务:字幕审核、渲染与自动流水线整合 + +## 背景 + +PR3 已建立原片/切片统一字幕轨、不可变 revision、毫秒级 cue、专业编辑器和动态 ASS。当前自动流水线仍在切片后跳过字幕,同步 FFmpeg 会阻塞 HTTP,发送中心也只凭 `subtitle_jobs.status=completed` 判断带字幕成片。 + +## 目标 + +1. 自动任务在切片后生成字幕草稿并进入 `PENDING_SUBTITLE_REVIEW`,不创建发布任务。 +2. 用户可明确选择“审核并批量烧录”或“跳过字幕,使用原片继续”。 +3. 字幕烧录使用 `workflow_jobs` 持久化队列,支持真实进度、取消、失败重试、checkpoint 和重启接管。 +4. 渲染固定引用 approved revision,使用临时输出、NVENC 失败回退 libx264、FFprobe 验证和成功后原子切换;失败保留旧 active 成片。 +5. AI 纠错只创建非 active 建议 revision,只能改文字和 cue 内断行;用户查看 diff 后选择接受。 +6. 发送中心只自动使用“approved revision + verified render”的字幕成片;明确跳过字幕时允许原片继续。 + +## 允许修改范围 + +- `app/models/task.py`、`app/models/subtitle.py` +- `app/db/database.py` +- `app/services/pipeline_engine.py`、`job_service.py`、`job_worker.py` +- `app/services/subtitle_data_service.py`、`subtitle_workflow_service.py`、新增字幕 AI 服务 +- `app/services/auto_publish_service.py`、`publish_service.py`、必要的发布 readiness 门禁 +- `app/routers/tasks.py`、`app/routers/subtitles.py` +- 字幕工作台、任务详情页及其 JS/CSS +- PR4 相关测试与项目文档 + +## 禁止修改范围 + +- 不改抖音/B站真实登录、验证码、风控和 Publisher 实现。 +- 不自动发布、部署、合并 PR 或删除用户数据。 +- 不引入说话人自动分离、翻译、卡拉 OK、多模态游戏选片。 +- 不读取或写入 API Key、Token、Cookie、`.env` 内容。 + +## 已确定实现要求 + +- 自动任务暂停是正常终态,不作为失败;恢复从 `METADATA_GENERATING` 开始。 +- 批量烧录的 payload 固定保存每条 `output_clip_id/revision_id`,重试复用 checkpoint,已成功条目不重复渲染。 +- 单条和批量 HTTP 入口都只入队,不同步运行 FFmpeg。 +- 字幕成片新增验证状态;旧版 completed 字幕按未验证处理,不自动进入发送中心。 +- explicit skip 决策写入任务 `auto_config_json`,不得由“没有字幕”静默推断。 +- AI 建议不可激活、不可改变时间戳/说话人;接受建议后生成新的人工草稿 revision。 + +## 验收标准 + +- 切片后生成全部 clip track 草稿并暂停,未创建 publish job。 +- 审核批量烧录成功后自动排队恢复流水线,发布任务使用 verified subtitle。 +- 明确跳过后恢复流水线,发布任务使用 original。 +- 未审批、未验证、revision 已过期的字幕不能作为发布源。 +- Job 可查询、取消、失败重试;取消/失败不覆盖旧 active 成片,不残留 `.part`。 +- 9:16、16:9、1:1、中文字体、音频映射、NVENC 回退、FFprobe 验证均有测试。 +- AI diff 支持选择接受,建议 revision 永不自动覆盖 active revision。 + +## 测试命令 + +```powershell +.venv\Scripts\python.exe -m ruff check app tests +.venv\Scripts\python.exe -m compileall -q app tests +node --check app/static/js/app.js +node --check app/static/js/subtitle-editor.js +.venv\Scripts\python.exe -m pytest tests/test_subtitle_auto_workflow.py tests/test_subtitle_editor.py tests/test_auto_pipeline.py tests/test_job_queue.py tests/test_publish_readiness.py tests/test_publish_task_linkage.py -q +.venv\Scripts\python.exe -m pytest tests/ -q +``` + +## 返回格式 + +- Luna Operator 只返回准确命令、通过/失败数、耗时、警告和失败证据,不修改生产文件。 +- 主代理检查实际 diff、范围外修改、敏感信息、TODO/debug、临时文件和 Git 状态后再验收。 diff --git a/tests/test_auto_pipeline.py b/tests/test_auto_pipeline.py index 09c9565..1f595b9 100644 --- a/tests/test_auto_pipeline.py +++ b/tests/test_auto_pipeline.py @@ -33,6 +33,14 @@ def auto_pipeline_db_cleanup(monkeypatch): connection.execute("DELETE FROM ai_analysis_windows WHERE task_id LIKE 'test-auto-%'") connection.execute("DELETE FROM publish_jobs WHERE task_id LIKE 'test-auto-%'") connection.execute("DELETE FROM subtitle_jobs WHERE task_id LIKE 'test-auto-%'") + connection.execute("DELETE FROM workflow_jobs WHERE task_id LIKE 'test-auto-%'") + connection.execute( + "DELETE FROM subtitle_cues WHERE revision_id IN (SELECT id FROM subtitle_revisions WHERE track_id IN (SELECT id FROM subtitle_tracks WHERE task_id LIKE 'test-auto-%'))" + ) + connection.execute( + "DELETE FROM subtitle_revisions WHERE track_id IN (SELECT id FROM subtitle_tracks WHERE task_id LIKE 'test-auto-%')" + ) + connection.execute("DELETE FROM subtitle_tracks WHERE task_id LIKE 'test-auto-%'") connection.execute("DELETE FROM output_clip WHERE task_id LIKE 'test-auto-%'") connection.execute("DELETE FROM cut_runs WHERE task_id LIKE 'test-auto-%'") connection.execute("DELETE FROM clip_candidates WHERE task_id LIKE 'test-auto-%'") @@ -44,6 +52,14 @@ def auto_pipeline_db_cleanup(monkeypatch): connection.execute("DELETE FROM ai_analysis_windows WHERE task_id LIKE 'test-auto-%'") connection.execute("DELETE FROM publish_jobs WHERE task_id LIKE 'test-auto-%'") connection.execute("DELETE FROM subtitle_jobs WHERE task_id LIKE 'test-auto-%'") + connection.execute("DELETE FROM workflow_jobs WHERE task_id LIKE 'test-auto-%'") + connection.execute( + "DELETE FROM subtitle_cues WHERE revision_id IN (SELECT id FROM subtitle_revisions WHERE track_id IN (SELECT id FROM subtitle_tracks WHERE task_id LIKE 'test-auto-%'))" + ) + connection.execute( + "DELETE FROM subtitle_revisions WHERE track_id IN (SELECT id FROM subtitle_tracks WHERE task_id LIKE 'test-auto-%')" + ) + connection.execute("DELETE FROM subtitle_tracks WHERE task_id LIKE 'test-auto-%'") connection.execute("DELETE FROM output_clip WHERE task_id LIKE 'test-auto-%'") connection.execute("DELETE FROM cut_runs WHERE task_id LIKE 'test-auto-%'") connection.execute("DELETE FROM clip_candidates WHERE task_id LIKE 'test-auto-%'") @@ -289,7 +305,7 @@ def test_create_auto_publish_job_records_scheduled_at(): "scheduled_at": "2026-06-23T08:10:00+00:00", } ] - result = create_auto_publish_jobs(task, scheduled_items) + result = create_auto_publish_jobs(task, scheduled_items, subtitle_delivery_mode="original") assert result["created_count"] == 1 with get_connection() as connection: row = connection.execute( @@ -343,6 +359,7 @@ def test_create_auto_publish_job_without_schedule_waits_for_send_center(): "scheduled_at": "", } ], + subtitle_delivery_mode="original", ) assert result["created_count"] == 1 with get_connection() as connection: @@ -490,7 +507,7 @@ def test_live_status_endpoint_tracks_running_auto_pipeline(): assert payload["is_running"] is True assert payload["should_poll"] is True assert payload["actions"]["primary"] == "processing" - assert len(payload["workflow_steps"]) == 10 + assert len(payload["workflow_steps"]) == 11 assert payload["workflow_steps"][3]["state"] == "current" assert any("实时状态测试日志" in line for line in payload["log_lines"]) diff --git a/tests/test_publish_scheduler.py b/tests/test_publish_scheduler.py index 0539b24..4e5bdf3 100644 --- a/tests/test_publish_scheduler.py +++ b/tests/test_publish_scheduler.py @@ -144,6 +144,7 @@ def test_auto_pipeline_creates_only_metadata_target_platform(tmp_path): "metadata": {"platform": "bilibili", "title": "自动标题", "caption": "自动正文", "hashtags": ["自动"], "risk_flags": []}, "scheduled_at": "", }], + subtitle_delivery_mode="original", ) created = result["created"][0] assert created["platform"] == "bilibili" diff --git a/tests/test_publish_task_grouping.py b/tests/test_publish_task_grouping.py index 2e2be48..fbf1e12 100644 --- a/tests/test_publish_task_grouping.py +++ b/tests/test_publish_task_grouping.py @@ -233,6 +233,7 @@ def test_dismiss_keeps_files_and_other_platform_and_blocks_recreation(tmp_path: "scheduled_at": "", } ], + subtitle_delivery_mode="original", ) assert auto_result["created_count"] == 0 assert auto_result["skipped_count"] == 1 diff --git a/tests/test_publish_task_linkage.py b/tests/test_publish_task_linkage.py index 1b3e124..a6d7e8a 100644 --- a/tests/test_publish_task_linkage.py +++ b/tests/test_publish_task_linkage.py @@ -51,6 +51,15 @@ def _cleanup() -> None: ) connection.execute("DELETE FROM publish_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) connection.execute("DELETE FROM subtitle_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute( + "DELETE FROM subtitle_cues WHERE revision_id IN (SELECT id FROM subtitle_revisions WHERE track_id IN (SELECT id FROM subtitle_tracks WHERE task_id LIKE ?))", + (f"{PREFIX}%",), + ) + connection.execute( + "DELETE FROM subtitle_revisions WHERE track_id IN (SELECT id FROM subtitle_tracks WHERE task_id LIKE ?)", + (f"{PREFIX}%",), + ) + connection.execute("DELETE FROM subtitle_tracks WHERE task_id LIKE ?", (f"{PREFIX}%",)) connection.execute("DELETE FROM output_clip WHERE task_id LIKE ?", (f"{PREFIX}%",)) connection.execute("DELETE FROM clip_candidates WHERE task_id LIKE ?", (f"{PREFIX}%",)) connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) @@ -356,20 +365,42 @@ def test_subtitle_sync_updates_only_unscheduled_video_sources( subtitled_path.write_bytes(b"subtitled") now = _time() with get_connection() as connection: + track_id = f"{PREFIX}track-{uuid4().hex[:8]}" + revision_id = f"{PREFIX}revision-{uuid4().hex[:8]}" + connection.execute( + """ + INSERT INTO subtitle_tracks ( + id, task_id, track_type, output_clip_id, name, active_revision_id, + sync_status, created_at, updated_at + ) VALUES (?, ?, 'clip', ?, '已审核字幕', ?, 'manual', ?, ?) + """, + (track_id, task_id, output_id, revision_id, now, now), + ) + connection.execute( + """ + INSERT INTO subtitle_revisions ( + id, track_id, revision_number, origin, status, cue_count, + checksum, created_at, approved_at + ) VALUES (?, ?, 1, 'manual', 'approved', 1, 'verified-test', ?, ?) + """, + (revision_id, track_id, now, now), + ) connection.execute( """ INSERT INTO subtitle_jobs ( - id, task_id, output_clip_id, status, output_file_path, - is_active, created_at, updated_at - ) VALUES (?, ?, ?, 'completed', ?, 1, ?, ?) + id, task_id, output_clip_id, revision_id, status, output_file_path, + validation_status, verified_at, is_active, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'completed', ?, 'verified', ?, 1, ?, ?) """, ( f"{PREFIX}subtitle-{uuid4().hex[:8]}", task_id, output_id, + revision_id, str(subtitled_path), now, now, + now, ), ) connection.commit() diff --git a/tests/test_subtitle_auto_workflow.py b/tests/test_subtitle_auto_workflow.py new file mode 100644 index 0000000..1359e19 --- /dev/null +++ b/tests/test_subtitle_auto_workflow.py @@ -0,0 +1,583 @@ +from __future__ import annotations + +import hashlib +import json +import shutil +import subprocess +from pathlib import Path +from unittest.mock import Mock +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.db.database import get_connection, init_db +from app.main import app +from app.models.task import TaskStatus +from app.services import job_service +from app.services.auto_publish_service import create_auto_publish_jobs +from app.services.pipeline_engine import PipelineEngine +from app.services.publish_readiness import build_send_readiness +from app.services.publish_service import sync_task_publish_jobs +from app.services.subtitle_ai_service import generate_subtitle_suggestions +from app.services.subtitle_auto_workflow_service import ( + cleanup_interrupted_subtitle_job, + enqueue_task_subtitle_render, + execute_subtitle_render_job, + prepare_task_subtitle_review, + skip_task_subtitles_and_resume, +) +from app.services.subtitle_data_service import ( + accept_suggestion_revision, + approve_revision, + ensure_clip_track, + get_revision, + get_track, +) +from app.services.subtitle_workflow_service import ( + _create_subtitle_job, + _build_ffmpeg_render_command, + _probe_media, + _render_with_fallback, + _validate_rendered_media, + render_subtitles_for_output_clip, +) +from app.services.storage_service import get_artifact_paths +from app.services.task_service import get_task + + +PREFIX = "test-subtitle-auto-" + + +def _headers() -> dict[str, str]: + if settings.local_admin_token: + return {"Authorization": f"Bearer {settings.local_admin_token}"} + return {} + + +@pytest.fixture(autouse=True) +def subtitle_auto_database(): + init_db() + _cleanup() + yield + _cleanup() + + +def _cleanup() -> None: + with get_connection() as connection: + publish_rows = connection.execute( + "SELECT id FROM publish_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",) + ).fetchall() + if publish_rows: + placeholders = ",".join("?" for _ in publish_rows) + connection.execute( + f"DELETE FROM publish_job_events WHERE job_id IN ({placeholders})", + [row["id"] for row in publish_rows], + ) + connection.execute("DELETE FROM publish_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM subtitle_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM workflow_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute( + "DELETE FROM subtitle_cues WHERE revision_id IN (SELECT id FROM subtitle_revisions WHERE track_id IN (SELECT id FROM subtitle_tracks WHERE task_id LIKE ?))", + (f"{PREFIX}%",), + ) + connection.execute( + "DELETE FROM subtitle_revisions WHERE track_id IN (SELECT id FROM subtitle_tracks WHERE task_id LIKE ?)", + (f"{PREFIX}%",), + ) + connection.execute("DELETE FROM subtitle_tracks WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM output_clip WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM transcription_chunks WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM transcription_runs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) + connection.commit() + + +def _create_task(tmp_path: Path, *, status: str = "VIDEO_CUTTING") -> tuple[str, str, Path]: + task_id = f"{PREFIX}{uuid4().hex[:8]}" + output_id = f"out-{uuid4().hex[:8]}" + source_path = tmp_path / f"{task_id}-source.mp4" + output_path = tmp_path / f"{output_id}.mp4" + source_path.write_bytes(b"source") + output_path.write_bytes(b"clip") + now = "2026-08-24T00:00:00+00:00" + segments = [ + {"start_seconds": 0.1, "end_seconds": 1.2, "text": "大家好", "confidence": 0.95}, + {"start_seconds": 1.3, "end_seconds": 2.8, "text": "这是直播高光", "confidence": 0.93}, + ] + result_json = json.dumps(segments, ensure_ascii=False) + result_checksum = hashlib.sha256(result_json.encode("utf-8")).hexdigest() + run_id = f"run-{uuid4().hex[:8]}" + with get_connection() as connection: + connection.execute( + """ + INSERT INTO tasks ( + id, task_name, source_type, platform, original_video_path, + selection_profile, auto_mode, auto_config_json, status, + created_at, updated_at + ) VALUES (?, '字幕自动流程测试', 'upload', 'general', ?, 'general', 1, '{}', ?, ?, ?) + """, + (task_id, str(source_path), status, now, now), + ) + connection.execute( + """ + INSERT INTO output_clip ( + id, task_id, output_file_path, output_file_name, status, is_active, + source_start_ms, source_end_ms, source_duration_ms, + source_fingerprint, snapshot_source, created_at, updated_at + ) VALUES (?, ?, ?, 'clip.mp4', 'completed', 1, 0, 5000, 5000, + 'source-v1', 'cut_commit', ?, ?) + """, + (output_id, task_id, str(output_path), now, now), + ) + connection.execute( + """ + INSERT INTO transcription_runs ( + id, task_id, source_fingerprint, provider, model, device, compute_type, + chunk_seconds, overlap_seconds, status, + total_chunks, completed_chunks, is_active, created_at, updated_at, completed_at + ) VALUES (?, ?, 'source-v1', 'local', 'small', 'cpu', 'int8', + 120, 5, 'completed', 1, 1, 1, ?, ?, ?) + """, + (run_id, task_id, now, now, now), + ) + connection.execute( + """ + INSERT INTO transcription_chunks ( + id, run_id, task_id, chunk_index, start_ms, end_ms, status, + result_json, result_checksum, created_at, updated_at + ) VALUES (?, ?, ?, 1, 0, 5000, 'completed', ?, ?, ?, ?) + """, + (f"chunk-{uuid4().hex[:8]}", run_id, task_id, result_json, result_checksum, now, now), + ) + connection.commit() + return task_id, output_id, output_path + + +def test_schema_contains_async_render_validation_fields(): + with get_connection() as connection: + columns = {row[1] for row in connection.execute("PRAGMA table_info(subtitle_jobs)")} + assert { + "workflow_job_id", + "revision_id", + "validation_status", + "validation_json", + "encoder", + "verified_at", + } <= columns + + +@pytest.mark.parametrize("width,height", [(360, 640), (640, 360), (480, 480)]) +def test_real_ffmpeg_render_supports_three_aspect_ratios(tmp_path: Path, width: int, height: int): + if not shutil.which("ffmpeg") or not shutil.which("ffprobe"): + pytest.skip("本机没有 FFmpeg/FFprobe") + task_id, output_id, output_path = _create_task(tmp_path) + try: + subprocess.run( + [ + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + f"color=c=navy:s={width}x{height}:r=25:d=3", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=44100:duration=3", + "-shortest", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + str(output_path), + ], + capture_output=True, + text=True, + timeout=60, + check=True, + ) + prepare_task_subtitle_review(task_id) + track = ensure_clip_track(task_id, output_id) + approve_revision(track["id"], track["active_revision_id"]) + + result = render_subtitles_for_output_clip(task_id, output_id) + rendered_path = Path(result["job"]["output_file_path"]) + probe = _probe_media(rendered_path) + + assert result["job"]["validation_status"] == "verified" + assert result["job"]["revision_id"] == track["active_revision_id"] + assert rendered_path.is_file() + assert probe["video_codec"] == "h264" + assert probe["pixel_format"] == "yuv420p" + assert probe["has_audio"] is True + assert probe["duration"] == pytest.approx(3.0, abs=1.0) + finally: + shutil.rmtree(get_artifact_paths(task_id)["task_dir"], ignore_errors=True) + + +def test_prepare_review_creates_clip_draft_and_pauses(tmp_path: Path): + task_id, output_id, _ = _create_task(tmp_path) + result = prepare_task_subtitle_review(task_id) + task = get_task(task_id, include_video_probe=False) + track = ensure_clip_track(task_id, output_id) + revision = get_revision(track["active_revision_id"], include_cues=True) + assert result["status"] == "pending_subtitle_review" + assert task["status"] == TaskStatus.PENDING_SUBTITLE_REVIEW.value + assert revision["status"] == "draft" + assert revision["cue_count"] == 2 + + init_db() + assert get_task(task_id, include_video_probe=False)["status"] == TaskStatus.PENDING_SUBTITLE_REVIEW.value + + +def test_review_page_and_api_enqueue_batch_job(tmp_path: Path): + task_id, _, _ = _create_task(tmp_path) + prepare_task_subtitle_review(task_id) + with TestClient(app) as client: + page = client.get(f"/subtitles/{task_id}", headers=_headers()) + task_page = client.get(f"/tasks/{task_id}", headers=_headers()) + live = client.get(f"/api/tasks/{task_id}/live-status", headers=_headers()) + queued = client.post( + f"/api/subtitles/tasks/{task_id}/approve-and-render", + json={"approve_active_revisions": True}, + headers=_headers(), + ) + jobs = client.get(f"/api/subtitles/tasks/{task_id}/jobs", headers=_headers()) + + assert page.status_code == 200 + assert 'id="subtitle-review-gate"' in page.text + assert 'id="subtitle-batch-approve-render"' in page.text + assert 'id="subtitle-skip-and-resume"' in page.text + assert 'id="subtitle-ai-suggest"' in page.text + assert 'data-sync-publish-task' not in page.text + assert task_page.status_code == 200 + assert f'href="/subtitles/{task_id}"' in task_page.text + assert live.status_code == 200 + assert live.json()["actions"]["primary"] == "subtitle_review" + assert live.json()["should_poll"] is False + assert queued.status_code == 200 + assert queued.json()["job"]["job_type"] == job_service.JOB_TYPE_SUBTITLE + assert jobs.status_code == 200 + assert jobs.json()["count"] == 1 + + +def test_skip_api_persists_original_delivery_decision(tmp_path: Path): + task_id, _, _ = _create_task(tmp_path) + prepare_task_subtitle_review(task_id) + with TestClient(app) as client: + response = client.post( + f"/api/subtitles/tasks/{task_id}/skip-and-resume", + headers=_headers(), + ) + assert response.status_code == 200, response.text + with get_connection() as connection: + raw_config = connection.execute( + "SELECT auto_config_json FROM tasks WHERE id = ?", + (task_id,), + ).fetchone()[0] + assert json.loads(raw_config)["subtitle_delivery_mode"] == "original" + + +def test_pipeline_stops_before_metadata_and_publish(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + task_id, _, _ = _create_task(tmp_path, status="CREATED") + engine = PipelineEngine() + for name in ("_prepare_source", "_transcribe_or_read_text", "_run_ai_analysis", "_select_clips", "_cut_video"): + monkeypatch.setattr(engine, name, Mock(return_value={"ok": True})) + + def pause(task_id_value: str, _context: dict) -> dict: + from app.services import task_service + + task_service.update_task_status(task_id_value, TaskStatus.PENDING_SUBTITLE_REVIEW) + return {"status": "pending_subtitle_review"} + + monkeypatch.setattr(engine, "_prepare_subtitle_drafts", pause) + metadata = Mock(return_value={}) + monkeypatch.setattr(engine, "_generate_metadata", metadata) + result = engine.run(task_id) + assert result["status"] == "pending_subtitle_review" + metadata.assert_not_called() + with get_connection() as connection: + assert connection.execute("SELECT COUNT(*) FROM publish_jobs WHERE task_id = ?", (task_id,)).fetchone()[0] == 0 + + +def test_batch_approval_pins_revisions_and_records_delivery_mode(tmp_path: Path): + task_id, output_id, _ = _create_task(tmp_path) + prepare_task_subtitle_review(task_id) + result = enqueue_task_subtitle_render( + task_id, + approve_active_revisions=True, + continue_pipeline=True, + ) + item = result["job"]["payload_json"]["items"][0] + revision = get_revision(item["revision_id"]) + with get_connection() as connection: + config = json.loads(connection.execute("SELECT auto_config_json FROM tasks WHERE id = ?", (task_id,)).fetchone()[0]) + assert item["output_clip_id"] == output_id + assert revision["status"] == "approved" + assert config["subtitle_delivery_mode"] == "subtitled" + + +def test_skip_is_explicit_and_queues_metadata_resume(tmp_path: Path): + task_id, _, _ = _create_task(tmp_path) + prepare_task_subtitle_review(task_id) + result = skip_task_subtitles_and_resume(task_id) + with get_connection() as connection: + config = json.loads(connection.execute("SELECT auto_config_json FROM tasks WHERE id = ?", (task_id,)).fetchone()[0]) + assert config["subtitle_delivery_mode"] == "original" + assert result["job"]["job_type"] == job_service.JOB_TYPE_AUTO_PIPELINE + assert result["job"]["payload_json"]["start_step"] == TaskStatus.METADATA_GENERATING.value + + +def test_skip_rejects_active_subtitle_job(tmp_path: Path): + task_id, _, _ = _create_task(tmp_path) + prepare_task_subtitle_review(task_id) + enqueue_task_subtitle_render(task_id, approve_active_revisions=True, continue_pipeline=True) + with pytest.raises(ValueError, match="仍在运行"): + skip_task_subtitles_and_resume(task_id) + + +def test_pending_subtitle_review_cannot_sync_publish_center(tmp_path: Path): + task_id, _, _ = _create_task(tmp_path) + prepare_task_subtitle_review(task_id) + with pytest.raises(ValueError, match="等待字幕审核"): + sync_task_publish_jobs(task_id) + + +def test_batch_execution_checkpoints_and_queues_resume(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + task_id, output_id, _ = _create_task(tmp_path) + prepare_task_subtitle_review(task_id) + queued = enqueue_task_subtitle_render(task_id, approve_active_revisions=True, continue_pipeline=True) + renderer = Mock( + return_value={ + "job": { + "id": "subtitle-render-1", + "output_file_path": str(tmp_path / "verified.mp4"), + } + } + ) + monkeypatch.setattr( + "app.services.subtitle_auto_workflow_service.render_subtitles_for_output_clip", + renderer, + ) + result = execute_subtitle_render_job(queued["job_id"], task_id, queued["job"]["payload_json"]) + checkpoint = job_service.get_job(queued["job_id"])["checkpoint_json"] + assert result["completed_count"] == 1 + assert checkpoint["completed"][output_id]["revision_id"] + assert result["resume_job_id"] + + +def test_cancel_cleanup_is_precise_and_retry_keeps_checkpoint( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + task_id, output_id, _ = _create_task(tmp_path) + prepare_task_subtitle_review(task_id) + queued = enqueue_task_subtitle_render(task_id, approve_active_revisions=True, continue_pipeline=True) + workflow_job_id = queued["job_id"] + revision_id = queued["job"]["payload_json"]["items"][0]["revision_id"] + child = _create_subtitle_job( + task_id, + output_id, + "processing", + revision_id=revision_id, + workflow_job_id=workflow_job_id, + is_active=0, + ) + directory = tmp_path / "subtitled" + directory.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr( + "app.services.subtitle_auto_workflow_service.get_artifact_paths", + lambda _task_id: {"subtitled_dir": directory}, + ) + owned_temp = directory / f".clip.{workflow_job_id}.part.mp4" + unrelated_temp = directory / ".clip.other-job.part.mp4" + owned_temp.write_bytes(b"partial") + unrelated_temp.write_bytes(b"keep") + + cleanup_interrupted_subtitle_job( + workflow_job_id, + status="cancelled", + message="用户已取消字幕烧录", + ) + with get_connection() as connection: + child_status = connection.execute( + "SELECT status FROM subtitle_jobs WHERE id = ?", + (child["id"],), + ).fetchone()[0] + assert child_status == "cancelled" + assert owned_temp.exists() is False + assert unrelated_temp.exists() is True + + checkpoint = {"completed": {output_id: {"revision_id": revision_id}}} + job_service.update_job_checkpoint(workflow_job_id, checkpoint) + job_service.mark_job_cancelled(workflow_job_id) + retried = job_service.retry_job(workflow_job_id) + assert retried["status"] == job_service.JOB_STATUS_QUEUED + assert retried["checkpoint_json"] == checkpoint + + +def test_ffmpeg_command_maps_optional_audio_and_forces_compatible_video(tmp_path: Path): + command = _build_ffmpeg_render_command( + tmp_path / "input.mp4", + tmp_path / "subtitle.ass", + tmp_path / "output.mp4", + encoder="libx264", + audio_mode="aac", + ) + assert command[command.index("-map") + 1] == "0:v:0" + assert "0:a:0?" in command + assert ["-c:v", "libx264"] == command[command.index("-c:v") : command.index("-c:v") + 2] + assert "yuv420p" in command + assert "-progress" in command + + +def test_nvenc_failure_falls_back_to_libx264(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + calls = [] + monkeypatch.setattr("app.services.subtitle_workflow_service._ffmpeg_has_encoder", lambda _name: True) + + def run(command: list[str], **_kwargs) -> None: + calls.append(command) + if "h264_nvenc" in command: + raise RuntimeError("NVENC unavailable") + + monkeypatch.setattr("app.services.subtitle_workflow_service._run_ffmpeg_progress", run) + encoder, audio_mode = _render_with_fallback( + tmp_path / "input.mp4", + tmp_path / "subtitle.ass", + tmp_path / "output.part.mp4", + workflow_job_id="job", + duration_seconds=3, + has_audio=True, + source_audio_codec="aac", + progress_start=5, + progress_end=95, + ) + assert encoder == "libx264" + assert audio_mode == "copy" + assert any("h264_nvenc" in command for command in calls) + assert any("libx264" in command for command in calls) + + +def test_ffprobe_validation_rejects_missing_audio_and_bad_pixel_format(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + output = tmp_path / "render.part.mp4" + output.write_bytes(b"video") + monkeypatch.setattr( + "app.services.subtitle_workflow_service._probe_media", + lambda _path: { + "duration": 3.0, + "video_codec": "h264", + "pixel_format": "yuv444p", + "has_audio": False, + "audio_codec": "", + }, + ) + with pytest.raises(RuntimeError, match="yuv420p"): + _validate_rendered_media(output, source_duration=3, source_has_audio=True) + + +class _SuggestionProvider: + name = "test-ai" + + def __init__(self, cue_ids: list[str]): + self.cue_ids = cue_ids + + def generate_json(self, _prompt: str) -> str: + return json.dumps( + { + "suggestions": [ + {"cue_id": cue_id, "suggested_text": f"建议{index + 1}", "reason": "错别字"} + for index, cue_id in enumerate(self.cue_ids) + ] + }, + ensure_ascii=False, + ) + + +def test_ai_suggestion_is_inactive_until_selected_diff_is_accepted(tmp_path: Path): + task_id, output_id, _ = _create_task(tmp_path) + prepare_task_subtitle_review(task_id) + track = ensure_clip_track(task_id, output_id) + base_revision = get_revision(track["active_revision_id"], include_cues=True) + cue_ids = [cue["id"] for cue in base_revision["cues"]] + suggestion = generate_subtitle_suggestions( + track["id"], + revision_id=base_revision["id"], + cue_ids=cue_ids, + provider=_SuggestionProvider(cue_ids), + ) + assert get_track(track["id"])["active_revision_id"] == base_revision["id"] + assert suggestion["revision"]["status"] == "suggested" + accepted = accept_suggestion_revision( + track["id"], + suggestion_revision_id=suggestion["revision"]["id"], + base_revision_id=base_revision["id"], + cue_ids=[cue_ids[0]], + ) + assert accepted["origin"] == "manual" + assert accepted["cues"][0]["text"] == "建议1" + assert accepted["cues"][1]["text"] == base_revision["cues"][1]["text"] + assert accepted["cues"][0]["start_ms"] == base_revision["cues"][0]["start_ms"] + + +def test_publish_readiness_blocks_unverified_subtitle_evidence(): + job = { + "status": "WAITING", + "platform": "douyin", + "publish_mode": "manual_export", + "video_source": "subtitled", + "video_file_path": "clip.mp4", + "video_path": "clip.mp4", + "title": "标题", + "caption": "正文", + "provider_payload": {"subtitle_delivery_mode": "subtitled"}, + } + readiness = build_send_readiness(job, accounts=[]) + assert readiness["dispatch_ready"] is False + assert any(issue["code"] == "subtitle_not_verified" for issue in readiness["issues"]) + + +def test_auto_publish_uses_verified_approved_subtitle(tmp_path: Path): + task_id, output_id, output_path = _create_task(tmp_path) + subtitled_path = tmp_path / "approved-subtitled.mp4" + cover_path = tmp_path / "cover.jpg" + subtitled_path.write_bytes(b"subtitled") + cover_path.write_bytes(b"cover") + result = create_auto_publish_jobs( + {"id": task_id, "platform": "general"}, + [ + { + "output_clip": { + "id": output_id, + "output_file_path": str(output_path), + "subtitle_status": "completed", + "subtitled_output_file_path": str(subtitled_path), + "subtitle_revision_id": "approved-revision", + "subtitle_revision_status": "approved", + "subtitle_validation_status": "verified", + "subtitle_verified_at": "2026-08-24T00:00:00+00:00", + }, + "cover": {"cover_file_path": str(cover_path), "cover_time_seconds": 1}, + "metadata": { + "platform": "douyin", + "title": "标题", + "caption": "正文", + "hashtags": ["测试"], + "risk_flags": [], + }, + "scheduled_at": "", + } + ], + subtitle_delivery_mode="subtitled", + ) + assert result["created"][0]["video_source"] == "subtitled" + assert result["created"][0]["video_file_path"] == str(subtitled_path) + issue_codes = {issue["code"] for issue in result["created"][0]["send_readiness"]["issues"]} + assert "subtitle_not_verified" not in issue_codes + assert "subtitle_review_required" not in issue_codes