@hypit/hypit 0.1.3 → 0.1.4

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 (92) hide show
  1. package/README.md +1 -1
  2. package/dist/public/studio-adapter.d.ts +31 -2
  3. package/package.json +4 -1
  4. package/packages/audio-track-studio/src/index.ts +7 -4
  5. package/packages/build-result/README.md +58 -0
  6. package/packages/build-result/package.json +2 -1
  7. package/packages/build-result/src/decode.ts +10 -8
  8. package/packages/build-result/src/file-reference.ts +42 -0
  9. package/packages/build-result/src/index.ts +3 -0
  10. package/packages/build-result/src/replace-file-windows.ts +58 -0
  11. package/packages/build-result/src/replace-file.ts +11 -0
  12. package/packages/build-result/src/store.ts +44 -13
  13. package/packages/build-result/src/types.ts +38 -8
  14. package/packages/build-result/src/writer.ts +12 -1
  15. package/packages/build-result-fs/README.md +23 -1
  16. package/packages/build-result-s3/README.md +5 -0
  17. package/packages/build-result-s3/src/repository.ts +19 -5
  18. package/packages/caption-fine/README.md +17 -2
  19. package/packages/caption-fine/src/manifest.ts +1 -1
  20. package/packages/caption-fine/src/render.ts +48 -36
  21. package/packages/caption-fine-studio/package.json +8 -3
  22. package/packages/caption-fine-studio/src/index.ts +10 -1
  23. package/packages/cli/README.md +25 -0
  24. package/packages/cli/src/arguments.ts +16 -11
  25. package/packages/cli/src/build-planning.ts +6 -1
  26. package/packages/cli/src/command.ts +1 -1
  27. package/packages/cli/src/commands/environment.ts +42 -13
  28. package/packages/cli/src/machine-view.ts +12 -3
  29. package/packages/cli/src/main.ts +4 -0
  30. package/packages/cli/src/output.ts +28 -12
  31. package/packages/cli/src/result-export.ts +22 -13
  32. package/packages/cli/src/run-file.ts +19 -8
  33. package/packages/comment-sticker-studio/src/index.ts +2 -1
  34. package/packages/compiler-node/src/compiler.ts +1 -0
  35. package/packages/estimate/README.md +56 -3
  36. package/packages/estimate/src/program.ts +7 -8
  37. package/packages/fonts-open/README.md +5 -0
  38. package/packages/fonts-open/package.json +5 -1
  39. package/packages/fonts-open/src/studio.ts +21 -0
  40. package/packages/media-track-studio/src/index.ts +7 -2
  41. package/packages/provider-whisperx-local/README.md +57 -0
  42. package/packages/ranking/src/render.ts +11 -9
  43. package/packages/ranking-studio/README.md +11 -0
  44. package/packages/ranking-studio/package.json +8 -3
  45. package/packages/ranking-studio/src/index.ts +11 -4
  46. package/packages/runtime/src/catalog.ts +2 -0
  47. package/packages/runtime-host-node/src/index.ts +1 -0
  48. package/packages/runtime-local/README.md +19 -0
  49. package/packages/runtime-local/src/programs.ts +39 -24
  50. package/packages/runtime-local/src/runtime.ts +2 -0
  51. package/packages/script/README.md +22 -0
  52. package/packages/script/src/edit.ts +123 -73
  53. package/packages/script/src/lexical.ts +40 -6
  54. package/packages/script/src/parser.ts +36 -8
  55. package/packages/script/src/types.ts +4 -0
  56. package/packages/seedance-kits/README.md +3 -1
  57. package/packages/seedance-kits/kits/speaker-v1.svs +5 -5
  58. package/packages/speech-alignment/src/align.ts +45 -21
  59. package/packages/studio/INSPECTOR.md +104 -0
  60. package/packages/studio/README.md +65 -4
  61. package/packages/studio/src/build-library.ts +99 -84
  62. package/packages/studio/src/library-media.ts +19 -0
  63. package/packages/studio/src/parameter-values.ts +41 -1
  64. package/packages/studio/src/parameters.ts +20 -21
  65. package/packages/studio/src/server.ts +58 -30
  66. package/packages/studio/src/shared.ts +20 -6
  67. package/packages/studio/src/studio-registry.ts +22 -0
  68. package/packages/studio/src/style.css +135 -144
  69. package/packages/studio/src/temporal-edit.ts +131 -0
  70. package/packages/studio/src/ui/artifact-name.ts +80 -0
  71. package/packages/studio/src/ui/artifact-preview.ts +99 -0
  72. package/packages/studio/src/ui/icons.ts +5 -1
  73. package/packages/studio/src/ui/library.ts +373 -192
  74. package/packages/studio/src/ui/main.ts +153 -31
  75. package/packages/studio/src/ui/material-preview.ts +1 -1
  76. package/packages/studio/src/ui/overlay.ts +21 -20
  77. package/packages/studio/src/ui/sidebar-panel.ts +31 -0
  78. package/packages/studio/src/ui/stage.ts +124 -8
  79. package/packages/studio/src/ui/timeline.ts +16 -179
  80. package/packages/studio/start.ts +15 -3
  81. package/packages/studio-adapter/README.md +21 -0
  82. package/packages/studio-adapter/src/index.ts +26 -1
  83. package/packages/temporal-markup/EDITING.md +183 -0
  84. package/packages/temporal-markup/README.md +4 -0
  85. package/packages/typography-track-studio/package.json +8 -3
  86. package/packages/typography-track-studio/src/index.ts +5 -1
  87. package/packages/video-cli/README.md +12 -3
  88. package/packages/video-cli/src/creation.ts +11 -5
  89. package/packages/whisperx/README.md +7 -0
  90. package/packages/workspace/src/index.ts +2 -0
  91. package/packages/workspace-fs-node/README.md +4 -1
  92. package/packages/workspace-fs-node/src/workspace.ts +3 -1
@@ -135,3 +135,25 @@ These are alternative spellings, not two occurrences to put in the same Script.
135
135
  pause. In the second, the next “my” starts both touching boundaries, so the coffee Selection owns it.
136
136
  Plain `@/coffee @smoothie` leaves the gap between previous word end and next word start outside both.
137
137
  The media consumer still decides playback and visual coverage inside those projected Windows.
138
+
139
+ ## Marker writeback
140
+
141
+ `adjustScriptSelection` and `adjustScriptMoment` accept explicit anchor identities. A Selection's
142
+ two endpoints are written together. Script owns their legal source sites: token punctuation and
143
+ postfix attributes stay attached, Segment boundaries stay structural, and coincident markers are
144
+ ordered together. Writeback reads the current source, removes the markers, normalizes ordinary
145
+ same-line prose, and places the markers at their requested anchors. Repeated spaces and tabs in
146
+ prose become necessary separators; punctuation uses the same attachment rules as Script's text
147
+ projection. Newlines, blank lines and each line's leading spaces/tabs remain intact. A marker at
148
+ the start of a line is placed after its indentation; whitespace after that marker is prose, not
149
+ additional indentation. Marker-only lines remain blank when the marker leaves. Comments, tags,
150
+ Dual Text and display attributes retain their own syntax and are not passed through prose cleanup.
151
+
152
+ Normalization keeps lexical units unchanged and does not restore earlier whitespace spellings.
153
+ It has no persistent formatting state: the same text structure and anchor relationships produce
154
+ the same spelling on repeated edits. Empty self-closing Segments expand when needed to make their
155
+ distinct boundaries writable.
156
+
157
+ Writeback reparses the result to retain the intended identities and unchanged narrative/caption
158
+ content. These checks concern authored order, not frame order. A consumer's temporal projection
159
+ determines whether the resulting Instant or Window is usable.
@@ -1,7 +1,9 @@
1
1
  import type { SourceRange } from "@hypit/protocol";
2
2
 
3
- import { narrativeValue } from "./narrative.js";
3
+ import { canonicalStringify } from "@hypit/protocol";
4
+ import { captionDocument, narrativeValue } from "./narrative.js";
4
5
  import { parseScript } from "./parser.js";
6
+ import { cleanHorizontalProse } from "./lexical.js";
5
7
  import type { Affinity, ParsedNarrative, SemanticAnchor } from "./types.js";
6
8
 
7
9
  export type ScriptAnchorEditSite = {
@@ -47,18 +49,16 @@ export function scriptAnchorEditSites(parsed: ParsedNarrative): readonly ScriptA
47
49
  const segment = segments.get(segmentId);
48
50
  if (segment === undefined) throw new Error(`Semantic Anchor ${anchor.id} names unknown Segment ${segmentId}.`);
49
51
  if (anchor.kind === "segment-start") {
50
- const first = parsed.tokens[segment.tokenStart];
51
52
  return {
52
53
  anchorId: anchor.id, kind: anchor.kind, segmentId,
53
- offset: first?.range.start ?? segment.contentRange.start,
54
+ offset: segment.contentRange.start,
54
55
  affinity: "left", placement: "before",
55
56
  };
56
57
  }
57
58
  if (anchor.kind === "segment-end") {
58
- const last = parsed.tokens[segment.tokenEndExclusive - 1];
59
59
  return {
60
60
  anchorId: anchor.id, kind: anchor.kind, segmentId,
61
- offset: last?.range.end ?? segment.contentRange.end,
61
+ offset: segment.contentRange.end,
62
62
  affinity: "right", placement: "after",
63
63
  };
64
64
  }
@@ -66,10 +66,10 @@ export function scriptAnchorEditSites(parsed: ParsedNarrative): readonly ScriptA
66
66
  if (token === undefined) throw new Error(`Semantic Anchor ${anchor.id} names no Script Token.`);
67
67
  return anchor.kind === "token-start" ? {
68
68
  anchorId: anchor.id, kind: anchor.kind, segmentId,
69
- offset: token.range.start, affinity: "right", placement: "before",
69
+ offset: token.editRange.start, affinity: "right", placement: "before",
70
70
  } : {
71
71
  anchorId: anchor.id, kind: anchor.kind, segmentId,
72
- offset: token.range.end, affinity: "left", placement: "after",
72
+ offset: token.editRange.end, affinity: "left", placement: "after",
73
73
  };
74
74
  });
75
75
  }
@@ -79,16 +79,6 @@ function marker(id: string, edge: "open" | "close", affinity: Affinity): string
79
79
  return affinity === "left" ? `@/${id}` : `@/${id}~`;
80
80
  }
81
81
 
82
- function insertion(id: string, edge: "open" | "close", site: ScriptAnchorEditSite): string {
83
- const value = marker(id, edge, site.affinity);
84
- return site.placement === "before" ? `${value} ` : ` ${value}`;
85
- }
86
-
87
- function momentInsertion(id: string, site: ScriptAnchorEditSite): string {
88
- const value = site.affinity === "left" ? `~@${id}!` : `@${id}!`;
89
- return site.placement === "before" ? `${value} ` : ` ${value}`;
90
- }
91
-
92
82
  function applyEdits(source: string, edits: readonly Edit[]): string {
93
83
  let next = source;
94
84
  const ordered = [...edits].sort((left, right) =>
@@ -102,73 +92,133 @@ function applyEdits(source: string, edits: readonly Edit[]): string {
102
92
  return next;
103
93
  }
104
94
 
105
- /**
106
- * Relocate one shared Selection by anchor identity. The returned Source is accepted only when
107
- * reparsing proves the requested public Narrative, so Studio never edits prose by frame guess.
108
- */
109
- export function adjustScriptSelection(input: {
95
+ type AdjustmentInput = {
110
96
  readonly sourceName: string;
111
97
  readonly source: string;
112
98
  readonly parsed: ParsedNarrative;
113
- readonly adjustment: ScriptSelectionAdjustment;
114
- }): string {
115
- const selection = input.parsed.selections.find((candidate) => candidate.id === input.adjustment.id);
116
- if (selection === undefined) throw new Error(`Script Selection ${input.adjustment.id} does not exist.`);
117
- const sites = new Map(scriptAnchorEditSites(input.parsed).map((site) => [site.anchorId, site] as const));
118
- const start = sites.get(input.adjustment.startAnchorId);
119
- const end = sites.get(input.adjustment.endAnchorId);
120
- if (start === undefined) throw new Error(`Selection start Anchor ${input.adjustment.startAnchorId} does not exist.`);
121
- if (end === undefined) throw new Error(`Selection end Anchor ${input.adjustment.endAnchorId} does not exist.`);
99
+ };
100
+
101
+ type NamedAnchor = { readonly id: string; readonly edge: "open" | "close" | "moment"; readonly anchorId: string };
122
102
 
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
+ }
123
117
  const edits: Edit[] = [];
124
- if (selection.startAnchorId !== start.anchorId) {
125
- edits.push({ range: selection.open.range, replacement: "" });
126
- edits.push({ range: { start: start.offset, end: start.offset }, replacement: insertion(selection.id, "open", start) });
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. */
132
+ 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: "" }));
139
+ 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}>` });
127
141
  }
128
- if (selection.endAnchorId !== end.anchorId) {
129
- edits.push({ range: selection.close.range, replacement: "" });
130
- edits.push({ range: { start: end.offset, end: end.offset }, replacement: insertion(selection.id, "close", end) });
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);
145
+ const sites = new Map(scriptAnchorEditSites(parsed).map((site, order) => [site.anchorId, { ...site, order }]));
146
+ const groups = new Map<number, Array<NamedAnchor & { affinity: Affinity; order: number }>>();
147
+ for (const value of markers) {
148
+ const site = sites.get(value.anchorId);
149
+ 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;
158
+ const group = groups.get(offset) ?? [];
159
+ group.push({ ...value, affinity: site.affinity, order: site.order });
160
+ groups.set(offset, group);
131
161
  }
132
- if (edits.length === 0) return input.source;
133
- const next = applyEdits(input.source, edits);
162
+ const insertions: Edit[] = [...groups].map(([offset, group]) => {
163
+ group.sort((a, b) => a.order - b.order
164
+ || (a.edge === "open" ? 0 : a.edge === "moment" ? 1 : 2) - (b.edge === "open" ? 0 : b.edge === "moment" ? 1 : 2)
165
+ || a.id.localeCompare(b.id));
166
+ let replacement = group.map((item) => item.edge === "moment"
167
+ ? `${item.affinity === "left" ? "~" : ""}@${item.id}!`
168
+ : 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 };
175
+ });
176
+ const next = applyEdits(base, insertions);
134
177
  const reparsed = parseScript(input.sourceName, next);
135
- const rewritten = reparsed.selections.find((candidate) => candidate.id === selection.id);
136
- if (rewritten?.startAnchorId !== start.anchorId || rewritten.endAnchorId !== end.anchorId) {
137
- throw new Error(`Script refused to move Selection ${selection.id} to the requested Anchors.`);
178
+ const actual = namedAnchors(reparsed);
179
+ if (canonicalStringify(actual) !== canonicalStringify(markers)) {
180
+ throw new Error("Script marker adjustment did not preserve the requested semantic bindings.");
138
181
  }
139
- const before = narrativeValue(input.parsed, "comparison") as unknown as { readonly selections: readonly { readonly id: string }[] };
140
- const after = narrativeValue(reparsed, "comparison") as unknown as { readonly selections: readonly { readonly id: string }[] };
141
- if (before.selections.length !== after.selections.length
142
- || before.selections.some((item) => !after.selections.some((candidate) => candidate.id === item.id))) {
143
- throw new Error("Script Selection adjustment changed authored identities.");
182
+ // Compare public content, not source offsets or marker-induced parser atom boundaries.
183
+ const content = (value: ParsedNarrative) => ({
184
+ ...narrativeValue(value, "comparison") as Record<string, unknown>, selections: [], moments: [],
185
+ caption: captionDocument(value, "caption", "comparison"),
186
+ });
187
+ if (canonicalStringify(content(input.parsed)) !== canonicalStringify(content(reparsed))) {
188
+ throw new Error("Script marker adjustment changed authored content.");
144
189
  }
145
190
  return next;
146
191
  }
147
192
 
148
- /** Relocate one Moment to an exact semantic Anchor without changing its identity. */
149
- export function adjustScriptMoment(input: {
150
- readonly sourceName: string;
151
- readonly source: string;
152
- readonly parsed: ParsedNarrative;
153
- readonly adjustment: ScriptMomentAdjustment;
154
- }): string {
155
- const moment = input.parsed.moments.find((candidate) => candidate.id === input.adjustment.id);
156
- if (moment === undefined) throw new Error(`Script Moment ${input.adjustment.id} does not exist.`);
157
- const site = scriptAnchorEditSites(input.parsed).find((candidate) => candidate.anchorId === input.adjustment.anchorId);
158
- if (site === undefined) throw new Error(`Moment Anchor ${input.adjustment.anchorId} does not exist.`);
159
- if (moment.anchorId === site.anchorId) return input.source;
160
- const next = applyEdits(input.source, [
161
- { range: moment.range, replacement: "" },
162
- { range: { start: site.offset, end: site.offset }, replacement: momentInsertion(moment.id, site) },
163
- ]);
164
- const reparsed = parseScript(input.sourceName, next);
165
- const rewritten = reparsed.moments.find((candidate) => candidate.id === moment.id);
166
- if (rewritten?.anchorId !== site.anchorId) {
167
- throw new Error(`Script refused to move Moment ${moment.id} to the requested Anchor.`);
168
- }
169
- if (reparsed.moments.length !== input.parsed.moments.length
170
- || input.parsed.moments.some((item) => !reparsed.moments.some((candidate) => candidate.id === item.id))) {
171
- throw new Error("Script Moment adjustment changed authored identities.");
172
- }
173
- return next;
193
+ function namedAnchors(parsed: ParsedNarrative): NamedAnchor[] {
194
+ return [
195
+ ...parsed.selections.flatMap((item): NamedAnchor[] => [
196
+ { id: item.id, edge: "open", anchorId: item.startAnchorId },
197
+ { id: item.id, edge: "close", anchorId: item.endAnchorId },
198
+ ]),
199
+ ...parsed.moments.map((item): NamedAnchor => ({ id: item.id, edge: "moment", anchorId: item.anchorId })),
200
+ ];
201
+ }
202
+
203
+ /** Rewrite a Selection's two endpoints together; Script owns order, not projected time. */
204
+ export function adjustScriptSelection(input: AdjustmentInput & { readonly adjustment: ScriptSelectionAdjustment }): string {
205
+ const { adjustment, parsed } = input;
206
+ const selection = parsed.selections.find((item) => item.id === adjustment.id);
207
+ if (!selection) throw new Error(`Script Selection ${adjustment.id} does not exist.`);
208
+ const order = parsed.semanticIndex.anchors.map((anchor) => anchor.id);
209
+ const start = order.indexOf(adjustment.startAnchorId);
210
+ const end = order.indexOf(adjustment.endAnchorId);
211
+ if (start < 0 || end < start) throw new Error("Selection endpoints must follow Script anchor order.");
212
+ return rewrite(input, namedAnchors(parsed).map((item) => item.id !== adjustment.id ? item : {
213
+ ...item, anchorId: item.edge === "open" ? adjustment.startAnchorId : adjustment.endAnchorId,
214
+ }));
215
+ }
216
+
217
+ /** Relocate a Moment by identity, independently of coincident projected Frames. */
218
+ export function adjustScriptMoment(input: AdjustmentInput & { readonly adjustment: ScriptMomentAdjustment }): string {
219
+ const moment = input.parsed.moments.find((item) => item.id === input.adjustment.id);
220
+ if (!moment) throw new Error(`Script Moment ${input.adjustment.id} does not exist.`);
221
+ return rewrite(input, namedAnchors(input.parsed).map((item) => item.id !== input.adjustment.id ? item : {
222
+ ...item, anchorId: input.adjustment.anchorId,
223
+ }));
174
224
  }
@@ -50,17 +50,51 @@ export function lexicalCount(value: string): number {
50
50
  return lexicalUnits(value).length;
51
51
  }
52
52
 
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
+
53
74
  /** Canonical prose spacing; punctuation remains display/speech information, never a timing token. */
54
- export function cleanProjection(value: string): string {
75
+ function attachProseSpacing(value: string): string {
55
76
  return value
56
- .replace(/\s+/gu, " ")
57
- .replace(/\s+([,.;:!?%…,。!?;:、%‰)】》」』〕〉}\]])/gu, "$1")
58
- .replace(/([([{(【《「『〔〈“‘])\s+/gu, "$1")
77
+ .replace(/ +([,.;:!?%…,。!?;:、%‰)】》」』〕〉}\]])/gu, "$1")
78
+ .replace(/([([{(【《「『〔〈“‘]) +/gu, "$1")
59
79
  // Do not erase a cross-script space: `here 你好` must remain two semantic regions.
60
80
  // Only collapse explicit spaces inside one CJK run; the lexical tokenizer already keeps
61
81
  // adjacent Latin and CJK runs separate when no space was authored.
62
- .replace(/([\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}])\s+(?=[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}])/gu, "$1")
63
- .trim();
82
+ .replace(/([\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]) +(?=[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}])/gu, "$1");
83
+ }
84
+
85
+ export function cleanProjection(value: string): string {
86
+ return attachProseSpacing(value.replace(/\s+/gu, " ")).trim();
87
+ }
88
+
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;
64
98
  }
65
99
 
66
100
  /** Structural markers split atoms but must not invent prose whitespace when those atoms rejoin. */
@@ -5,6 +5,7 @@ import {
5
5
  displayWordSurfaces,
6
6
  joinProjection,
7
7
  lexicalUnits,
8
+ lexicalEditRanges,
8
9
  splitLeadingClosingPunctuation,
9
10
  } from "./lexical.js";
10
11
  import type {
@@ -141,6 +142,7 @@ export function parseScript(
141
142
 
142
143
  const segments: ParsedSegment[] = [];
143
144
  const tokens: ParsedToken[] = [];
145
+ const proseRanges: Array<{ start: number; end: number }> = [];
144
146
  const captionRegions: ParsedCaptionRegion[] = [];
145
147
  const selections = new Map<string, RawSelection>();
146
148
  const moments = new Map<string, RawMoment>();
@@ -237,12 +239,13 @@ export function parseScript(
237
239
  raw: string,
238
240
  absoluteStart: number,
239
241
  dual = false,
240
- ): Array<{ readonly value: string; readonly start: number; readonly end: number }> => {
241
- const pieces: Array<{ value: string; start: number; end: number }> = [];
242
+ ): Array<{ readonly value: string; readonly start: number; readonly end: number; readonly positions: readonly number[] }> => {
243
+ const pieces: Array<{ value: string; start: number; end: number; positions: readonly number[] }> = [];
242
244
  let buffer = "";
243
245
  let bufferStart = 0;
246
+ const positions = [absoluteStart];
244
247
  const flush = (end: number): void => {
245
- if (buffer) pieces.push({ value: buffer, start: absoluteStart + bufferStart, end: absoluteStart + end });
248
+ if (buffer) pieces.push({ value: buffer, start: absoluteStart + bufferStart, end: absoluteStart + end, positions });
246
249
  buffer = "";
247
250
  bufferStart = end;
248
251
  };
@@ -255,10 +258,12 @@ export function parseScript(
255
258
  ?? fail("SCRIPT_ESCAPE", `Unknown Script escape "${raw.slice(index, index + 2)}".`, absoluteStart + index);
256
259
  buffer += escape.value;
257
260
  index += escape.length;
261
+ positions.push(absoluteStart + index);
258
262
  continue;
259
263
  }
260
264
  buffer += raw[index];
261
265
  index += 1;
266
+ positions.push(absoluteStart + index);
262
267
  }
263
268
  flush(raw.length);
264
269
  return pieces;
@@ -424,6 +429,7 @@ export function parseScript(
424
429
  start: number,
425
430
  end: number,
426
431
  captureCaptionRegion = true,
432
+ sourcePositions?: readonly number[],
427
433
  ): void => {
428
434
  if (!current) {
429
435
  if (speech.trim() || caption.trim()) {
@@ -433,7 +439,19 @@ export function parseScript(
433
439
  }
434
440
  current.lexicalRun += speech;
435
441
  const tokenStart = tokens.length;
436
- for (const match of lexicalUnits(speech)) {
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()) {
437
455
  const normalized = normalizeWord(match.text);
438
456
  if (!normalized) continue;
439
457
  const index = tokens.length;
@@ -449,8 +467,12 @@ export function parseScript(
449
467
  text: match.text,
450
468
  normalized,
451
469
  range: {
452
- start: sourceOffset + start + match.index,
453
- end: sourceOffset + start + match.index + match.text.length,
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),
454
476
  },
455
477
  });
456
478
  }
@@ -486,7 +508,7 @@ export function parseScript(
486
508
  let emittedCaption = false;
487
509
  const addLiteral = (part: string, start: number): void => {
488
510
  for (const piece of literalPieces(part, start, true)) {
489
- addText(piece.value, emittedCaption ? "" : caption, piece.start, piece.end, false);
511
+ addText(piece.value, emittedCaption ? "" : caption, piece.start, piece.end, false, piece.positions);
490
512
  emittedCaption = true;
491
513
  }
492
514
  };
@@ -654,13 +676,18 @@ export function parseScript(
654
676
  offset += 1;
655
677
  }
656
678
  const raw = source.slice(textStart, offset);
657
- for (const piece of literalPieces(raw, textStart)) addText(piece.value, piece.value, piece.start, piece.end);
679
+ if (raw) proseRanges.push({ start: sourceOffset + textStart, end: sourceOffset + offset });
680
+ for (const piece of literalPieces(raw, textStart)) addText(piece.value, piece.value, piece.start, piece.end, true, piece.positions);
658
681
  if (source[offset] === "{") {
659
682
  const block = parseAttributeBlock(source.slice(offset), offset);
660
683
  if (!raw || /\s$/u.test(raw)) {
661
684
  fail("SCRIPT_ATTRIBUTE_TARGET", "A token attribute must immediately follow a display token.", offset);
662
685
  }
663
686
  markLastCaptionSurface(block.attributes, offset);
687
+ const token = tokens.at(-1)!;
688
+ tokens[tokens.length - 1] = { ...token, editRange: {
689
+ start: token.editRange.start, end: sourceOffset + offset + block.length,
690
+ } };
664
691
  offset += block.length;
665
692
  continue;
666
693
  }
@@ -815,6 +842,7 @@ export function parseScript(
815
842
  return {
816
843
 
817
844
  sourceRange: { start: sourceOffset, end: sourceOffset + source.length },
845
+ proseRanges,
818
846
  segments,
819
847
  tokens,
820
848
  turns,
@@ -45,6 +45,8 @@ export type ParsedToken = NarrativeToken & {
45
45
  readonly index: number;
46
46
  readonly segmentTokenIndex: number;
47
47
  readonly range: SourceRange;
48
+ /** Writable word surface, including attached punctuation and display attributes. */
49
+ readonly editRange: SourceRange;
48
50
  };
49
51
  export type ParsedTurn = NarrativeTurn & { readonly range: SourceRange };
50
52
 
@@ -79,6 +81,8 @@ export type ParsedNarrative = Omit<
79
81
  > & {
80
82
  /** Exact Script body range, used only for source-preserving Program-boundary edits. */
81
83
  readonly sourceRange: SourceRange;
84
+ /** Ordinary source prose/whitespace; excludes tags, Dual Text, attributes and comments. */
85
+ readonly proseRanges: readonly SourceRange[];
82
86
  readonly segments: readonly ParsedSegment[];
83
87
  readonly tokens: readonly ParsedToken[];
84
88
  readonly turns: readonly ParsedTurn[];
@@ -78,7 +78,9 @@ no duration-estimation node on the generation route.
78
78
 
79
79
  The Recipe carries recurring choices exposed by the selected template. `dialogue` receives the
80
80
  Script Segment's `.dialogue` Text, retaining pronunciation and Role turns. Optional `action` supplies
81
- this passage's attitude, attention, interaction and any motivated cuts admitted by the Kit.
81
+ this passage's attitude, vocal delivery, attention, interaction and any motivated cuts admitted by
82
+ the Kit. In Speaker, `performance` selects the recurring approach to voice and visible expression;
83
+ `gesture` selects body language, while `action` directs the particular thought and response.
82
84
  Do not retype the complete spoken text into action or treat each Role turn as a required new Take.
83
85
  For B-roll, `story` carries the silent visual events rather than spoken dialogue.
84
86
 
@@ -75,19 +75,19 @@
75
75
  }
76
76
 
77
77
  text-template.speaker-v1.choice.performance.natural-explainer {
78
- text: "PERFORMANCE: natural explainer energy with clear eye focus, expressive eyebrows, small nods, and a friendly conversational rhythm.";
78
+ text: "PERFORMANCE: engaged, friendly conversation with responsive intonation and clear emphasis as the thought develops. Let gaze, expression, and gesture convey the speaker's attitude to what they are saying.";
79
79
  }
80
80
 
81
81
  text-template.speaker-v1.choice.performance.high-energy-ugc {
82
- text: "PERFORMANCE: vivid high-energy short-form delivery with expressive face changes on key words.";
82
+ text: "PERFORMANCE: vivid, energetic short-form delivery with nimble phrasing, animated intonation, and emphatic key ideas. Let the voice and visible reactions develop together as the speaker makes their point.";
83
83
  }
84
84
 
85
85
  text-template.speaker-v1.choice.performance.calm-authority {
86
- text: "PERFORMANCE: composed, confident, precise, and trustworthy, with deliberate emphasis.";
86
+ text: "PERFORMANCE: composed, thoughtful conviction with measured phrasing and purposeful vocal emphasis. Let focused attention and small, deliberate reactions express how the speaker weighs the idea and reaches a judgment.";
87
87
  }
88
88
 
89
89
  text-template.speaker-v1.choice.performance.reactive-playful {
90
- text: "PERFORMANCE: playful and reactive, with amused looks, quick smiles, skeptical eyebrows, and lively timing.";
90
+ text: "PERFORMANCE: playful, responsive delivery with teasing inflection, lively timing, and an audible smile where the thought amuses the speaker. Let curiosity, skepticism, or delight come through in the voice and matching visible reactions as the passage calls for them.";
91
91
  }
92
92
 
93
93
  text-template.speaker-v1.block.gesture {
@@ -131,6 +131,6 @@
131
131
  order: 100;
132
132
  slot: action;
133
133
  optional: true;
134
- label: "ACTION DIRECTION:";
134
+ label: "PERFORMANCE AND ACTION DIRECTION:";
135
135
  }
136
136
  </sheet>
@@ -66,6 +66,9 @@ function pairedCost(
66
66
  ): number {
67
67
  const sourceText = comparisonText(source);
68
68
  const evidenceText = comparisonText(evidence);
69
+ // Identical wording with different token boundaries is a segmentation difference. Its cost
70
+ // must not grow with the number of letters a recognizer happens to emit for one word.
71
+ if (sourceText === evidenceText) return source.length === 1 && evidence.length === 1 ? 0 : 0.055;
69
72
  const width = Math.max([...sourceText].length, [...evidenceText].length, 1);
70
73
  const distance = editDistance([...sourceText], [...evidenceText]) / width;
71
74
  const grouping = 0.055 * Math.max(0, source.length + evidence.length - 2);
@@ -74,6 +77,17 @@ function pairedCost(
74
77
  return distance * evidenceReliability(evidence) + grouping + 0.06 * unmatchedWordBoundaries;
75
78
  }
76
79
 
80
+ function exactRunLength(target: string, parts: readonly string[], start: number): number {
81
+ if (target.length === 0) return 0;
82
+ let joined = "";
83
+ for (let index = start; index < parts.length; index += 1) {
84
+ joined += parts[index];
85
+ if (joined === target) return index - start + 1;
86
+ if (!target.startsWith(joined)) return 0;
87
+ }
88
+ return 0;
89
+ }
90
+
77
91
  function better(candidate: Cell, current: Cell | undefined): boolean {
78
92
  if (!current) return true;
79
93
  if (candidate.cost < current.cost - EPSILON) return true;
@@ -155,34 +169,44 @@ export function alignWordGroups(
155
169
  }
156
170
  };
157
171
 
172
+ const sourceTexts = source.map((token) => normalizeForAlignment(token.text));
173
+ const evidenceTexts = evidence.map((word) => normalizeForAlignment(word.text));
158
174
  for (let sourceIndex = 0; sourceIndex <= source.length; sourceIndex += 1) {
159
175
  for (let evidenceIndex = 0; evidenceIndex <= evidence.length; evidenceIndex += 1) {
160
176
  if (!rows[sourceIndex]![evidenceIndex]) continue;
177
+ const pair = (sourceCount: number, evidenceCount: number): void => {
178
+ const sourceGroup = source.slice(sourceIndex, sourceIndex + sourceCount);
179
+ const evidenceGroup = evidence.slice(evidenceIndex, evidenceIndex + evidenceCount);
180
+ const exactOneToOne = sourceCount === 1
181
+ && evidenceCount === 1
182
+ && sourceGroup[0]!.normalized === normalizeForAlignment(evidenceGroup[0]!.text);
183
+ const cost = pairedCost(sourceGroup, evidenceGroup);
184
+ update(
185
+ sourceIndex,
186
+ evidenceIndex,
187
+ sourceIndex + sourceCount,
188
+ evidenceIndex + evidenceCount,
189
+ {
190
+ sourceSegmentId,
191
+ sourceTokenIds: sourceGroup.map((token) => token.id),
192
+ evidenceWordStart: evidenceIndex,
193
+ evidenceWordEndExclusive: evidenceIndex + evidenceCount,
194
+ relation: relation(sourceCount, evidenceCount, exactOneToOne),
195
+ cost,
196
+ },
197
+ exactOneToOne ? 1 : 0,
198
+ );
199
+ };
161
200
  for (let sourceCount = 1; sourceCount <= maxGroupSize && sourceIndex + sourceCount <= source.length; sourceCount += 1) {
162
201
  for (let evidenceCount = 1; evidenceCount <= maxGroupSize && evidenceIndex + evidenceCount <= evidence.length; evidenceCount += 1) {
163
- const sourceGroup = source.slice(sourceIndex, sourceIndex + sourceCount);
164
- const evidenceGroup = evidence.slice(evidenceIndex, evidenceIndex + evidenceCount);
165
- const exactOneToOne = sourceCount === 1
166
- && evidenceCount === 1
167
- && sourceGroup[0]!.normalized === normalizeForAlignment(evidenceGroup[0]!.text);
168
- const cost = pairedCost(sourceGroup, evidenceGroup);
169
- update(
170
- sourceIndex,
171
- evidenceIndex,
172
- sourceIndex + sourceCount,
173
- evidenceIndex + evidenceCount,
174
- {
175
- sourceSegmentId,
176
- sourceTokenIds: sourceGroup.map((token) => token.id),
177
- evidenceWordStart: evidenceIndex,
178
- evidenceWordEndExclusive: evidenceIndex + evidenceCount,
179
- relation: relation(sourceCount, evidenceCount, exactOneToOne),
180
- cost,
181
- },
182
- exactOneToOne ? 1 : 0,
183
- );
202
+ pair(sourceCount, evidenceCount);
184
203
  }
185
204
  }
205
+ // Bound fuzzy alternatives, but follow a complete exact split/merge regardless of token count.
206
+ const evidenceRun = exactRunLength(sourceTexts[sourceIndex] ?? "", evidenceTexts, evidenceIndex);
207
+ if (evidenceRun > maxGroupSize) pair(1, evidenceRun);
208
+ const sourceRun = exactRunLength(evidenceTexts[evidenceIndex] ?? "", sourceTexts, sourceIndex);
209
+ if (sourceRun > maxGroupSize) pair(sourceRun, 1);
186
210
  if (sourceIndex < source.length) {
187
211
  const token = source[sourceIndex]!;
188
212
  update(