@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 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;可选带样式的 ASS;两者都不覆盖已有文件。
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 应结合实际音频检查文本、术语、标点、语义分组、阅读速度和两行布局,并继续翻页直到 `hasMore: false`。
86
+ 自动结果只是初稿。Agent 应结合实际音频检查文本、术语、标点、语义分组、阅读速度和画幅布局,并继续翻页直到 `hasMore: false`。
86
87
 
87
- 默认策略适合中文口播:每行最多 16 个可见字符、最多两行;目标 1220 字、1.5–4.5 秒、约 9 字/秒。它们用于找更自然的解,不要求每条字幕机械一致。极端时间戳冲突只会局部放宽,不会让整段字幕坍缩为一条或退化成逐字闪烁。
88
+ `layoutPreset` 默认为 `landscape`:横屏字幕严格单行,每条最多 16 个可见字符,目标 816 字。`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: "这是修正后的\n第一条字幕。" }
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
- 工具会返回 SRT / ASS 文件引用、SHA-256 和持久化导出回执。默认 ASS 字体是 `Noto Sans CJK SC`;实际渲染环境需要自行安装或嵌入该字体。
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.1.0`。发布工作流会在标签快照上重新执行检查并通过 npm OIDC 发布。
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 without rendering video.",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@speclip/pi-subtitles",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Agent-reviewed subtitle timing, layout, and standard track export for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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, 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.
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 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.
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
- ## Default talking-head policy
13
+ ## Talking-head layout policy
14
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.
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
- 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.
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 > (policy.maxLineChars + 2) * policy.maxLines || durationMs > policy.maxDurationMs)) break;
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: 54,
9
+ fontSize: 80,
10
10
  primaryColour: "&H00FFFFFF",
11
11
  outlineColour: "&H00000000",
12
+ bold: true,
12
13
  outline: 3,
13
- shadow: 0,
14
+ shadow: 3,
14
15
  alignment: 2,
15
- marginV: 90,
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,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`;
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
- const policy = { ...DEFAULT_SUBTITLE_POLICY, ...input };
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 policy = resolvedPolicy(input.policy);
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 assertReviewedCues(cues: SubtitleCue[], timelineDurationMs: number): void {
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.timelineDurationMs);
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
- ...(input.assStyle ? { assStyle: input.assStyle } : {}),
293
+ ...(resolvedAssStyle ? { assStyle: resolvedAssStyle } : {}),
271
294
  });
272
295
  const exportId = randomUUID();
273
296
  const receiptPath = projectRelative(input.projectId, `exports/${exportId}.json`);