@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
@@ -1,4 +1,4 @@
1
- import { mkdir, mkdtemp, readFile, rename, rm, stat } from "node:fs/promises";
1
+ import { copyFile, link, mkdir, mkdtemp, readFile, rename, rm, stat } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { dirname, extname, join, resolve } from "node:path";
4
4
 
@@ -6,6 +6,8 @@ import type { CliIo } from "@hypit/cli";
6
6
  import { downloadVideo, isVideoUrl, prepareVideoDownload } from "@hypit/yt-dlp";
7
7
  import sharp from "sharp";
8
8
 
9
+ import { decodeMediaFrames } from "./media-frames.js";
10
+ import { writeFrameGrid } from "./frame-grid.js";
9
11
  import { runProcess, runProcessOutput, runProcessWithInput } from "./process.js";
10
12
  import { phraseRanges, readTranscript, wordsAt } from "./transcript.js";
11
13
  import type { FrameWords, TranscriptWord } from "./transcript.js";
@@ -35,13 +37,17 @@ function clamp(value: number, low: number, high: number): number { return Math.m
35
37
  type Parsed = {
36
38
  readonly positionals: readonly string[];
37
39
  readonly options: ReadonlyMap<string, string>;
40
+ readonly repeated: ReadonlyMap<string, readonly string[]>;
38
41
  readonly flags: ReadonlySet<string>;
39
42
  readonly json: boolean;
40
43
  };
41
44
 
42
- function parseArguments(argv: readonly string[], allowed: readonly string[], allowedFlags: readonly string[] = []): Parsed {
45
+ function parseArguments(
46
+ argv: readonly string[], allowed: readonly string[], allowedFlags: readonly string[] = [], repeatable: readonly string[] = [],
47
+ ): Parsed {
43
48
  const positionals: string[] = [];
44
49
  const options = new Map<string, string>();
50
+ const repeated = new Map<string, string[]>();
45
51
  const flags = new Set<string>();
46
52
  let json = false;
47
53
  for (let index = 0; index < argv.length; index += 1) {
@@ -56,13 +62,14 @@ function parseArguments(argv: readonly string[], allowed: readonly string[], all
56
62
  continue;
57
63
  }
58
64
  if (!allowed.includes(item)) throw new Error(`unknown option ${item}`);
59
- if (options.has(item)) throw new Error(`${item} cannot be repeated`);
65
+ if (!repeatable.includes(item) && options.has(item)) throw new Error(`${item} cannot be repeated`);
60
66
  const value = argv[index + 1];
61
67
  if (value === undefined || (value.startsWith("--") && value.length > 2)) throw new Error(`${item} requires a value`);
62
- options.set(item, value);
68
+ if (repeatable.includes(item)) repeated.set(item, [...(repeated.get(item) ?? []), value]);
69
+ else options.set(item, value);
63
70
  index += 1;
64
71
  }
65
- return { positionals, options, flags, json };
72
+ return { positionals, options, repeated, flags, json };
66
73
  }
67
74
 
68
75
  function required(parsed: Parsed, option: string, hint: string): string {
@@ -132,14 +139,22 @@ async function destinationDirectory(parsed: Parsed, cwd: string, hint: string):
132
139
  // ---------------------------------------------------------------------------------------------------
133
140
  // ffmpeg
134
141
 
135
- export type MediaProbe = {
136
- readonly duration: number;
142
+ type VideoProbe = {
143
+ readonly hasVideo: true;
137
144
  readonly width: number;
138
145
  readonly height: number;
139
146
  readonly frameRate: number;
140
- readonly hasAudio: boolean;
141
147
  };
142
148
 
149
+ export type MediaProbe = {
150
+ readonly duration: number;
151
+ readonly hasAudio: boolean;
152
+ } & (VideoProbe | { readonly hasVideo: false });
153
+
154
+ function requireVideo(info: MediaProbe, path: string): asserts info is MediaProbe & VideoProbe {
155
+ assert(info.hasVideo, `${path}: no video stream`);
156
+ }
157
+
143
158
  export async function probeMedia(path: string): Promise<MediaProbe> {
144
159
  const raw = await runProcess("ffprobe", [
145
160
  "-v", "error", "-show_entries", "format=duration:stream=codec_type,width,height,r_frame_rate", "-of", "json", path,
@@ -151,16 +166,20 @@ export async function probeMedia(path: string): Promise<MediaProbe> {
151
166
  const video = parsed.streams?.find((item) => item.codec_type === "video");
152
167
  const duration = Number(parsed.format?.duration);
153
168
  assert(Number.isFinite(duration) && duration > 0, `${path}: duration is unavailable`);
154
- assert(video?.width !== undefined && video.height !== undefined, `${path}: no video stream`);
169
+ const hasAudio = parsed.streams?.some((item) => item.codec_type === "audio") ?? false;
170
+ assert(video !== undefined || hasAudio, `${path}: no audio or video stream`);
171
+ if (video === undefined) return { duration: round(duration), hasVideo: false, hasAudio };
172
+ assert(video.width !== undefined && video.height !== undefined, `${path}: video dimensions are unavailable`);
155
173
  // ffprobe reports the rate as a ratio; a still image reports none.
156
174
  const ratio = (video.r_frame_rate ?? "").split("/");
157
175
  const rate = Number(ratio[0]) / Number(ratio[1] ?? 1);
158
176
  return {
159
177
  duration: round(duration),
178
+ hasVideo: true,
160
179
  width: video.width,
161
180
  height: video.height,
162
181
  frameRate: Number.isFinite(rate) && rate > 0 ? round(rate) : 0,
163
- hasAudio: parsed.streams?.some((item) => item.codec_type === "audio") ?? false,
182
+ hasAudio,
164
183
  };
165
184
  }
166
185
 
@@ -257,7 +276,7 @@ export async function cutClip(
257
276
  start: number,
258
277
  end: number,
259
278
  target: string,
260
- labelInfo?: Pick<MediaProbe, "frameRate">,
279
+ labelInfo?: Pick<VideoProbe, "frameRate">,
261
280
  ): Promise<void> {
262
281
  assert(end > start, `the clip must end after it starts (${start} to ${end})`);
263
282
  const seconds = round(end - start);
@@ -289,6 +308,64 @@ export async function cutClip(
289
308
  }
290
309
  }
291
310
 
311
+ type CutSpan = { readonly start: number; readonly end: number };
312
+
313
+ function keptSpan(raw: string, duration: number): CutSpan {
314
+ const parts = raw.split(":");
315
+ assert(parts.length === 2 && parts.every((part) => part.trim().length > 0), `--keep needs start:end in seconds, got ${raw}`);
316
+ const [start, end] = parts.map(Number);
317
+ assert(Number.isFinite(start) && Number.isFinite(end) && start! >= 0 && end! > start! && end! <= duration + 0.001,
318
+ `--keep ${raw} must satisfy 0 <= start < end <= ${duration} s`);
319
+ return { start: start!, end: end! };
320
+ }
321
+
322
+ /** Decode the selected parts once, join them on a new local clock, and preserve only existing streams. */
323
+ async function cutProduction(source: string, spans: readonly CutSpan[], target: string, info: MediaProbe): Promise<void> {
324
+ const seek = Math.max(0, spans[0]!.start - SEEK_RUN_UP);
325
+ const labels: string[] = [];
326
+ const filters: string[] = [];
327
+ const videoInputs = spans.map((_, index) => `vsrc${index}`);
328
+ const audioInputs = spans.map((_, index) => `asrc${index}`);
329
+ if (info.hasVideo && spans.length > 1) filters.push(`[0:v:0]split=${spans.length}${videoInputs.map((item) => `[${item}]`).join("")}`);
330
+ if (info.hasAudio && spans.length > 1) filters.push(`[0:a:0]asplit=${spans.length}${audioInputs.map((item) => `[${item}]`).join("")}`);
331
+ for (const [index, span] of spans.entries()) {
332
+ const start = round(span.start - seek);
333
+ const end = round(span.end - seek);
334
+ if (info.hasVideo) filters.push(`[${spans.length === 1 ? "0:v:0" : videoInputs[index]}]trim=start=${start}:end=${end},setpts=PTS-STARTPTS[v${index}]`);
335
+ if (info.hasAudio) filters.push(`[${spans.length === 1 ? "0:a:0" : audioInputs[index]}]atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS[a${index}]`);
336
+ labels.push(`${info.hasVideo ? `[v${index}]` : ""}${info.hasAudio ? `[a${index}]` : ""}`);
337
+ }
338
+ if (spans.length > 1) filters.push(`${labels.join("")}concat=n=${spans.length}:v=${info.hasVideo ? 1 : 0}:a=${info.hasAudio ? 1 : 0}${info.hasVideo ? "[v]" : ""}${info.hasAudio ? "[a]" : ""}`);
339
+ const videoLabel = spans.length === 1 ? "[v0]" : "[v]";
340
+ const audioLabel = spans.length === 1 ? "[a0]" : "[a]";
341
+ await runProcess("ffmpeg", [
342
+ "-hide_banner", "-loglevel", "error", "-y",
343
+ ...(seek > 0 ? ["-ss", String(round(seek))] : []), "-i", source,
344
+ "-filter_complex", filters.join(";"),
345
+ ...(info.hasVideo ? ["-map", videoLabel, "-c:v", "libx264", "-preset", "medium", "-crf", "17", "-pix_fmt", "yuv420p", "-fps_mode", "vfr",
346
+ ...([".mp4", ".mov"].includes(extname(target).toLowerCase()) ? ["-movflags", "+faststart"] : [])] : []),
347
+ ...(info.hasAudio ? ["-map", audioLabel, "-c:a", ...(info.hasVideo ? ["aac", "-b:a", "192k"] : ["pcm_s24le"])] : []),
348
+ target,
349
+ ]);
350
+ }
351
+
352
+ /** Publish a completed cut without overwriting a file another process may have created meanwhile. */
353
+ async function writeCut(
354
+ source: string, spans: readonly CutSpan[], target: string, info: MediaProbe, labeled: boolean,
355
+ ): Promise<void> {
356
+ const work = await mkdtemp(join(dirname(target), ".hypit-cut-"));
357
+ const staged = join(work, `cut${extname(target)}`);
358
+ try {
359
+ if (labeled) {
360
+ requireVideo(info, source);
361
+ await cutClip(source, spans[0]!.start, spans[0]!.end, staged, info);
362
+ } else await cutProduction(source, spans, staged, info);
363
+ await link(staged, target);
364
+ } finally {
365
+ await rm(work, { recursive: true, force: true });
366
+ }
367
+ }
368
+
292
369
  export async function cutFrame(source: string, at: number, target: string, labelTime = false): Promise<number> {
293
370
  if (!labelTime) {
294
371
  const jump = Math.max(0, at - SEEK_RUN_UP);
@@ -362,29 +439,16 @@ export async function tileFrames(
362
439
  const temporary = await mkdtemp(join(tmpdir(), "hypit-tile-"));
363
440
  try {
364
441
  const frames: SampledFrame[] = [];
365
- const cells: { picture: Buffer; label: Buffer; height: number; labelHeight: number }[] = [];
442
+ const cells: { path: string; label: Buffer }[] = [];
366
443
  for (let index = 0; index < times.length; index += 1) {
367
444
  const at = times[index]!;
368
445
  const path = join(temporary, `${index}.jpg`);
369
446
  const actual = await cutFrame(source, at, path);
370
447
  const frameWords = words === undefined ? undefined : wordsAt(words, actual);
371
448
  frames.push({ requestedAt: at, at: actual, ...(frameWords === undefined ? {} : { words: frameWords }) });
372
- const picture = await sharp(path).resize({ width: cellWidth }).png().toBuffer({ resolveWithObject: true });
373
- const label = await frameLabel(actual, cellWidth, frameWords);
374
- const labelHeight = (await sharp(label).metadata()).height!;
375
- cells.push({ picture: picture.data, height: picture.info.height, label, labelHeight });
449
+ cells.push({ path, label: await frameLabel(actual, cellWidth, frameWords) });
376
450
  }
377
- const rows = Math.ceil(times.length / columns);
378
- const pictureHeight = Math.max(...cells.map((cell) => cell.height));
379
- const rowHeight = pictureHeight + Math.max(...cells.map((cell) => cell.labelHeight));
380
- await sharp({ create: {
381
- width: columns * (cellWidth + 8) + 8, height: rows * (rowHeight + 8) + 8,
382
- channels: 3, background: "#0c0c0c",
383
- } }).composite(cells.flatMap((cell, index) => {
384
- const left = 8 + (index % columns) * (cellWidth + 8);
385
- const top = 8 + Math.floor(index / columns) * (rowHeight + 8);
386
- return [{ input: cell.picture, left, top }, { input: cell.label, left, top: top + pictureHeight }];
387
- })).jpeg({ quality: 90 }).toFile(target);
451
+ await writeFrameGrid(cells, target, cellWidth, columns);
388
452
  return frames;
389
453
  } finally {
390
454
  await rm(temporary, { recursive: true, force: true });
@@ -490,27 +554,54 @@ async function probe(argv: readonly string[], io: CliIo, cwd: string): Promise<v
490
554
  const source = await sourceFile(parsed, cwd);
491
555
  const info = await probeMedia(source);
492
556
  if (parsed.json) { io.write(`${JSON.stringify({ path: source, ...info }, null, 2)}\n`); return; }
493
- io.write(`${source}\n ${info.duration} s ${info.width}×${info.height} ${info.frameRate > 0 ? `${info.frameRate} fps` : "still"} ${info.hasAudio ? "with audio" : "no audio"}\n`);
557
+ io.write(`${source}\n ${info.duration} s ${info.hasVideo
558
+ ? `${info.width}×${info.height} ${info.frameRate > 0 ? `${info.frameRate} fps` : "still"} ${info.hasAudio ? "with audio" : "no audio"}`
559
+ : "audio only"}\n`);
494
560
  }
495
561
 
496
562
  async function cut(argv: readonly string[], io: CliIo, cwd: string): Promise<void> {
497
- const parsed = parseArguments(argv, ["--start", "--end", "--to"], ["--label-time"]);
563
+ const parsed = parseArguments(argv, ["--start", "--end", "--keep", "--to"], ["--label-time"], ["--keep"]);
498
564
  const source = await sourceFile(parsed, cwd);
499
- const start = secondsOption(parsed, "--start", 0);
500
565
  const info = await probeMedia(source);
501
- const end = secondsOption(parsed, "--end", info.duration);
502
- assert(end <= info.duration + 0.001, `--end ${end} is past the end of the file (${info.duration} s)`);
503
- const target = await destination(parsed, cwd, "the clip to write, for example notes/hook.mp4");
566
+ const kept = parsed.repeated.get("--keep") ?? [];
567
+ assert(kept.length === 0 || (!parsed.options.has("--start") && !parsed.options.has("--end")),
568
+ "choose --keep intervals or --start/--end");
569
+ const spans = kept.length > 0 ? kept.map((item) => keptSpan(item, info.duration)) : [{
570
+ start: secondsOption(parsed, "--start", 0), end: secondsOption(parsed, "--end", info.duration),
571
+ }];
572
+ for (const [index, span] of spans.entries()) {
573
+ assert(span.end > span.start && span.end <= info.duration + 0.001,
574
+ `range must satisfy 0 <= start < end <= ${info.duration} s`);
575
+ if (index > 0) assert(span.start >= spans[index - 1]!.end, "--keep intervals must be ordered and non-overlapping");
576
+ }
504
577
  const labeled = parsed.flags.has("--label-time");
505
- await cutClip(source, start, end, target, labeled ? info : undefined);
506
- if (parsed.json) { io.write(`${JSON.stringify({ path: target, start, end, seconds: round(end - start), labeled }, null, 2)}\n`); return; }
507
- io.write(`${target}\n ${start} s to ${end} s (${round(end - start)} s)${labeled ? ", source time visible" : ""}\n`);
578
+ assert(!labeled || (info.hasVideo && spans.length === 1), "--label-time needs one video interval");
579
+ const target = await destination(parsed, cwd, "the clip to write, for example notes/hook.mp4");
580
+ if (!info.hasVideo) assert(extname(target).toLowerCase() === ".wav", "--to must end in .wav for audio-only media");
581
+ await writeCut(source, spans, target, info, labeled);
582
+ const output = await probeMedia(target);
583
+ const requestedSeconds = round(spans.reduce((total, span) => total + span.end - span.start, 0));
584
+ if (parsed.json) {
585
+ let outputAt = 0;
586
+ const mapping = spans.map((span) => {
587
+ const item = { sourceStart: span.start, sourceEnd: span.end,
588
+ nominalOutputStart: round(outputAt), nominalOutputEnd: round(outputAt + span.end - span.start) };
589
+ outputAt += span.end - span.start;
590
+ return item;
591
+ });
592
+ io.write(`${JSON.stringify({ path: target, ...(spans.length === 1 ? { start: spans[0]!.start, end: spans[0]!.end } : {}),
593
+ spans, mapping, seconds: requestedSeconds, actualSeconds: output.duration, hasVideo: output.hasVideo, hasAudio: output.hasAudio, labeled }, null, 2)}\n`);
594
+ return;
595
+ }
596
+ io.write(`${target}\n ${spans.map((span) => `${span.start}–${span.end} s`).join(" + ")} (${output.duration} s output)${labeled ? ", source time visible" : ""}\n`);
508
597
  }
509
598
 
510
599
  async function frames(argv: readonly string[], io: CliIo, cwd: string): Promise<void> {
511
- const parsed = parseArguments(argv, [...SAMPLE_OPTIONS, "--to"], ["--label-time"]);
600
+ const parsed = parseArguments(argv, [...SAMPLE_OPTIONS, "--to"], ["--label-time", "--every-frame"]);
601
+ if (parsed.flags.has("--every-frame")) { await nativeFrames(parsed, io, cwd, false); return; }
512
602
  const source = await sourceFile(parsed, cwd);
513
603
  const info = await probeMedia(source);
604
+ requireVideo(info, source);
514
605
  const words = await transcriptOption(parsed, cwd);
515
606
  const times = sampleTimes(parsed, info.duration, words, true);
516
607
  const to = await destinationDirectory(parsed, cwd, "the new directory to write the frames into");
@@ -538,6 +629,7 @@ async function tile(argv: readonly string[], io: CliIo, cwd: string): Promise<vo
538
629
  const parsed = parseArguments(argv, [...SAMPLE_OPTIONS, "--frames", "--cell", "--columns", "--to"]);
539
630
  const source = await sourceFile(parsed, cwd);
540
631
  const info = await probeMedia(source);
632
+ requireVideo(info, source);
541
633
  const words = await transcriptOption(parsed, cwd);
542
634
  const times = sampleTimes(parsed, info.duration, words);
543
635
  // Preserve source width unless an exceptionally tiny input needs room for a legible timecode.
@@ -573,9 +665,11 @@ function tileRange(value: unknown, index: number, duration: number): TileRange {
573
665
  }
574
666
 
575
667
  async function tiles(argv: readonly string[], io: CliIo, cwd: string): Promise<void> {
576
- const parsed = parseArguments(argv, [...SAMPLE_OPTIONS, "--ranges", "--frames", "--cell", "--columns", "--rows", "--to"]);
668
+ const parsed = parseArguments(argv, [...SAMPLE_OPTIONS, "--ranges", "--frames", "--cell", "--columns", "--rows", "--to"], ["--every-frame"]);
669
+ if (parsed.flags.has("--every-frame")) { await nativeFrames(parsed, io, cwd, true); return; }
577
670
  const source = await sourceFile(parsed, cwd);
578
671
  const info = await probeMedia(source);
672
+ requireVideo(info, source);
579
673
  const words = await transcriptOption(parsed, cwd);
580
674
  const rangesPath = parsed.options.get("--ranges");
581
675
  let ranges: { id?: string; start: number; end: number; times: readonly number[] }[];
@@ -631,9 +725,65 @@ async function tiles(argv: readonly string[], io: CliIo, cwd: string): Promise<v
631
725
  io.write(`${to}\n ${written.length} time-labeled ${written.length === 1 ? "grid" : "grids"}\n`);
632
726
  }
633
727
 
728
+ /** Native-frame observation: each requested interval is decoded once, then paginated. */
729
+ async function nativeFrames(parsed: Parsed, io: CliIo, cwd: string, grid: boolean): Promise<void> {
730
+ for (const key of ["--at", "--every", "--frames"]) assert(!parsed.options.has(key), `--every-frame cannot be combined with ${key}`);
731
+ const source = await sourceFile(parsed, cwd);
732
+ const info = await probeMedia(source);
733
+ requireVideo(info, source);
734
+ const words = await transcriptOption(parsed, cwd);
735
+ let ranges: TileRange[];
736
+ const rangeFile = parsed.options.get("--ranges");
737
+ if (rangeFile === undefined) ranges = [sampleRange(parsed, info.duration, words)];
738
+ else {
739
+ for (const key of ["--start", "--end", "--around", "--padding", "--occurrence"]) assert(!parsed.options.has(key), `--ranges cannot be combined with ${key}`);
740
+ const raw = JSON.parse(await readFile(resolve(cwd, rangeFile), "utf8"));
741
+ assert(Array.isArray(raw) && raw.length > 0, "--ranges needs a non-empty JSON array");
742
+ ranges = raw.map((value, index) => tileRange(value, index, info.duration));
743
+ assert(ranges.every(range => range.frames === undefined && range.every === undefined), "Native ranges cannot specify frames or every");
744
+ }
745
+ const cell = integerOption(parsed, "--cell", Math.max(80, Math.min(TILE_CELL_WIDTH, info.width)), 80);
746
+ const columns = integerOption(parsed, "--columns", TILE_COLUMNS, 1);
747
+ const rows = integerOption(parsed, "--rows", 3, 1);
748
+ const to = await destinationDirectory(parsed, cwd, "the new directory for native frame evidence");
749
+ const staging = await mkdtemp(join(dirname(to), ".hypit-native-"));
750
+ const written: { at: number; path: string }[] = [];
751
+ const grids: { path: string; frames: readonly { at: number }[] }[] = [];
752
+ try {
753
+ for (const [rangeIndex, range] of ranges.entries()) {
754
+ const decodedDirectory = join(staging, "decoded");
755
+ const decoded = await decodeMediaFrames(source, range.start, range.end, decodedDirectory);
756
+ if (grid) {
757
+ const perPage = columns * rows;
758
+ for (let offset = 0; offset < decoded.length; offset += perPage) {
759
+ const page = decoded.slice(offset, offset + perPage);
760
+ const cells = [];
761
+ for (const frame of page) cells.push({ path: frame.path, label: await frameLabel(frame.at, cell,
762
+ words === undefined ? undefined : wordsAt(words, frame.at)) });
763
+ const name = `${String(rangeIndex + 1).padStart(3, "0")}-${range.id ?? "frames"}-p${String(offset / perPage + 1).padStart(3, "0")}.jpg`;
764
+ await writeFrameGrid(cells, join(staging, name), cell, columns);
765
+ grids.push({ path: join(to, name), frames: page.map(frame => ({ at: frame.at })) });
766
+ }
767
+ } else for (const [index, frame] of decoded.entries()) {
768
+ const name = `frame-${String(index).padStart(9, "0")}.jpg`;
769
+ const target = join(staging, name);
770
+ if (parsed.flags.has("--label-time") || words !== undefined) await writeFrameGrid([{ path: frame.path,
771
+ label: await frameLabel(frame.at, info.width, words === undefined ? undefined : wordsAt(words, frame.at)) }], target, info.width, 1);
772
+ else await copyFile(frame.path, target);
773
+ written.push({ at: frame.at, path: join(to, name) });
774
+ }
775
+ await rm(decodedDirectory, { recursive: true, force: true });
776
+ }
777
+ await rename(staging, to);
778
+ } catch (error) { await rm(staging, { recursive: true, force: true }); throw error; }
779
+ if (parsed.json) io.write(`${JSON.stringify({ directory: to, ...(grid ? { grids } : { frames: written }) }, null, 2)}\n`);
780
+ else io.write(`${to}\n ${grid ? `${grids.length} grids` : `${written.length} frames`} from continuous source decoding\n`);
781
+ }
782
+
634
783
  async function boundaries(argv: readonly string[], io: CliIo, cwd: string): Promise<void> {
635
784
  const parsed = parseArguments(argv, ["--rate", "--threshold"]);
636
785
  const source = await sourceFile(parsed, cwd);
786
+ requireVideo(await probeMedia(source), source);
637
787
  const rate = numberOption(parsed, "--rate", DEFAULT_BOUNDARY_RATE, 1, 120);
638
788
  const threshold = numberOption(parsed, "--threshold", DEFAULT_BOUNDARY_THRESHOLD, 0, 1);
639
789
  const candidates = await visualBoundaries(source, rate, threshold);
@@ -653,6 +803,7 @@ async function fetch(argv: readonly string[], io: CliIo, cwd: string): Promise<v
653
803
  assert([".mp4", ".mkv", ".webm", ".mov"].includes(extname(target).toLowerCase()), "--to must end in .mp4, .mkv, .webm or .mov");
654
804
  await downloadVideo(url, target);
655
805
  const info = await probeMedia(target);
806
+ requireVideo(info, target);
656
807
  if (parsed.json) { io.write(`${JSON.stringify({ path: target, url, ...info }, null, 2)}\n`); return; }
657
808
  io.write(`${target}\n ${info.duration} s ${info.width}×${info.height} ${info.hasAudio ? "with audio" : "no audio"}\n`);
658
809
  }
@@ -662,26 +813,27 @@ async function fetch(argv: readonly string[], io: CliIo, cwd: string): Promise<v
662
813
 
663
814
  export function writeMediaHelp(io: CliIo, topic?: MediaCommand): void {
664
815
  const sections: Record<MediaCommand, readonly string[]> = {
665
- probe: [" hypit media probe <file>", " Duration, size, frame rate and whether there is audio."],
666
- cut: [" hypit media cut <file> --start <s> --end <s> [--label-time] --to <clip.mp4>",
667
- " One exact stretch. --label-time visibly overlays absolute source time on the evidence copy."],
668
- frames: [" hypit media frames <file> (--at <s,s,…> | --every <s>) [--start <s> --end <s>] [--label-time] [--transcript <json>] --to <dir>",
816
+ probe: [" hypit media probe <file>", " Duration and available audio/video streams; video adds size and frame rate."],
817
+ cut: [" hypit media cut <file> [--start <s> --end <s> | --keep <start:end> ...] [--label-time] --to <clip.mp4|clip.wav>",
818
+ " Keep one stretch or join ordered stretches from one source. Audio-only outputs WAV; MP4 is a useful video target.",
819
+ " --label-time overlays source time on a single video evidence copy."],
820
+ frames: [" hypit media frames <file> (--at <s,s,…> | --every <s> | --every-frame) [--start <s> --end <s>] [--label-time] [--transcript <json>] --to <dir>",
669
821
  " Individual frames named by source time; --transcript adds word times and context below each picture."],
670
822
  tile: [" hypit media tile <file> [--start <s> --end <s> | --at <s,s,…>] [--every <s> | --frames <n>] [--transcript <json>] [--cell <px>] [--columns <n>] --to <grid.jpg>",
671
823
  " One grid. --every samples from start, excluding end; --frames chooses evenly spaced midpoints."],
672
- tiles: [" hypit media tiles <file> [--start <s> --end <s> | --at <s,s,…> | --ranges <json>] [--every <s> | --frames <n>] [--transcript <json>] [--cell <px>] [--columns <n>] [--rows <n>] --to <dir>",
673
- " Paginated grids (3 rows by default). Range files contain { start, end, id?, frames?, every? }."],
824
+ tiles: [" hypit media tiles <file> [--start <s> --end <s> | --at <s,s,…> | --ranges <json>] [--every <s> | --frames <n> | --every-frame] [--transcript <json>] [--cell <px>] [--columns <n>] [--rows <n>] --to <dir>",
825
+ " Paginated grids (3 rows by default). --every-frame decodes every original frame once per interval, preserving actual timestamps. Range files contain { start, end, id?, frames?, every? }."],
674
826
  boundaries: [" hypit media boundaries <file> [--rate <samples/s>] [--threshold <0..1>]",
675
827
  " Mechanical adjacent-frame change candidates with scores; never editorial shot labels."],
676
828
  "prepare-fetch": [" hypit media prepare-fetch", " Explicitly prepare the pinned yt-dlp environment; does not fetch media."],
677
829
  fetch: [" hypit media fetch <url> --to <video.mp4>", " A link turned into a file with the pinned yt-dlp, video and audio together."],
678
830
  };
679
831
  const chosen = topic === undefined ? mediaCommands : [topic];
680
- io.write(`hypit media\nInspect media at chosen times and scales. Local work; no Runtime Profile, no request, no state.\n\n${
832
+ io.write(`hypit media\nInspect and prepare media locally. No Runtime Profile, network request or project state.\n\n${
681
833
  chosen.map((item) => sections[item].join("\n")).join("\n\n")}\n\nFrames and grids accept --around <phrase> with --transcript instead of --start/--end.\n`
682
834
  + "Use --occurrence <n> for a repeated phrase; --padding <s> adds time on each side (default 0.3).\n"
683
835
  + "Transcript times must share the input media's clock. An untimed gap is labeled as such, without inferring silence.\n"
684
- + "Commands that create evidence write only what --to names and refuse to overwrite. Add --json for the complete machine view.\n");
836
+ + "Commands write only what --to names and refuse to overwrite. Add --json for the complete machine view.\n");
685
837
  }
686
838
 
687
839
  export async function runMediaCli(argv: readonly string[], io: CliIo, cwd = process.cwd()): Promise<void> {
@@ -9,6 +9,7 @@ function run(
9
9
  args: readonly string[],
10
10
  input: ProcessInput | undefined,
11
11
  timeoutMs: number,
12
+ onStderrLine?: (line: string) => void,
12
13
  ): Promise<ProcessOutput> {
13
14
  return new Promise((resolveRun, reject) => {
14
15
  const child = spawn(executable, [...args], { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], windowsHide: true });
@@ -24,7 +25,16 @@ function run(
24
25
  };
25
26
  const timer = setTimeout(() => { child.kill("SIGKILL"); finish(new Error(`${executable} timed out after ${timeoutMs} ms`)); }, timeoutMs);
26
27
  child.stdout?.on("data", (chunk: Buffer) => out.push(chunk));
27
- child.stderr?.on("data", (chunk: Buffer) => { err = `${err}${chunk.toString("utf8")}`.slice(-100_000); });
28
+ let pendingLine = "";
29
+ child.stderr?.on("data", (chunk: Buffer) => {
30
+ const text = chunk.toString("utf8");
31
+ err = `${err}${text}`.slice(-100_000);
32
+ if (onStderrLine !== undefined) {
33
+ const lines = `${pendingLine}${text}`.split(/\r?\n/u);
34
+ pendingLine = lines.pop()!;
35
+ for (const line of lines) onStderrLine(line);
36
+ }
37
+ });
28
38
  child.on("error", (error) => finish(new Error(`${executable} could not start: ${error.message}`)));
29
39
  child.on("close", (code) => {
30
40
  if (code === 0) finish();
@@ -56,8 +66,8 @@ export async function runProcess(executable: string, args: readonly string[], ti
56
66
  }
57
67
 
58
68
  /** Successful stderr can carry tool metadata, such as the timestamp of an extracted frame. */
59
- export function runProcessOutput(executable: string, args: readonly string[], timeoutMs = 300_000): Promise<ProcessOutput> {
60
- return run(executable, args, undefined, timeoutMs);
69
+ export function runProcessOutput(executable: string, args: readonly string[], timeoutMs = 300_000, onStderrLine?: (line: string) => void): Promise<ProcessOutput> {
70
+ return run(executable, args, undefined, timeoutMs, onStderrLine);
61
71
  }
62
72
 
63
73
  /** The same process boundary when a deterministic byte stream is one of the program's inputs. */
@@ -0,0 +1,14 @@
1
+ /** Keep terminal input as bytes until a complete UTF-8 secret can be decoded. */
2
+ export function acceptSecretBytes(raw: number[], chunk: Uint8Array): "continue" | "done" | "cancelled" {
3
+ for (const byte of chunk) {
4
+ if (byte === 3) return "cancelled";
5
+ if (byte === 10 || byte === 13) return "done";
6
+ if (byte === 8 || byte === 127) {
7
+ while (raw.length > 0 && (raw.at(-1)! & 0xc0) === 0x80) raw.pop();
8
+ raw.pop();
9
+ continue;
10
+ }
11
+ raw.push(byte);
12
+ }
13
+ return "continue";
14
+ }
@@ -0,0 +1,186 @@
1
+ import { createReadStream, createWriteStream } from "node:fs";
2
+ import { mkdir, mkdtemp, readFile, rename, rm, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, extname, join, resolve } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { Readable } from "node:stream";
7
+ import type { ReadableStream as NodeReadableStream } from "node:stream/web";
8
+ import { pipeline } from "node:stream/promises";
9
+ import type { CliIo } from "@hypit/cli";
10
+ import { assertHyperframesDocument, hyperframesHtmlAssetUrls, hyperframesHtmlDomain } from "@hypit/hyperframes";
11
+ import type { HyperframesDocument, HyperframesHtmlProject } from "@hypit/hyperframes";
12
+ import { FileResourceStore } from "@hypit/resource-store-fs";
13
+ import { hyperframesFramesRequest, renderHyperframesCapabilities, renderHyperframesTypes, verifyHyperframesFrames } from "@hypit/render-hyperframes";
14
+ import type { Need } from "@hypit/protocol";
15
+ import { creationEnvironment, selectedProvider, providerLine, providerView } from "./creation.js";
16
+ import type { CreationEnvironment } from "./creation.js";
17
+ import { snapshotFrameLabel, writeFrameGrid } from "./frame-grid.js";
18
+
19
+ const MEDIA_TYPES: Readonly<Record<string, string>> = {
20
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
21
+ ".mp4": "video/mp4", ".webm": "video/webm", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf",
22
+ };
23
+
24
+ async function readProject(source: string, html: string, resources: FileResourceStore): Promise<HyperframesHtmlProject> {
25
+ const assets = [];
26
+ const base = /^https?:/u.test(source) ? new URL(source) : pathToFileURL(source);
27
+ for (const url of hyperframesHtmlAssetUrls(html)) {
28
+ const address = new URL(url, base);
29
+ let mediaType: string | undefined;
30
+ let stream: Readable;
31
+ if (address.protocol === "file:") {
32
+ if (base.protocol !== "file:") throw new Error(`Network HTML cannot read local asset ${url}`);
33
+ const path = fileURLToPath(address);
34
+ mediaType = MEDIA_TYPES[extname(path).toLowerCase()];
35
+ if (mediaType === undefined) throw new Error(`Unsupported snapshot asset ${url}; use compiled HTML with inline scripts and styles`);
36
+ stream = createReadStream(path);
37
+ } else if (address.protocol === "http:" || address.protocol === "https:") {
38
+ const response = await fetch(address);
39
+ if (!response.ok || response.body === null) throw new Error(`Snapshot asset ${url}: HTTP ${response.status}`);
40
+ mediaType = response.headers.get("content-type")?.split(";")[0]?.trim();
41
+ if (mediaType === undefined || !Object.values(MEDIA_TYPES).includes(mediaType)) {
42
+ await response.body.cancel();
43
+ throw new Error(`Unsupported snapshot asset ${url} (${mediaType}); use compiled HTML with inline scripts and styles`);
44
+ }
45
+ stream = Readable.fromWeb(response.body as NodeReadableStream<Uint8Array>);
46
+ } else throw new Error(`Unsupported snapshot asset protocol ${address.protocol}`);
47
+ try { assets.push({ url, artifact: await resources.putStream(stream, mediaType) }); }
48
+ finally { stream.destroy(); }
49
+ }
50
+ return { html, assets };
51
+ }
52
+
53
+ const OPTIONS = ["--studio", "--to", "--at-frame", "--start-frame", "--end-frame-exclusive", "--step-frames", "--grid", "--cell", "--runtime", "--workspace"];
54
+
55
+ export function writeSnapshotHelp(io: CliIo): void {
56
+ io.write(`hypit snapshot\nCapture exact frames from an existing compiled HyperFrames HTML programme through the selected Runtime Profile.\n\n`
57
+ + ` hypit snapshot --studio <studio-url> --at-frame <n[,n,…]> --to <directory>\n`
58
+ + ` hypit snapshot <index.html|HTML-URL> --at-frame <n[,n,…]> --to <directory>\n`
59
+ + ` hypit snapshot <index.html|HTML-URL> --start-frame <n> --end-frame-exclusive <n> [--step-frames <n>] --to <directory>\n`
60
+ + ` [--grid <columns>x<rows>] [--cell <pixels>] [--runtime <profile>] [--workspace <project>] [--json]\n\n`
61
+ + `Frame indices are zero-based on the original programme clock. Ranges exclude the end.\n`
62
+ + `Writes full-size PNGs and optional paginated grids. --cell controls grid image width (default 480).\n`
63
+ + `Uses render-frames, one immediate request. Browser preparation belongs to the selected Provider.\n`
64
+ + `HTML carries the compiled frame clock and media/font URLs; scripts and styles are inline.\nStudio exposes its current programme at http://<host>:<port>/__studio/visual.html.\n`);
65
+ }
66
+
67
+ export async function runSnapshotCli(argv: readonly string[], io: CliIo, environment: CreationEnvironment = creationEnvironment()): Promise<void> {
68
+ if (argv.includes("--help")) { writeSnapshotHelp(io); return; }
69
+ const options = new Map<string, string>();
70
+ const positionals = [];
71
+ const json = argv.includes("--json");
72
+ for (let index = 1; index < argv.length; index++) {
73
+ const arg = argv[index]!;
74
+ if (["--json", "--debug", "--verbose", "--no-color"].includes(arg)) continue;
75
+ if (arg === "--color") { index++; continue; }
76
+ if (!arg.startsWith("--")) { positionals.push(arg); continue; }
77
+ if (!OPTIONS.includes(arg)) throw new Error(`Unknown snapshot option ${arg}`);
78
+ if (options.has(arg)) throw new Error(`${arg} cannot be repeated`);
79
+ const value = argv[++index];
80
+ if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`);
81
+ options.set(arg, value);
82
+ }
83
+ const studio = options.get("--studio");
84
+ if (positionals.length !== (studio === undefined ? 1 : 0) || !options.has("--to")) throw new Error("snapshot takes one HTML input or --studio <url>, and --to <directory>");
85
+ const integer = (raw: string | undefined, label: string, minimum = 0): number => {
86
+ if (raw === undefined || !/^\d+$/u.test(raw) || !Number.isSafeInteger(Number(raw)) || Number(raw) < minimum) throw new Error(`${label} needs an integer >= ${minimum}`);
87
+ return Number(raw);
88
+ };
89
+ const source = studio === undefined
90
+ ? /^https?:\/\//u.test(positionals[0]!) ? positionals[0]! : resolve(environment.cwd, positionals[0]!)
91
+ : new URL("/__studio/document", studio).href;
92
+ let document: HyperframesDocument | undefined;
93
+ let html: string;
94
+ if (studio !== undefined) {
95
+ const response = await fetch(source);
96
+ if (!response.ok) throw new Error(`Studio programme: HTTP ${response.status} ${await response.text()}`);
97
+ document = await response.json() as HyperframesDocument;
98
+ assertHyperframesDocument(document);
99
+ html = document.html;
100
+ } else if (/^https?:/u.test(source)) {
101
+ const response = await fetch(source);
102
+ if (!response.ok) throw new Error(`Snapshot HTML: HTTP ${response.status} ${await response.text()}`);
103
+ html = await response.text();
104
+ } else html = await readFile(source, "utf8");
105
+ const domain = document ?? hyperframesHtmlDomain(html);
106
+ const explicit = options.get("--at-frame");
107
+ let frames: number[];
108
+ if (explicit !== undefined) {
109
+ if (["--start-frame", "--end-frame-exclusive", "--step-frames"].some(key => options.has(key))) throw new Error("Choose --at-frame or a frame range");
110
+ frames = explicit.split(",").map(value => integer(value, "--at-frame"));
111
+ } else {
112
+ const start = integer(options.get("--start-frame"), "--start-frame");
113
+ const end = integer(options.get("--end-frame-exclusive"), "--end-frame-exclusive");
114
+ const step = integer(options.get("--step-frames") ?? "1", "--step-frames", 1);
115
+ if (end <= start || end > domain.frameCount) throw new Error(`The frame range must satisfy 0 <= start < end <= ${domain.frameCount}`);
116
+ frames = Array.from({ length: Math.ceil((end - start) / step) }, (_, index) => start + index * step);
117
+ }
118
+ const grid = options.get("--grid")?.split("x");
119
+ if (grid !== undefined && grid.length !== 2) throw new Error("--grid needs columns x rows, for example 4x3");
120
+ const columns = grid === undefined ? undefined : integer(grid[0], "grid columns", 1);
121
+ const rows = grid === undefined ? undefined : integer(grid[1], "grid rows", 1);
122
+ if (options.has("--cell") && grid === undefined) throw new Error("--cell requires --grid");
123
+ const cell = integer(options.get("--cell") ?? "480", "--cell", 32);
124
+ const to = resolve(environment.cwd, options.get("--to")!);
125
+ if (await stat(to).then(() => true, () => false)) throw new Error(`Destination ${to} already exists`);
126
+ const temporary = await mkdtemp(join(tmpdir(), "hypit-snapshot-"));
127
+ let staging: string | undefined;
128
+ try {
129
+ const resources = new FileResourceStore(temporary);
130
+ let input: { document: HyperframesDocument } | { project: HyperframesHtmlProject };
131
+ if (document !== undefined) {
132
+ for (const artifact of document.artifacts) {
133
+ const response = await fetch(new URL(`/__studio/material/${artifact.resource}`, studio));
134
+ if (!response.ok || response.body === null) throw new Error(`Studio material ${artifact.resource}: HTTP ${response.status}`);
135
+ const stream = Readable.fromWeb(response.body as NodeReadableStream<Uint8Array>);
136
+ try { await resources.writeStream(artifact, stream); }
137
+ finally { stream.destroy(); }
138
+ }
139
+ input = { document };
140
+ } else input = { project: await readProject(source, html, resources) };
141
+ const need: Need = { id: "need:hypit-snapshot", capability: renderHyperframesCapabilities.renderFrames,
142
+ returns: renderHyperframesTypes.frames, result: "record:hypit-snapshot", constraints: hyperframesFramesRequest({ ...input, frames }) };
143
+ const { host, profile } = await environment.openHost(options.get("--runtime"), options.get("--workspace"));
144
+ const provider = await selectedProvider(host, need, profile);
145
+ if (!json) io.write(`Capturing ${frames.length} frames through ${providerLine(provider)}\n`);
146
+ const report = json ? io.writeProgress : io.writeProgress ?? io.write;
147
+ const fulfilled = await host.invoke(need, resources, {
148
+ reportProgress: async progress => { report?.(` · ${progress.phase}\n`); },
149
+ reportDiagnostic: async diagnostic => { report?.(` · ${diagnostic.message}\n`); },
150
+ });
151
+ if (fulfilled.value.kind !== "inline") throw new Error("The frame list came back by reference");
152
+ verifyHyperframesFrames(fulfilled.value.value);
153
+ const images = fulfilled.value.value;
154
+ if (images.length !== frames.length) throw new Error(`Requested ${frames.length} frames, received ${images.length}`);
155
+ await mkdir(dirname(to), { recursive: true });
156
+ staging = await mkdtemp(join(dirname(to), ".hypit-snapshot-"));
157
+ const files = [];
158
+ for (const [index, image] of images.entries()) {
159
+ const frame = frames[index]!;
160
+ const name = `frame-${String(frame).padStart(9, "0")}.png`;
161
+ const stream = await resources.open(image.resource);
162
+ if (stream === undefined) throw new Error(`Snapshot frame ${frame} is unavailable`);
163
+ await pipeline(stream, createWriteStream(join(staging, name), { flags: "wx" }));
164
+ files.push({ frame, seconds: frame * domain.frameRate.denominator / domain.frameRate.numerator, path: join(to, name) });
165
+ }
166
+ const grids: string[] = [];
167
+ if (columns !== undefined && rows !== undefined) {
168
+ const count = columns * rows;
169
+ for (let offset = 0; offset < files.length; offset += count) {
170
+ const page = [];
171
+ for (const file of files.slice(offset, offset + count)) page.push({ path: join(staging, `frame-${String(file.frame).padStart(9, "0")}.png`),
172
+ label: await snapshotFrameLabel(file.frame, file.seconds, cell) });
173
+ const name = `grid-${String(grids.length + 1).padStart(3, "0")}.jpg`;
174
+ await writeFrameGrid(page, join(staging, name), cell, columns);
175
+ grids.push(join(to, name));
176
+ }
177
+ }
178
+ await rename(staging, to);
179
+ staging = undefined;
180
+ if (json) io.write(`${JSON.stringify({ format: "hypit.video-cli-snapshot@1", source, ...providerView(provider), frames: files, grids }, null, 2)}\n`);
181
+ else io.write(`Captured ${files.length} PNGs${grids.length === 0 ? "" : ` and ${grids.length} grid${grids.length === 1 ? "" : "s"}`}\n ${to}\n`);
182
+ } finally {
183
+ await rm(temporary, { recursive: true, force: true });
184
+ if (staging !== undefined) await rm(staging, { recursive: true, force: true });
185
+ }
186
+ }