@speclip/pi-subtitles 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Speclip contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # @speclip/pi-subtitles
2
+
3
+ 给 [Pi](https://pi.dev) 使用的字幕决策插件包。它把 ASR 词级时间戳映射到完成 A-roll 剪辑后的成片时间轴,由 Agent 审阅断句、标点和换行,再导出可追溯的 UTF-8 SRT / ASS 字幕轨道。
4
+
5
+ 它不直接烧录视频,也不把任意 FFmpeg 参数塞进 Agent 工具。字幕判断与视频渲染保持分离:本包负责“字幕写什么、何时出现、如何分行”,支持字幕轨道的渲染器负责最终画面合成与视觉验收。
6
+
7
+ ## 能力边界
8
+
9
+ - 输入:`pi-speech` 兼容的词级 JSON 转录,也兼容常见 `{ word, start, end }` 秒级结构。
10
+ - A-roll 映射:按保留源片段压缩时间轴;字幕不会跨越剪辑点。
11
+ - 自动分段:结合标点、停顿、字数、时长和阅读速度生成初稿。
12
+ - Agent 审阅:通过不可变 revision 修正 ASR 文本、标点、断句和换行。
13
+ - 导出:标准 SRT;可选带样式的 ASS;两者都不覆盖已有文件。
14
+ - 溯源:绑定原转录哈希、精确 revision、输出哈希和持久化导出回执。
15
+
16
+ B-roll 只覆盖画面而保留 A-roll 主音频时,不会改变字幕时间。若后续改变了语速、主音频或 A-roll 片段顺序,应新建字幕项目。
17
+
18
+ ## 环境要求
19
+
20
+ - Node.js 22.19+
21
+ - Pi 0.84.1–0.84.x
22
+ - 一份含词级时间戳的 JSON 转录
23
+
24
+ ## 安装与验证
25
+
26
+ ```bash
27
+ npm install
28
+ npm run check
29
+ pi install ./
30
+ ```
31
+
32
+ 也可以安装发布包:
33
+
34
+ ```bash
35
+ pi install npm:@speclip/pi-subtitles
36
+ ```
37
+
38
+ ## 工具工作流
39
+
40
+ ### 1. 创建字幕项目
41
+
42
+ 无 A-roll 剪辑时,只传源时长:
43
+
44
+ ```js
45
+ subtitles_create {
46
+ projectId: "launch-captions",
47
+ transcriptPath: "transcripts/launch.json",
48
+ sourceDurationMs: 93224
49
+ }
50
+ ```
51
+
52
+ 有 A-roll 剪辑时,必须传最终保留的源区间:
53
+
54
+ ```js
55
+ subtitles_create {
56
+ projectId: "launch-captions",
57
+ transcriptPath: "transcripts/launch.json",
58
+ sourceDurationMs: 93224,
59
+ timelineSegments: [
60
+ { id: "a-001", sourceStartMs: 0, sourceEndMs: 12400 },
61
+ { id: "a-002", sourceStartMs: 13100, sourceEndMs: 28700 },
62
+ { id: "a-003", sourceStartMs: 29400, sourceEndMs: 93224 }
63
+ ]
64
+ }
65
+ ```
66
+
67
+ 计算关系为:
68
+
69
+ ```text
70
+ 成片时间 = 前面所有保留片段的累计时长 + 词源时间 - 当前片段源起点
71
+ ```
72
+
73
+ 若一个词跨过剪辑边界,工具会拒绝创建,要求回到 A-roll 调整到词间安全边界。
74
+
75
+ ### 2. 分页审阅全部字幕
76
+
77
+ ```js
78
+ subtitles_get {
79
+ projectId: "launch-captions",
80
+ offset: 0,
81
+ limit: 100
82
+ }
83
+ ```
84
+
85
+ 自动结果只是初稿。Agent 应结合实际音频检查文本、术语、标点、语义分组、阅读速度和两行布局,并继续翻页直到 `hasMore: false`。
86
+
87
+ 默认策略适合中文口播:每行最多 16 个可见字符、最多两行;目标 12–20 字、1.5–4.5 秒、约 9 字/秒。它们用于找更自然的解,不要求每条字幕机械一致。极端时间戳冲突只会局部放宽,不会让整段字幕坍缩为一条或退化成逐字闪烁。
88
+
89
+ ### 3. 写入 Agent 审阅版本
90
+
91
+ ```js
92
+ subtitles_apply {
93
+ projectId: "launch-captions",
94
+ expectedRevision: 1,
95
+ changeReason: "核对音频后修正产品名和语义标点",
96
+ cues: [
97
+ { id: "cue-001", beginMs: 320, endMs: 2840, text: "这是修正后的\n第一条字幕。" }
98
+ ]
99
+ }
100
+ ```
101
+
102
+ `cues` 必须是完整列表,不是某一页。时间必须递增、不重叠,且不能超过成片时长。`expectedRevision` 防止并发审阅互相覆盖;每次成功都会产生新的不可变快照。
103
+
104
+ ### 4. 导出标准轨道
105
+
106
+ ```js
107
+ subtitles_export {
108
+ projectId: "launch-captions",
109
+ revision: 2,
110
+ srtPath: "deliverables/launch.zh-CN.srt",
111
+ assPath: "deliverables/launch.zh-CN.ass",
112
+ assStyle: {
113
+ playResX: 3840,
114
+ playResY: 2160,
115
+ fontName: "Noto Sans CJK SC",
116
+ fontSize: 96,
117
+ outline: 5,
118
+ marginV: 180
119
+ }
120
+ }
121
+ ```
122
+
123
+ 工具会返回 SRT / ASS 文件引用、SHA-256 和持久化导出回执。默认 ASS 字体是 `Noto Sans CJK SC`;实际渲染环境需要自行安装或嵌入该字体。
124
+
125
+ 本包的完成状态是“字幕轨道已生成并校验”,不是“字幕已经烧进视频”。最后应把轨道交给支持字幕的渲染器,并检查实际成片中的同步、遮挡、安全区、字体回退和漏字。
126
+
127
+ ## 工作区数据
128
+
129
+ 项目状态保存在:
130
+
131
+ ```text
132
+ .subtitles/projects/<projectId>/
133
+ ├── project.json
134
+ ├── snapshots/<revision>.json
135
+ └── exports/<exportId>.json
136
+ ```
137
+
138
+ 源转录与导出文件都不会被覆盖。源转录字节变化后,旧项目会拒绝继续导出,避免把新内容误配到旧时间轴。
139
+
140
+ ## 开发
141
+
142
+ ```bash
143
+ npm ci
144
+ npm run check
145
+ npm pack --dry-run
146
+ ```
147
+
148
+ GitHub Release 标签必须与 `package.json` 完全一致,例如 `v0.1.0`。发布工作流会在标签快照上重新执行检查并通过 npm OIDC 发布。
149
+
150
+ ## License
151
+
152
+ MIT
@@ -0,0 +1,148 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import {
4
+ applySubtitleRevision,
5
+ createSubtitleProject,
6
+ exportSubtitleProject,
7
+ getSubtitleProject,
8
+ } from "../../src/project.ts";
9
+
10
+ function result(details: unknown) {
11
+ return {
12
+ content: [{ type: "text" as const, text: JSON.stringify(details, null, 2) }],
13
+ details,
14
+ };
15
+ }
16
+
17
+ const timelineSegment = Type.Object({
18
+ id: Type.String({ minLength: 1, maxLength: 128 }),
19
+ sourceStartMs: Type.Number({ minimum: 0 }),
20
+ sourceEndMs: Type.Number({ exclusiveMinimum: 0 }),
21
+ }, { additionalProperties: false });
22
+
23
+ const subtitleCue = Type.Object({
24
+ id: Type.String({ minLength: 1, maxLength: 128 }),
25
+ beginMs: Type.Number({ minimum: 0 }),
26
+ endMs: Type.Number({ exclusiveMinimum: 0 }),
27
+ text: Type.String({ minLength: 1, maxLength: 2_000 }),
28
+ }, { additionalProperties: false });
29
+
30
+ const policy = Type.Object({
31
+ maxLineChars: Type.Optional(Type.Integer({ minimum: 4, maximum: 80 })),
32
+ maxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 4 })),
33
+ targetMinChars: Type.Optional(Type.Integer({ minimum: 1, maximum: 160 })),
34
+ targetMaxChars: Type.Optional(Type.Integer({ minimum: 1, maximum: 320 })),
35
+ targetMinDurationMs: Type.Optional(Type.Integer({ minimum: 100, maximum: 20_000 })),
36
+ targetMaxDurationMs: Type.Optional(Type.Integer({ minimum: 100, maximum: 30_000 })),
37
+ minDurationMs: Type.Optional(Type.Integer({ minimum: 100, maximum: 20_000 })),
38
+ maxDurationMs: Type.Optional(Type.Integer({ minimum: 100, maximum: 30_000 })),
39
+ targetCps: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })),
40
+ relaxedMaxCps: Type.Optional(Type.Number({ minimum: 1, maximum: 150 })),
41
+ weakPauseMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 10_000 })),
42
+ strongPauseMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 20_000 })),
43
+ }, { additionalProperties: false });
44
+
45
+ const assStyle = Type.Object({
46
+ playResX: Type.Optional(Type.Integer({ minimum: 1, maximum: 16_384 })),
47
+ playResY: Type.Optional(Type.Integer({ minimum: 1, maximum: 16_384 })),
48
+ fontName: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
49
+ fontSize: Type.Optional(Type.Number({ minimum: 1, maximum: 500 })),
50
+ primaryColour: Type.Optional(Type.String({ pattern: "^&H[0-9A-Fa-f]{8}$" })),
51
+ outlineColour: Type.Optional(Type.String({ pattern: "^&H[0-9A-Fa-f]{8}$" })),
52
+ outline: Type.Optional(Type.Number({ minimum: 0, maximum: 20 })),
53
+ shadow: Type.Optional(Type.Number({ minimum: 0, maximum: 20 })),
54
+ alignment: Type.Optional(Type.Integer({ minimum: 1, maximum: 9 })),
55
+ marginV: Type.Optional(Type.Integer({ minimum: 0, maximum: 4_000 })),
56
+ }, { additionalProperties: false });
57
+
58
+ export default function subtitles(pi: ExtensionAPI): void {
59
+ pi.registerTool({
60
+ name: "subtitles_create",
61
+ label: "Create subtitle project",
62
+ description: "Create an immutable workspace subtitle project from a word-timestamp JSON transcript. Optionally remaps retained A-roll source segments onto the final edited timeline, then generates readable cue groups without rendering video.",
63
+ parameters: Type.Object({
64
+ projectId: Type.String({ minLength: 1, maxLength: 128, pattern: "^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$" }),
65
+ transcriptPath: Type.String({ minLength: 1, description: "Existing workspace-relative word-timestamp JSON from pi-speech or a compatible ASR." }),
66
+ sourceDurationMs: Type.Number({ exclusiveMinimum: 0 }),
67
+ timelineSegments: Type.Optional(Type.Array(timelineSegment, {
68
+ minItems: 1,
69
+ maxItems: 1_000,
70
+ description: "Ordered retained A-roll source ranges. Omit only when the final timeline is identical to the source.",
71
+ })),
72
+ policy: Type.Optional(policy),
73
+ }, { additionalProperties: false }),
74
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
75
+ signal?.throwIfAborted();
76
+ onUpdate?.({ content: [{ type: "text", text: "Mapping word timestamps onto the final timeline" }], details: { progress: 0.2 } });
77
+ const created = await createSubtitleProject(ctx.cwd, params);
78
+ signal?.throwIfAborted();
79
+ onUpdate?.({ content: [{ type: "text", text: "Subtitle project created" }], details: { progress: 1 } });
80
+ return result(created);
81
+ },
82
+ });
83
+
84
+ pi.registerTool({
85
+ name: "subtitles_get",
86
+ label: "Get subtitle project",
87
+ description: "Read one immutable subtitle revision and a bounded page of cues for Agent review.",
88
+ parameters: Type.Object({
89
+ projectId: Type.String({ minLength: 1, maxLength: 128 }),
90
+ revision: Type.Optional(Type.Integer({ minimum: 1 })),
91
+ offset: Type.Optional(Type.Integer({ minimum: 0, default: 0 })),
92
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200, default: 100 })),
93
+ }, { additionalProperties: false }),
94
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
95
+ signal?.throwIfAborted();
96
+ const loaded = await getSubtitleProject(ctx.cwd, params.projectId, params.revision);
97
+ const offset = params.offset ?? 0;
98
+ const limit = params.limit ?? 100;
99
+ return result({
100
+ project: loaded.project,
101
+ revision: loaded.snapshot.revision,
102
+ snapshotSha256: loaded.snapshot.snapshotSha256,
103
+ total: loaded.snapshot.cues.length,
104
+ offset,
105
+ limit,
106
+ hasMore: offset + limit < loaded.snapshot.cues.length,
107
+ cues: loaded.snapshot.cues.slice(offset, offset + limit),
108
+ });
109
+ },
110
+ });
111
+
112
+ pi.registerTool({
113
+ name: "subtitles_apply",
114
+ label: "Apply subtitle review",
115
+ description: "Create a new immutable subtitle revision from a complete Agent-reviewed cue list. Uses optimistic locking and requires a human-readable change reason.",
116
+ parameters: Type.Object({
117
+ projectId: Type.String({ minLength: 1, maxLength: 128 }),
118
+ expectedRevision: Type.Integer({ minimum: 1 }),
119
+ cues: Type.Array(subtitleCue, { minItems: 1, maxItems: 10_000 }),
120
+ changeReason: Type.String({ minLength: 1, maxLength: 2_000 }),
121
+ }, { additionalProperties: false }),
122
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
123
+ signal?.throwIfAborted();
124
+ return result(await applySubtitleRevision(ctx.cwd, params));
125
+ },
126
+ });
127
+
128
+ pi.registerTool({
129
+ name: "subtitles_export",
130
+ label: "Export subtitle tracks",
131
+ description: "Export one exact reviewed revision as a new UTF-8 SRT file and optional styled ASS file. Never overwrites outputs and returns a persisted provenance receipt; video burn-in remains an explicit renderer handoff.",
132
+ parameters: Type.Object({
133
+ projectId: Type.String({ minLength: 1, maxLength: 128 }),
134
+ revision: Type.Integer({ minimum: 1 }),
135
+ srtPath: Type.String({ minLength: 5, pattern: "\\.srt$" }),
136
+ assPath: Type.Optional(Type.String({ minLength: 5, pattern: "\\.ass$" })),
137
+ assStyle: Type.Optional(assStyle),
138
+ }, { additionalProperties: false }),
139
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
140
+ signal?.throwIfAborted();
141
+ onUpdate?.({ content: [{ type: "text", text: "Exporting standard subtitle tracks" }], details: { progress: 0.2 } });
142
+ const exported = await exportSubtitleProject(ctx.cwd, params);
143
+ signal?.throwIfAborted();
144
+ onUpdate?.({ content: [{ type: "text", text: "Subtitle tracks exported" }], details: { progress: 1 } });
145
+ return result(exported);
146
+ },
147
+ });
148
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@speclip/pi-subtitles",
3
+ "version": "0.1.0",
4
+ "description": "Agent-reviewed subtitle timing, layout, and standard track export for Pi",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/linyqh/pi-subtitles.git"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "registry": "https://registry.npmjs.org"
14
+ },
15
+ "engines": {
16
+ "node": ">=22.19"
17
+ },
18
+ "os": [
19
+ "darwin",
20
+ "linux"
21
+ ],
22
+ "files": [
23
+ "README.md",
24
+ "LICENSE",
25
+ "extensions",
26
+ "prompts",
27
+ "skills",
28
+ "src"
29
+ ],
30
+ "scripts": {
31
+ "typecheck": "tsc --noEmit",
32
+ "test": "node --experimental-strip-types --test tests/*.test.ts",
33
+ "check": "npm run typecheck && npm test"
34
+ },
35
+ "keywords": [
36
+ "pi-package",
37
+ "subtitles",
38
+ "captions",
39
+ "srt",
40
+ "ass",
41
+ "video-editing"
42
+ ],
43
+ "pi": {
44
+ "extensions": [
45
+ "./extensions/subtitles/index.ts"
46
+ ],
47
+ "skills": [
48
+ "./skills"
49
+ ],
50
+ "prompts": [
51
+ "./prompts"
52
+ ]
53
+ },
54
+ "peerDependencies": {
55
+ "@earendil-works/pi-coding-agent": ">=0.84.1 <0.85.0",
56
+ "typebox": "^1.3.7"
57
+ },
58
+ "devDependencies": {
59
+ "@earendil-works/pi-coding-agent": "0.84.2",
60
+ "@types/node": "22.20.1",
61
+ "typebox": "1.3.7",
62
+ "typescript": "7.0.2"
63
+ }
64
+ }
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: Add reviewed subtitles to an edited talking-head video
3
+ ---
4
+
5
+ Use the `add-subtitles` skill to map a word-level transcript onto the final A-roll timeline, inspect every generated cue page, preserve spoken meaning, apply only evidence-backed text or grouping corrections, and export an exact SRT plus optional ASS track for this request: $@
6
+
7
+ Finish A-roll first. Do not time subtitles from B-roll assets, submit a partial cue page as a complete revision, overwrite existing tracks, or claim that the video contains subtitles until a renderer has applied and visually reviewed the exported track.
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: add-subtitles
3
+ description: Build and review subtitles from word-level ASR timestamps after A-roll editing, then export exact SRT and optional styled ASS tracks. Use when the user asks to add captions, subtitles, SRT, ASS, or Chinese talking-head text to an edited video.
4
+ ---
5
+
6
+ # Add subtitles
7
+
8
+ Use `pi-speech` for word timestamps and this package for final-timeline mapping, readable cue decisions, Agent review, and standard subtitle track export.
9
+
10
+ 1. Finish the A-roll edit before creating subtitles. Obtain the exact source duration and the ordered retained A-roll source ranges. B-roll overlays do not change subtitle timing while the primary A-roll audio remains unchanged.
11
+ 2. Use a `pi-speech`-compatible JSON transcript with word-level `beginMs` and `endMs`. Do not derive cue timing from sentence text, a summary, or B-roll asset timestamps.
12
+ 3. Call `subtitles_create` once. Pass `timelineSegments` when A-roll has cuts; omit it only when the final timeline is identical to the source. Each retained range must end on a word-safe edit boundary. The tool rejects a word that straddles a cut.
13
+ 4. Treat the generated cues as a proposal. Read every page with `subtitles_get`; do not review only the first page. Check the words against the spoken audio, then check punctuation, semantic grouping, reading speed, line breaks, and whether a cue crosses an editorial thought boundary.
14
+ 5. Keep speech meaning intact. Correct ASR spelling or punctuation only with audio evidence. Do not delete content merely to make a line shorter. Preserve intentional fillers, emphasis, terminology, numbers, names, and sentence-ending tone.
15
+ 6. Prefer one complete idea per cue, at most two balanced lines, and natural boundaries at strong punctuation or acoustic pauses. Avoid one-character orphan cues and rapid flashes. Do not force every cue to an identical duration; subtitle timing follows the actual delivery rhythm.
16
+ 7. If edits are needed, gather the complete cue list across all pages and call `subtitles_apply` with the current `expectedRevision` and a concrete `changeReason`. Never submit one paginated slice as if it were the full revision. If the revision changed, re-read and reconcile instead of overwriting another review.
17
+ 8. Call `subtitles_export` with the exact reviewed revision. SRT is the portable timing/text track. Request ASS only when positioning or typography is needed, and choose a font that exists in the render environment. The default ASS style names `Noto Sans CJK SC`; embedding or installing that font is the renderer's responsibility.
18
+ 9. Keep rendering explicit. This package does not burn subtitles into video and does not invent FFmpeg arguments. Hand the returned SRT or ASS artifact and provenance receipt to a renderer that supports subtitle tracks, then visually review the actual final video for safe-area placement, occlusion, glyph fallback, sync, and missing text.
19
+
20
+ Never overwrite a transcript, subtitle track, or render. If the transcript hash changes, create a new subtitle project so timing decisions stay reproducible.
21
+
22
+ Read [timing and layout](references/timing-and-layout.md) when reviewing dense, fast, bilingual, or unusually sparse speech.
@@ -0,0 +1,32 @@
1
+ # Timing and layout review
2
+
3
+ ## Timeline truth
4
+
5
+ For a retained A-roll source range, final cue time is:
6
+
7
+ ```text
8
+ final_time = accumulated_retained_duration + source_word_time - range_source_start
9
+ ```
10
+
11
+ A cut is also a subtitle boundary. Even when the two retained words become adjacent in the final video, a cue must not bridge across the edit because it can expose removed language or create misleading timing.
12
+
13
+ ## Default talking-head policy
14
+
15
+ - Up to 16 visible characters per line, two lines per cue.
16
+ - Aim for 12–20 visible characters, but preserve semantic grouping first.
17
+ - Aim for 1.5–4.5 seconds; allow 0.833–7 seconds when the speech requires it.
18
+ - Prefer about 9 visible characters per second and relax locally up to 11 before making a worse semantic break.
19
+ - A pause near 250ms is useful weak boundary evidence; 450ms is strong boundary evidence.
20
+
21
+ These are working defaults, not hard editorial targets. The hard requirements are monotonic non-overlapping timestamps, no cue beyond the final timeline, and preservation of the reviewed spoken content.
22
+
23
+ ## Review order
24
+
25
+ 1. Spoken text and terminology.
26
+ 2. Final-timeline sync after every A-roll cut.
27
+ 3. Semantic cue boundary.
28
+ 4. Reading speed and display duration.
29
+ 5. Balanced line wrapping and safe-area placement.
30
+ 6. Font availability and actual rendered glyphs.
31
+
32
+ When timing evidence is mathematically inconsistent, relax only the affected cue. Do not collapse the full transcript into one cue or degrade into one character per cue.
@@ -0,0 +1,94 @@
1
+ export interface TranscriptWord {
2
+ text: string;
3
+ beginMs: number;
4
+ endMs: number;
5
+ punctuation: string;
6
+ }
7
+
8
+ export interface TimelineSegment {
9
+ id: string;
10
+ sourceStartMs: number;
11
+ sourceEndMs: number;
12
+ }
13
+
14
+ export interface TimelineWord extends TranscriptWord {
15
+ timelineSegmentId?: string;
16
+ }
17
+
18
+ export interface SubtitlePolicy {
19
+ maxLineChars: number;
20
+ maxLines: number;
21
+ targetMinChars: number;
22
+ targetMaxChars: number;
23
+ targetMinDurationMs: number;
24
+ targetMaxDurationMs: number;
25
+ minDurationMs: number;
26
+ maxDurationMs: number;
27
+ targetCps: number;
28
+ relaxedMaxCps: number;
29
+ weakPauseMs: number;
30
+ strongPauseMs: number;
31
+ }
32
+
33
+ export interface SubtitleCue {
34
+ id: string;
35
+ beginMs: number;
36
+ endMs: number;
37
+ text: string;
38
+ }
39
+
40
+ export interface FileRef {
41
+ path: string;
42
+ bytes: number;
43
+ sha256: string;
44
+ mimeType: string;
45
+ }
46
+
47
+ export interface AssStyle {
48
+ playResX: number;
49
+ playResY: number;
50
+ fontName: string;
51
+ fontSize: number;
52
+ primaryColour: string;
53
+ outlineColour: string;
54
+ outline: number;
55
+ shadow: number;
56
+ alignment: number;
57
+ marginV: number;
58
+ }
59
+
60
+ export interface SubtitleProject {
61
+ schemaVersion: 1;
62
+ projectId: string;
63
+ createdAt: string;
64
+ updatedAt: string;
65
+ currentRevision: number;
66
+ sourceDurationMs: number;
67
+ timelineDurationMs: number;
68
+ transcript: FileRef;
69
+ timelineSegments: TimelineSegment[];
70
+ policy: SubtitlePolicy;
71
+ }
72
+
73
+ export interface SubtitleSnapshot {
74
+ schemaVersion: 1;
75
+ projectId: string;
76
+ revision: number;
77
+ parentRevision: number | null;
78
+ createdAt: string;
79
+ changeReason: string;
80
+ cues: SubtitleCue[];
81
+ snapshotSha256: string;
82
+ }
83
+
84
+ export interface SubtitleExportReceipt {
85
+ schemaVersion: 1;
86
+ exportId: string;
87
+ projectId: string;
88
+ revision: number;
89
+ createdAt: string;
90
+ snapshotSha256: string;
91
+ tracks: { srt: FileRef; ass?: FileRef };
92
+ receiptPath: string;
93
+ receiptSha256: string;
94
+ }
package/src/cues.ts ADDED
@@ -0,0 +1,255 @@
1
+ import type {
2
+ SubtitleCue,
3
+ SubtitlePolicy,
4
+ TimelineSegment,
5
+ TimelineWord,
6
+ TranscriptWord,
7
+ } from "./contracts.ts";
8
+
9
+ export const DEFAULT_SUBTITLE_POLICY: SubtitlePolicy = {
10
+ maxLineChars: 16,
11
+ maxLines: 2,
12
+ targetMinChars: 12,
13
+ targetMaxChars: 20,
14
+ targetMinDurationMs: 1_500,
15
+ targetMaxDurationMs: 4_500,
16
+ minDurationMs: 833,
17
+ maxDurationMs: 7_000,
18
+ targetCps: 9,
19
+ relaxedMaxCps: 11,
20
+ weakPauseMs: 250,
21
+ strongPauseMs: 450,
22
+ };
23
+
24
+ interface TimedToken {
25
+ text: string;
26
+ beginMs: number;
27
+ endMs: number;
28
+ spaceBefore: boolean;
29
+ }
30
+
31
+ const STRONG_PUNCTUATION = new Set(["。", "!", "?", "?", "!", "."]);
32
+ const WEAK_PUNCTUATION = new Set([",", "、", ";", ":", ",", ";", ":"]);
33
+
34
+ function finiteNonNegative(value: unknown): value is number {
35
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
36
+ }
37
+
38
+ function validateWords(words: TranscriptWord[]): void {
39
+ let previousEnd = -1;
40
+ for (const word of words) {
41
+ if (typeof word.text !== "string" || !word.text.trim() || !finiteNonNegative(word.beginMs)
42
+ || !finiteNonNegative(word.endMs) || word.endMs <= word.beginMs || typeof word.punctuation !== "string") {
43
+ throw new Error("Transcript contains an invalid word timestamp or text");
44
+ }
45
+ if (word.beginMs < previousEnd) throw new Error("Transcript word timestamps overlap or are out of order");
46
+ previousEnd = word.endMs;
47
+ }
48
+ }
49
+
50
+ function validateTimeline(segments: TimelineSegment[]): void {
51
+ if (segments.length === 0) throw new Error("Timeline must retain at least one A-roll segment");
52
+ let previousEnd = -1;
53
+ const ids = new Set<string>();
54
+ for (const segment of segments) {
55
+ if (!segment.id || ids.has(segment.id) || !finiteNonNegative(segment.sourceStartMs)
56
+ || !finiteNonNegative(segment.sourceEndMs) || segment.sourceEndMs <= segment.sourceStartMs) {
57
+ throw new Error("Timeline contains an invalid or duplicate segment");
58
+ }
59
+ if (segment.sourceStartMs < previousEnd) throw new Error("Timeline source segments overlap or are out of order");
60
+ ids.add(segment.id);
61
+ previousEnd = segment.sourceEndMs;
62
+ }
63
+ }
64
+
65
+ export function remapWordsToTimeline(words: TranscriptWord[], segments: TimelineSegment[]): TimelineWord[] {
66
+ validateWords(words);
67
+ validateTimeline(segments);
68
+ const remapped: TimelineWord[] = [];
69
+ let outputOffsetMs = 0;
70
+ for (const segment of segments) {
71
+ for (const word of words) {
72
+ const overlaps = word.beginMs < segment.sourceEndMs && word.endMs > segment.sourceStartMs;
73
+ if (!overlaps) continue;
74
+ if (word.beginMs < segment.sourceStartMs || word.endMs > segment.sourceEndMs) {
75
+ throw new Error(`Word timestamp crosses A-roll edit boundary: ${word.text}`);
76
+ }
77
+ remapped.push({
78
+ ...word,
79
+ beginMs: outputOffsetMs + word.beginMs - segment.sourceStartMs,
80
+ endMs: outputOffsetMs + word.endMs - segment.sourceStartMs,
81
+ timelineSegmentId: segment.id,
82
+ });
83
+ }
84
+ outputOffsetMs += segment.sourceEndMs - segment.sourceStartMs;
85
+ }
86
+ return remapped;
87
+ }
88
+
89
+ function isAsciiWord(value: string): boolean {
90
+ return value.length > 0 && [...value].every((character) => /[A-Za-z0-9'-]/u.test(character));
91
+ }
92
+
93
+ function splitTiming(word: TimelineWord, policy: SubtitlePolicy): TimedToken[] {
94
+ const text = word.text.trim();
95
+ if (!text) return [];
96
+ const durationMs = word.endMs - word.beginMs;
97
+ if (/\s/u.test(text)) {
98
+ const matches = [...text.matchAll(/\S+/gu)];
99
+ return matches.flatMap((match) => {
100
+ const start = match.index ?? 0;
101
+ const nested = splitTiming({
102
+ text: match[0],
103
+ beginMs: word.beginMs + durationMs * start / text.length,
104
+ endMs: word.beginMs + durationMs * (start + match[0].length) / text.length,
105
+ punctuation: "",
106
+ }, policy);
107
+ if (nested[0] && start > 0) nested[0].spaceBefore = true;
108
+ return nested;
109
+ });
110
+ }
111
+ const asciiWord = isAsciiWord(text);
112
+ const mustSplit = [...text].length > policy.maxLineChars * policy.maxLines || durationMs > policy.maxDurationMs;
113
+ const units = asciiWord && !mustSplit ? [text] : [...text];
114
+ const stepMs = durationMs / units.length;
115
+ return units.map((unit, index) => ({
116
+ text: `${unit}${index === units.length - 1 ? word.punctuation : ""}`,
117
+ beginMs: word.beginMs + index * stepMs,
118
+ endMs: Math.min(word.beginMs + (index + 1) * stepMs, word.beginMs + index * stepMs + policy.maxDurationMs),
119
+ spaceBefore: asciiWord && index === 0,
120
+ }));
121
+ }
122
+
123
+ function visibleChars(value: string): number {
124
+ return [...value].filter((character) => !/\s/u.test(character) && !/\p{Cf}/u.test(character)).length;
125
+ }
126
+
127
+ function joinTokens(tokens: TimedToken[]): string {
128
+ let value = "";
129
+ for (const token of tokens) value += `${value && token.spaceBefore ? " " : ""}${token.text}`;
130
+ return value;
131
+ }
132
+
133
+ function wrapTokens(tokens: TimedToken[], maxLineChars: number): string {
134
+ const whole = joinTokens(tokens);
135
+ if (visibleChars(whole) <= maxLineChars) return whole;
136
+ for (const lineLimit of [maxLineChars, maxLineChars + 2]) {
137
+ const candidates: Array<{ cost: number; first: string; second: string }> = [];
138
+ for (let split = 1; split < tokens.length; split += 1) {
139
+ const first = joinTokens(tokens.slice(0, split));
140
+ const second = joinTokens(tokens.slice(split));
141
+ const firstLength = visibleChars(first);
142
+ const secondLength = visibleChars(second);
143
+ if (firstLength > lineLimit || secondLength > lineLimit) continue;
144
+ const last = [...first].at(-1);
145
+ const punctuationBonus = last && STRONG_PUNCTUATION.has(last) ? -8 : last && WEAK_PUNCTUATION.has(last) ? -6 : 0;
146
+ candidates.push({
147
+ cost: Math.abs(firstLength - secondLength) + Math.max(0, firstLength - secondLength) * 0.5 + punctuationBonus,
148
+ first,
149
+ second,
150
+ });
151
+ }
152
+ if (candidates.length > 0) {
153
+ candidates.sort((left, right) => left.cost - right.cost);
154
+ return `${candidates[0]!.first}\n${candidates[0]!.second}`;
155
+ }
156
+ }
157
+ const characters = [...whole];
158
+ return `${characters.slice(0, maxLineChars).join("")}\n${characters.slice(maxLineChars).join("")}`;
159
+ }
160
+
161
+ function cueCost(tokens: TimedToken[], start: number, end: number, remainingCharacters: number, policy: SubtitlePolicy): number {
162
+ const cueTokens = tokens.slice(start, end);
163
+ const characters = visibleChars(joinTokens(cueTokens));
164
+ const durationMs = Math.max(1, cueTokens.at(-1)!.endMs - cueTokens[0]!.beginMs);
165
+ let cost = characters < policy.targetMinChars
166
+ ? (policy.targetMinChars - characters) * 1.25
167
+ : characters > policy.targetMaxChars ? (characters - policy.targetMaxChars) * 1.6 : 0;
168
+ if (durationMs < policy.targetMinDurationMs) cost += (policy.targetMinDurationMs - durationMs) / 200;
169
+ if (durationMs > policy.targetMaxDurationMs) cost += (durationMs - policy.targetMaxDurationMs) / 500;
170
+ const cps = characters / (durationMs / 1_000);
171
+ cost += Math.max(0, cps - policy.targetCps) ** 2 * 0.8;
172
+ cost += Math.max(0, cps - policy.relaxedMaxCps) ** 2 * 2;
173
+ const lastCharacter = [...cueTokens.at(-1)!.text.trim()].at(-1);
174
+ cost += lastCharacter && STRONG_PUNCTUATION.has(lastCharacter) ? -10
175
+ : lastCharacter && WEAK_PUNCTUATION.has(lastCharacter) ? -4 : 12;
176
+ if (end === tokens.length) cost -= 6;
177
+ else {
178
+ const pauseMs = tokens[end]!.beginMs - tokens[end - 1]!.endMs;
179
+ if (pauseMs >= policy.strongPauseMs) cost -= 18;
180
+ else if (pauseMs >= policy.weakPauseMs) cost -= 10;
181
+ }
182
+ if (end < tokens.length && remainingCharacters <= 2) cost += 18;
183
+ if (characters <= 2 && (start > 0 || end < tokens.length)) cost += 18;
184
+ return cost;
185
+ }
186
+
187
+ function selectBoundaries(tokens: TimedToken[], policy: SubtitlePolicy): number[] {
188
+ const count = tokens.length;
189
+ const prefix = [0];
190
+ for (const token of tokens) prefix.push(prefix.at(-1)! + visibleChars(token.text));
191
+ const costs = Array<number>(count + 1).fill(Number.POSITIVE_INFINITY);
192
+ const previous = Array<number>(count + 1).fill(-1);
193
+ costs[0] = 0;
194
+ for (let start = 0; start < count; start += 1) {
195
+ if (!Number.isFinite(costs[start])) continue;
196
+ for (let end = start + 1; end <= count; end += 1) {
197
+ const characters = prefix[end]! - prefix[start]!;
198
+ const durationMs = Math.max(1, tokens[end - 1]!.endMs - tokens[start]!.beginMs);
199
+ if (end > start + 1 && (characters > (policy.maxLineChars + 2) * policy.maxLines || durationMs > policy.maxDurationMs)) break;
200
+ const shortPenalty = durationMs < policy.minDurationMs && !(start === 0 && end === count)
201
+ ? policy.minDurationMs - durationMs : 0;
202
+ const candidate = costs[start]! + shortPenalty
203
+ + cueCost(tokens, start, end, prefix[count]! - prefix[end]!, policy);
204
+ if (candidate < costs[end]!) {
205
+ costs[end] = candidate;
206
+ previous[end] = start;
207
+ }
208
+ }
209
+ }
210
+ if (previous[count]! < 0) return [count];
211
+ const boundaries: number[] = [];
212
+ for (let cursor = count; cursor > 0; cursor = previous[cursor]!) boundaries.push(cursor);
213
+ return boundaries.reverse();
214
+ }
215
+
216
+ function buildGroup(tokens: TimedToken[], policy: SubtitlePolicy): Omit<SubtitleCue, "id">[] {
217
+ const boundaries = selectBoundaries(tokens, policy);
218
+ const cues: Omit<SubtitleCue, "id">[] = [];
219
+ let start = 0;
220
+ for (const end of boundaries) {
221
+ const selected = tokens.slice(start, end);
222
+ cues.push({
223
+ beginMs: selected[0]!.beginMs,
224
+ endMs: Math.max(selected[0]!.beginMs + 1, selected.at(-1)!.endMs),
225
+ text: wrapTokens(selected, policy.maxLineChars),
226
+ });
227
+ start = end;
228
+ }
229
+ return cues;
230
+ }
231
+
232
+ export function buildSubtitleCues(
233
+ words: TimelineWord[],
234
+ timelineEndMs: number,
235
+ policy: Partial<SubtitlePolicy> = {},
236
+ ): SubtitleCue[] {
237
+ if (!finiteNonNegative(timelineEndMs)) throw new Error("timelineEndMs must be non-negative");
238
+ validateWords(words);
239
+ const resolved = { ...DEFAULT_SUBTITLE_POLICY, ...policy };
240
+ const groups: TimelineWord[][] = [];
241
+ for (const word of words) {
242
+ const previous = groups.at(-1);
243
+ if (!previous || previous[0]!.timelineSegmentId !== word.timelineSegmentId) groups.push([word]);
244
+ else previous.push(word);
245
+ }
246
+ const raw = groups.flatMap((group) => buildGroup(group.flatMap((word) => splitTiming(word, resolved)), resolved));
247
+ return raw.map((cue, index) => {
248
+ const nextStart = raw[index + 1]?.beginMs ?? Math.max(cue.endMs, timelineEndMs);
249
+ return {
250
+ id: `cue-${String(index + 1).padStart(3, "0")}`,
251
+ ...cue,
252
+ endMs: Math.max(cue.endMs, Math.min(cue.beginMs + resolved.minDurationMs, cue.beginMs + resolved.maxDurationMs, nextStart)),
253
+ };
254
+ });
255
+ }
package/src/export.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { AssStyle, FileRef, SubtitleCue } from "./contracts.ts";
2
+ import { rm } from "node:fs/promises";
3
+ import { resolveExistingWorkspaceFile, writeNewWorkspaceFile } from "./workspace.ts";
4
+
5
+ export const DEFAULT_ASS_STYLE: AssStyle = {
6
+ playResX: 1920,
7
+ playResY: 1080,
8
+ fontName: "Noto Sans CJK SC",
9
+ fontSize: 54,
10
+ primaryColour: "&H00FFFFFF",
11
+ outlineColour: "&H00000000",
12
+ outline: 3,
13
+ shadow: 0,
14
+ alignment: 2,
15
+ marginV: 90,
16
+ };
17
+
18
+ function pad(value: number, width: number): string {
19
+ return String(value).padStart(width, "0");
20
+ }
21
+
22
+ function srtTime(milliseconds: number): string {
23
+ const rounded = Math.max(0, Math.round(milliseconds));
24
+ const hours = Math.floor(rounded / 3_600_000);
25
+ const minutes = Math.floor(rounded % 3_600_000 / 60_000);
26
+ const seconds = Math.floor(rounded % 60_000 / 1_000);
27
+ return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)},${pad(rounded % 1_000, 3)}`;
28
+ }
29
+
30
+ function assTime(milliseconds: number): string {
31
+ const centiseconds = Math.max(0, Math.round(milliseconds / 10));
32
+ const hours = Math.floor(centiseconds / 360_000);
33
+ const minutes = Math.floor(centiseconds % 360_000 / 6_000);
34
+ const seconds = Math.floor(centiseconds % 6_000 / 100);
35
+ return `${hours}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(centiseconds % 100, 2)}`;
36
+ }
37
+
38
+ function assertCues(cues: SubtitleCue[]): void {
39
+ let previousEnd = -1;
40
+ const ids = new Set<string>();
41
+ for (const cue of cues) {
42
+ if (!cue.id || ids.has(cue.id) || !Number.isFinite(cue.beginMs) || !Number.isFinite(cue.endMs)
43
+ || cue.beginMs < 0 || cue.endMs <= cue.beginMs || !cue.text.trim()) {
44
+ throw new Error("Subtitle cues contain invalid timing, text, or duplicate IDs");
45
+ }
46
+ if (cue.beginMs < previousEnd) throw new Error("Subtitle cues overlap or are out of order");
47
+ ids.add(cue.id);
48
+ previousEnd = cue.endMs;
49
+ }
50
+ }
51
+
52
+ export function renderSrt(cues: SubtitleCue[]): string {
53
+ assertCues(cues);
54
+ return `${cues.map((cue, index) => `${index + 1}\n${srtTime(cue.beginMs)} --> ${srtTime(cue.endMs)}\n${cue.text}`).join("\n\n")}\n`;
55
+ }
56
+
57
+ function assText(value: string): string {
58
+ return value.replaceAll("\\", "\\\\").replaceAll("{", "\\{").replaceAll("}", "\\}").replaceAll("\n", "\\N");
59
+ }
60
+
61
+ export function renderAss(cues: SubtitleCue[], style: Partial<AssStyle> = {}): string {
62
+ assertCues(cues);
63
+ const resolved = { ...DEFAULT_ASS_STYLE, ...style };
64
+ const header = `[Script Info]\nScriptType: v4.00+\nPlayResX: ${resolved.playResX}\nPlayResY: ${resolved.playResY}\nWrapStyle: 2\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,${resolved.fontName},${resolved.fontSize},${resolved.primaryColour},${resolved.primaryColour},${resolved.outlineColour},&H00000000,0,0,0,0,100,100,0,0,1,${resolved.outline},${resolved.shadow},${resolved.alignment},40,40,${resolved.marginV},1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text`;
65
+ const events = cues.map((cue) => `Dialogue: 0,${assTime(cue.beginMs)},${assTime(cue.endMs)},Default,,0,0,0,,${assText(cue.text)}`);
66
+ return `${header}\n${events.join("\n")}\n`;
67
+ }
68
+
69
+ export async function exportSubtitleTracks(
70
+ cwd: string,
71
+ cues: SubtitleCue[],
72
+ input: { srtPath: string; assPath?: string; assStyle?: Partial<AssStyle> },
73
+ ): Promise<{ srt: FileRef; ass?: FileRef }> {
74
+ assertCues(cues);
75
+ if (!input.srtPath.toLowerCase().endsWith(".srt")) throw new Error("srtPath must end in .srt");
76
+ if (input.assPath !== undefined && !input.assPath.toLowerCase().endsWith(".ass")) throw new Error("assPath must end in .ass");
77
+ const srt = await writeNewWorkspaceFile(cwd, input.srtPath, renderSrt(cues), "application/x-subrip; charset=utf-8");
78
+ if (!input.assPath) return { srt };
79
+ try {
80
+ const ass = await writeNewWorkspaceFile(cwd, input.assPath, renderAss(cues, input.assStyle), "text/x-ssa; charset=utf-8");
81
+ return { srt, ass };
82
+ } catch (error) {
83
+ await rm(await resolveExistingWorkspaceFile(cwd, srt.path), { force: true });
84
+ throw error;
85
+ }
86
+ }
package/src/project.ts ADDED
@@ -0,0 +1,287 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { buildSubtitleCues, DEFAULT_SUBTITLE_POLICY, remapWordsToTimeline } from "./cues.ts";
5
+ import type {
6
+ AssStyle,
7
+ SubtitleCue,
8
+ SubtitleExportReceipt,
9
+ SubtitlePolicy,
10
+ SubtitleProject,
11
+ SubtitleSnapshot,
12
+ TimelineSegment,
13
+ TranscriptWord,
14
+ } from "./contracts.ts";
15
+ import { exportSubtitleTracks, renderSrt } from "./export.ts";
16
+ import {
17
+ readWorkspaceJson,
18
+ resolveExistingWorkspaceFile,
19
+ resolveNewWorkspaceFile,
20
+ snapshotExistingWorkspaceFile,
21
+ writeNewWorkspaceFile,
22
+ } from "./workspace.ts";
23
+
24
+ const PROJECT_ID = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/u;
25
+
26
+ interface RawTranscript {
27
+ words?: unknown[];
28
+ sentences?: Array<{ words?: unknown[] }>;
29
+ }
30
+
31
+ export interface CreateSubtitleProjectInput {
32
+ projectId: string;
33
+ transcriptPath: string;
34
+ sourceDurationMs: number;
35
+ timelineSegments?: TimelineSegment[];
36
+ policy?: Partial<SubtitlePolicy>;
37
+ }
38
+
39
+ export interface ApplySubtitleRevisionInput {
40
+ projectId: string;
41
+ expectedRevision: number;
42
+ cues: SubtitleCue[];
43
+ changeReason: string;
44
+ }
45
+
46
+ function stableJson(value: unknown): string {
47
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
48
+ if (value && typeof value === "object") {
49
+ return `{${Object.entries(value as Record<string, unknown>).sort(([left], [right]) => left.localeCompare(right))
50
+ .map(([key, nested]) => `${JSON.stringify(key)}:${stableJson(nested)}`).join(",")}}`;
51
+ }
52
+ return JSON.stringify(value);
53
+ }
54
+
55
+ function sha256Json(value: unknown): string {
56
+ return createHash("sha256").update(stableJson(value)).digest("hex");
57
+ }
58
+
59
+ function assertProjectId(projectId: string): void {
60
+ if (!PROJECT_ID.test(projectId)) throw new Error("projectId must be 1-128 lowercase letters, numbers, dots, dashes, or underscores");
61
+ }
62
+
63
+ function projectRelative(projectId: string, path = ""): string {
64
+ assertProjectId(projectId);
65
+ return `.subtitles/projects/${projectId}${path ? `/${path}` : ""}`;
66
+ }
67
+
68
+ function normalizedWord(value: unknown): TranscriptWord | undefined {
69
+ if (!value || typeof value !== "object") return undefined;
70
+ const item = value as Record<string, unknown>;
71
+ const text = typeof item.text === "string" ? item.text : typeof item.word === "string" ? item.word : undefined;
72
+ const beginMs = typeof item.beginMs === "number" ? item.beginMs : typeof item.start === "number" ? item.start * 1_000 : undefined;
73
+ const endMs = typeof item.endMs === "number" ? item.endMs : typeof item.end === "number" ? item.end * 1_000 : undefined;
74
+ if (!text || beginMs === undefined || endMs === undefined) return undefined;
75
+ const trimmed = text.trim();
76
+ if (!trimmed || /^(?:<sil>|\[sil\])$/iu.test(trimmed)) return undefined;
77
+ return {
78
+ text: trimmed,
79
+ beginMs,
80
+ endMs,
81
+ punctuation: typeof item.punctuation === "string" ? item.punctuation : "",
82
+ };
83
+ }
84
+
85
+ function transcriptWords(transcript: RawTranscript): TranscriptWord[] {
86
+ const raw = Array.isArray(transcript.sentences)
87
+ ? transcript.sentences.flatMap((sentence) => Array.isArray(sentence.words) ? sentence.words : [])
88
+ : Array.isArray(transcript.words) ? transcript.words : [];
89
+ const words = raw.map(normalizedWord).filter((word): word is TranscriptWord => word !== undefined);
90
+ if (words.length === 0) throw new Error("Transcript contains no usable word timestamps");
91
+ return words;
92
+ }
93
+
94
+ function validateSourceAndTimeline(sourceDurationMs: number, timelineSegments: TimelineSegment[]): void {
95
+ if (!Number.isFinite(sourceDurationMs) || sourceDurationMs <= 0) throw new Error("sourceDurationMs must be positive");
96
+ if (timelineSegments.some((segment) => segment.sourceEndMs > sourceDurationMs)) {
97
+ throw new Error("Timeline segment exceeds sourceDurationMs");
98
+ }
99
+ }
100
+
101
+ function resolvedPolicy(input: Partial<SubtitlePolicy> | undefined): SubtitlePolicy {
102
+ const policy = { ...DEFAULT_SUBTITLE_POLICY, ...input };
103
+ for (const [name, value] of Object.entries(policy)) {
104
+ if (!Number.isFinite(value) || value < 0) throw new Error(`Subtitle policy ${name} must be a finite non-negative number`);
105
+ }
106
+ if (policy.maxLineChars < 1 || policy.maxLines < 1 || policy.targetMinChars > policy.targetMaxChars
107
+ || policy.targetMinDurationMs > policy.targetMaxDurationMs || policy.minDurationMs > policy.maxDurationMs
108
+ || policy.targetMaxDurationMs > policy.maxDurationMs || policy.targetCps > policy.relaxedMaxCps
109
+ || policy.weakPauseMs > policy.strongPauseMs) {
110
+ throw new Error("Subtitle policy contains inconsistent minimum, target, or maximum values");
111
+ }
112
+ return policy;
113
+ }
114
+
115
+ function timelineDuration(segments: TimelineSegment[]): number {
116
+ return segments.reduce((total, segment) => total + segment.sourceEndMs - segment.sourceStartMs, 0);
117
+ }
118
+
119
+ function snapshot(projectId: string, revision: number, parentRevision: number | null, cues: SubtitleCue[], changeReason: string): SubtitleSnapshot {
120
+ const base = {
121
+ schemaVersion: 1 as const,
122
+ projectId,
123
+ revision,
124
+ parentRevision,
125
+ createdAt: new Date().toISOString(),
126
+ changeReason,
127
+ cues,
128
+ };
129
+ return { ...base, snapshotSha256: sha256Json(base) };
130
+ }
131
+
132
+ async function absoluteProjectDirectory(cwd: string, projectId: string): Promise<string> {
133
+ const marker = await resolveNewWorkspaceFile(cwd, `${projectRelative(projectId)}/.marker`).catch(async (error) => {
134
+ if (!/already exists/u.test((error as Error).message)) throw error;
135
+ return join(dirname(await resolveExistingWorkspaceFile(cwd, `${projectRelative(projectId)}/project.json`)), ".marker");
136
+ });
137
+ return dirname(marker);
138
+ }
139
+
140
+ async function writeInternalJson(cwd: string, relativePath: string, value: unknown): Promise<void> {
141
+ await writeNewWorkspaceFile(cwd, relativePath, `${JSON.stringify(value, null, 2)}\n`, "application/json");
142
+ }
143
+
144
+ export async function createSubtitleProject(cwd: string, input: CreateSubtitleProjectInput) {
145
+ assertProjectId(input.projectId);
146
+ const transcript = await snapshotExistingWorkspaceFile(cwd, input.transcriptPath, "application/json");
147
+ const rawTranscript = await readWorkspaceJson<RawTranscript>(cwd, input.transcriptPath, "word transcript");
148
+ const segments = input.timelineSegments ?? [{ id: "a-1", sourceStartMs: 0, sourceEndMs: input.sourceDurationMs }];
149
+ validateSourceAndTimeline(input.sourceDurationMs, segments);
150
+ const durationMs = timelineDuration(segments);
151
+ const policy = resolvedPolicy(input.policy);
152
+ const cues = buildSubtitleCues(remapWordsToTimeline(transcriptWords(rawTranscript), segments), durationMs, policy);
153
+ if (cues.length === 0) throw new Error("Retained A-roll timeline contains no transcribed words");
154
+ const now = new Date().toISOString();
155
+ const project: SubtitleProject = {
156
+ schemaVersion: 1,
157
+ projectId: input.projectId,
158
+ createdAt: now,
159
+ updatedAt: now,
160
+ currentRevision: 1,
161
+ sourceDurationMs: input.sourceDurationMs,
162
+ timelineDurationMs: durationMs,
163
+ transcript,
164
+ timelineSegments: segments,
165
+ policy,
166
+ };
167
+ const first = snapshot(input.projectId, 1, null, cues, "Initial subtitle generation from word timestamps");
168
+ const projectsMarker = await resolveNewWorkspaceFile(cwd, ".subtitles/projects/.marker");
169
+ const projectsRoot = dirname(projectsMarker);
170
+ await mkdir(projectsRoot, { recursive: true });
171
+ const target = join(projectsRoot, input.projectId);
172
+ const temporary = join(projectsRoot, `.${input.projectId}.${randomUUID()}.tmp`);
173
+ await mkdir(join(temporary, "snapshots"), { recursive: true });
174
+ try {
175
+ await writeFile(join(temporary, "project.json"), `${JSON.stringify(project, null, 2)}\n`, { flag: "wx" });
176
+ await writeFile(join(temporary, "snapshots/1.json"), `${JSON.stringify(first, null, 2)}\n`, { flag: "wx" });
177
+ await rename(temporary, target);
178
+ } catch (error) {
179
+ await rm(temporary, { recursive: true, force: true });
180
+ if ((error as NodeJS.ErrnoException).code === "ENOTEMPTY" || (error as NodeJS.ErrnoException).code === "EEXIST") {
181
+ throw new Error(`Project already exists: ${input.projectId}`);
182
+ }
183
+ throw error;
184
+ }
185
+ return { project, snapshot: first };
186
+ }
187
+
188
+ export async function getSubtitleProject(cwd: string, projectId: string, revision?: number) {
189
+ assertProjectId(projectId);
190
+ const project = await readWorkspaceJson<SubtitleProject>(cwd, projectRelative(projectId, "project.json"), `subtitle project ${projectId}`);
191
+ if (project.schemaVersion !== 1 || project.projectId !== projectId || !Number.isInteger(project.currentRevision)) {
192
+ throw new Error(`Invalid subtitle project: ${projectId}`);
193
+ }
194
+ const selected = revision ?? project.currentRevision;
195
+ if (!Number.isInteger(selected) || selected < 1 || selected > project.currentRevision) throw new Error(`Invalid subtitle revision: ${projectId}@${selected}`);
196
+ const selectedSnapshot = await readWorkspaceJson<SubtitleSnapshot>(cwd, projectRelative(projectId, `snapshots/${selected}.json`), `subtitle snapshot ${projectId}@${selected}`);
197
+ const { snapshotSha256, ...unsigned } = selectedSnapshot;
198
+ if (selectedSnapshot.schemaVersion !== 1 || selectedSnapshot.projectId !== projectId || selectedSnapshot.revision !== selected
199
+ || snapshotSha256 !== sha256Json(unsigned)) throw new Error(`Invalid subtitle snapshot: ${projectId}@${selected}`);
200
+ return { project, snapshot: selectedSnapshot };
201
+ }
202
+
203
+ function assertReviewedCues(cues: SubtitleCue[], timelineDurationMs: number): void {
204
+ if (cues.length === 0 || cues.length > 10_000) throw new Error("cues must contain 1-10000 items");
205
+ renderSrt(cues);
206
+ if (cues.at(-1)!.endMs > timelineDurationMs) throw new Error("Subtitle cue exceeds the final timeline duration");
207
+ }
208
+
209
+ async function withProjectLock<T>(cwd: string, projectId: string, action: (directory: string) => Promise<T>): Promise<T> {
210
+ const directory = await absoluteProjectDirectory(cwd, projectId);
211
+ const lock = join(directory, ".write-lock");
212
+ try {
213
+ await mkdir(lock);
214
+ } catch (error) {
215
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Project is already being updated: ${projectId}`);
216
+ throw error;
217
+ }
218
+ try {
219
+ return await action(directory);
220
+ } finally {
221
+ await rm(lock, { recursive: true, force: true });
222
+ }
223
+ }
224
+
225
+ export async function applySubtitleRevision(cwd: string, input: ApplySubtitleRevisionInput) {
226
+ if (!input.changeReason.trim()) throw new Error("changeReason is required for an Agent-reviewed revision");
227
+ return await withProjectLock(cwd, input.projectId, async (directory) => {
228
+ const { project } = await getSubtitleProject(cwd, input.projectId);
229
+ if (project.currentRevision !== input.expectedRevision) {
230
+ throw new Error(`Project ${input.projectId} expected revision ${input.expectedRevision} but current revision is ${project.currentRevision}`);
231
+ }
232
+ assertReviewedCues(input.cues, project.timelineDurationMs);
233
+ const revision = project.currentRevision + 1;
234
+ const nextSnapshot = snapshot(input.projectId, revision, project.currentRevision, input.cues, input.changeReason.trim());
235
+ const snapshotPath = join(directory, `snapshots/${revision}.json`);
236
+ await writeFile(snapshotPath, `${JSON.stringify(nextSnapshot, null, 2)}\n`, { flag: "wx" });
237
+ const nextProject: SubtitleProject = { ...project, currentRevision: revision, updatedAt: new Date().toISOString() };
238
+ const temporary = join(directory, `project.${randomUUID()}.tmp`);
239
+ try {
240
+ await writeFile(temporary, `${JSON.stringify(nextProject, null, 2)}\n`, { flag: "wx" });
241
+ await rename(temporary, join(directory, "project.json"));
242
+ } catch (error) {
243
+ await rm(temporary, { force: true });
244
+ await rm(snapshotPath, { force: true });
245
+ throw error;
246
+ }
247
+ return { project: nextProject, snapshot: nextSnapshot };
248
+ });
249
+ }
250
+
251
+ async function assertTranscriptUnchanged(cwd: string, project: SubtitleProject): Promise<void> {
252
+ const current = await snapshotExistingWorkspaceFile(cwd, project.transcript.path, project.transcript.mimeType);
253
+ if (current.sha256 !== project.transcript.sha256 || current.bytes !== project.transcript.bytes) {
254
+ throw new Error("Source transcript has changed since subtitle project creation");
255
+ }
256
+ }
257
+
258
+ export async function exportSubtitleProject(cwd: string, input: {
259
+ projectId: string;
260
+ revision: number;
261
+ srtPath: string;
262
+ assPath?: string;
263
+ assStyle?: Partial<AssStyle>;
264
+ }) {
265
+ const { project, snapshot: selectedSnapshot } = await getSubtitleProject(cwd, input.projectId, input.revision);
266
+ await assertTranscriptUnchanged(cwd, project);
267
+ const tracks = await exportSubtitleTracks(cwd, selectedSnapshot.cues, {
268
+ srtPath: input.srtPath,
269
+ ...(input.assPath ? { assPath: input.assPath } : {}),
270
+ ...(input.assStyle ? { assStyle: input.assStyle } : {}),
271
+ });
272
+ const exportId = randomUUID();
273
+ const receiptPath = projectRelative(input.projectId, `exports/${exportId}.json`);
274
+ const base = {
275
+ schemaVersion: 1 as const,
276
+ exportId,
277
+ projectId: input.projectId,
278
+ revision: input.revision,
279
+ createdAt: new Date().toISOString(),
280
+ snapshotSha256: selectedSnapshot.snapshotSha256,
281
+ tracks,
282
+ receiptPath,
283
+ };
284
+ const receipt: SubtitleExportReceipt = { ...base, receiptSha256: sha256Json(base) };
285
+ await writeInternalJson(cwd, receiptPath, receipt);
286
+ return { project, snapshot: selectedSnapshot, receipt };
287
+ }
@@ -0,0 +1,99 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { link, lstat, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
4
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
+ import type { FileRef } from "./contracts.ts";
6
+
7
+ function isWithin(root: string, candidate: string): boolean {
8
+ const value = relative(root, candidate);
9
+ return value === "" || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value));
10
+ }
11
+
12
+ async function exists(path: string): Promise<boolean> {
13
+ try {
14
+ await lstat(path);
15
+ return true;
16
+ } catch (error) {
17
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
18
+ throw error;
19
+ }
20
+ }
21
+
22
+ async function nearestExistingAncestor(path: string): Promise<string> {
23
+ let current = path;
24
+ while (!(await exists(current))) current = dirname(current);
25
+ return current;
26
+ }
27
+
28
+ export async function resolveNewWorkspaceFile(cwd: string, inputPath: string): Promise<string> {
29
+ const root = await realpath(cwd);
30
+ const target = resolve(root, inputPath);
31
+ if (target === root || !isWithin(root, target)) throw new Error(`Path is outside the workspace: ${inputPath}`);
32
+ if (await exists(target)) throw new Error(`Output already exists: ${inputPath}`);
33
+ const ancestor = await realpath(await nearestExistingAncestor(dirname(target)));
34
+ if (!isWithin(root, ancestor)) throw new Error(`Path resolves outside the workspace: ${inputPath}`);
35
+ return target;
36
+ }
37
+
38
+ async function sha256(path: string): Promise<string> {
39
+ const hash = createHash("sha256");
40
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
41
+ return hash.digest("hex");
42
+ }
43
+
44
+ export async function resolveExistingWorkspaceFile(cwd: string, inputPath: string): Promise<string> {
45
+ const root = await realpath(cwd);
46
+ const lexical = resolve(root, inputPath);
47
+ if (!isWithin(root, lexical)) throw new Error(`Path is outside the workspace: ${inputPath}`);
48
+ if ((await lstat(lexical)).isSymbolicLink()) throw new Error(`Unsafe workspace-file symlink: ${inputPath}`);
49
+ const canonical = await realpath(lexical);
50
+ if (!isWithin(root, canonical)) throw new Error(`Path resolves outside the workspace: ${inputPath}`);
51
+ if (!(await stat(canonical)).isFile()) throw new Error(`Path is not a file: ${inputPath}`);
52
+ return canonical;
53
+ }
54
+
55
+ export async function snapshotExistingWorkspaceFile(cwd: string, inputPath: string, mimeType: string): Promise<FileRef> {
56
+ const root = await realpath(cwd);
57
+ const path = await resolveExistingWorkspaceFile(cwd, inputPath);
58
+ return {
59
+ path: relative(root, path).split(sep).join("/"),
60
+ bytes: (await stat(path)).size,
61
+ sha256: await sha256(path),
62
+ mimeType,
63
+ };
64
+ }
65
+
66
+ export async function readWorkspaceJson<T>(cwd: string, inputPath: string, label: string): Promise<T> {
67
+ const path = await resolveExistingWorkspaceFile(cwd, inputPath);
68
+ try {
69
+ return JSON.parse(await readFile(path, "utf8")) as T;
70
+ } catch (error) {
71
+ throw new Error(`Invalid ${label}: ${(error as Error).message}`);
72
+ }
73
+ }
74
+
75
+ export async function writeNewWorkspaceFile(cwd: string, inputPath: string, content: string, mimeType: string): Promise<FileRef> {
76
+ const target = await resolveNewWorkspaceFile(cwd, inputPath);
77
+ await mkdir(dirname(target), { recursive: true });
78
+ const temporary = `${target}.${randomUUID()}.tmp`;
79
+ try {
80
+ await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
81
+ try {
82
+ await link(temporary, target);
83
+ } catch (error) {
84
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Output already exists: ${inputPath}`);
85
+ throw error;
86
+ }
87
+ await rm(temporary);
88
+ } catch (error) {
89
+ await rm(temporary, { force: true });
90
+ throw error;
91
+ }
92
+ const root = await realpath(cwd);
93
+ return {
94
+ path: relative(root, target).split(sep).join("/"),
95
+ bytes: (await stat(target)).size,
96
+ sha256: await sha256(target),
97
+ mimeType,
98
+ };
99
+ }