@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.
- package/README.md +2 -2
- package/dist/public/browser-capture.d.ts +9 -3
- package/dist/public/caption.d.ts +2 -0
- package/dist/public/narrative.d.ts +2 -0
- package/dist/public/runtime-kit.d.ts +2 -2
- package/dist/public/speech.d.ts +2 -0
- package/examples/semantic-composition/packages/responsive-explainer/README.md +1 -1
- package/package.json +1 -1
- package/packages/browser-capture/README.md +9 -2
- package/packages/browser-capture/package.json +5 -0
- package/packages/browser-capture/src/browser.ts +22 -8
- package/packages/browser-capture/src/index.ts +8 -2
- package/packages/caption/README.md +1 -1
- package/packages/caption/src/display.ts +1 -0
- package/packages/caption-fine/README.md +19 -5
- package/packages/caption-fine/package.json +1 -0
- package/packages/caption-fine/src/joined-box.ts +114 -0
- package/packages/caption-fine/src/manifest.ts +1 -1
- package/packages/caption-fine/src/render.ts +99 -92
- package/packages/caption-fine/src/spacing.ts +8 -61
- package/packages/caption-fine-studio/src/index.ts +2 -4
- package/packages/cli/README.md +3 -2
- package/packages/cli/src/arguments.ts +2 -2
- package/packages/cli/src/command.ts +1 -1
- package/packages/cli/src/commands/environment.ts +9 -2
- package/packages/cli/src/machine-view.ts +1 -1
- package/packages/cli/src/output.ts +1 -0
- package/packages/credential-store-os/src/store.ts +5 -1
- package/packages/narrative/README.md +3 -1
- package/packages/narrative/src/schema.ts +1 -0
- package/packages/narrative/src/types.ts +2 -0
- package/packages/provider-whisperx-local/README.md +26 -10
- package/packages/provider-whisperx-local/src/activation.ts +10 -1
- package/packages/provider-whisperx-local/src/program.ts +8 -4
- package/packages/runtime-host-node/src/index.ts +1 -0
- package/packages/runtime-kit/README.md +6 -2
- package/packages/runtime-kit/src/index.ts +2 -2
- package/packages/runtime-local/README.md +6 -1
- package/packages/runtime-local/src/host.ts +2 -0
- package/packages/runtime-local/src/index.ts +1 -0
- package/packages/runtime-local/src/programs.ts +53 -17
- package/packages/script/README.md +81 -34
- package/packages/script/bin/migrate-0.2.mjs +90 -0
- package/packages/script/src/edit.ts +28 -60
- package/packages/script/src/lexical.ts +59 -130
- package/packages/script/src/manifest.ts +11 -11
- package/packages/script/src/narrative.ts +4 -2
- package/packages/script/src/parser.ts +140 -193
- package/packages/script/src/types.ts +2 -3
- package/packages/studio/src/ui/code.ts +1 -1
- package/packages/studio/src/ui/main.ts +2 -2
- package/packages/studio/src/ui/markers.ts +3 -3
- package/packages/studio/src/ui/selection.ts +1 -1
- package/packages/studio/src/ui/syntax.ts +42 -21
- package/packages/temporal-markup/README.md +1 -1
- package/packages/video-cli/README.md +9 -5
- package/packages/video-cli/src/capture.ts +17 -2
- package/packages/video-cli/src/creation.ts +4 -6
- package/packages/video-cli/src/media.ts +10 -3
- package/packages/whisperx/README.md +56 -3
- package/packages/whisperx/src/component.ts +2 -5
- package/packages/whisperx/src/evidence.ts +4 -3
- package/packages/whisperx/src/index.ts +1 -0
- package/packages/whisperx/src/manifest.ts +2 -3
- package/packages/whisperx/src/surface.ts +3 -6
- package/packages/whisperx/src/types.ts +9 -1
- package/packages/yt-dlp/README.md +9 -3
- package/packages/yt-dlp/package.json +2 -1
- package/packages/yt-dlp/src/download.ts +10 -24
- package/packages/yt-dlp/src/environment.ts +36 -0
- package/packages/yt-dlp/src/index.ts +1 -0
- package/services/whisperx/README.md +45 -12
- package/services/whisperx/src/hypit_whisperx_service/application.py +3 -0
- package/services/whisperx/src/hypit_whisperx_service/check.py +7 -0
- package/services/whisperx/src/hypit_whisperx_service/config.py +7 -0
- package/services/whisperx/src/hypit_whisperx_service/engine.py +21 -5
- package/services/whisperx/src/hypit_whisperx_service/models.py +131 -0
- package/services/whisperx/src/hypit_whisperx_service/prepare.py +21 -3
- package/services/whisperx/src/hypit_whisperx_service/resources.py +19 -3
- package/services/yt-dlp/README.md +14 -15
- package/services/yt-dlp/pyproject.toml +1 -1
- 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" ?
|
|
79
|
-
return affinity === "
|
|
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
|
-
/**
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const edits: Edit[] =
|
|
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
|
|
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
|
|
143
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
?
|
|
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
|
-
|
|
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
|
|
12
|
+
String.raw`(?:(?!${CHARACTER_UNIT})[\p{L}\p{N}])${WORD_CHARACTER}*(?:['’.-]${WORD_CHARACTER}+)*`,
|
|
13
13
|
].join("|"), "gu");
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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(
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
142
|
-
if (
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const first = lexicalUnits(
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"|
|
|
76
|
-
"|
|
|
77
|
-
"|
|
|
78
|
-
"| `@id
|
|
79
|
-
"|
|
|
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,
|
|
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
|
|
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;
|