@hypit/hypit 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/public/hyperframes.d.ts +19 -2
- package/package.json +3 -1
- package/packages/caption/src/temporalize.ts +11 -1
- package/packages/caption-fine/README.md +4 -4
- package/packages/credential-store-file/README.md +4 -0
- package/packages/credential-store-file/src/store.ts +2 -2
- package/packages/credential-store-os/runtime/windows-credential.ps1 +3 -0
- package/packages/credential-store-os/src/windows.ts +14 -0
- package/packages/credential-store-platform/README.md +2 -0
- package/packages/hyperframes/README.md +7 -0
- package/packages/hyperframes/src/html-project.ts +99 -0
- package/packages/hyperframes/src/index.ts +2 -0
- package/packages/hyperframes/src/project.ts +28 -0
- package/packages/media-execution/src/execute.ts +21 -3
- package/packages/pixverse/README.md +35 -0
- package/packages/pixverse/package.json +21 -0
- package/packages/pixverse/src/activation.ts +11 -0
- package/packages/pixverse/src/index.ts +122 -0
- package/packages/pixverse/src/surface.ts +146 -0
- package/packages/provider-hiapi/README.md +9 -1
- package/packages/provider-hyperframes-local/README.md +19 -7
- package/packages/provider-hyperframes-local/src/capture.ts +18 -8
- package/packages/provider-hyperframes-local/src/index.ts +1 -1
- package/packages/provider-hyperframes-local/src/output.ts +1 -1
- package/packages/provider-hyperframes-local/src/process-tree.ts +8 -2
- package/packages/provider-hyperframes-local/src/provider.ts +29 -2
- package/packages/provider-hyperframes-local/src/render.ts +45 -20
- package/packages/provider-hyperframes-local/src/sampling.ts +3 -2
- package/packages/provider-hypihub/README.md +10 -2
- package/packages/provider-monid/README.md +29 -9
- package/packages/provider-monid/package.json +2 -1
- package/packages/provider-monid/src/mapping.ts +24 -0
- package/packages/provider-monid/src/provider.ts +24 -11
- package/packages/provider-monid/src/routes.ts +25 -3
- package/packages/provider-pollo/README.md +9 -1
- package/packages/provider-tokendance/README.md +9 -1
- package/packages/provider-whisperx-local/README.md +8 -5
- package/packages/render-hyperframes/README.md +9 -0
- package/packages/render-hyperframes/src/index.ts +3 -0
- package/packages/render-hyperframes/src/manifest.ts +7 -2
- package/packages/render-hyperframes/src/product.ts +41 -2
- package/packages/runtime-local/src/process-control.ts +4 -1
- package/packages/runtime-local/src/programs.ts +15 -3
- package/packages/runtime-local/src/supervisor.ts +8 -1
- package/packages/script/README.md +11 -4
- package/packages/script/src/format.ts +11 -24
- package/packages/script/src/manifest.ts +2 -2
- package/packages/script/src/parser.ts +16 -21
- package/packages/script/src/surface.ts +5 -3
- package/packages/script/src/types.ts +1 -1
- package/packages/source/README.md +1 -1
- package/packages/source/src/header.ts +2 -1
- package/packages/studio/README.md +13 -0
- package/packages/studio/src/feedback-server.ts +7 -0
- package/packages/studio/src/mutation-origin.ts +20 -0
- package/packages/studio/src/parameters.ts +3 -7
- package/packages/studio/src/preview/render.ts +7 -1
- package/packages/studio/src/server.ts +39 -0
- package/packages/studio/src/session.ts +7 -3
- package/packages/studio/src/ui/syntax.ts +11 -10
- package/packages/temporal-markup/EDITING.md +1 -1
- package/packages/video-cli/README.md +47 -4
- package/packages/video-cli/package.json +3 -0
- package/packages/video-cli/src/cli.ts +9 -9
- package/packages/video-cli/src/creation.ts +3 -3
- package/packages/video-cli/src/frame-grid.ts +34 -0
- package/packages/video-cli/src/index.ts +4 -0
- package/packages/video-cli/src/media-frames.ts +25 -0
- package/packages/video-cli/src/media.ts +198 -46
- package/packages/video-cli/src/process.ts +13 -3
- package/packages/video-cli/src/secret-input.ts +14 -0
- package/packages/video-cli/src/snapshot.ts +186 -0
- package/packages/wan/README.md +31 -0
- package/packages/wan/package.json +21 -0
- package/packages/wan/src/activation.ts +11 -0
- package/packages/wan/src/index.ts +160 -0
- package/packages/wan/src/surface.ts +147 -0
- package/packages/yt-dlp/src/download.ts +32 -18
|
@@ -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
|
+
}
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
readonly
|
|
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
|
-
|
|
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
|
|
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<
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
502
|
-
assert(
|
|
503
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
666
|
-
cut: [" hypit media cut <file> --start <s> --end <s> [--label-time] --to <clip.mp4>",
|
|
667
|
-
"
|
|
668
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|