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\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 %}
+
+
@@ -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.