@hypit/hypit 0.1.14 → 0.2.1

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.
Files changed (90) hide show
  1. package/README.md +1 -1
  2. package/dist/public/browser-capture.d.ts +9 -3
  3. package/dist/public/caption.d.ts +2 -0
  4. package/dist/public/narrative.d.ts +2 -0
  5. package/dist/public/runtime-kit.d.ts +2 -2
  6. package/dist/public/speech.d.ts +2 -0
  7. package/examples/provider-package/hypit.runtime.json +2 -2
  8. package/examples/semantic-composition/packages/responsive-explainer/README.md +1 -1
  9. package/package.json +2 -1
  10. package/packages/browser-capture/README.md +9 -2
  11. package/packages/browser-capture/package.json +5 -0
  12. package/packages/browser-capture/src/browser.ts +22 -8
  13. package/packages/browser-capture/src/index.ts +8 -2
  14. package/packages/caption/README.md +1 -1
  15. package/packages/caption/src/display.ts +1 -0
  16. package/packages/caption-fine/README.md +19 -5
  17. package/packages/caption-fine/package.json +1 -0
  18. package/packages/caption-fine/src/joined-box.ts +114 -0
  19. package/packages/caption-fine/src/manifest.ts +1 -1
  20. package/packages/caption-fine/src/render.ts +99 -92
  21. package/packages/caption-fine/src/spacing.ts +8 -61
  22. package/packages/caption-fine-studio/src/index.ts +2 -4
  23. package/packages/cli/README.md +3 -2
  24. package/packages/cli/src/arguments.ts +2 -2
  25. package/packages/cli/src/command.ts +1 -1
  26. package/packages/cli/src/commands/environment.ts +9 -2
  27. package/packages/cli/src/machine-view.ts +1 -1
  28. package/packages/cli/src/output.ts +1 -0
  29. package/packages/credential-store-file/README.md +3 -1
  30. package/packages/credential-store-os/README.md +4 -0
  31. package/packages/credential-store-os/src/store.ts +5 -1
  32. package/packages/credential-store-platform/README.md +62 -0
  33. package/packages/credential-store-platform/package.json +23 -0
  34. package/packages/credential-store-platform/src/activation.ts +39 -0
  35. package/packages/credential-store-platform/src/index.ts +1 -0
  36. package/packages/credential-store-platform/src/store.ts +89 -0
  37. package/packages/narrative/README.md +3 -1
  38. package/packages/narrative/src/schema.ts +1 -0
  39. package/packages/narrative/src/types.ts +2 -0
  40. package/packages/provider-whisperx-local/README.md +26 -10
  41. package/packages/provider-whisperx-local/src/activation.ts +10 -1
  42. package/packages/provider-whisperx-local/src/program.ts +8 -4
  43. package/packages/runtime-host-node/src/index.ts +1 -0
  44. package/packages/runtime-kit/README.md +6 -2
  45. package/packages/runtime-kit/src/index.ts +2 -2
  46. package/packages/runtime-local/README.md +6 -1
  47. package/packages/runtime-local/src/host.ts +2 -0
  48. package/packages/runtime-local/src/index.ts +1 -0
  49. package/packages/runtime-local/src/programs.ts +53 -17
  50. package/packages/script/README.md +81 -34
  51. package/packages/script/bin/migrate-0.2.mjs +90 -0
  52. package/packages/script/src/edit.ts +28 -60
  53. package/packages/script/src/lexical.ts +59 -130
  54. package/packages/script/src/manifest.ts +11 -11
  55. package/packages/script/src/narrative.ts +4 -2
  56. package/packages/script/src/parser.ts +140 -193
  57. package/packages/script/src/types.ts +2 -3
  58. package/packages/studio/src/ui/code.ts +1 -1
  59. package/packages/studio/src/ui/main.ts +2 -2
  60. package/packages/studio/src/ui/markers.ts +3 -3
  61. package/packages/studio/src/ui/selection.ts +1 -1
  62. package/packages/studio/src/ui/syntax.ts +42 -21
  63. package/packages/temporal-markup/README.md +1 -1
  64. package/packages/video-cli/README.md +9 -5
  65. package/packages/video-cli/src/capture.ts +17 -2
  66. package/packages/video-cli/src/creation.ts +4 -6
  67. package/packages/video-cli/src/media.ts +10 -3
  68. package/packages/whisperx/README.md +56 -3
  69. package/packages/whisperx/src/component.ts +2 -5
  70. package/packages/whisperx/src/evidence.ts +4 -3
  71. package/packages/whisperx/src/index.ts +1 -0
  72. package/packages/whisperx/src/manifest.ts +2 -3
  73. package/packages/whisperx/src/surface.ts +3 -6
  74. package/packages/whisperx/src/types.ts +9 -1
  75. package/packages/yt-dlp/README.md +9 -3
  76. package/packages/yt-dlp/package.json +2 -1
  77. package/packages/yt-dlp/src/download.ts +10 -24
  78. package/packages/yt-dlp/src/environment.ts +36 -0
  79. package/packages/yt-dlp/src/index.ts +1 -0
  80. package/services/whisperx/README.md +45 -12
  81. package/services/whisperx/src/hypit_whisperx_service/application.py +3 -0
  82. package/services/whisperx/src/hypit_whisperx_service/check.py +7 -0
  83. package/services/whisperx/src/hypit_whisperx_service/config.py +7 -0
  84. package/services/whisperx/src/hypit_whisperx_service/engine.py +21 -5
  85. package/services/whisperx/src/hypit_whisperx_service/models.py +131 -0
  86. package/services/whisperx/src/hypit_whisperx_service/prepare.py +21 -3
  87. package/services/whisperx/src/hypit_whisperx_service/resources.py +19 -3
  88. package/services/yt-dlp/README.md +14 -15
  89. package/services/yt-dlp/pyproject.toml +1 -1
  90. package/services/yt-dlp/uv.lock +513 -2
@@ -48,15 +48,15 @@ export const scriptMarkupSurfaces = [
48
48
  ],
49
49
  example: [
50
50
  '<script id="story">',
51
- " @whole",
51
+ " @{whole}",
52
52
  " <hook>",
53
- " <HOST> @problem Never let anyone take credit for your work. @/problem",
53
+ " <HOST> @{problem} Never let anyone take credit for your work. @{/problem}",
54
54
  " </hook>",
55
55
  "",
56
56
  " <meeting>",
57
- " <HOST> I started sending <BCC | B C C> recaps. @ranking! Everything changed.",
57
+ " <HOST> I started sending <BCC | B C C> recaps. @{ranking!} Everything changed.",
58
58
  " </meeting>",
59
- " @/whole~",
59
+ " @{/whole~}",
60
60
  "</script>",
61
61
  ].join("\n"),
62
62
  notes: [
@@ -67,16 +67,16 @@ export const scriptMarkupSurfaces = [
67
67
  "Dual Text is written `<display | speech>`: the left side reaches Caption and the right side reaches dialogue and speech. `<display|>` inherits speech from the displayed prose and forms the same complete alignment unit; its word times remain individual. `<|speech>` speaks without displaying. Both sides empty is invalid.",
68
68
  "Inside Dual Text, semantic markers belong to the source of spoken text: the explicit right side, or the shared left side when speech is omitted. Display attributes remain visual metadata and never enter spoken text.",
69
69
  "A flat token attribute follows a complete display token as `{name}` or `{name=value}`; multiple attributes use one comma-separated block. Attributes do not nest, do not carry timing, and never split a Dual Alignment Unit.",
70
- "Selection and Moment markers are zero-width, share one name namespace, and may not split a speech token:",
70
+ "Selection and Moment markers are fully enclosed in `@{...}` with all sigils inside. They are zero-width, share one name namespace, and may not split a speech token. Surrounding prose spaces remain content; do not add spaces to delimit a name:",
71
71
  [
72
72
  "| Marker | Meaning |",
73
73
  "|---|---|",
74
- "| `@id` | Opens a Selection at the next word's start |",
75
- "| `~@id` | Opens a Selection at the previous word's end |",
76
- "| `@/id` | Closes a Selection at the previous word's end |",
77
- "| `@/id~` | Closes a Selection at the next word's start |",
78
- "| `@id!` | A Moment at the next word's start |",
79
- "| `~@id!` | A Moment at the previous word's end |",
74
+ "| `@{id}` | Opens a Selection at the next word's start |",
75
+ "| `@{~id}` | Opens a Selection at the previous word's end |",
76
+ "| `@{/id}` | Closes a Selection at the previous word's end |",
77
+ "| `@{/id~}` | Closes a Selection at the next word's start |",
78
+ "| `@{id!}` | A Moment at the next word's start |",
79
+ "| `@{~id!}` | A Moment at the previous word's end |",
80
80
  ].join("\n"),
81
81
  "Each Selection name has one opening and one closing marker; use distinct names for distinct semantic ranges.",
82
82
  "`<!-- -->` comments never enter any projection, and `\\@`, `\\<`, `\\\\`, `\\{` and `\\}` write those characters literally; inside Dual Text `\\|` and `\\>` do the same.",
@@ -4,7 +4,7 @@ import type { CaptionAlignmentUnit, CaptionDocument, CaptionDisplayWord } from "
4
4
  import { sealText } from "@hypit/text";
5
5
 
6
6
  import type { ParsedCaptionRegion, ParsedNarrative } from "./types.js";
7
- import { cleanProjection, displayWordSurfaces, joinProjection, lexicalCount } from "./lexical.js";
7
+ import { cleanProjection, displaySurfaces, joinProjection, lexicalCount } from "./lexical.js";
8
8
 
9
9
  function turnForRegion(parsed: ParsedNarrative, region: ParsedCaptionRegion): ParsedNarrative["turns"][number] {
10
10
  const turn = parsed.turns.find((candidate) =>
@@ -21,7 +21,8 @@ function projectCaption(parsed: ParsedNarrative, id: string, narrativeId: string
21
21
  for (const region of parsed.captionProjection.regions) {
22
22
  if (region.kind === "hidden") continue;
23
23
  const turn = turnForRegion(parsed, region);
24
- const surfaces = displayWordSurfaces(region.display);
24
+ const display = displaySurfaces(region.display);
25
+ const surfaces = display.map(word => word.text);
25
26
  if (surfaces.length === 0) throw new Error(`Caption region ${region.id} contains no visible display surface`);
26
27
  // A Dual Text alias is one indivisible N:M correspondence unit. Ordinary prose gives one
27
28
  // unit per display surface so the author can place cue/style boundaries between words.
@@ -46,6 +47,7 @@ function projectCaption(parsed: ParsedNarrative, id: string, narrativeId: string
46
47
  turnId: turn.id,
47
48
  ...(turn.role === undefined ? {} : { role: turn.role }),
48
49
  text: surface,
50
+ separatorBefore: group.indices[groupIndex] === 0 ? region.separatorBefore : display[group.indices[groupIndex]!]!.separatorBefore,
49
51
  attributes,
50
52
  });
51
53
  return wordId;
@@ -2,11 +2,10 @@
2
2
  import { ScriptSyntaxError } from "./error.js";
3
3
  import {
4
4
  cleanProjection,
5
- displayWordSurfaces,
6
5
  joinProjection,
7
6
  lexicalUnits,
8
- lexicalEditRanges,
9
- splitLeadingClosingPunctuation,
7
+ analyzeProse,
8
+ splitDisplayPrefix,
10
9
  } from "./lexical.js";
11
10
  import type {
12
11
  CaptionWordAttribute,
@@ -37,8 +36,6 @@ type MutableSegment = {
37
36
  readonly tokenStart: number;
38
37
  readonly sourceStart: number;
39
38
  readonly contentStart: number;
40
- lexicalRun: string;
41
- readonly lexicalMarkers: Array<{ readonly position: number; readonly offset: number }>;
42
39
  };
43
40
 
44
41
  const SEGMENT_ID = /^[a-z][a-z0-9_-]{0,63}$/u;
@@ -49,7 +46,7 @@ const ATTRIBUTE_NAME = /^[a-z][a-z0-9_-]{0,63}$/u;
49
46
  const ATTRIBUTE_VALUE = /^[A-Za-z0-9_.:-]+$/u;
50
47
 
51
48
  /** A marker's structural position before its affinity picks one anchor. */
52
- type RawMarkerBoundary = Omit<MarkerBoundary, "anchorId">;
49
+ type RawMarkerBoundary = { -readonly [K in keyof Omit<MarkerBoundary, "anchorId">]: Omit<MarkerBoundary, "anchorId">[K] };
53
50
  type RawEdge<T> = Omit<T, "boundary"> & { readonly boundary: RawMarkerBoundary };
54
51
  type RawSelection = Omit<ParsedSelection, "startAnchorId" | "endAnchorId" | "open" | "close"> & {
55
52
  readonly open: RawEdge<ParsedSelection["open"]>;
@@ -65,33 +62,15 @@ function normalizeWord(value: string): string {
65
62
  }
66
63
 
67
64
  function parseMarker(source: string, offset: number): Marker | undefined {
68
- const markerName = String.raw`[a-z][a-z0-9_-]{0,63}`;
69
- const rest = source.slice(offset);
70
- const closeRight = new RegExp(String.raw`^@\/(${markerName})~`, "u").exec(rest);
71
- if (closeRight) {
72
- return { id: closeRight[1]!, kind: "close", affinity: "right", length: closeRight[0].length };
65
+ const match = /^@\{(~)?(\/)?([a-z][a-z0-9_-]{0,63})([!~])?\}/u.exec(source.slice(offset));
66
+ if (!match) return undefined;
67
+ const [, left, close, id, suffix] = match;
68
+ if (close) {
69
+ if (left || suffix === "!") return undefined;
70
+ return { id: id!, kind: "close", affinity: suffix === "~" ? "right" : "left", length: match[0].length };
73
71
  }
74
- const closeLeft = new RegExp(String.raw`^@\/(${markerName})(?![A-Za-z0-9_-])`, "u").exec(rest);
75
- if (closeLeft) {
76
- return { id: closeLeft[1]!, kind: "close", affinity: "left", length: closeLeft[0].length };
77
- }
78
- const moment = new RegExp(String.raw`^(~)?@(${markerName})!`, "u").exec(rest);
79
- if (moment) {
80
- return {
81
- id: moment[2]!,
82
- kind: "moment",
83
- affinity: moment[1] === "~" ? "left" : "right",
84
- length: moment[0].length,
85
- };
86
- }
87
- const open = new RegExp(String.raw`^(~)?@(${markerName})(?![A-Za-z0-9_-])`, "u").exec(rest);
88
- if (!open) return undefined;
89
- return {
90
- id: open[2]!,
91
- kind: "open",
92
- affinity: open[1] === "~" ? "left" : "right",
93
- length: open[0].length,
94
- };
72
+ if (suffix === "~") return undefined;
73
+ return { id: id!, kind: suffix === "!" ? "moment" : "open", affinity: left ? "left" : "right", length: match[0].length };
95
74
  }
96
75
 
97
76
  function segmentAnchorId(segmentId: string, edge: "start" | "end"): string {
@@ -142,8 +121,9 @@ export function parseScript(
142
121
 
143
122
  const segments: ParsedSegment[] = [];
144
123
  const tokens: ParsedToken[] = [];
145
- const proseRanges: Array<{ start: number; end: number }> = [];
146
124
  const captionRegions: ParsedCaptionRegion[] = [];
125
+ let precedingCaptionRegion: number | undefined;
126
+ let pendingCaptionText = "";
147
127
  const selections = new Map<string, RawSelection>();
148
128
  const moments = new Map<string, RawMoment>();
149
129
  const captionBreaks: Array<{ readonly tokenIndex: number; readonly range: { readonly start: number; readonly end: number } }> = [];
@@ -157,33 +137,64 @@ export function parseScript(
157
137
  let offset = 0;
158
138
  let structuralPosition = 0;
159
139
 
160
- const boundary = (): RawMarkerBoundary => ({
161
- tokenIndex: tokens.length,
162
- structuralPosition,
163
- ...(current ? { segmentId: current.id } : {}),
164
- });
140
+ // One prose run crosses annotations and comments. Only an explicit correspondence,
141
+ // speaker change or Segment edge ends it. Source positions stay local to parsing.
142
+ let runText = "";
143
+ let runCaption = "";
144
+ let runCapture = true;
145
+ let runStarts: number[] = [];
146
+ let runEnds: number[] = [];
147
+ let runBoundaries: Array<{ position: number; offset: number; kind: "marker" | "cue"; value: { tokenIndex: number } }> = [];
148
+ let runAttributes: Array<{ position: number; offset: number; end: number; attributes: readonly CaptionWordAttribute[] }> = [];
149
+
150
+ const boundary = (start: number): RawMarkerBoundary => {
151
+ const value = { tokenIndex: tokens.length, structuralPosition,
152
+ ...(current ? { segmentId: current.id } : {}) };
153
+ if (current) runBoundaries.push({ position: runText.length, offset: start, kind: "marker", value });
154
+ return value;
155
+ };
165
156
 
166
- const finishLexicalRun = (): void => {
167
- if (!current) return;
168
- for (const match of lexicalUnits(current.lexicalRun)) {
169
- const start = match.index;
170
- const end = start + match.text.length;
171
- const split = current.lexicalMarkers.find((marker) => start < marker.position && marker.position < end);
172
- if (split) {
173
- fail(
174
- "SCRIPT_MARKER_TOKEN_BOUNDARY",
175
- "A Selection or Moment marker cannot split a speech token.",
176
- split.offset,
177
- );
157
+ const finishLexicalRun = (): ParsedCaptionRegion["marks"] => {
158
+ if (!current) return [];
159
+ const { units, editRanges: ranges } = analyzeProse(runText, runCapture && precedingCaptionRegion !== undefined);
160
+ const tokenStart = tokens.length;
161
+ for (const event of runBoundaries) {
162
+ if (ranges.some(range => range.start < event.position && event.position < range.end)) {
163
+ fail(event.kind === "marker" ? "SCRIPT_MARKER_TOKEN_BOUNDARY" : "SCRIPT_CAPTION_BREAK_TOKEN_BOUNDARY",
164
+ "A semantic marker or Cue break cannot split a word and its attached punctuation.", event.offset);
165
+ }
166
+ event.value.tokenIndex = tokenStart + units.filter(unit => unit.index + unit.text.length <= event.position).length;
167
+ if (event.kind === "cue" && event.value.tokenIndex === current.tokenStart) {
168
+ fail("SCRIPT_CAPTION_BREAK_EMPTY", "Caption Cue break must follow visible Script text.", event.offset);
178
169
  }
179
170
  }
180
- current.lexicalRun = "";
181
- current.lexicalMarkers.length = 0;
171
+ if (units.length === 0 && runAttributes.length > 0) fail("SCRIPT_ATTRIBUTE_TARGET", "A spoken display attribute needs a complete word.", runAttributes[0]!.offset);
172
+ const marks = bindDisplayAttributes(runText, runAttributes, units);
173
+ for (const [ordinal, unit] of units.entries()) {
174
+ const index = tokens.length;
175
+ const segmentTokenIndex = index - current.tokenStart;
176
+ const range = ranges[ordinal]!;
177
+ const attachedAttributes = runAttributes.filter(attribute =>
178
+ attribute.position >= unit.index + unit.text.length && attribute.position <= range.end);
179
+ tokens.push({ id: tokenId(current.id, segmentTokenIndex), index, segmentId: current.id, segmentTokenIndex,
180
+ startAnchorId: tokenAnchorId(current.id, segmentTokenIndex, "start"),
181
+ endAnchorId: tokenAnchorId(current.id, segmentTokenIndex, "end"),
182
+ text: unit.text, normalized: normalizeWord(unit.text),
183
+ range: { start: runStarts[unit.index]!, end: runEnds[unit.index + unit.text.length - 1]! },
184
+ editRange: { start: runStarts[range.start]!, end: Math.max(runEnds[range.end - 1]!, ...attachedAttributes.map(attribute => sourceOffset + attribute.end)) },
185
+ });
186
+ }
187
+ if (runText || runCaption) {
188
+ const range = { start: runStarts[0]!, end: runEnds.at(-1)! };
189
+ current.atoms.push({ kind: "text", speech: runText, caption: runCaption, tokenStart, tokenEndExclusive: tokens.length, range });
190
+ if (runCapture) addCaptionRegion(runCaption, current.id, tokenStart, tokens.length, "identity", range, marks);
191
+ }
192
+ runText = ""; runCaption = ""; runStarts = []; runEnds = []; runBoundaries = []; runAttributes = [];
193
+ return marks;
182
194
  };
183
195
 
184
196
  const addMarker = (marker: Marker, start: number): void => {
185
197
  if (!TEMPORAL_ID.test(marker.id)) fail("SCRIPT_TEMPORAL_ID", `Invalid temporal id "${marker.id}".`, start);
186
- if (current) current.lexicalMarkers.push({ position: current.lexicalRun.length, offset: start });
187
198
  if (marker.kind === "moment") {
188
199
  if (openSelections.has(marker.id) || selections.has(marker.id)) {
189
200
  fail("SCRIPT_TEMPORAL_TYPE", `Temporal name "${marker.id}" cannot be both Selection and Moment.`, start);
@@ -194,7 +205,7 @@ export function parseScript(
194
205
  moments.set(marker.id, {
195
206
  id: marker.id,
196
207
  affinity: marker.affinity,
197
- boundary: boundary(),
208
+ boundary: boundary(start),
198
209
  range: { start: sourceOffset + start, end: sourceOffset + start + marker.length },
199
210
  });
200
211
  return;
@@ -211,7 +222,7 @@ export function parseScript(
211
222
  }
212
223
  openSelections.set(marker.id, {
213
224
  affinity: marker.affinity,
214
- boundary: boundary(),
225
+ boundary: boundary(start),
215
226
  start: sourceOffset + start,
216
227
  end: sourceOffset + start + marker.length,
217
228
  });
@@ -229,7 +240,7 @@ export function parseScript(
229
240
  },
230
241
  close: {
231
242
  affinity: marker.affinity,
232
- boundary: boundary(),
243
+ boundary: boundary(start),
233
244
  range: { start: sourceOffset + start, end: sourceOffset + start + marker.length },
234
245
  },
235
246
  });
@@ -261,6 +272,7 @@ export function parseScript(
261
272
  positions.push(absoluteStart + index);
262
273
  continue;
263
274
  }
275
+ if (dual && raw[index] === "<") fail("SCRIPT_DUAL_NESTED", "Dual Text cannot nest; escape a literal < as \\<.", absoluteStart + index);
264
276
  buffer += raw[index];
265
277
  index += 1;
266
278
  positions.push(absoluteStart + index);
@@ -301,13 +313,32 @@ export function parseScript(
301
313
  return { attributes, length: close + 1 };
302
314
  };
303
315
 
316
+ const bindDisplayAttributes = (
317
+ display: string,
318
+ annotations: readonly { position: number; offset: number; attributes: readonly CaptionWordAttribute[] }[],
319
+ units = lexicalUnits(display),
320
+ ): ParsedCaptionRegion["marks"] => {
321
+ const marks: Array<ParsedCaptionRegion["marks"][number]> = [];
322
+ for (const annotation of annotations) {
323
+ const displayIndex = units.length === 0
324
+ ? (display.trim() && annotation.position === display.trimEnd().length ? 0 : -1)
325
+ : units.findLastIndex(unit => unit.index + unit.text.length <= annotation.position);
326
+ if (displayIndex < 0 || units.some(unit => unit.index < annotation.position && annotation.position < unit.index + unit.text.length)) {
327
+ fail("SCRIPT_ATTRIBUTE_TARGET", "A display attribute must follow a complete word.", annotation.offset);
328
+ }
329
+ if (marks.some(mark => mark.displayIndex === displayIndex)) fail("SCRIPT_ATTRIBUTE_OVERLAP", "A display word cannot receive two separate attribute blocks.", annotation.offset);
330
+ marks.push({ displayIndex, attributes: annotation.attributes });
331
+ }
332
+ return marks;
333
+ };
334
+
304
335
  const parseMarkedDisplay = (
305
336
  raw: string,
306
337
  absoluteStart: number,
307
338
  ): { readonly display: string; readonly marks: readonly ParsedCaptionRegion["marks"][number][] } => {
308
339
  const parts: string[] = [];
309
- const marks: ParsedCaptionRegion["marks"][number][] = [];
310
340
  let cursor = 0;
341
+ const attributePositions: Array<{ position: number; offset: number; attributes: readonly CaptionWordAttribute[] }> = [];
311
342
  for (let index = 0; index < raw.length; index += 1) {
312
343
  if (raw[index] === "\\") {
313
344
  index += 1;
@@ -319,21 +350,15 @@ export function parseScript(
319
350
  if (!before || /\s$/u.test(before)) {
320
351
  fail("SCRIPT_ATTRIBUTE_TARGET", "A token attribute must immediately follow a display token.", absoluteStart + index);
321
352
  }
322
- const cleanBefore = literalString(before, absoluteStart, true);
323
- const displayIndex = displayWordSurfaces(cleanBefore).length - 1;
324
- if (displayIndex < 0) {
325
- fail("SCRIPT_ATTRIBUTE_TARGET", "A token attribute must follow a visible display token.", absoluteStart + index);
326
- }
327
- if (marks.some((mark) => mark.displayIndex === displayIndex)) {
328
- fail("SCRIPT_ATTRIBUTE_OVERLAP", "A display token cannot receive two separate attribute blocks.", absoluteStart + index);
329
- }
353
+ const cleanBefore = parts.join("") + literalString(raw.slice(cursor, index), absoluteStart + cursor, true);
330
354
  parts.push(literalString(raw.slice(cursor, index), absoluteStart + cursor, true));
331
- marks.push({ displayIndex, attributes: block.attributes });
355
+ attributePositions.push({ position: cleanBefore.length, offset: absoluteStart + index, attributes: block.attributes });
332
356
  cursor = index + block.length;
333
357
  index += block.length - 1;
334
358
  }
335
359
  parts.push(literalString(raw.slice(cursor), absoluteStart + cursor, true));
336
- return { display: parts.join(""), marks };
360
+ const display = parts.join("");
361
+ return { display, marks: bindDisplayAttributes(display, attributePositions) };
337
362
  };
338
363
 
339
364
  const assertDualDisplayLiteral = (raw: string, absoluteStart: number): void => {
@@ -365,6 +390,7 @@ export function parseScript(
365
390
  captionRegions.push({
366
391
  id: `caption-region:${captionRegions.length + 1}`,
367
392
  display: "",
393
+ separatorBefore: "",
368
394
  segmentId,
369
395
  startToken: tokenStart,
370
396
  endTokenExclusive: tokenEndExclusive,
@@ -375,21 +401,30 @@ export function parseScript(
375
401
  }
376
402
  return;
377
403
  }
378
- const split = splitLeadingClosingPunctuation(displayValue);
379
- const previous = captionRegions.at(-1);
380
- const canAttachPrevious = previous !== undefined && previous.segmentId === segmentId;
381
- if (split.previous && canAttachPrevious) {
382
- captionRegions[captionRegions.length - 1] = {
404
+ // Dual Text edge whitespace is syntax padding; ordinary prose whitespace is content.
405
+ const raw = kind === "alias" ? cleanProjection(displayValue) : displayValue;
406
+ const value = (pendingCaptionText + raw).replace(/\s+/gu, " ");
407
+ const split = splitDisplayPrefix(value, precedingCaptionRegion !== undefined);
408
+ const previous = precedingCaptionRegion === undefined ? undefined : captionRegions[precedingCaptionRegion];
409
+ if (previous && split.previous.trim()) {
410
+ captionRegions[precedingCaptionRegion!] = {
383
411
  ...previous,
384
- display: cleanProjection(`${previous.display}${split.previous}`),
385
- range: { start: previous.range.start, end: range.end },
412
+ display: previous.display + split.previous.trimEnd(),
386
413
  };
387
414
  }
388
- const display = canAttachPrevious ? split.current : `${split.previous}${split.current}`;
389
- if (!display || tokenEndExclusive <= tokenStart) return;
415
+ pendingCaptionText = /\s$/u.test(value) ? " " : "";
416
+ const display = (previous ? split.current : split.previous + split.current).trim();
417
+ if (tokenEndExclusive <= tokenStart) {
418
+ // A literal opener outside an explicit correspondence awaits that display,
419
+ // while closing punctuation has already been attached to the preceding one.
420
+ pendingCaptionText = previous ? split.current || pendingCaptionText : value;
421
+ return;
422
+ }
423
+ if (!display) return;
390
424
  captionRegions.push({
391
425
  id: `caption-region:${captionRegions.length + 1}`,
392
426
  display,
427
+ separatorBefore: previous && /\s$/u.test(split.previous) ? " " : "",
393
428
  segmentId,
394
429
  startToken: tokenStart,
395
430
  endTokenExclusive: tokenEndExclusive,
@@ -397,102 +432,27 @@ export function parseScript(
397
432
  marks,
398
433
  range,
399
434
  });
400
- };
401
-
402
- const markLastCaptionSurface = (
403
- attributes: readonly CaptionWordAttribute[],
404
- offset: number,
405
- ): void => {
406
- const previous = captionRegions.at(-1);
407
- if (previous === undefined || previous.kind === "hidden") {
408
- fail("SCRIPT_ATTRIBUTE_TARGET", "A token attribute must follow a visible display token.", offset);
409
- }
410
- const target = previous!;
411
- if (target.kind === "alias") {
412
- fail("SCRIPT_ATTRIBUTE_DUAL", "Annotate a Dual display word before the pipe; a mark cannot wrap a Dual Text.", offset);
413
- }
414
- const displayIndex = displayWordSurfaces(target.display).length - 1;
415
- if (displayIndex < 0) fail("SCRIPT_ATTRIBUTE_TARGET", "A token attribute must follow a visible display token.", offset);
416
- const existing = target.marks.find((mark) => mark.displayIndex === displayIndex);
417
- if (existing !== undefined) {
418
- fail("SCRIPT_ATTRIBUTE_OVERLAP", "A display token cannot receive two separate attribute blocks.", offset);
419
- }
420
- captionRegions[captionRegions.length - 1] = {
421
- ...target,
422
- marks: [...target.marks, { displayIndex, attributes }],
423
- };
435
+ precedingCaptionRegion = captionRegions.length - 1;
424
436
  };
425
437
 
426
438
  const addText = (
427
439
  speech: string,
428
440
  caption: string,
429
441
  start: number,
430
- end: number,
442
+ _end: number,
431
443
  captureCaptionRegion = true,
432
444
  sourcePositions?: readonly number[],
433
445
  ): void => {
434
446
  if (!current) {
435
- if (speech.trim() || caption.trim()) {
436
- fail("SCRIPT_TEXT_OUTSIDE_SEGMENT", "Natural-language text is only allowed inside a named Segment.", start);
437
- }
447
+ if (speech.trim() || caption.trim()) fail("SCRIPT_TEXT_OUTSIDE_SEGMENT", "Natural-language text is only allowed inside a named Segment.", start);
438
448
  return;
439
449
  }
440
- current.lexicalRun += speech;
441
- const tokenStart = tokens.length;
442
- const editRanges = lexicalEditRanges(speech);
443
- const units = lexicalUnits(speech);
444
- // Decoded characters may occupy more than one source character (for example \@).
445
- const position = (index: number): number => sourceOffset + (sourcePositions?.[index] ?? start + index);
446
- // A punctuation-only piece can follow a display attribute or escaped source piece.
447
- const preceding = tokens.at(-1);
448
- const closing = splitLeadingClosingPunctuation(speech).previous;
449
- if (preceding?.segmentId === current.id && preceding.editRange.end === sourceOffset + start && closing && speech.startsWith(closing)) {
450
- tokens[tokens.length - 1] = { ...preceding, editRange: {
451
- start: preceding.editRange.start, end: position(closing.length),
452
- } };
453
- }
454
- for (const [unitIndex, match] of units.entries()) {
455
- const normalized = normalizeWord(match.text);
456
- if (!normalized) continue;
457
- const index = tokens.length;
458
- const segmentTokenIndex = index - current.tokenStart;
459
- const id = tokenId(current.id, segmentTokenIndex);
460
- tokens.push({
461
- id,
462
- index,
463
- segmentId: current.id,
464
- segmentTokenIndex,
465
- startAnchorId: tokenAnchorId(current.id, segmentTokenIndex, "start"),
466
- endAnchorId: tokenAnchorId(current.id, segmentTokenIndex, "end"),
467
- text: match.text,
468
- normalized,
469
- range: {
470
- start: position(match.index),
471
- end: position(match.index + match.text.length),
472
- },
473
- editRange: {
474
- start: position(editRanges[unitIndex]!.start),
475
- end: position(editRanges[unitIndex]!.end),
476
- },
477
- });
478
- }
479
- current.atoms.push({
480
- kind: "text",
481
- speech,
482
- caption,
483
- tokenStart,
484
- tokenEndExclusive: tokens.length,
485
- range: { start: sourceOffset + start, end: sourceOffset + end },
486
- });
487
- if (captureCaptionRegion && cleanProjection(caption)) {
488
- addCaptionRegion(
489
- caption,
490
- current.id,
491
- tokenStart,
492
- tokens.length,
493
- "identity",
494
- { start: sourceOffset + start, end: sourceOffset + end },
495
- );
450
+ runCapture = captureCaptionRegion;
451
+ runText += speech;
452
+ runCaption += caption;
453
+ for (let index = 0; index < speech.length; index++) {
454
+ runStarts.push(sourceOffset + (sourcePositions?.[index] ?? start + index));
455
+ runEnds.push(sourceOffset + (sourcePositions?.[index + 1] ?? start + index + 1));
496
456
  }
497
457
  };
498
458
 
@@ -502,12 +462,15 @@ export function parseScript(
502
462
  caption: string | undefined,
503
463
  marks: ParsedCaptionRegion["marks"] = [],
504
464
  ): void => {
465
+ const leading = /^\s*/u.exec(raw)![0].length;
466
+ raw = raw.trim();
467
+ absoluteStart += leading;
468
+ if (caption !== undefined) caption = caption.trim();
505
469
  const startToken = tokens.length;
506
470
  // An omitted speech side shares the written display source. Keep its original offsets:
507
471
  // marker edits target these words, not a synthetic copy beyond the pipe.
508
472
  const shared = caption === undefined;
509
473
  const spokenParts: string[] = [];
510
- const sharedMarks: Array<ParsedCaptionRegion["marks"][number]> = [];
511
474
  let partStart = 0;
512
475
  let index = 0;
513
476
  let emittedCaption = false;
@@ -526,6 +489,7 @@ export function parseScript(
526
489
  if (raw.startsWith("||", index)) {
527
490
  fail("SCRIPT_CAPTION_BREAK_DUAL", "Caption Cue break cannot occur inside Dual Text; split the Dual Text into separate units.", absoluteStart + index);
528
491
  }
492
+ if (!shared && raw[index] === "{") fail("SCRIPT_DUAL_SPEECH_ATTRIBUTE", "Display attributes belong to the display side; escape literal braces in speech.", absoluteStart + index);
529
493
  if (shared && raw[index] === "{") {
530
494
  const before = raw.slice(partStart, index);
531
495
  if (!before || /\s$/u.test(before)) {
@@ -533,25 +497,15 @@ export function parseScript(
533
497
  }
534
498
  addLiteral(before, absoluteStart + partStart);
535
499
  const block = parseAttributeBlock(raw.slice(index), absoluteStart + index);
536
- const displayIndex = displayWordSurfaces(spokenParts.join("")).length - 1;
537
- if (displayIndex < 0 || tokens.length === startToken) {
538
- fail("SCRIPT_ATTRIBUTE_TARGET", "A token attribute must follow a visible display token.", absoluteStart + index);
539
- }
540
- if (sharedMarks.some(mark => mark.displayIndex === displayIndex)) {
541
- fail("SCRIPT_ATTRIBUTE_OVERLAP", "A display token cannot receive two separate attribute blocks.", absoluteStart + index);
542
- }
543
- sharedMarks.push({ displayIndex, attributes: block.attributes });
544
- const token = tokens.at(-1)!;
545
- tokens[tokens.length - 1] = { ...token, editRange: {
546
- start: token.editRange.start, end: sourceOffset + absoluteStart + index + block.length,
547
- } };
500
+ runAttributes.push({ position: runText.length, offset: absoluteStart + index,
501
+ end: absoluteStart + index + block.length, attributes: block.attributes });
548
502
  index += block.length;
549
503
  partStart = index;
550
504
  continue;
551
505
  }
552
506
  const marker = parseMarker(raw, index);
553
507
  if (!marker) {
554
- if (raw[index] === "@") fail("SCRIPT_MARKER", "Unescaped @ must begin a valid marker.", absoluteStart + index);
508
+ if (raw[index] === "@") fail("SCRIPT_MARKER", "Unescaped @ must begin a delimited @{...} marker; migrate bare markers from 0.1 explicitly.", absoluteStart + index);
555
509
  index += 1;
556
510
  continue;
557
511
  }
@@ -562,6 +516,7 @@ export function parseScript(
562
516
  }
563
517
  addLiteral(raw.slice(partStart), absoluteStart + partStart);
564
518
  const display = caption ?? spokenParts.join("");
519
+ const sharedMarks = finishLexicalRun();
565
520
  if (shared && tokens.length === startToken) {
566
521
  fail("SCRIPT_DUAL_EMPTY", "Dual Text with omitted speech must contain spoken text on its display side.", absoluteStart);
567
522
  }
@@ -600,7 +555,6 @@ export function parseScript(
600
555
 
601
556
  while (offset < source.length) {
602
557
  if (source.startsWith("<!--", offset)) {
603
- finishLexicalRun();
604
558
  const end = source.indexOf("-->", offset + 4);
605
559
  if (end < 0) fail("SCRIPT_COMMENT", "Unclosed Script comment.", offset);
606
560
  offset = end + 3;
@@ -619,6 +573,8 @@ export function parseScript(
619
573
  fail("SCRIPT_SEGMENT_DUPLICATE", `Duplicate Segment id "${id}".`, offset);
620
574
  }
621
575
  const contentStart = offset + open[0].length;
576
+ precedingCaptionRegion = undefined;
577
+ pendingCaptionText = "";
622
578
  current = {
623
579
  id,
624
580
  index: segments.length,
@@ -626,8 +582,6 @@ export function parseScript(
626
582
  tokenStart: tokens.length,
627
583
  sourceStart: sourceOffset + offset,
628
584
  contentStart: sourceOffset + contentStart,
629
- lexicalRun: "",
630
- lexicalMarkers: [],
631
585
  };
632
586
  offset = contentStart;
633
587
  if (self) closeCurrent(offset, true);
@@ -660,7 +614,7 @@ export function parseScript(
660
614
  offset += marker.length;
661
615
  continue;
662
616
  }
663
- if (source[offset] === "@") fail("SCRIPT_MARKER", "Unescaped @ must begin a valid marker.", offset);
617
+ if (source[offset] === "@") fail("SCRIPT_MARKER", "Unescaped @ must begin a delimited @{...} marker; migrate bare markers from 0.1 explicitly.", offset);
664
618
 
665
619
  if (source[offset] === "<" && current) {
666
620
  const end = findUnescaped(source, ">", offset + 1);
@@ -679,7 +633,7 @@ export function parseScript(
679
633
  assertDualDisplayLiteral(inside.slice(0, pipe), offset + 1);
680
634
  const markedDisplay = parseMarkedDisplay(inside.slice(0, pipe), offset + 1);
681
635
  const display = markedDisplay.display;
682
- if (!speech.replace(/~?@\/?[A-Za-z_][A-Za-z0-9_.-]*!?~?/gu, "").trim()) {
636
+ if (!speech.replace(/@\{[^{}]*\}/gu, "").trim()) {
683
637
  fail("SCRIPT_DUAL_EMPTY", "Dual Text speech side must not be empty.", offset);
684
638
  }
685
639
  consumeSpeechSide(speech, offset + pipe + 2, display, markedDisplay.marks);
@@ -689,6 +643,8 @@ export function parseScript(
689
643
  }
690
644
  if (!ROLE_LABEL.test(inside)) fail("SCRIPT_ANGLE", `Unknown Script construct <${inside}>.`, offset);
691
645
  finishLexicalRun();
646
+ precedingCaptionRegion = undefined;
647
+ pendingCaptionText = "";
692
648
  current.atoms.push({
693
649
  kind: "role",
694
650
  label: inside,
@@ -715,29 +671,21 @@ export function parseScript(
715
671
  offset += 1;
716
672
  }
717
673
  const raw = source.slice(textStart, offset);
718
- if (raw) proseRanges.push({ start: sourceOffset + textStart, end: sourceOffset + offset });
719
674
  for (const piece of literalPieces(raw, textStart)) addText(piece.value, piece.value, piece.start, piece.end, true, piece.positions);
720
675
  if (source[offset] === "{") {
721
676
  const block = parseAttributeBlock(source.slice(offset), offset);
722
677
  if (!raw || /\s$/u.test(raw)) {
723
678
  fail("SCRIPT_ATTRIBUTE_TARGET", "A token attribute must immediately follow a display token.", offset);
724
679
  }
725
- markLastCaptionSurface(block.attributes, offset);
726
- const token = tokens.at(-1)!;
727
- tokens[tokens.length - 1] = { ...token, editRange: {
728
- start: token.editRange.start, end: sourceOffset + offset + block.length,
729
- } };
680
+ runAttributes.push({ position: runText.length, offset, end: offset + block.length, attributes: block.attributes });
730
681
  offset += block.length;
731
682
  continue;
732
683
  }
733
684
  if (source.startsWith("||", offset)) {
734
- if (!current || tokens.length === current.tokenStart) {
735
- fail("SCRIPT_CAPTION_BREAK_EMPTY", "Caption Cue break must follow visible Script text.", offset);
736
- }
737
- captionBreaks.push({
738
- tokenIndex: tokens.length,
739
- range: { start: sourceOffset + offset, end: sourceOffset + offset + 2 },
740
- });
685
+ if (!current) fail("SCRIPT_CAPTION_BREAK_EMPTY", "Caption Cue break belongs inside a Segment.", offset);
686
+ const value = { tokenIndex: tokens.length, range: { start: sourceOffset + offset, end: sourceOffset + offset + 2 } };
687
+ captionBreaks.push(value);
688
+ runBoundaries.push({ position: runText.length, offset, kind: "cue", value });
741
689
  offset += 2;
742
690
  }
743
691
  }
@@ -881,7 +829,6 @@ export function parseScript(
881
829
  return {
882
830
 
883
831
  sourceRange: { start: sourceOffset, end: sourceOffset + source.length },
884
- proseRanges,
885
832
  segments,
886
833
  tokens,
887
834
  turns,