From bcaba93468a83fb1d39f69ea54198d537084b609 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 23 Aug 2026 23:39:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=B8=93=E4=B8=9A=E5=AD=97=E5=B9=95=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT_LOG.md | 12 + NEXT_STEPS.md | 12 +- THIRD_PARTY_NOTICES.md | 27 + app/db/database.py | 149 +++ app/main.py | 3 +- app/models/subtitle.py | 77 ++ app/models/task.py | 4 + app/routers/subtitles.py | 178 +++ app/services/subtitle_data_service.py | 1162 +++++++++++++++++ app/services/subtitle_workflow_service.py | 149 ++- app/services/video_cut_workflow_service.py | 42 +- app/static/css/styles.css | 193 +++ app/static/js/app.js | 9 + app/static/js/subtitle-editor.js | 597 +++++++++ app/static/vendor/wavesurfer/LICENSE | 29 + app/static/vendor/wavesurfer/regions.min.js | 1 + app/static/vendor/wavesurfer/timeline.min.js | 1 + .../vendor/wavesurfer/wavesurfer.min.js | 1 + app/templates/subtitle_workflow.html | 108 ++ docs/ARCHITECTURE.md | 16 + docs/DATABASE_SCHEMA.md | 9 + docs/SUBTITLE_AND_PUBLISH_PLAN.md | 6 + docs/TASK_FLOW.md | 8 + docs/UI_REFERENCE.md | 10 + .../2026-08-23-subtitle-editor-rebuild.md | 65 + requirements.in | 1 + requirements.txt | 1 + tests/test_subtitle_editor.py | 466 +++++++ third_party_licenses/pysubs2-LICENSE.txt | 19 + 29 files changed, 3284 insertions(+), 71 deletions(-) create mode 100644 app/models/subtitle.py create mode 100644 app/routers/subtitles.py create mode 100644 app/services/subtitle_data_service.py create mode 100644 app/static/js/subtitle-editor.js create mode 100644 app/static/vendor/wavesurfer/LICENSE create mode 100644 app/static/vendor/wavesurfer/regions.min.js create mode 100644 app/static/vendor/wavesurfer/timeline.min.js create mode 100644 app/static/vendor/wavesurfer/wavesurfer.min.js create mode 100644 docs/agent_tasks/2026-08-23-subtitle-editor-rebuild.md create mode 100644 tests/test_subtitle_editor.py create mode 100644 third_party_licenses/pysubs2-LICENSE.txt diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 5347a60..7827b7b 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,17 @@ # Development Log +## 2026-08-23 字幕数据层与专业编辑器重构(PR 3) + +- 新增 `subtitle_tracks / subtitle_revisions / subtitle_cues`,以原片主时间轴为事实源;每次人工编辑、导入或同步都创建不可变 revision,渲染任务固定引用 revision。 +- 输出切片在切割提交时保存原片起止毫秒、时长和源指纹快照;切片字幕按快照截取并换算本地时间,人工编辑后只标记待同步,不自动覆盖。 +- 结构化转写 checkpoint 成为字幕首选数据源,保留置信度和毫秒时间;`transcript.md` 只作为旧任务兼容输入,不再限制 120 行。 +- 锁定 `pysubs2==1.9.0`,支持 SRT、VTT、ASS 导入导出和动态 ASS 序列化;ASS 受格式规范限制为 10ms 精度,内部 revision 与 SRT/VTT 继续保持 1ms。 +- 本地固定 `wavesurfer.js@7.12.11` 及 Regions/Timeline;原片波形由服务端 FFmpeg 以 100Hz 预计算 peaks 并缓存,浏览器不解码完整长视频音频。 +- 字幕工作台新增视频联动、当前行高亮、虚拟列表、搜索替换、毫秒编辑、区间拖动、拆分合并、增删、批量位移、撤销重做、说话人、自动保存、审核与导入导出。 +- 中文质量规则只提示不改字;ASS 样式按 9:16、16:9、1:1 实际分辨率计算,支持安全区、描边、阴影和说话人颜色。 +- GPL-3.0 的 VideoCaptioner 仅研究流程,没有复制源码;依赖版本及 MIT/BSD 许可证已记录在 `THIRD_PARTY_NOTICES.md`。 +- 新增 20 项字幕专项测试,覆盖幂等迁移、毫秒精度、150 行不截断、切片边界、人工版本保护、原片继承、多步编辑、三格式往返、三种画幅、说话人样式、固定渲染 revision、服务端 peaks 与 API 范围查询。 + ## 2026-08-23 长直播分层高光选片(PR 2) - `long_live_talk` 不再进入通用选片,改用固定约 300 秒、重叠 60 秒的语言高光窗口。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 628d7f2..74d3e6c 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -4,10 +4,18 @@ - [x] PR 1:模式必选、已有文件入口、媒体/磁盘预检、持久化重型 Job、转写断点与词级时间戳。 - [x] PR 2:`long_live_talk` 5 分钟重叠窗口、每小时覆盖、全局配额、去重、90% 覆盖门禁和窗口级恢复。 -- [ ] PR 3:统一字幕 track/revision/cue、pysubs2 导入导出、wavesurfer 波形与专业编辑器。 +- [x] PR 3:统一字幕 track/revision/cue、pysubs2 导入导出、wavesurfer 波形与专业编辑器。 - [ ] PR 4:字幕审核暂停、AI 建议 revision、异步批量烧录、NVENC 回退和发送中心门禁。 -当前验证重点:PR 2 完成专项与全量回归后进入字幕数据层;在 PR 3/PR 4 完成前,不宣称新字幕审核和批量烧录已经可用。 +当前验证重点:PR 3 完成专项、页面和全量回归后进入自动流水线整合;异步批量烧录、AI 建议 revision 和发送中心审核门禁仍属于 PR 4,不能把 PR 3 的旧同步烧录兼容入口当成最终自动流程。 + +### PR 3 人工检查 + +1. 打开任一已有转写和切片的任务字幕工作台,先选“原片主字幕”,确认视频、波形和数千行虚拟列表可滚动。 +2. 修改文字或毫秒时间,等待状态显示新 revision 已保存;刷新页面后修改仍在,旧 revision 仍可从 API 查询。 +3. 切换切片字幕轨,确认时间从 0 开始;人工修改后更新原片,切片应显示待同步而不是被覆盖。 +4. 分别导入、导出 SRT/VTT/ASS;ASS 按规范以 10ms 为单位,SRT/VTT 与数据库保持毫秒精度。 +5. PR 4 完成前不要用本页做自动批量烧录或真实发送;当前“自动加字幕”仍是旧同步兼容入口。 ## 2026-08-23 v2.1.0 主线同步后检查 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 38f9fca..367c8df 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -22,3 +22,30 @@ - 许可证:Apache License 2.0(以 Playwright 官方仓库许可证为准)。 第三方网站和平台名称、商标及页面属于各自权利人。使用本项目投稿时,用户仍需遵守抖音、哔哩哔哩及浏览器相关服务条款,不得使用本项目绕过验证或平台风控。 + +## pysubs2 1.9.0 + +- 项目:https://github.com/tkarabela/pysubs2 +- 用途:SRT、WebVTT、ASS 的解析、导入、导出与 ASS 样式序列化。 +- 许可证:MIT License。 +- 本项目锁定运行时版本 `pysubs2==1.9.0`;完整许可证保存在 `third_party_licenses/pysubs2-LICENSE.txt`。 + +## wavesurfer.js 7.12.11 + +- 项目:https://github.com/katspaugh/wavesurfer.js +- 用途:字幕编辑器波形、Regions 区间拖动与 Timeline 时间刻度。 +- 许可证:BSD 3-Clause License。 +- 本项目固定并本地托管 `wavesurfer.min.js`、`regions.min.js` 与 `timeline.min.js`,避免运行时依赖 CDN;完整许可证保存在 `app/static/vendor/wavesurfer/LICENSE`。 + +## Subtitle Edit(设计参考) + +- 项目:https://github.com/SubtitleEdit/subtitleedit +- 参考范围:波形联动、字幕行编辑、拆分合并、撤销重做与质量检查交互。 +- 本项目没有复制 Subtitle Edit 源码,仅借鉴成熟交互设计。 + +## VideoCaptioner(仅流程研究) + +- 项目:https://github.com/WEIFENG2333/VideoCaptioner +- 许可证:GPL-3.0。 +- 参考范围:字幕识别、校对、导出和视频合成的产品流程。 +- GPL 源码没有复制、改写或打包进入本项目。 diff --git a/app/db/database.py b/app/db/database.py index c72dc79..53c4841 100644 --- a/app/db/database.py +++ b/app/db/database.py @@ -38,6 +38,12 @@ def init_db() -> None: settings.data_dir / "backups", "long-live-foundation", ) + if _requires_subtitle_editor_schema_migration(settings.database_path): + create_schema_migration_backup( + settings.database_path, + settings.data_dir / "backups", + "subtitle-editor-rebuild", + ) with get_connection() as connection: connection.executescript( @@ -113,6 +119,11 @@ def init_db() -> None: output_file_name TEXT, status TEXT NOT NULL DEFAULT 'pending', error_message TEXT, + source_start_ms INTEGER, + source_end_ms INTEGER, + source_duration_ms INTEGER, + source_fingerprint TEXT, + snapshot_source TEXT NOT NULL DEFAULT 'legacy_inferred', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(task_id) REFERENCES tasks(id), @@ -156,6 +167,10 @@ def init_db() -> None: font_color TEXT NOT NULL DEFAULT '#ffffff', stroke_color TEXT NOT NULL DEFAULT '#111827', shadow_enabled INTEGER NOT NULL DEFAULT 1, + outline_width REAL NOT NULL DEFAULT 3, + shadow_depth REAL NOT NULL DEFAULT 1, + safe_area_percent REAL NOT NULL DEFAULT 5, + speaker_styles_json TEXT NOT NULL DEFAULT '{}', is_default INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -165,18 +180,75 @@ def init_db() -> None: id TEXT PRIMARY KEY, task_id TEXT NOT NULL, output_clip_id TEXT NOT NULL, + revision_id TEXT, style_preset_id TEXT, status TEXT NOT NULL DEFAULT 'pending', subtitle_file_path TEXT, output_file_path TEXT, error_message 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(style_preset_id) REFERENCES subtitle_style_presets(id) ); + CREATE TABLE IF NOT EXISTS subtitle_tracks ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + track_type TEXT NOT NULL, + output_clip_id TEXT, + name TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'zh-CN', + source_track_id TEXT, + source_revision_id TEXT, + source_fingerprint TEXT NOT NULL DEFAULT '', + active_revision_id TEXT, + sync_status TEXT NOT NULL DEFAULT 'up_to_date', + has_manual_edits INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(task_id, track_type, output_clip_id), + FOREIGN KEY(task_id) REFERENCES tasks(id), + FOREIGN KEY(output_clip_id) REFERENCES output_clip(id), + FOREIGN KEY(source_track_id) REFERENCES subtitle_tracks(id) + ); + + CREATE TABLE IF NOT EXISTS subtitle_revisions ( + id TEXT PRIMARY KEY, + track_id TEXT NOT NULL, + revision_number INTEGER NOT NULL, + origin TEXT NOT NULL, + parent_revision_id TEXT, + status TEXT NOT NULL DEFAULT 'draft', + note TEXT, + cue_count INTEGER NOT NULL DEFAULT 0, + checksum TEXT NOT NULL, + created_at TEXT NOT NULL, + approved_at TEXT, + UNIQUE(track_id, revision_number), + FOREIGN KEY(track_id) REFERENCES subtitle_tracks(id) ON DELETE CASCADE, + FOREIGN KEY(parent_revision_id) REFERENCES subtitle_revisions(id) + ); + + CREATE TABLE IF NOT EXISTS subtitle_cues ( + id TEXT PRIMARY KEY, + revision_id TEXT NOT NULL, + cue_index INTEGER NOT NULL, + start_ms INTEGER NOT NULL, + end_ms INTEGER NOT NULL, + text TEXT NOT NULL, + confidence REAL, + speaker TEXT NOT NULL DEFAULT '', + source_cue_id TEXT, + created_at TEXT NOT NULL, + UNIQUE(revision_id, cue_index), + FOREIGN KEY(revision_id) REFERENCES subtitle_revisions(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS publish_platform_configs ( platform TEXT PRIMARY KEY, app_name TEXT NOT NULL DEFAULT '', @@ -431,6 +503,7 @@ def init_db() -> None: _migrate_ai_analysis_runs_table(connection) _migrate_subtitle_style_presets_table(connection) _migrate_subtitle_jobs_table(connection) + _migrate_subtitle_editor_tables(connection) _migrate_publish_platform_configs_table(connection) _migrate_publish_accounts_table(connection) _migrate_publish_jobs_table(connection) @@ -468,6 +541,26 @@ def _requires_long_live_schema_migration(database_path) -> bool: ) +def _requires_subtitle_editor_schema_migration(database_path) -> bool: + """已有数据库缺少字幕 revision 结构时,先做在线备份。""" + 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) + output_columns = {row[1] for row in connection.execute("PRAGMA table_info(output_clip)").fetchall()} + job_columns = {row[1] for row in connection.execute("PRAGMA table_info(subtitle_jobs)").fetchall()} + table_names = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + finally: + if connection: + connection.close() + return ( + "source_start_ms" not in output_columns + or "revision_id" not in job_columns + or not {"subtitle_tracks", "subtitle_revisions", "subtitle_cues"} <= table_names + ) + + 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} @@ -491,6 +584,10 @@ 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_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)", + "CREATE INDEX IF NOT EXISTS idx_subtitle_cues_revision_time ON subtitle_cues(revision_id, start_ms, cue_index)", # 发布任务(按状态、平台、时间;按任务、输出切片) "CREATE INDEX IF NOT EXISTS idx_publish_jobs_status_platform_created ON publish_jobs(status, platform, created_at)", "CREATE INDEX IF NOT EXISTS idx_publish_jobs_task_output ON publish_jobs(task_id, output_clip_id)", @@ -705,6 +802,11 @@ def _migrate_output_clip_table(connection: sqlite3.Connection) -> None: "updated_at": "ALTER TABLE output_clip ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''", "cut_run_id": "ALTER TABLE output_clip ADD COLUMN cut_run_id TEXT", "is_active": "ALTER TABLE output_clip ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1", + "source_start_ms": "ALTER TABLE output_clip ADD COLUMN source_start_ms INTEGER", + "source_end_ms": "ALTER TABLE output_clip ADD COLUMN source_end_ms INTEGER", + "source_duration_ms": "ALTER TABLE output_clip ADD COLUMN source_duration_ms INTEGER", + "source_fingerprint": "ALTER TABLE output_clip ADD COLUMN source_fingerprint TEXT", + "snapshot_source": "ALTER TABLE output_clip ADD COLUMN snapshot_source TEXT NOT NULL DEFAULT 'legacy_inferred'", } for column, statement in migrations.items(): @@ -777,6 +879,10 @@ def _migrate_subtitle_style_presets_table(connection: sqlite3.Connection) -> Non "font_color": "ALTER TABLE subtitle_style_presets ADD COLUMN font_color TEXT NOT NULL DEFAULT '#ffffff'", "stroke_color": "ALTER TABLE subtitle_style_presets ADD COLUMN stroke_color TEXT NOT NULL DEFAULT '#111827'", "shadow_enabled": "ALTER TABLE subtitle_style_presets ADD COLUMN shadow_enabled INTEGER NOT NULL DEFAULT 1", + "outline_width": "ALTER TABLE subtitle_style_presets ADD COLUMN outline_width REAL NOT NULL DEFAULT 3", + "shadow_depth": "ALTER TABLE subtitle_style_presets ADD COLUMN shadow_depth REAL NOT NULL DEFAULT 1", + "safe_area_percent": "ALTER TABLE subtitle_style_presets ADD COLUMN safe_area_percent REAL NOT NULL DEFAULT 5", + "speaker_styles_json": "ALTER TABLE subtitle_style_presets ADD COLUMN speaker_styles_json TEXT NOT NULL DEFAULT '{}'", "is_default": "ALTER TABLE subtitle_style_presets ADD COLUMN is_default INTEGER NOT NULL DEFAULT 1", "created_at": "ALTER TABLE subtitle_style_presets ADD COLUMN created_at TEXT NOT NULL DEFAULT ''", "updated_at": "ALTER TABLE subtitle_style_presets ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''", @@ -794,6 +900,7 @@ def _migrate_subtitle_jobs_table(connection: sqlite3.Connection) -> None: migrations = { "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", "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", @@ -813,6 +920,48 @@ def _migrate_subtitle_jobs_table(connection: sqlite3.Connection) -> None: connection.execute("UPDATE subtitle_jobs SET is_active = 1 WHERE is_active IS NULL") +def _migrate_subtitle_editor_tables(connection: sqlite3.Connection) -> None: + """创建不可变字幕轨、revision 与 cue 数据层。""" + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS subtitle_tracks ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, track_type TEXT NOT NULL, + output_clip_id TEXT, name TEXT NOT NULL, language TEXT NOT NULL DEFAULT 'zh-CN', + source_track_id TEXT, source_revision_id TEXT, source_fingerprint TEXT NOT NULL DEFAULT '', + active_revision_id TEXT, + sync_status TEXT NOT NULL DEFAULT 'up_to_date', + has_manual_edits INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + UNIQUE(task_id, track_type, output_clip_id), + FOREIGN KEY(task_id) REFERENCES tasks(id), + FOREIGN KEY(output_clip_id) REFERENCES output_clip(id), + FOREIGN KEY(source_track_id) REFERENCES subtitle_tracks(id) + ); + CREATE TABLE IF NOT EXISTS subtitle_revisions ( + id TEXT PRIMARY KEY, track_id TEXT NOT NULL, revision_number INTEGER NOT NULL, + origin TEXT NOT NULL, parent_revision_id TEXT, status TEXT NOT NULL DEFAULT 'draft', + note TEXT, cue_count INTEGER NOT NULL DEFAULT 0, checksum TEXT NOT NULL, + created_at TEXT NOT NULL, approved_at TEXT, + UNIQUE(track_id, revision_number), + FOREIGN KEY(track_id) REFERENCES subtitle_tracks(id) ON DELETE CASCADE, + FOREIGN KEY(parent_revision_id) REFERENCES subtitle_revisions(id) + ); + CREATE TABLE IF NOT EXISTS subtitle_cues ( + id TEXT PRIMARY KEY, revision_id TEXT NOT NULL, cue_index INTEGER NOT NULL, + start_ms INTEGER NOT NULL, end_ms INTEGER NOT NULL, text TEXT NOT NULL, + confidence REAL, speaker TEXT NOT NULL DEFAULT '', source_cue_id TEXT, + created_at TEXT NOT NULL, UNIQUE(revision_id, cue_index), + FOREIGN KEY(revision_id) REFERENCES subtitle_revisions(id) ON DELETE CASCADE + ); + """ + ) + track_columns = _get_table_columns(connection, "subtitle_tracks") + if "source_fingerprint" not in track_columns: + connection.execute( + "ALTER TABLE subtitle_tracks ADD COLUMN source_fingerprint TEXT NOT NULL DEFAULT ''" + ) + + def _migrate_publish_platform_configs_table(connection: sqlite3.Connection) -> None: columns = _get_table_columns(connection, "publish_platform_configs") if not columns: diff --git a/app/main.py b/app/main.py index b67d83e..e75c42c 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, tasks +from app.routers import ai_prompts, files, 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 @@ -135,6 +135,7 @@ async def security_middleware(request: Request, call_next): app.include_router(pages.router) 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) diff --git a/app/models/subtitle.py b/app/models/subtitle.py new file mode 100644 index 0000000..47b3a20 --- /dev/null +++ b/app/models/subtitle.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field, model_validator + + +class SubtitleCueInput(BaseModel): + id: str | None = Field(default=None, max_length=64) + start_ms: int = Field(ge=0) + end_ms: int = Field(gt=0) + text: str = Field(min_length=1, max_length=4000) + confidence: float | None = Field(default=None, ge=0, le=1) + speaker: str = Field(default="", max_length=80) + source_cue_id: str | None = Field(default=None, max_length=64) + + @model_validator(mode="after") + def validate_range(self): + if self.end_ms <= self.start_ms: + raise ValueError("字幕结束时间必须晚于开始时间") + return self + + +class SubtitleRevisionCreate(BaseModel): + base_revision_id: str | None = Field(default=None, max_length=64) + cues: list[SubtitleCueInput] = Field(default_factory=list, max_length=20000) + note: str = Field(default="", max_length=500) + + +class SubtitleOperation(BaseModel): + type: Literal["update", "split", "merge", "add", "delete", "shift", "replace"] + cue_id: str | None = Field(default=None, max_length=64) + cue_ids: list[str] = Field(default_factory=list, max_length=20000) + start_ms: int | None = Field(default=None, ge=0) + end_ms: int | None = Field(default=None, gt=0) + split_ms: int | None = Field(default=None, gt=0) + text: str | None = Field(default=None, max_length=4000) + second_text: str | None = Field(default=None, max_length=4000) + speaker: str | None = Field(default=None, max_length=80) + confidence: float | None = Field(default=None, ge=0, le=1) + delta_ms: int | None = Field(default=None, ge=-86_400_000, le=86_400_000) + search: str | None = Field(default=None, max_length=500) + replacement: str | None = Field(default=None, max_length=500) + cue: SubtitleCueInput | None = None + + +class SubtitleOperationsRequest(BaseModel): + base_revision_id: str = Field(min_length=1, max_length=64) + operations: list[SubtitleOperation] = Field(min_length=1, max_length=1000) + note: str = Field(default="", max_length=500) + + +class SubtitleApproveRequest(BaseModel): + revision_id: str = Field(min_length=1, max_length=64) + + +class SubtitleSyncRequest(BaseModel): + force: bool = False + + +class SubtitleAIRevisionRequest(BaseModel): + revision_id: str = Field(min_length=1, max_length=64) + cue_ids: list[str] = Field(default_factory=list, max_length=500) + instructions: str = Field(default="", max_length=2000) + + +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) + position: Literal["bottom_center", "middle_lower", "top_center"] = "bottom_center" + font_color: str = Field(default="#ffffff", pattern=r"^#[0-9A-Fa-f]{6}$") + stroke_color: str = Field(default="#111827", pattern=r"^#[0-9A-Fa-f]{6}$") + shadow_enabled: bool = True + outline_width: float = Field(default=3, ge=0, le=20) + shadow_depth: float = Field(default=1, ge=0, le=20) + safe_area_percent: float = Field(default=5, ge=0, le=25) + speaker_styles: dict[str, dict[str, Any]] = Field(default_factory=dict) diff --git a/app/models/task.py b/app/models/task.py index abe3967..de3c1fd 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -136,6 +136,10 @@ class SubtitleStyleUpdate(BaseModel): font_color: str = Field(default="#ffffff", pattern=r"^#[0-9a-fA-F]{6}$") stroke_color: str = Field(default="#111827", pattern=r"^#[0-9a-fA-F]{6}$") shadow_enabled: bool = True + outline_width: float = Field(default=3, ge=0, le=20) + shadow_depth: float = Field(default=1, ge=0, le=20) + safe_area_percent: float = Field(default=5, ge=0, le=25) + speaker_styles: dict[str, dict[str, Any]] = Field(default_factory=dict) class PublishPlatformConfigUpdate(BaseModel): diff --git a/app/routers/subtitles.py b/app/routers/subtitles.py new file mode 100644 index 0000000..3fb2c59 --- /dev/null +++ b/app/routers/subtitles.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from urllib.parse import quote + +from fastapi import APIRouter, File, Form, HTTPException, Query, Response, UploadFile + +from app.models.subtitle import ( + SubtitleApproveRequest, + SubtitleOperationsRequest, + SubtitleRevisionCreate, + SubtitleSyncRequest, +) +from app.services.subtitle_data_service import ( + SubtitleRevisionConflict, + apply_revision_operations, + approve_revision, + create_manual_revision, + ensure_source_track, + export_subtitle_text, + get_revision_cues, + get_track, + get_waveform_peaks, + import_subtitle_text, + list_revisions, + list_task_tracks, + sync_clip_track, +) + + +router = APIRouter(prefix="/api/subtitles", tags=["subtitles"]) + + +@router.get("/tasks/{task_id}/tracks") +def list_tracks(task_id: str, ensure: bool = Query(default=True)) -> dict: + return _call(lambda: {"tracks": list_task_tracks(task_id, ensure=ensure)}) + + +@router.post("/tasks/{task_id}/source-track") +def generate_source_track(task_id: str, payload: SubtitleSyncRequest) -> dict: + return _call(lambda: {"track": ensure_source_track(task_id, force=payload.force)}) + + +@router.get("/tracks/{track_id}") +def read_track(track_id: str) -> dict: + return _call(lambda: {"track": get_track(track_id)}) + + +@router.get("/tracks/{track_id}/revisions") +def read_revisions(track_id: str) -> dict: + return _call(lambda: {"revisions": list_revisions(track_id)}) + + +@router.get("/tracks/{track_id}/cues") +def read_cues( + track_id: str, + revision_id: str | None = Query(default=None), + start_ms: int | None = Query(default=None, ge=0), + end_ms: int | None = Query(default=None, ge=0), + offset: int = Query(default=0, ge=0), + limit: int = Query(default=500, ge=1, le=2000), +) -> dict: + return _call( + lambda: get_revision_cues( + track_id, + revision_id=revision_id, + start_ms=start_ms, + end_ms=end_ms, + offset=offset, + limit=limit, + ) + ) + + +@router.post("/tracks/{track_id}/revisions") +def save_revision(track_id: str, payload: SubtitleRevisionCreate) -> dict: + return _call( + lambda: { + "revision": create_manual_revision( + track_id, + base_revision_id=payload.base_revision_id, + cues=payload.cues, + note=payload.note, + ) + } + ) + + +@router.post("/tracks/{track_id}/operations") +def apply_operations(track_id: str, payload: SubtitleOperationsRequest) -> dict: + return _call( + lambda: { + "revision": apply_revision_operations( + track_id, + base_revision_id=payload.base_revision_id, + operations=payload.operations, + note=payload.note, + ) + } + ) + + +@router.post("/tracks/{track_id}/approve") +def approve(track_id: str, payload: SubtitleApproveRequest) -> dict: + return _call(lambda: {"revision": approve_revision(track_id, payload.revision_id)}) + + +@router.post("/tracks/{track_id}/sync-source") +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}/import") +async def import_subtitle( + track_id: str, + file: UploadFile = File(...), + format_name: str | None = Form(default=None), +) -> dict: + raw = await file.read(10 * 1024 * 1024 + 1) + if len(raw) > 10 * 1024 * 1024: + raise HTTPException(status_code=413, detail="字幕文件不能超过 10 MB") + try: + content = raw.decode("utf-8-sig") + except UnicodeDecodeError: + try: + content = raw.decode("gb18030") + except UnicodeDecodeError as exc: + raise HTTPException(status_code=422, detail="字幕文件必须是 UTF-8 或 GB18030 文本") from exc + resolved_format = format_name or (file.filename or "").rsplit(".", 1)[-1] + return _call( + lambda: { + "revision": import_subtitle_text( + track_id, + content=content, + format_name=resolved_format, + note=f"导入文件:{file.filename or 'subtitle'}", + ) + } + ) + + +@router.get("/tracks/{track_id}/export") +def export_subtitle( + track_id: str, + format_name: str = Query(pattern=r"^(srt|vtt|ass)$"), + revision_id: str | None = Query(default=None), +) -> Response: + try: + content, media_type, filename = export_subtitle_text( + track_id, + revision_id=revision_id, + format_name=format_name, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return Response( + content=content.encode("utf-8-sig"), + media_type=f"{media_type}; charset=utf-8", + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"}, + ) + + +@router.get("/tracks/{track_id}/peaks") +def waveform_peaks( + track_id: str, + max_points: int = Query(default=12000, ge=1000, le=50000), +) -> dict: + return _call(lambda: get_waveform_peaks(track_id, max_points=max_points)) + + +def _call(callback): + try: + return callback() + except SubtitleRevisionConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc diff --git a/app/services/subtitle_data_service.py b/app/services/subtitle_data_service.py new file mode 100644 index 0000000..64f8f99 --- /dev/null +++ b/app/services/subtitle_data_service.py @@ -0,0 +1,1162 @@ +"""不可变字幕轨、revision、cue、导入导出与长音频波形服务。""" + +from __future__ import annotations + +from array import array +from datetime import datetime, timezone +import hashlib +import json +import math +from pathlib import Path +import re +import shutil +import subprocess +import sys +from typing import Any, Iterable +from uuid import uuid4 + +import pysubs2 + +from app.core.config import settings +from app.db.database import get_connection +from app.services.ai.ai_clip_analyzer import _extract_transcript_rows, _read_transcript +from app.services.storage_service import ( + get_artifact_paths, + get_source_video_path, + resolve_video_file_path, +) +from app.services.transcription_checkpoint_service import fingerprint_file + + +MAX_CUES_PER_REVISION = 20_000 +MAX_RANGE_LIMIT = 2_000 +DEFAULT_RANGE_LIMIT = 500 +QUALITY_MAX_LINES = 2 +QUALITY_MAX_CHINESE_CHARS_PER_LINE = 18 +QUALITY_MIN_DURATION_MS = 800 +QUALITY_MAX_DURATION_MS = 7_000 +QUALITY_MIN_GAP_MS = 80 +QUALITY_MAX_CHINESE_CHARS_PER_SECOND = 12 + + +class SubtitleRevisionConflict(ValueError): + pass + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def list_task_tracks(task_id: str, *, ensure: bool = True) -> list[dict[str, Any]]: + if ensure: + ensure_source_track(task_id) + with get_connection() as connection: + output_rows = connection.execute( + """ + SELECT id FROM output_clip + WHERE task_id = ? AND is_active = 1 AND status = 'completed' + ORDER BY created_at ASC + """, + (task_id,), + ).fetchall() + for row in output_rows: + ensure_clip_track(task_id, row["id"]) + + with get_connection() as connection: + rows = connection.execute( + """ + SELECT st.*, sr.revision_number, sr.origin AS revision_origin, + sr.status AS revision_status, sr.cue_count, + oc.output_file_name, oc.source_start_ms, oc.source_end_ms, + oc.source_duration_ms, oc.snapshot_source + FROM subtitle_tracks st + LEFT JOIN subtitle_revisions sr ON sr.id = st.active_revision_id + LEFT JOIN output_clip oc ON oc.id = st.output_clip_id + WHERE st.task_id = ? AND st.is_active = 1 + ORDER BY CASE st.track_type WHEN 'source' THEN 0 ELSE 1 END, + oc.source_start_ms, st.created_at + """, + (task_id,), + ).fetchall() + return [_track_to_dict(dict(row)) for row in rows] + + +def get_track(track_id: str) -> dict[str, Any]: + with get_connection() as connection: + row = connection.execute( + """ + SELECT st.*, sr.revision_number, sr.origin AS revision_origin, + sr.status AS revision_status, sr.cue_count, + oc.output_file_name, oc.source_start_ms, oc.source_end_ms, + oc.source_duration_ms, oc.snapshot_source + FROM subtitle_tracks st + LEFT JOIN subtitle_revisions sr ON sr.id = st.active_revision_id + LEFT JOIN output_clip oc ON oc.id = st.output_clip_id + WHERE st.id = ? + """, + (track_id,), + ).fetchone() + if not row: + raise ValueError("字幕轨不存在") + return _track_to_dict(dict(row)) + + +def ensure_source_track(task_id: str, *, force: bool = False) -> dict[str, Any]: + with get_connection() as connection: + if not connection.execute("SELECT id FROM tasks WHERE id = ?", (task_id,)).fetchone(): + raise ValueError("任务不存在") + existing = connection.execute( + """ + SELECT * FROM subtitle_tracks + WHERE task_id = ? AND track_type = 'source' AND is_active = 1 + ORDER BY created_at DESC LIMIT 1 + """, + (task_id,), + ).fetchone() + + cues, source_fingerprint, origin = _load_source_cues(task_id) + if not cues: + raise ValueError("当前任务没有可用的结构化转写或时间戳 Markdown") + if existing and existing["source_fingerprint"] == source_fingerprint and existing["active_revision_id"]: + return get_track(existing["id"]) + if existing and existing["has_manual_edits"] and not force: + with get_connection() as connection: + connection.execute( + "UPDATE subtitle_tracks SET sync_status = 'pending_source_refresh', updated_at = ? WHERE id = ?", + (_now_iso(), existing["id"]), + ) + connection.commit() + return get_track(existing["id"]) + + now = _now_iso() + track_id = existing["id"] if existing else uuid4().hex + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + if not existing: + connection.execute( + """ + INSERT INTO subtitle_tracks ( + id, task_id, track_type, output_clip_id, name, language, + source_fingerprint, sync_status, has_manual_edits, + is_active, created_at, updated_at + ) VALUES (?, ?, 'source', NULL, '原片主字幕', 'zh-CN', ?, + 'up_to_date', 0, 1, ?, ?) + """, + (track_id, task_id, source_fingerprint, now, now), + ) + revision = _insert_revision_with_connection( + connection, + track_id, + cues, + origin=origin, + parent_revision_id=existing["active_revision_id"] if existing else None, + status="draft", + note="从结构化转写生成原片主时间轴" if origin == "asr" else "从旧版 Markdown 兼容生成", + activate=True, + ) + connection.execute( + """ + UPDATE subtitle_tracks + SET source_fingerprint = ?, active_revision_id = ?, sync_status = 'up_to_date', + has_manual_edits = 0, updated_at = ? + WHERE id = ? + """, + (source_fingerprint, revision["id"], now, track_id), + ) + connection.commit() + + with get_connection() as connection: + clip_tracks = connection.execute( + "SELECT id FROM subtitle_tracks WHERE task_id = ? AND track_type = 'clip' AND is_active = 1", + (task_id,), + ).fetchall() + for clip_track in clip_tracks: + sync_clip_track(clip_track["id"], force=False) + return get_track(track_id) + + +def ensure_clip_track(task_id: str, output_clip_id: str) -> dict[str, Any]: + source_track = ensure_source_track(task_id) + output = ensure_output_clip_snapshot(task_id, output_clip_id) + with get_connection() as connection: + existing = connection.execute( + """ + SELECT * FROM subtitle_tracks + WHERE task_id = ? AND track_type = 'clip' AND output_clip_id = ? AND is_active = 1 + """, + (task_id, output_clip_id), + ).fetchone() + if not existing: + now = _now_iso() + track_id = uuid4().hex + connection.execute( + """ + INSERT INTO subtitle_tracks ( + id, task_id, track_type, output_clip_id, name, language, + source_track_id, source_revision_id, source_fingerprint, + sync_status, has_manual_edits, is_active, created_at, updated_at + ) VALUES (?, ?, 'clip', ?, ?, 'zh-CN', ?, NULL, ?, + 'pending_sync', 0, 1, ?, ?) + """, + ( + track_id, + task_id, + output_clip_id, + output.get("output_file_name") or "切片字幕", + source_track["id"], + source_track.get("source_fingerprint") or "", + now, + now, + ), + ) + connection.commit() + else: + track_id = existing["id"] + sync_clip_track(track_id, force=False) + return get_track(track_id) + + +def sync_clip_track(track_id: str, *, force: bool = False) -> dict[str, Any]: + track = get_track(track_id) + if track["track_type"] != "clip": + raise ValueError("只有切片字幕轨可以从原片同步") + source_track = get_track(track["source_track_id"]) + source_revision_id = source_track.get("active_revision_id") + if not source_revision_id: + raise ValueError("原片字幕还没有可同步 revision") + if track.get("has_manual_edits") and not force: + if track.get("source_revision_id") != source_revision_id: + with get_connection() as connection: + connection.execute( + "UPDATE subtitle_tracks SET sync_status = 'pending_sync', updated_at = ? WHERE id = ?", + (_now_iso(), track_id), + ) + connection.commit() + return get_track(track_id) + + if track.get("source_revision_id") == source_revision_id and track.get("active_revision_id"): + return track + + output = ensure_output_clip_snapshot(track["task_id"], track["output_clip_id"]) + source_start_ms = int(output["source_start_ms"]) + source_end_ms = int(output["source_end_ms"]) + source_cues = _fetch_all_revision_cues(source_revision_id) + local_cues = inherit_cues_for_clip(source_cues, source_start_ms, source_end_ms) + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + revision = _insert_revision_with_connection( + connection, + track_id, + local_cues, + origin="source_sync", + parent_revision_id=track.get("active_revision_id"), + status="draft", + note=f"继承原片 revision {source_revision_id}", + activate=True, + ) + connection.execute( + """ + UPDATE subtitle_tracks + SET source_revision_id = ?, source_fingerprint = ?, active_revision_id = ?, + sync_status = 'up_to_date', has_manual_edits = 0, updated_at = ? + WHERE id = ? + """, + ( + source_revision_id, + source_track.get("source_fingerprint") or "", + revision["id"], + _now_iso(), + track_id, + ), + ) + connection.commit() + return get_track(track_id) + + +def ensure_output_clip_snapshot(task_id: str, output_clip_id: str) -> dict[str, Any]: + with get_connection() as connection: + row = connection.execute( + """ + SELECT oc.*, cc.start_time, cc.end_time + FROM output_clip oc + LEFT JOIN clip_candidates cc ON cc.id = oc.clip_candidate_id + WHERE oc.id = ? AND oc.task_id = ? + """, + (output_clip_id, task_id), + ).fetchone() + if not row: + raise ValueError("切片记录不存在") + output = dict(row) + if output.get("source_start_ms") is not None and output.get("source_end_ms") is not None: + return output + if not output.get("start_time") or not output.get("end_time"): + raise ValueError("旧切片缺少可推断的原片边界,请重新生成切片") + from app.services.task_service import _parse_time_to_seconds + + start_ms = round(_parse_time_to_seconds(output["start_time"]) * 1000) + end_ms = round(_parse_time_to_seconds(output["end_time"]) * 1000) + if end_ms <= start_ms: + raise ValueError("旧切片的原片边界无效,请重新生成切片") + task = _get_task_row(task_id) + source_path = get_source_video_path(task) + source_fingerprint = fingerprint_file(source_path) if source_path and source_path.exists() else "" + with get_connection() as connection: + connection.execute( + """ + UPDATE output_clip + SET source_start_ms = ?, source_end_ms = ?, source_duration_ms = ?, + source_fingerprint = ?, snapshot_source = 'legacy_inferred', updated_at = ? + WHERE id = ? + """, + (start_ms, end_ms, end_ms - start_ms, source_fingerprint, _now_iso(), output_clip_id), + ) + connection.commit() + return ensure_output_clip_snapshot(task_id, output_clip_id) + + +def inherit_cues_for_clip( + source_cues: Iterable[dict[str, Any]], + source_start_ms: int, + source_end_ms: int, +) -> list[dict[str, Any]]: + inherited = [] + for cue in source_cues: + cue_start = int(cue["start_ms"]) + cue_end = int(cue["end_ms"]) + if cue_end <= source_start_ms or cue_start >= source_end_ms: + continue + local_start = max(0, cue_start - source_start_ms) + local_end = min(source_end_ms, cue_end) - source_start_ms + if local_end <= local_start: + continue + inherited.append( + { + "start_ms": local_start, + "end_ms": local_end, + "text": cue["text"], + "confidence": cue.get("confidence"), + "speaker": cue.get("speaker") or "", + "source_cue_id": cue.get("id") or cue.get("source_cue_id"), + } + ) + return inherited + + +def create_manual_revision( + track_id: str, + *, + base_revision_id: str | None, + cues: Iterable[Any], + note: str = "", +) -> dict[str, Any]: + track = get_track(track_id) + if (track.get("active_revision_id") or None) != (base_revision_id or None): + raise SubtitleRevisionConflict("字幕已产生新版本,请刷新后再保存,当前编辑没有覆盖新版本") + normalized = [_cue_input_to_dict(cue) for cue in cues] + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + revision = _insert_revision_with_connection( + connection, + track_id, + normalized, + origin="manual", + parent_revision_id=base_revision_id, + status="draft", + note=note or "字幕编辑器自动保存", + activate=True, + ) + connection.execute( + """ + UPDATE subtitle_tracks + SET active_revision_id = ?, has_manual_edits = 1, + sync_status = CASE WHEN track_type = 'clip' THEN 'manual' ELSE sync_status END, + updated_at = ? + WHERE id = ? + """, + (revision["id"], _now_iso(), track_id), + ) + connection.commit() + if track["track_type"] == "source": + _sync_dependent_clip_tracks(track["task_id"]) + return get_revision(revision["id"], include_cues=True) + + +def apply_revision_operations( + track_id: str, + *, + base_revision_id: str, + operations: Iterable[Any], + note: str = "", +) -> dict[str, Any]: + cues = _fetch_all_revision_cues(base_revision_id) + for operation_value in operations: + operation = operation_value.model_dump() if hasattr(operation_value, "model_dump") else dict(operation_value) + cues = _apply_operation(cues, operation) + return create_manual_revision( + track_id, + base_revision_id=base_revision_id, + cues=cues, + note=note or "字幕批量编辑", + ) + + +def get_revision(revision_id: str, *, include_cues: bool = False) -> dict[str, Any]: + with get_connection() as connection: + row = connection.execute( + "SELECT * FROM subtitle_revisions WHERE id = ?", + (revision_id,), + ).fetchone() + if not row: + raise ValueError("字幕 revision 不存在") + revision = dict(row) + if include_cues: + revision["cues"] = _fetch_all_revision_cues(revision_id) + revision["quality"] = evaluate_subtitle_quality(revision["cues"]) + return revision + + +def list_revisions(track_id: str) -> list[dict[str, Any]]: + get_track(track_id) + with get_connection() as connection: + rows = connection.execute( + "SELECT * FROM subtitle_revisions WHERE track_id = ? ORDER BY revision_number DESC", + (track_id,), + ).fetchall() + return [dict(row) for row in rows] + + +def get_revision_cues( + track_id: str, + *, + revision_id: str | None = None, + start_ms: int | None = None, + end_ms: int | None = None, + offset: int = 0, + limit: int = DEFAULT_RANGE_LIMIT, +) -> dict[str, Any]: + track = get_track(track_id) + revision_id = revision_id or track.get("active_revision_id") + if not revision_id: + return {"track": track, "revision": None, "cues": [], "total": 0, "quality": {"issues": []}} + revision = get_revision(revision_id) + if revision["track_id"] != track_id: + raise ValueError("revision 不属于当前字幕轨") + clauses = ["revision_id = ?"] + params: list[Any] = [revision_id] + if start_ms is not None: + clauses.append("end_ms > ?") + params.append(max(0, start_ms)) + if end_ms is not None: + clauses.append("start_ms < ?") + params.append(max(0, end_ms)) + safe_limit = max(1, min(MAX_RANGE_LIMIT, int(limit))) + safe_offset = max(0, int(offset)) + where = " AND ".join(clauses) + with get_connection() as connection: + total = connection.execute( + f"SELECT COUNT(*) FROM subtitle_cues WHERE {where}", + params, + ).fetchone()[0] + rows = connection.execute( + f""" + SELECT * FROM subtitle_cues WHERE {where} + ORDER BY cue_index ASC LIMIT ? OFFSET ? + """, + [*params, safe_limit, safe_offset], + ).fetchall() + cues = [dict(row) for row in rows] + return { + "track": track, + "revision": revision, + "cues": cues, + "total": total, + "offset": safe_offset, + "limit": safe_limit, + "quality": evaluate_subtitle_quality(cues), + } + + +def approve_revision(track_id: str, revision_id: str) -> dict[str, Any]: + get_track(track_id) + revision = get_revision(revision_id) + if revision["track_id"] != track_id: + raise ValueError("revision 不属于当前字幕轨") + now = _now_iso() + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + connection.execute( + "UPDATE subtitle_revisions SET status = 'approved', approved_at = ? WHERE id = ?", + (now, revision_id), + ) + connection.execute( + "UPDATE subtitle_tracks SET active_revision_id = ?, updated_at = ? WHERE id = ?", + (revision_id, now, track_id), + ) + connection.commit() + return get_revision(revision_id, include_cues=True) + + +def import_subtitle_text( + track_id: str, + *, + content: str, + format_name: str, + note: str = "", +) -> dict[str, Any]: + format_name = _validate_format(format_name) + try: + document = pysubs2.SSAFile.from_string(content, format_=format_name) + except Exception as exc: + raise ValueError(f"字幕文件解析失败:{exc}") from exc + cues = [ + { + "start_ms": int(event.start), + "end_ms": int(event.end), + "text": event.plaintext.strip(), + "speaker": event.name or "", + "confidence": None, + "source_cue_id": None, + } + for event in document.events + if event.type == "Dialogue" and event.end > event.start and event.plaintext.strip() + ] + track = get_track(track_id) + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + revision = _insert_revision_with_connection( + connection, + track_id, + cues, + origin="import", + parent_revision_id=track.get("active_revision_id"), + status="draft", + note=note or f"导入 {format_name.upper()} 字幕", + activate=True, + ) + connection.execute( + "UPDATE subtitle_tracks SET active_revision_id = ?, has_manual_edits = 1, sync_status = 'manual', updated_at = ? WHERE id = ?", + (revision["id"], _now_iso(), track_id), + ) + connection.commit() + if track["track_type"] == "source": + _sync_dependent_clip_tracks(track["task_id"]) + return get_revision(revision["id"], include_cues=True) + + +def export_subtitle_text( + track_id: str, + *, + revision_id: str | None = None, + format_name: str, +) -> tuple[str, str, str]: + format_name = _validate_format(format_name) + track = get_track(track_id) + revision_id = revision_id or track.get("active_revision_id") + if not revision_id: + raise ValueError("当前字幕轨没有可导出的 revision") + revision = get_revision(revision_id) + if revision["track_id"] != track_id: + raise ValueError("revision 不属于当前字幕轨") + document = _build_pysubs2_document(track, revision_id, dynamic_ass=format_name == "ass") + content = document.to_string(format_name) + media_type = { + "srt": "application/x-subrip", + "vtt": "text/vtt", + "ass": "text/x-ssa", + }[format_name] + return content, media_type, f"{_safe_file_stem(track['name'])}.{format_name}" + + +def serialize_revision_to_ass(track_id: str, revision_id: str) -> str: + track = get_track(track_id) + revision = get_revision(revision_id) + if revision["track_id"] != track_id: + raise ValueError("revision 不属于当前字幕轨") + return _build_pysubs2_document(track, revision_id, dynamic_ass=True).to_string("ass") + + +def evaluate_subtitle_quality(cues: Iterable[dict[str, Any]]) -> dict[str, Any]: + ordered = sorted((dict(cue) for cue in cues), key=lambda cue: (int(cue["start_ms"]), int(cue["end_ms"]))) + issues: list[dict[str, Any]] = [] + previous = None + for cue in ordered: + cue_id = cue.get("id") or "" + text = str(cue.get("text") or "") + duration = int(cue["end_ms"]) - int(cue["start_ms"]) + lines = text.splitlines() or [text] + if len(lines) > QUALITY_MAX_LINES: + issues.append(_issue(cue_id, "too_many_lines", "warning", "建议最多 2 行")) + if any(_chinese_char_count(line) > QUALITY_MAX_CHINESE_CHARS_PER_LINE for line in lines): + issues.append(_issue(cue_id, "line_too_long", "warning", "单行建议不超过 18 个中文字符")) + if duration < QUALITY_MIN_DURATION_MS: + issues.append(_issue(cue_id, "too_short", "warning", "字幕显示时间短于 800ms")) + if duration > QUALITY_MAX_DURATION_MS: + issues.append(_issue(cue_id, "too_long", "warning", "字幕显示时间长于 7 秒")) + chars_per_second = _chinese_char_count(text) / max(0.001, duration / 1000) + if chars_per_second > QUALITY_MAX_CHINESE_CHARS_PER_SECOND: + issues.append(_issue(cue_id, "reading_speed", "warning", "中文阅读速度过快")) + if previous: + gap = int(cue["start_ms"]) - int(previous["end_ms"]) + if gap < 0: + issues.append(_issue(cue_id, "overlap", "error", "字幕时间与上一条重叠")) + elif gap < QUALITY_MIN_GAP_MS: + issues.append(_issue(cue_id, "small_gap", "warning", "与上一条间隔小于 80ms")) + previous = cue + return { + "issues": issues, + "error_count": sum(1 for item in issues if item["severity"] == "error"), + "warning_count": sum(1 for item in issues if item["severity"] == "warning"), + } + + +def get_waveform_peaks(track_id: str, *, max_points: int = 12_000) -> dict[str, Any]: + track = get_track(track_id) + max_points = max(1_000, min(50_000, int(max_points))) + media_path = _track_media_path(track) + if not media_path or not media_path.exists(): + raise ValueError("字幕轨对应的媒体文件不存在") + fingerprint = fingerprint_file(media_path) + cache_dir = get_artifact_paths(track["task_id"])["transcript_path"].parent + cache_dir.mkdir(parents=True, exist_ok=True) + cache_path = cache_dir / f"waveform_{track['track_type']}_{fingerprint[:12]}_{max_points}.json" + if cache_path.exists(): + try: + cached = json.loads(cache_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + cached = None + if isinstance(cached, dict) and cached.get("fingerprint") == fingerprint: + return {**cached, "cached": True} + if not shutil.which("ffmpeg"): + raise RuntimeError("FFmpeg 不可用,无法预计算波形 peaks") + command = [ + "ffmpeg", "-v", "error", "-i", str(media_path), "-map", "0:a:0", + "-ac", "1", "-ar", "100", "-f", "s16le", "pipe:1", + ] + try: + result = subprocess.run( + command, + capture_output=True, + timeout=max(900, settings.ffmpeg_audio_extract_timeout), + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError("波形预计算超时") from exc + if result.returncode != 0: + error = result.stderr.decode("utf-8", errors="replace").strip() + raise RuntimeError(error or "波形预计算失败") + samples = array("h") + samples.frombytes(result.stdout) + if sys.byteorder != "little": + samples.byteswap() + peaks = _downsample_peaks(samples, max_points) + duration_ms = round(len(samples) / 100 * 1000) + payload = { + "track_id": track_id, + "fingerprint": fingerprint, + "duration_ms": duration_ms, + "sample_rate": 100, + "point_count": len(peaks), + "peaks": peaks, + "cached": False, + } + temp_path = cache_path.with_suffix(".tmp") + temp_path.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + temp_path.replace(cache_path) + return payload + + +def _insert_revision_with_connection( + connection, + track_id: str, + cues: Iterable[dict[str, Any]], + *, + origin: str, + parent_revision_id: str | None, + status: str, + note: str, + activate: bool, +) -> dict[str, Any]: + normalized = _normalize_cues(cues) + if len(normalized) > MAX_CUES_PER_REVISION: + raise ValueError(f"单个字幕 revision 最多 {MAX_CUES_PER_REVISION} 条") + revision_number = connection.execute( + "SELECT COALESCE(MAX(revision_number), 0) + 1 FROM subtitle_revisions WHERE track_id = ?", + (track_id,), + ).fetchone()[0] + revision_id = uuid4().hex + now = _now_iso() + checksum = _cue_checksum(normalized) + connection.execute( + """ + INSERT INTO subtitle_revisions ( + id, track_id, revision_number, origin, parent_revision_id, + status, note, cue_count, checksum, created_at, approved_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) + """, + ( + revision_id, + track_id, + revision_number, + origin, + parent_revision_id, + status, + note, + len(normalized), + checksum, + now, + ), + ) + for index, cue in enumerate(normalized): + connection.execute( + """ + INSERT INTO subtitle_cues ( + id, revision_id, cue_index, start_ms, end_ms, text, + confidence, speaker, source_cue_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + uuid4().hex, + revision_id, + index, + cue["start_ms"], + cue["end_ms"], + cue["text"], + cue.get("confidence"), + cue.get("speaker") or "", + cue.get("source_cue_id"), + now, + ), + ) + if activate: + connection.execute( + "UPDATE subtitle_tracks SET active_revision_id = ?, updated_at = ? WHERE id = ?", + (revision_id, now, track_id), + ) + return { + "id": revision_id, + "track_id": track_id, + "revision_number": revision_number, + "origin": origin, + "status": status, + "cue_count": len(normalized), + "checksum": checksum, + } + + +def _load_source_cues(task_id: str) -> tuple[list[dict[str, Any]], str, str]: + with get_connection() as connection: + run = connection.execute( + """ + SELECT * FROM transcription_runs + WHERE task_id = ? AND is_active = 1 + ORDER BY updated_at DESC LIMIT 1 + """, + (task_id,), + ).fetchone() + chunks = [] + if run: + chunks = connection.execute( + """ + SELECT * FROM transcription_chunks + WHERE run_id = ? AND status = 'completed' + ORDER BY chunk_index ASC + """, + (run["id"],), + ).fetchall() + if run and chunks: + cues: list[dict[str, Any]] = [] + checksum_parts = [] + overlap_ms = int(run["overlap_seconds"] or 0) * 1000 + for chunk in chunks: + raw = str(chunk["result_json"] or "") + checksum = hashlib.sha256(raw.encode("utf-8")).hexdigest() + if not raw or checksum != str(chunk["result_checksum"] or ""): + continue + checksum_parts.append(checksum) + try: + segments = json.loads(raw) + except json.JSONDecodeError: + continue + for segment in segments if isinstance(segments, list) else []: + if not isinstance(segment, dict): + continue + start_ms = int(chunk["start_ms"]) + round(float(segment.get("start_seconds") or 0) * 1000) + end_ms = int(chunk["start_ms"]) + round(float(segment.get("end_seconds") or 0) * 1000) + if int(chunk["chunk_index"]) > 1 and start_ms < int(chunk["start_ms"]) + overlap_ms: + continue + text = " ".join(str(segment.get("text") or "").split()) + if text and end_ms > start_ms: + cues.append( + { + "start_ms": start_ms, + "end_ms": end_ms, + "text": text, + "confidence": segment.get("confidence"), + "speaker": "", + "source_cue_id": None, + } + ) + if cues: + fingerprint = hashlib.sha256( + f"{run['source_fingerprint']}|{'|'.join(checksum_parts)}".encode("utf-8") + ).hexdigest() + return cues, fingerprint, "asr" + + transcript_path = get_artifact_paths(task_id)["transcript_path"] + if not transcript_path.exists(): + return [], "", "markdown" + transcript_text = _read_transcript(transcript_path) + rows = _extract_transcript_rows(transcript_text) + cues = [ + { + "start_ms": row.start_seconds * 1000, + "end_ms": row.end_seconds * 1000, + "text": row.text, + "confidence": None, + "speaker": "", + "source_cue_id": None, + } + for row in rows + if row.end_seconds > row.start_seconds + ] + return cues, hashlib.sha256(transcript_text.encode("utf-8")).hexdigest(), "markdown" + + +def _apply_operation(cues: list[dict[str, Any]], operation: dict[str, Any]) -> list[dict[str, Any]]: + operation_type = operation["type"] + items = [dict(cue) for cue in cues] + by_id = {str(cue.get("id") or ""): index for index, cue in enumerate(items)} + cue_id = str(operation.get("cue_id") or "") + if operation_type == "update": + if cue_id not in by_id: + raise ValueError("要更新的字幕行不存在") + cue = items[by_id[cue_id]] + for field in ("start_ms", "end_ms", "text", "speaker", "confidence"): + if operation.get(field) is not None: + cue[field] = operation[field] + elif operation_type == "split": + if cue_id not in by_id: + raise ValueError("要拆分的字幕行不存在") + index = by_id[cue_id] + cue = items[index] + split_ms = int(operation.get("split_ms") or 0) + if not int(cue["start_ms"]) < split_ms < int(cue["end_ms"]): + raise ValueError("拆分点必须位于字幕时间范围内") + first_text = str(operation.get("text") or cue["text"]).strip() + second_text = str(operation.get("second_text") or cue["text"]).strip() + first = {**cue, "end_ms": split_ms, "text": first_text} + second = {**cue, "id": None, "start_ms": split_ms, "text": second_text} + items[index : index + 1] = [first, second] + elif operation_type == "merge": + selected_ids = set(operation.get("cue_ids") or []) + selected = [cue for cue in items if cue.get("id") in selected_ids] + if len(selected) < 2: + raise ValueError("合并至少需要两条字幕") + selected.sort(key=lambda cue: int(cue["start_ms"])) + merged = { + **selected[0], + "start_ms": min(int(cue["start_ms"]) for cue in selected), + "end_ms": max(int(cue["end_ms"]) for cue in selected), + "text": str(operation.get("text") or " ".join(str(cue["text"]) for cue in selected)).strip(), + } + first_index = min(items.index(cue) for cue in selected) + items = [cue for cue in items if cue.get("id") not in selected_ids] + items.insert(first_index, merged) + elif operation_type == "add": + cue = operation.get("cue") + if not cue: + raise ValueError("新增字幕缺少 cue") + items.append(_cue_input_to_dict(cue)) + elif operation_type == "delete": + selected_ids = set(operation.get("cue_ids") or ([cue_id] if cue_id else [])) + items = [cue for cue in items if cue.get("id") not in selected_ids] + elif operation_type == "shift": + selected_ids = set(operation.get("cue_ids") or []) + delta = int(operation.get("delta_ms") or 0) + for cue in items: + if not selected_ids or cue.get("id") in selected_ids: + duration = int(cue["end_ms"]) - int(cue["start_ms"]) + cue["start_ms"] = max(0, int(cue["start_ms"]) + delta) + cue["end_ms"] = cue["start_ms"] + duration + elif operation_type == "replace": + search = str(operation.get("search") or "") + if not search: + raise ValueError("搜索文字不能为空") + replacement = str(operation.get("replacement") or "") + for cue in items: + cue["text"] = str(cue["text"]).replace(search, replacement) + else: + raise ValueError("不支持的字幕编辑操作") + return _normalize_cues(items) + + +def _normalize_cues(cues: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + normalized = [] + for raw in cues: + cue = _cue_input_to_dict(raw) + start_ms = int(cue["start_ms"]) + end_ms = int(cue["end_ms"]) + text = str(cue.get("text") or "").strip() + if start_ms < 0 or end_ms <= start_ms: + raise ValueError("字幕时间范围无效") + if not text: + raise ValueError("字幕文字不能为空") + normalized.append( + { + # 编辑操作在同一次请求内会连续执行,必须保留当前 revision 的 cue id。 + # 写入新 revision 时会生成新 id;checksum 也不会把旧 id 算进去。 + "id": cue.get("id"), + "start_ms": start_ms, + "end_ms": end_ms, + "text": text, + "confidence": cue.get("confidence"), + "speaker": str(cue.get("speaker") or "").strip()[:80], + "source_cue_id": cue.get("source_cue_id"), + } + ) + return sorted(normalized, key=lambda cue: (cue["start_ms"], cue["end_ms"])) + + +def _cue_input_to_dict(cue: Any) -> dict[str, Any]: + if hasattr(cue, "model_dump"): + return cue.model_dump() + return dict(cue) + + +def _cue_checksum(cues: list[dict[str, Any]]) -> str: + canonical = [ + { + "start_ms": int(cue["start_ms"]), + "end_ms": int(cue["end_ms"]), + "text": str(cue["text"]), + "confidence": cue.get("confidence"), + "speaker": str(cue.get("speaker") or ""), + "source_cue_id": cue.get("source_cue_id"), + } + for cue in cues + ] + raw = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _fetch_all_revision_cues(revision_id: str) -> list[dict[str, Any]]: + with get_connection() as connection: + rows = connection.execute( + "SELECT * FROM subtitle_cues WHERE revision_id = ? ORDER BY cue_index ASC", + (revision_id,), + ).fetchall() + return [dict(row) for row in rows] + + +def _sync_dependent_clip_tracks(task_id: str) -> None: + with get_connection() as connection: + rows = connection.execute( + "SELECT id FROM subtitle_tracks WHERE task_id = ? AND track_type = 'clip' AND is_active = 1", + (task_id,), + ).fetchall() + for row in rows: + sync_clip_track(row["id"], force=False) + + +def _track_to_dict(track: dict[str, Any]) -> dict[str, Any]: + track["has_manual_edits"] = bool(track.get("has_manual_edits")) + track["is_active"] = bool(track.get("is_active")) + if track.get("track_type") == "source": + track["media_url"] = f"/media/tasks/{track['task_id']}/source-video" + else: + track["media_url"] = f"/media/tasks/{track['task_id']}/output-clips/{track['output_clip_id']}" + track["peaks_url"] = f"/api/subtitles/tracks/{track['id']}/peaks" + return track + + +def _get_task_row(task_id: str) -> dict[str, Any]: + with get_connection() as connection: + row = connection.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() + if not row: + raise ValueError("任务不存在") + return dict(row) + + +def _track_media_path(track: dict[str, Any]) -> Path | None: + if track["track_type"] == "source": + return get_source_video_path(_get_task_row(track["task_id"])) + with get_connection() as connection: + row = connection.execute( + "SELECT output_file_path FROM output_clip WHERE id = ? AND task_id = ?", + (track["output_clip_id"], track["task_id"]), + ).fetchone() + return resolve_video_file_path(row["output_file_path"]) if row and row["output_file_path"] else None + + +def _build_pysubs2_document( + track: dict[str, Any], + revision_id: str, + *, + dynamic_ass: bool, +) -> pysubs2.SSAFile: + document = pysubs2.SSAFile() + cues = _fetch_all_revision_cues(revision_id) + style_names: dict[str, str] = {} + if dynamic_ass: + style = _get_default_style() + width, height = _probe_media_dimensions(_track_media_path(track)) + document.info["PlayResX"] = str(width) + document.info["PlayResY"] = str(height) + document.info["ScaledBorderAndShadow"] = "yes" + alignment = { + "top_center": pysubs2.Alignment.TOP_CENTER, + "middle_lower": pysubs2.Alignment.MIDDLE_CENTER, + }.get(style.get("position"), pysubs2.Alignment.BOTTOM_CENTER) + base_height = 1920 if height > width else 1080 + scale = height / base_height + font_size = max(18, float(style.get("font_size") or 42) * scale) + margin_v = round(height * float(style.get("safe_area_percent") or 5) / 100) + default_style = pysubs2.SSAStyle( + fontname=_resolve_font(str(style.get("font_family") or "Microsoft YaHei")), + fontsize=font_size, + primarycolor=_hex_color(style.get("font_color") or "#ffffff"), + outlinecolor=_hex_color(style.get("stroke_color") or "#111827"), + backcolor=pysubs2.Color(0, 0, 0, 127), + bold=True, + outline=float(style.get("outline_width") or 3) * scale, + shadow=float(style.get("shadow_depth") or 1) * scale if style.get("shadow_enabled") else 0, + alignment=alignment, + marginl=round(width * 0.055), + marginr=round(width * 0.055), + marginv=margin_v, + ) + document.styles["Default"] = default_style + speaker_styles = style.get("speaker_styles") or {} + for speaker, overrides in speaker_styles.items(): + style_name = f"Speaker_{len(style_names) + 1}" + speaker_style = default_style.copy() + if isinstance(overrides, dict) and overrides.get("font_color"): + speaker_style.primarycolor = _hex_color(overrides["font_color"]) + document.styles[style_name] = speaker_style + style_names[str(speaker)] = style_name + for cue in cues: + speaker = str(cue.get("speaker") or "") + document.events.append( + pysubs2.SSAEvent( + start=int(cue["start_ms"]), + end=int(cue["end_ms"]), + text=str(cue["text"]).replace("\n", r"\N"), + name=speaker, + style=style_names.get(speaker, "Default"), + ) + ) + return document + + +def _get_default_style() -> dict[str, Any]: + with get_connection() as connection: + row = connection.execute( + "SELECT * FROM subtitle_style_presets WHERE is_default = 1 ORDER BY updated_at DESC LIMIT 1" + ).fetchone() + style = dict(row) if row else {} + try: + style["speaker_styles"] = json.loads(style.get("speaker_styles_json") or "{}") + except json.JSONDecodeError: + style["speaker_styles"] = {} + if not style["speaker_styles"]: + style["speaker_styles"] = { + "主播": {"font_color": "#ffffff"}, + "嘉宾": {"font_color": "#ffd60a"}, + } + style["shadow_enabled"] = bool(style.get("shadow_enabled", True)) + return style + + +def _probe_media_dimensions(media_path: Path | None) -> tuple[int, int]: + if not media_path or not media_path.exists() or not shutil.which("ffprobe"): + return 1080, 1920 + command = [ + "ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height", "-of", "json", str(media_path), + ] + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=settings.ffprobe_timeout, + check=False, + ) + payload = json.loads(result.stdout or "{}") + stream = (payload.get("streams") or [{}])[0] + width = int(stream.get("width") or 0) + height = int(stream.get("height") or 0) + if width > 0 and height > 0: + return width, height + except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError, ValueError, IndexError): + pass + return 1080, 1920 + + +def _resolve_font(font_family: str) -> str: + from app.services.subtitle_workflow_service import _resolve_subtitle_font_family + + return _resolve_subtitle_font_family(font_family) + + +def _hex_color(value: str) -> pysubs2.Color: + match = re.fullmatch(r"#?([0-9A-Fa-f]{6})", str(value or "")) + cleaned = match.group(1) if match else "ffffff" + return pysubs2.Color(int(cleaned[0:2], 16), int(cleaned[2:4], 16), int(cleaned[4:6], 16)) + + +def _downsample_peaks(samples: array, max_points: int) -> list[float]: + if not samples: + return [] + bucket_size = max(1, math.ceil(len(samples) / max_points)) + peaks = [] + for index in range(0, len(samples), bucket_size): + bucket = samples[index : index + bucket_size] + peak = max(bucket, key=lambda value: abs(value)) + peaks.append(round(float(peak) / 32768, 5)) + return peaks + + +def _validate_format(format_name: str) -> str: + normalized = str(format_name or "").lower().lstrip(".") + if normalized not in {"srt", "vtt", "ass"}: + raise ValueError("字幕格式只支持 SRT、VTT 或 ASS") + return normalized + + +def _safe_file_stem(value: str) -> str: + cleaned = re.sub(r"[^\w\-\u4e00-\u9fff]+", "_", value, flags=re.UNICODE).strip("_") + return cleaned[:80] or "subtitles" + + +def _issue(cue_id: str, code: str, severity: str, message: str) -> dict[str, str]: + return {"cue_id": cue_id, "code": code, "severity": severity, "message": message} + + +def _chinese_char_count(value: str) -> int: + return len(re.findall(r"[\u4e00-\u9fff]", value or "")) + + +__all__ = [ + "SubtitleRevisionConflict", + "apply_revision_operations", + "approve_revision", + "create_manual_revision", + "ensure_clip_track", + "ensure_output_clip_snapshot", + "ensure_source_track", + "evaluate_subtitle_quality", + "export_subtitle_text", + "get_revision", + "get_revision_cues", + "get_track", + "get_waveform_peaks", + "import_subtitle_text", + "inherit_cues_for_clip", + "list_revisions", + "list_task_tracks", + "serialize_revision_to_ass", + "sync_clip_track", +] diff --git a/app/services/subtitle_workflow_service.py b/app/services/subtitle_workflow_service.py index 60a6544..d604c2f 100644 --- a/app/services/subtitle_workflow_service.py +++ b/app/services/subtitle_workflow_service.py @@ -3,6 +3,7 @@ 从 task_service 中拆分出来的字幕样式、ASS 渲染和字幕烧录函数。 """ +import json import shutil import subprocess from pathlib import Path @@ -10,7 +11,6 @@ from uuid import uuid4 from app.services.storage_service import get_artifact_paths, resolve_video_file_path -from app.services.transcript_service import read_transcript_range # ---------- 字幕字体常量 ---------- @@ -31,6 +31,10 @@ "completed": "已加字幕", "failed": "字幕失败", } +DEFAULT_SPEAKER_STYLES = { + "主播": {"font_color": "#ffffff"}, + "嘉宾": {"font_color": "#ffd60a"}, +} # ---------- 数据库读/写 ---------- @@ -58,9 +62,19 @@ def get_default_subtitle_style() -> dict: "font_color": "#ffffff", "stroke_color": "#111827", "shadow_enabled": True, + "outline_width": 3, + "shadow_depth": 1, + "safe_area_percent": 5, + "speaker_styles": DEFAULT_SPEAKER_STYLES, } style = dict(row) style["shadow_enabled"] = bool(style.get("shadow_enabled")) + try: + style["speaker_styles"] = json.loads(style.get("speaker_styles_json") or "{}") + except json.JSONDecodeError: + style["speaker_styles"] = {} + if not style["speaker_styles"]: + style["speaker_styles"] = DEFAULT_SPEAKER_STYLES return style @@ -79,7 +93,9 @@ def update_default_subtitle_style(payload) -> dict: """ UPDATE subtitle_style_presets SET font_family = ?, font_size = ?, position = ?, font_color = ?, - stroke_color = ?, shadow_enabled = ?, updated_at = ? + stroke_color = ?, shadow_enabled = ?, outline_width = ?, + shadow_depth = ?, safe_area_percent = ?, speaker_styles_json = ?, + updated_at = ? WHERE id = ? """, ( @@ -89,6 +105,10 @@ def update_default_subtitle_style(payload) -> dict: payload.font_color, payload.stroke_color, 1 if payload.shadow_enabled else 0, + payload.outline_width, + payload.shadow_depth, + payload.safe_area_percent, + json.dumps(payload.speaker_styles, ensure_ascii=False, separators=(",", ":")), now, "default", ), @@ -98,9 +118,11 @@ def update_default_subtitle_style(payload) -> dict: """ INSERT INTO subtitle_style_presets ( id, name, font_family, font_size, position, font_color, - stroke_color, shadow_enabled, is_default, created_at, updated_at + stroke_color, shadow_enabled, outline_width, shadow_depth, + safe_area_percent, speaker_styles_json, + is_default, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( "default", @@ -111,6 +133,10 @@ def update_default_subtitle_style(payload) -> dict: payload.font_color, payload.stroke_color, 1 if payload.shadow_enabled else 0, + payload.outline_width, + payload.shadow_depth, + payload.safe_area_percent, + json.dumps(payload.speaker_styles, ensure_ascii=False, separators=(",", ":")), 1, now, now, @@ -159,6 +185,7 @@ def _create_subtitle_job( output_file_path: str = "", error_message: str = "", is_active: int = 0, + revision_id: str | None = None, ) -> dict: """创建新的字幕任务记录(不再 upsert,每次生成都创建新记录)""" from app.db.database import get_connection @@ -170,16 +197,17 @@ def _create_subtitle_job( connection.execute( """ INSERT INTO subtitle_jobs ( - id, task_id, output_clip_id, style_preset_id, status, + id, task_id, output_clip_id, revision_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, "default", status, subtitle_file_path, @@ -191,7 +219,8 @@ def _create_subtitle_job( ), ) connection.commit() - return {"id": job_id, "task_id": task_id, "output_clip_id": output_clip_id, "status": status, + return {"id": job_id, "task_id": task_id, "output_clip_id": output_clip_id, + "revision_id": revision_id, "status": status, "subtitle_file_path": subtitle_file_path, "output_file_path": output_file_path, "error_message": error_message, "is_active": is_active} @@ -274,63 +303,40 @@ def _resolve_subtitle_font_family(requested_font_family: str | None) -> str: def _build_subtitle_rows(task_id: str, output_clip: dict) -> tuple[int, list[dict[str, Any]]]: - from app.services.task_service import _parse_time_to_seconds, get_clip_candidate # noqa: F811 - - clip = get_clip_candidate(task_id, output_clip["clip_candidate_id"]) if output_clip.get("clip_candidate_id") else None - if not clip: - return 0, [{"start_seconds": 0, "end_seconds": 3, "text": output_clip.get("output_file_name") or "精彩片段"}] - - clip_start = int(clip["start_seconds"]) - clip_end = int(clip["end_seconds"]) - rows = read_transcript_range(get_artifact_paths(task_id)["transcript_path"], clip_start, clip_end, max_rows=120) - subtitle_rows = [] - for row in rows: - row_start = _parse_time_to_seconds(row["start_time"]) - row_end = _parse_time_to_seconds(row["end_time"]) - start_seconds = max(0, row_start - clip_start) - end_seconds = max(start_seconds + 1, min(clip_end, row_end) - clip_start) - subtitle_rows.append({"start_seconds": start_seconds, "end_seconds": end_seconds, "text": row["text"]}) - if subtitle_rows: - return clip_start, subtitle_rows - - fallback_text = clip.get("summary") or clip.get("title") or "精彩片段" - return clip_start, [{"start_seconds": 0, "end_seconds": min(5, max(3, clip_end - clip_start)), "text": fallback_text}] - - -def _write_ass_file(task_id: str, output_clip: dict, style: dict) -> Path: + """旧调用方兼容导出;数据来自统一 revision,不再读取并截断 Markdown。""" + from app.services.subtitle_data_service import ensure_clip_track, get_revision + + track = ensure_clip_track(task_id, output_clip["id"]) + revision = get_revision(track["active_revision_id"], include_cues=True) + source_start_ms = int(track.get("source_start_ms") or 0) + rows = [ + { + "start_seconds": int(cue["start_ms"]) / 1000, + "end_seconds": int(cue["end_ms"]) / 1000, + "text": cue["text"], + } + for cue in revision["cues"] + ] + return round(source_start_ms / 1000), rows + + +def _write_ass_file( + task_id: str, + output_clip: dict, + style: dict, + *, + revision_id: str | None = None, +) -> Path: + from app.services.subtitle_data_service import ensure_clip_track, serialize_revision_to_ass + 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" - _, rows = _build_subtitle_rows(task_id, output_clip) - - alignment = "8" if style.get("position") == "top_center" else "2" - margin_v = "92" if style.get("position") == "bottom_center" else "190" - if style.get("position") == "top_center": - margin_v = "70" - outline = "3" if style.get("shadow_enabled") else "1" - shadow = "1" if style.get("shadow_enabled") else "0" - font_family = _resolve_subtitle_font_family(style.get("font_family")) - font_size = int(style.get("font_size") or 42) - primary_color = _hex_to_ass_color(style.get("font_color") or "#ffffff") - outline_color = _hex_to_ass_color(style.get("stroke_color") or "#111827") - events = "\n".join( - f"Dialogue: 0,{_ass_time(row['start_seconds'])},{_ass_time(row['end_seconds'])},Default,,0,0,0,,{_escape_ass_text(row['text'])}" - for row in rows - ) - content = f"""[Script Info] -ScriptType: v4.00+ -PlayResX: 1080 -PlayResY: 1920 -ScaledBorderAndShadow: yes - -[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,{font_family},{font_size},{primary_color},&H000000FF,{outline_color},&H7F000000,-1,0,0,0,100,100,0,0,1,{outline},{shadow},{alignment},60,60,{margin_v},1 - -[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -{events} -""" + 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") + content = serialize_revision_to_ass(track["id"], selected_revision_id) subtitle_path.write_text(content, encoding="utf-8") return subtitle_path @@ -365,17 +371,34 @@ 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 + 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: + 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" # === 版本化:创建新的字幕 job,不覆盖旧的 === - job = _create_subtitle_job(task_id, output_clip_id, "processing", is_active=0) + job = _create_subtitle_job( + task_id, + output_clip_id, + "processing", + is_active=0, + revision_id=revision_id, + ) append_task_log(task_id, f"开始自动加字幕:{input_path.name}") try: - subtitle_path = _write_ass_file(task_id, output_clip, style) + subtitle_path = _write_ass_file( + task_id, + output_clip, + style, + revision_id=revision_id, + ) command = [ "ffmpeg", "-y", diff --git a/app/services/video_cut_workflow_service.py b/app/services/video_cut_workflow_service.py index 37c3bd7..6c86da1 100644 --- a/app/services/video_cut_workflow_service.py +++ b/app/services/video_cut_workflow_service.py @@ -10,7 +10,7 @@ from app.models.task import TaskStatus from app.services.storage_service import get_artifact_paths, get_source_video_path, validate_source_video_path from app.services.task_log_service import append_task_log -from app.services.video_cut_service import CutResult, cut_clips +from app.services.video_cut_service import CutResult, cut_clips, parse_time_to_seconds # ---------- Cut Run 数据库操作 ---------- @@ -87,19 +87,38 @@ def _fail_cut_run(run_id: str, error_message: str = "") -> None: # ---------- Output Clip 数据库操作 ---------- -def _insert_output_clip_record(task_id: str, cut_run_id: str, result: CutResult) -> None: +def _insert_output_clip_record( + task_id: str, + cut_run_id: str, + result: CutResult, + *, + source_fingerprint: str = "", +) -> None: from app.db.database import get_connection from app.services.task_service import _now_iso now = _now_iso() with get_connection() as connection: + candidate = connection.execute( + "SELECT start_time, end_time FROM clip_candidates WHERE id = ? AND task_id = ?", + (result.clip_candidate_id, task_id), + ).fetchone() + source_start_ms = None + source_end_ms = None + snapshot_source = "legacy_inferred" + if candidate: + source_start_ms = round(parse_time_to_seconds(candidate["start_time"]) * 1000) + source_end_ms = round(parse_time_to_seconds(candidate["end_time"]) * 1000) + snapshot_source = "cut_commit" connection.execute( """ INSERT INTO output_clip ( id, task_id, clip_candidate_id, output_file_path, output_file_name, - status, error_message, cut_run_id, is_active, created_at, updated_at + status, error_message, cut_run_id, is_active, + source_start_ms, source_end_ms, source_duration_ms, + source_fingerprint, snapshot_source, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?) """, ( uuid4().hex[:12], @@ -110,6 +129,11 @@ def _insert_output_clip_record(task_id: str, cut_run_id: str, result: CutResult) result.status, result.error_message, cut_run_id, + source_start_ms, + source_end_ms, + source_end_ms - source_start_ms if source_start_ms is not None and source_end_ms is not None else None, + source_fingerprint, + snapshot_source, now, now, ), @@ -204,6 +228,9 @@ def process_task_video_cuts(task_id: str, *, sync_publish_jobs: bool = True) -> append_task_log(task_id, f"创建切割批次:第 {cut_run['run_number']} 次切割") try: + from app.services.transcription_checkpoint_service import fingerprint_file + + source_fingerprint = fingerprint_file(source_path) results = cut_clips( source_video=source_path, clips=enabled_clips, @@ -220,7 +247,12 @@ def process_task_video_cuts(task_id: str, *, sync_publish_jobs: bool = True) -> # 插入新 output_clip 记录,关联到当前 cut_run for result in results: - _insert_output_clip_record(task_id, cut_run_id, result) + _insert_output_clip_record( + task_id, + cut_run_id, + result, + source_fingerprint=source_fingerprint, + ) if result.status == "completed": append_task_log(task_id, f"切片完成:{result.output_file_name}") else: diff --git a/app/static/css/styles.css b/app/static/css/styles.css index b9281bf..6b19cdf 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -5764,3 +5764,196 @@ td a, .publish-history-view-switch .secondary-button { flex: 1; } .publish-history-toolbar-actions .compact-filter { width: 100%; } } + +/* ── Subtitle editor v2: 原片主时间轴 + 切片继承 ── */ +.subtitle-editor { + margin-bottom: 24px; + overflow: hidden; +} +.subtitle-editor-heading, +.subtitle-editor-trackbar, +.subtitle-editor-toolbar, +.subtitle-waveform-toolbar, +.subtitle-editor-state { + display: flex; + align-items: center; + gap: 10px; +} +.subtitle-editor-heading { align-items: flex-start; } +.subtitle-editor-heading > div:first-child { flex: 1; } +.subtitle-editor-heading h2 { margin-bottom: 5px; } +.subtitle-editor-heading p:last-child { margin: 0; color: var(--muted); } +.subtitle-editor-state { justify-content: flex-end; flex-wrap: wrap; } +.subtitle-editor-trackbar { + justify-content: space-between; + flex-wrap: wrap; + margin: 18px 0 14px; + padding: 13px 14px; + border: 1px solid rgba(126, 151, 187, 0.2); + border-radius: 15px; + background: rgba(245, 249, 255, 0.78); +} +.subtitle-editor-trackbar > label { display: grid; gap: 5px; min-width: min(380px, 100%); } +.subtitle-editor-trackbar > label > span, +.subtitle-editor-toolbar label > span, +.subtitle-cue-time-inputs span, +.subtitle-speaker span { + color: var(--muted); + font-size: 0.72rem; + font-weight: 750; +} +.subtitle-editor-revision-meta { flex: 1; min-width: 230px; color: var(--muted); font-size: 0.82rem; } +.subtitle-import-button { cursor: pointer; } +.subtitle-editor-media-grid { + display: grid; + grid-template-columns: minmax(280px, 0.72fr) minmax(420px, 1.28fr); + gap: 14px; +} +.subtitle-video-stage, +.subtitle-waveform-card { + position: relative; + overflow: hidden; + min-width: 0; + border: 1px solid rgba(126, 151, 187, 0.22); + border-radius: 16px; + background: #101521; +} +.subtitle-video-stage { + display: grid; + place-items: center; + min-height: 248px; +} +.subtitle-video-stage video { width: 100%; max-height: 390px; background: #080b12; } +.subtitle-video-overlay { + position: absolute; + left: 6%; + right: 6%; + bottom: 6%; + min-height: 1.4em; + color: #fff; + font-size: clamp(16px, 2vw, 28px); + font-weight: 800; + line-height: 1.35; + text-align: center; + white-space: pre-line; + text-shadow: 0 2px 4px #000, 1px 0 2px #000, -1px 0 2px #000; + pointer-events: none; +} +.subtitle-waveform-card { align-self: stretch; padding: 14px; color: #d9e8ff; background: linear-gradient(145deg, #111a2a, #192842); } +.subtitle-waveform-toolbar { justify-content: space-between; margin-bottom: 13px; font-size: 0.78rem; } +.subtitle-waveform-toolbar span { color: #9bb6da; } +#subtitle-waveform { min-height: 92px; border-radius: 10px; background: rgba(255, 255, 255, 0.045); } +#subtitle-timeline { min-height: 24px; color: #b7cae6; } +.subtitle-editor-toolbar { + flex-wrap: wrap; + align-items: flex-end; + margin: 14px 0 12px; + padding: 13px; + border-radius: 15px; + background: #f4f7fb; +} +.subtitle-search-field { display: grid; flex: 1 1 170px; gap: 5px; } +.subtitle-shift-field { display: grid; flex: 0 1 125px; gap: 5px; } +.subtitle-editor-toolbar .control { min-height: 36px; } +.subtitle-quality-summary { + margin-bottom: 10px; + padding: 10px 13px; + border: 1px solid rgba(38, 118, 255, 0.18); + border-radius: 12px; + color: #355274; + background: #eef6ff; + font-size: 0.82rem; +} +.subtitle-quality-summary.has-errors { color: #a72530; border-color: rgba(199, 50, 63, 0.24); background: #fff0f1; } +.subtitle-quality-summary.has-warnings { color: #765307; border-color: rgba(213, 151, 20, 0.25); background: #fff8e5; } +.subtitle-quality-summary.is-clean { color: #146840; border-color: rgba(24, 146, 89, 0.2); background: #ecfaf3; } +.subtitle-cue-list { + position: relative; + height: min(640px, 64vh); + overflow: auto; + border: 1px solid rgba(126, 151, 187, 0.22); + border-radius: 16px; + background: #f8fafc; + contain: strict; +} +.subtitle-cue-spacer { width: 1px; opacity: 0; } +.subtitle-cue-viewport { position: absolute; inset: 0 0 auto 0; } +.subtitle-cue-row { + position: absolute; + top: 0; + left: 0; + right: 0; + display: grid; + grid-template-columns: 42px 106px 160px minmax(260px, 1fr) 126px; + grid-template-rows: 83px 31px; + gap: 7px 10px; + height: 124px; + margin: 4px 8px; + padding: 10px; + border: 1px solid rgba(126, 151, 187, 0.18); + border-radius: 13px; + background: #fff; + box-shadow: 0 5px 15px rgba(33, 62, 97, 0.04); + box-sizing: border-box; +} +.subtitle-cue-row.is-selected { border-color: rgba(38, 118, 255, 0.55); background: #f4f8ff; } +.subtitle-cue-row.is-current { box-shadow: inset 4px 0 0 var(--blue), 0 7px 20px rgba(38, 118, 255, 0.12); } +.subtitle-cue-check { display: grid; grid-template-rows: 26px auto; place-items: center; color: var(--muted); font-weight: 750; } +.subtitle-cue-time { + align-self: center; + padding: 8px; + border: 0; + border-radius: 9px; + color: var(--blue); + background: #edf5ff; + font: inherit; + font-size: 0.74rem; + font-weight: 780; + cursor: pointer; +} +.subtitle-cue-time-inputs { display: grid; grid-template-columns: 1fr; gap: 5px; } +.subtitle-cue-time-inputs label, +.subtitle-speaker { display: grid; gap: 3px; } +.subtitle-cue-time-inputs input, +.subtitle-speaker input, +.subtitle-cue-row textarea { + width: 100%; + min-width: 0; + padding: 7px 8px; + border: 1px solid rgba(126, 151, 187, 0.3); + border-radius: 8px; + color: var(--text); + background: #fff; + font: inherit; + box-sizing: border-box; +} +.subtitle-cue-row textarea { resize: none; line-height: 1.45; } +.subtitle-cue-issues { + grid-column: 2 / -1; + overflow: hidden; + color: var(--muted); + font-size: 0.74rem; + text-overflow: ellipsis; + white-space: nowrap; +} +.subtitle-cue-empty { padding: 50px 20px; color: var(--muted); text-align: center; } + +@media (max-width: 1080px) { + .subtitle-editor-media-grid { grid-template-columns: 1fr; } + .subtitle-cue-row { grid-template-columns: 38px 100px 150px minmax(220px, 1fr); } + .subtitle-speaker { display: none; } +} + +@media (max-width: 760px) { + .subtitle-editor-heading, + .subtitle-editor-trackbar, + .subtitle-editor-state { align-items: stretch; flex-direction: column; } + .subtitle-editor-trackbar .button-row { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } + .subtitle-cue-row { + grid-template-columns: 34px 96px minmax(185px, 1fr); + grid-template-rows: 83px 31px; + min-width: 560px; + } + .subtitle-cue-time-inputs { display: none; } + .subtitle-cue-issues { grid-column: 2 / -1; } +} diff --git a/app/static/js/app.js b/app/static/js/app.js index aebcb4e..864f5d1 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1789,6 +1789,15 @@ if (subtitleStyleForm) { const formData = new FormData(subtitleStyleForm); const payload = Object.fromEntries(formData.entries()); payload.font_size = Number(payload.font_size || 42); + payload.outline_width = Number(payload.outline_width || 3); + payload.shadow_depth = Number(payload.shadow_depth || 1); + payload.safe_area_percent = Number(payload.safe_area_percent || 5); + payload.speaker_styles = { + 主播: { font_color: payload.speaker_host_color || "#ffffff" }, + 嘉宾: { font_color: payload.speaker_guest_color || "#ffd60a" }, + }; + delete payload.speaker_host_color; + delete payload.speaker_guest_color; payload.shadow_enabled = Boolean(subtitleStyleForm.elements.shadow_enabled?.checked); if (submitButton) submitButton.disabled = true; if (subtitleStyleResult) subtitleStyleResult.textContent = "正在保存字幕样式..."; diff --git a/app/static/js/subtitle-editor.js b/app/static/js/subtitle-editor.js new file mode 100644 index 0000000..6a4bfd5 --- /dev/null +++ b/app/static/js/subtitle-editor.js @@ -0,0 +1,597 @@ +(() => { + "use strict"; + + const root = document.querySelector("#subtitle-editor"); + if (!root) return; + + const elements = { + track: root.querySelector("#subtitle-track-select"), + saveState: root.querySelector("#subtitle-save-state"), + revisionMeta: root.querySelector("#subtitle-revision-meta"), + approve: root.querySelector("#subtitle-approve"), + video: root.querySelector("#subtitle-editor-video"), + overlay: root.querySelector("#subtitle-video-overlay"), + waveform: root.querySelector("#subtitle-waveform"), + waveformStatus: root.querySelector("#subtitle-waveform-status"), + list: root.querySelector("#subtitle-cue-list"), + spacer: root.querySelector("#subtitle-cue-spacer"), + viewport: root.querySelector("#subtitle-cue-viewport"), + quality: root.querySelector("#subtitle-quality-summary"), + search: root.querySelector("#subtitle-search"), + replacement: root.querySelector("#subtitle-replacement"), + replaceAll: root.querySelector("#subtitle-replace-all"), + add: root.querySelector("#subtitle-add"), + split: root.querySelector("#subtitle-split"), + merge: root.querySelector("#subtitle-merge"), + remove: root.querySelector("#subtitle-delete"), + shiftMs: root.querySelector("#subtitle-shift-ms"), + shift: root.querySelector("#subtitle-shift"), + undo: root.querySelector("#subtitle-undo"), + redo: root.querySelector("#subtitle-redo"), + save: root.querySelector("#subtitle-save-now"), + importFile: root.querySelector("#subtitle-import-file"), + exports: ["srt", "vtt", "ass"].map((format) => [ + format, + root.querySelector(`#subtitle-export-${format}`), + ]), + }; + + const ROW_HEIGHT = 132; + const PAGE_SIZE = 2000; + const state = { + taskId: root.dataset.taskId, + tracks: [], + track: null, + revision: null, + cues: [], + visibleIndices: [], + selectedIds: new Set(), + currentCueId: null, + undo: [], + redo: [], + waveSurfer: null, + regions: null, + selectedRegion: null, + saveTimer: null, + saving: false, + dirty: false, + changeVersion: 0, + requestToken: 0, + }; + + function setStatus(message, tone = "blue") { + elements.saveState.textContent = message; + elements.saveState.className = `status-pill tone-${tone}`; + } + + async function api(url, options = {}) { + const response = await fetch(url, options); + let payload = {}; + try { + payload = await response.json(); + } catch (_error) { + payload = {}; + } + if (!response.ok) { + const detail = typeof payload.detail === "string" ? payload.detail : "字幕请求失败"; + const error = new Error(detail); + error.status = response.status; + throw error; + } + return payload; + } + + function cueSnapshot() { + return state.cues.map((cue) => ({ + ...cue, + start_ms: Number(cue.start_ms), + end_ms: Number(cue.end_ms), + })); + } + + function restoreSnapshot(snapshot) { + state.cues = snapshot.map((cue) => ({ ...cue })); + state.selectedIds.clear(); + state.currentCueId = null; + markChanged(); + applySearch(); + updateCurrentCue(); + } + + function mutate(callback) { + state.undo.push(cueSnapshot()); + if (state.undo.length > 50) state.undo.shift(); + state.redo = []; + callback(); + state.cues.sort((left, right) => left.start_ms - right.start_ms || left.end_ms - right.end_ms); + markChanged(); + applySearch(); + updateUndoButtons(); + } + + function markChanged(schedule = true) { + state.dirty = true; + state.changeVersion += 1; + elements.save.disabled = false; + setStatus("有未保存修改", "amber"); + renderQuality(); + if (schedule) { + window.clearTimeout(state.saveTimer); + state.saveTimer = window.setTimeout(() => saveRevision(false), 1800); + } + } + + function updateUndoButtons() { + elements.undo.disabled = state.undo.length === 0; + elements.redo.disabled = state.redo.length === 0; + } + + function applySearch() { + const query = elements.search.value.trim().toLocaleLowerCase(); + state.visibleIndices = state.cues + .map((cue, index) => ({ cue, index })) + .filter(({ cue }) => !query || `${cue.text} ${cue.speaker || ""}`.toLocaleLowerCase().includes(query)) + .map(({ index }) => index); + elements.spacer.style.height = `${state.visibleIndices.length * ROW_HEIGHT}px`; + renderVirtualRows(); + } + + function formatMs(value) { + const total = Math.max(0, Number(value) || 0); + const hours = Math.floor(total / 3600000); + const minutes = Math.floor((total % 3600000) / 60000); + const seconds = Math.floor((total % 60000) / 1000); + const milliseconds = Math.floor(total % 1000); + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`; + } + + function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function cueIssues(cue, previous) { + const issues = []; + const duration = cue.end_ms - cue.start_ms; + const lines = String(cue.text || "").split(/\r?\n/); + const chinese = (value) => (String(value).match(/[\u4e00-\u9fff]/g) || []).length; + if (lines.length > 2) issues.push("超过 2 行"); + if (lines.some((line) => chinese(line) > 18)) issues.push("单行超过 18 个中文字符"); + if (duration < 800) issues.push("短于 800ms"); + if (duration > 7000) issues.push("长于 7 秒"); + if (chinese(cue.text) / Math.max(0.001, duration / 1000) > 12) issues.push("阅读速度过快"); + if (previous) { + const gap = cue.start_ms - previous.end_ms; + if (gap < 0) issues.push("与上一条重叠"); + else if (gap < 80) issues.push("间隔小于 80ms"); + } + return issues; + } + + function renderVirtualRows() { + if (!state.visibleIndices.length) { + elements.viewport.innerHTML = '
没有匹配的字幕行
'; + return; + } + const scrollTop = elements.list.scrollTop; + const first = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - 4); + const count = Math.ceil(elements.list.clientHeight / ROW_HEIGHT) + 8; + const last = Math.min(state.visibleIndices.length, first + count); + const rows = []; + for (let virtualIndex = first; virtualIndex < last; virtualIndex += 1) { + const cueIndex = state.visibleIndices[virtualIndex]; + const cue = state.cues[cueIndex]; + const previous = cueIndex > 0 ? state.cues[cueIndex - 1] : null; + const issues = cueIssues(cue, previous); + const isSelected = state.selectedIds.has(cue.id); + const isCurrent = state.currentCueId === cue.id; + rows.push(` +
+ + +
+ + +
+ + +
${issues.length ? escapeHtml(issues.join(" · ")) : "时间与阅读速度正常"}
+
+ `); + } + elements.viewport.innerHTML = rows.join(""); + bindVisibleRows(); + } + + function bindVisibleRows() { + elements.viewport.querySelectorAll(".subtitle-cue-row").forEach((row) => { + const cue = state.cues.find((item) => item.id === row.dataset.cueId); + if (!cue) return; + row.querySelector("[data-cue-select]").addEventListener("change", (event) => { + if (event.target.checked) state.selectedIds.add(cue.id); + else state.selectedIds.delete(cue.id); + renderVirtualRows(); + }); + row.querySelector("[data-cue-seek]").addEventListener("click", () => selectCue(cue, true)); + row.querySelectorAll("[data-cue-field]").forEach((input) => { + let editStarted = false; + let beforeEdit = null; + input.addEventListener("focus", () => { + editStarted = false; + beforeEdit = cueSnapshot(); + }); + input.addEventListener("input", () => { + if (!editStarted) { + state.undo.push(beforeEdit || cueSnapshot()); + if (state.undo.length > 50) state.undo.shift(); + state.redo = []; + editStarted = true; + updateUndoButtons(); + } + const field = input.dataset.cueField; + cue[field] = field.endsWith("_ms") ? Number(input.value) : input.value; + markChanged(); + }); + input.addEventListener("change", () => { + if (cue.end_ms <= cue.start_ms) cue.end_ms = cue.start_ms + 1; + state.cues.sort((left, right) => left.start_ms - right.start_ms || left.end_ms - right.end_ms); + applySearch(); + selectCue(cue, false); + }); + }); + }); + } + + function renderQuality() { + let errors = 0; + let warnings = 0; + state.cues.forEach((cue, index) => { + const issues = cueIssues(cue, index ? state.cues[index - 1] : null); + warnings += issues.length; + if (issues.includes("与上一条重叠")) errors += 1; + }); + elements.quality.className = `subtitle-quality-summary${errors ? " has-errors" : warnings ? " has-warnings" : " is-clean"}`; + elements.quality.textContent = errors + ? `${errors} 个重叠错误,另有 ${Math.max(0, warnings - errors)} 条质量提醒;请先处理红色问题。` + : warnings + ? `没有重叠错误,共 ${warnings} 条时长、间隔、行长或阅读速度提醒。` + : `共 ${state.cues.length} 条字幕,当前没有发现质量问题。`; + } + + function findCurrentCue(timeMs) { + let low = 0; + let high = state.cues.length - 1; + let candidate = null; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + if (state.cues[middle].start_ms <= timeMs) { + candidate = state.cues[middle]; + low = middle + 1; + } else { + high = middle - 1; + } + } + return candidate && candidate.end_ms >= timeMs ? candidate : null; + } + + function updateCurrentCue() { + const cue = findCurrentCue((elements.video.currentTime || 0) * 1000); + const nextId = cue?.id || null; + if (state.currentCueId !== nextId) { + state.currentCueId = nextId; + renderVirtualRows(); + } + elements.overlay.textContent = cue ? `${cue.speaker ? `${cue.speaker}:` : ""}${cue.text}` : ""; + } + + function selectCue(cue, seek) { + state.currentCueId = cue.id; + if (seek) elements.video.currentTime = cue.start_ms / 1000; + elements.overlay.textContent = `${cue.speaker ? `${cue.speaker}:` : ""}${cue.text}`; + renderSelectedRegion(cue); + const visibleIndex = state.visibleIndices.indexOf(state.cues.indexOf(cue)); + if (visibleIndex >= 0) { + const top = visibleIndex * ROW_HEIGHT; + if (top < elements.list.scrollTop || top + ROW_HEIGHT > elements.list.scrollTop + elements.list.clientHeight) { + elements.list.scrollTop = Math.max(0, top - ROW_HEIGHT); + } + } + renderVirtualRows(); + } + + function renderSelectedRegion(cue) { + if (!state.regions) return; + state.regions.clearRegions(); + state.selectedRegion = state.regions.addRegion({ + id: `cue-${cue.id}`, + start: cue.start_ms / 1000, + end: cue.end_ms / 1000, + color: "rgba(38, 118, 255, 0.22)", + drag: true, + resize: true, + }); + } + + async function loadTracks() { + setStatus("正在生成或读取统一字幕轨…", "blue"); + try { + const payload = await api(`/api/subtitles/tasks/${encodeURIComponent(state.taskId)}/tracks`); + state.tracks = payload.tracks || []; + elements.track.innerHTML = state.tracks.map((track) => { + const label = track.track_type === "source" ? "原片主字幕" : `切片 · ${track.output_file_name || track.name}`; + const suffix = track.has_manual_edits ? "(人工版)" : track.sync_status === "pending_sync" ? "(待同步)" : ""; + return ``; + }).join(""); + elements.track.disabled = state.tracks.length === 0; + if (!state.tracks.length) throw new Error("当前任务没有可用字幕轨"); + await loadTrack(state.tracks[0].id); + } catch (error) { + setStatus(error.message, "red"); + elements.revisionMeta.textContent = "请先完成结构化转写并生成切片。"; + } + } + + async function loadTrack(trackId) { + const token = ++state.requestToken; + window.clearTimeout(state.saveTimer); + setStatus("正在载入毫秒级字幕…", "blue"); + const track = state.tracks.find((item) => item.id === trackId); + if (!track) return; + try { + const first = await api(`/api/subtitles/tracks/${encodeURIComponent(trackId)}/cues?offset=0&limit=${PAGE_SIZE}`); + const cues = [...(first.cues || [])]; + for (let offset = cues.length; offset < Number(first.total || 0); offset += PAGE_SIZE) { + const page = await api(`/api/subtitles/tracks/${encodeURIComponent(trackId)}/cues?offset=${offset}&limit=${PAGE_SIZE}`); + cues.push(...(page.cues || [])); + } + if (token !== state.requestToken) return; + state.track = first.track; + state.revision = first.revision; + state.cues = cues; + state.selectedIds.clear(); + state.currentCueId = null; + state.undo = []; + state.redo = []; + state.dirty = false; + state.changeVersion = 0; + elements.track.value = trackId; + elements.video.src = state.track.media_url; + elements.approve.disabled = !state.revision; + elements.save.disabled = true; + elements.exports.forEach(([, button]) => { button.disabled = !state.revision; }); + renderRevisionMeta(); + applySearch(); + renderQuality(); + updateUndoButtons(); + setStatus(`已载入 ${state.cues.length} 条,自动保存已开启`, "green"); + loadWaveform(token); + } catch (error) { + setStatus(error.message, "red"); + } + } + + function renderRevisionMeta() { + if (!state.revision) { + elements.revisionMeta.textContent = "尚无 revision"; + return; + } + const status = state.revision.status === "approved" ? "已审核" : "草稿"; + elements.revisionMeta.textContent = `Revision ${state.revision.revision_number} · ${status} · ${state.cues.length} 条 · ${state.track.sync_status}`; + } + + async function loadWaveform(token) { + if (state.waveSurfer) { + state.waveSurfer.destroy(); + state.waveSurfer = null; + state.regions = null; + } + elements.waveform.innerHTML = ""; + document.querySelector("#subtitle-timeline").innerHTML = ""; + elements.waveformStatus.textContent = "正在读取 peaks…"; + try { + const payload = await api(`${state.track.peaks_url}?max_points=12000`); + if (token !== state.requestToken) return; + if (!window.WaveSurfer || !window.WaveSurfer.Regions || !window.WaveSurfer.Timeline) { + throw new Error("本地 wavesurfer.js 未正确载入"); + } + state.regions = window.WaveSurfer.Regions.create(); + const timeline = window.WaveSurfer.Timeline.create({ container: "#subtitle-timeline", height: 24 }); + state.waveSurfer = window.WaveSurfer.create({ + container: elements.waveform, + media: elements.video, + peaks: [Float32Array.from(payload.peaks || [])], + duration: Number(payload.duration_ms || 0) / 1000, + height: 92, + minPxPerSec: state.track.track_type === "source" ? 0.08 : 8, + normalize: true, + waveColor: "#9fbce8", + progressColor: "#2676ff", + cursorColor: "#ff9f0a", + plugins: [state.regions, timeline], + }); + state.regions.on("region-update-end", (region) => { + if (!state.currentCueId) return; + const cue = state.cues.find((item) => item.id === state.currentCueId); + if (!cue) return; + mutate(() => { + cue.start_ms = Math.max(0, Math.round(region.start * 1000)); + cue.end_ms = Math.max(cue.start_ms + 1, Math.round(region.end * 1000)); + }); + }); + elements.waveformStatus.textContent = `${payload.point_count} 个 peaks${payload.cached ? " · 已复用缓存" : " · 新生成"}`; + } catch (error) { + elements.waveformStatus.textContent = `波形暂不可用:${error.message}`; + } + } + + async function saveRevision(force) { + if (!state.dirty || state.saving || !state.track || !state.revision) return; + window.clearTimeout(state.saveTimer); + state.saving = true; + const version = state.changeVersion; + const cues = cueSnapshot(); + const baseRevisionId = state.revision.id; + setStatus("正在自动保存新 revision…", "blue"); + try { + const payload = await api(`/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/revisions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ base_revision_id: baseRevisionId, cues, note: force ? "手动立即保存" : "字幕编辑器自动保存" }), + }); + state.revision = payload.revision; + if (version === state.changeVersion) { + state.cues = (payload.revision.cues || []).map((cue) => ({ ...cue })); + state.dirty = false; + state.selectedIds.clear(); + elements.save.disabled = true; + applySearch(); + setStatus(`Revision ${state.revision.revision_number} 已保存`, "green"); + } else { + state.dirty = true; + setStatus("保存期间又有修改,正在继续保存…", "amber"); + } + renderRevisionMeta(); + } catch (error) { + setStatus(error.status === 409 ? "版本已变化,请重新选择字幕轨后再编辑" : `保存失败:${error.message}`, "red"); + } finally { + state.saving = false; + if (state.dirty && version !== state.changeVersion) { + state.saveTimer = window.setTimeout(() => saveRevision(false), 300); + } + } + } + + function selectedCues() { + return state.cues.filter((cue) => state.selectedIds.has(cue.id)); + } + + elements.track.addEventListener("change", () => loadTrack(elements.track.value)); + elements.list.addEventListener("scroll", renderVirtualRows, { passive: true }); + elements.video.addEventListener("timeupdate", updateCurrentCue); + elements.search.addEventListener("input", applySearch); + elements.save.addEventListener("click", () => saveRevision(true)); + + elements.undo.addEventListener("click", () => { + if (!state.undo.length) return; + state.redo.push(cueSnapshot()); + restoreSnapshot(state.undo.pop()); + updateUndoButtons(); + }); + elements.redo.addEventListener("click", () => { + if (!state.redo.length) return; + state.undo.push(cueSnapshot()); + restoreSnapshot(state.redo.pop()); + updateUndoButtons(); + }); + + elements.add.addEventListener("click", () => { + const start = Math.max(0, Math.round((elements.video.currentTime || 0) * 1000)); + const cue = { id: `local-${crypto.randomUUID()}`, start_ms: start, end_ms: start + 2000, text: "新字幕", speaker: "", confidence: null, source_cue_id: null }; + mutate(() => state.cues.push(cue)); + selectCue(cue, false); + }); + + elements.remove.addEventListener("click", () => { + if (!state.selectedIds.size) return setStatus("请先勾选要删除的字幕行", "amber"); + mutate(() => { state.cues = state.cues.filter((cue) => !state.selectedIds.has(cue.id)); }); + state.selectedIds.clear(); + }); + + elements.merge.addEventListener("click", () => { + const cues = selectedCues().sort((left, right) => left.start_ms - right.start_ms); + if (cues.length < 2) return setStatus("合并至少需要勾选两行", "amber"); + mutate(() => { + const merged = { ...cues[0], end_ms: Math.max(...cues.map((cue) => cue.end_ms)), text: cues.map((cue) => cue.text).join(" ") }; + const ids = new Set(cues.map((cue) => cue.id)); + state.cues = state.cues.filter((cue) => !ids.has(cue.id)); + state.cues.push(merged); + state.selectedIds = new Set([merged.id]); + }); + }); + + elements.split.addEventListener("click", () => { + const cue = state.cues.find((item) => item.id === state.currentCueId) || selectedCues()[0]; + if (!cue) return setStatus("请先点击一行字幕", "amber"); + let splitMs = Math.round((elements.video.currentTime || 0) * 1000); + if (splitMs <= cue.start_ms || splitMs >= cue.end_ms) splitMs = Math.round((cue.start_ms + cue.end_ms) / 2); + const middle = Math.max(1, Math.floor(cue.text.length / 2)); + const originalEndMs = cue.end_ms; + mutate(() => { + cue.end_ms = splitMs; + const second = { ...cue, id: `local-${crypto.randomUUID()}`, start_ms: splitMs, end_ms: originalEndMs, text: cue.text.slice(middle).trim() || cue.text }; + cue.text = cue.text.slice(0, middle).trim() || cue.text; + state.cues.push(second); + }); + }); + + elements.shift.addEventListener("click", () => { + const delta = Number(elements.shiftMs.value || 0); + if (!Number.isFinite(delta) || delta === 0) return setStatus("请输入非 0 的毫秒位移", "amber"); + const selected = state.selectedIds; + mutate(() => state.cues.forEach((cue) => { + if (!selected.size || selected.has(cue.id)) { + const duration = cue.end_ms - cue.start_ms; + cue.start_ms = Math.max(0, cue.start_ms + delta); + cue.end_ms = cue.start_ms + duration; + } + })); + }); + + elements.replaceAll.addEventListener("click", () => { + const search = elements.search.value; + if (!search) return setStatus("请先输入要搜索的文字", "amber"); + const replacement = elements.replacement.value; + mutate(() => state.cues.forEach((cue) => { cue.text = cue.text.split(search).join(replacement); })); + }); + + elements.approve.addEventListener("click", async () => { + if (state.dirty) await saveRevision(false); + if (state.dirty || !state.revision) return; + try { + const payload = await api(`/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ revision_id: state.revision.id }), + }); + state.revision = payload.revision; + renderRevisionMeta(); + setStatus(`Revision ${state.revision.revision_number} 已审核`, "green"); + } catch (error) { + setStatus(`审核失败:${error.message}`, "red"); + } + }); + + elements.importFile.addEventListener("change", async () => { + const file = elements.importFile.files?.[0]; + if (!file || !state.track) return; + const form = new FormData(); + form.append("file", file); + setStatus(`正在导入 ${file.name}…`, "blue"); + try { + await api(`/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/import`, { method: "POST", body: form }); + await loadTrack(state.track.id); + } catch (error) { + setStatus(`导入失败:${error.message}`, "red"); + } finally { + elements.importFile.value = ""; + } + }); + + elements.exports.forEach(([format, button]) => button.addEventListener("click", () => { + if (!state.track || !state.revision) return; + window.location.href = `/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/export?format_name=${format}&revision_id=${encodeURIComponent(state.revision.id)}`; + })); + + window.addEventListener("beforeunload", (event) => { + if (!state.dirty) return; + event.preventDefault(); + event.returnValue = ""; + }); + + loadTracks(); +})(); diff --git a/app/static/vendor/wavesurfer/LICENSE b/app/static/vendor/wavesurfer/LICENSE new file mode 100644 index 0000000..88998ae --- /dev/null +++ b/app/static/vendor/wavesurfer/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2012-2023, katspaugh and contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/app/static/vendor/wavesurfer/regions.min.js b/app/static/vendor/wavesurfer/regions.min.js new file mode 100644 index 0000000..ff8303e --- /dev/null +++ b/app/static/vendor/wavesurfer/regions.min.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):((t="undefined"!=typeof globalThis?globalThis:t||self).WaveSurfer=t.WaveSurfer||{},t.WaveSurfer.Regions=e())}(this,(function(){"use strict";class t{constructor(){this.listeners={}}on(t,e,i){if(this.listeners[t]||(this.listeners[t]=new Set),null==i?void 0:i.once){const i=(...n)=>{this.un(t,i),e(...n)};return this.listeners[t].add(i),()=>this.un(t,i)}return this.listeners[t].add(e),()=>this.un(t,e)}un(t,e){var i;null===(i=this.listeners[t])||void 0===i||i.delete(e)}once(t,e){return this.on(t,e,{once:!0})}unAll(){this.listeners={}}emit(t,...e){this.listeners[t]&&this.listeners[t].forEach((t=>t(...e)))}}class e extends t{constructor(t){super(),this.subscriptions=[],this.isDestroyed=!1,this.options=t}onInit(){}_init(t){this.isDestroyed&&(this.subscriptions=[],this.isDestroyed=!1),this.wavesurfer=t,this.onInit()}destroy(){this.emit("destroy"),this.subscriptions.forEach((t=>t())),this.subscriptions=[],this.isDestroyed=!0,this.wavesurfer=void 0}}function i(t,e){const n=e.xmlns?document.createElementNS(e.xmlns,t):document.createElement(t);for(const[t,s]of Object.entries(e))if("children"===t&&s)for(const[t,e]of Object.entries(s))e instanceof Node?n.appendChild(e):"string"==typeof e?n.appendChild(document.createTextNode(e)):n.appendChild(i(t,e));else"style"===t?Object.assign(n.style,s):"textContent"===t?n.textContent=s:n.setAttribute(t,s.toString());return n}function n(t,e,n){const s=i(t,e||{});return null==n||n.appendChild(s),s}function s(t){let e=t;const i=new Set;return{get value(){return e},set(t){Object.is(e,t)||(e=t,i.forEach((t=>t(e))))},update(t){this.set(t(e))},subscribe:t=>(i.add(t),()=>i.delete(t))}}function r(t,e){let i;const n=()=>{i&&(i(),i=void 0),i=t()},s=e.map((t=>t.subscribe(n)));return n(),()=>{i&&(i(),i=void 0),s.forEach((t=>t()))}}function o(t,e){const i=s(null),n=t=>{i.set(t)};return t.addEventListener(e,n),i._cleanup=()=>{t.removeEventListener(e,n)},i}function l(t){const e=t._cleanup;"function"==typeof e&&e()}function h(t,e={}){const{threshold:i=3,mouseButton:n=0,touchDelay:r=100}=e,o=s(null),h=new Map,a=matchMedia("(pointer: coarse)").matches;let d=()=>{};const c=e=>{if(e.button!==n)return;if(h.has(e.pointerId))return;if(h.set(e.pointerId,e),h.size>1)return;const s=e.pointerId;let l=e.clientX,c=e.clientY,u=!1;const p=Date.now(),v=t.getBoundingClientRect(),{left:g,top:m}=v,f=t=>{if(t.pointerId!==s)return;if(t.defaultPrevented||h.size>1)return;if(a&&Date.now()-pi||Math.abs(v)>i)&&(t.preventDefault(),t.stopPropagation(),u||(o.set({type:"start",x:l-g,y:c-m}),u=!0),o.set({type:"move",x:e-g,y:n-m,deltaX:d,deltaY:v}),l=e,c=n)},b=t=>{if(h.delete(t.pointerId)){if(t.pointerId===s&&u){const e=t.clientX,i=t.clientY;o.set({type:"end",x:e-g,y:i-m})}0===h.size&&d()}},E=t=>{t.relatedTarget&&t.relatedTarget!==document.documentElement||b(t)},C=t=>{u&&(t.stopPropagation(),t.preventDefault())},L=t=>{t.defaultPrevented||h.size>1||u&&t.preventDefault()};document.addEventListener("pointermove",f),document.addEventListener("pointerup",b),document.addEventListener("pointerout",E),document.addEventListener("pointercancel",E),document.addEventListener("touchmove",L,{passive:!1}),document.addEventListener("click",C,{capture:!0}),d=()=>{document.removeEventListener("pointermove",f),document.removeEventListener("pointerup",b),document.removeEventListener("pointerout",E),document.removeEventListener("pointercancel",E),document.removeEventListener("touchmove",L),setTimeout((()=>{document.removeEventListener("click",C,{capture:!0})}),10)}};t.addEventListener("pointerdown",c);return{signal:o,cleanup:()=>{d(),t.removeEventListener("pointerdown",c),h.clear(),l(o)}}}class a extends t{constructor(t,e,i=0){var n,s,r,o,l,h,a,d,c,u;super(),this.totalDuration=e,this.numberOfChannels=i,this.element=null,this.minLength=0,this.maxLength=1/0,this.contentEditable=!1,this.subscriptions=[],this.updatingSide=void 0,this.isRemoved=!1,this.subscriptions=[],this.id=t.id||`region-${Math.random().toString(32).slice(2)}`,this.start=this.clampPosition(t.start),this.end=this.clampPosition(null!==(n=t.end)&&void 0!==n?n:t.start),this.drag=null===(s=t.drag)||void 0===s||s,this.resize=null===(r=t.resize)||void 0===r||r,this.resizeStart=null===(o=t.resizeStart)||void 0===o||o,this.resizeEnd=null===(l=t.resizeEnd)||void 0===l||l,this.color=null!==(h=t.color)&&void 0!==h?h:"rgba(0, 0, 0, 0.1)",this.minLength=null!==(a=t.minLength)&&void 0!==a?a:this.minLength,this.maxLength=null!==(d=t.maxLength)&&void 0!==d?d:this.maxLength,this.channelIdx=null!==(c=t.channelIdx)&&void 0!==c?c:-1,this.contentEditable=null!==(u=t.contentEditable)&&void 0!==u?u:this.contentEditable,this.element=this.initElement(),this.setContent(t.content),this.setPart(),this.renderPosition(),this.initMouseEvents()}clampPosition(t){return Math.max(0,Math.min(this.totalDuration,t))}setPart(){var t;const e=this.start===this.end;null===(t=this.element)||void 0===t||t.setAttribute("part",`${e?"marker":"region"} ${this.id}`)}addResizeHandles(t){const e={position:"absolute",zIndex:"2",width:"6px",height:"100%",top:"0",cursor:"ew-resize",wordBreak:"keep-all"},i=n("div",{part:"region-handle region-handle-left",style:Object.assign(Object.assign({},e),{left:"0",borderLeft:"2px solid rgba(0, 0, 0, 0.5)",borderRadius:"2px 0 0 2px"})},t),s=n("div",{part:"region-handle region-handle-right",style:Object.assign(Object.assign({},e),{right:"0",borderRight:"2px solid rgba(0, 0, 0, 0.5)",borderRadius:"0 2px 2px 0"})},t),o=h(i,{threshold:1}),l=h(s,{threshold:1}),a=r((()=>{const t=o.signal.value;t&&("move"===t.type&&void 0!==t.deltaX?this.onResize(t.deltaX,"start"):"end"===t.type&&this.onEndResizing("start"))}),[o.signal]),d=r((()=>{const t=l.signal.value;t&&("move"===t.type&&void 0!==t.deltaX?this.onResize(t.deltaX,"end"):"end"===t.type&&this.onEndResizing("end"))}),[l.signal]);this.subscriptions.push((()=>{a(),d(),o.cleanup(),l.cleanup()}))}removeResizeHandles(t){const e=t.querySelector('[part*="region-handle-left"]'),i=t.querySelector('[part*="region-handle-right"]');e&&t.removeChild(e),i&&t.removeChild(i)}initElement(){if(this.isRemoved)return null;const t=this.start===this.end;let e=0,i=100;this.channelIdx>=0&&this.numberOfChannels>0&&this.channelIdxt&&this.emit("click",t))),u=i.subscribe((t=>t&&this.emit("over",t))),p=n.subscribe((t=>t&&this.emit("leave",t))),v=s.subscribe((t=>t&&this.emit("dblclick",t))),g=a.subscribe((t=>t&&this.toggleCursor(!0))),m=d.subscribe((t=>t&&this.toggleCursor(!1)));this.subscriptions.push((()=>{c(),u(),p(),v(),g(),m(),l(e),l(i),l(n),l(s),l(a),l(d)}));const f=h(t),b=r((()=>{const t=f.signal.value;t&&("start"===t.type?this.toggleCursor(!0):"move"===t.type&&void 0!==t.deltaX?this.onMove(t.deltaX):"end"===t.type&&(this.toggleCursor(!1),this.drag&&this.emit("update-end")))}),[f.signal]);this.subscriptions.push((()=>{b(),f.cleanup()})),this.contentEditable&&this.content&&(this.contentClickListener=t=>this.onContentClick(t),this.contentBlurListener=()=>this.onContentBlur(),this.content.addEventListener("click",this.contentClickListener),this.content.addEventListener("blur",this.contentBlurListener))}_onUpdate(t,e,i){var n;if(!(null===(n=this.element)||void 0===n?void 0:n.parentElement))return;const{width:s}=this.element.parentElement.getBoundingClientRect(),r=t/s*this.totalDuration;let o=e&&"start"!==e?this.start:this.start+r,l=e&&"end"!==e?this.end:this.end+r;const h=void 0!==i;h&&this.updatingSide&&this.updatingSide!==e&&("start"===this.updatingSide?o=i:l=i),o=Math.max(0,o),l=Math.min(this.totalDuration,l);const a=l-o;this.updatingSide=e;const d=a>=this.minLength&&a<=this.maxLength;o<=l&&(d||h)&&(this.start=o,this.end=l,this.renderPosition(),this.emit("update",e))}onMove(t){this.drag&&this._onUpdate(t)}onResize(t,e){this.resize&&(this.resizeStart||"start"!==e)&&(this.resizeEnd||"end"!==e)&&this._onUpdate(t,e)}onEndResizing(t){this.resize&&(this.emit("update-end",t),this.updatingSide=void 0)}onContentClick(t){t.stopPropagation();t.target.focus(),this.emit("click",t)}onContentBlur(){this.emit("update-end")}_setTotalDuration(t){this.totalDuration=t,this.renderPosition()}play(t){this.emit("play",t&&this.end!==this.start?this.end:void 0)}getContent(t=!1){var e;return t?this.content||void 0:this.element instanceof HTMLElement?(null===(e=this.content)||void 0===e?void 0:e.innerHTML)||void 0:""}setContent(t){var e;if(this.element)if(this.content&&this.contentEditable&&(this.contentClickListener&&this.content.removeEventListener("click",this.contentClickListener),this.contentBlurListener&&this.content.removeEventListener("blur",this.contentBlurListener)),null===(e=this.content)||void 0===e||e.remove(),t){if("string"==typeof t){const e=this.start===this.end;this.content=n("div",{style:{padding:`0.2em ${e?.2:.4}em`,display:"inline-block"},textContent:t})}else this.content=t;this.contentEditable&&(this.content.contentEditable="true",this.contentClickListener=t=>this.onContentClick(t),this.contentBlurListener=()=>this.onContentBlur(),this.content.addEventListener("click",this.contentClickListener),this.content.addEventListener("blur",this.contentBlurListener)),this.content.setAttribute("part","region-content"),this.element.appendChild(this.content),this.emit("content-changed")}else this.content=void 0}setOptions(t){var e,i;if(this.element){if(t.color&&(this.color=t.color,this.element.style.backgroundColor=this.color),void 0!==t.drag&&(this.drag=t.drag,this.element.style.cursor=this.drag?"grab":"default"),void 0!==t.start||void 0!==t.end){const n=this.start===this.end;this.start=this.clampPosition(null!==(e=t.start)&&void 0!==e?e:this.start),this.end=this.clampPosition(null!==(i=t.end)&&void 0!==i?i:n?this.start:this.end),this.renderPosition(),this.setPart(),this.emit("render")}if(t.content&&this.setContent(t.content),t.id&&(this.id=t.id,this.setPart()),void 0!==t.resize&&t.resize!==this.resize){const e=this.start===this.end;this.resize=t.resize,this.resize&&!e?this.addResizeHandles(this.element):this.removeResizeHandles(this.element)}void 0!==t.resizeStart&&(this.resizeStart=t.resizeStart),void 0!==t.resizeEnd&&(this.resizeEnd=t.resizeEnd)}}remove(){this.isRemoved=!0,this.emit("remove"),this.subscriptions.forEach((t=>t())),this.subscriptions=[],this.content&&this.contentEditable&&(this.contentClickListener&&(this.content.removeEventListener("click",this.contentClickListener),this.contentClickListener=void 0),this.contentBlurListener&&(this.content.removeEventListener("blur",this.contentBlurListener),this.contentBlurListener=void 0)),this.element&&(this.element.remove(),this.element=null),this.unAll()}}class d extends e{constructor(t){super(t),this.regions=[],this.regionsContainer=this.initRegionsContainer()}static create(t){return new d(t)}onInit(){if(!this.wavesurfer)throw Error("WaveSurfer is not initialized");this.wavesurfer.getWrapper().appendChild(this.regionsContainer),this.subscriptions.push(this.wavesurfer.on("ready",(t=>{this.regions.forEach((e=>e._setTotalDuration(t)))})));let t=[];this.subscriptions.push(this.wavesurfer.on("timeupdate",(e=>{const i=this.regions.filter((t=>t.start<=e&&(t.end===t.start?t.start+.05:t.end)>=e));i.forEach((e=>{t.includes(e)||this.emit("region-in",e)})),t.forEach((t=>{i.includes(t)||this.emit("region-out",t)})),t=i})))}initRegionsContainer(){return n("div",{part:"regions-container",style:{position:"absolute",top:"0",left:"0",width:"100%",height:"100%",zIndex:"5",pointerEvents:"none"}})}getRegions(){return this.regions}avoidOverlapping(t){t.content&&!t.isRemoved&&setTimeout((()=>{if(!t.content)return;const e=t.content;e.style.marginTop="0";const i=e.getBoundingClientRect(),n=this.regions.indexOf(t);if(n<0)return;const s=this.regions.slice(0,n).filter((t=>!t.isRemoved)).reduce(((e,n)=>{if(n===t||!n.content)return e;const s=n.content.getBoundingClientRect();return i.leftt.top-e.top)).reduce(((t,e)=>{const n=i.top+t,s=n+i.height;return nthis.avoidOverlapping(t)))}adjustScroll(t){var e,i;if(!t.element)return;const n=null===(i=null===(e=this.wavesurfer)||void 0===e?void 0:e.getWrapper())||void 0===i?void 0:i.parentElement;if(!n)return;const{clientWidth:s,scrollWidth:r}=n;if(r<=s)return;const o=n.getBoundingClientRect(),l=t.element.getBoundingClientRect(),h=l.left-o.left,a=l.right-o.left;h<0?n.scrollLeft+=h:a>s&&(n.scrollLeft+=a-s)}virtualAppend(t,e,i){const n=()=>{if(!this.wavesurfer)return;const n=this.wavesurfer.getWidth(),s=this.wavesurfer.getScroll(),r=e.clientWidth,o=this.wavesurfer.getDuration(),l=Math.round(t.start/o*r),h=l+(Math.round((t.end-t.start)/o*r)||1)>s&&l{if(!this.wavesurfer||!t.element)return;n();const e=this.wavesurfer.on("scroll",n),i=this.wavesurfer.on("zoom",n),s=this.wavesurfer.on("resize",n),r=t.on("render",n),o=[e,i,s,r];this.subscriptions.push(...o),t.once("remove",(()=>{e(),i(),s(),r(),this.subscriptions=this.subscriptions.filter((t=>!o.includes(t)))}))}),0)}saveRegion(t){if(!t.element)return;this.virtualAppend(t,this.regionsContainer,t.element),this.avoidOverlapping(t),this.regions.push(t);const e=[t.on("update",(e=>{e||this.adjustScroll(t),this.emit("region-update",t,e)})),t.on("update-end",(e=>{this.avoidOverlappingAll(),this.emit("region-updated",t,e)})),t.on("play",(e=>{var i;null===(i=this.wavesurfer)||void 0===i||i.play(t.start,e)})),t.on("click",(e=>{this.emit("region-clicked",t,e)})),t.on("dblclick",(e=>{this.emit("region-double-clicked",t,e)})),t.on("content-changed",(()=>{this.emit("region-content-changed",t)})),t.once("remove",(()=>{e.forEach((t=>t())),this.subscriptions=this.subscriptions.filter((t=>!e.includes(t))),this.regions=this.regions.filter((e=>e!==t)),this.emit("region-removed",t)}))];this.subscriptions.push(...e),this.emit("region-created",t)}addRegion(t){var e,i;if(!this.wavesurfer)throw Error("WaveSurfer is not initialized");const n=this.wavesurfer.getDuration(),s=null===(i=null===(e=this.wavesurfer)||void 0===e?void 0:e.getDecodedData())||void 0===i?void 0:i.numberOfChannels,r=new a(t,n,s);if(this.emit("region-initialized",r),n)this.saveRegion(r);else{const t=this.wavesurfer.once("ready",(e=>{r._setTotalDuration(e),this.saveRegion(r),this.subscriptions=this.subscriptions.filter((e=>e!==t))}));this.subscriptions.push(t)}return r}enableDragSelection(t,e=3){var i;const n=null===(i=this.wavesurfer)||void 0===i?void 0:i.getWrapper();if(!(n&&n instanceof HTMLElement))return()=>{};let s=null,o=0,l=0;const d=h(n,{threshold:e}),c=r((()=>{var e,i;const n=d.signal.value;if(n)if("start"===n.type){if(o=n.x,!this.wavesurfer)return;const r=this.wavesurfer.getDuration(),h=null===(i=null===(e=this.wavesurfer)||void 0===e?void 0:e.getDecodedData())||void 0===i?void 0:i.numberOfChannels,{width:d}=this.wavesurfer.getWrapper().getBoundingClientRect();l=o/d*r;const c=n.x/d*r,u=(n.x+5)/d*r;s=new a(Object.assign(Object.assign({},t),{start:c,end:u}),r,h),this.emit("region-initialized",s),s.element&&this.regionsContainer.appendChild(s.element)}else"move"===n.type&&void 0!==n.deltaX?s&&s._onUpdate(n.deltaX,n.x>o?"end":"start",l):"end"===n.type&&s&&(this.saveRegion(s),s.updatingSide=void 0,s=null)}),[d.signal]);return()=>{c(),d.cleanup()}}clearRegions(){this.regions.slice().forEach((t=>t.remove())),this.regions=[]}destroy(){this.clearRegions(),super.destroy(),this.regionsContainer.remove()}}return d})); diff --git a/app/static/vendor/wavesurfer/timeline.min.js b/app/static/vendor/wavesurfer/timeline.min.js new file mode 100644 index 0000000..25f6c11 --- /dev/null +++ b/app/static/vendor/wavesurfer/timeline.min.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):((t="undefined"!=typeof globalThis?globalThis:t||self).WaveSurfer=t.WaveSurfer||{},t.WaveSurfer.Timeline=e())}(this,(function(){"use strict";class t{constructor(){this.listeners={}}on(t,e,i){if(this.listeners[t]||(this.listeners[t]=new Set),null==i?void 0:i.once){const i=(...s)=>{this.un(t,i),e(...s)};return this.listeners[t].add(i),()=>this.un(t,i)}return this.listeners[t].add(e),()=>this.un(t,e)}un(t,e){var i;null===(i=this.listeners[t])||void 0===i||i.delete(e)}once(t,e){return this.on(t,e,{once:!0})}unAll(){this.listeners={}}emit(t,...e){this.listeners[t]&&this.listeners[t].forEach((t=>t(...e)))}}class e extends t{constructor(t){super(),this.subscriptions=[],this.isDestroyed=!1,this.options=t}onInit(){}_init(t){this.isDestroyed&&(this.subscriptions=[],this.isDestroyed=!1),this.wavesurfer=t,this.onInit()}destroy(){this.emit("destroy"),this.subscriptions.forEach((t=>t())),this.subscriptions=[],this.isDestroyed=!0,this.wavesurfer=void 0}}function i(t,e){const s=e.xmlns?document.createElementNS(e.xmlns,t):document.createElement(t);for(const[t,n]of Object.entries(e))if("children"===t&&n)for(const[t,e]of Object.entries(n))e instanceof Node?s.appendChild(e):"string"==typeof e?s.appendChild(document.createTextNode(e)):s.appendChild(i(t,e));else"style"===t?Object.assign(s.style,n):"textContent"===t?s.textContent=n:s.setAttribute(t,n.toString());return s}function s(t,e,s){return i(t,e||{})}const n={height:20,timeOffset:0,formatTimeCallback:t=>{if(t/60>1){return`${Math.floor(t/60)}:${`${(t=Math.round(t%60))<10?"0":""}${t}`}`}return`${Math.round(1e3*t)/1e3}`}};class o extends e{constructor(t){super(t||{}),this.notchElements=new Map,this.currentTimeline=null,this.options=Object.assign({},n,t),this.timelineWrapper=this.initTimelineWrapper()}static create(t){return new o(t)}onInit(){var t;if(!this.wavesurfer)throw Error("WaveSurfer is not initialized");let e=this.wavesurfer.getWrapper();if((i=this.options.container)instanceof HTMLElement||"object"==typeof i&&null!==i&&i.nodeType===Node.ELEMENT_NODE&&"object"==typeof i.style)e=this.options.container;else if("string"==typeof this.options.container){const t=document.querySelector(this.options.container);if(!t)throw Error(`No Timeline container found matching ${this.options.container}`);e=t}var i;this.options.insertPosition?(e.firstElementChild||e).insertAdjacentElement(this.options.insertPosition,this.timelineWrapper):e.appendChild(this.timelineWrapper);const s=this.wavesurfer.getState();this.subscriptions.push(function(t,e){let i;const s=()=>{i&&(i(),i=void 0),i=t()},n=e.map((t=>t.subscribe(s)));return s(),()=>{i&&(i(),i=void 0),n.forEach((t=>t()))}}((()=>{(s.duration.value>0||this.options.duration)&&this.initTimeline()}),[s.duration])),this.subscriptions.push(this.wavesurfer.on("redraw",(()=>this.initTimeline()))),this.subscriptions.push(this.wavesurfer.on("scroll",((t,e,i,s)=>{this.currentTimeline&&this.updateVisibleNotches(i,s,this.currentTimeline)}))),((null===(t=this.wavesurfer)||void 0===t?void 0:t.getDuration())||this.options.duration)&&this.initTimeline()}destroy(){this.timelineWrapper.remove(),super.destroy()}initTimelineWrapper(){return s("div",{part:"timeline-wrapper",style:{pointerEvents:"none"}})}defaultTimeInterval(t){return t>=25?1:5*t>=25?5:15*t>=25?15:60*Math.ceil(.5/t)}defaultPrimaryLabelInterval(t){return t>=25?10:5*t>=25?6:4}defaultSecondaryLabelInterval(t){return t>=25?5:2}virtualAppend(t,e,i){if(this.notchElements.set(i,{start:t,width:i.clientWidth,wasVisible:!1}),!this.wavesurfer)return;const s=this.wavesurfer.getScroll(),n=s+this.wavesurfer.getWidth(),o=this.notchElements.get(i),r=t>=s&&t+o.width{const o=s.start>=t&&s.start+s.width{this.un(t,i),e(...n)};return this.listeners[t].add(i),()=>this.un(t,i)}return this.listeners[t].add(e),()=>this.un(t,e)}un(t,e){var i;null===(i=this.listeners[t])||void 0===i||i.delete(e)}once(t,e){return this.on(t,e,{once:!0})}unAll(){this.listeners={}}emit(t,...e){this.listeners[t]&&this.listeners[t].forEach((t=>t(...e)))}}const i={decode:function(e,i){return t(this,void 0,void 0,(function*(){const t=new AudioContext({sampleRate:i});try{return yield t.decodeAudioData(e)}finally{"closed"!==t.state&&(yield t.close().catch((()=>{})))}}))},createBuffer:function(t,e){if(!t||0===t.length)throw new Error("channelData must be a non-empty array");if(e<=0)throw new Error("duration must be greater than 0");if("number"==typeof t[0]&&(t=[t]),!t[0]||0===t[0].length)throw new Error("channelData must contain non-empty channel arrays");!function(t){const e=t[0];if(e.some((t=>t>1||t<-1))){const i=e.length;let n=0;for(let t=0;tn&&(n=i)}for(const e of t)for(let t=0;tt instanceof Float32Array?t:Float32Array.from(t)));return{duration:e,length:i[0].length,sampleRate:i[0].length/e,numberOfChannels:i.length,getChannelData:t=>{const e=i[t];if(!e)throw new Error(`Channel ${t} not found`);return e},copyFromChannel:AudioBuffer.prototype.copyFromChannel,copyToChannel:AudioBuffer.prototype.copyToChannel}}};function n(t,e){const i=e.xmlns?document.createElementNS(e.xmlns,t):document.createElement(t);for(const[t,s]of Object.entries(e))if("children"===t&&s)for(const[t,e]of Object.entries(s))e instanceof Node?i.appendChild(e):"string"==typeof e?i.appendChild(document.createTextNode(e)):i.appendChild(n(t,e));else"style"===t?Object.assign(i.style,s):"textContent"===t?i.textContent=s:i.setAttribute(t,s.toString());return i}function s(t,e,i){const s=n(t,e||{});return null==i||i.appendChild(s),s}function r(t){return t instanceof HTMLElement||"object"==typeof t&&null!==t&&t.nodeType===Node.ELEMENT_NODE&&"object"==typeof t.style}var o=Object.freeze({__proto__:null,createElement:s,default:s,isHTMLElement:r});const a={fetchBlob:function(e,i,n){return t(this,void 0,void 0,(function*(){var s;const r=yield fetch(e,n);if(r.status>=400)throw new Error(`Failed to fetch ${e}: ${r.status} (${r.statusText})`);return function(e,i,n){t(this,void 0,void 0,(function*(){var t;if(!e.body||!e.headers)return;const s=e.body.getReader(),r=Number(e.headers.get("Content-Length"))||0;let o=0;const a=()=>{s.cancel()};if(n){if(n.aborted)return void s.cancel();n.addEventListener("abort",a,{once:!0})}try{for(;;){const e=yield s.read();if(e.done)break;if(o+=(null===(t=e.value)||void 0===t?void 0:t.length)||0,r>0){const t=Math.round(o/r*100);i(t)}}}catch(t){if(t instanceof DOMException&&"AbortError"===t.name)return;console.warn("Progress tracking error:",t)}finally{n&&n.removeEventListener("abort",a)}}))}(r.clone(),i,null!==(s=null==n?void 0:n.signal)&&void 0!==s?s:void 0),r.blob()}))}};function l(t){let e=t;const i=new Set;return{get value(){return e},set(t){Object.is(e,t)||(e=t,i.forEach((t=>t(e))))},update(t){this.set(t(e))},subscribe:t=>(i.add(t),()=>i.delete(t))}}function h(t,e){const i=l(t());return e.forEach((e=>e.subscribe((()=>{const e=t();Object.is(i.value,e)||i.set(e)})))),{get value(){return i.value},subscribe:t=>i.subscribe(t)}}function c(t,e){let i;const n=()=>{i&&(i(),i=void 0),i=t()},s=e.map((t=>t.subscribe(n)));return n(),()=>{i&&(i(),i=void 0),s.forEach((t=>t()))}}class u extends e{get isPlayingSignal(){return this._isPlaying}get currentTimeSignal(){return this._currentTime}get durationSignal(){return this._duration}get volumeSignal(){return this._volume}get mutedSignal(){return this._muted}get playbackRateSignal(){return this._playbackRate}get seekingSignal(){return this._seeking}constructor(t){super(),this.isExternalMedia=!1,this._ownBlobUrl=null,this.reactiveMediaEventCleanups=[],t.media?(this.media=t.media,this.isExternalMedia=!0):this.media=document.createElement("audio"),this._isPlaying=l(!1),this._currentTime=l(0),this._duration=l(0),this._volume=l(this.media.volume),this._muted=l(this.media.muted),this._playbackRate=l(this.media.playbackRate||1),this._seeking=l(!1),this.setupReactiveMediaEvents(),t.mediaControls&&(this.media.controls=!0),t.autoplay&&(this.media.autoplay=!0),null!=t.playbackRate&&this.onMediaEvent("canplay",(()=>{null!=t.playbackRate&&(this.media.playbackRate=t.playbackRate)}),{once:!0})}setupReactiveMediaEvents(){this.reactiveMediaEventCleanups.push(this.onMediaEvent("play",(()=>{this._isPlaying.set(!0)}))),this.reactiveMediaEventCleanups.push(this.onMediaEvent("pause",(()=>{this._isPlaying.set(!1)}))),this.reactiveMediaEventCleanups.push(this.onMediaEvent("ended",(()=>{this._isPlaying.set(!1)}))),this.reactiveMediaEventCleanups.push(this.onMediaEvent("timeupdate",(()=>{this._currentTime.set(this.media.currentTime)}))),this.reactiveMediaEventCleanups.push(this.onMediaEvent("durationchange",(()=>{this._duration.set(this.media.duration||0)}))),this.reactiveMediaEventCleanups.push(this.onMediaEvent("loadedmetadata",(()=>{this._duration.set(this.media.duration||0)}))),this.reactiveMediaEventCleanups.push(this.onMediaEvent("seeking",(()=>{this._seeking.set(!0)}))),this.reactiveMediaEventCleanups.push(this.onMediaEvent("seeked",(()=>{this._seeking.set(!1)}))),this.reactiveMediaEventCleanups.push(this.onMediaEvent("volumechange",(()=>{this._volume.set(this.media.volume),this._muted.set(this.media.muted)}))),this.reactiveMediaEventCleanups.push(this.onMediaEvent("ratechange",(()=>{this._playbackRate.set(this.media.playbackRate)})))}onMediaEvent(t,e,i){return this.media.addEventListener(t,e,i),()=>this.media.removeEventListener(t,e,i)}getSrc(){return this.media.currentSrc||this.media.src||""}revokeSrc(){this._ownBlobUrl&&(URL.revokeObjectURL(this._ownBlobUrl),this._ownBlobUrl=null)}canPlayType(t){return""!==this.media.canPlayType(t)}setSrc(t,e){const i=this.getSrc();if(t&&i===t)return;this.revokeSrc();const n=e instanceof Blob&&(this.canPlayType(e.type)||!t)?URL.createObjectURL(e):t;if(n!==t&&(this._ownBlobUrl=n),i&&this.media.removeAttribute("src"),n||t)try{this.media.src=n}catch(e){this.media.src=t}}destroy(){this.reactiveMediaEventCleanups.forEach((t=>t())),this.reactiveMediaEventCleanups=[],this.revokeSrc(),this.unAll(),this.isExternalMedia||(this.media.pause(),this.media.removeAttribute("src"),this.media.load(),this.media.remove())}setMediaElement(t){this.reactiveMediaEventCleanups.forEach((t=>t())),this.reactiveMediaEventCleanups=[],this.media=t,this.setupReactiveMediaEvents()}play(){return t(this,void 0,void 0,(function*(){try{return yield this.media.play()}catch(t){if(t instanceof DOMException&&"AbortError"===t.name)return;throw t}}))}pause(){this.media.pause()}isPlaying(){return!this.media.paused&&!this.media.ended}setTime(t){this.media.currentTime=Math.max(0,Math.min(t,this.getDuration()))}getDuration(){return this.media.duration}getCurrentTime(){return this.media.currentTime}getVolume(){return this.media.volume}setVolume(t){this.media.volume=t}getMuted(){return this.media.muted}setMuted(t){this.media.muted=t}getPlaybackRate(){return this.media.playbackRate}isSeeking(){return this.media.seeking}setPlaybackRate(t,e){null!=e&&(this.media.preservesPitch=e),this.media.playbackRate=t}getMediaElement(){return this.media}setSinkId(t){return this.media.setSinkId(t)}}function d({maxTop:t,maxBottom:e,halfHeight:i,vScale:n,barMinHeight:s=0,barAlign:r}){let o=Math.round(t*i*n);let a=o+Math.round(e*i*n)||1;return afunction(t){const{scrollLeft:e,scrollWidth:i,clientWidth:n}=t;if(0===i)return{startX:0,endX:1};const s=e/i,r=(e+n)/i;return{startX:Math.max(0,Math.min(1,s)),endX:Math.max(0,Math.min(1,r))}}(e.value)),[e]),n=h((()=>function(t){return{left:t.scrollLeft,right:t.scrollLeft+t.clientWidth}}(e.value)),[e]),s=()=>{e.set({scrollLeft:t.scrollLeft,scrollWidth:t.scrollWidth,clientWidth:t.clientWidth})};t.addEventListener("scroll",s,{passive:!0});return{scrollData:e,percentages:i,bounds:n,cleanup:()=>{t.removeEventListener("scroll",s),b(e)}}}class C extends e{constructor(t,e){super(),this.timeouts=[],this.isScrollable=!1,this.audioData=null,this.resizeObserver=null,this.lastContainerWidth=0,this.isDragging=!1,this.subscriptions=[],this.unsubscribeOnScroll=[],this.dragStream=null,this.scrollStream=null,this.containerInlinePadding=0,this.onClickWrapper=t=>{const e=this.wrapper.getBoundingClientRect(),[i,n]=m(e,t.clientX,t.clientY);this.emit("click",i,n)},this.onDblClickWrapper=t=>{const e=this.wrapper.getBoundingClientRect(),[i,n]=m(e,t.clientX,t.clientY);this.emit("dblclick",i,n)},this.subscriptions=[],this.options=t;const i=this.parentFromOptionsContainer(t.container);this.parent=i;const[n,s]=this.initHtml();i.appendChild(n),this.container=n,this.scrollContainer=s.querySelector(".scroll"),this.wrapper=s.querySelector(".wrapper"),this.canvasWrapper=s.querySelector(".canvases"),this.progressWrapper=s.querySelector(".progress"),this.cursor=s.querySelector(".cursor"),this.calculateInlinePadding(),e&&s.appendChild(e),this.initEvents()}parentFromOptionsContainer(t){let e;if("string"==typeof t?e=document.querySelector(t):r(t)&&(e=t),!e)throw new Error("Container not found");return e}initEvents(){this.wrapper.addEventListener("click",this.onClickWrapper),this.wrapper.addEventListener("dblclick",this.onDblClickWrapper),!0!==this.options.dragToSeek&&"object"!=typeof this.options.dragToSeek||this.initDrag(),this.scrollStream=y(this.scrollContainer);const t=c((()=>{const{startX:t,endX:e}=this.scrollStream.percentages.value,{left:i,right:n}=this.scrollStream.bounds.value;this.emit("scroll",t,e,i,n)}),[this.scrollStream.percentages,this.scrollStream.bounds]);if(this.subscriptions.push(t),"function"==typeof ResizeObserver){const t=this.createDelay(100);this.resizeObserver=new ResizeObserver((()=>{t().then((()=>this.onContainerResize())).catch((()=>{}))})),this.resizeObserver.observe(this.scrollContainer)}}onContainerResize(){const t=this.parent.clientWidth;this.calculateInlinePadding(),t===this.lastContainerWidth&&"auto"!==this.options.height||(this.lastContainerWidth=t,this.reRender(),this.emit("resize"))}initDrag(){if(this.dragStream)return;this.dragStream=function(t,e={}){const{threshold:i=3,mouseButton:n=0,touchDelay:s=100}=e,r=l(null),o=new Map,a=matchMedia("(pointer: coarse)").matches;let h=()=>{};const c=e=>{if(e.button!==n)return;if(o.has(e.pointerId))return;if(o.set(e.pointerId,e),o.size>1)return;const l=e.pointerId;let c=e.clientX,u=e.clientY,d=!1;const p=Date.now(),m=t.getBoundingClientRect(),{left:f,top:g}=m,v=t=>{if(t.pointerId!==l)return;if(t.defaultPrevented||o.size>1)return;if(a&&Date.now()-pi||Math.abs(m)>i)&&(t.preventDefault(),t.stopPropagation(),d||(r.set({type:"start",x:c-f,y:u-g}),d=!0),r.set({type:"move",x:e-f,y:n-g,deltaX:h,deltaY:m}),c=e,u=n)},b=t=>{if(o.delete(t.pointerId)){if(t.pointerId===l&&d){const e=t.clientX,i=t.clientY;r.set({type:"end",x:e-f,y:i-g})}0===o.size&&h()}},y=t=>{t.relatedTarget&&t.relatedTarget!==document.documentElement||b(t)},C=t=>{d&&(t.stopPropagation(),t.preventDefault())},S=t=>{t.defaultPrevented||o.size>1||d&&t.preventDefault()};document.addEventListener("pointermove",v),document.addEventListener("pointerup",b),document.addEventListener("pointerout",y),document.addEventListener("pointercancel",y),document.addEventListener("touchmove",S,{passive:!1}),document.addEventListener("click",C,{capture:!0}),h=()=>{document.removeEventListener("pointermove",v),document.removeEventListener("pointerup",b),document.removeEventListener("pointerout",y),document.removeEventListener("pointercancel",y),document.removeEventListener("touchmove",S),setTimeout((()=>{document.removeEventListener("click",C,{capture:!0})}),10)}};return t.addEventListener("pointerdown",c),{signal:r,cleanup:()=>{h(),t.removeEventListener("pointerdown",c),o.clear(),b(r)}}}(this.wrapper);const t=c((()=>{const t=this.dragStream.signal.value;if(!t)return;const e=this.wrapper.getBoundingClientRect().width,i=(n=t.x/e)<0?0:n>1?1:n;var n;"start"===t.type?(this.isDragging=!0,this.emit("dragstart",i)):"move"===t.type?this.emit("drag",i):"end"===t.type&&(this.isDragging=!1,this.emit("dragend",i))}),[this.dragStream.signal]);this.subscriptions.push(t)}calculateInlinePadding(){const{paddingLeft:t,paddingRight:e}=getComputedStyle(this.scrollContainer),i=parseFloat(t)+parseFloat(e);this.containerInlinePadding=Number.isNaN(i)?0:i}initHtml(){const t=document.createElement("div"),e=t.attachShadow({mode:"open"}),i=this.options.cspNonce&&"string"==typeof this.options.cspNonce?this.options.cspNonce.replace(/"/g,""):"";return e.innerHTML=`\n \n :host {\n user-select: none;\n min-width: 1px;\n }\n :host audio {\n display: block;\n width: 100%;\n }\n :host .scroll {\n overflow-x: auto;\n overflow-y: hidden;\n width: 100%;\n position: relative;\n }\n :host .noScrollbar {\n scrollbar-color: transparent;\n scrollbar-width: none;\n }\n :host .noScrollbar::-webkit-scrollbar {\n display: none;\n -webkit-appearance: none;\n }\n :host .wrapper {\n position: relative;\n overflow: visible;\n z-index: 2;\n }\n :host .canvases {\n min-height: ${this.getHeight(this.options.height,this.options.splitChannels)}px;\n pointer-events: none;\n }\n :host .canvases > div {\n position: relative;\n }\n :host canvas {\n display: block;\n position: absolute;\n top: 0;\n image-rendering: pixelated;\n }\n :host .progress {\n pointer-events: none;\n position: absolute;\n z-index: 2;\n top: 0;\n left: 0;\n width: 0;\n height: 100%;\n overflow: hidden;\n }\n :host .progress > div {\n position: relative;\n }\n :host .cursor {\n pointer-events: none;\n position: absolute;\n z-index: 5;\n top: 0;\n left: 0;\n height: 100%;\n border-radius: 2px;\n }\n \n\n
\n
\n
\n
\n
\n
\n
\n `,[t,e]}setOptions(t){var e;if(this.options.container!==t.container){const e=this.parentFromOptionsContainer(t.container);e.appendChild(this.container),this.parent=e}!0===t.dragToSeek||"object"==typeof this.options.dragToSeek?this.initDrag():(null===(e=this.dragStream)||void 0===e||e.cleanup(),this.dragStream=null),this.options=t,this.reRender()}getWrapper(){return this.wrapper}getWidth(){return this.scrollContainer.clientWidth-this.containerInlinePadding}getScroll(){return this.scrollContainer.scrollLeft}setScroll(t){this.scrollContainer.scrollLeft=t}setScrollPercentage(t){const{scrollWidth:e}=this.scrollContainer,i=e*t;this.setScroll(i)}destroy(){var t;this.wrapper.removeEventListener("click",this.onClickWrapper),this.wrapper.removeEventListener("dblclick",this.onDblClickWrapper),this.timeouts.forEach((t=>t())),this.timeouts=[],this.subscriptions.forEach((t=>t())),this.container.remove(),this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),null===(t=this.unsubscribeOnScroll)||void 0===t||t.forEach((t=>t())),this.unsubscribeOnScroll=[],this.dragStream&&(this.dragStream.cleanup(),this.dragStream=null),this.scrollStream&&(this.scrollStream.cleanup(),this.scrollStream=null)}createDelay(t=10){let e,i;const n=()=>{e&&(clearTimeout(e),e=void 0),i&&(i(),i=void 0)};return this.timeouts.push(n),()=>new Promise(((s,r)=>{n(),i=r,e=setTimeout((()=>{e=void 0,i=void 0,s()}),t)}))}getHeight(t,e){var i;const n=(null===(i=this.audioData)||void 0===i?void 0:i.numberOfChannels)||1;return function({optionsHeight:t,optionsSplitChannels:e,parentHeight:i,numberOfChannels:n,defaultHeight:s=128}){if(null==t)return s;const r=Number(t);if(!isNaN(r))return r;if("auto"===t){const t=i||s;return(null==e?void 0:e.every((t=>!t.overlay)))?t/n:t}return s}({optionsHeight:t,optionsSplitChannels:e,parentHeight:this.parent.clientHeight,numberOfChannels:n,defaultHeight:128})}convertColorValues(t,e){return function(t,e,i){if(!Array.isArray(t))return t||"";if(0===t.length)return"#999";if(t.length<2)return t[0]||"";const n=document.createElement("canvas"),s=n.getContext("2d");if(!s)return t[0]||"";const r=i||n.height*e,o=s.createLinearGradient(0,0,0,r),a=1/(t.length-1);return t.forEach(((t,e)=>{o.addColorStop(e*a,t)})),o}(t,this.getPixelRatio(),null==e?void 0:e.canvas.height)}getPixelRatio(){return t=window.devicePixelRatio,Math.max(1,t||1);var t}renderBarWaveform(t,e,i,n){const{width:s,height:r}=i.canvas,{halfHeight:o,barWidth:a,barRadius:l,barIndexScale:h,barSpacing:c,barMinHeight:u}=function({width:t,height:e,length:i,options:n,pixelRatio:s}){const r=e/2,o=n.barWidth?n.barWidth*s:1,a=n.barGap?n.barGap*s:n.barWidth?o/2:0,l=o+a||1;return{halfHeight:r,barWidth:o,barGap:a,barRadius:n.barRadius||0,barMinHeight:n.barMinHeight?n.barMinHeight*s:0,barIndexScale:i>0?t/l/i:0,barSpacing:l}}({width:s,height:r,length:(t[0]||[]).length,options:e,pixelRatio:this.getPixelRatio()}),m=function({channelData:t,barIndexScale:e,barSpacing:i,barWidth:n,halfHeight:s,vScale:r,canvasHeight:o,barAlign:a,barMinHeight:l}){const h=t[0]||[],c=t[1]||h,u=h.length,m=[];let f=0,g=0,v=0;for(let t=0;t<=u;t++){const u=Math.round(t*e);if(u>f){const{topHeight:t,totalHeight:e}=d({maxTop:g,maxBottom:v,halfHeight:s,vScale:r,barMinHeight:l,barAlign:a}),h=p({barAlign:a,halfHeight:s,topHeight:t,totalHeight:e,canvasHeight:o});m.push({x:f*i,y:h,width:n,height:e}),f=u,g=0,v=0}const b=Math.abs(h[t]||0),y=Math.abs(c[t]||0);b>g&&(g=b),y>v&&(v=y)}return m}({channelData:t,barIndexScale:h,barSpacing:c,barWidth:a,halfHeight:o,vScale:n,canvasHeight:r,barAlign:e.barAlign,barMinHeight:u});i.beginPath();for(const t of m)l&&"roundRect"in i?i.roundRect(t.x,t.y,t.width,t.height,l):i.rect(t.x,t.y,t.width,t.height);i.fill(),i.closePath()}renderLineWaveform(t,e,i,n){const{width:s,height:r}=i.canvas,o=function({channelData:t,width:e,height:i,vScale:n}){const s=i/2,r=t[0]||[];return[r,t[1]||r].map(((t,i)=>{const r=t.length,o=r?e/r:0,a=s,l=0===i?-1:1,h=[{x:0,y:a}];let c=0,u=0;for(let e=0;e<=r;e++){const i=Math.round(e*o);if(i>c){const t=a+(Math.round(u*s*n)||1)*l;h.push({x:c,y:t}),c=i,u=0}const r=Math.abs(t[e]||0);r>u&&(u=r)}return h.push({x:c,y:a}),h}))}({channelData:t,width:s,height:r,vScale:n});i.beginPath();for(const t of o)if(t.length){i.moveTo(t[0].x,t[0].y);for(let e=1;ea&&(a=i)}return a?r/a:r}({channelData:t,barHeight:e.barHeight,normalize:e.normalize,maxPeak:e.maxPeak});f(e)?this.renderBarWaveform(t,e,i,n):this.renderLineWaveform(t,e,i,n)}renderSingleCanvas(t,e,i,n,s,r,o){const a=this.getPixelRatio(),l=document.createElement("canvas");l.width=Math.round(i*a),l.height=Math.round(n*a),l.style.width=`${i}px`,l.style.height=`${n}px`,l.style.left=`${Math.round(s)}px`,r.appendChild(l);const h=l.getContext("2d");if(e.renderFunction?(h.fillStyle=this.convertColorValues(e.waveColor,h),e.renderFunction(t,h)):this.renderWaveform(t,e,h),l.width>0&&l.height>0){const t=l.cloneNode(),i=t.getContext("2d");i.drawImage(l,0,0),i.globalCompositeOperation="source-in",i.fillStyle=this.convertColorValues(e.progressColor,i),i.fillRect(0,0,l.width,l.height),o.appendChild(t)}}renderMultiCanvas(t,e,i,n,s,r){const o=this.getPixelRatio(),{clientWidth:a}=this.scrollContainer,l=i/o,h=function({clientWidth:t,totalWidth:e,options:i}){return g(Math.min(8e3,t,e),i)}({clientWidth:a,totalWidth:l,options:e});let c={};if(0===h)return;const u=i=>{if(i<0||i>=d)return;if(c[i])return;c[i]=!0;const o=i*h;let a=Math.min(l-o,h);if(a=g(a,e),a<=0)return;const u=function({channelData:t,offset:e,clampedWidth:i,totalWidth:n}){return t.map((t=>{const s=Math.floor(e/n*t.length),r=Math.floor((e+i)/n*t.length);return t.slice(s,r)}))}({channelData:t,offset:o,clampedWidth:a,totalWidth:l});this.renderSingleCanvas(u,e,a,n,o,s,r)},d=Math.ceil(l/h);if(!this.isScrollable){for(let t=0;tu(t))),d>1){const t=this.on("scroll",(()=>{const{scrollLeft:t}=this.scrollContainer;Object.keys(c).length>10&&(s.innerHTML="",r.innerHTML="",c={}),v({scrollLeft:t,totalWidth:l,numCanvases:d}).forEach((t=>u(t)))}));this.unsubscribeOnScroll.push(t)}}renderChannel(t,e,i,n){var{overlay:s}=e,r=function(t,e){var i={};for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&e.indexOf(n)<0&&(i[n]=t[n]);if(null!=t&&"function"==typeof Object.getOwnPropertySymbols){var s=0;for(n=Object.getOwnPropertySymbols(t);s0&&(o.style.marginTop=`-${a}px`),this.canvasWrapper.style.minHeight=`${a}px`,this.canvasWrapper.appendChild(o);const l=o.cloneNode();this.progressWrapper.appendChild(l),this.renderMultiCanvas(t,r,i,a,o,l)}render(e){return t(this,void 0,void 0,(function*(){var t;this.timeouts.forEach((t=>t())),this.timeouts=[],this.unsubscribeOnScroll.forEach((t=>t())),this.unsubscribeOnScroll=[],this.canvasWrapper.innerHTML="",this.progressWrapper.innerHTML="",null!=this.options.width&&(this.scrollContainer.style.width="number"==typeof this.options.width?`${this.options.width}px`:this.options.width);const i=this.getPixelRatio(),n=this.scrollContainer.clientWidth-this.containerInlinePadding,{scrollWidth:s,isScrollable:r,useParentWidth:o,width:a}=function({duration:t,minPxPerSec:e=0,parentWidth:i,fillParent:n,pixelRatio:s}){const r=Math.ceil(t*e),o=r>i,a=Boolean(n&&!o);return{scrollWidth:r,isScrollable:o,useParentWidth:a,width:(a?i:r)*s}}({duration:e.duration,minPxPerSec:this.options.minPxPerSec||0,parentWidth:n,fillParent:this.options.fillParent,pixelRatio:i});if(this.isScrollable=r,this.wrapper.style.width=o?"100%":`${s}px`,this.scrollContainer.style.overflowX=this.isScrollable?"auto":"hidden",this.scrollContainer.classList.toggle("noScrollbar",!!this.options.hideScrollbar),this.cursor.style.backgroundColor=`${this.options.cursorColor||this.options.progressColor}`,this.cursor.style.width=`${this.options.cursorWidth}px`,this.audioData=e,this.emit("render"),this.options.splitChannels)for(let i=0;i1&&t.push(e.getChannelData(1)),this.renderChannel(t,this.options,a,0)}Promise.resolve().then((()=>this.emit("rendered")))}))}reRender(){if(this.unsubscribeOnScroll.forEach((t=>t())),this.unsubscribeOnScroll=[],!this.audioData)return;const{scrollWidth:t}=this.scrollContainer,{right:e}=this.progressWrapper.getBoundingClientRect();if(this.render(this.audioData),!this.isScrollable&&this.scrollContainer.scrollLeft)this.scrollContainer.scrollLeft=0;else if(this.isScrollable&&t!==this.scrollContainer.scrollWidth){const{right:t}=this.progressWrapper.getBoundingClientRect(),i=function(t){const e=2*t;return(e<0?Math.floor(e):Math.ceil(e))/2}(t-e);this.scrollContainer.scrollLeft+=i}}zoom(t){this.options.minPxPerSec=t,this.reRender()}scrollIntoView(t,e=!1){var i;const{scrollLeft:n,scrollWidth:s,clientWidth:r}=this.scrollContainer,o=t*s,a=n,l=n+r,h=r/2;if(this.isDragging){const t=30;o+t>l?this.scrollContainer.scrollLeft+=t:o-tl)&&(this.scrollContainer.scrollLeft=o-(this.options.autoCenter?h:0));const t=o-n-h;if(e&&this.options.autoCenter&&t>0){const e=null===(i=this.audioData)||void 0===i?void 0:i.duration;if(void 0===e||e<=0)return void(this.scrollContainer.scrollLeft+=t);const n=s/e;this.scrollContainer.scrollLeft+=n<=600?Math.min(t,10):t}}}renderProgress(t,e){if(isNaN(t))return;const i=100*t;this.canvasWrapper.style.clipPath=`polygon(${i}% 0%, 100% 0%, 100% 100%, ${i}% 100%)`,this.progressWrapper.style.width=`${i}%`,this.cursor.style.left=`${i}%`,this.cursor.style.transform=this.options.cursorWidth?`translateX(-${t*this.options.cursorWidth}px)`:"",this.isScrollable&&this.options.autoScroll&&this.audioData&&this.audioData.duration>0&&this.scrollIntoView(t,e)}exportImage(e,i,n){return t(this,void 0,void 0,(function*(){const t=this.canvasWrapper.querySelectorAll("canvas");if(!t.length)throw new Error("No waveform data");if("dataURL"===n){const n=Array.from(t).map((t=>t.toDataURL(e,i)));return Promise.resolve(n)}return Promise.all(Array.from(t).map((t=>new Promise(((n,s)=>{t.toBlob((t=>{t?n(t):s(new Error("Could not export image"))}),e,i)})))))}))}}class S extends e{constructor(){super(...arguments),this.animationFrameId=null,this.isRunning=!1}start(){if(this.isRunning)return;this.isRunning=!0;const t=()=>{this.isRunning&&(this.emit("tick"),this.animationFrameId=requestAnimationFrame(t))};t()}stop(){this.isRunning=!1,null!==this.animationFrameId&&(cancelAnimationFrame(this.animationFrameId),this.animationFrameId=null)}destroy(){this.stop(),this.unAll()}}class E extends e{constructor(t){super(),this.bufferNode=null,this.playStartTime=0,this.playbackPosition=0,this._muted=!1,this._playbackRate=1,this._duration=void 0,this.buffer=null,this.currentSrc="",this.paused=!0,this.crossOrigin=null,this.seeking=!1,this.autoplay=!1,this.addEventListener=this.on,this.removeEventListener=this.un,this._destroyed=!1,function(){const t=globalThis.navigator;if(null==t?void 0:t.audioSession)try{t.audioSession.type="playback"}catch(t){console.warn("Setting navigator.audioSession.type failed:",t)}}(),this.audioContext=t||new AudioContext,this.gainNode=this.audioContext.createGain(),this.gainNode.connect(this.audioContext.destination)}load(){return t(this,void 0,void 0,(function*(){}))}remove(){this.destroy()}destroy(){if(!this._destroyed){if(this._destroyed=!0,this.currentSrc="",this.bufferNode){this.bufferNode.onended=null;try{this.bufferNode.stop()}catch(t){}this.bufferNode.disconnect(),this.bufferNode=null}this.gainNode.disconnect(),"function"==typeof this.audioContext.close&&Promise.resolve(this.audioContext.close.call(this.audioContext)).catch((()=>{})),this.buffer=null,this.unAll()}}get src(){return this.currentSrc}set src(t){if(this.currentSrc=t,this._duration=void 0,!t)return this.buffer=null,void this.emit("emptied");fetch(t).then((e=>{if(e.status>=400)throw new Error(`Failed to fetch ${t}: ${e.status} (${e.statusText})`);return e.arrayBuffer()})).then((e=>this.currentSrc!==t?null:this.audioContext.decodeAudioData(e))).then((e=>{this.currentSrc===t&&(this.buffer=e,this.emit("loadedmetadata"),this.emit("canplay"),this.autoplay&&this.play())})).catch((t=>{console.error("WebAudioPlayer load error:",t)}))}_play(){if(!this.paused)return;this.paused=!1,this.bufferNode&&(this.bufferNode.onended=null,this.bufferNode.disconnect()),this.bufferNode=this.audioContext.createBufferSource(),this.buffer&&(this.bufferNode.buffer=this.buffer),this.bufferNode.playbackRate.value=this._playbackRate,this.bufferNode.connect(this.gainNode);let t=this.playbackPosition;(t>=this.duration||t<0)&&(t=0,this.playbackPosition=0),this.bufferNode.start(this.audioContext.currentTime,t),this.playStartTime=this.audioContext.currentTime,this.bufferNode.onended=()=>{!this.paused&&this.duration-this.currentTime<.01&&(this.pause(),this.emit("ended"))}}_pause(){if(this.paused=!0,this.bufferNode){this.bufferNode.onended=null;try{this.bufferNode.stop()}catch(t){}}this.playbackPosition+=(this.audioContext.currentTime-this.playStartTime)*this._playbackRate}play(){return t(this,void 0,void 0,(function*(){this.paused&&(this._play(),this.emit("play"))}))}pause(){this.paused||(this._pause(),this.emit("pause"))}stopAt(t){const e=(t-this.currentTime)/this._playbackRate,i=this.bufferNode;null==i||i.stop(this.audioContext.currentTime+e),null==i||i.addEventListener("ended",(()=>{i===this.bufferNode&&(this.bufferNode=null,this.pause(),this.playbackPosition=Math.min(t,this.duration),this.emit("timeupdate"))}),{once:!0})}setSinkId(e){return t(this,void 0,void 0,(function*(){return this.audioContext.setSinkId(e)}))}get playbackRate(){return this._playbackRate}set playbackRate(t){const e=!this.paused;e&&this._pause(),this._playbackRate=t,e&&this._play(),this.bufferNode&&(this.bufferNode.playbackRate.value=t)}get currentTime(){return this.paused?this.playbackPosition:this.playbackPosition+(this.audioContext.currentTime-this.playStartTime)*this._playbackRate}set currentTime(t){const e=!this.paused;e&&this._pause(),this.playbackPosition=t,e&&this._play(),this.emit("seeking"),this.emit("timeupdate")}get duration(){var t,e;return null!==(t=this._duration)&&void 0!==t?t:(null===(e=this.buffer)||void 0===e?void 0:e.duration)||0}set duration(t){this._duration=t}get volume(){return this.gainNode.gain.value}set volume(t){this.gainNode.gain.value=t,this.emit("volumechange")}get muted(){return this._muted}set muted(t){this._muted!==t&&(this._muted=t,this._muted?this.gainNode.disconnect():this.gainNode.connect(this.audioContext.destination))}canPlayType(t){return/^(audio|video)\//.test(t)}getGainNode(){return this.gainNode}getChannelData(){const t=[];if(!this.buffer)return t;const e=this.buffer.numberOfChannels;for(let i=0;i!u.value),[u]),S=h((()=>null!==f.value),[f]),E=h((()=>S.value&&c.value>0),[S,c]),P=h((()=>a.value),[a]),w=h((()=>c.value>0?a.value/c.value:0),[a,c]);return{state:{currentTime:a,duration:c,isPlaying:u,isPaused:C,isSeeking:d,volume:p,playbackRate:m,audioBuffer:f,peaks:g,url:v,zoom:b,scrollPosition:y,canPlay:S,isReady:E,progress:P,progressPercent:w},actions:{setCurrentTime:t=>{const e=Math.max(0,Math.min(c.value||1/0,t));a.set(e)},setDuration:t=>{c.set(Math.max(0,t))},setPlaying:t=>{u.set(t)},setSeeking:t=>{d.set(t)},setVolume:t=>{const e=Math.max(0,Math.min(1,t));p.set(e)},setPlaybackRate:t=>{const e=Math.max(.1,Math.min(16,t));m.set(e)},setAudioBuffer:t=>{f.set(t),t&&c.set(t.duration)},setPeaks:t=>{g.set(t)},setUrl:t=>{v.set(t)},setZoom:t=>{b.set(Math.max(0,t))},setScrollPosition:t=>{y.set(Math.max(0,t))}}}}({isPlaying:this.isPlayingSignal,currentTime:this.currentTimeSignal,duration:this.durationSignal,volume:this.volumeSignal,playbackRate:this.playbackRateSignal,isSeeking:this.seekingSignal});this.wavesurferState=i,this.wavesurferActions=n,this.timer=new S;const s=e?void 0:this.getMediaElement();this.renderer=new C(this.options,s),this.initPlayerEvents(),this.initRendererEvents(),this.initTimerEvents(),this.initReactiveState(),this.initPlugins();const r=this.options.url||this.getSrc()||"";Promise.resolve().then((()=>{this.emit("init");const{peaks:t,duration:e}=this.options;(r||t&&e)&&this.load(r,t,e).catch((()=>{}))}))}updateProgress(t=this.getCurrentTime()){return this.renderer.renderProgress(t/this.getDuration(),this.isPlaying()),t}initTimerEvents(){this.subscriptions.push(this.timer.on("tick",(()=>{if(!this.isSeeking()){const t=this.updateProgress();if(this.emit("timeupdate",t),this.emit("audioprocess",t),null!=this.stopAtPosition&&this.isPlaying()&&t>=this.stopAtPosition){const t=this.stopAtPosition;this.pause(),this.setTime(t)}}})))}initReactiveState(){this.reactiveCleanups.push(function(t,e){const i=[];i.push(c((()=>{const i=t.isPlaying.value;e.emit(i?"play":"pause")}),[t.isPlaying])),i.push(c((()=>{const i=t.currentTime.value;e.emit("timeupdate",i),t.isPlaying.value&&e.emit("audioprocess",i)}),[t.currentTime,t.isPlaying])),i.push(c((()=>{t.isSeeking.value&&e.emit("seeking",t.currentTime.value)}),[t.isSeeking,t.currentTime]));let n=!1;i.push(c((()=>{t.isReady.value&&!n&&(n=!0,e.emit("ready",t.duration.value))}),[t.isReady,t.duration])),i.push(c((()=>{null===t.audioBuffer.value&&(n=!1)}),[t.audioBuffer]));let s=!1;return i.push(c((()=>{const i=t.isPlaying.value,n=t.currentTime.value,r=t.duration.value,o=r>0&&n>=r;s&&!i&&o&&e.emit("finish"),s=i&&o}),[t.isPlaying,t.currentTime,t.duration])),i.push(c((()=>{const i=t.zoom.value;i>0&&e.emit("zoom",i)}),[t.zoom])),()=>{i.forEach((t=>t()))}}(this.wavesurferState,{emit:this.emit.bind(this)}))}initPlayerEvents(){this.isPlaying()&&(this.emit("play"),this.timer.start()),this.mediaSubscriptions.push(this.onMediaEvent("timeupdate",(()=>{const t=this.updateProgress();this.emit("timeupdate",t)})),this.onMediaEvent("play",(()=>{this.emit("play"),this.timer.start()})),this.onMediaEvent("pause",(()=>{this.emit("pause"),this.timer.stop(),this.stopAtPosition=null})),this.onMediaEvent("emptied",(()=>{this.timer.stop(),this.stopAtPosition=null})),this.onMediaEvent("ended",(()=>{this.emit("timeupdate",this.getDuration()),this.emit("finish"),this.stopAtPosition=null})),this.onMediaEvent("seeking",(()=>{this.emit("seeking",this.getCurrentTime())})),this.onMediaEvent("error",(()=>{var t;this.emit("error",null!==(t=this.getMediaElement().error)&&void 0!==t?t:new Error("Media error")),this.stopAtPosition=null})))}initRendererEvents(){this.subscriptions.push(this.renderer.on("click",((t,e)=>{this.options.interact&&(this.seekTo(t),this.emit("interaction",t*this.getDuration()),this.emit("click",t,e))})),this.renderer.on("dblclick",((t,e)=>{this.emit("dblclick",t,e)})),this.renderer.on("scroll",((t,e,i,n)=>{const s=this.getDuration();this.emit("scroll",t*s,e*s,i,n)})),this.renderer.on("render",(()=>{this.emit("redraw")})),this.renderer.on("rendered",(()=>{this.emit("redrawcomplete")})),this.renderer.on("dragstart",(t=>{this.emit("dragstart",t)})),this.renderer.on("dragend",(t=>{this.emit("dragend",t)})),this.renderer.on("resize",(()=>{this.emit("resize")})));{let t;const e=this.renderer.on("drag",(e=>{var i;if(!this.options.interact)return;this.renderer.renderProgress(e),clearTimeout(t);let n=0;const s=this.options.dragToSeek;this.isPlaying()?n=0:!0===s?n=200:s&&"object"==typeof s&&(n=null!==(i=s.debounceTime)&&void 0!==i?i:200),t=setTimeout((()=>{this.seekTo(e)}),n),this.emit("interaction",e*this.getDuration()),this.emit("drag",e)}));this.subscriptions.push((()=>{clearTimeout(t),e()}))}}initPlugins(){var t;(null===(t=this.options.plugins)||void 0===t?void 0:t.length)&&this.options.plugins.forEach((t=>{this.registerPlugin(t)}))}unsubscribePlayerEvents(){this.mediaSubscriptions.forEach((t=>t())),this.mediaSubscriptions=[]}setOptions(t){this.options=Object.assign({},this.options,t),t.duration&&!t.peaks&&(this.decodedData=i.createBuffer(this.exportPeaks(),t.duration)),t.peaks&&t.duration&&(this.decodedData=i.createBuffer(t.peaks,t.duration)),this.renderer.setOptions(this.options),t.audioRate&&this.setPlaybackRate(t.audioRate),null!=t.mediaControls&&(this.getMediaElement().controls=t.mediaControls)}registerPlugin(t){if(this.plugins.includes(t))return t;t._init(this),this.plugins.push(t);const e=t.once("destroy",(()=>{this.plugins=this.plugins.filter((e=>e!==t)),this.subscriptions=this.subscriptions.filter((t=>t!==e))}));return this.subscriptions.push(e),t}unregisterPlugin(t){this.plugins=this.plugins.filter((e=>e!==t)),t.destroy()}getWrapper(){return this.renderer.getWrapper()}getWidth(){return this.renderer.getWidth()}getScroll(){return this.renderer.getScroll()}setScroll(t){return this.renderer.setScroll(t)}setScrollTime(t){const e=t/this.getDuration();this.renderer.setScrollPercentage(e)}getActivePlugins(){return this.plugins}loadAudio(e,n,s,r){return t(this,void 0,void 0,(function*(){var t;const o=++this._loadVersion;if(this._isDestroyed=!1,this.emit("load",e),!this.options.media&&this.isPlaying()&&this.pause(),this.decodedData=null,this.stopAtPosition=null,null===(t=this.abortController)||void 0===t||t.abort(),this.abortController=null,!n&&!s){const t=this.options.fetchParams||{};window.AbortController&&!t.signal&&(this.abortController=new AbortController,t.signal=this.abortController.signal);const i=t=>this.emit("loading",t);if(n=yield a.fetchBlob(e,i,t),this._isDestroyed||o!==this._loadVersion)return;const s=this.options.blobMimeType;s&&(n=new Blob([n],{type:s}))}if(this._isDestroyed||o!==this._loadVersion)return;this.setSrc(e,n);const l=yield new Promise((t=>{const e=r||this.getDuration();e?t(e):this.mediaSubscriptions.push(this.onMediaEvent("loadedmetadata",(()=>t(this.getDuration())),{once:!0}))}));if(!this._isDestroyed&&o===this._loadVersion){if(!e&&!n){const t=this.getMediaElement();t instanceof E&&(t.duration=l)}if(s)this.decodedData=i.createBuffer(s,l||0);else if(n){const t=yield n.arrayBuffer();if(this._isDestroyed||o!==this._loadVersion)return;this.decodedData=yield i.decode(t,this.options.sampleRate)}this._isDestroyed||o!==this._loadVersion||(this.decodedData&&(this.emit("decode",this.getDuration()),this.renderer.render(this.decodedData)),this.emit("ready",this.getDuration()))}}))}load(e,i,n){return t(this,void 0,void 0,(function*(){try{return yield this.loadAudio(e,void 0,i,n)}catch(t){throw this.emit("error",t),t}}))}loadBlob(e,i,n){return t(this,void 0,void 0,(function*(){try{return yield this.loadAudio("",e,i,n)}catch(t){throw this.emit("error",t),t}}))}zoom(t){if(!this.decodedData)throw new Error("No audio loaded");this.renderer.zoom(t),this.emit("zoom",t)}getDecodedData(){return this.decodedData}exportPeaks({channels:t=2,maxLength:e=8e3,precision:i=1e4}={}){if(!this.decodedData)throw new Error("The audio has not been decoded yet");const n=Math.min(t,this.decodedData.numberOfChannels),s=[];for(let t=0;tMath.abs(s)&&(s=i)}r.push(Math.round(s*i)/i)}s.push(r)}return s}getDuration(){let t=super.getDuration()||0;return 0!==t&&t!==1/0||!this.decodedData||(t=this.decodedData.duration),t}toggleInteraction(t){this.options.interact=t}setTime(t){this.stopAtPosition=null,super.setTime(t),this.updateProgress(t),this.emit("timeupdate",t)}seekTo(t){const e=this.getDuration()*t;this.setTime(e)}play(e,i){const n=Object.create(null,{play:{get:()=>super.play}});return t(this,void 0,void 0,(function*(){null!=e&&this.setTime(e);const t=yield n.play.call(this);return null!=i&&(this.media instanceof E?this.media.stopAt(i):this.stopAtPosition=i),t}))}playPause(){return t(this,void 0,void 0,(function*(){return this.isPlaying()?this.pause():this.play()}))}stop(){this.pause(),this.setTime(0)}skip(t){this.setTime(this.getCurrentTime()+t)}empty(){this.load("",[[0]],.001)}setMediaElement(t){this.unsubscribePlayerEvents(),super.setMediaElement(t),this.initPlayerEvents()}exportImage(){return t(this,arguments,void 0,(function*(t="image/png",e=1,i="dataURL"){return this.renderer.exportImage(t,e,i)}))}destroy(){var t;this._isDestroyed=!0,this.emit("destroy"),null===(t=this.abortController)||void 0===t||t.abort(),this.plugins.forEach((t=>t.destroy())),this.subscriptions.forEach((t=>t())),this.unsubscribePlayerEvents(),this.reactiveCleanups.forEach((t=>t())),this.reactiveCleanups=[],this.timer.destroy(),this.renderer.destroy(),super.destroy()}}return w.BasePlugin=class extends e{constructor(t){super(),this.subscriptions=[],this.isDestroyed=!1,this.options=t}onInit(){}_init(t){this.isDestroyed&&(this.subscriptions=[],this.isDestroyed=!1),this.wavesurfer=t,this.onInit()}destroy(){this.emit("destroy"),this.subscriptions.forEach((t=>t())),this.subscriptions=[],this.isDestroyed=!0,this.wavesurfer=void 0}},w.dom=o,w})); diff --git a/app/templates/subtitle_workflow.html b/app/templates/subtitle_workflow.html index de23c58..b79a790 100644 --- a/app/templates/subtitle_workflow.html +++ b/app/templates/subtitle_workflow.html @@ -38,6 +38,84 @@

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

{% endfor %} +
+
+
+

Professional Subtitle Editor

+

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

+

先在原片修正统一字幕,再按切片边界自动继承;已经人工编辑的切片不会被覆盖。

+
+
+ 正在载入字幕轨… + +
+
+ +
+ +
尚未载入 revision
+
+ + + + +
+
+ +
+
+ +
+
+
+
+ 服务端预计算波形 + 等待载入 +
+
+
+
+
+ +
+ + + + + + + + + + + + +
+ +
+ 质量检查会提示重叠、间隔、时长、行数与中文阅读速度,但不会自动改字。 +
+
+
+
+
+
+
@@ -164,6 +242,30 @@

字幕样式模板

启用阴影和描边,适合短视频平台观看 +
+ + +
+ +
+ + +

样式会保存到 SQLite,后续自动加字幕会使用这套模板。

@@ -304,4 +406,10 @@

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

{% endblock %} {% block extra_scripts %} +{% if subtitle_task_mode %} + + + + +{% endif %} {% endblock %} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a80917..dc8dbb6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,21 @@ # 系统架构 +## 2026-08-23:统一字幕架构 + +```text +转写 checkpoint / 旧 transcript.md + ↓ +source subtitle_track → immutable subtitle_revision → subtitle_cues(ms) + ↓ 按 output_clip 原片边界快照截取 +clip subtitle_track → immutable subtitle_revision → SRT/VTT/ASS/编辑器 +``` + +- `subtitle_data_service.py` 是字幕数据事实入口,负责轨、版本、cue、同步、导入导出、质量检查和服务端波形 peaks。 +- 原片轨只保存一条 active track;内容变化产生新 revision。切片轨记录来源 track/revision,未人工编辑时可同步,人工编辑后只进入 `pending_sync`。 +- revision 内容不可原地更新;审核只改变 revision 状态,渲染必须固定引用 revision id,避免编辑过程中改变已排队输出。 +- `pysubs2` 负责字幕格式与 ASS,不再手写固定分辨率 ASS。`wavesurfer.js` 仅消费服务端 peaks 与媒体流,不读取六小时完整音频到浏览器内存。 +- 当前同步 FFmpeg 烧录仅作为旧 API 兼容;持久化渲染队列、取消、重试、NVENC 回退与发送中心门禁在 PR 4 接入。 + ## 2026-08-23:长直播选片层 `long_live_talk` 使用独立的 `long_live_talk_analyzer`: diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 542c346..f878e93 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -1,5 +1,14 @@ # 数据库结构说明 +## 2026-08-23:字幕 revision 数据结构 + +- `output_clip` 新增 `source_start_ms / source_end_ms / source_duration_ms / source_fingerprint / snapshot_source`。新切片使用 `cut_commit`,旧切片第一次使用时可从候选边界生成 `legacy_inferred`;之后候选修改不会漂移已保存边界。 +- `subtitle_tracks`:任务级原片轨或切片轨,记录 `source_track_id / source_revision_id / active_revision_id / sync_status / has_manual_edits`。 +- `subtitle_revisions`:不可变内容版本,记录来源 `asr / markdown / source_sync / manual / import / ai_suggestion`、父版本、状态、cue 数和 checksum。 +- `subtitle_cues`:毫秒级 `start_ms / end_ms`、文字、置信度、手工说话人和 `source_cue_id`;按 revision 和时间查询。 +- `subtitle_jobs.revision_id` 固定渲染使用的版本;样式表新增描边宽度、阴影深度、安全区百分比与说话人样式 JSON。 +- 启动迁移保持幂等;已有数据库缺少上述结构时先通过 SQLite 在线 backup 创建 `subtitle-editor-rebuild` 迁移前快照,不删除旧字幕 job 或历史切片。 + ## 2026-08-23:长直播 AI 窗口 checkpoint 新增 `ai_analysis_windows`: diff --git a/docs/SUBTITLE_AND_PUBLISH_PLAN.md b/docs/SUBTITLE_AND_PUBLISH_PLAN.md index 8cbd3a2..82a4c0c 100644 --- a/docs/SUBTITLE_AND_PUBLISH_PLAN.md +++ b/docs/SUBTITLE_AND_PUBLISH_PLAN.md @@ -1,5 +1,11 @@ # 字幕与一键推送功能计划书 +## 2026-08-23 PR 3 实现状态 + +- 已完成统一原片/切片 track、不可变 revision、毫秒 cue、切片边界快照、人工编辑保护。 +- 已完成 pysubs2 SRT/VTT/ASS、动态 ASS 样式、本地 wavesurfer 波形、虚拟列表和专业编辑操作。 +- 尚未完成自动流水线审核暂停、AI 纠错建议、持久化批量渲染、NVENC 回退和发送中心审核门禁;这些仅在 PR 4 实现。 + ## 1. 最终目标 把当前“上传视频 → 转写 → AI 分析 → 片段审核 → 自动切片”的流程继续向后延伸,形成: diff --git a/docs/TASK_FLOW.md b/docs/TASK_FLOW.md index 9cc6727..1fe2b59 100644 --- a/docs/TASK_FLOW.md +++ b/docs/TASK_FLOW.md @@ -1,5 +1,13 @@ # 任务状态流转 +## 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 才会让自动流水线暂停审核并异步批量烧录。 + ## 2026-08-23:长直播高光流程 1. 逐句时间戳转写按约 5 分钟、60 秒重叠生成窗口。 diff --git a/docs/UI_REFERENCE.md b/docs/UI_REFERENCE.md index 4e7cc6a..7c7bf9d 100644 --- a/docs/UI_REFERENCE.md +++ b/docs/UI_REFERENCE.md @@ -1,5 +1,15 @@ # UI 参考说明 +## 2026-08-23 更新:专业字幕编辑器 + +- 字幕任务页顶部新增“原片主时间轴 · 切片继承编辑器”,轨下拉明确区分原片和每条切片,并显示人工版、待同步、revision 与审核状态。 +- 视频与字幕双向联动;视频上覆盖当前字幕,波形使用本地 wavesurfer Regions/Timeline,拖动蓝色区间直接修改毫秒起止。 +- 字幕列表固定高度并虚拟渲染,只创建可见行 DOM;适合数千 cue。每行提供勾选、跳转、开始/结束毫秒、文字、说话人和质量提醒。 +- 工具栏包含搜索替换、新增、拆分、合并、删除、批量位移、撤销、重做和立即保存;普通编辑 1.8 秒后自动保存为新 revision。 +- 质量卡将重叠显示为错误,时长、间隔、行长、行数和阅读速度显示为提醒;页面不会自动改字。 +- 样式侧栏增加描边宽度、阴影深度和安全区;实际 ASS 画布按视频 9:16、16:9 或 1:1 尺寸生成。 +- 小屏将视频与波形改为单列,字幕行横向保留关键编辑字段;不引入 React/Vue。 + ## 2026-08-23 更新:长直播分析完整性状态 - 长直播 AI 窗口失败时,任务详情的 AI 状态显示“分析不完整”和当前覆盖率,不把部分结果冒充为已完整分析。 diff --git a/docs/agent_tasks/2026-08-23-subtitle-editor-rebuild.md b/docs/agent_tasks/2026-08-23-subtitle-editor-rebuild.md new file mode 100644 index 0000000..ad971f2 --- /dev/null +++ b/docs/agent_tasks/2026-08-23-subtitle-editor-rebuild.md @@ -0,0 +1,65 @@ +# PR 3 执行任务:字幕数据层与专业编辑器重构 + +## 背景 + +现有字幕工作台从 `transcript.md` 最多读取 120 行,按整秒时间生成固定 1080×1920 ASS,并在 HTTP 请求内同步烧录。长直播需要以原片毫秒时间轴为事实来源,切片字幕必须继承且不能覆盖人工编辑。 + +## 目标 + +1. 建立 `subtitle_tracks / subtitle_revisions / subtitle_cues` 统一字幕模型。 +2. 保存 output clip 的不可变原片起止毫秒快照,完成原片→切片本地时间换算。 +3. 使用 `pysubs2==1.9.0` 实现 SRT/VTT/ASS 导入导出与 ASS 序列化。 +4. 本地固定 `wavesurfer.js@7.12.11`,使用 Regions/Timeline 和服务端 peaks 构建长视频编辑器。 +5. 提供文本、毫秒时间、说话人、增删、拆分、合并、批量位移、搜索替换、撤销重做、自动保存和质量告警。 + +## 允许修改范围 + +- 字幕数据库表、output_clip 快照和 subtitle_jobs revision 引用。 +- 新字幕服务、模型、API 路由、媒体 peaks 接口。 +- 字幕工作台模板、专用 JS/CSS 和固定版本 vendor 文件。 +- requirements、第三方许可证、测试和项目文档。 + +## 禁止修改范围 + +- 不实现自动说话人分离、翻译、卡拉 OK 或 AI 自动覆盖。 +- 不复制 GPL-3.0 VideoCaptioner 源码;仅借鉴流程。 +- PR 3 不接入自动流水线审核暂停和异步批量烧录,这属于 PR 4。 +- 不删除旧 `subtitle_jobs` 或旧带字幕成片;旧同步烧录入口暂时兼容。 +- 不读取或写入 secrets,不合并 PR,不改写 Git 历史。 + +## 已确定实现要求 + +- revision 内容创建后不可原地改写;每次人工保存创建子 revision 并切换 active。 +- 渲染 Job 增加 `revision_id`,后续只能引用固定 revision。 +- source track 来自结构化转写 checkpoint;无结构化结果时完整读取 Markdown,不再截断 120 行。 +- clip track 使用 `source_start_ms/source_end_ms` 快照截取 source cues 并换算本地毫秒。 +- 未人工编辑 clip 自动跟随 source active revision;已有人工 revision 只标记 `pending_sync`。 +- 质量规则只返回 warning/error,不自动修改文字。 +- 波形 peaks 服务端缓存并限制点数,浏览器不解码完整 6 小时音频。 +- 样式序列化使用实际视频宽高,并提供安全区与说话人样式。 + +## 验收标准 + +- SRT/VTT/ASS 往返保留毫秒时间,中文不乱码。 +- 超过 120 cues 不截断;重叠、时长、间隔、行长和阅读速度规则正确。 +- 原片→切片边界截取准确;人工 clip revision 不被 source 更新覆盖。 +- 数千 cues 范围查询分页;前端实现虚拟滚动及编辑操作。 +- 六小时 peaks 输出点数受控且有缓存。 +- 旧字幕任务、通用/综艺/长直播流程不回归。 + +## 测试命令 + +```powershell +.venv\Scripts\ruff.exe check app tests +.venv\Scripts\python.exe -m compileall app tests +node --check app/static/js/subtitle-editor.js +.venv\Scripts\python.exe -m pytest tests/test_subtitle_editor.py tests/test_split_services.py tests/test_versioning_rollback.py -q +.venv\Scripts\python.exe -m pytest tests/ -q +``` + +## 返回格式 + +- 数据模型、API 和编辑器能力摘要 +- 第三方版本与许可证 +- 专项/全量测试证据 +- 中文 commit、推送分支、堆叠 PR 链接 diff --git a/requirements.in b/requirements.in index c84c629..a50c48c 100644 --- a/requirements.in +++ b/requirements.in @@ -10,3 +10,4 @@ tzdata>=2026.1 playwright>=1.58,<1.63 aiofiles>=25.1,<26 faster-whisper>=1.2,<1.3 +pysubs2==1.9.0 diff --git a/requirements.txt b/requirements.txt index d44dc29..0b340de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ tzdata==2026.3 playwright==1.62.0 aiofiles==25.1.0 faster-whisper==1.2.1 +pysubs2==1.9.0 diff --git a/tests/test_subtitle_editor.py b/tests/test_subtitle_editor.py new file mode 100644 index 0000000..8468c9f --- /dev/null +++ b/tests/test_subtitle_editor.py @@ -0,0 +1,466 @@ +from __future__ import annotations + +from array import array +import hashlib +import json +from uuid import uuid4 + +import pysubs2 +import pytest +from fastapi.testclient import TestClient + +from app.db.database import get_connection, init_db +from app.main import app +from app.services.subtitle_data_service import ( + apply_revision_operations, + create_manual_revision, + ensure_clip_track, + ensure_source_track, + evaluate_subtitle_quality, + export_subtitle_text, + get_revision, + get_track, + get_waveform_peaks, + import_subtitle_text, + inherit_cues_for_clip, + serialize_revision_to_ass, +) +from app.services.video_cut_service import CutResult +from app.services.video_cut_workflow_service import _insert_output_clip_record +from app.services.subtitle_workflow_service import _write_ass_file + + +PREFIX = "test-subtitle-editor-" + + +@pytest.fixture(autouse=True) +def subtitle_editor_database(): + init_db() + _cleanup() + yield + _cleanup() + + +def _cleanup() -> None: + with get_connection() as connection: + 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 subtitle_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM output_clip WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM cut_runs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM clip_candidates 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(*, segments: list[dict] | None = None, with_clip: bool = True) -> tuple[str, str | None]: + task_id = f"{PREFIX}{uuid4().hex[:10]}" + now = "2026-08-23T12:00:00+00:00" + with get_connection() as connection: + connection.execute( + "INSERT INTO tasks (id, task_name, platform, status, created_at, updated_at) VALUES (?, ?, 'general', 'completed', ?, ?)", + (task_id, "字幕编辑器测试", now, now), + ) + if segments is not None: + _insert_transcription(connection, task_id, segments, now) + output_id = None + if with_clip: + output_id = f"out-{uuid4().hex[:10]}" + 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, 1000, 5000, 4000, + 'source-v1', 'cut_commit', ?, ?) + """, + (output_id, task_id, "C:/missing/clip.mp4", now, now), + ) + connection.commit() + return task_id, output_id + + +def _insert_transcription(connection, task_id: str, segments: list[dict], now: str) -> None: + run_id = f"run-{uuid4().hex[:10]}" + raw = json.dumps(segments, ensure_ascii=False, separators=(",", ":")) + checksum = hashlib.sha256(raw.encode("utf-8")).hexdigest() + 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, + attempt_count, result_json, result_checksum, created_at, updated_at + ) VALUES (?, ?, ?, 1, 0, 120000, 'completed', 1, ?, ?, ?, ?) + """, + (f"chunk-{uuid4().hex[:10]}", run_id, task_id, raw, checksum, now, now), + ) + + +def _segments(count: int = 4) -> list[dict]: + return [ + { + "start_seconds": index * 1.5 + 0.123, + "end_seconds": index * 1.5 + 1.345, + "text": f"第{index + 1}条中文字幕", + "confidence": 0.91, + "words": [ + { + "start_ms": round((index * 1.5 + 0.123) * 1000), + "end_ms": round((index * 1.5 + 0.5) * 1000), + "text": "第", + "confidence": 0.9, + } + ], + } + for index in range(count) + ] + + +def test_schema_migration_is_idempotent_and_contains_revision_tables(): + init_db() + init_db() + with get_connection() as connection: + names = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + output_columns = {row[1] for row in connection.execute("PRAGMA table_info(output_clip)")} + assert {"subtitle_tracks", "subtitle_revisions", "subtitle_cues"} <= names + assert {"source_start_ms", "source_end_ms", "source_duration_ms", "source_fingerprint"} <= output_columns + + +def test_source_track_uses_structured_checkpoint_with_millisecond_precision(): + task_id, _ = _create_task(segments=_segments(), with_clip=False) + track = ensure_source_track(task_id) + revision = get_revision(track["active_revision_id"], include_cues=True) + assert revision["cue_count"] == 4 + assert revision["cues"][0]["start_ms"] == 123 + assert revision["cues"][0]["end_ms"] == 1345 + assert revision["cues"][0]["confidence"] == pytest.approx(0.91) + + +def test_more_than_120_cues_are_not_truncated(): + task_id, _ = _create_task(segments=_segments(150), with_clip=False) + track = ensure_source_track(task_id) + revision = get_revision(track["active_revision_id"], include_cues=True) + assert revision["cue_count"] == 150 + assert len(revision["cues"]) == 150 + assert revision["cues"][-1]["text"] == "第150条中文字幕" + + +def test_source_to_clip_boundary_conversion_is_exact(): + source = [ + {"id": "a", "start_ms": 0, "end_ms": 1500, "text": "开头"}, + {"id": "b", "start_ms": 1500, "end_ms": 3000, "text": "中间"}, + {"id": "c", "start_ms": 4500, "end_ms": 6000, "text": "结尾"}, + {"id": "d", "start_ms": 7000, "end_ms": 8000, "text": "范围外"}, + ] + inherited = inherit_cues_for_clip(source, 1000, 5000) + assert [(cue["start_ms"], cue["end_ms"], cue["text"]) for cue in inherited] == [ + (0, 500, "开头"), + (500, 2000, "中间"), + (3500, 4000, "结尾"), + ] + assert [cue["source_cue_id"] for cue in inherited] == ["a", "b", "c"] + + +def test_clip_track_inherits_snapshot_and_manual_revision_is_not_overwritten(): + task_id, output_id = _create_task(segments=_segments(), with_clip=True) + clip_track = ensure_clip_track(task_id, output_id) + active = get_revision(clip_track["active_revision_id"], include_cues=True) + edited = [{**cue, "text": f"人工:{cue['text']}"} for cue in active["cues"]] + manual = create_manual_revision( + clip_track["id"], + base_revision_id=active["id"], + cues=edited, + note="人工精修", + ) + + with get_connection() as connection: + chunk = connection.execute( + "SELECT * FROM transcription_chunks WHERE task_id = ?", (task_id,) + ).fetchone() + changed = _segments() + changed[1]["text"] = "原片字幕已经变化" + raw = json.dumps(changed, ensure_ascii=False, separators=(",", ":")) + connection.execute( + "UPDATE transcription_chunks SET result_json = ?, result_checksum = ? WHERE id = ?", + (raw, hashlib.sha256(raw.encode("utf-8")).hexdigest(), chunk["id"]), + ) + connection.commit() + + ensure_source_track(task_id, force=True) + protected = get_track(clip_track["id"]) + assert protected["active_revision_id"] == manual["id"] + assert protected["sync_status"] == "pending_sync" + assert protected["has_manual_edits"] is True + + +def test_source_manual_revision_syncs_unedited_clip_but_only_flags_edited_clip(): + task_id, output_id = _create_task(segments=_segments(), with_clip=True) + source_track = ensure_source_track(task_id) + clip_track = ensure_clip_track(task_id, output_id) + source_revision = get_revision(source_track["active_revision_id"], include_cues=True) + source_cues = [{**cue, "text": f"原片修改:{cue['text']}"} for cue in source_revision["cues"]] + new_source = create_manual_revision( + source_track["id"], + base_revision_id=source_revision["id"], + cues=source_cues, + ) + followed = get_track(clip_track["id"]) + assert followed["source_revision_id"] == new_source["id"] + assert followed["sync_status"] == "up_to_date" + + clip_revision = get_revision(followed["active_revision_id"], include_cues=True) + manual_clip = create_manual_revision( + clip_track["id"], + base_revision_id=clip_revision["id"], + cues=[{**cue, "text": f"切片精修:{cue['text']}"} for cue in clip_revision["cues"]], + ) + unchanged_source = ensure_clip_track(task_id, output_id) + assert unchanged_source["active_revision_id"] == manual_clip["id"] + assert unchanged_source["sync_status"] == "manual" + source_after = get_revision(new_source["id"], include_cues=True) + create_manual_revision( + source_track["id"], + base_revision_id=new_source["id"], + cues=[{**cue, "text": f"再次修改:{cue['text']}"} for cue in source_after["cues"]], + ) + protected = get_track(clip_track["id"]) + assert protected["active_revision_id"] == manual_clip["id"] + assert protected["sync_status"] == "pending_sync" + + +def test_multiple_operations_keep_cue_ids_until_new_revision_is_committed(): + task_id, _ = _create_task(segments=_segments(), with_clip=False) + track = ensure_source_track(task_id) + revision = get_revision(track["active_revision_id"], include_cues=True) + cue_id = revision["cues"][0]["id"] + updated = apply_revision_operations( + track["id"], + base_revision_id=revision["id"], + operations=[ + {"type": "update", "cue_id": cue_id, "text": "连续操作"}, + {"type": "shift", "cue_ids": [cue_id], "delta_ms": 250}, + ], + ) + assert updated["cues"][0]["text"] == "连续操作" + assert updated["cues"][0]["start_ms"] == 373 + + +@pytest.mark.parametrize("format_name", ["srt", "vtt", "ass"]) +def test_pysubs2_round_trip_preserves_chinese_and_milliseconds(format_name: str): + task_id, _ = _create_task(segments=_segments(), with_clip=False) + track = ensure_source_track(task_id) + content, _media_type, _filename = export_subtitle_text(track["id"], format_name=format_name) + parsed = pysubs2.SSAFile.from_string(content, format_=format_name) + # ASS 规范使用厘秒;SRT/VTT 保留 1ms,ASS 最多产生 5ms 的量化误差。 + tolerance_ms = 5 if format_name == "ass" else 0 + assert abs(parsed.events[0].start - 123) <= tolerance_ms + assert abs(parsed.events[0].end - 1345) <= tolerance_ms + assert "中文字幕" in parsed.events[0].plaintext + + imported = import_subtitle_text(track["id"], content=content, format_name=format_name) + assert abs(imported["cues"][0]["start_ms"] - 123) <= tolerance_ms + assert abs(imported["cues"][0]["end_ms"] - 1345) <= tolerance_ms + + +@pytest.mark.parametrize("dimensions", [(1080, 1920), (1920, 1080), (1080, 1080)]) +def test_ass_resolution_follows_real_media_dimensions(monkeypatch, dimensions): + task_id, output_id = _create_task(segments=_segments(), with_clip=True) + track = ensure_clip_track(task_id, output_id) + monkeypatch.setattr("app.services.subtitle_data_service._probe_media_dimensions", lambda _path: dimensions) + ass = serialize_revision_to_ass(track["id"], track["active_revision_id"]) + document = pysubs2.SSAFile.from_string(ass, format_="ass") + assert int(document.info["PlayResX"]) == dimensions[0] + assert int(document.info["PlayResY"]) == dimensions[1] + assert document.styles["Default"].marginv == round(dimensions[1] * 0.05) + + +def test_ass_applies_default_host_and_guest_speaker_styles(monkeypatch): + task_id, output_id = _create_task(segments=_segments(), with_clip=True) + track = ensure_clip_track(task_id, output_id) + revision = get_revision(track["active_revision_id"], include_cues=True) + cues = [] + for index, cue in enumerate(revision["cues"]): + cues.append({**cue, "speaker": "主播" if index == 0 else "嘉宾"}) + manual = create_manual_revision( + track["id"], + base_revision_id=revision["id"], + cues=cues, + ) + monkeypatch.setattr( + "app.services.subtitle_data_service._probe_media_dimensions", + lambda _path: (1920, 1080), + ) + document = pysubs2.SSAFile.from_string( + serialize_revision_to_ass(track["id"], manual["id"]), + format_="ass", + ) + assert document.events[0].style != "Default" + assert document.events[1].style != "Default" + assert document.styles[document.events[0].style].primarycolor == pysubs2.Color(255, 255, 255) + assert document.styles[document.events[1].style].primarycolor == pysubs2.Color(255, 214, 10) + + +def test_ass_render_uses_explicit_immutable_revision_not_latest_active(monkeypatch, tmp_path): + task_id, output_id = _create_task(segments=_segments(), with_clip=True) + track = ensure_clip_track(task_id, output_id) + original = get_revision(track["active_revision_id"], include_cues=True) + create_manual_revision( + track["id"], + base_revision_id=original["id"], + cues=[{**cue, "text": "最新人工版本"} for cue in original["cues"]], + ) + monkeypatch.setattr( + "app.services.subtitle_workflow_service.get_artifact_paths", + lambda _task_id: {"subtitled_dir": tmp_path}, + ) + path = _write_ass_file( + task_id, + {"id": output_id, "output_file_name": "fixed.mp4"}, + {}, + revision_id=original["id"], + ) + document = pysubs2.load(str(path), encoding="utf-8") + assert "最新人工版本" not in document.events[0].plaintext + assert "中文字幕" in document.events[0].plaintext + + +def test_quality_rules_only_report_and_do_not_change_text(): + cues = [ + {"id": "a", "start_ms": 0, "end_ms": 500, "text": "这是一行非常非常非常非常非常长的中文字幕"}, + {"id": "b", "start_ms": 400, "end_ms": 9000, "text": "发生重叠\n第二行\n第三行"}, + ] + original = json.loads(json.dumps(cues, ensure_ascii=False)) + quality = evaluate_subtitle_quality(cues) + codes = {issue["code"] for issue in quality["issues"]} + assert {"too_short", "line_too_long", "reading_speed", "overlap", "too_long", "too_many_lines"} <= codes + assert quality["error_count"] == 1 + assert cues == original + + +def test_cut_commit_saves_immutable_source_bounds(): + task_id, _ = _create_task(segments=None, with_clip=False) + candidate_id = f"candidate-{uuid4().hex[:8]}" + run_id = f"cut-{uuid4().hex[:8]}" + now = "2026-08-23T12:00:00+00:00" + with get_connection() as connection: + connection.execute( + """ + INSERT INTO clip_candidates ( + id, task_id, title, start_time, end_time, duration_seconds, + created_at, updated_at + ) VALUES (?, ?, '候选', '00:00:01.250', '00:00:05.750', 5, ?, ?) + """, + (candidate_id, task_id, now, now), + ) + connection.execute( + "INSERT INTO cut_runs (id, task_id, run_number, status, is_active, created_at, updated_at) VALUES (?, ?, 1, 'processing', 0, ?, ?)", + (run_id, task_id, now, now), + ) + connection.commit() + _insert_output_clip_record( + task_id, + run_id, + CutResult(candidate_id, "C:/missing/output.mp4", "output.mp4", "completed"), + source_fingerprint="fingerprint-v1", + ) + with get_connection() as connection: + connection.execute( + "UPDATE clip_candidates SET start_time = '00:01:00', end_time = '00:02:00' WHERE id = ?", + (candidate_id,), + ) + row = connection.execute( + "SELECT * FROM output_clip WHERE task_id = ? AND clip_candidate_id = ?", + (task_id, candidate_id), + ).fetchone() + assert row["source_start_ms"] == 1250 + assert row["source_end_ms"] == 5750 + assert row["source_duration_ms"] == 4500 + assert row["source_fingerprint"] == "fingerprint-v1" + assert row["snapshot_source"] == "cut_commit" + + +def test_cue_api_supports_time_range_and_pagination(): + task_id, _ = _create_task(segments=_segments(20), with_clip=False) + track = ensure_source_track(task_id) + response = TestClient(app).get( + f"/api/subtitles/tracks/{track['id']}/cues", + params={"start_ms": 3000, "end_ms": 9000, "offset": 1, "limit": 2}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["total"] >= 4 + assert len(payload["cues"]) == 2 + assert all(cue["end_ms"] > 3000 and cue["start_ms"] < 9000 for cue in payload["cues"]) + + +def test_subtitle_page_loads_local_editor_and_vendored_wavesurfer(): + task_id, _ = _create_task(segments=_segments(), with_clip=True) + response = TestClient(app).get(f"/subtitles/{task_id}") + assert response.status_code == 200 + assert 'id="subtitle-editor"' in response.text + assert "vendor/wavesurfer/wavesurfer.min.js" in response.text + assert "vendor/wavesurfer/regions.min.js" in response.text + assert "js/subtitle-editor.js" in response.text + + +def test_waveform_peaks_are_precomputed_at_low_sample_rate_and_cached(monkeypatch, tmp_path): + media_path = tmp_path / "source.mp4" + media_path.write_bytes(b"fake-video-source") + task_id, _ = _create_task(segments=_segments(), with_clip=False) + with get_connection() as connection: + connection.execute( + "UPDATE tasks SET original_video_path = ? WHERE id = ?", + (str(media_path), task_id), + ) + connection.commit() + track = ensure_source_track(task_id) + transcript_path = tmp_path / "artifacts" / "transcript.md" + calls = [] + + class Result: + returncode = 0 + stderr = b"" + stdout = array("h", [0, 1000, -2000, 32000, -12000] * 400).tobytes() + + def fake_run(command, **_kwargs): + calls.append(command) + return Result() + + monkeypatch.setattr("app.services.subtitle_data_service.shutil.which", lambda _name: "ffmpeg") + monkeypatch.setattr("app.services.subtitle_data_service.subprocess.run", fake_run) + monkeypatch.setattr( + "app.services.subtitle_data_service.get_artifact_paths", + lambda _task_id: {"transcript_path": transcript_path}, + ) + first = get_waveform_peaks(track["id"], max_points=1000) + second = get_waveform_peaks(track["id"], max_points=1000) + assert calls and calls[0][calls[0].index("-ar") + 1] == "100" + assert first["point_count"] <= 1000 + assert first["cached"] is False + assert second["cached"] is True + assert len(calls) == 1 diff --git a/third_party_licenses/pysubs2-LICENSE.txt b/third_party_licenses/pysubs2-LICENSE.txt new file mode 100644 index 0000000..dc62e89 --- /dev/null +++ b/third_party_licenses/pysubs2-LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2014-2026 Tomas Karabela + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE.