@hypit/hypit 0.1.14 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +1 -1
  2. package/dist/public/browser-capture.d.ts +9 -3
  3. package/dist/public/caption.d.ts +2 -0
  4. package/dist/public/narrative.d.ts +2 -0
  5. package/dist/public/runtime-kit.d.ts +2 -2
  6. package/dist/public/speech.d.ts +2 -0
  7. package/examples/provider-package/hypit.runtime.json +2 -2
  8. package/examples/semantic-composition/packages/responsive-explainer/README.md +1 -1
  9. package/package.json +2 -1
  10. package/packages/browser-capture/README.md +9 -2
  11. package/packages/browser-capture/package.json +5 -0
  12. package/packages/browser-capture/src/browser.ts +22 -8
  13. package/packages/browser-capture/src/index.ts +8 -2
  14. package/packages/caption/README.md +1 -1
  15. package/packages/caption/src/display.ts +1 -0
  16. package/packages/caption-fine/README.md +19 -5
  17. package/packages/caption-fine/package.json +1 -0
  18. package/packages/caption-fine/src/joined-box.ts +114 -0
  19. package/packages/caption-fine/src/manifest.ts +1 -1
  20. package/packages/caption-fine/src/render.ts +99 -92
  21. package/packages/caption-fine/src/spacing.ts +8 -61
  22. package/packages/caption-fine-studio/src/index.ts +2 -4
  23. package/packages/cli/README.md +3 -2
  24. package/packages/cli/src/arguments.ts +2 -2
  25. package/packages/cli/src/command.ts +1 -1
  26. package/packages/cli/src/commands/environment.ts +9 -2
  27. package/packages/cli/src/machine-view.ts +1 -1
  28. package/packages/cli/src/output.ts +1 -0
  29. package/packages/credential-store-file/README.md +3 -1
  30. package/packages/credential-store-os/README.md +4 -0
  31. package/packages/credential-store-os/src/store.ts +5 -1
  32. package/packages/credential-store-platform/README.md +62 -0
  33. package/packages/credential-store-platform/package.json +23 -0
  34. package/packages/credential-store-platform/src/activation.ts +39 -0
  35. package/packages/credential-store-platform/src/index.ts +1 -0
  36. package/packages/credential-store-platform/src/store.ts +89 -0
  37. package/packages/narrative/README.md +3 -1
  38. package/packages/narrative/src/schema.ts +1 -0
  39. package/packages/narrative/src/types.ts +2 -0
  40. package/packages/provider-whisperx-local/README.md +26 -10
  41. package/packages/provider-whisperx-local/src/activation.ts +10 -1
  42. package/packages/provider-whisperx-local/src/program.ts +8 -4
  43. package/packages/runtime-host-node/src/index.ts +1 -0
  44. package/packages/runtime-kit/README.md +6 -2
  45. package/packages/runtime-kit/src/index.ts +2 -2
  46. package/packages/runtime-local/README.md +6 -1
  47. package/packages/runtime-local/src/host.ts +2 -0
  48. package/packages/runtime-local/src/index.ts +1 -0
  49. package/packages/runtime-local/src/programs.ts +53 -17
  50. package/packages/script/README.md +81 -34
  51. package/packages/script/bin/migrate-0.2.mjs +90 -0
  52. package/packages/script/src/edit.ts +28 -60
  53. package/packages/script/src/lexical.ts +59 -130
  54. package/packages/script/src/manifest.ts +11 -11
  55. package/packages/script/src/narrative.ts +4 -2
  56. package/packages/script/src/parser.ts +140 -193
  57. package/packages/script/src/types.ts +2 -3
  58. package/packages/studio/src/ui/code.ts +1 -1
  59. package/packages/studio/src/ui/main.ts +2 -2
  60. package/packages/studio/src/ui/markers.ts +3 -3
  61. package/packages/studio/src/ui/selection.ts +1 -1
  62. package/packages/studio/src/ui/syntax.ts +42 -21
  63. package/packages/temporal-markup/README.md +1 -1
  64. package/packages/video-cli/README.md +9 -5
  65. package/packages/video-cli/src/capture.ts +17 -2
  66. package/packages/video-cli/src/creation.ts +4 -6
  67. package/packages/video-cli/src/media.ts +10 -3
  68. package/packages/whisperx/README.md +56 -3
  69. package/packages/whisperx/src/component.ts +2 -5
  70. package/packages/whisperx/src/evidence.ts +4 -3
  71. package/packages/whisperx/src/index.ts +1 -0
  72. package/packages/whisperx/src/manifest.ts +2 -3
  73. package/packages/whisperx/src/surface.ts +3 -6
  74. package/packages/whisperx/src/types.ts +9 -1
  75. package/packages/yt-dlp/README.md +9 -3
  76. package/packages/yt-dlp/package.json +2 -1
  77. package/packages/yt-dlp/src/download.ts +10 -24
  78. package/packages/yt-dlp/src/environment.ts +36 -0
  79. package/packages/yt-dlp/src/index.ts +1 -0
  80. package/services/whisperx/README.md +45 -12
  81. package/services/whisperx/src/hypit_whisperx_service/application.py +3 -0
  82. package/services/whisperx/src/hypit_whisperx_service/check.py +7 -0
  83. package/services/whisperx/src/hypit_whisperx_service/config.py +7 -0
  84. package/services/whisperx/src/hypit_whisperx_service/engine.py +21 -5
  85. package/services/whisperx/src/hypit_whisperx_service/models.py +131 -0
  86. package/services/whisperx/src/hypit_whisperx_service/prepare.py +21 -3
  87. package/services/whisperx/src/hypit_whisperx_service/resources.py +19 -3
  88. package/services/yt-dlp/README.md +14 -15
  89. package/services/yt-dlp/pyproject.toml +1 -1
  90. package/services/yt-dlp/uv.lock +513 -2
@@ -61,13 +61,14 @@ export type ParsedMoment = Omit<NarrativeMoment, "narrativeId"> & {
61
61
  };
62
62
 
63
63
  export type ParsedCaptionRegion = {
64
+ readonly separatorBefore: "" | " ";
64
65
  readonly id: string;
65
66
  readonly display: string;
66
67
  readonly segmentId: string;
67
68
  readonly startToken: number;
68
69
  readonly endTokenExclusive: number;
69
70
  readonly kind: "identity" | "alias" | "hidden";
70
- /** Flat word attributes authored on the display side; indices address displayWordSurfaces(display). */
71
+ /** Flat word attributes authored on the display side; indices address displaySurfaces(display). */
71
72
  readonly marks: readonly {
72
73
  readonly displayIndex: number;
73
74
  readonly attributes: readonly CaptionWordAttribute[];
@@ -81,8 +82,6 @@ export type ParsedNarrative = Omit<
81
82
  > & {
82
83
  /** Exact Script body range, used only for source-preserving Program-boundary edits. */
83
84
  readonly sourceRange: SourceRange;
84
- /** Ordinary source prose/whitespace; excludes tags, Dual Text, attributes and comments. */
85
- readonly proseRanges: readonly SourceRange[];
86
85
  readonly segments: readonly ParsedSegment[];
87
86
  readonly tokens: readonly ParsedToken[];
88
87
  readonly turns: readonly ParsedTurn[];
@@ -354,7 +354,7 @@ export function createCodePane(): CodePane {
354
354
  else {
355
355
  const span = document.createElement("span");
356
356
  // A marker pair shares a tone with the clip it binds, so the two
357
- // ends of `@claim … @/claim` read as one thing.
357
+ // ends of `@{claim}@{/claim}` read as one thing.
358
358
  const tone = token.id === undefined ? undefined : tones.get(token.id);
359
359
  span.className = `tok tok-${token.kind}${tone === undefined ? "" : ` tone-${tone}`}`;
360
360
  if (token.id !== undefined) span.dataset.marker = token.id;
@@ -1034,8 +1034,8 @@ code.element.addEventListener("click", (event) => {
1034
1034
  const clip = clipAtOffset(state.snapshot, offset);
1035
1035
 
1036
1036
  // A click inside marked prose lands inside the innermost marker written there,
1037
- // not at the start of whatever encloses it. `@amount` places nothing, so
1038
- // resolving through clips alone would throw the playhead out to `@fee`.
1037
+ // not at the start of whatever encloses it. `@{amount}` places nothing, so
1038
+ // resolving through clips alone would throw the playhead out to `@{fee}`.
1039
1039
  const span = spanAtOffset(state.snapshot, offset);
1040
1040
  // Prose is anywhere a marker was written; whether a clip also covers that
1041
1041
  // offset only decides which clip to select, not whether the click counts.
@@ -55,12 +55,12 @@ export function intentTones(snapshot: StudioSnapshot): ReadonlyMap<string, numbe
55
55
  * The Script ranges whose speech the playhead is inside, outermost first.
56
56
  *
57
57
  * Segments and Selections alike, because a Segment is a level too: `<price>`
58
- * encloses `@bags`, so drawing only the Selection would show a nesting with its
58
+ * encloses `@{bags}`, so drawing only the Selection would show a nesting with its
59
59
  * outermost level missing.
60
60
  *
61
61
  * Derived from the Script tokens each range encloses rather than from the clips
62
62
  * bound to it, because a range is a claim about speech whether or not anything
63
- * was placed on it. `@amount` inside `@fee` is still inside `@fee` even when
63
+ * was placed on it. `@{amount}` inside `@{fee}` is still inside `@{fee}` even when
64
64
  * only the outer one drives a Media Item.
65
65
  */
66
66
  export type ScriptSpan = {
@@ -110,7 +110,7 @@ export function liveRanges(snapshot: StudioSnapshot, frame: number): readonly Sc
110
110
  *
111
111
  * Clicking marked prose should land inside the thing that was clicked, and the
112
112
  * innermost intent is the most specific claim there. Resolving to a clip alone
113
- * would send a click on `@amount` to whatever encloses it, because a marker that
113
+ * would send a click on `@{amount}` to whatever encloses it, because a marker that
114
114
  * places nothing still means something.
115
115
  */
116
116
  export function spanAtOffset(snapshot: StudioSnapshot, offset: number): ScriptSpan | undefined {
@@ -53,7 +53,7 @@ export type Store = {
53
53
  /**
54
54
  * The clip an author is pointing at in the source text.
55
55
  *
56
- * A Script intent wins over the element that binds it: `@claim … @/claim` sits
56
+ * A Script intent wins over the element that binds it: `@{claim}@{/claim}` sits
57
57
  * inside the `<script>` element, so without that preference every click in the
58
58
  * prose would select the Speech Take instead of the B-roll. Ties break toward
59
59
  * the tightest range, which is the most specific thing under the cursor.
@@ -4,7 +4,7 @@
4
4
  * SVML has a small, closed grammar — tags, quoted strings, whole-value
5
5
  * references, and Script prose with markers — so it is tokenized directly rather
6
6
  * than through a general highlighter that has no grammar for it. The Script
7
- * markers are the point: `@claim … @/claim` is what a Media Item binds to, so it
7
+ * markers are the point: `@{claim}@{/claim}` is what a Media Item binds to, so it
8
8
  * has to read as a distinct thing from an ordinary attribute.
9
9
  *
10
10
  * Tokens are non-overlapping and in source order. Gaps between them are plain
@@ -33,12 +33,30 @@ export type Token = {
33
33
 
34
34
  const NAME = /^[A-Za-z_][A-Za-z0-9_.:-]*/u;
35
35
  const ATTRIBUTE = /^[A-Za-z_][A-Za-z0-9_.:-]*/u;
36
- /**
37
- * The Script's own marker grammar: `@id` opens a Selection, `@/id` closes one,
38
- * `@id!` marks a Moment, and a leading `~` or trailing `~` chooses which side of
39
- * the neighbouring word the marker binds to.
40
- */
41
- const MARKER = /^~?@\/?[a-z][a-z0-9_-]*[!~]?/u;
36
+ /** All control sigils belong inside a delimited Script marker. */
37
+ const MARKER = /^@\{(?:(~)?([a-z][a-z0-9_-]{0,63})(!)?|\/([a-z][a-z0-9_-]{0,63})(~)?)\}/u;
38
+
39
+ function unescapedIndex(source: string, character: string, from: number, to = source.length): number {
40
+ for (let index = from; index < to; index++) {
41
+ if (source[index] === "\\") { index++; continue; }
42
+ if (source[index] === character) return index;
43
+ }
44
+ return -1;
45
+ }
46
+
47
+ function scriptDecoration(source: string, cursor: number, push: Push, markers = true, attributes = true): number {
48
+ if (source[cursor] === "\\") return cursor + 2;
49
+ const marker = markers ? MARKER.exec(source.slice(cursor)) : null;
50
+ if (marker) {
51
+ push(cursor, cursor + marker[0].length, "marker", marker[2] ?? marker[4]);
52
+ return cursor + marker[0].length;
53
+ }
54
+ if (attributes && source[cursor] === "{") {
55
+ const end = unescapedIndex(source, "}", cursor + 1);
56
+ if (end >= 0) { push(cursor, end + 1, "attr"); return end + 1; }
57
+ }
58
+ return cursor + 1;
59
+ }
42
60
 
43
61
  function localName(tag: string): string {
44
62
  const colon = tag.indexOf(":");
@@ -145,11 +163,12 @@ function tokenizeAttributes(source: string, from: number, push: Push): number {
145
163
  return cursor;
146
164
  }
147
165
 
148
- /** Script prose: Segment and Role Cue tags stay tags; `@id` markers stand out. */
166
+ /** Script prose: Segment and Role Cue tags stay tags; `@{id}` markers stand out. */
149
167
  function tokenizeScriptBody(source: string, from: number, tag: string, push: Push): number {
150
168
  const close = `</${tag}>`;
151
169
  let cursor = from;
152
170
  while (cursor < source.length) {
171
+ if (source[cursor] === "\\") { cursor += 2; continue; }
153
172
  if (source.startsWith(close, cursor)) {
154
173
  push(cursor, cursor + 2, "punct");
155
174
  push(cursor + 2, cursor + 2 + tag.length, "tag");
@@ -164,6 +183,20 @@ function tokenizeScriptBody(source: string, from: number, tag: string, push: Pus
164
183
  continue;
165
184
  }
166
185
  if (source[cursor] === "<") {
186
+ const endDual = unescapedIndex(source, ">", cursor + 1);
187
+ const pipe = endDual < 0 ? -1 : unescapedIndex(source, "|", cursor + 1, endDual);
188
+ if (pipe >= 0) {
189
+ const shared = !source.slice(pipe + 1, endDual).trim();
190
+ push(cursor, cursor + 1, "punct");
191
+ cursor++;
192
+ while (cursor < pipe) cursor = scriptDecoration(source, cursor, push, shared, true);
193
+ push(pipe, pipe + 1, "punct");
194
+ cursor = pipe + 1;
195
+ while (cursor < endDual) cursor = scriptDecoration(source, cursor, push, true, false);
196
+ push(endDual, endDual + 1, "punct");
197
+ cursor = endDual + 1;
198
+ continue;
199
+ }
167
200
  const closing = source[cursor + 1] === "/";
168
201
  const nameStart = cursor + (closing ? 2 : 1);
169
202
  const name = NAME.exec(source.slice(nameStart));
@@ -177,19 +210,7 @@ function tokenizeScriptBody(source: string, from: number, tag: string, push: Pus
177
210
  cursor = stop;
178
211
  continue;
179
212
  }
180
- // A leading `~` binds the marker to the word on its left, and is part of it.
181
- if (source[cursor] === "@" || (source[cursor] === "~" && source[cursor + 1] === "@")) {
182
- const marker = MARKER.exec(source.slice(cursor));
183
- if (marker !== null) {
184
- // Strip the affinity sigils, the `@` and the closing `/` so an opening
185
- // marker, its closing marker and a Moment all report the same name.
186
- const id = marker[0].replace(/^~?@\/?/u, "").replace(/[!~]$/u, "");
187
- push(cursor, cursor + marker[0].length, "marker", id);
188
- cursor += marker[0].length;
189
- continue;
190
- }
191
- }
192
- cursor += 1;
213
+ cursor = scriptDecoration(source, cursor, push);
193
214
  }
194
215
  return cursor;
195
216
  }
@@ -75,7 +75,7 @@ time and implements its own schedule or state, while the shared temporal protoco
75
75
  time came from. An outer lifetime and child activations are separate inputs when a component persists
76
76
  between events.
77
77
 
78
- Script's `@`, `~@`, close markers and Moment syntax belong to `@hypit/script`; media playback belongs
78
+ Script's delimited `@{...}` Selection and Moment syntax belong to `@hypit/script`; media playback belongs
79
79
  to `@hypit/media-track`; a graphic component's reveal or preset semantics belong to that component.
80
80
 
81
81
  ## Independently bound endpoints
@@ -42,7 +42,8 @@ in [file CredentialStore](../credential-store-file/README.md#select-it-before-lo
42
42
  `hypit doctor --endpoint <name>` checks a selected Endpoint;
43
43
  `hypit runtime up --endpoint <name>` prepares that Endpoint and starts the Worker. Repeat the flag
44
44
  for several chosen Endpoints; omitting it prepares the whole Profile. `hypit programs up --endpoint
45
- <name>` prepares a local helper independently of the Worker.
45
+ <name>` prepares and starts a local helper independently of the Worker. `hypit programs prepare
46
+ --endpoint <name>` only prepares its selected resources, including for a service already running.
46
47
 
47
48
  With the selected execution environment:
48
49
 
@@ -67,9 +68,11 @@ hypit transcribe reference.mp4 --to notes/reference.transcript.json --language e
67
68
  hypit measure main.svml --segment hook --language en --pace normal --rounding round
68
69
  ```
69
70
 
70
- For `transcribe`, set `--language` to the spoken language: `en`, `zh` or `es`. Chinese speech uses `zh`, including
71
- Chinese speech containing English names; the requested language selects the recognition/alignment
72
- model, independently of the eventual caption font or script's simplified/traditional characters.
71
+ For `transcribe`, set `--language` to an explicit lowercase two- or three-letter spoken language code,
72
+ such as `en`, `zh` or `ko`. The selected service owns which languages it can align. Chinese speech uses `zh`, including
73
+ Chinese speech containing English names. The request selects the recognition language and
74
+ language-specific aligner; ASR size remains a deployment choice. Caption font and Script's
75
+ simplified/traditional characters are independent authoring choices.
73
76
 
74
77
  `transcribe` uses the Profile's `whisperx-alignment` Endpoint (after
75
78
  extracting 16 kHz mono speech audio with ffmpeg). Direct invocation forwards the Provider's progress
@@ -142,7 +145,8 @@ Grid `samples` retain the requested times; `frames` contain the actual extracted
142
145
  It also reports every page path for `tiles`. The media layer reads existing timed text; transcription
143
146
  and its Endpoint remain separate. `boundaries` reports adjacent-frame
144
147
  change candidates and their measured scores; it does not suppress short changes or call them shots.
145
- `fetch` turns a link into a file with the pinned yt-dlp; [the downloader package](../yt-dlp/README.md)
148
+ `prepare-fetch` explicitly prepares the locked downloader environment; `fetch` requires it and
149
+ turns a link into a file with the pinned yt-dlp; [the downloader package](../yt-dlp/README.md)
146
150
  owns its dependencies, download choices and file handling. Commands that create evidence write only
147
151
  what `--to` names and refuse to overwrite. `vocabulary` reads the installed
148
152
  manifests: every package with its tags and models, or one package's Surfaces with their attributes,
@@ -25,6 +25,9 @@ Browser options:
25
25
  --scale <number> Device pixel ratio (default: 1)
26
26
  --channel <name> Installed Chrome channel, e.g. chrome
27
27
  --browser <path> Explicit browser executable instead of a channel
28
+ --browser-version <version> Exact Chrome for Testing version
29
+ --browser-cache <directory> Cache shared by installation and capture
30
+ --browser-download-base-url <url> Compatible archive source for install-browser
28
31
  --headed Show the browser
29
32
  --timeout-ms <milliseconds> Page operation and navigation timeout
30
33
  --json Print all saved files with their actual dimensions
@@ -70,6 +73,7 @@ export async function runCaptureCli(argv: readonly string[], io: CliIo, cwd = pr
70
73
  args: (separator < 0 ? argv : argv.slice(0, separator)).slice(2), allowPositionals: true,
71
74
  options: {
72
75
  viewport: { type: "string" }, scale: { type: "string" }, channel: { type: "string" },
76
+ "browser-version": { type: "string" }, "browser-cache": { type: "string" }, "browser-download-base-url": { type: "string" },
73
77
  browser: { type: "string" }, headed: { type: "boolean" }, "timeout-ms": { type: "string" },
74
78
  json: { type: "boolean" }, debug: { type: "boolean" }, verbose: { type: "boolean" },
75
79
  "no-color": { type: "boolean" }, color: { type: "string" }, help: { type: "boolean" },
@@ -81,14 +85,19 @@ export async function runCaptureCli(argv: readonly string[], io: CliIo, cwd = pr
81
85
  });
82
86
  const values = parsed.values;
83
87
  if (values.help) { writeCaptureHelp(io); return; }
88
+ const managedBrowser = {
89
+ ...(values["browser-version"] === undefined ? {} : { version: values["browser-version"] }),
90
+ ...(values["browser-cache"] === undefined ? {} : { cacheDirectory: resolve(cwd, values["browser-cache"]) }),
91
+ ...(values["browser-download-base-url"] === undefined ? {} : { downloadBaseUrl: values["browser-download-base-url"] }),
92
+ };
84
93
  if (command === "install-browser") {
85
94
  if (parsed.positionals.length > 0 || args.length > 0) throw new Error("install-browser takes no positional arguments");
86
95
  for (const name of Object.keys(values)) {
87
- if (!["json", "debug", "verbose", "color", "no-color"].includes(name)) throw new Error(`install-browser does not use --${name}`);
96
+ if (!["json", "debug", "verbose", "color", "no-color", "browser-version", "browser-cache", "browser-download-base-url"].includes(name)) throw new Error(`install-browser does not use --${name}`);
88
97
  }
89
98
  io.writeProgress?.("Preparing the capture browser…\n");
90
99
  const { installCaptureBrowser } = await import("@hypit/browser-capture");
91
- const path = await installCaptureBrowser();
100
+ const path = await installCaptureBrowser(managedBrowser);
92
101
  io.write(values.json ? `${JSON.stringify({ format: "hypit.capture-browser@1", path })}\n` : `Capture browser ready: ${path}\n`);
93
102
  return;
94
103
  }
@@ -138,6 +147,12 @@ export async function runCaptureCli(argv: readonly string[], io: CliIo, cwd = pr
138
147
  });
139
148
  };
140
149
  }
150
+ if (Object.keys(managedBrowser).length > 0) options = { ...options, browser: { ...options.browser, ...managedBrowser } };
151
+ if (values.browser || values.channel) {
152
+ if (Object.keys(managedBrowser).length > 0) throw new Error("Choose managed browser options or --browser/--channel");
153
+ const { browser: _managed, ...externalOptions } = options;
154
+ options = externalOptions;
155
+ }
141
156
  const launch = { ...options.launch };
142
157
  if (values.browser) { launch.executablePath = resolve(cwd, values.browser); delete launch.channel; }
143
158
  if (values.channel) {
@@ -21,7 +21,7 @@ import { speechEvidenceTypes } from "@hypit/speech-evidence";
21
21
  import type { AlignedTranscriptEvidence } from "@hypit/speech-evidence";
22
22
  import { sealText } from "@hypit/text";
23
23
  import { whisperXCapabilities, whisperXRequestForEvidenceAudio } from "@hypit/whisperx";
24
- import type { WhisperXLanguage } from "@hypit/whisperx";
24
+ import { parseWhisperXLanguage } from "@hypit/whisperx";
25
25
 
26
26
  import { videoCliDistribution } from "./distribution.js";
27
27
  import { runProcess } from "./process.js";
@@ -282,9 +282,7 @@ async function transcribe(argv: readonly string[], io: CliIo, environment: Creat
282
282
  const parsed = parseArguments(argv, ["--language", "--to", "--runtime", "--workspace"]);
283
283
  assert(parsed.positionals.length === 1, "transcribe takes exactly one audio or video file");
284
284
  const source = resolve(environment.cwd, parsed.positionals[0]!);
285
- const language = parsed.options.get("--language");
286
- assert(language === "en" || language === "zh" || language === "es",
287
- "transcribe requires --language en|zh|es for the spoken language (use --language zh for Chinese)");
285
+ const language = parseWhisperXLanguage(parsed.options.get("--language"), "transcribe --language");
288
286
  const to = await destination(parsed, environment.cwd);
289
287
  const { profile, host } = await environment.openHost(
290
288
  parsed.options.get("--runtime"),
@@ -298,7 +296,7 @@ async function transcribe(argv: readonly string[], io: CliIo, environment: Creat
298
296
  id: "need:hypit-transcribe",
299
297
  capability: whisperXCapabilities.alignment,
300
298
  returns: speechEvidenceTypes.alignedTranscript,
301
- constraints: whisperXRequestForEvidenceAudio(audio, { language: language as WhisperXLanguage }),
299
+ constraints: whisperXRequestForEvidenceAudio(audio, { language }),
302
300
  result: "record:hypit-transcribe",
303
301
  };
304
302
  const provider = await selectedProvider(host, need, profile);
@@ -437,7 +435,7 @@ export function writeCreationHelp(io: CliIo, topic?: CreationCommand): void {
437
435
  "hypit transcribe",
438
436
  "Establish word times with the whisperx-alignment Endpoint of the selected Runtime Profile.",
439
437
  "",
440
- " hypit transcribe <audio|video> --to <transcript.json> --language en|zh|es [--runtime <profile>] [--workspace <project>]",
438
+ " hypit transcribe <audio|video> --to <transcript.json> --language <code> [--runtime <profile>] [--workspace <project>]",
441
439
  "",
442
440
  "Extracts 16 kHz mono speech audio with ffmpeg and writes every word with its start and end in",
443
441
  "seconds. One immediate request; no Build, Result or state.",
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { dirname, extname, join, resolve } from "node:path";
4
4
 
5
5
  import type { CliIo } from "@hypit/cli";
6
- import { downloadVideo, isVideoUrl } from "@hypit/yt-dlp";
6
+ import { downloadVideo, isVideoUrl, prepareVideoDownload } from "@hypit/yt-dlp";
7
7
  import sharp from "sharp";
8
8
 
9
9
  import { runProcess, runProcessOutput, runProcessWithInput } from "./process.js";
@@ -15,7 +15,7 @@ import type { FrameWords, TranscriptWord } from "./transcript.js";
15
15
  * the input media. These commands expose evidence; editorial interpretation belongs to the author.
16
16
  */
17
17
 
18
- export const mediaCommands = ["probe", "cut", "frames", "tile", "tiles", "boundaries", "fetch"] as const;
18
+ export const mediaCommands = ["probe", "cut", "frames", "tile", "tiles", "boundaries", "fetch", "prepare-fetch"] as const;
19
19
  export type MediaCommand = typeof mediaCommands[number];
20
20
 
21
21
  function assert(condition: unknown, message: string): asserts condition {
@@ -673,6 +673,7 @@ export function writeMediaHelp(io: CliIo, topic?: MediaCommand): void {
673
673
  " Paginated grids (3 rows by default). Range files contain { start, end, id?, frames?, every? }."],
674
674
  boundaries: [" hypit media boundaries <file> [--rate <samples/s>] [--threshold <0..1>]",
675
675
  " Mechanical adjacent-frame change candidates with scores; never editorial shot labels."],
676
+ "prepare-fetch": [" hypit media prepare-fetch", " Explicitly prepare the pinned yt-dlp environment; does not fetch media."],
676
677
  fetch: [" hypit media fetch <url> --to <video.mp4>", " A link turned into a file with the pinned yt-dlp, video and audio together."],
677
678
  };
678
679
  const chosen = topic === undefined ? mediaCommands : [topic];
@@ -697,5 +698,11 @@ export async function runMediaCli(argv: readonly string[], io: CliIo, cwd = proc
697
698
  else if (command === "tile") await tile(rest, io, cwd);
698
699
  else if (command === "tiles") await tiles(rest, io, cwd);
699
700
  else if (command === "boundaries") await boundaries(rest, io, cwd);
700
- else await fetch(rest, io, cwd);
701
+ else if (command === "prepare-fetch") {
702
+ const parsed = parseArguments(rest, []);
703
+ assert(parsed.positionals.length === 0, "prepare-fetch takes no positional arguments");
704
+ io.writeProgress?.("Preparing the selected yt-dlp environment…\n");
705
+ const executable = prepareVideoDownload();
706
+ io.write(parsed.json ? `${JSON.stringify({ executable })}\n` : `yt-dlp ready: ${executable}\n`);
707
+ } else await fetch(rest, io, cwd);
701
708
  }
@@ -3,7 +3,7 @@
3
3
  Explicit WhisperX model-family capability for the official speech program. Importing this package
4
4
  selects WhisperX; Runtime registration only binds the resulting alignment Need to a concrete
5
5
  execution endpoint. The default Hypit Skill path uses the HypiHub-hosted WhisperX endpoint; the
6
- trusted local worker remains an explicit fallback.
6
+ trusted local worker remains an explicit deployment choice.
7
7
 
8
8
  The package contains no credentials, Python environment or queue. Providers translate the typed
9
9
  request directly into provider-neutral `AlignedTranscriptEvidence`. There is no vendor-shaped
@@ -11,7 +11,8 @@ Evidence wrapper or pass-through normalization node in the graph.
11
11
 
12
12
  `<whisperx:SemanticTake>` is the real-media semantic Surface. It consumes one normalized
13
13
  `SynchronizedMedia` and exactly one Script Segment. When that Segment contains Tokens, it also
14
- requires `language="en"`, `language="zh"` or `language="es"`. The language is passed directly to
14
+ requires an explicit lowercase two- or three-letter language code, such as `language="ko"`.
15
+ This package checks the code's form, not a cross-Provider support list. The language is passed directly to
15
16
  WhisperX; Script text and audio are not used to choose it implicitly. `@hypit/media-pipeline`
16
17
  projects the Take's audio to canonical 16 kHz mono `SpeechEvidenceAudio`; WhisperX sees only those
17
18
  bytes. A deterministic local alignment then combines the returned evidence with the Segment and
@@ -38,4 +39,56 @@ translates their local frames when assembling the final ProgramSpace and complet
38
39
 
39
40
  `@hypit/provider-hypihub` is the default concrete adapter; it uploads the canonical evidence audio
40
41
  and requests verbose JSON with segment- and word-level timestamps. `@hypit/provider-whisperx-local`
41
- remains available as an explicit local fallback.
42
+ remains available as an explicit local deployment.
43
+
44
+
45
+ ## Language selection and local preparation
46
+
47
+ ```svml
48
+ <whisperx:SemanticTake id="opening" narrative={story}
49
+ segment={story.segment.opening} media={opening-media.media} language="ko"/>
50
+ ```
51
+
52
+ The selected service owns which languages it can align. The local service uses its pinned WhisperX
53
+ version's default language-to-alignment-model mapping: the author does not choose weight URLs in
54
+ SVML. ASR size and hardware are separate deployment choices (`expectedModel`, `expectedDevice`,
55
+ `expectedCompute`). A multilingual ASR such as `small` can serve Korean; an English-only `.en`
56
+ model cannot and is rejected explicitly. Unknown alignment languages fail in the service without
57
+ switching language, model family or Provider. Locale aliases such as `zh-CN`, language names and
58
+ `auto` are not interpreted by this Surface; use the service's explicit code.
59
+
60
+ For local execution, merge this into the existing Runtime Profile:
61
+
62
+ ```json
63
+ {
64
+ "endpoints": {
65
+ "whisperx.local": {
66
+ "use": "@hypit/provider-whisperx-local",
67
+ "config": { "expectedModel": "small", "alignmentLanguages": ["ko"] }
68
+ }
69
+ },
70
+ "bindings": {
71
+ "@hypit/whisperx@1#whisperx-alignment": "whisperx.local"
72
+ }
73
+ }
74
+ ```
75
+
76
+ `alignmentLanguages` declares resources to prepare on this machine. Each SVML `language` states
77
+ what that particular Take uses; the preparation list never supplies an implicit author language.
78
+
79
+ ```bash
80
+ hypit programs prepare --runtime hypit.runtime.json --endpoint whisperx.local
81
+ hypit runtime up --runtime hypit.runtime.json --endpoint whisperx.local
82
+ hypit build video.svrun --runtime hypit.runtime.json --follow
83
+ ```
84
+
85
+ The Run references the authored SVML. Preparation downloads missing resources without starting the
86
+ service; `runtime up` prepares if needed and starts the helper and Worker. Build only uses prepared
87
+ resources. Adding a language to the same cache requires preparation, not a service restart.
88
+ See the [local Provider README](../provider-whisperx-local/README.md) for cache and hardware choices.
89
+ Other Endpoints needed by the Run must also be prepared and available.
90
+
91
+ For reference analysis rather than an authored SemanticTake, use the same language code with
92
+ `hypit transcribe source.mp4 --language ko --to transcript.json --runtime hypit.runtime.json`.
93
+ Hosted execution uses the selected hosted deployment's models and preparation; the local
94
+ `alignmentLanguages` option does not configure a remote service.
@@ -6,7 +6,7 @@ import { canonicalize } from "@hypit/protocol";
6
6
 
7
7
  import { whisperXRequestForEvidenceAudio } from "./evidence.js";
8
8
  import { whisperXCapabilities, whisperXProducers } from "./manifest.js";
9
- import type { WhisperXLanguage } from "./types.js";
9
+ import { parseWhisperXLanguage } from "./types.js";
10
10
 
11
11
  function inline(value: StoredValue, subject: string): CanonicalValue {
12
12
  if (value.kind !== "inline") throw new Error(`${subject} must be inline`);
@@ -19,10 +19,7 @@ export const whisperXComponent = {
19
19
  producer: whisperXProducers.request,
20
20
  handler: ({ inputs }) => {
21
21
  const evidence = inline(inputs.evidence!.value, "SpeechEvidenceAudio") as unknown as SpeechEvidenceAudio;
22
- const language = inline(inputs.language!.value, "WhisperXLanguage") as unknown as WhisperXLanguage;
23
- if (language !== "en" && language !== "zh" && language !== "es") {
24
- throw new Error("WhisperXLanguage must be en, zh, or es");
25
- }
22
+ const language = parseWhisperXLanguage(inline(inputs.language!.value, "WhisperXLanguage"));
26
23
  return {
27
24
  outputs: {},
28
25
  needs: { alignment: canonicalize(whisperXRequestForEvidenceAudio(evidence, { language })) },
@@ -1,6 +1,7 @@
1
1
  import { assertSpeechEvidenceAudioIdentity } from "@hypit/speech";
2
2
  import type { SpeechEvidenceAudio } from "@hypit/speech";
3
3
  import type { WhisperXAlignmentRequest, WhisperXLanguage } from "./types.js";
4
+ import { parseWhisperXLanguage } from "./types.js";
4
5
 
5
6
  function assert(condition: unknown, message: string): asserts condition {
6
7
  if (!condition) throw new Error(message);
@@ -14,9 +15,9 @@ export function verifyWhisperXAlignmentRequest(value: unknown): WhisperXAlignmen
14
15
  assert(request.audio?.kind === "blob"
15
16
  && request.audio.mediaType === "audio/wav"
16
17
  && Number.isSafeInteger(request.sampleFrames)
17
- && request.sampleFrames > 0
18
- && (request.language === "en" || request.language === "zh" || request.language === "es"),
18
+ && request.sampleFrames > 0,
19
19
  "WhisperX alignment request is invalid");
20
+ parseWhisperXLanguage(request.language);
20
21
  return request;
21
22
  }
22
23
 
@@ -64,6 +65,6 @@ export function whisperXRequestForEvidenceAudio(
64
65
  return {
65
66
  audio: evidence.artifact,
66
67
  sampleFrames: evidence.sampleFrames,
67
- language: options.language,
68
+ language: parseWhisperXLanguage(options.language),
68
69
  };
69
70
  }
@@ -12,3 +12,4 @@ export { decodeWhisperXSemanticTakeSurface } from "./surface.js";
12
12
  export { interpretWhisperXTranscript } from "./transcript.js";
13
13
  export type { WhisperXTranscriptResponse } from "./transcript.js";
14
14
  export type * from "./types.js";
15
+ export { parseWhisperXLanguage } from "./types.js";
@@ -38,8 +38,7 @@ export const whisperXMarkupSurfaces = [{
38
38
  accepts: [mediaTypes.synchronized],
39
39
  summary: "Selects the already normalized SynchronizedMedia represented by this Segment." },
40
40
  { name: "language", kind: "literal", required: false,
41
- values: ["en", "zh", "es"],
42
- summary: "For a Segment with Tokens, explicitly selects the English, Chinese or Spanish WhisperX models." },
41
+ summary: "For a Segment with Tokens, explicitly states its spoken language code. The selected service owns the available alignment models." },
43
42
  ],
44
43
  ports: [
45
44
  { name: "take", type: speechTypes.semanticTake,
@@ -50,7 +49,7 @@ export const whisperXMarkupSurfaces = [{
50
49
  notes: [
51
50
  "A Segment with Tokens states all five attributes and sends its prepared audio for alignment.",
52
51
  "An empty Segment omits language and maps its authored start/end Anchors directly to the prepared-media boundaries.",
53
- "For speech, language is never detected from Script text or audio; each alignment call states en, zh or es explicitly.",
52
+ "For speech, each alignment call states an explicit lowercase two- or three-letter language code; language is not inferred from Script text or audio.",
54
53
  "Importing this package is what selects the WhisperX model family; the Runtime separately binds the alignment Need to an Endpoint.",
55
54
  ],
56
55
  },
@@ -13,7 +13,7 @@ import { mediaTypes } from "@hypit/media";
13
13
 
14
14
  import { whisperXBoundarySemanticTakeFragment, whisperXSemanticTakeFragment } from "./fragment.js";
15
15
  import { whisperXTypes } from "./manifest.js";
16
- import type { WhisperXLanguage } from "./types.js";
16
+ import { parseWhisperXLanguage } from "./types.js";
17
17
 
18
18
  function reference(
19
19
  element: StructuredElement,
@@ -61,16 +61,13 @@ export const decodeWhisperXSemanticTakeSurface: StructuredSurfaceHandler = ({ el
61
61
  fragments: [whisperXBoundarySemanticTakeFragment],
62
62
  };
63
63
  }
64
- const language = stringAttribute(element, "language");
65
- if (language !== "en" && language !== "zh" && language !== "es") {
66
- throw new Error(`${element.name}.language must be en, zh, or es`);
67
- }
64
+ const language = parseWhisperXLanguage(stringAttribute(element, "language"), `${element.name}.language`);
68
65
  const languageId = `${id}.language`;
69
66
  return {
70
67
  records: [{
71
68
  id: languageId,
72
69
  type: whisperXTypes.language,
73
- value: { kind: "inline", value: language as WhisperXLanguage },
70
+ value: { kind: "inline", value: language },
74
71
  range: element.range,
75
72
  }],
76
73
  components: [{
@@ -1,6 +1,14 @@
1
1
  import type { SpeechEvidenceAudio } from "@hypit/speech";
2
2
 
3
- export type WhisperXLanguage = "en" | "zh" | "es";
3
+ /** Explicit language code. Executable language support belongs to the selected service. */
4
+ export type WhisperXLanguage = string;
5
+
6
+ export function parseWhisperXLanguage(value: unknown, subject = "WhisperX language"): WhisperXLanguage {
7
+ if (typeof value !== "string" || !/^[a-z]{2,3}$/u.test(value) || value === "und") {
8
+ throw new Error(`${subject} must be an explicit lowercase two- or three-letter spoken language code, such as en, zh, or ko; auto and und are not explicit languages`);
9
+ }
10
+ return value;
11
+ }
4
12
 
5
13
  export type WhisperXAlignmentRequest = {
6
14
  readonly audio: SpeechEvidenceAudio["artifact"];
@@ -3,6 +3,7 @@
3
3
  `@hypit/yt-dlp` owns video download for the Hypit Distribution. Video CLI exposes it as:
4
4
 
5
5
  ```bash
6
+ hypit media prepare-fetch
6
7
  hypit media fetch "https://example.com/watch?v=VIDEO_ID" --to references/source.mp4
7
8
  ```
8
9
 
@@ -18,9 +19,14 @@ Runtime Profile and creates no Build.
18
19
  preparation and overwrite policy. The target extension must be `.mp4`, `.mkv`, `.webm` or `.mov`.
19
20
 
20
21
  The downloader locates its declared `@hypit/yt-dlp-service-runtime` package through the active
21
- Distribution/package resolver and invokes that package's locked Python project through `uv`.
22
- Ship its `pyproject.toml` and `uv.lock`; the caller's location and a `services/` ancestor are irrelevant.
23
- `uv` and `ffmpeg` must be available on PATH; the CLI also uses `ffprobe` to report the saved media.
22
+ Distribution/package resolver. `prepareVideoDownload()` (`media prepare-fetch`) explicitly runs
23
+ `uv sync --frozen` into `<hostState>/programs/yt-dlp/<version>/.venv`. `downloadVideo()` only invokes
24
+ that prepared executable and refuses a missing environment. Ship `pyproject.toml` and `uv.lock`;
25
+ the caller's location and a `services/` ancestor are irrelevant. `uv` is needed for preparation;
26
+ `ffmpeg` must be on PATH for fetching, and the CLI uses `ffprobe` to report the saved media.
27
+ The default upstream extras include the EJS solver in that locked environment. Fetch explicitly
28
+ selects the calling Node executable, ignores user yt-dlp configuration/plugins, and disables updates
29
+ and remote component acquisition. Fetching source media remains a network operation.
24
30
 
25
31
  The request selects `bv*+ba/b`, with `res:1080,vcodec:h264` format preferences and `--no-playlist`.
26
32
  Available source streams determine the result. It stages the download in the OS temporary directory,
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@hypit/package-loader-node": "workspace:*",
12
- "@hypit/yt-dlp-service-runtime": "workspace:*"
12
+ "@hypit/yt-dlp-service-runtime": "workspace:*",
13
+ "@hypit/runtime-host-node": "workspace:*"
13
14
  }
14
15
  }