@hypit/hypit 0.2.6 → 0.2.8

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 (70) hide show
  1. package/README.md +1 -1
  2. package/dist/public/hyperframes.d.ts +19 -2
  3. package/examples/provider-package/README.md +2 -2
  4. package/package.json +2 -1
  5. package/packages/caption-fine/README.md +4 -4
  6. package/packages/credential-store-file/README.md +4 -0
  7. package/packages/credential-store-file/src/store.ts +2 -2
  8. package/packages/credential-store-platform/README.md +2 -0
  9. package/packages/hyperframes/README.md +7 -0
  10. package/packages/hyperframes/src/html-project.ts +99 -0
  11. package/packages/hyperframes/src/index.ts +2 -0
  12. package/packages/hyperframes/src/project.ts +28 -0
  13. package/packages/pixverse/README.md +35 -0
  14. package/packages/pixverse/package.json +21 -0
  15. package/packages/pixverse/src/activation.ts +11 -0
  16. package/packages/pixverse/src/index.ts +122 -0
  17. package/packages/pixverse/src/surface.ts +146 -0
  18. package/packages/provider-hiapi/README.md +1 -1
  19. package/packages/provider-hyperframes-local/README.md +19 -7
  20. package/packages/provider-hyperframes-local/src/capture.ts +18 -8
  21. package/packages/provider-hyperframes-local/src/index.ts +1 -1
  22. package/packages/provider-hyperframes-local/src/output.ts +1 -1
  23. package/packages/provider-hyperframes-local/src/process-tree.ts +8 -2
  24. package/packages/provider-hyperframes-local/src/provider.ts +29 -2
  25. package/packages/provider-hyperframes-local/src/render.ts +45 -20
  26. package/packages/provider-hyperframes-local/src/sampling.ts +3 -2
  27. package/packages/provider-hypihub/README.md +3 -4
  28. package/packages/provider-monid/README.md +1 -1
  29. package/packages/provider-tokendance/README.md +1 -1
  30. package/packages/provider-whisperx-local/README.md +8 -5
  31. package/packages/render-hyperframes/README.md +9 -0
  32. package/packages/render-hyperframes/src/index.ts +3 -0
  33. package/packages/render-hyperframes/src/manifest.ts +7 -2
  34. package/packages/render-hyperframes/src/product.ts +41 -2
  35. package/packages/runtime-local/src/process-control.ts +4 -1
  36. package/packages/runtime-local/src/programs.ts +15 -3
  37. package/packages/runtime-local/src/supervisor.ts +8 -1
  38. package/packages/script/README.md +11 -4
  39. package/packages/script/src/format.ts +11 -24
  40. package/packages/script/src/manifest.ts +2 -2
  41. package/packages/script/src/parser.ts +16 -21
  42. package/packages/script/src/surface.ts +5 -3
  43. package/packages/script/src/types.ts +1 -1
  44. package/packages/seedance/README.md +11 -18
  45. package/packages/seedance/src/index.ts +7 -7
  46. package/packages/seedance/src/surface.ts +9 -8
  47. package/packages/seedance-kits/README.md +4 -3
  48. package/packages/source/README.md +1 -1
  49. package/packages/source/src/header.ts +2 -1
  50. package/packages/studio/README.md +13 -0
  51. package/packages/studio/src/feedback-server.ts +7 -0
  52. package/packages/studio/src/mutation-origin.ts +20 -0
  53. package/packages/studio/src/parameters.ts +3 -7
  54. package/packages/studio/src/preview/render.ts +7 -1
  55. package/packages/studio/src/server.ts +39 -0
  56. package/packages/studio/src/session.ts +7 -3
  57. package/packages/studio/src/ui/syntax.ts +11 -10
  58. package/packages/temporal-markup/EDITING.md +1 -1
  59. package/packages/video-cli/README.md +47 -4
  60. package/packages/video-cli/package.json +3 -0
  61. package/packages/video-cli/src/cli.ts +9 -9
  62. package/packages/video-cli/src/creation.ts +3 -3
  63. package/packages/video-cli/src/frame-grid.ts +34 -0
  64. package/packages/video-cli/src/index.ts +4 -0
  65. package/packages/video-cli/src/media-frames.ts +25 -0
  66. package/packages/video-cli/src/media.ts +198 -46
  67. package/packages/video-cli/src/process.ts +13 -3
  68. package/packages/video-cli/src/secret-input.ts +14 -0
  69. package/packages/video-cli/src/snapshot.ts +186 -0
  70. package/packages/yt-dlp/src/download.ts +32 -18
@@ -9,7 +9,7 @@ import type { ServedFile } from "./compile.js";
9
9
  import type { StudioDomain } from "./domain.js";
10
10
  import type { Observations } from "./observe.js";
11
11
  import { preview } from "./programme.js";
12
- import { renderPreview } from "./preview/render.js";
12
+ import { renderStudioProgramme } from "./preview/render.js";
13
13
  import type { RunPlan } from "./run.js";
14
14
  import type { StudioSnapshot } from "./shared.js";
15
15
  import type { StudioCompanionRegistry } from "./studio-registry.js";
@@ -47,6 +47,8 @@ function sourceFiles(run: RunPlan): readonly StudioSourceFile[] {
47
47
 
48
48
  export type StudioSession = {
49
49
  readonly snapshot: StudioSnapshot;
50
+ readonly document: import("@hypit/hyperframes").HyperframesDocument;
51
+ readonly visualHtml: string;
50
52
  readonly material: ReadonlyMap<string, ServedFile>;
51
53
  readonly observations: Observations;
52
54
  readonly projections: readonly StudioViewRequirement[];
@@ -77,7 +79,7 @@ export async function readStudioSession(input: {
77
79
  projections: inspection.projections,
78
80
  ...(input.transientExecution === undefined ? {} : { transientExecution: input.transientExecution }),
79
81
  });
80
- const rendered = renderPreview({
82
+ const rendered = renderStudioProgramme({
81
83
  composition: built.composition,
82
84
  space: built.space as never,
83
85
  served: new Set(built.served.keys()),
@@ -85,6 +87,8 @@ export async function readStudioSession(input: {
85
87
  const text = readFileSync(input.run.authorSource, "utf8");
86
88
  const files = sourceFiles(input.run);
87
89
  return {
90
+ document: rendered.document,
91
+ visualHtml: rendered.html,
88
92
  snapshot: snapshot(input.registry, built, {
89
93
  revision: input.revision,
90
94
  path: input.sourcePath ?? input.run.authorSource,
@@ -99,7 +103,7 @@ export async function readStudioSession(input: {
99
103
  },
100
104
  canvas: built.canvas,
101
105
  frameRate: built.frameRate,
102
- preview: { kind: "hyperframes", srcdoc: rendered },
106
+ preview: { kind: "hyperframes", srcdoc: rendered.preview },
103
107
  workspaceRoot: input.workspaceRoot,
104
108
  sourceFiles: files,
105
109
  surfaces: input.domain.surfaces,
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * A tokenizer for SVML's own surface syntax.
3
3
  *
4
- * SVML has a small, closed grammar tags, quoted strings, whole-value
5
- * references, and Script prose with markers — so it is tokenized directly rather
6
- * than through a general highlighter that has no grammar for it. The Script
4
+ * Recognize the common Markup shell and Script prose for highlighting, without
5
+ * validating package-owned Surfaces. The Script
7
6
  * markers are the point: `@{claim} … @{/claim}` is what a Media Item binds to, so it
8
7
  * has to read as a distinct thing from an ordinary attribute.
9
8
  *
@@ -105,8 +104,7 @@ export function tokenizeSvml(source: string): readonly Token[] {
105
104
  cursor = nameStart + name[0].length;
106
105
  cursor = tokenizeAttributes(source, cursor, push);
107
106
 
108
- // Script is the language's one Raw Surface: its body is prose with
109
- // markers, not markup, and must be scanned by different rules.
107
+ // Script's Raw Surface has its own prose grammar.
110
108
  if (!closing && localName(name[0]) === "script" && source[cursor - 2] !== "/") {
111
109
  cursor = tokenizeScriptBody(source, cursor, name[0], push);
112
110
  }
@@ -167,6 +165,7 @@ function tokenizeAttributes(source: string, from: number, push: Push): number {
167
165
  function tokenizeScriptBody(source: string, from: number, tag: string, push: Push): number {
168
166
  const close = `</${tag}>`;
169
167
  let cursor = from;
168
+ let inSegment = false;
170
169
  while (cursor < source.length) {
171
170
  if (source[cursor] === "\\") { cursor += 2; continue; }
172
171
  if (source.startsWith(close, cursor)) {
@@ -199,14 +198,16 @@ function tokenizeScriptBody(source: string, from: number, tag: string, push: Pus
199
198
  }
200
199
  const closing = source[cursor + 1] === "/";
201
200
  const nameStart = cursor + (closing ? 2 : 1);
202
- const name = NAME.exec(source.slice(nameStart));
203
- if (name === null) { cursor += 1; continue; }
204
201
  const end = source.indexOf(">", nameStart);
205
202
  const stop = end < 0 ? source.length : end + 1;
203
+ const selfClosing = end >= 0 && source[end - 1] === "/";
204
+ const nameEnd = end < 0 ? stop : end - (selfClosing ? 1 : 0);
206
205
  push(cursor, nameStart, "punct");
207
- // An upper-case tag inside Script is a Role Cue, not a Segment.
208
- push(nameStart, nameStart + name[0].length, /^[A-Z]/u.test(name[0]) ? "role" : "tag");
209
- push(nameStart + name[0].length, stop, "punct");
206
+ // Context distinguishes Segments from Role Cues, regardless of language or case.
207
+ push(nameStart, nameEnd, inSegment && !closing ? "role" : "tag");
208
+ push(nameEnd, stop, "punct");
209
+ if (closing) inSegment = false;
210
+ else if (!inSegment && !selfClosing) inSegment = true;
210
211
  cursor = stop;
211
212
  continue;
212
213
  }
@@ -164,7 +164,7 @@ write target. The `fixed` case means no temporal gesture writes that endpoint, n
164
164
  parameter editing is forbidden.
165
165
 
166
166
  - `@hypit/script` owns tokenization, anchor identities, legal marker sites, and structural writeback.
167
- It normalizes equivalent marker whitespace, retains punctuation and attributes, writes coincident
167
+ It relocates markers without rewriting unrelated prose whitespace, retains punctuation and attributes, writes coincident
168
168
  markers together, and preserves all unrelated relationships and caption information.
169
169
  - `@hypit/temporal-markup` owns the interpretation of each time form and declares its write targets.
170
170
  Direct semantic references expose semantic targets; quoted time expressions expose parameter
@@ -1,8 +1,8 @@
1
1
  # `@hypit/video-cli`
2
2
 
3
3
  Official video command application. It selects the Markup compiler Host and supplies one editable
4
- starter Runtime Profile. The CLI still imports no Provider or Store implementation; installed packages
5
- are activated only by explicit Source imports or Profile `use` entries.
4
+ starter Runtime Profile. Execution Endpoints are selected by Profile `use` entries; Source imports
5
+ activate author packages. Immediate tools own their temporary input/output resource storage.
6
6
 
7
7
  Every Frontend, Surface, deterministic Producer and Validator is activated from Source imports.
8
8
  Installing a new author package therefore does not require a video CLI or Core release. Source
@@ -67,9 +67,35 @@ to the chosen file. `measure` estimates a passage locally:
67
67
 
68
68
  ```bash
69
69
  hypit transcribe reference.mp4 --to notes/reference.transcript.json --language en
70
+ hypit transcribe assets/recorded-voice.wav --to notes/voice.transcript.json --language en
70
71
  hypit measure main.svml --segment hook --language en --pace normal --rounding round
71
72
  ```
72
73
 
74
+ `snapshot` follows the same immediate invocation model for picture inspection. Prefer it for
75
+ existing production states and motion sequences, keeping Studio for playback with sound and Builds
76
+ for encoded delivery:
77
+
78
+ ```bash
79
+ hypit snapshot --studio http://localhost:5191 --at-frame 240,255,269 --to evidence/states
80
+ hypit snapshot --studio http://localhost:5191 --start-frame 240 --end-frame-exclusive 270 \
81
+ --grid 4x3 --cell 480 --to evidence/motion
82
+ hypit snapshot ./picture/index.html --at-frame 240 --to evidence/detail
83
+ ```
84
+
85
+ `--studio` reads Studio's current compiled document and its declared resources; a path or HTML URL
86
+ reads materialized HTML with inline scripts/styles and directly addressed media/fonts. The Profile's
87
+ `@hypit/render-hyperframes@1#render-frames` Endpoint returns PNGs in selected-frame order.
88
+ `--runtime` and `--workspace` select the environment just as for `transcribe`. The call streams
89
+ resources through a temporary `FileResourceStore`, writes full-size PNGs and optional grid pages,
90
+ then releases that temporary storage. `--to` names a new directory. `--json` reports paths and original
91
+ frame positions. Grid labels remain outside the picture. No Build, Worker receipt or video encoding
92
+ is created. Provider-owned browser preparation is unchanged.
93
+
94
+ `media frames --every-frame` and `media tiles --every-frame` decode every source frame in a selected
95
+ half-open seconds interval once. Native timestamps, including variable frame rate, are read from
96
+ decoder PTS and time base. `tiles --ranges <json> --every-frame` decodes each listed interval and
97
+ paginates its images. `--transcript` adds word context. This native path uses no FPS resampling.
98
+
73
99
  For `transcribe`, set `--language` to an explicit lowercase two- or three-letter spoken language code,
74
100
  such as `en`, `zh` or `ko`. The selected service owns which languages it can align. Chinese speech uses `zh`, including
75
101
  Chinese speech containing English names. The request selects the recognition language and
@@ -106,6 +132,9 @@ prints what a Source may write:
106
132
  ```bash
107
133
  hypit media probe reference.mp4
108
134
  hypit media cut reference.mp4 --start 12 --end 19.5 --label-time --to notes/hook.mp4
135
+ hypit media cut assets/talk.mp4 --start 12 --end 19.5 --to assets/opening.mp4
136
+ hypit media cut assets/talk.mp4 --keep 12:15.5 --keep 16:19.5 --to assets/opening-edited.mp4
137
+ hypit media cut assets/narration.wav --keep 0.3:4.1 --keep 4.6:9.2 --to assets/narration-edited.wav
109
138
  hypit media frames reference.mp4 --at 12.4,13.1 --label-time --to notes/hook-frames
110
139
  hypit media tile reference.mp4 --start 12 --end 19.5 --to notes/hook-grid.jpg
111
140
  hypit media tile reference.mp4 --at 12.4,13.1,14.8 --columns 3 --to notes/exact-grid.jpg
@@ -119,7 +148,21 @@ hypit vocabulary @hypit/media-pipeline --tag StillVideo
119
148
  hypit vocabulary --visual text
120
149
  ```
121
150
 
122
- `cut` isolates the requested interval and can visibly overlay source time on the evidence copy.
151
+ `probe` accepts audio-only files as well as video. `cut` keeps one interval using `--start` and
152
+ `--end`, or joins explicitly retained, ordered, non-overlapping intervals using repeated
153
+ `--keep start:end` in source seconds. The latter is useful for removing gaps inside one recorded
154
+ performance; it does not decide where Script Segments belong. Video retains its available picture
155
+ and sound together (MP4 is a useful output container); audio-only outputs PCM WAV. A silent source
156
+ video stays silent. The
157
+ `--json` reports the source intervals, their nominal positions on the new local clock, and the
158
+ measured output duration; actual frame and sample boundaries can differ slightly from the nominal
159
+ positions. It writes a new file and refuses to
160
+ overwrite an existing one. `--label-time` retains its single-video-interval role: it visibly
161
+ overlays source time on an inspection copy, not on the clean production media.
162
+
163
+ `transcribe` also accepts audio or video. Its transcript refers to the *input file's* clock. A
164
+ cut or joined file has a new clock; use the final recorded performance and its Script in the
165
+ Build's semantic preparation rather than treating source transcript timestamps as final timing.
123
166
  `frames` writes one JPEG per requested time, selecting the first decoded frame at or after it.
124
167
  Visible frame labels use that frame's actual timestamp, as do the labels below each `tile` cell.
125
168
  Sampling is shared by `frames`, `tile` and `tiles`:
@@ -149,7 +192,7 @@ and its Endpoint remain separate. `boundaries` reports adjacent-frame
149
192
  change candidates and their measured scores; it does not suppress short changes or call them shots.
150
193
  `prepare-fetch` explicitly prepares the locked downloader environment; `fetch` requires it and
151
194
  turns a link into a file with the pinned yt-dlp; [the downloader package](../yt-dlp/README.md)
152
- owns its dependencies, download choices and file handling. Commands that create evidence write only
195
+ owns its dependencies, download choices and file handling. Commands that create files write only
153
196
  what `--to` names and refuse to overwrite. `vocabulary` reads the installed
154
197
  manifests: every package with its tags and models, or one package's Surfaces with their attributes,
155
198
  children and example, or the value shapes a drawing Producer must emit.
@@ -18,6 +18,7 @@
18
18
  "@hypit/driver-node": "workspace:*",
19
19
  "@hypit/estimate": "workspace:*",
20
20
  "@hypit/generation": "workspace:*",
21
+ "@hypit/hyperframes": "workspace:*",
21
22
  "@hypit/markup": "workspace:*",
22
23
  "@hypit/model-kit": "workspace:*",
23
24
  "@hypit/package-loader-node": "workspace:*",
@@ -28,6 +29,8 @@
28
29
  "@hypit/provider-monid": "workspace:*",
29
30
  "@hypit/provider-pollo": "workspace:*",
30
31
  "@hypit/provider-tokendance": "workspace:*",
32
+ "@hypit/render-hyperframes": "workspace:*",
33
+ "@hypit/resource-store-fs": "workspace:*",
31
34
  "@hypit/runtime": "workspace:*",
32
35
  "@hypit/runtime-kit": "workspace:*",
33
36
  "@hypit/runtime-host-node": "workspace:*",
@@ -4,8 +4,10 @@ import type { CliIo } from "@hypit/cli";
4
4
  import { creationCommands, isCreationCommand, writeCreationHelp } from "./creation.js";
5
5
  import { isMediaCommand, mediaCommands, writeMediaHelp } from "./media.js";
6
6
  import { writeVocabularyHelp } from "./vocabulary.js";
7
+ import { writeSnapshotHelp } from "./snapshot.js";
7
8
  import { writeCaptureHelp } from "./capture.js";
8
9
  import { runVersionCli, writeVersionHelp } from "./version.js";
10
+ import { acceptSecretBytes } from "./secret-input.js";
9
11
 
10
12
  const argv = process.argv.slice(2);
11
13
  const json = argv.includes("--json");
@@ -26,22 +28,19 @@ async function readSecret(prompt: string): Promise<string> {
26
28
  }
27
29
  process.stderr.write(prompt);
28
30
  return await new Promise<string>((resolve, reject) => {
29
- let value = "";
31
+ const raw: number[] = [];
30
32
  const finish = (error?: Error): void => {
31
33
  process.stdin.off("data", input);
32
34
  process.stdin.setRawMode(false);
33
35
  process.stdin.pause();
34
36
  process.stderr.write("\n");
35
- if (error === undefined) resolve(value);
37
+ if (error === undefined) resolve(Buffer.from(raw).toString("utf8"));
36
38
  else reject(error);
37
39
  };
38
40
  const input = (chunk: Buffer): void => {
39
- for (const byte of chunk) {
40
- if (byte === 3) { finish(new Error("credential input cancelled")); return; }
41
- if (byte === 10 || byte === 13) { finish(); return; }
42
- if (byte === 8 || byte === 127) { value = value.slice(0, -1); continue; }
43
- value += String.fromCharCode(byte);
44
- }
41
+ const result = acceptSecretBytes(raw, chunk);
42
+ if (result === "cancelled") finish(new Error("credential input cancelled"));
43
+ if (result === "done") finish();
45
44
  };
46
45
  process.stdin.setRawMode(true);
47
46
  process.stdin.resume();
@@ -68,6 +67,7 @@ async function main(): Promise<void> {
68
67
  const topic = argv[0] === "help" ? argv[1]
69
68
  : argv[0] === "--help" ? undefined
70
69
  : argv.includes("--help") ? argv[0] : undefined;
70
+ if (topic === "snapshot") { writeSnapshotHelp(io); return; }
71
71
  if (topic === "version") { writeVersionHelp(io); return; }
72
72
  if (isCreationCommand(topic)) {
73
73
  writeCreationHelp(io, topic);
@@ -89,7 +89,7 @@ async function main(): Promise<void> {
89
89
  writeCliHelp(io, topic);
90
90
  if (topic === undefined) {
91
91
  io.write(`\nInstallation\n version [--check] [--registry <url>] [--json]\n\nCreation tools (one request through the selected Runtime Profile, no Build)\n${
92
- creationCommands.map((item) => ` ${item}`).join("\n")}\n hypit help <tool> for each\n`
92
+ [...creationCommands, "snapshot"].map((item) => ` ${item}`).join("\n")}\n hypit help <tool> for each\n`
93
93
  + `\nStudio\n studio --run <build.svrun>\n hypit studio --help for session options\n\nPreparation (local tools and project files)\n media ${mediaCommands.join(" | ")}\n capture screenshot | run | install-browser\n vocabulary\n hypit help media, hypit help capture, hypit help vocabulary\n`);
94
94
  }
95
95
  return;
@@ -150,7 +150,7 @@ function capabilityName(capability: CapabilityRef): string {
150
150
  }
151
151
 
152
152
  /** Which Endpoint will serve this call and where its Provider publishes prices, before spending. */
153
- async function selectedProvider(host: CreationHost, need: Need, profile: string): Promise<RuntimeHostCapabilityProvider> {
153
+ export async function selectedProvider(host: CreationHost, need: Need, profile: string): Promise<RuntimeHostCapabilityProvider> {
154
154
  const [provider] = await host.providers([{
155
155
  request: need.id,
156
156
  capability: need.capability,
@@ -176,7 +176,7 @@ function priceLine(provider: RuntimeHostCapabilityProvider): string {
176
176
  : provider.pricing.kind === "local" ? "local, no Provider charge" : provider.pricing.url;
177
177
  }
178
178
 
179
- function providerView(provider: RuntimeHostCapabilityProvider) {
179
+ export function providerView(provider: RuntimeHostCapabilityProvider) {
180
180
  return {
181
181
  endpoint: provider.endpoint ?? null,
182
182
  use: provider.use ?? null,
@@ -184,7 +184,7 @@ function providerView(provider: RuntimeHostCapabilityProvider) {
184
184
  };
185
185
  }
186
186
 
187
- function providerLine(provider: RuntimeHostCapabilityProvider): string {
187
+ export function providerLine(provider: RuntimeHostCapabilityProvider): string {
188
188
  return `${provider.endpoint} (${provider.use}) · ${priceLine(provider)}`;
189
189
  }
190
190
 
@@ -0,0 +1,34 @@
1
+ import sharp from "sharp";
2
+
3
+ /** Arrange already captured evidence. Decoding and time selection belong to the caller. */
4
+ export async function writeFrameGrid(
5
+ frames: readonly { readonly path: string; readonly label: Buffer }[],
6
+ target: string, cellWidth: number, columns: number,
7
+ ): Promise<void> {
8
+ if (frames.length === 0) throw new Error("A frame grid needs at least one image");
9
+ const cells = [];
10
+ for (const frame of frames) {
11
+ const picture = await sharp(frame.path).resize({ width: cellWidth }).png().toBuffer({ resolveWithObject: true });
12
+ const labelHeight = (await sharp(frame.label).metadata()).height!;
13
+ cells.push({ picture: picture.data, height: picture.info.height, label: frame.label, labelHeight });
14
+ }
15
+ const pictureHeight = Math.max(...cells.map(cell => cell.height));
16
+ const rowHeight = pictureHeight + Math.max(...cells.map(cell => cell.labelHeight));
17
+ const rows = Math.ceil(cells.length / columns);
18
+ await sharp({ create: {
19
+ width: columns * (cellWidth + 8) + 8, height: rows * (rowHeight + 8) + 8,
20
+ channels: 3, background: "#0c0c0c",
21
+ } }).composite(cells.flatMap((cell, index) => {
22
+ const left = 8 + index % columns * (cellWidth + 8);
23
+ const top = 8 + Math.floor(index / columns) * (rowHeight + 8);
24
+ return [{ input: cell.picture, left, top }, { input: cell.label, left, top: top + pictureHeight }];
25
+ })).toFile(target);
26
+ }
27
+
28
+ export async function snapshotFrameLabel(frame: number, seconds: number, width: number): Promise<Buffer> {
29
+ const text = await sharp({ text: { text: `<span foreground="#f5f5f5">Frame ${frame} · ${seconds.toFixed(6)} s</span>`,
30
+ font: `sans ${Math.max(12, Math.round(width / 28))}`, width: width - 16, rgba: true,
31
+ } }).png().toBuffer({ resolveWithObject: true });
32
+ return sharp({ create: { width, height: text.info.height + 16, channels: 3, background: "#0c0c0c" } })
33
+ .composite([{ input: text.data, left: 8, top: 8 }]).png().toBuffer();
34
+ }
@@ -12,6 +12,7 @@ import { videoCliDistribution } from "./distribution.js";
12
12
  import { runMediaCli } from "./media.js";
13
13
  import { runVocabularyCli } from "./vocabulary.js";
14
14
  import { runCaptureCli } from "./capture.js";
15
+ import { runSnapshotCli } from "./snapshot.js";
15
16
  import { runVersionCli } from "./version.js";
16
17
 
17
18
  export {
@@ -42,6 +43,7 @@ export function runVideoCli(
42
43
  : [videoCliDistribution.packageRoot]);
43
44
  installExternalPackageResolution([hypitHostPackageRoot()]);
44
45
  if (isCreationCommand(argv[0])) return runCreationCli(argv, io);
46
+ if (argv[0] === "snapshot") return runSnapshotCli(argv, io);
45
47
  if (argv[0] === "media") return runMediaCli(argv, io);
46
48
  if (argv[0] === "capture") return runCaptureCli(argv, io);
47
49
  if (argv[0] === "vocabulary") return runVocabularyCli(argv, io);
@@ -50,3 +52,5 @@ export function runVideoCli(
50
52
  bootstrapPackages: packages,
51
53
  });
52
54
  }
55
+
56
+ export { runSnapshotCli, writeSnapshotHelp } from "./snapshot.js";
@@ -0,0 +1,25 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { runProcessOutput } from "./process.js";
4
+
5
+ export type DecodedFrame = { readonly at: number; readonly path: string };
6
+
7
+ /** Decode a source interval once, retaining each decoded frame and its actual source timestamp. */
8
+ export async function decodeMediaFrames(source: string, start: number, end: number, directory: string): Promise<readonly DecodedFrame[]> {
9
+ if (!Number.isFinite(start) || start < 0 || !Number.isFinite(end) || end <= start) throw new Error("Native frames need 0 <= start < end");
10
+ await mkdir(directory, { recursive: true });
11
+ const times: number[] = [];
12
+ let numerator = 0, denominator = 0;
13
+ await runProcessOutput("ffmpeg", ["-hide_banner", "-loglevel", "info", "-nostdin", "-copyts", "-start_at_zero",
14
+ ...(start > 0 ? ["-ss", String(start)] : []), "-i", source,
15
+ "-map", "0:v:0", "-an", "-vf", `trim=start=${start}:end=${end},showinfo=checksum=0`,
16
+ "-fps_mode", "passthrough", "-pix_fmt", "yuvj420p", "-q:v", "3", "-start_number", "0", join(directory, "%09d.jpg"),
17
+ ], 300_000, line => {
18
+ const base = /config in time_base:\s*(\d+)\/(\d+)/u.exec(line);
19
+ if (base !== null) { numerator = Number(base[1]); denominator = Number(base[2]); }
20
+ const pts = /\bn:\s*\d+\s+pts:\s*(-?\d+)/u.exec(line);
21
+ if (pts !== null && denominator > 0) times.push(Number(pts[1]) * numerator / denominator);
22
+ });
23
+ if (times.length === 0) throw new Error(`No video frames in [${start}, ${end}) s`);
24
+ return times.map((at, index) => ({ at, path: join(directory, `${String(index).padStart(9, "0")}.jpg`) }));
25
+ }