@speclip/pi-subtitles 0.1.0 → 0.2.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/README.md +12 -17
- package/extensions/subtitles/index.ts +9 -2
- package/package.json +1 -1
- package/skills/add-subtitles/SKILL.md +3 -3
- package/skills/add-subtitles/references/timing-and-layout.md +11 -4
- package/src/contracts.ts +6 -0
- package/src/cues.ts +4 -4
- package/src/export.ts +39 -5
- package/src/project.ts +31 -8
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
- 输入:`pi-speech` 兼容的词级 JSON 转录,也兼容常见 `{ word, start, end }` 秒级结构。
|
|
10
10
|
- A-roll 映射:按保留源片段压缩时间轴;字幕不会跨越剪辑点。
|
|
11
11
|
- 自动分段:结合标点、停顿、字数、时长和阅读速度生成初稿。
|
|
12
|
-
- Agent 审阅:通过不可变 revision 修正 ASR
|
|
13
|
-
- 导出:标准 SRT
|
|
12
|
+
- Agent 审阅:通过不可变 revision 修正 ASR 文本、标点和断句;横屏项目强制单行,审阅阶段也不能绕过。
|
|
13
|
+
- 导出:标准 SRT;可选带横屏/竖屏安全区预设的 ASS;两者都不覆盖已有文件。
|
|
14
14
|
- 溯源:绑定原转录哈希、精确 revision、输出哈希和持久化导出回执。
|
|
15
15
|
|
|
16
16
|
B-roll 只覆盖画面而保留 A-roll 主音频时,不会改变字幕时间。若后续改变了语速、主音频或 A-roll 片段顺序,应新建字幕项目。
|
|
@@ -45,7 +45,8 @@ pi install npm:@speclip/pi-subtitles
|
|
|
45
45
|
subtitles_create {
|
|
46
46
|
projectId: "launch-captions",
|
|
47
47
|
transcriptPath: "transcripts/launch.json",
|
|
48
|
-
sourceDurationMs: 93224
|
|
48
|
+
sourceDurationMs: 93224,
|
|
49
|
+
layoutPreset: "landscape"
|
|
49
50
|
}
|
|
50
51
|
```
|
|
51
52
|
|
|
@@ -82,9 +83,9 @@ subtitles_get {
|
|
|
82
83
|
}
|
|
83
84
|
```
|
|
84
85
|
|
|
85
|
-
自动结果只是初稿。Agent
|
|
86
|
+
自动结果只是初稿。Agent 应结合实际音频检查文本、术语、标点、语义分组、阅读速度和画幅布局,并继续翻页直到 `hasMore: false`。
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
`layoutPreset` 默认为 `landscape`:横屏字幕严格单行,每条最多 16 个可见字符,目标 8–16 字。`portrait` 允许最多两行。两种画幅都以 1.5–4.5 秒、约 9 字/秒为节奏参考;这些节奏值用于找自然断点,不要求每条机械一致。
|
|
88
89
|
|
|
89
90
|
### 3. 写入 Agent 审阅版本
|
|
90
91
|
|
|
@@ -94,7 +95,7 @@ subtitles_apply {
|
|
|
94
95
|
expectedRevision: 1,
|
|
95
96
|
changeReason: "核对音频后修正产品名和语义标点",
|
|
96
97
|
cues: [
|
|
97
|
-
{ id: "cue-001", beginMs: 320, endMs: 2840, text: "
|
|
98
|
+
{ id: "cue-001", beginMs: 320, endMs: 2840, text: "这是修正后的第一条字幕。" }
|
|
98
99
|
]
|
|
99
100
|
}
|
|
100
101
|
```
|
|
@@ -108,19 +109,13 @@ subtitles_export {
|
|
|
108
109
|
projectId: "launch-captions",
|
|
109
110
|
revision: 2,
|
|
110
111
|
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
|
-
}
|
|
112
|
+
assPath: "deliverables/launch.zh-CN.ass"
|
|
120
113
|
}
|
|
121
114
|
```
|
|
122
115
|
|
|
123
|
-
|
|
116
|
+
工具会按项目画幅自动使用安全区样式,并返回 SRT / ASS 文件引用、SHA-256 和持久化导出回执。横屏使用固定 `1920×1080` ASS 设计坐标,竖屏使用 `1080×1920`;这与源视频是 1080p 还是 4K 无关,libass 会等比缩放。不要把 4K 源分辨率直接写成 ASS 的 `PlayRes`。
|
|
117
|
+
|
|
118
|
+
默认样式为大号粗体白字、黑色描边和柔和阴影。横屏位于画面下方安全区并保持单行;竖屏字幕上移,同时扩大右边距避开常见交互区。几何参数只允许在安全范围内微调,不能通过 `assStyle` 把字幕缩回不可读尺寸。默认字体是 `Noto Sans CJK SC`,渲染环境仍需安装该字体或提供可用回退字体。
|
|
124
119
|
|
|
125
120
|
本包的完成状态是“字幕轨道已生成并校验”,不是“字幕已经烧进视频”。最后应把轨道交给支持字幕的渲染器,并检查实际成片中的同步、遮挡、安全区、字体回退和漏字。
|
|
126
121
|
|
|
@@ -145,7 +140,7 @@ npm run check
|
|
|
145
140
|
npm pack --dry-run
|
|
146
141
|
```
|
|
147
142
|
|
|
148
|
-
GitHub Release 标签必须与 `package.json` 完全一致,例如 `v0.
|
|
143
|
+
GitHub Release 标签必须与 `package.json` 完全一致,例如 `v0.2.0`。发布工作流会在标签快照上重新执行检查并通过 npm OIDC 发布。
|
|
149
144
|
|
|
150
145
|
## License
|
|
151
146
|
|
|
@@ -49,9 +49,12 @@ const assStyle = Type.Object({
|
|
|
49
49
|
fontSize: Type.Optional(Type.Number({ minimum: 1, maximum: 500 })),
|
|
50
50
|
primaryColour: Type.Optional(Type.String({ pattern: "^&H[0-9A-Fa-f]{8}$" })),
|
|
51
51
|
outlineColour: Type.Optional(Type.String({ pattern: "^&H[0-9A-Fa-f]{8}$" })),
|
|
52
|
+
bold: Type.Optional(Type.Boolean()),
|
|
52
53
|
outline: Type.Optional(Type.Number({ minimum: 0, maximum: 20 })),
|
|
53
54
|
shadow: Type.Optional(Type.Number({ minimum: 0, maximum: 20 })),
|
|
54
55
|
alignment: Type.Optional(Type.Integer({ minimum: 1, maximum: 9 })),
|
|
56
|
+
marginL: Type.Optional(Type.Integer({ minimum: 0, maximum: 4_000 })),
|
|
57
|
+
marginR: Type.Optional(Type.Integer({ minimum: 0, maximum: 4_000 })),
|
|
55
58
|
marginV: Type.Optional(Type.Integer({ minimum: 0, maximum: 4_000 })),
|
|
56
59
|
}, { additionalProperties: false });
|
|
57
60
|
|
|
@@ -59,11 +62,15 @@ export default function subtitles(pi: ExtensionAPI): void {
|
|
|
59
62
|
pi.registerTool({
|
|
60
63
|
name: "subtitles_create",
|
|
61
64
|
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
|
|
65
|
+
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. Landscape is the default and strictly remains single-line.",
|
|
63
66
|
parameters: Type.Object({
|
|
64
67
|
projectId: Type.String({ minLength: 1, maxLength: 128, pattern: "^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$" }),
|
|
65
68
|
transcriptPath: Type.String({ minLength: 1, description: "Existing workspace-relative word-timestamp JSON from pi-speech or a compatible ASR." }),
|
|
66
69
|
sourceDurationMs: Type.Number({ exclusiveMinimum: 0 }),
|
|
70
|
+
layoutPreset: Type.Optional(Type.Union([
|
|
71
|
+
Type.Literal("landscape"),
|
|
72
|
+
Type.Literal("portrait"),
|
|
73
|
+
], { description: "Canvas layout. Landscape is the default and strictly uses one subtitle line." })),
|
|
67
74
|
timelineSegments: Type.Optional(Type.Array(timelineSegment, {
|
|
68
75
|
minItems: 1,
|
|
69
76
|
maxItems: 1_000,
|
|
@@ -128,7 +135,7 @@ export default function subtitles(pi: ExtensionAPI): void {
|
|
|
128
135
|
pi.registerTool({
|
|
129
136
|
name: "subtitles_export",
|
|
130
137
|
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.",
|
|
138
|
+
description: "Export one exact reviewed revision as a new UTF-8 SRT file and optional styled ASS file using the project's landscape or portrait safe-area preset. Never overwrites outputs and returns a persisted provenance receipt; video burn-in remains an explicit renderer handoff.",
|
|
132
139
|
parameters: Type.Object({
|
|
133
140
|
projectId: Type.String({ minLength: 1, maxLength: 128 }),
|
|
134
141
|
revision: Type.Integer({ minimum: 1 }),
|
package/package.json
CHANGED
|
@@ -9,12 +9,12 @@ Use `pi-speech` for word timestamps and this package for final-timeline mapping,
|
|
|
9
9
|
|
|
10
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
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.
|
|
12
|
+
3. Call `subtitles_create` once. Set `layoutPreset` to `landscape` or `portrait`; it defaults to `landscape`. 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
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
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
|
|
15
|
+
6. Prefer one complete idea per cue and natural boundaries at strong punctuation or acoustic pauses. Landscape captions are always one line and must be split into another timed cue when too long; never insert `\n`. Portrait captions may use at most two balanced lines. 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
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
|
|
17
|
+
8. Call `subtitles_export` with the exact reviewed revision. SRT is the portable timing/text track. Request ASS for final burn-in: the project layout selects a canonical safe-area preset with large bold white text, black outline, soft shadow, and canvas-independent design coordinates. Do not replace `PlayRes` with the source video's 4K resolution. The default style names `Noto Sans CJK SC`; embedding or installing that font is the renderer's responsibility.
|
|
18
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
19
|
|
|
20
20
|
Never overwrite a transcript, subtitle track, or render. If the transcript hash changes, create a new subtitle project so timing decisions stay reproducible.
|
|
@@ -10,15 +10,22 @@ final_time = accumulated_retained_duration + source_word_time - range_source_sta
|
|
|
10
10
|
|
|
11
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
12
|
|
|
13
|
-
##
|
|
13
|
+
## Talking-head layout policy
|
|
14
14
|
|
|
15
|
-
-
|
|
16
|
-
-
|
|
15
|
+
- Landscape: exactly one line per cue, up to 16 visible characters; aim for 8–16.
|
|
16
|
+
- Portrait: up to two balanced lines, 16 visible characters per line; aim for 12–20 total.
|
|
17
17
|
- Aim for 1.5–4.5 seconds; allow 0.833–7 seconds when the speech requires it.
|
|
18
18
|
- Prefer about 9 visible characters per second and relax locally up to 11 before making a worse semantic break.
|
|
19
19
|
- A pause near 250ms is useful weak boundary evidence; 450ms is strong boundary evidence.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
Timing values are working defaults, not hard editorial targets. Landscape single-line layout, monotonic non-overlapping timestamps, no cue beyond the final timeline, and preservation of the reviewed spoken content are hard requirements.
|
|
22
|
+
|
|
23
|
+
## Safe-area presets
|
|
24
|
+
|
|
25
|
+
- Landscape uses canonical `1920×1080` ASS coordinates: font 80, bold, outline 3, shadow 3, left/right margins 120, bottom margin 140.
|
|
26
|
+
- Portrait uses canonical `1080×1920` ASS coordinates: font 72, bold, outline 3, shadow 3, left margin 90, right margin 200, bottom margin 460.
|
|
27
|
+
- Keep these design coordinates for 1080p, 2K, and 4K sources. libass scales them to the actual frame.
|
|
28
|
+
- Portrait's larger right and bottom margins avoid common right-side action controls and the lower interaction/caption region.
|
|
22
29
|
|
|
23
30
|
## Review order
|
|
24
31
|
|
package/src/contracts.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface SubtitleCue {
|
|
|
37
37
|
text: string;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export type SubtitleLayoutPreset = "landscape" | "portrait";
|
|
41
|
+
|
|
40
42
|
export interface FileRef {
|
|
41
43
|
path: string;
|
|
42
44
|
bytes: number;
|
|
@@ -51,9 +53,12 @@ export interface AssStyle {
|
|
|
51
53
|
fontSize: number;
|
|
52
54
|
primaryColour: string;
|
|
53
55
|
outlineColour: string;
|
|
56
|
+
bold: boolean;
|
|
54
57
|
outline: number;
|
|
55
58
|
shadow: number;
|
|
56
59
|
alignment: number;
|
|
60
|
+
marginL: number;
|
|
61
|
+
marginR: number;
|
|
57
62
|
marginV: number;
|
|
58
63
|
}
|
|
59
64
|
|
|
@@ -63,6 +68,7 @@ export interface SubtitleProject {
|
|
|
63
68
|
createdAt: string;
|
|
64
69
|
updatedAt: string;
|
|
65
70
|
currentRevision: number;
|
|
71
|
+
layoutPreset?: SubtitleLayoutPreset;
|
|
66
72
|
sourceDurationMs: number;
|
|
67
73
|
timelineDurationMs: number;
|
|
68
74
|
transcript: FileRef;
|
package/src/cues.ts
CHANGED
|
@@ -130,9 +130,9 @@ function joinTokens(tokens: TimedToken[]): string {
|
|
|
130
130
|
return value;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
function wrapTokens(tokens: TimedToken[], maxLineChars: number): string {
|
|
133
|
+
function wrapTokens(tokens: TimedToken[], maxLineChars: number, maxLines: number): string {
|
|
134
134
|
const whole = joinTokens(tokens);
|
|
135
|
-
if (visibleChars(whole) <= maxLineChars) return whole;
|
|
135
|
+
if (maxLines === 1 || visibleChars(whole) <= maxLineChars) return whole;
|
|
136
136
|
for (const lineLimit of [maxLineChars, maxLineChars + 2]) {
|
|
137
137
|
const candidates: Array<{ cost: number; first: string; second: string }> = [];
|
|
138
138
|
for (let split = 1; split < tokens.length; split += 1) {
|
|
@@ -196,7 +196,7 @@ function selectBoundaries(tokens: TimedToken[], policy: SubtitlePolicy): number[
|
|
|
196
196
|
for (let end = start + 1; end <= count; end += 1) {
|
|
197
197
|
const characters = prefix[end]! - prefix[start]!;
|
|
198
198
|
const durationMs = Math.max(1, tokens[end - 1]!.endMs - tokens[start]!.beginMs);
|
|
199
|
-
if (end > start + 1 && (characters >
|
|
199
|
+
if (end > start + 1 && (characters > policy.maxLineChars * policy.maxLines || durationMs > policy.maxDurationMs)) break;
|
|
200
200
|
const shortPenalty = durationMs < policy.minDurationMs && !(start === 0 && end === count)
|
|
201
201
|
? policy.minDurationMs - durationMs : 0;
|
|
202
202
|
const candidate = costs[start]! + shortPenalty
|
|
@@ -222,7 +222,7 @@ function buildGroup(tokens: TimedToken[], policy: SubtitlePolicy): Omit<Subtitle
|
|
|
222
222
|
cues.push({
|
|
223
223
|
beginMs: selected[0]!.beginMs,
|
|
224
224
|
endMs: Math.max(selected[0]!.beginMs + 1, selected.at(-1)!.endMs),
|
|
225
|
-
text: wrapTokens(selected, policy.maxLineChars),
|
|
225
|
+
text: wrapTokens(selected, policy.maxLineChars, policy.maxLines),
|
|
226
226
|
});
|
|
227
227
|
start = end;
|
|
228
228
|
}
|
package/src/export.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AssStyle, FileRef, SubtitleCue } from "./contracts.ts";
|
|
1
|
+
import type { AssStyle, FileRef, SubtitleCue, SubtitleLayoutPreset } from "./contracts.ts";
|
|
2
2
|
import { rm } from "node:fs/promises";
|
|
3
3
|
import { resolveExistingWorkspaceFile, writeNewWorkspaceFile } from "./workspace.ts";
|
|
4
4
|
|
|
@@ -6,15 +6,49 @@ export const DEFAULT_ASS_STYLE: AssStyle = {
|
|
|
6
6
|
playResX: 1920,
|
|
7
7
|
playResY: 1080,
|
|
8
8
|
fontName: "Noto Sans CJK SC",
|
|
9
|
-
fontSize:
|
|
9
|
+
fontSize: 80,
|
|
10
10
|
primaryColour: "&H00FFFFFF",
|
|
11
11
|
outlineColour: "&H00000000",
|
|
12
|
+
bold: true,
|
|
12
13
|
outline: 3,
|
|
13
|
-
shadow:
|
|
14
|
+
shadow: 3,
|
|
14
15
|
alignment: 2,
|
|
15
|
-
|
|
16
|
+
marginL: 120,
|
|
17
|
+
marginR: 120,
|
|
18
|
+
marginV: 140,
|
|
16
19
|
};
|
|
17
20
|
|
|
21
|
+
export const PORTRAIT_ASS_STYLE: AssStyle = {
|
|
22
|
+
...DEFAULT_ASS_STYLE,
|
|
23
|
+
playResX: 1080,
|
|
24
|
+
playResY: 1920,
|
|
25
|
+
fontSize: 72,
|
|
26
|
+
marginL: 90,
|
|
27
|
+
marginR: 200,
|
|
28
|
+
marginV: 460,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function resolveAssStyle(layoutPreset: SubtitleLayoutPreset, overrides: Partial<AssStyle> = {}): AssStyle {
|
|
32
|
+
const base = layoutPreset === "portrait" ? PORTRAIT_ASS_STYLE : DEFAULT_ASS_STYLE;
|
|
33
|
+
const expected = `${base.playResX}x${base.playResY}`;
|
|
34
|
+
if ((overrides.playResX !== undefined && overrides.playResX !== base.playResX)
|
|
35
|
+
|| (overrides.playResY !== undefined && overrides.playResY !== base.playResY)) {
|
|
36
|
+
throw new Error(`${layoutPreset} ASS style must use canonical ${expected} design coordinates`);
|
|
37
|
+
}
|
|
38
|
+
const resolved = { ...base, ...overrides };
|
|
39
|
+
const limits = layoutPreset === "portrait"
|
|
40
|
+
? { font: [60, 90], marginL: [60, 140], marginR: [160, 260], marginV: [430, 500] }
|
|
41
|
+
: { font: [64, 96], marginL: [90, 180], marginR: [90, 180], marginV: [120, 170] };
|
|
42
|
+
const within = (value: number, [minimum, maximum]: number[]) => Number.isFinite(value) && value >= minimum! && value <= maximum!;
|
|
43
|
+
if (!resolved.bold || resolved.alignment !== 2 || !within(resolved.fontSize, limits.font)
|
|
44
|
+
|| !within(resolved.marginL, limits.marginL) || !within(resolved.marginR, limits.marginR)
|
|
45
|
+
|| !within(resolved.marginV, limits.marginV) || !within(resolved.outline, [2, 5])
|
|
46
|
+
|| !within(resolved.shadow, [1, 5])) {
|
|
47
|
+
throw new Error(`${layoutPreset} ASS style must preserve the readable safe-area preset`);
|
|
48
|
+
}
|
|
49
|
+
return resolved;
|
|
50
|
+
}
|
|
51
|
+
|
|
18
52
|
function pad(value: number, width: number): string {
|
|
19
53
|
return String(value).padStart(width, "0");
|
|
20
54
|
}
|
|
@@ -61,7 +95,7 @@ function assText(value: string): string {
|
|
|
61
95
|
export function renderAss(cues: SubtitleCue[], style: Partial<AssStyle> = {}): string {
|
|
62
96
|
assertCues(cues);
|
|
63
97
|
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
|
|
98
|
+
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,${resolved.bold ? -1 : 0},0,0,0,100,100,0,0,1,${resolved.outline},${resolved.shadow},${resolved.alignment},${resolved.marginL},${resolved.marginR},${resolved.marginV},1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text`;
|
|
65
99
|
const events = cues.map((cue) => `Dialogue: 0,${assTime(cue.beginMs)},${assTime(cue.endMs)},Default,,0,0,0,,${assText(cue.text)}`);
|
|
66
100
|
return `${header}\n${events.join("\n")}\n`;
|
|
67
101
|
}
|
package/src/project.ts
CHANGED
|
@@ -8,11 +8,12 @@ import type {
|
|
|
8
8
|
SubtitleExportReceipt,
|
|
9
9
|
SubtitlePolicy,
|
|
10
10
|
SubtitleProject,
|
|
11
|
+
SubtitleLayoutPreset,
|
|
11
12
|
SubtitleSnapshot,
|
|
12
13
|
TimelineSegment,
|
|
13
14
|
TranscriptWord,
|
|
14
15
|
} from "./contracts.ts";
|
|
15
|
-
import { exportSubtitleTracks, renderSrt } from "./export.ts";
|
|
16
|
+
import { exportSubtitleTracks, renderSrt, resolveAssStyle } from "./export.ts";
|
|
16
17
|
import {
|
|
17
18
|
readWorkspaceJson,
|
|
18
19
|
resolveExistingWorkspaceFile,
|
|
@@ -32,6 +33,7 @@ export interface CreateSubtitleProjectInput {
|
|
|
32
33
|
projectId: string;
|
|
33
34
|
transcriptPath: string;
|
|
34
35
|
sourceDurationMs: number;
|
|
36
|
+
layoutPreset?: SubtitleLayoutPreset;
|
|
35
37
|
timelineSegments?: TimelineSegment[];
|
|
36
38
|
policy?: Partial<SubtitlePolicy>;
|
|
37
39
|
}
|
|
@@ -98,8 +100,14 @@ function validateSourceAndTimeline(sourceDurationMs: number, timelineSegments: T
|
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
function resolvedPolicy(input: Partial<SubtitlePolicy> | undefined): SubtitlePolicy {
|
|
102
|
-
|
|
103
|
+
function resolvedPolicy(layoutPreset: SubtitleLayoutPreset, input: Partial<SubtitlePolicy> | undefined): SubtitlePolicy {
|
|
104
|
+
if (layoutPreset === "landscape" && input?.maxLines !== undefined && input.maxLines !== 1) {
|
|
105
|
+
throw new Error("Landscape subtitles must use one line");
|
|
106
|
+
}
|
|
107
|
+
const layoutDefaults: Partial<SubtitlePolicy> = layoutPreset === "landscape"
|
|
108
|
+
? { maxLines: 1, targetMinChars: 8, targetMaxChars: 16 }
|
|
109
|
+
: { maxLines: 2 };
|
|
110
|
+
const policy = { ...DEFAULT_SUBTITLE_POLICY, ...layoutDefaults, ...input };
|
|
103
111
|
for (const [name, value] of Object.entries(policy)) {
|
|
104
112
|
if (!Number.isFinite(value) || value < 0) throw new Error(`Subtitle policy ${name} must be a finite non-negative number`);
|
|
105
113
|
}
|
|
@@ -148,7 +156,8 @@ export async function createSubtitleProject(cwd: string, input: CreateSubtitlePr
|
|
|
148
156
|
const segments = input.timelineSegments ?? [{ id: "a-1", sourceStartMs: 0, sourceEndMs: input.sourceDurationMs }];
|
|
149
157
|
validateSourceAndTimeline(input.sourceDurationMs, segments);
|
|
150
158
|
const durationMs = timelineDuration(segments);
|
|
151
|
-
const
|
|
159
|
+
const layoutPreset = input.layoutPreset ?? "landscape";
|
|
160
|
+
const policy = resolvedPolicy(layoutPreset, input.policy);
|
|
152
161
|
const cues = buildSubtitleCues(remapWordsToTimeline(transcriptWords(rawTranscript), segments), durationMs, policy);
|
|
153
162
|
if (cues.length === 0) throw new Error("Retained A-roll timeline contains no transcribed words");
|
|
154
163
|
const now = new Date().toISOString();
|
|
@@ -158,6 +167,7 @@ export async function createSubtitleProject(cwd: string, input: CreateSubtitlePr
|
|
|
158
167
|
createdAt: now,
|
|
159
168
|
updatedAt: now,
|
|
160
169
|
currentRevision: 1,
|
|
170
|
+
layoutPreset,
|
|
161
171
|
sourceDurationMs: input.sourceDurationMs,
|
|
162
172
|
timelineDurationMs: durationMs,
|
|
163
173
|
transcript,
|
|
@@ -200,10 +210,20 @@ export async function getSubtitleProject(cwd: string, projectId: string, revisio
|
|
|
200
210
|
return { project, snapshot: selectedSnapshot };
|
|
201
211
|
}
|
|
202
212
|
|
|
203
|
-
function
|
|
213
|
+
function visibleCharacters(value: string): number {
|
|
214
|
+
return [...value].filter((character) => !/\s/u.test(character) && !/\p{Cf}/u.test(character)).length;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function assertReviewedCues(cues: SubtitleCue[], project: SubtitleProject): void {
|
|
204
218
|
if (cues.length === 0 || cues.length > 10_000) throw new Error("cues must contain 1-10000 items");
|
|
205
219
|
renderSrt(cues);
|
|
206
|
-
if (cues.at(-1)!.endMs > timelineDurationMs) throw new Error("Subtitle cue exceeds the final timeline duration");
|
|
220
|
+
if (cues.at(-1)!.endMs > project.timelineDurationMs) throw new Error("Subtitle cue exceeds the final timeline duration");
|
|
221
|
+
if (project.layoutPreset === "landscape") {
|
|
222
|
+
if (cues.some((cue) => /[\r\n]/u.test(cue.text))) throw new Error("Landscape subtitles must use one line");
|
|
223
|
+
if (cues.some((cue) => visibleCharacters(cue.text) > project.policy.maxLineChars)) {
|
|
224
|
+
throw new Error(`Landscape subtitle exceeds ${project.policy.maxLineChars} visible characters`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
207
227
|
}
|
|
208
228
|
|
|
209
229
|
async function withProjectLock<T>(cwd: string, projectId: string, action: (directory: string) => Promise<T>): Promise<T> {
|
|
@@ -229,7 +249,7 @@ export async function applySubtitleRevision(cwd: string, input: ApplySubtitleRev
|
|
|
229
249
|
if (project.currentRevision !== input.expectedRevision) {
|
|
230
250
|
throw new Error(`Project ${input.projectId} expected revision ${input.expectedRevision} but current revision is ${project.currentRevision}`);
|
|
231
251
|
}
|
|
232
|
-
assertReviewedCues(input.cues, project
|
|
252
|
+
assertReviewedCues(input.cues, project);
|
|
233
253
|
const revision = project.currentRevision + 1;
|
|
234
254
|
const nextSnapshot = snapshot(input.projectId, revision, project.currentRevision, input.cues, input.changeReason.trim());
|
|
235
255
|
const snapshotPath = join(directory, `snapshots/${revision}.json`);
|
|
@@ -264,10 +284,13 @@ export async function exportSubtitleProject(cwd: string, input: {
|
|
|
264
284
|
}) {
|
|
265
285
|
const { project, snapshot: selectedSnapshot } = await getSubtitleProject(cwd, input.projectId, input.revision);
|
|
266
286
|
await assertTranscriptUnchanged(cwd, project);
|
|
287
|
+
const resolvedAssStyle = input.assPath
|
|
288
|
+
? resolveAssStyle(project.layoutPreset ?? "landscape", input.assStyle)
|
|
289
|
+
: undefined;
|
|
267
290
|
const tracks = await exportSubtitleTracks(cwd, selectedSnapshot.cues, {
|
|
268
291
|
srtPath: input.srtPath,
|
|
269
292
|
...(input.assPath ? { assPath: input.assPath } : {}),
|
|
270
|
-
...(
|
|
293
|
+
...(resolvedAssStyle ? { assStyle: resolvedAssStyle } : {}),
|
|
271
294
|
});
|
|
272
295
|
const exportId = randomUUID();
|
|
273
296
|
const receiptPath = projectRelative(input.projectId, `exports/${exportId}.json`);
|