Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions DEVELOPMENT_LOG.md
Original file line number Diff line number Diff line change
@@ -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 秒的语言高光窗口。
Expand Down
12 changes: 10 additions & 2 deletions NEXT_STEPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 主线同步后检查

Expand Down
27 changes: 27 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 源码没有复制、改写或打包进入本项目。
149 changes: 149 additions & 0 deletions app/db/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand All @@ -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 '',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}
Expand All @@ -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)",
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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 ''",
Expand All @@ -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",
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading