@hypit/hypit 0.1.14 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- 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/provider-package/hypit.runtime.json +2 -2
- package/examples/semantic-composition/packages/responsive-explainer/README.md +1 -1
- package/package.json +2 -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-file/README.md +3 -1
- package/packages/credential-store-os/README.md +4 -0
- package/packages/credential-store-os/src/store.ts +5 -1
- package/packages/credential-store-platform/README.md +62 -0
- package/packages/credential-store-platform/package.json +23 -0
- package/packages/credential-store-platform/src/activation.ts +39 -0
- package/packages/credential-store-platform/src/index.ts +1 -0
- package/packages/credential-store-platform/src/store.ts +89 -0
- 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
|
@@ -10,7 +10,7 @@ know that Script, Segment or Narrative exist.
|
|
|
10
10
|
|
|
11
11
|
```svml
|
|
12
12
|
<script id="story">
|
|
13
|
-
@answer
|
|
13
|
+
@{answer}
|
|
14
14
|
|
|
15
15
|
<opening>
|
|
16
16
|
<ALICE> I will make the first point.
|
|
@@ -18,7 +18,7 @@ know that Script, Segment or Narrative exist.
|
|
|
18
18
|
</opening>
|
|
19
19
|
|
|
20
20
|
<pause/>
|
|
21
|
-
|
|
21
|
+
@{/answer}
|
|
22
22
|
</script>
|
|
23
23
|
```
|
|
24
24
|
|
|
@@ -65,8 +65,8 @@ CaptionDocument contracts.
|
|
|
65
65
|
Role Cue or the Segment close ends that turn.
|
|
66
66
|
- **Dual Text**: one authored speech span with separate display and spoken projections, written
|
|
67
67
|
`<display text | spoken text>`. In `<display text|>`, omitted speech inherits the displayed prose.
|
|
68
|
-
- **Selection marker**: a named semantic range, written `@name ...
|
|
69
|
-
- **Moment marker**: a named semantic point, written `@name
|
|
68
|
+
- **Selection marker**: a named semantic range, written `@{name} ... @{/name}`.
|
|
69
|
+
- **Moment marker**: a named semantic point, written `@{name!}`.
|
|
70
70
|
- **CaptionDocument**: the Script-owned caption truth; it contains **Display Words**,
|
|
71
71
|
**Alignment Units** and **Cue Breaks**. It contains no seconds or frames.
|
|
72
72
|
- **Token attribute**: a flat postfix display-word annotation such as `really{emphasis}` or
|
|
@@ -86,7 +86,7 @@ Caption Styles choose whether to highlight, reveal or keep the text steady.
|
|
|
86
86
|
|
|
87
87
|
In a shared side, markers and display attributes do not enter either text projection. Its speech
|
|
88
88
|
Tokens retain offsets into the actually written left-hand text, so Studio can move an anchor inside
|
|
89
|
-
`<组@beat
|
|
89
|
+
`<组@{beat!}件化|>` without expanding the shorthand. Attributes still annotate the preceding display
|
|
90
90
|
word, not the whole group. Whitespace-only speech is omitted; a group with no spoken text on either
|
|
91
91
|
side is invalid. This adds no new public value type or protocol version.
|
|
92
92
|
|
|
@@ -101,8 +101,8 @@ Units; it does not split the Segment, cut the picture or end a Selection.
|
|
|
101
101
|
<script id="story">
|
|
102
102
|
<exchange>
|
|
103
103
|
<HOST> I use it || every day, || since <2012 | twenty twelve>.
|
|
104
|
-
<GUEST> Even @proof on holiday
|
|
105
|
-
<HOST> @answer! Especially then.
|
|
104
|
+
<GUEST> Even @{proof} on holiday @{/proof}?
|
|
105
|
+
<HOST> @{answer!} Especially then.
|
|
106
106
|
</exchange>
|
|
107
107
|
</script>
|
|
108
108
|
```
|
|
@@ -117,19 +117,53 @@ following characters. Display punctuation attaches to neighboring words without
|
|
|
117
117
|
These units support precise timing and highlighting. A Caption Cue can hold a whole phrase of them;
|
|
118
118
|
`||` chooses its handoff independently of character counts or visual line wrapping.
|
|
119
119
|
|
|
120
|
+
Annotations do not create speech boundaries. Comments are transparent (`hel<!--note-->lo`
|
|
121
|
+
remains `hello`); a postfix attribute or `||` inside a word is invalid. Script analyzes a complete
|
|
122
|
+
prose run before binding these constructs. An explicit Dual correspondence and a speaker/Segment
|
|
123
|
+
boundary remain authored structure. Shared Dual groups still expose their internal speech anchors.
|
|
124
|
+
|
|
125
|
+
A Dual display side is literal authored text, including symbols and emoji: `<😀|smile>` and
|
|
126
|
+
`<.|dot>` have explicit speech correspondence and require no invented speech token for the symbol.
|
|
127
|
+
A literal-only display is one display surface within that correspondence.
|
|
128
|
+
|
|
129
|
+
## Display spelling and separators
|
|
130
|
+
|
|
131
|
+
Script preserves the normalized display spelling independently of speech tokenization. Ordinary
|
|
132
|
+
whitespace runs become one space; leading/trailing whitespace in a Turn and padding at the edges
|
|
133
|
+
of a Dual Text side are omitted. No language-specific rule removes a Chinese space or inserts a
|
|
134
|
+
space between numeric and Korean/Latin tokens. `是的 就是这样`, `3개월`, `3 개월`, `3D` and `3 D`
|
|
135
|
+
therefore remain distinct as authored. Source newlines are prose formatting, not Caption Cue breaks.
|
|
136
|
+
Use `||` for Cues and a family's layout controls for visual rows.
|
|
137
|
+
|
|
138
|
+
Each `CaptionDisplayWord.separatorBefore` is `""` or `" "`, relative to the preceding displayed word
|
|
139
|
+
in its Turn. Together with `text`, it carries the display spelling to consumers; it is not a speech
|
|
140
|
+
Token and has no timing. A renderer suppresses the leading separator at a displayed line/Cue start.
|
|
141
|
+
Shared groups retain internal separators and individual speech anchors: `<New York|>` is one
|
|
142
|
+
Alignment Unit with a space inside, while `<3D|>` has none. Grouping is a creative choice, not a
|
|
143
|
+
workaround for preserving spelling.
|
|
144
|
+
|
|
120
145
|
## Selection and Moment affinity
|
|
121
146
|
|
|
147
|
+
Every marker starts with `@{` and ends with `}`. The complete marker is zero-width in speech and
|
|
148
|
+
display; surrounding prose whitespace remains prose. All control sigils belong inside the braces:
|
|
149
|
+
`@{beat!}` is a Moment, whereas `@{part}!` opens a Selection followed by a literal exclamation mark.
|
|
150
|
+
Names match `[a-z][a-z0-9_-]{0,63}`; whitespace and nesting inside a marker are invalid.
|
|
151
|
+
A marker cannot split a speech Token or separate it from attached punctuation: place
|
|
152
|
+
`@{beat!}“测试”`, not `“@{beat!}测试”`. `hello{emphasis}` is a postfix display attribute; `@{part}`
|
|
153
|
+
is consumed as one marker and cannot be mistaken for that attribute. Write a literal `@{part}` as
|
|
154
|
+
`\@\{part\}`.
|
|
155
|
+
|
|
122
156
|
A marker selects the adjacent semantic anchor; it does not write a timecode. Inside a spoken
|
|
123
157
|
passage, its affinity normally chooses a neighboring word boundary:
|
|
124
158
|
|
|
125
159
|
| Marker | Boundary |
|
|
126
160
|
| --- | --- |
|
|
127
|
-
| `@name` | Selection opens at the next word's start: right affinity. |
|
|
128
|
-
|
|
|
129
|
-
|
|
|
130
|
-
|
|
|
131
|
-
| `@name
|
|
132
|
-
|
|
|
161
|
+
| `@{name}` | Selection opens at the next word's start: right affinity. |
|
|
162
|
+
| `@{~name}` | Selection opens at the previous word's end: left affinity. |
|
|
163
|
+
| `@{/name}` | Selection closes at the previous word's end: left affinity. |
|
|
164
|
+
| `@{/name~}` | Selection closes at the next word's start: right affinity. |
|
|
165
|
+
| `@{name!}` | Moment at the next word's start: right affinity. |
|
|
166
|
+
| `@{~name!}` | Moment at the previous word's end: left affinity. |
|
|
133
167
|
|
|
134
168
|
At structural edges, the parser retains the corresponding Segment or program boundary rather than
|
|
135
169
|
inventing a neighboring word. Selection and Moment ids share one namespace. Selections can overlap,
|
|
@@ -138,37 +172,50 @@ cross and span Segments; unlike tags, they do not need to nest.
|
|
|
138
172
|
To join two visual Selections without exposing their inter-word pause, match affinity on both sides:
|
|
139
173
|
|
|
140
174
|
```text
|
|
141
|
-
@coffee my coffee
|
|
142
|
-
@coffee my coffee
|
|
175
|
+
@{coffee} my coffee @{/coffee} @{~smoothie} my smoothie @{/smoothie}
|
|
176
|
+
@{coffee} my coffee @{/coffee~} @{smoothie} my smoothie @{/smoothie}
|
|
143
177
|
```
|
|
144
178
|
|
|
145
179
|
These are alternative spellings, not two occurrences to put in the same Script. In the first,
|
|
146
180
|
“coffee” ends both the first Window and the gap's left boundary, so the smoothie Selection owns the
|
|
147
181
|
pause. In the second, the next “my” starts both touching boundaries, so the coffee Selection owns it.
|
|
148
|
-
Plain
|
|
182
|
+
Plain `@{/coffee} @{smoothie}` leaves the gap between previous word end and next word start outside both.
|
|
149
183
|
The media consumer still decides playback and visual coverage inside those projected Windows.
|
|
150
184
|
|
|
151
185
|
## Marker writeback
|
|
152
186
|
|
|
153
187
|
`adjustScriptSelection` and `adjustScriptMoment` accept explicit anchor identities. A Selection's
|
|
154
|
-
two endpoints are
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
188
|
+
two endpoints are moved together; unrelated markers and all source prose remain untouched. Names
|
|
189
|
+
terminate at `}`, so writeback never inserts a separating space or normalizes surrounding prose.
|
|
190
|
+
A no-op adjustment returns the original source. Script owns legal insertion sites: Tokens and
|
|
191
|
+
postfix attributes stay together, Dual Text markers follow the actual speech side, and coincident
|
|
192
|
+
markers written together have deterministic order. Empty self-closing Segments expand only when a
|
|
193
|
+
requested boundary needs an interior insertion site.
|
|
194
|
+
|
|
195
|
+
Writeback reparses the result to retain the requested bindings and unchanged speech, display and
|
|
196
|
+
Narrative/Caption content. Source offsets remain parser-private; no formatting history is stored.
|
|
197
|
+
|
|
198
|
+
## Explicit migration from 0.1
|
|
199
|
+
|
|
200
|
+
The 0.2 parser rejects bare `@name` markers. Preview migration from the repository or installed
|
|
201
|
+
Distribution root, then explicitly write the reviewed result:
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
node packages/script/bin/migrate-0.2.mjs /path/to/film.svml
|
|
205
|
+
node packages/script/bin/migrate-0.2.mjs /path/to/film.svml --write
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Use `--body` for a file containing a raw Script body rather than outer SVML. The tool converts
|
|
209
|
+
markers only inside Script bodies, leaves comments and escapes intact, and does not touch provider
|
|
210
|
+
prompt references such as `@image1`. It neither installs anything nor runs during a build.
|
|
211
|
+
|
|
212
|
+
The tool changes marker spelling, not marker placement. Move a marker that separates a word from
|
|
213
|
+
its attached quote or punctuation to the complete word boundary before using that source.
|
|
214
|
+
|
|
215
|
+
Review authored whitespace after migration: spaces previously discarded by Chinese/punctuation
|
|
216
|
+
normalization now appear. The tool preserves source spaces rather than guessing the author's intent.
|
|
217
|
+
Regenerate affected Narrative, caption and Build results with the new reader/writer together;
|
|
218
|
+
protocol identities remain `@1`. Existing rendered media is not modified by source migration.
|
|
172
219
|
|
|
173
220
|
## Complete authored content and narrow exports
|
|
174
221
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Explicit, one-time marker migration. Never called by parsing, building, or Studio. */
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
export function migrateScriptBody(source) {
|
|
8
|
+
let result = "";
|
|
9
|
+
for (let cursor = 0; cursor < source.length;) {
|
|
10
|
+
if (source[cursor] === "\\") {
|
|
11
|
+
result += source.slice(cursor, cursor + 2);
|
|
12
|
+
cursor += 2;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (source.startsWith("<!--", cursor)) {
|
|
16
|
+
const close = source.indexOf("-->", cursor + 4);
|
|
17
|
+
if (close < 0) throw new Error("Unclosed Script comment");
|
|
18
|
+
result += source.slice(cursor, close + 3);
|
|
19
|
+
cursor = close + 3;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (source.startsWith("@{", cursor)) {
|
|
23
|
+
const close = source.indexOf("}", cursor + 2);
|
|
24
|
+
if (close < 0) throw new Error("Unclosed delimited Script marker");
|
|
25
|
+
result += source.slice(cursor, close + 1);
|
|
26
|
+
cursor = close + 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const rest = source.slice(cursor);
|
|
30
|
+
const match = /^@\/([a-z][a-z0-9_-]{0,63})(~)/u.exec(rest)
|
|
31
|
+
?? /^@\/([a-z][a-z0-9_-]{0,63})(?![A-Za-z0-9_-])/u.exec(rest);
|
|
32
|
+
const open = match ? undefined : /^(~)?@([a-z][a-z0-9_-]{0,63})(!)/u.exec(rest)
|
|
33
|
+
?? /^(~)?@([a-z][a-z0-9_-]{0,63})(?![A-Za-z0-9_-])/u.exec(rest);
|
|
34
|
+
if (match) {
|
|
35
|
+
result += `@{/${match[1]}${match[2] ?? ""}}`;
|
|
36
|
+
cursor += match[0].length;
|
|
37
|
+
} else if (open) {
|
|
38
|
+
result += `@{${open[1] ?? ""}${open[2]}${open[3] ?? ""}}`;
|
|
39
|
+
cursor += open[0].length;
|
|
40
|
+
} else {
|
|
41
|
+
result += source[cursor++];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Change Script raw bodies only; package references and generation prompt tags stay untouched. */
|
|
48
|
+
export function migrateSvml(source) {
|
|
49
|
+
let result = "";
|
|
50
|
+
let cursor = 0;
|
|
51
|
+
const token = /<!--[\s\S]*?-->|<(?:[A-Za-z_][\w.-]*:)?script(?=[\s/>])(?:"[^"]*"|'[^']*'|[^'">])*>/gu;
|
|
52
|
+
for (let match = token.exec(source); match; match = token.exec(source)) {
|
|
53
|
+
if (match[0].startsWith("<!--") || match[0].endsWith("/>")) continue;
|
|
54
|
+
const name = /^<([^\s>]+)/u.exec(match[0])[1];
|
|
55
|
+
const close = `</${name}>`;
|
|
56
|
+
const start = match.index + match[0].length;
|
|
57
|
+
let end = start;
|
|
58
|
+
while (end < source.length) {
|
|
59
|
+
if (source[end] === "\\") { end += 2; continue; }
|
|
60
|
+
if (source.startsWith("<!--", end)) {
|
|
61
|
+
const stop = source.indexOf("-->", end + 4);
|
|
62
|
+
if (stop < 0) throw new Error("Unclosed Script comment");
|
|
63
|
+
end = stop + 3;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (source.startsWith(close, end)) break;
|
|
67
|
+
end++;
|
|
68
|
+
}
|
|
69
|
+
if (end >= source.length) throw new Error(`Missing ${close}`);
|
|
70
|
+
result += source.slice(cursor, start) + migrateScriptBody(source.slice(start, end)) + close;
|
|
71
|
+
cursor = end + close.length;
|
|
72
|
+
token.lastIndex = cursor;
|
|
73
|
+
}
|
|
74
|
+
return result + source.slice(cursor);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
78
|
+
const args = process.argv.slice(2);
|
|
79
|
+
const flags = new Set(args.filter(arg => arg.startsWith("--")));
|
|
80
|
+
const paths = args.filter(arg => !arg.startsWith("--"));
|
|
81
|
+
if (paths.length !== 1 || [...flags].some(flag => !["--write", "--body"].includes(flag))) {
|
|
82
|
+
console.error("Usage: node migrate-0.2.mjs <source.svml> [--body] [--write]\nDefault: print migrated SVML. --body: input is a raw Script body. --write: replace the named file.");
|
|
83
|
+
process.exitCode = 2;
|
|
84
|
+
} else {
|
|
85
|
+
const source = await readFile(paths[0], "utf8");
|
|
86
|
+
const migrated = flags.has("--body") ? migrateScriptBody(source) : migrateSvml(source);
|
|
87
|
+
if (flags.has("--write")) await writeFile(paths[0], migrated);
|
|
88
|
+
else process.stdout.write(migrated);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -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
|
}
|