@hypit/hypit 0.1.14 → 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.
Files changed (82) hide show
  1. package/README.md +2 -2
  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/semantic-composition/packages/responsive-explainer/README.md +1 -1
  8. package/package.json +1 -1
  9. package/packages/browser-capture/README.md +9 -2
  10. package/packages/browser-capture/package.json +5 -0
  11. package/packages/browser-capture/src/browser.ts +22 -8
  12. package/packages/browser-capture/src/index.ts +8 -2
  13. package/packages/caption/README.md +1 -1
  14. package/packages/caption/src/display.ts +1 -0
  15. package/packages/caption-fine/README.md +19 -5
  16. package/packages/caption-fine/package.json +1 -0
  17. package/packages/caption-fine/src/joined-box.ts +114 -0
  18. package/packages/caption-fine/src/manifest.ts +1 -1
  19. package/packages/caption-fine/src/render.ts +99 -92
  20. package/packages/caption-fine/src/spacing.ts +8 -61
  21. package/packages/caption-fine-studio/src/index.ts +2 -4
  22. package/packages/cli/README.md +3 -2
  23. package/packages/cli/src/arguments.ts +2 -2
  24. package/packages/cli/src/command.ts +1 -1
  25. package/packages/cli/src/commands/environment.ts +9 -2
  26. package/packages/cli/src/machine-view.ts +1 -1
  27. package/packages/cli/src/output.ts +1 -0
  28. package/packages/credential-store-os/src/store.ts +5 -1
  29. package/packages/narrative/README.md +3 -1
  30. package/packages/narrative/src/schema.ts +1 -0
  31. package/packages/narrative/src/types.ts +2 -0
  32. package/packages/provider-whisperx-local/README.md +26 -10
  33. package/packages/provider-whisperx-local/src/activation.ts +10 -1
  34. package/packages/provider-whisperx-local/src/program.ts +8 -4
  35. package/packages/runtime-host-node/src/index.ts +1 -0
  36. package/packages/runtime-kit/README.md +6 -2
  37. package/packages/runtime-kit/src/index.ts +2 -2
  38. package/packages/runtime-local/README.md +6 -1
  39. package/packages/runtime-local/src/host.ts +2 -0
  40. package/packages/runtime-local/src/index.ts +1 -0
  41. package/packages/runtime-local/src/programs.ts +53 -17
  42. package/packages/script/README.md +81 -34
  43. package/packages/script/bin/migrate-0.2.mjs +90 -0
  44. package/packages/script/src/edit.ts +28 -60
  45. package/packages/script/src/lexical.ts +59 -130
  46. package/packages/script/src/manifest.ts +11 -11
  47. package/packages/script/src/narrative.ts +4 -2
  48. package/packages/script/src/parser.ts +140 -193
  49. package/packages/script/src/types.ts +2 -3
  50. package/packages/studio/src/ui/code.ts +1 -1
  51. package/packages/studio/src/ui/main.ts +2 -2
  52. package/packages/studio/src/ui/markers.ts +3 -3
  53. package/packages/studio/src/ui/selection.ts +1 -1
  54. package/packages/studio/src/ui/syntax.ts +42 -21
  55. package/packages/temporal-markup/README.md +1 -1
  56. package/packages/video-cli/README.md +9 -5
  57. package/packages/video-cli/src/capture.ts +17 -2
  58. package/packages/video-cli/src/creation.ts +4 -6
  59. package/packages/video-cli/src/media.ts +10 -3
  60. package/packages/whisperx/README.md +56 -3
  61. package/packages/whisperx/src/component.ts +2 -5
  62. package/packages/whisperx/src/evidence.ts +4 -3
  63. package/packages/whisperx/src/index.ts +1 -0
  64. package/packages/whisperx/src/manifest.ts +2 -3
  65. package/packages/whisperx/src/surface.ts +3 -6
  66. package/packages/whisperx/src/types.ts +9 -1
  67. package/packages/yt-dlp/README.md +9 -3
  68. package/packages/yt-dlp/package.json +2 -1
  69. package/packages/yt-dlp/src/download.ts +10 -24
  70. package/packages/yt-dlp/src/environment.ts +36 -0
  71. package/packages/yt-dlp/src/index.ts +1 -0
  72. package/services/whisperx/README.md +45 -12
  73. package/services/whisperx/src/hypit_whisperx_service/application.py +3 -0
  74. package/services/whisperx/src/hypit_whisperx_service/check.py +7 -0
  75. package/services/whisperx/src/hypit_whisperx_service/config.py +7 -0
  76. package/services/whisperx/src/hypit_whisperx_service/engine.py +21 -5
  77. package/services/whisperx/src/hypit_whisperx_service/models.py +131 -0
  78. package/services/whisperx/src/hypit_whisperx_service/prepare.py +21 -3
  79. package/services/whisperx/src/hypit_whisperx_service/resources.py +19 -3
  80. package/services/yt-dlp/README.md +14 -15
  81. package/services/yt-dlp/pyproject.toml +1 -1
  82. package/services/yt-dlp/uv.lock +513 -2
@@ -3,7 +3,6 @@ import type { SourceRange } from "@hypit/protocol";
3
3
  import { canonicalStringify } from "@hypit/protocol";
4
4
  import { captionDocument, narrativeValue } from "./narrative.js";
5
5
  import { parseScript } from "./parser.js";
6
- import { cleanHorizontalProse } from "./lexical.js";
7
6
  import type { Affinity, ParsedNarrative, SemanticAnchor } from "./types.js";
8
7
 
9
8
  export type ScriptAnchorEditSite = {
@@ -75,8 +74,8 @@ export function scriptAnchorEditSites(parsed: ParsedNarrative): readonly ScriptA
75
74
  }
76
75
 
77
76
  function marker(id: string, edge: "open" | "close", affinity: Affinity): string {
78
- if (edge === "open") return affinity === "left" ? `~@${id}` : `@${id}`;
79
- return affinity === "left" ? `@/${id}` : `@/${id}~`;
77
+ if (edge === "open") return `@{${affinity === "left" ? "~" : ""}${id}}`;
78
+ return `@{/${id}${affinity === "right" ? "~" : ""}}`;
80
79
  }
81
80
 
82
81
  function applyEdits(source: string, edits: readonly Edit[]): string {
@@ -100,61 +99,32 @@ type AdjustmentInput = {
100
99
 
101
100
  type NamedAnchor = { readonly id: string; readonly edge: "open" | "close" | "moment"; readonly anchorId: string };
102
101
 
103
- /** Normalize only parser-owned prose. Indentation is read from this edit's input, never stored. */
104
- function normalizedProse(source: string, parsed: ParsedNarrative, original: string): string {
105
- const bodyStarts = new Set(parsed.segments.flatMap(segment => [segment.contentRange.start,
106
- ...segment.atoms.filter(atom => atom.kind === "role").map(atom => atom.range.end)]));
107
- const bodyEnds = new Set(parsed.segments.map(segment => segment.contentRange.end));
108
- const indentation = original.split(/\r\n|\r|\n/u).map(line => /^[ \t]*/u.exec(line)![0].length);
109
- const lines: Array<{ start: number; end: number; indentEnd: number }> = [];
110
- let offset = 0;
111
- const parts = source.split(/(\r\n|\r|\n)/u);
112
- for (let index = 0; index < parts.length; index += 2) {
113
- const text = parts[index]!;
114
- lines.push({ start: offset, end: offset + text.length, indentEnd: offset + indentation[index / 2]! });
115
- offset += text.length + (parts[index + 1]?.length ?? 0);
116
- }
117
- const edits: Edit[] = [];
118
- for (const range of parsed.proseRanges) for (const line of lines) {
119
- const start = Math.max(range.start, line.indentEnd);
120
- const end = Math.min(range.end, line.end);
121
- if (start >= end) continue;
122
- let text = cleanHorizontalProse(source.slice(start, end));
123
- const outside = !parsed.segments.some(segment => range.start >= segment.contentRange.start && range.end <= segment.contentRange.end);
124
- if (start === line.indentEnd || bodyStarts.has(start) || outside) text = text.replace(/^[ \t]+/u, "");
125
- if (end === line.end || bodyEnds.has(end) || outside) text = text.replace(/[ \t]+$/u, "");
126
- edits.push({ range: { start, end }, replacement: text });
127
- }
128
- return applyEdits(source, edits);
129
- }
130
-
131
- /** One canonical boundary spelling, independent of the order of previous gestures. */
102
+ /** Move only the requested markers. Delimited names never require editing prose separators. */
132
103
  function rewrite(input: AdjustmentInput, markers: readonly NamedAnchor[]): string {
133
- const ranges = [
134
- ...input.parsed.moments.map((item) => item.range),
135
- ...input.parsed.selections.flatMap((item) => [item.open.range, item.close.range]),
136
- ].map((range) => ({ start: range.start - input.parsed.sourceRange.start, end: range.end - input.parsed.sourceRange.start }))
137
- .sort((a, b) => a.start - b.start);
138
- const edits: Edit[] = ranges.map(range => ({ range, replacement: "" }));
104
+ const original = namedAnchors(input.parsed);
105
+ const changedNames = new Set(markers.filter(value => original.find(item => item.id === value.id && item.edge === value.edge)?.anchorId !== value.anchorId).map(value => value.id));
106
+ const moved = markers.filter(value => changedNames.has(value.id));
107
+ if (moved.length === 0) return input.source;
108
+ const origin = input.parsed.sourceRange.start;
109
+ const edits: Edit[] = moved.map(value => {
110
+ const range = value.edge === "moment"
111
+ ? input.parsed.moments.find(item => item.id === value.id)!.range
112
+ : input.parsed.selections.find(item => item.id === value.id)![value.edge].range;
113
+ return { range: { start: range.start - origin, end: range.end - origin }, replacement: "" };
114
+ });
139
115
  for (const segment of input.parsed.segments) {
140
- if (segment.selfClosing) edits.push({ range: { start: segment.range.start - input.parsed.sourceRange.start, end: segment.range.end - input.parsed.sourceRange.start }, replacement: `<${segment.id}></${segment.id}>` });
116
+ if (segment.selfClosing && moved.some(value => value.anchorId === segment.startAnchorId || value.anchorId === segment.endAnchorId)) {
117
+ edits.push({ range: { start: segment.range.start - origin, end: segment.range.end - origin }, replacement: `<${segment.id}></${segment.id}>` });
118
+ }
141
119
  }
142
- const unmarked = applyEdits(input.source, edits);
143
- const base = normalizedProse(unmarked, parseScript(input.sourceName, unmarked), input.source);
144
- const parsed = parseScript(input.sourceName, base);
120
+ const base = applyEdits(input.source, edits);
121
+ const parsed = parseScript(input.sourceName, base, origin);
145
122
  const sites = new Map(scriptAnchorEditSites(parsed).map((site, order) => [site.anchorId, { ...site, order }]));
146
123
  const groups = new Map<number, Array<NamedAnchor & { affinity: Affinity; order: number }>>();
147
- for (const value of markers) {
124
+ for (const value of moved) {
148
125
  const site = sites.get(value.anchorId);
149
126
  if (!site) throw new Error(`Semantic Anchor ${value.anchorId} does not exist.`);
150
- let offset = site.offset;
151
- const lineStart = Math.max(base.lastIndexOf("\n", offset - 1), base.lastIndexOf("\r", offset - 1)) + 1;
152
- const indentEnd = lineStart + /^[ \t]*/u.exec(base.slice(lineStart))![0].length;
153
- if (offset < indentEnd) offset = indentEnd;
154
- // Reuse horizontal separators without moving a word marker ahead of line indentation.
155
- let gapStart = offset;
156
- while (gapStart > 0 && /[ \t]/u.test(base[gapStart - 1]!)) gapStart -= 1;
157
- if (gapStart > 0 && !/[\r\n]/u.test(base[gapStart - 1]!)) offset = gapStart;
127
+ const offset = site.offset - origin;
158
128
  const group = groups.get(offset) ?? [];
159
129
  group.push({ ...value, affinity: site.affinity, order: site.order });
160
130
  groups.set(offset, group);
@@ -163,18 +133,13 @@ function rewrite(input: AdjustmentInput, markers: readonly NamedAnchor[]): strin
163
133
  group.sort((a, b) => a.order - b.order
164
134
  || (a.edge === "open" ? 0 : a.edge === "moment" ? 1 : 2) - (b.edge === "open" ? 0 : b.edge === "moment" ? 1 : 2)
165
135
  || a.id.localeCompare(b.id));
166
- let replacement = group.map((item) => item.edge === "moment"
167
- ? `${item.affinity === "left" ? "~" : ""}@${item.id}!`
136
+ const replacement = group.map(item => item.edge === "moment"
137
+ ? `@{${item.affinity === "left" ? "~" : ""}${item.id}!}`
168
138
  : marker(item.id, item.edge, item.affinity)).join("");
169
- let end = offset;
170
- while (end < base.length && /[ \t]/u.test(base[end]!)) end += 1;
171
- if (end > offset) replacement += " ";
172
- // Only undelimited names followed by an ASCII name character need a separator.
173
- if (/[a-z0-9_-]$/u.test(replacement) && /[A-Za-z0-9_-]/u.test(base[offset] ?? "")) replacement += " ";
174
- return { range: { start: offset, end }, replacement };
139
+ return { range: { start: offset, end: offset }, replacement };
175
140
  });
176
141
  const next = applyEdits(base, insertions);
177
- const reparsed = parseScript(input.sourceName, next);
142
+ const reparsed = parseScript(input.sourceName, next, origin);
178
143
  const actual = namedAnchors(reparsed);
179
144
  if (canonicalStringify(actual) !== canonicalStringify(markers)) {
180
145
  throw new Error("Script marker adjustment did not preserve the requested semantic bindings.");
@@ -183,6 +148,9 @@ function rewrite(input: AdjustmentInput, markers: readonly NamedAnchor[]): strin
183
148
  const content = (value: ParsedNarrative) => ({
184
149
  ...narrativeValue(value, "comparison") as Record<string, unknown>, selections: [], moments: [],
185
150
  caption: captionDocument(value, "caption", "comparison"),
151
+ speech: value.serializations.speech,
152
+ dialogue: value.serializations.dialogue,
153
+ display: value.captionProjection.text,
186
154
  });
187
155
  if (canonicalStringify(content(input.parsed)) !== canonicalStringify(content(reparsed))) {
188
156
  throw new Error("Script marker adjustment changed authored content.");
@@ -9,159 +9,88 @@ const WORD_CHARACTER = String.raw`(?:(?!${CHARACTER_UNIT})[\p{L}\p{M}\p{N}])`;
9
9
  const LEXICAL_UNIT = new RegExp([
10
10
  String.raw`(?:\p{N}{1,3}(?:[,,]\p{N}{3})+|\p{N}+)(?:[..]\p{N}+)?(?:-\p{N}+(?:[..]\p{N}+)?)*(?!\p{N}|-[\p{L}\p{M}])`,
11
11
  String.raw`${CHARACTER_UNIT}\p{M}*`,
12
- String.raw`${WORD_CHARACTER}+(?:['’.-]${WORD_CHARACTER}+)*`,
12
+ String.raw`(?:(?!${CHARACTER_UNIT})[\p{L}\p{N}])${WORD_CHARACTER}*(?:['’.-]${WORD_CHARACTER}+)*`,
13
13
  ].join("|"), "gu");
14
14
 
15
- const OPENING_PUNCTUATION = new Set([
16
- "(", "[", "{", "(", "【", "《", "「", "『", "〔", "〈", "“", "‘",
17
- "$", "¥", "¥", "€", "£",
18
- ]);
19
-
20
- const QUOTE_PUNCTUATION = new Set(["\"", "'", "`", "’", "ʼ"]);
21
- const UNICODE_OPENING_PUNCTUATION = /[\p{Ps}\p{Pi}]/u;
22
-
23
- /**
24
- * ASCII quotation marks have no Unicode opening/closing category. In a gap between
25
- * lexical units, whitespace before the mark is the useful authoring signal:
26
- * `said "hello` opens a quote, while `hello" world` closes one. A quote before the
27
- * first unit is opening by definition; contractions stay inside one lexical unit.
28
- */
29
- function isOpeningPunctuation(
30
- character: string,
31
- gap: string,
32
- position: number,
33
- hasPreviousSurface: boolean,
34
- ): boolean {
35
- if (OPENING_PUNCTUATION.has(character) || UNICODE_OPENING_PUNCTUATION.test(character)) return true;
36
- if (!QUOTE_PUNCTUATION.has(character)) return false;
37
- if (!hasPreviousSurface) return true;
38
- const before = [...gap.slice(0, position)].at(-1);
39
- return before !== undefined && /\s/u.test(before);
15
+ // Unicode owns bracket/quote categories. ASCII symmetric quotes need local context;
16
+ // they have no opening/closing category. This is prose analysis, not marker syntax.
17
+ function openingAt(gap: string, hasPrevious: boolean, beforeWord = true): number {
18
+ for (const match of gap.matchAll(/./gu)) {
19
+ const character = match[0];
20
+ if (/[\p{Ps}\p{Pi}\p{Sc}]/u.test(character)
21
+ || ((/["'`]/u.test(character) || (beforeWord && /\p{Pf}/u.test(character))) && (!hasPrevious || /\s$/u.test(gap.slice(0, match.index))))) return match.index;
22
+ }
23
+ return gap.length;
40
24
  }
41
25
 
42
26
  export function lexicalUnits(value: string): readonly LexicalUnit[] {
43
- return [...value.matchAll(LEXICAL_UNIT)].map((match) => ({
44
- text: match[0],
45
- index: match.index,
46
- }));
27
+ return [...value.matchAll(LEXICAL_UNIT)].map(match => ({ text: match[0], index: match.index }));
47
28
  }
48
29
 
49
30
  export function lexicalCount(value: string): number {
50
31
  return lexicalUnits(value).length;
51
32
  }
52
33
 
53
- /** Source positions for markers; unlike timing units, these include attached punctuation. */
54
- export function lexicalEditRanges(value: string): readonly { start: number; end: number }[] {
55
- const units = lexicalUnits(value);
56
- return units.map((unit, index) => {
57
- const previousEnd = index === 0 ? 0 : units[index - 1]!.index + units[index - 1]!.text.length;
58
- const nextStart = units[index + 1]?.index ?? value.length;
59
- const leading = value.slice(previousEnd, unit.index);
60
- let start = unit.index;
61
- for (const [position, character] of [...leading.matchAll(/./gu)].map((match) => [match.index, match[0]] as const)) {
62
- if (isOpeningPunctuation(character, leading, position, index > 0)) { start = previousEnd + position; break; }
63
- }
64
- let end = unit.index + unit.text.length;
65
- const trailing = value.slice(end, nextStart);
66
- for (const match of trailing.matchAll(/./gu)) {
67
- if (/\s/u.test(match[0]) || isOpeningPunctuation(match[0], trailing, match.index, true)) break;
68
- end += match[0].length;
69
- }
70
- return { start, end };
71
- });
72
- }
73
-
74
- /** Canonical prose spacing; punctuation remains display/speech information, never a timing token. */
75
- function attachProseSpacing(value: string): string {
76
- return value
77
- .replace(/ +([,.;:!?%…,。!?;:、%‰)】》」』〕〉}\]])/gu, "$1")
78
- .replace(/([([{(【《「『〔〈“‘]) +/gu, "$1")
79
- // Do not erase a cross-script space: `here 你好` must remain two semantic regions.
80
- // Only collapse explicit spaces inside one CJK run; the lexical tokenizer already keeps
81
- // adjacent Latin and CJK runs separate when no space was authored.
82
- .replace(/([\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]) +(?=[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}])/gu, "$1");
83
- }
84
-
34
+ /** Normalize source formatting without inventing language-specific separators. */
85
35
  export function cleanProjection(value: string): string {
86
- return attachProseSpacing(value.replace(/\s+/gu, " ")).trim();
36
+ return value.replace(/\s+/gu, " ").trim();
87
37
  }
88
38
 
89
- /** Source formatting keeps line breaks and lexical boundaries, including spaced decimal-like prose. */
90
- export function cleanHorizontalProse(value: string): string {
91
- const collapsed = value.replace(/[ \t]+/gu, " ");
92
- const attached = attachProseSpacing(collapsed);
93
- const words = (text: string) => lexicalUnits(text).map(unit => unit.text);
94
- // A separator in `3 .14` cannot be erased into the different token `3.14`.
95
- const before = words(collapsed);
96
- const after = words(attached);
97
- return before.length === after.length && before.every((word, index) => word === after[index]) ? attached : collapsed;
98
- }
99
-
100
- /** Structural markers split atoms but must not invent prose whitespace when those atoms rejoin. */
101
39
  export function joinProjection(parts: readonly string[]): string {
102
40
  return cleanProjection(parts.join(""));
103
41
  }
104
42
 
105
- /**
106
- * Display units follow the same lexical boundaries as semantic speech tokens. Punctuation is kept
107
- * for rendering: opening punctuation belongs to the next unit; all other inter-token punctuation
108
- * belongs to the previous unit. Whitespace is layout, not a display word of its own.
109
- */
110
- export function displayWordSurfaces(value: string): readonly string[] {
111
- const prose = cleanProjection(value);
112
- const units = lexicalUnits(prose);
113
- if (units.length === 0) return [];
43
+ export type DisplaySurface = { readonly text: string; readonly separatorBefore: "" | " " };
44
+ type ProseWord = { start: number; end: number; separatorBefore: "" | " " };
114
45
 
115
- const surfaces: string[] = [];
46
+ /** Analyze complete prose once. Speech cores and their written surfaces are distinct:
47
+ * punctuation has spelling and source extent, but does not acquire speech timing.
48
+ * A display-only literal (for example an emoji in Dual Text) needs no speech core.
49
+ */
50
+ export function analyzeProse(value: string, followsDisplay = false): {
51
+ readonly units: readonly LexicalUnit[];
52
+ readonly surfaces: readonly DisplaySurface[];
53
+ readonly editRanges: readonly { start: number; end: number }[];
54
+ } {
55
+ const units = lexicalUnits(value);
56
+ const words: ProseWord[] = [];
116
57
  let cursor = 0;
117
- let prefix = "";
118
58
  for (const unit of units) {
119
- const gap = prose.slice(cursor, unit.index);
120
- const punctuation = [...gap]
121
- .map((character, position) => ({ character, position }))
122
- .filter(({ character }) => !/\s/u.test(character));
123
- if (surfaces.length === 0) {
124
- prefix += punctuation.map(({ character }) => character).join("");
125
- } else {
126
- const suffix = punctuation
127
- .filter(({ character, position }) => !isOpeningPunctuation(character, gap, position, true))
128
- .map(({ character }) => character)
129
- .join("");
130
- const opening = punctuation
131
- .filter(({ character, position }) => isOpeningPunctuation(character, gap, position, true))
132
- .map(({ character }) => character)
133
- .join("");
134
- if (suffix) surfaces[surfaces.length - 1] += suffix;
135
- prefix += opening;
136
- }
137
- surfaces.push(`${prefix}${unit.text}`);
138
- prefix = "";
59
+ const gap = value.slice(cursor, unit.index);
60
+ const opening = openingAt(gap, words.length > 0 || followsDisplay);
61
+ const closing = gap.slice(0, opening);
62
+ const previous = words.at(-1);
63
+ if (previous) previous.end = cursor + closing.trimEnd().length;
64
+ const start = previous || followsDisplay ? cursor + opening : value.slice(0, unit.index).search(/\S/u);
65
+ words.push({ start: start < 0 ? unit.index : start, end: unit.index + unit.text.length,
66
+ separatorBefore: previous && /\s$/u.test(closing) ? " " : "" });
139
67
  cursor = unit.index + unit.text.length;
140
68
  }
141
- const trailing = prose.slice(cursor).replace(/\s+/gu, "");
142
- if (trailing) surfaces[surfaces.length - 1] += trailing;
143
- return surfaces;
69
+ const last = words.at(-1);
70
+ if (last) last.end = value.trimEnd().length;
71
+ const surfaces = words.map(word => ({ text: value.slice(word.start, word.end).replace(/\s+/gu, " "), separatorBefore: word.separatorBefore }));
72
+ const editRanges = words.map((word, index) => {
73
+ const unit = units[index]!;
74
+ let end = unit.index + unit.text.length;
75
+ // Whitespace is a legal insertion boundary. Only adjacent trailing punctuation
76
+ // belongs to the indivisible edit surface ("word," versus "word ,").
77
+ const trailing = value.slice(end, word.end);
78
+ const whitespace = trailing.search(/\s/u);
79
+ end += whitespace < 0 ? trailing.length : whitespace;
80
+ return { start: word.start, end };
81
+ });
82
+ if (surfaces.length === 0 && value.trim()) surfaces.push({ text: cleanProjection(value), separatorBefore: "" });
83
+ return { units, surfaces, editRanges };
144
84
  }
145
85
 
146
- /** Closing punctuation before a lexical unit belongs to the previous display unit when one exists. */
147
- export function splitLeadingClosingPunctuation(value: string): {
148
- readonly previous: string;
149
- readonly current: string;
150
- } {
151
- const prose = cleanProjection(value);
152
- const first = lexicalUnits(prose)[0];
153
- if (first === undefined) return { previous: prose.replace(/\s+/gu, ""), current: "" };
154
- const leading = prose.slice(0, first.index);
155
- const punctuation = [...leading]
156
- .map((character, position) => ({ character, position }))
157
- .filter(({ character }) => !/\s/u.test(character));
158
- const previous = punctuation
159
- .filter(({ character, position }) => !isOpeningPunctuation(character, leading, position, false))
160
- .map(({ character }) => character)
161
- .join("");
162
- const opening = punctuation
163
- .filter(({ character, position }) => isOpeningPunctuation(character, leading, position, false))
164
- .map(({ character }) => character)
165
- .join("");
166
- return { previous, current: `${opening}${prose.slice(first.index)}` };
86
+ export function displaySurfaces(value: string): readonly DisplaySurface[] {
87
+ return analyzeProse(value).surfaces;
88
+ }
89
+
90
+ /** Correspondence boundaries retain punctuation on either side of an explicit Dual. */
91
+ export function splitDisplayPrefix(value: string, hasPrevious: boolean): { previous: string; current: string } {
92
+ const first = lexicalUnits(value)[0];
93
+ const end = first?.index ?? value.length;
94
+ const split = openingAt(value.slice(0, end), hasPrevious, first !== undefined);
95
+ return { previous: value.slice(0, split), current: value.slice(split) };
167
96
  }
@@ -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;