diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 3554d4b..28d7a6e 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,17 @@ # Development Log +## 2026-08-25 任务 20 状态恢复与新建流程精简 + +- 已确认任务 `20 - E1829 110128 舒淇,安以轩P29-29` 的第 2 次切片批次、12 条 active 成片和发送中心 12/12 关联完整,原“失败”来自完成后重复触发 AI 分析并尝试删除仍被切片引用的候选记录。 +- 修复前 SQLite Online Backup 为 `backups/workflow-before-task20-repair-20260824-233721.sqlite3`;只把任务 `3210d91ee1fb` 恢复为 `completed / 100%` 并清空误报错误,候选、切片、Workflow Job、AI run 和发布任务 ID 前后完全一致,修复依据已写入任务日志。 +- 手动 AI 入口新增原子重入保护:活跃全自动 Job、已有 active 切片或已有发送中心记录时返回 HTTP 409;候选替换在同一事务内先检查 `output_clip` 引用,改为可理解的业务冲突,不再暴露 SQLite 外键错误,也不会把已完成任务降级为失败。 +- 新建任务只保留本机视频上传;任务名称历史候选最多显示最近 5 个未删除且去重后的名称,JSON 已有文件创建入口、目录浏览 API 和 NAS 页面能力已移除。 +- `source_type`、`nas_file_path` 物理列继续保留兼容;账本迁移 `20260824_02_task_upload_only` 在写入前创建 SQLite Online Backup,将旧来源归一到 `original_video_path + upload`,不删除外部视频。 +- “每小时高光密度”和候选总上限仅在长直播高光模式显示和提交;通用与康熙模式不读取页面参数,服务端固定使用兼容默认值。 +- 新增 `POST /api/subtitles/tasks/{task_id}/skip-to-review`;明确跳过字幕后进入片段审核,不再自动续跑文案和发送中心。审核保存并完整关联后才标记完成;关联不完整时保留审核状态与错误。 +- 本轮未修改 AI Provider、Prompt、模型、候选算法或真实发布逻辑,也没有重跑任务 20 的 AI、切片或发布任务。 +- 活动库迁移备份为 `data/backups/workflow-before-task-upload-only-20260825-000702-586803-d14c09b5.sqlite3`;迁移后旧来源记录为 0,`integrity_check=ok`、`foreign_key_check=0`。完整测试 `672 passed`,Ruff、Python Compileall、JavaScript 语法和浏览器烟测通过;8001、Scheduler 与 8765 Windows Worker 健康。 + ## 2026-08-24 稳定 V1 P1.3c 数据库迁移账本与唯一索引 Fail-Closed - 新增 `schema_migrations` 账本;每条正式新迁移记录稳定 version、name、checksum 和完成时间,同版本定义漂移时拒绝启动。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 8a7e097..6932184 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,5 +1,12 @@ # Next Steps +## 2026-08-25 任务 20 修复后检查 + +1. 打开 `http://127.0.0.1:8001/`,确认任务 20 显示“已完成”、无外键错误,并仍为 12 条 active 切片和发送中心已关联 12/12。 +2. 打开“新建任务”:任务名候选最多 5 个,只能上传本机文件;通用/康熙模式不显示密度设置,长直播模式才显示。 +3. 对低风险全自动测试任务选择“跳过字幕,进入片段审核”,确认不会立即生成发送中心任务;在片段审核保存后才同步。 +4. 不需要点击“立即发送”;本轮不会执行真实投稿,也不改变 `NEED_REVIEW` 的人工确认边界。 + ## 2026-08-24 稳定 V1 整改路线 - [x] P0.1:Pytest 与活动数据库、媒体目录彻底隔离,危险整表清理增加 fail-closed 校验。 @@ -12,6 +19,7 @@ - [x] P1.3a:任务状态接口受控转换;Task/Job lease 代际一致;自动流水线取消恢复、READY 终态和关联发布清理明确;切片批次编号、输出目录、Task 终态和数据库激活原子化。 - [x] P1.3b:自动流水线已增加版本化步骤 checkpoint、输入/产物证据、旧 Job 原位重试和重领恢复;已覆盖 AI、转写、切片、文案、排期与发布草稿的失效边界。 - [x] P1.3c:已增加 `schema_migrations` 版本/checksum 账本、迁移前 Online Backup 和 fail-closed v2 活动发布唯一索引;重复数据、索引漂移或账本异常都会拒绝启动。本轮未直接迁移活动库。 +- [x] 任务 20 专项:完成状态恢复、重复 AI 409 保护、上传单入口、最近 5 个任务名、长直播参数显隐和“跳过字幕后进入片段审核”闭环。 - [ ] P1.3d:处理字幕批准批次原子性、字幕/切片中断目录清理和剩余跨进程恢复边界。 - [ ] P1.4:统一第三方 AI/FFmpeg 超时、错误 JSON、429/5xx 与重试幂等边界,并避免重复计费。 - [ ] P1.5:在不扩大个人本地项目范围的前提下处理密钥日志、输入校验和本地管理员接口门禁。 diff --git a/app/db/database.py b/app/db/database.py index 1d85095..cfd57c7 100644 --- a/app/db/database.py +++ b/app/db/database.py @@ -41,6 +41,27 @@ f"DROP INDEX {PUBLISH_ACTIVE_UNIQUE_INDEX_LEGACY_NAME}" ).encode("utf-8") ).hexdigest() +TASK_UPLOAD_ONLY_MIGRATION_VERSION = "20260824_02_task_upload_only" +TASK_UPLOAD_ONLY_MIGRATION_NAME = "任务视频来源归一为本机上传" +TASK_UPLOAD_ONLY_MIGRATION_SQL = """ +UPDATE tasks +SET original_video_path = CASE + WHEN original_video_path IS NULL OR TRIM(original_video_path) = '' THEN nas_file_path + ELSE original_video_path + END, + source_type = 'upload', + nas_file_path = NULL +WHERE source_type != 'upload' + OR source_type IS NULL + OR (nas_file_path IS NOT NULL AND TRIM(nas_file_path) != '') +""".strip() +TASK_UPLOAD_ONLY_MIGRATION_CHECKSUM = hashlib.sha256( + ( + f"{TASK_UPLOAD_ONLY_MIGRATION_VERSION}\n" + f"{TASK_UPLOAD_ONLY_MIGRATION_NAME}\n" + f"{TASK_UPLOAD_ONLY_MIGRATION_SQL}" + ).encode("utf-8") +).hexdigest() class SchemaMigrationError(RuntimeError): @@ -79,6 +100,7 @@ def init_db() -> None: needs_subtitle_editor_backup = _requires_subtitle_editor_schema_migration(settings.database_path) needs_subtitle_auto_backup = _requires_subtitle_auto_schema_migration(settings.database_path) needs_publish_index_backup = _requires_publish_active_index_migration(settings.database_path) + needs_task_upload_only_backup = _requires_task_upload_only_migration(settings.database_path) if needs_long_live_backup: create_schema_migration_backup( settings.database_path, @@ -116,6 +138,12 @@ def init_db() -> None: settings.data_dir / "backups", "publish-active-unique-index-v2", ) + if needs_task_upload_only_backup: + create_schema_migration_backup( + settings.database_path, + settings.data_dir / "backups", + "task-upload-only", + ) with get_connection() as connection: connection.executescript( @@ -721,6 +749,31 @@ def _requires_publish_active_index_migration(database_path) -> bool: connection.close() +def _requires_task_upload_only_migration(database_path) -> bool: + """只有确实存在旧 NAS 来源数据时才在归一化前备份。""" + 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(tasks)").fetchall()} + if not {"source_type", "nas_file_path"} <= columns: + return False + row = connection.execute( + """ + SELECT 1 FROM tasks + WHERE source_type != 'upload' + OR source_type IS NULL + OR (nas_file_path IS NOT NULL AND TRIM(nas_file_path) != '') + LIMIT 1 + """ + ).fetchone() + return row is not None + finally: + if connection is not None: + connection.close() + + 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} @@ -856,6 +909,24 @@ def _apply_publish_active_unique_index_migration(connection: sqlite3.Connection) connection.execute(f"DROP INDEX IF EXISTS {PUBLISH_ACTIVE_UNIQUE_INDEX_LEGACY_NAME}") +def _apply_task_upload_only_migration(connection: sqlite3.Connection) -> None: + connection.execute(TASK_UPLOAD_ONLY_MIGRATION_SQL) + + +def _verify_task_upload_only_migration(connection: sqlite3.Connection) -> None: + row = connection.execute( + """ + SELECT 1 FROM tasks + WHERE source_type != 'upload' + OR source_type IS NULL + OR (nas_file_path IS NOT NULL AND TRIM(nas_file_path) != '') + LIMIT 1 + """ + ).fetchone() + if row is not None: + raise SchemaMigrationError("仍存在未归一化的 NAS 视频来源记录") + + def _registered_schema_migrations() -> tuple[SchemaMigration, ...]: return ( SchemaMigration( @@ -865,6 +936,13 @@ def _registered_schema_migrations() -> tuple[SchemaMigration, ...]: apply=_apply_publish_active_unique_index_migration, verify=_verify_publish_active_unique_index_migration, ), + SchemaMigration( + version=TASK_UPLOAD_ONLY_MIGRATION_VERSION, + name=TASK_UPLOAD_ONLY_MIGRATION_NAME, + checksum=TASK_UPLOAD_ONLY_MIGRATION_CHECKSUM, + apply=_apply_task_upload_only_migration, + verify=_verify_task_upload_only_migration, + ), ) diff --git a/app/main.py b/app/main.py index 3aa32aa..9b719be 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,7 @@ from app.core.config import settings from app.db.database import init_db -from app.routers import ai_prompts, files, media, pages, publish, settings as settings_router, subtitles, tasks +from app.routers import ai_prompts, media, pages, publish, settings as settings_router, subtitles, tasks from app.services.publish_scheduler import start_scheduler_background from app.services.storage_service import configure_runtime_media_storage from app.services.job_worker import WorkflowJobRunner @@ -143,7 +143,6 @@ async def security_middleware(request: Request, call_next): app.include_router(ai_prompts.router) app.include_router(tasks.router) app.include_router(subtitles.router) -app.include_router(files.router) app.include_router(media.router) app.include_router(publish.router) app.include_router(settings_router.router) diff --git a/app/models/task.py b/app/models/task.py index 6be1b67..ac2bf88 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -43,10 +43,8 @@ class TaskStatus(str, Enum): class TaskCreate(BaseModel): task_name: str = Field(..., min_length=1, max_length=120) - source_type: Literal["upload", "nas"] = "upload" platform: Literal["douyin", "bilibili", "general"] = "general" original_video_path: Optional[str] = None - nas_file_path: Optional[str] = None max_clip_duration: int = Field(default=10, ge=1, le=60) candidate_clip_count: int = Field(default=12, ge=1, le=50) selection_profile: Literal["general", "variety_comedy", "long_live_talk"] diff --git a/app/routers/files.py b/app/routers/files.py deleted file mode 100644 index 4a00a61..0000000 --- a/app/routers/files.py +++ /dev/null @@ -1,11 +0,0 @@ -from fastapi import APIRouter - -from app.services.storage_service import browse_video_directory - - -router = APIRouter(prefix="/api/files", tags=["files"]) - - -@router.get("/browse") -async def browse_files(path: str | None = None) -> dict: - return browse_video_directory(path) diff --git a/app/routers/subtitles.py b/app/routers/subtitles.py index 68346d6..9bf91e6 100644 --- a/app/routers/subtitles.py +++ b/app/routers/subtitles.py @@ -19,6 +19,7 @@ enqueue_task_subtitle_render, prepare_task_subtitle_review, skip_task_subtitles_and_resume, + skip_task_subtitles_to_review, ) from app.services.subtitle_data_service import ( SubtitleRevisionConflict, @@ -72,6 +73,11 @@ def skip_and_resume(task_id: str) -> dict: return _call(lambda: skip_task_subtitles_and_resume(task_id)) +@router.post("/tasks/{task_id}/skip-to-review") +def skip_to_review(task_id: str) -> dict: + return _call(lambda: skip_task_subtitles_to_review(task_id)) + + @router.get("/tasks/{task_id}/jobs") def list_subtitle_jobs(task_id: str) -> dict: jobs = [ diff --git a/app/routers/tasks.py b/app/routers/tasks.py index d9a9a6e..3c331d7 100644 --- a/app/routers/tasks.py +++ b/app/routers/tasks.py @@ -37,17 +37,6 @@ async def list_tasks() -> list[dict]: return task_service.list_tasks() -@router.post("") -async def create_task(payload: TaskCreate, background_tasks: BackgroundTasks) -> dict: - try: - result = task_service.create_task_record(payload) - if payload.auto_mode: - result["auto_pipeline"] = start_auto_pipeline(result["id"], background_tasks=background_tasks) - return result - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - @router.post("/upload") async def create_upload_task( background_tasks: BackgroundTasks, @@ -87,15 +76,14 @@ async def create_upload_task( ) payload = TaskCreate( task_name=task_name, - source_type="upload", platform=platform, original_video_path=str(saved_path), max_clip_duration=max_clip_duration, candidate_clip_count=candidate_clip_count, selection_profile=selection_profile, final_clip_target=final_clip_target, - highlight_density_per_hour=highlight_density_per_hour, - highlight_total_limit=highlight_total_limit, + highlight_density_per_hour=(highlight_density_per_hour if selection_profile == "long_live_talk" else 4), + highlight_total_limit=(highlight_total_limit if selection_profile == "long_live_talk" else 30), ai_preference=ai_preference, auto_mode=auto_mode, auto_clip_count=auto_clip_count, @@ -297,6 +285,8 @@ async def process_ai_analysis( ) -> dict: try: return await run_in_threadpool(task_service.process_task_ai_analysis, task_id, provider=provider) + except task_service.AIAnalysisConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -317,6 +307,8 @@ async def get_ai_analysis_runs(task_id: str) -> dict: async def restore_ai_analysis_run(task_id: str, run_id: str) -> dict: try: return task_service.restore_ai_analysis_run(task_id, run_id) + except task_service.AIAnalysisConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/app/services/ai_analysis_workflow_service.py b/app/services/ai_analysis_workflow_service.py index 110d7f9..f46a401 100644 --- a/app/services/ai_analysis_workflow_service.py +++ b/app/services/ai_analysis_workflow_service.py @@ -33,6 +33,10 @@ AI_CLIP_MIN_RECOMMENDED_SECONDS = 45 +class AIAnalysisConflictError(ValueError): + """当前任务已有执行中或下游结果,拒绝破坏性 AI 重入。""" + + # ---------- AI Provider 辅助 ---------- def _ai_model_name(provider_name: str) -> str: @@ -260,11 +264,185 @@ def _replace_clip_candidates(task_id: str, clips: list[dict]) -> None: now = _now_iso() with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + referenced = connection.execute( + """ + SELECT 1 + FROM output_clip + WHERE task_id = ? AND clip_candidate_id IS NOT NULL + LIMIT 1 + """, + (task_id,), + ).fetchone() + if referenced: + connection.rollback() + raise AIAnalysisConflictError( + "任务已有切片引用当前候选结果,不能直接覆盖 AI 分析;" + "请在片段审核页修改现有候选并重新切片。" + ) connection.execute("DELETE FROM clip_candidates WHERE task_id = ?", (task_id,)) _insert_clip_candidates_with_connection(connection, task_id, clips, now) connection.commit() +def _pipeline_lease_belongs_to_task(task_id: str) -> bool: + from app.services import job_service + + active_lease = job_service.current_job_lease() + if not active_lease: + return False + if not job_service.validate_job_lease(active_lease[0], active_lease[1], active_lease[2]): + return False + with get_connection() as connection: + row = connection.execute( + """ + SELECT 1 + FROM workflow_jobs + WHERE id = ? AND task_id = ? AND job_type = ? AND status = ? + AND lease_owner = ? AND lease_token = ? + """, + ( + active_lease[0], + task_id, + job_service.JOB_TYPE_AUTO_PIPELINE, + job_service.JOB_STATUS_RUNNING, + active_lease[1], + active_lease[2], + ), + ).fetchone() + return row is not None + + +def _claim_ai_analysis(task_id: str, task: dict) -> bool: + """自动流水线沿用租约;手动请求则原子校验并占用 ai_analyzing 状态。""" + from app.services import job_service + from app.services.task_service import STATUS_PROGRESS, _now_iso + + if _pipeline_lease_belongs_to_task(task_id): + return False + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + current = connection.execute( + "SELECT status FROM tasks WHERE id = ? AND COALESCE(is_deleted, 0) = 0", + (task_id,), + ).fetchone() + if not current: + connection.rollback() + raise AIAnalysisConflictError("任务不存在或已被删除,不能启动 AI 分析。") + active_pipeline = connection.execute( + """ + SELECT 1 + FROM workflow_jobs + WHERE task_id = ? AND job_type = ? AND status IN (?, ?) + LIMIT 1 + """, + ( + task_id, + job_service.JOB_TYPE_AUTO_PIPELINE, + job_service.JOB_STATUS_QUEUED, + job_service.JOB_STATUS_RUNNING, + ), + ).fetchone() + materialized = connection.execute( + """ + SELECT 1 + WHERE EXISTS ( + SELECT 1 FROM output_clip + WHERE task_id = ? AND COALESCE(is_active, 1) = 1 + ) OR EXISTS ( + SELECT 1 FROM publish_jobs WHERE task_id = ? + ) + """, + (task_id, task_id), + ).fetchone() + current_status = str(current["status"] or "") + if active_pipeline: + connection.rollback() + raise AIAnalysisConflictError("全自动流水线正在处理此任务,请等待当前流程完成,不要重复启动 AI 分析。") + if materialized: + connection.rollback() + raise AIAnalysisConflictError( + "任务已经生成切片或进入发送中心,不能覆盖 AI 候选;请在片段审核页修改后重新切片。" + ) + if current_status in {TaskStatus.ai_analyzing.value, TaskStatus.AI_ANALYZING.value}: + connection.rollback() + raise AIAnalysisConflictError("AI 分析已经在运行,请等待当前分析完成。") + if current_status != str(task.get("status") or ""): + connection.rollback() + raise AIAnalysisConflictError("任务状态刚刚发生变化,请刷新页面后再决定是否重新分析。") + if current_status not in { + TaskStatus.pending_ai.value, + TaskStatus.pending_review.value, + TaskStatus.failed.value, + }: + connection.rollback() + raise AIAnalysisConflictError("当前任务阶段不允许手动启动 AI 分析,请从任务详情继续正确流程。") + connection.execute( + """ + UPDATE tasks + SET status = ?, progress = ?, error_message = NULL, last_error = NULL, updated_at = ? + WHERE id = ? AND status = ? AND COALESCE(is_deleted, 0) = 0 + """, + ( + TaskStatus.ai_analyzing.value, + STATUS_PROGRESS[TaskStatus.ai_analyzing.value], + _now_iso(), + task_id, + current_status, + ), + ) + connection.commit() + return True + + +def _restore_task_after_ai_conflict(task_id: str, previous_task: dict) -> None: + """只回滚仍停留在本次 ai_analyzing 的状态,避免覆盖其他流程的新状态。""" + from app.services.task_service import _now_iso + + with get_connection() as connection: + connection.execute( + """ + UPDATE tasks + SET status = ?, progress = ?, error_message = ?, last_error = ?, updated_at = ? + WHERE id = ? AND status = ? AND COALESCE(is_deleted, 0) = 0 + """, + ( + previous_task.get("status") or TaskStatus.pending_review.value, + int(previous_task.get("progress") or 0), + previous_task.get("error_message") or None, + previous_task.get("last_error") or None, + _now_iso(), + task_id, + TaskStatus.ai_analyzing.value, + ), + ) + connection.commit() + + +def _mark_ai_failed_if_still_running(task_id: str, error_message: str) -> None: + """AI 失败只能结束自己仍持有的可见状态,不能降级已完成任务。""" + from app.services.task_service import STATUS_PROGRESS, _now_iso + + with get_connection() as connection: + connection.execute( + """ + UPDATE tasks + SET status = ?, progress = ?, error_message = ?, last_error = ?, updated_at = ? + WHERE id = ? AND status = ? AND COALESCE(is_deleted, 0) = 0 + """, + ( + TaskStatus.failed.value, + STATUS_PROGRESS[TaskStatus.failed.value], + error_message, + error_message, + _now_iso(), + task_id, + TaskStatus.ai_analyzing.value, + ), + ) + connection.commit() + + def _summarize_analysis_clips(clips: list[dict]) -> list[dict]: summaries = [] for clip in clips: @@ -693,6 +871,7 @@ def process_task_ai_analysis(task_id: str, provider: str | None = None) -> dict: task = get_task(task_id, include_video_probe=False) if not task: raise ValueError("任务不存在") + manual_claimed = _claim_ai_analysis(task_id, task) paths = get_artifact_paths(task_id) if not paths["transcript_path"].exists(): @@ -702,7 +881,8 @@ def process_task_ai_analysis(task_id: str, provider: str | None = None) -> dict: raise ValueError(error) provider_name = (provider or settings.ai_default_provider).lower() - update_task_status(task_id, TaskStatus.ai_analyzing) + if not manual_claimed: + update_task_status(task_id, TaskStatus.ai_analyzing) append_task_log(task_id, f"开始 AI 片段分析,Provider:{provider_name}") used_provider = provider_name @@ -753,10 +933,14 @@ def process_task_ai_analysis(task_id: str, provider: str | None = None) -> dict: ), ) _append_ai_clip_quality_warnings(task_id, analysis_payload["clips"]) - except (AIAnalysisError, Exception) as exc: + except AIAnalysisConflictError: + _restore_task_after_ai_conflict(task_id, task) + append_task_log(task_id, "AI 分析已停止:任务已有下游切片引用,原任务状态和数据保持不变") + raise + except Exception as exc: error = str(exc) user_error = _summarize_ai_error(error) - update_task_status(task_id, TaskStatus.failed, user_error) + _mark_ai_failed_if_still_running(task_id, user_error) append_task_log(task_id, f"AI 分析失败:{error}") raise ValueError(user_error) from exc diff --git a/app/services/publish_service.py b/app/services/publish_service.py index 335add9..04aca2b 100644 --- a/app/services/publish_service.py +++ b/app/services/publish_service.py @@ -645,11 +645,7 @@ def _format_task_created_at(value: str | None) -> str: def _task_source_file_name(job: dict) -> str: - source_path = ( - job.get("task_nas_file_path") - if job.get("task_source_type") == "nas" - else job.get("task_original_video_path") - ) + source_path = job.get("task_original_video_path") source_text = str(source_path or "").strip() if not source_text: return "未记录原视频文件名" @@ -2084,7 +2080,7 @@ def sync_task_publish_jobs( if not task: raise ValueError("任务不存在") if bool(task["auto_mode"]) and task["status"] == TaskStatus.PENDING_SUBTITLE_REVIEW.value: - raise ValueError("自动流水线正在等待字幕审核,请先批量烧录或明确跳过字幕,再进入发送中心") + raise ValueError("自动流水线正在等待字幕审核,请先批量烧录,或明确跳过字幕并完成片段审核") items = _list_completed_publish_clips(task_id) if not items: raise ValueError("当前任务还没有可同步的激活切片") diff --git a/app/services/storage_service.py b/app/services/storage_service.py index 1e85883..29a72e1 100644 --- a/app/services/storage_service.py +++ b/app/services/storage_service.py @@ -484,8 +484,7 @@ def validate_source_video_path(path_value: str | None) -> tuple[bool, str]: def get_source_video_path(task: dict) -> Path | None: - source_path = task.get("nas_file_path") if task.get("source_type") == "nas" else task.get("original_video_path") - return resolve_video_file_path(source_path) + return resolve_video_file_path(task.get("original_video_path")) def save_uploaded_video(task_id: str, filename: str, file_object: BinaryIO, task_dir_name: str | None = None) -> Path: @@ -852,49 +851,3 @@ def move_task_directory_to_trash(task_id: str, task_name: str, task_dir_name: st if source_dir.exists() and source_dir.resolve() != target_dir.resolve(): shutil.move(str(source_dir), str(target_dir)) return trash_dir_name, target_dir - - -def browse_video_directory(path_value: str | None) -> dict: - allowed_roots = _collect_allowed_roots() - - if path_value: - base_path = Path(path_value) - # 阻止路径遍历 - path_str = str(base_path).replace("\\", "/") - if ".." in Path(path_str).parts: - return {"path": str(base_path), "exists": False, "directories": [], "files": [], - "error": "路径包含不安全的跳转字符"} - if not _is_path_within_roots(base_path, roots=allowed_roots): - # 回退到 STORAGE_ROOT - base_path = settings.storage_root - else: - base_path = settings.storage_root - - if not base_path.exists(): - return {"path": str(base_path), "exists": False, "directories": [], "files": []} - if base_path.is_file(): - base_path = base_path.parent - - # 再次确认父目录在允许范围内 - if not _is_path_within_roots(base_path, roots=allowed_roots): - base_path = settings.storage_root - - directories = [] - files = [] - try: - for item in sorted(base_path.iterdir(), key=lambda value: (value.is_file(), value.name.lower())): - if item.is_dir(): - directories.append({"name": item.name, "path": str(item)}) - elif is_video_file(item): - files.append({"name": item.name, "path": str(item), "size": item.stat().st_size}) - except PermissionError: - return {"path": str(base_path), "exists": True, "directories": [], "files": [], - "error": "没有权限浏览此目录"} - - return { - "path": str(base_path), - "parent_path": str(base_path.parent) if _is_path_within_roots(base_path.parent, roots=allowed_roots) else "", - "exists": True, - "directories": directories, - "files": files, - } diff --git a/app/services/subtitle_auto_workflow_service.py b/app/services/subtitle_auto_workflow_service.py index 64b5f5c..8821bbb 100644 --- a/app/services/subtitle_auto_workflow_service.py +++ b/app/services/subtitle_auto_workflow_service.py @@ -123,36 +123,82 @@ def enqueue_task_subtitle_render( } -def skip_task_subtitles_and_resume(task_id: str) -> dict[str, Any]: +def skip_task_subtitles_to_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("任务不存在") - 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, "用户已明确跳过字幕,后续发布内容将使用原片切片") + now = task_service._now_iso() + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + task = connection.execute( + """ + SELECT auto_mode, status, auto_config_json + FROM tasks + WHERE id = ? AND COALESCE(is_deleted, 0) = 0 + """, + (task_id,), + ).fetchone() + if not task: + connection.rollback() + raise ValueError("任务不存在") + if not bool(task["auto_mode"]): + connection.rollback() + raise ValueError("只有全自动任务需要执行字幕跳过决策") + if task["status"] != TaskStatus.PENDING_SUBTITLE_REVIEW.value: + connection.rollback() + raise ValueError("当前任务不在字幕审核暂停状态") + active_subtitle_job = connection.execute( + """ + SELECT 1 FROM workflow_jobs + WHERE task_id = ? AND job_type = ? AND status IN (?, ?) + LIMIT 1 + """, + ( + task_id, + job_service.JOB_TYPE_SUBTITLE, + job_service.JOB_STATUS_QUEUED, + job_service.JOB_STATUS_RUNNING, + ), + ).fetchone() + if active_subtitle_job: + connection.rollback() + raise ValueError("字幕烧录仍在运行,请先取消并等待停止后再选择跳过字幕") + try: + config = json.loads(task["auto_config_json"] or "{}") + except json.JSONDecodeError: + config = {} + config["subtitle_delivery_mode"] = "original" + config["subtitle_decided_at"] = now + connection.execute( + """ + UPDATE tasks + SET auto_config_json = ?, status = ?, progress = ?, + error_message = NULL, last_error = NULL, updated_at = ? + WHERE id = ? AND status = ? + """, + ( + json.dumps(config, ensure_ascii=False), + TaskStatus.pending_review.value, + task_service.STATUS_PROGRESS[TaskStatus.pending_review.value], + now, + task_id, + TaskStatus.PENDING_SUBTITLE_REVIEW.value, + ), + ) + connection.commit() + review_url = f"/tasks/{task_id}/clips/review" + append_task_log(task_id, "用户已明确跳过字幕,已进入片段审核;后续发送中心使用原片切片") return { - "status": job["status"], - "message": "已明确跳过字幕,流水线恢复任务已加入队列" if created else "流水线恢复任务已经在队列中", - "job": job, - "job_id": job["id"], - "created": created, + "status": TaskStatus.pending_review.value, + "message": "已跳过字幕,请审核片段后再同步发送中心。", + "review_url": review_url, } +def skip_task_subtitles_and_resume(task_id: str) -> dict[str, Any]: + """兼容旧页面缓存;行为已调整为跳过字幕后进入片段审核。""" + return skip_task_subtitles_to_review(task_id) + + 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: diff --git a/app/services/task_lifecycle_service.py b/app/services/task_lifecycle_service.py index ee19fd5..d206da5 100644 --- a/app/services/task_lifecycle_service.py +++ b/app/services/task_lifecycle_service.py @@ -37,7 +37,12 @@ class TaskStatusConflictError(RuntimeError): TaskStatus.transcribing.value: {TaskStatus.pending_ai.value, TaskStatus.failed.value}, TaskStatus.pending_ai.value: {TaskStatus.ai_analyzing.value, TaskStatus.failed.value}, TaskStatus.ai_analyzing.value: {TaskStatus.pending_review.value, TaskStatus.failed.value}, - TaskStatus.pending_review.value: {TaskStatus.ai_analyzing.value, TaskStatus.cutting.value}, + TaskStatus.pending_review.value: { + TaskStatus.ai_analyzing.value, + TaskStatus.cutting.value, + TaskStatus.completed.value, + TaskStatus.completed_with_errors.value, + }, TaskStatus.cutting.value: { TaskStatus.completed.value, TaskStatus.completed_with_errors.value, @@ -137,7 +142,7 @@ def create_task_record(payload: TaskCreate, task_id: str | None = None, task_dir resolved_task_id = task_id or uuid4().hex[:12] resolved_task_dir_name = task_dir_name now = _now_iso() - source_path = payload.nas_file_path if payload.source_type == "nas" else payload.original_video_path + source_path = payload.original_video_path has_source_file = bool(source_path) media_preflight = None if source_path: @@ -182,10 +187,10 @@ def create_task_record(payload: TaskCreate, task_id: str | None = None, task_dir "id": resolved_task_id, "task_name": payload.task_name, "task_dir_name": resolved_task_dir_name, - "source_type": payload.source_type, + "source_type": "upload", "platform": payload.platform, "original_video_path": payload.original_video_path, - "nas_file_path": payload.nas_file_path, + "nas_file_path": None, "max_clip_duration": payload.max_clip_duration, "candidate_clip_count": payload.candidate_clip_count, "selection_profile": payload.selection_profile, @@ -209,7 +214,7 @@ def create_task_record(payload: TaskCreate, task_id: str | None = None, task_dir if "title" in existing_columns: insert_data["title"] = payload.task_name if "source_path" in existing_columns: - insert_data["source_path"] = payload.nas_file_path or payload.original_video_path + insert_data["source_path"] = payload.original_video_path if "max_clip_minutes" in existing_columns: insert_data["max_clip_minutes"] = payload.max_clip_duration if "target_clip_count" in existing_columns: @@ -372,10 +377,10 @@ def _validate_public_transition_preconditions( ) -> None: if status_value == TaskStatus.pending_processing.value: source = connection.execute( - "SELECT original_video_path, nas_file_path FROM tasks WHERE id = ?", + "SELECT original_video_path FROM tasks WHERE id = ?", (task_id,), ).fetchone() - if not source or not str(source["nas_file_path"] or source["original_video_path"] or "").strip(): + if not source or not str(source["original_video_path"] or "").strip(): raise TaskStatusConflictError("任务尚未绑定源视频,不能进入待处理状态") elif status_value == TaskStatus.cutting.value: enabled = connection.execute( @@ -474,6 +479,9 @@ def update_task_selection_settings( raise ValueError("选片模式只能是通用内容价值、康熙笑点选片模式或长直播高光") if final_clip_target < 1 or final_clip_target > 12: raise ValueError("最终启用目标必须在 1 到 12 条之间") + if selection_profile != "long_live_talk": + highlight_density_per_hour = 4 + highlight_total_limit = 30 if not 1 <= highlight_density_per_hour <= 10: raise ValueError("每小时高光密度必须在 1 到 10 条之间") if not 1 <= highlight_total_limit <= 50: diff --git a/app/services/task_service.py b/app/services/task_service.py index 64935e2..023a15e 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -13,6 +13,7 @@ from app.models.task import ClipCandidateBatchItem, ClipCandidateUpdate, TaskStatus from app.services.ai_analysis_workflow_service import ( AI_CLIP_MIN_RECOMMENDED_SECONDS, + AIAnalysisConflictError, _ai_model_name, _ai_provider_label, _analysis_payload_to_preview, @@ -240,7 +241,6 @@ SOURCE_TYPE_LABELS = { "upload": "上传视频", - "nas": "选择 NAS / 本地文件", } OUTPUT_STATUS_LABELS = { @@ -402,8 +402,6 @@ def get_platform_label(platform: str) -> str: def get_source_path(task: dict) -> str: - if task.get("source_type") == "nas": - return task.get("nas_file_path") or "尚未选择 NAS / 本地文件" return task.get("original_video_path") or "尚未上传视频" @@ -585,7 +583,7 @@ def list_task_name_history() -> list[str]: AND TRIM(task_name) <> '' GROUP BY TRIM(task_name) ORDER BY latest_created_at DESC, task_name ASC - LIMIT 100 + LIMIT 5 """ ).fetchall() return [str(row["task_name"]) for row in rows] @@ -984,6 +982,22 @@ def sync_reviewed_clips_to_publish_center( ) action_message = "已保存当前审核选择,现有切片与选择一致,无需重复生成。" + link_state = publish_sync.get("link_state") or {} + publish_sync_ok = ( + publish_sync.get("status") == "ok" + and not (publish_sync.get("errors") or []) + and link_state.get("state") == "linked" + and int(link_state.get("missing_count") or 0) == 0 + ) + current_task = get_task(task_id, include_video_probe=False) + if publish_sync_ok and current_task and current_task.get("status") == TaskStatus.pending_review.value: + transition_task_status(task_id, TaskStatus.completed) + elif not publish_sync_ok and current_task and current_task.get("status") in { + TaskStatus.completed.value, + TaskStatus.completed_with_errors.value, + }: + update_task_status(task_id, TaskStatus.pending_review) + return { "status": publish_sync.get("status") or "ok", "message": f"{action_message}{publish_sync.get('message') or '发送中心同步完成。'}", @@ -993,7 +1007,7 @@ def sync_reviewed_clips_to_publish_center( "review_save": save_result, "cut_result": cut_result, "publish_sync": publish_sync, - "link_state": publish_sync.get("link_state") or {}, + "link_state": link_state, "errors": publish_sync.get("errors") or [], "warnings": publish_sync.get("warnings") or [], } diff --git a/app/services/transcript_service.py b/app/services/transcript_service.py index 7cc4397..0567556 100644 --- a/app/services/transcript_service.py +++ b/app/services/transcript_service.py @@ -191,7 +191,7 @@ def write_transcript_markdown( checkpoint = None task_id = str(task.get("id") or "").strip() if task_id: - source_path = task.get("nas_file_path") or task.get("original_video_path") or audio_path + source_path = task.get("original_video_path") or audio_path checkpoint = TranscriptionCheckpoint( task_id=task_id, source_path=source_path, diff --git a/app/static/css/styles.css b/app/static/css/styles.css index afb5b26..1a32a53 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -765,6 +765,10 @@ dd { gap: 12px; } +#long-live-settings[hidden] { + display: none; +} + .segmented label, .toggle-line, .inline-switch { diff --git a/app/static/js/app.js b/app/static/js/app.js index 1fa2359..42b93ac 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -34,13 +34,8 @@ window.apiFetch = apiFetch; const newTaskForm = document.querySelector("#new-task-form"); const newTaskAutoMode = newTaskForm?.querySelector("input[name='auto_mode']"); const newTaskSubmitButton = document.querySelector("#new-task-submit-button"); -const newTaskSourceInputs = newTaskForm?.querySelectorAll("input[name='source_type']") || []; -const uploadSourcePanel = document.querySelector("#upload-source-panel"); -const existingSourcePanel = document.querySelector("#existing-source-panel"); -const nasFilePathInput = document.querySelector("#nas-file-path"); const selectionProfileInput = document.querySelector("#selection-profile"); const longLiveSettings = document.querySelector("#long-live-settings"); -let currentBrowseParentPath = ""; function updateNewTaskSubmitLabel() { if (!newTaskSubmitButton) return; @@ -52,25 +47,6 @@ if (newTaskAutoMode) { updateNewTaskSubmitLabel(); } -function selectedNewTaskSource() { - return newTaskForm?.querySelector("input[name='source_type']:checked")?.value || "upload"; -} - -function updateNewTaskSourcePanels() { - const useExisting = selectedNewTaskSource() === "nas"; - if (uploadSourcePanel) uploadSourcePanel.hidden = useExisting; - if (existingSourcePanel) existingSourcePanel.hidden = !useExisting; - const videoFileInput = document.querySelector("#video-file-input"); - if (videoFileInput) videoFileInput.required = !useExisting; - if (nasFilePathInput) nasFilePathInput.required = useExisting; - if (useExisting && !document.querySelector("#browse-current-path")?.dataset.loaded) { - browseVideoDirectory(""); - } -} - -newTaskSourceInputs.forEach((input) => input.addEventListener("change", updateNewTaskSourcePanels)); -updateNewTaskSourcePanels(); - if (selectionProfileInput && longLiveSettings) { const updateLongLiveSettings = () => { longLiveSettings.hidden = selectionProfileInput.value !== "long_live_talk"; @@ -79,58 +55,6 @@ if (selectionProfileInput && longLiveSettings) { updateLongLiveSettings(); } -async function browseVideoDirectory(path = "") { - const browser = document.querySelector("#video-file-browser"); - const currentPath = document.querySelector("#browse-current-path"); - if (!browser || !currentPath) return; - browser.textContent = "正在读取目录..."; - try { - const suffix = path ? `?path=${encodeURIComponent(path)}` : ""; - const data = await apiFetch(`/api/files/browse${suffix}`); - currentPath.textContent = `当前目录:${data.path || "不可用"}`; - currentPath.dataset.loaded = "true"; - currentBrowseParentPath = data.parent_path || ""; - browser.replaceChildren(); - if (data.error) { - const error = document.createElement("p"); - error.className = "form-hint error-text"; - error.textContent = data.error; - browser.append(error); - } - for (const directory of data.directories || []) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "file-browser-item directory-item"; - button.textContent = `📁 ${directory.name}`; - button.addEventListener("click", () => browseVideoDirectory(directory.path)); - browser.append(button); - } - for (const file of data.files || []) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "file-browser-item video-item"; - button.textContent = `🎬 ${file.name} · ${(Number(file.size || 0) / (1024 ** 3)).toFixed(2)} GB`; - button.addEventListener("click", () => { - nasFilePathInput.value = file.path; - browser.querySelectorAll(".is-selected").forEach((item) => item.classList.remove("is-selected")); - button.classList.add("is-selected"); - }); - browser.append(button); - } - if (!(data.directories || []).length && !(data.files || []).length && !data.error) { - browser.textContent = "此目录下没有可用的视频文件。"; - } - } catch (error) { - browser.textContent = `目录读取失败:${error.message}`; - } -} - -document.querySelector("#browse-refresh-button")?.addEventListener("click", () => { - const current = document.querySelector("#browse-current-path")?.textContent?.replace("当前目录:", "") || ""; - browseVideoDirectory(current); -}); -document.querySelector("#browse-parent-button")?.addEventListener("click", () => browseVideoDirectory(currentBrowseParentPath)); - if (newTaskForm) { newTaskForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -145,38 +69,21 @@ if (newTaskForm) { try { if (!payload.selection_profile) throw new Error("请选择选片模式"); - let data; - if (selectedNewTaskSource() === "upload") { - if (!videoFileInput.files.length) throw new Error("请选择要上传的视频文件"); - const uploadData = new FormData(); - for (const key of [ - "task_name", "platform", "max_clip_duration", "candidate_clip_count", - "selection_profile", "final_clip_target", "highlight_density_per_hour", "highlight_total_limit", - ]) uploadData.append(key, payload[key] || ""); - uploadData.append("ai_preference", ""); - uploadData.append("auto_mode", payload.auto_mode === "true" ? "true" : "false"); - uploadData.append("auto_metadata_use_ai", "false"); - uploadData.append("video_file", videoFileInput.files[0]); - data = await apiFetch("/api/tasks/upload", { method: "POST", body: uploadData }); - } else { - if (!payload.nas_file_path) throw new Error("请选择本地或 NAS 中的已有视频文件"); - data = await apiFetch("/api/tasks", { - method: "POST", - body: JSON.stringify({ - task_name: payload.task_name, - source_type: "nas", - platform: payload.platform || "general", - nas_file_path: payload.nas_file_path, - max_clip_duration: Number(payload.max_clip_duration || 10), - candidate_clip_count: Number(payload.candidate_clip_count || 12), - selection_profile: payload.selection_profile, - final_clip_target: Number(payload.final_clip_target || 5), - highlight_density_per_hour: Number(payload.highlight_density_per_hour || 4), - highlight_total_limit: Number(payload.highlight_total_limit || 30), - auto_mode: payload.auto_mode === "true", - }), - }); + if (!videoFileInput.files.length) throw new Error("请选择要上传的视频文件"); + const uploadData = new FormData(); + for (const key of [ + "task_name", "platform", "max_clip_duration", "candidate_clip_count", + "selection_profile", "final_clip_target", + ]) uploadData.append(key, payload[key] || ""); + if (payload.selection_profile === "long_live_talk") { + uploadData.append("highlight_density_per_hour", payload.highlight_density_per_hour || "4"); + uploadData.append("highlight_total_limit", payload.highlight_total_limit || "30"); } + uploadData.append("ai_preference", ""); + uploadData.append("auto_mode", payload.auto_mode === "true" ? "true" : "false"); + uploadData.append("auto_metadata_use_ai", "false"); + uploadData.append("video_file", videoFileInput.files[0]); + const data = await apiFetch("/api/tasks/upload", { method: "POST", body: uploadData }); result.textContent = `${data.message}${payload.auto_mode === "true" ? " 全自动流水线已启动。" : ""} 正在进入详情页...`; window.location.href = data.detail_url; } catch (error) { @@ -1677,15 +1584,18 @@ async function saveTaskSelectionSettings() { if (!Number.isInteger(finalTarget) || finalTarget < 1 || finalTarget > 12) { throw new Error("最终启用目标必须是 1 到 12 之间的整数。"); } + const settingsPayload = { + selection_profile: aiSelectionProfile, + final_clip_target: finalTarget, + }; + if (aiSelectionProfile === "long_live_talk") { + settingsPayload.highlight_density_per_hour = Number(aiHighlightDensity?.value || 4); + settingsPayload.highlight_total_limit = Number(aiHighlightTotalLimit?.value || 30); + } const response = await fetch(`/api/tasks/${aiAnalysisForm.dataset.taskId}/selection-settings`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - selection_profile: aiSelectionProfile, - final_clip_target: finalTarget, - highlight_density_per_hour: Number(aiHighlightDensity?.value || 4), - highlight_total_limit: Number(aiHighlightTotalLimit?.value || 30), - }), + body: JSON.stringify(settingsPayload), }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || "选片设置保存失败"); @@ -1754,7 +1664,7 @@ document.querySelectorAll("[data-sync-publish-task]").forEach((button) => { document.querySelectorAll(".js-hide-task").forEach((button) => { button.addEventListener("click", async () => { const taskTitle = button.dataset.taskTitle || "这条任务"; - const confirmed = window.confirm(`确认永久删除“${taskTitle}”吗?\n\n系统会永久删除 E 盘任务目录内的原片副本、音频、转写、切片、字幕、封面和发布包,删除后无法恢复。\n\nNAS 或任务目录外的原始视频不会被删除。`); + const confirmed = window.confirm(`确认永久删除“${taskTitle}”吗?\n\n系统会永久删除 E 盘任务目录内的原片副本、音频、转写、切片、字幕、封面和发布包,删除后无法恢复。\n\n任务目录外的原始视频不会被删除。`); if (!confirmed) return; const originalText = button.textContent; @@ -2067,17 +1977,17 @@ async function pollSubtitleWorkflowJob(jobId, statusNode, messageNode) { document.querySelectorAll("[data-live-subtitle-skip]").forEach((button) => { button.addEventListener("click", async () => { - if (!window.confirm("确认跳过字幕吗?后续发送中心将明确使用原片切片。")) return; + if (!window.confirm("确认跳过字幕并进入片段审核吗?审核保存后才会同步发送中心。")) return; const taskId = button.dataset.taskId; button.disabled = true; const originalText = button.textContent; - button.textContent = "正在恢复流水线..."; + button.textContent = "正在进入审核..."; try { - const data = await apiFetch(`/api/subtitles/tasks/${encodeURIComponent(taskId)}/skip-and-resume`, { + const data = await apiFetch(`/api/subtitles/tasks/${encodeURIComponent(taskId)}/skip-to-review`, { method: "POST", }); - if (taskLiveNote) taskLiveNote.textContent = data.message || "已跳过字幕并恢复流水线"; - startTaskLiveStatusPolling(true); + if (taskLiveNote) taskLiveNote.textContent = data.message || "已跳过字幕,正在进入片段审核"; + window.location.href = data.review_url || `/tasks/${encodeURIComponent(taskId)}/clips/review`; } catch (error) { window.alert(`跳过字幕失败:${summarizeErrorMessage(error.message)}`); button.disabled = false; diff --git a/app/static/js/subtitle-editor.js b/app/static/js/subtitle-editor.js index 0caffc8..2b5db9f 100644 --- a/app/static/js/subtitle-editor.js +++ b/app/static/js/subtitle-editor.js @@ -751,11 +751,11 @@ }); batch.skip?.addEventListener("click", async () => { - if (!window.confirm("确认跳过字幕并使用原片继续吗?该选择会写入发布任务,不能静默改回字幕版。")) return; + 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)}`; + const payload = await api(`/api/subtitles/tasks/${encodeURIComponent(state.taskId)}/skip-to-review`, { method: "POST" }); + window.location.href = payload.review_url || `/tasks/${encodeURIComponent(state.taskId)}/clips/review`; } catch (error) { batch.message.textContent = `跳过字幕失败:${error.message}`; batch.panel.hidden = false; diff --git a/app/templates/new_task.html b/app/templates/new_task.html index d2f36b0..a3127e7 100644 --- a/app/templates/new_task.html +++ b/app/templates/new_task.html @@ -29,36 +29,19 @@

基本信息

- 视频来源 -
- - -
+ 上传本机视频
- 上传本机视频 - 默认最大 4 GB。更大的直播录像请使用“已有文件”,避免浏览器上传中断。 + 选择本机视频文件 + 默认最大 4 GB,上传后统一保存到 E 盘任务目录。
- +

尚未选择视频文件。

-