Add HyperFrames skills
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011h6NB8JQtfPc6eRCefEzBB
This commit is contained in:
964 files changed
+142321
No files matched your search
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { duckKeyframes, duckLane, speechSpans } from "./lib/duck.mjs";
|
||||
import { track } from "./lib/telemetry.mjs";
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
meta: { type: "string" },
|
||||
target: { type: "string" },
|
||||
duck: { type: "string", default: "0.25" },
|
||||
attack: { type: "string", default: "0.15" },
|
||||
release: { type: "string", default: "0.4" },
|
||||
"merge-gap": { type: "string", default: "0.6" },
|
||||
sequential: { type: "boolean", default: false },
|
||||
gap: { type: "string", default: "0" },
|
||||
offsets: { type: "string" },
|
||||
composition: { type: "string" },
|
||||
json: { type: "boolean", default: false },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
if (args.help) {
|
||||
console.log(`media-use audio-duck — generate a volume ducking lane (data-automation)
|
||||
|
||||
Usage:
|
||||
node audio-duck.mjs --meta audio_meta.json --target "#bgm"
|
||||
|
||||
Options:
|
||||
--meta audio_meta.json or JSON word transcript
|
||||
--target GSAP selector for the background audio element
|
||||
--duck Duck multiplier (default: 0.25)
|
||||
--attack Duck-in duration seconds (default: 0.15)
|
||||
--release Restore duration seconds (default: 0.4)
|
||||
--merge-gap Bridge speech gaps smaller than this many seconds (default: 0.6)
|
||||
--sequential Place multi-line meta back to back at composition time
|
||||
--gap Extra seconds between sequential lines (default: 0)
|
||||
--offsets Explicit placement, "l1=0,l2=3.4" (voice id = start seconds)
|
||||
--composition Read target data-volume and data-start from this HTML file
|
||||
--json Output { spans, keyframes, lane }
|
||||
--help, -h Show this help`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
run();
|
||||
await track("media_use_duck", { sequential: !!args.sequential });
|
||||
} catch (err) {
|
||||
if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
|
||||
else console.error(`error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (!args.meta || !args.target) throw new Error("--meta and --target are required");
|
||||
const meta = JSON.parse(readFileSync(resolve(args.meta), "utf8"));
|
||||
const target = args.target;
|
||||
const { baseVolume, clipStart } = readTargetAttrs(args.composition, target);
|
||||
const offsets = args.offsets
|
||||
? Object.fromEntries(
|
||||
args.offsets.split(",").map((pair) => {
|
||||
const [id, t] = pair.split("=");
|
||||
return [id.trim(), Number(t)];
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
const spans = speechSpans(meta, {
|
||||
mergeGap: Number(args["merge-gap"]),
|
||||
sequential: args.sequential,
|
||||
gap: Number(args.gap),
|
||||
offsets,
|
||||
});
|
||||
const keyframes = duckKeyframes(spans, {
|
||||
duck: Number(args.duck),
|
||||
attack: Number(args.attack),
|
||||
release: Number(args.release),
|
||||
baseVolume,
|
||||
});
|
||||
|
||||
const lane = duckLane(keyframes, { clipStart, baseVolume });
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify({ spans, keyframes, lane }));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`<!-- auto-duck: ${target} under narration; add to its <audio> element -->`);
|
||||
console.log(`data-automation='${JSON.stringify(lane)}'`);
|
||||
}
|
||||
|
||||
function readTargetAttrs(composition, target) {
|
||||
if (!composition || !target.startsWith("#")) return { baseVolume: 1, clipStart: 0 };
|
||||
const id = target.slice(1);
|
||||
const html = readFileSync(resolve(composition), "utf8");
|
||||
// ponytail: regex is enough here because this only reads one attribute from
|
||||
// one user-authored composition element, not arbitrary HTML.
|
||||
const tag = html.match(new RegExp(`<[^>]*\\bid=["']${escapeRegExp(id)}["'][^>]*>`, "i"))?.[0];
|
||||
const attr = (name) => Number(tag?.match(new RegExp(`\\b${name}=["']([^"']+)["']`, "i"))?.[1]);
|
||||
const volume = attr("data-volume");
|
||||
const start = attr("data-start");
|
||||
return {
|
||||
baseVolume: Number.isFinite(volume) ? volume : 1,
|
||||
clipStart: Number.isFinite(start) ? start : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
process.env.HYPERFRAMES_MEDIA_HOME = mkdtempSync(join(tmpdir(), "media-use-compat-home-"));
|
||||
|
||||
function run(scriptsDir, script, args) {
|
||||
return execFileSync(process.execPath, [join(scriptsDir, script), ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
test("published memory scripts run without reaching outside the skill", () => {
|
||||
const scratchDir = mkdtempSync(join(tmpdir(), "media-use-compat-"));
|
||||
const skillDir = join(scratchDir, "media-use");
|
||||
cpSync(new URL("..", import.meta.url), skillDir, { recursive: true });
|
||||
const scriptsDir = join(skillDir, "scripts");
|
||||
const projectDir = join(scratchDir, "project");
|
||||
try {
|
||||
run(scriptsDir, "prefs.mjs", ["get", "--hyperframes", projectDir, "--json"]);
|
||||
run(scriptsDir, "recipe.mjs", ["list", "--hyperframes", projectDir, "--json"]);
|
||||
run(scriptsDir, "transcribe.mjs", ["--help"]);
|
||||
} finally {
|
||||
rmSync(scratchDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("published audio helpers import without the CLI package tree", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "media-use-audio-"));
|
||||
const skill = join(root, "media-use");
|
||||
try {
|
||||
cpSync(new URL("..", import.meta.url), skill, { recursive: true });
|
||||
for (const helper of ["heygen.mjs", "tts.mjs"])
|
||||
run(join(skill, "audio/scripts/lib"), helper, []);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
for (const name of ["faceless-explainer", "pr-to-video", "product-launch-video"]) {
|
||||
test(name + " assembles from its installed directory alone", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "installed-workflow-"));
|
||||
try {
|
||||
const skill = join(root, name);
|
||||
cpSync(new URL("../../" + name, import.meta.url), skill, { recursive: true });
|
||||
const project = join(root, "project");
|
||||
mkdirSync(join(project, "compositions/frames"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(project, "STORYBOARD.md"),
|
||||
"---\nformat: 1920x1080\nmessage: Test\n---\n\n## Frame 1\n- duration: 3s\n- src: compositions/frames/01-a.html\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(project, "compositions/frames/01-a.html"),
|
||||
'<template><div data-composition-id="01-a" data-duration="3" data-width="1920" data-height="1080"><section class="clip" data-start="0" data-duration="3"></section></div></template>',
|
||||
);
|
||||
run(join(skill, "scripts"), "assemble-index.mjs", [
|
||||
"--hyperframes",
|
||||
project,
|
||||
"--storyboard",
|
||||
join(project, "STORYBOARD.md"),
|
||||
]);
|
||||
assert.match(readFileSync(join(project, "index.html"), "utf8"), /01-a/);
|
||||
importPacketBuilder(join(skill, "scripts/frame-packets.mjs"));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("general-video packet builder starts from its installed directory alone", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "installed-packets-"));
|
||||
try {
|
||||
cpSync(new URL("../../general-video", import.meta.url), root, { recursive: true });
|
||||
importPacketBuilder(join(root, "scripts/frame-packets.mjs"));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function importPacketBuilder(path) {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
'const m = await import(process.argv[1]); if (typeof m.buildFramePackets !== "function") throw new Error("missing builder");',
|
||||
pathToFileURL(path).href,
|
||||
],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, extname, resolve } from "node:path";
|
||||
import { text } from "node:stream/consumers";
|
||||
import { parseArgs } from "node:util";
|
||||
import {
|
||||
ERROR_DIFFUSION_ALGORITHMS,
|
||||
applyErrorDiffusionRgba,
|
||||
errorDiffusionBufferLength,
|
||||
} from "./lib/error-diffusion.mjs";
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"]);
|
||||
const OUTPUT_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
input: { type: "string", short: "i" },
|
||||
out: { type: "string", short: "o" },
|
||||
algorithm: { type: "string", short: "a", default: "floyd-steinberg" },
|
||||
palette: { type: "string", default: "#000000,#ffffff" },
|
||||
"point-size": { type: "string", default: "3" },
|
||||
brightness: { type: "string", default: "1" },
|
||||
contrast: { type: "string", default: "1.2" },
|
||||
detail: { type: "string", default: "1" },
|
||||
json: { type: "boolean", default: false },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
if (args.help) {
|
||||
console.log(`media-use dither — exact cached error-diffusion for image or MP4 media
|
||||
|
||||
Usage:
|
||||
node dither.mjs --input in.mp4 --out out.mp4 [options]
|
||||
|
||||
Options:
|
||||
--algorithm, -a ${Object.keys(ERROR_DIFFUSION_ALGORITHMS).join(" | ")}
|
||||
--palette 2-6 authored-order #rrggbb colors, comma-separated
|
||||
--point-size Block size in pixels, 1-20 (default: 3)
|
||||
--brightness 0.5-2 (default: 1)
|
||||
--contrast 0.5-2 (default: 1.2)
|
||||
--detail Diffused-error strength, 0.1-1 (default: 1)
|
||||
--json Output JSON status
|
||||
--help, -h Show this help
|
||||
|
||||
Video output uses the source average frame rate as CFR; VFR cadence is normalized.
|
||||
|
||||
After processing, register the output with:
|
||||
node resolve.mjs --from <output> --type image|video`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await run();
|
||||
if (args.json) console.log(JSON.stringify({ ok: true, ...result }));
|
||||
else {
|
||||
console.log(`dithered ${result.input} -> ${result.out} (${result.algorithm})`);
|
||||
console.log(`next: resolve --from ${result.out} --type ${result.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (args.json) console.log(JSON.stringify({ ok: false, error: message }));
|
||||
else console.error(`error: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!args.input || !args.out) throw new Error("--input and --out are required");
|
||||
const inputPath = resolve(args.input);
|
||||
const outPath = resolve(args.out);
|
||||
if (!existsSync(inputPath)) throw new Error(`input file not found: ${inputPath}`);
|
||||
if (inputPath === outPath) throw new Error("--out must differ from --input");
|
||||
|
||||
const metadata = probe(inputPath);
|
||||
if (metadata.colorTransfer === "smpte2084" || metadata.colorTransfer === "arib-std-b67") {
|
||||
throw new Error(
|
||||
`HDR ${metadata.colorTransfer} input is not supported by the 8-bit SDR dither processor; tone-map to Rec.709 first`,
|
||||
);
|
||||
}
|
||||
const options = {
|
||||
algorithm: args.algorithm,
|
||||
palette: args.palette.split(",").map((color) => color.trim()),
|
||||
pointSize: Number(args["point-size"]),
|
||||
brightness: Number(args.brightness),
|
||||
contrast: Number(args.contrast),
|
||||
detail: Number(args.detail),
|
||||
};
|
||||
// Validate before starting FFmpeg or creating an output file.
|
||||
applyErrorDiffusionRgba(new Uint8ClampedArray(4), 1, 1, options, new Float32Array(3));
|
||||
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
const inputIsImage = IMAGE_EXTENSIONS.has(extname(inputPath).toLowerCase());
|
||||
if (inputIsImage) {
|
||||
if (!OUTPUT_IMAGE_EXTENSIONS.has(extname(outPath).toLowerCase())) {
|
||||
throw new Error("image output must use .png, .jpg, .jpeg, or .webp");
|
||||
}
|
||||
processImage(inputPath, outPath, metadata, options);
|
||||
} else {
|
||||
if (extname(outPath).toLowerCase() !== ".mp4") throw new Error("video output must use .mp4");
|
||||
await processVideo(inputPath, outPath, metadata, options);
|
||||
}
|
||||
|
||||
return {
|
||||
input: inputPath,
|
||||
out: outPath,
|
||||
type: inputIsImage ? "image" : "video",
|
||||
algorithm: options.algorithm,
|
||||
palette: options.palette,
|
||||
point_size: options.pointSize,
|
||||
brightness: options.brightness,
|
||||
contrast: options.contrast,
|
||||
detail: options.detail,
|
||||
};
|
||||
}
|
||||
|
||||
function probe(filePath) {
|
||||
const raw = execFileSync(
|
||||
"ffprobe",
|
||||
["-v", "error", "-print_format", "json", "-show_streams", "-show_format", "--", filePath],
|
||||
{ encoding: "utf8", timeout: 10_000 },
|
||||
);
|
||||
const parsed = JSON.parse(raw);
|
||||
const video = parsed.streams?.find((stream) => stream.codec_type === "video");
|
||||
if (!video?.width || !video?.height)
|
||||
throw new Error(`no readable video/image stream: ${filePath}`);
|
||||
const fps = usableFrameRate(video.avg_frame_rate) ?? usableFrameRate(video.r_frame_rate) ?? "30";
|
||||
return {
|
||||
width: video.width,
|
||||
height: video.height,
|
||||
fps,
|
||||
colorTransfer: video.color_transfer || "",
|
||||
};
|
||||
}
|
||||
|
||||
function processImage(inputPath, outPath, metadata, options) {
|
||||
const frameBytes = metadata.width * metadata.height * 4;
|
||||
const rgba = execFileSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-",
|
||||
],
|
||||
{ maxBuffer: frameBytes + 1024 },
|
||||
);
|
||||
if (rgba.length !== frameBytes)
|
||||
throw new Error(`decoded ${rgba.length} bytes; expected ${frameBytes}`);
|
||||
applyErrorDiffusionRgba(rgba, metadata.width, metadata.height, options);
|
||||
|
||||
const temporary = temporaryOutput(outPath);
|
||||
try {
|
||||
execFileSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-s:v",
|
||||
`${metadata.width}x${metadata.height}`,
|
||||
"-i",
|
||||
"-",
|
||||
"-frames:v",
|
||||
"1",
|
||||
temporary,
|
||||
],
|
||||
{ input: rgba, maxBuffer: frameBytes + 1024 },
|
||||
);
|
||||
renameSync(temporary, outPath);
|
||||
} finally {
|
||||
rmSync(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function processVideo(inputPath, outPath, metadata, options) {
|
||||
const temporary = temporaryOutput(outPath);
|
||||
const frameBytes = metadata.width * metadata.height * 4;
|
||||
const keyframeInterval = String(Math.max(1, Math.round(frameRateNumber(metadata.fps))));
|
||||
const errors = new Float32Array(
|
||||
errorDiffusionBufferLength(metadata.width, metadata.height, options.pointSize),
|
||||
);
|
||||
const decoder = spawn("ffmpeg", [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-",
|
||||
]);
|
||||
const encoder = spawn("ffmpeg", [
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-s:v",
|
||||
`${metadata.width}x${metadata.height}`,
|
||||
"-r",
|
||||
metadata.fps,
|
||||
"-i",
|
||||
"-",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a?",
|
||||
"-map_metadata",
|
||||
"1",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"18",
|
||||
"-g",
|
||||
keyframeInterval,
|
||||
"-keyint_min",
|
||||
keyframeInterval,
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-x264-params",
|
||||
"colorprim=bt709:transfer=bt709:colormatrix=bt709",
|
||||
"-color_primaries:v",
|
||||
"bt709",
|
||||
"-color_trc:v",
|
||||
"bt709",
|
||||
"-colorspace:v",
|
||||
"bt709",
|
||||
"-color_range",
|
||||
"tv",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-shortest",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
temporary,
|
||||
]);
|
||||
const decoderError = text(decoder.stderr);
|
||||
const encoderError = text(encoder.stderr);
|
||||
const decoderDone = once(decoder, "close").then(([code]) => code ?? 1);
|
||||
const encoderDone = once(encoder, "close").then(([code]) => code ?? 1);
|
||||
|
||||
try {
|
||||
const frame = Buffer.allocUnsafe(frameBytes);
|
||||
let frameOffset = 0;
|
||||
for await (const chunk of decoder.stdout) {
|
||||
let chunkOffset = 0;
|
||||
while (chunkOffset < chunk.length) {
|
||||
const length = Math.min(frameBytes - frameOffset, chunk.length - chunkOffset);
|
||||
chunk.copy(frame, frameOffset, chunkOffset, chunkOffset + length);
|
||||
chunkOffset += length;
|
||||
frameOffset += length;
|
||||
if (frameOffset !== frameBytes) continue;
|
||||
applyErrorDiffusionRgba(frame, metadata.width, metadata.height, options, errors);
|
||||
await writeFrame(encoder.stdin, frame);
|
||||
frameOffset = 0;
|
||||
}
|
||||
}
|
||||
if (frameOffset)
|
||||
throw new Error(`decoder returned a partial RGBA frame (${frameOffset} bytes)`);
|
||||
encoder.stdin.end();
|
||||
const [decoderCode, encoderCode] = await Promise.all([decoderDone, encoderDone]);
|
||||
if (decoderCode !== 0) throw new Error(`FFmpeg decode failed: ${(await decoderError).trim()}`);
|
||||
if (encoderCode !== 0) throw new Error(`FFmpeg encode failed: ${(await encoderError).trim()}`);
|
||||
renameSync(temporary, outPath);
|
||||
} catch (error) {
|
||||
decoder.kill("SIGKILL");
|
||||
encoder.kill("SIGKILL");
|
||||
throw error;
|
||||
} finally {
|
||||
rmSync(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeFrame(stream, frame) {
|
||||
return new Promise((resolveWrite, reject) => {
|
||||
stream.write(frame, (error) => (error ? reject(error) : resolveWrite()));
|
||||
});
|
||||
}
|
||||
|
||||
function usableFrameRate(value) {
|
||||
if (!value || value === "0/0") return null;
|
||||
const number = frameRateNumber(value);
|
||||
return Number.isFinite(number) && number > 0 ? value : null;
|
||||
}
|
||||
|
||||
function frameRateNumber(value) {
|
||||
const [numerator, denominator = "1"] = value.split("/");
|
||||
return Number(numerator) / Number(denominator);
|
||||
}
|
||||
|
||||
function temporaryOutput(outPath) {
|
||||
const extension = extname(outPath);
|
||||
return `${outPath.slice(0, -extension.length)}.part-${process.pid}${extension}`;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL("./dither.mjs", import.meta.url));
|
||||
const HAS_FFMPEG =
|
||||
spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0 &&
|
||||
spawnSync("ffprobe", ["-version"], { stdio: "ignore" }).status === 0;
|
||||
|
||||
function fixture() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "media-use-dither-"));
|
||||
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
function ffmpeg(args) {
|
||||
execFileSync("ffmpeg", ["-y", "-hide_banner", "-loglevel", "error", ...args]);
|
||||
}
|
||||
|
||||
function run(args) {
|
||||
return spawnSync(process.execPath, [SCRIPT, ...args, "--json"], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
test("processes an image into the requested ordered palette", { skip: !HAS_FFMPEG }, (t) => {
|
||||
const { dir, cleanup } = fixture();
|
||||
t.after(cleanup);
|
||||
const input = join(dir, "source.png");
|
||||
const output = join(dir, "dithered.png");
|
||||
ffmpeg(["-f", "lavfi", "-i", "testsrc2=size=16x12:rate=1", "-frames:v", "1", input]);
|
||||
|
||||
const result = run([
|
||||
"--input",
|
||||
input,
|
||||
"--out",
|
||||
output,
|
||||
"--algorithm",
|
||||
"floyd-steinberg",
|
||||
"--palette",
|
||||
"#000000,#ffffff",
|
||||
"--point-size",
|
||||
"3",
|
||||
]);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.equal(JSON.parse(result.stdout).type, "image");
|
||||
|
||||
const rgb = execFileSync("ffmpeg", [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
output,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgb24",
|
||||
"-",
|
||||
]);
|
||||
for (let index = 0; index < rgb.length; index += 3) {
|
||||
const value = rgb[index];
|
||||
assert.ok(value === 0 || value === 255);
|
||||
assert.equal(rgb[index + 1], value);
|
||||
assert.equal(rgb[index + 2], value);
|
||||
}
|
||||
});
|
||||
|
||||
test("processes moving MP4 frames, audio, and BT.709 metadata", { skip: !HAS_FFMPEG }, (t) => {
|
||||
const { dir, cleanup } = fixture();
|
||||
t.after(cleanup);
|
||||
const input = join(dir, "source.mp4");
|
||||
const output = join(dir, "dithered.mp4");
|
||||
ffmpeg([
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=16x12:rate=3:duration=2",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=3",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
input,
|
||||
]);
|
||||
|
||||
const result = run([
|
||||
"--input",
|
||||
input,
|
||||
"--out",
|
||||
output,
|
||||
"--algorithm",
|
||||
"atkinson",
|
||||
"--palette",
|
||||
"#0f380f,#306230,#8bac0f,#9bbc0f",
|
||||
]);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const probe = JSON.parse(
|
||||
execFileSync(
|
||||
"ffprobe",
|
||||
["-v", "error", "-print_format", "json", "-show_streams", "-show_format", "--", output],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
),
|
||||
);
|
||||
const video = probe.streams.find((stream) => stream.codec_type === "video");
|
||||
const audio = probe.streams.find((stream) => stream.codec_type === "audio");
|
||||
assert.equal(video.width, 16);
|
||||
assert.equal(video.height, 12);
|
||||
assert.equal(video.nb_frames, "6");
|
||||
assert.equal(video.color_space, "bt709");
|
||||
assert.equal(video.color_transfer, "bt709");
|
||||
assert.equal(video.color_primaries, "bt709");
|
||||
assert.equal(video.color_range, "tv");
|
||||
assert.equal(audio.codec_name, "aac");
|
||||
assert.ok(Number(probe.format.duration) < 2.5, "audio must not outlive processed video");
|
||||
const keyframes = execFileSync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-skip_frame",
|
||||
"nokey",
|
||||
"-show_entries",
|
||||
"frame=best_effort_timestamp_time",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
"--",
|
||||
output,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((value) => Number.parseFloat(value));
|
||||
assert.deepEqual(keyframes, [0, 1]);
|
||||
});
|
||||
|
||||
test("rejects tagged PQ or HLG instead of silently producing SDR", { skip: !HAS_FFMPEG }, (t) => {
|
||||
const { dir, cleanup } = fixture();
|
||||
t.after(cleanup);
|
||||
const input = join(dir, "hlg.mp4");
|
||||
ffmpeg([
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=white:size=16x12:rate=1:duration=1",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p10le",
|
||||
"-x264-params",
|
||||
"colorprim=bt2020:transfer=arib-std-b67:colormatrix=bt2020nc",
|
||||
"-color_primaries:v",
|
||||
"bt2020",
|
||||
"-color_trc:v",
|
||||
"arib-std-b67",
|
||||
"-colorspace:v",
|
||||
"bt2020nc",
|
||||
input,
|
||||
]);
|
||||
|
||||
const result = run(["--input", input, "--out", join(dir, "wrong.mp4")]);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).error, /HDR arib-std-b67 input is not supported/);
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* media-use eval — compare baseline (no media-use) vs. with media-use
|
||||
* on real registry blocks. Produces an HTML report.
|
||||
*/
|
||||
|
||||
import {
|
||||
mkdtempSync,
|
||||
cpSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
existsSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, basename, resolve, dirname } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(SCRIPT_DIR, "..", "..", "..");
|
||||
const RESOLVE_SCRIPT = join(SCRIPT_DIR, "resolve.mjs");
|
||||
|
||||
const TEST_BLOCKS = [
|
||||
"registry/blocks/nyc-paris-flight",
|
||||
"registry/blocks/macos-tahoe-liquid-glass",
|
||||
"registry/blocks/blue-sweater-intro-video",
|
||||
"registry/blocks/vpn-youtube-spot",
|
||||
"registry/blocks/apple-money-count",
|
||||
"registry/blocks/liquid-glass-notification",
|
||||
"registry/blocks/instagram-follow",
|
||||
];
|
||||
|
||||
// Run resolve.mjs with args as a literal argv array (no shell), so values
|
||||
// interpolated from manifest metadata (--intent prompt, --type) can't inject
|
||||
// shell. Mirrors the execFileSync fix in probe.mjs / heygen-search.mjs.
|
||||
function run(args, opts = {}) {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
output: execFileSync(process.execPath, [RESOLVE_SCRIPT, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 15000,
|
||||
stdio: "pipe",
|
||||
...opts,
|
||||
}).trim(),
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, output: (err.stdout || "") + (err.stderr || ""), code: err.status };
|
||||
}
|
||||
}
|
||||
|
||||
function countAssetFiles(dir) {
|
||||
const assetsDir = join(dir, "assets");
|
||||
if (!existsSync(assetsDir)) return { count: 0, files: [] };
|
||||
const files = [];
|
||||
function walk(d, base = "") {
|
||||
for (const e of readdirSync(d, { withFileTypes: true })) {
|
||||
const rel = base ? `${base}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) walk(join(d, e.name), rel);
|
||||
else files.push(rel);
|
||||
}
|
||||
}
|
||||
walk(assetsDir);
|
||||
return { count: files.length, files };
|
||||
}
|
||||
|
||||
function evalBlock(blockPath) {
|
||||
const fullPath = join(REPO_ROOT, blockPath);
|
||||
if (!existsSync(fullPath)) return null;
|
||||
|
||||
const name = basename(blockPath);
|
||||
const tmp = mkdtempSync(join(tmpdir(), `mu-eval-${name}-`));
|
||||
|
||||
try {
|
||||
cpSync(fullPath, tmp, { recursive: true });
|
||||
|
||||
// baseline: what the agent sees WITHOUT media-use
|
||||
const baseline = countAssetFiles(tmp);
|
||||
const htmlFiles = readdirSync(tmp).filter((f) => f.endsWith(".html"));
|
||||
|
||||
// parse compositions for asset references
|
||||
const assetRefs = [];
|
||||
for (const hf of htmlFiles) {
|
||||
const html = readFileSync(join(tmp, hf), "utf8");
|
||||
const srcMatches = html.matchAll(/src=["']([^"']+?)["']/g);
|
||||
for (const m of srcMatches) {
|
||||
const ref = m[1];
|
||||
if (ref.startsWith("data:") || ref.startsWith("http")) continue;
|
||||
assetRefs.push({ composition: hf, ref });
|
||||
}
|
||||
const urlMatches = html.matchAll(/url\(["']?([^"')]+?)["']?\)/g);
|
||||
for (const m of urlMatches) {
|
||||
const ref = m[1];
|
||||
if (ref.startsWith("data:") || ref.startsWith("http") || ref.startsWith("#")) continue;
|
||||
assetRefs.push({ composition: hf, ref });
|
||||
}
|
||||
}
|
||||
|
||||
// with media-use: run --adopt
|
||||
const adoptResult = run(["--adopt", "--project", tmp, "--json"]);
|
||||
let adopted = { ok: false, adopted: 0, assets: [] };
|
||||
if (adoptResult.ok) {
|
||||
try {
|
||||
adopted = JSON.parse(adoptResult.output);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
// read the generated index
|
||||
const indexPath = join(tmp, ".media", "index.md");
|
||||
const indexContent = existsSync(indexPath)
|
||||
? readFileSync(indexPath, "utf8")
|
||||
: "(no index generated)";
|
||||
|
||||
// read manifest for detail
|
||||
const manifestPath = join(tmp, ".media", "manifest.jsonl");
|
||||
const manifest = existsSync(manifestPath)
|
||||
? readFileSync(manifestPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => {
|
||||
try {
|
||||
return JSON.parse(l);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
// test resolve cache hit: try resolving something that was adopted
|
||||
let resolveTest = null;
|
||||
if (manifest.length > 0) {
|
||||
const first = manifest[0];
|
||||
const prompt = first.provenance?.prompt || first.description;
|
||||
const r = run(["--type", first.type, "--intent", prompt, "--project", tmp, "--json"]);
|
||||
if (r.ok) {
|
||||
try {
|
||||
resolveTest = JSON.parse(r.output);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// test resolve miss: try resolving something that doesn't exist
|
||||
const missResult = run([
|
||||
"--type",
|
||||
"bgm",
|
||||
"--intent",
|
||||
"nonexistent query xyz",
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]);
|
||||
let resolveMiss = null;
|
||||
if (!missResult.ok) {
|
||||
try {
|
||||
resolveMiss = JSON.parse(missResult.output);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
// coverage: which composition refs are covered by the manifest
|
||||
const manifestPaths = new Set(manifest.map((m) => m.path));
|
||||
const coverage = assetRefs.map((r) => ({
|
||||
...r,
|
||||
covered: manifestPaths.has(r.ref),
|
||||
}));
|
||||
|
||||
return {
|
||||
name,
|
||||
baseline: { fileCount: baseline.count, files: baseline.files, htmlCount: htmlFiles.length },
|
||||
compositions: htmlFiles,
|
||||
assetRefs: coverage,
|
||||
adopted: { count: adopted.adopted, assets: adopted.assets || [] },
|
||||
index: indexContent,
|
||||
manifest,
|
||||
resolveTest,
|
||||
resolveMiss,
|
||||
};
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function generateReport(results) {
|
||||
const all = results.filter(Boolean);
|
||||
const passed = all.filter((r) => r.adopted.count > 0);
|
||||
|
||||
const rows = results
|
||||
.filter(Boolean)
|
||||
.map((r) => {
|
||||
const hasMetadata = r.manifest.some((m) => m.duration || m.width);
|
||||
const cacheHit = r.resolveTest?._source === "cached";
|
||||
const missHandled = r.resolveMiss?.ok === false;
|
||||
|
||||
return `<tr>
|
||||
<td><strong>${r.name}</strong></td>
|
||||
<td>${r.baseline.fileCount} files, ${r.baseline.htmlCount} comp${r.baseline.htmlCount === 1 ? "" : "s"}</td>
|
||||
<td>${r.adopted.count} adopted</td>
|
||||
<td>${hasMetadata ? "<span class='pass'>with metadata</span>" : "<span class='warn'>no metadata</span>"}</td>
|
||||
<td>${cacheHit ? "<span class='pass'>cache hit</span>" : "<span class='warn'>no hit</span>"}</td>
|
||||
<td>${missHandled ? "<span class='pass'>handled</span>" : "<span class='fail'>unexpected</span>"}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const details = results
|
||||
.filter(Boolean)
|
||||
.filter((r) => r.adopted.count > 0)
|
||||
.map((r) => {
|
||||
const assetRows = r.manifest
|
||||
.map((m) => {
|
||||
const dur = m.duration != null ? `${m.duration}s` : "—";
|
||||
const dims = m.width && m.height ? `${m.width}×${m.height}` : "—";
|
||||
return `<tr><td>${m.id}</td><td>${m.type}</td><td>${dur}</td><td>${dims}</td><td class="path">${m.path}</td><td>${m.description || ""}</td></tr>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const coveredCount = r.assetRefs.filter((c) => c.covered).length;
|
||||
const totalRefs = r.assetRefs.length;
|
||||
const coveragePct = totalRefs > 0 ? Math.round((coveredCount / totalRefs) * 100) : 100;
|
||||
|
||||
const refRows = r.assetRefs
|
||||
.map(
|
||||
(c) =>
|
||||
`<tr><td class="path">${c.composition}</td><td class="path">${c.ref}</td><td>${c.covered ? "<span class='pass'>covered</span>" : "<span class='warn'>not in manifest</span>"}</td></tr>`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `<div class="block-detail">
|
||||
<h3>${r.name}</h3>
|
||||
<p style="font-size:13px;color:var(--muted)">${r.compositions.length} composition${r.compositions.length === 1 ? "" : "s"}: ${r.compositions.join(", ")}</p>
|
||||
|
||||
<div class="comparison">
|
||||
<div class="col">
|
||||
<h4>Baseline (no media-use)</h4>
|
||||
<p>Agent sees: ${r.baseline.fileCount} raw files in assets/<br>No metadata, no type info, no relationship to compositions.</p>
|
||||
<pre class="file-list">${r.baseline.files.join("\n") || "(no assets)"}</pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>With media-use (after --adopt)</h4>
|
||||
<p>Agent reads index.md — structured, typed, with metadata:</p>
|
||||
<pre class="index">${escapeHtml(r.index)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
totalRefs > 0
|
||||
? `<h4>Composition → asset coverage <span class="${coveragePct === 100 ? "pass" : "warn"}">${coveragePct}%</span> (${coveredCount}/${totalRefs} refs)</h4>
|
||||
<table class="manifest">
|
||||
<thead><tr><th>composition</th><th>asset reference</th><th>in manifest?</th></tr></thead>
|
||||
<tbody>${refRows}</tbody>
|
||||
</table>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<h4>Manifest records</h4>
|
||||
<table class="manifest">
|
||||
<thead><tr><th>id</th><th>type</th><th>dur</th><th>dims</th><th>path</th><th>description</th></tr></thead>
|
||||
<tbody>${assetRows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `<title>media-use eval report</title>
|
||||
<style>
|
||||
:root { --bg: #fafaf7; --text: #1b1b18; --muted: #7a756a; --accent: #0d7377; --good: #1a7a3a; --warn: #b45309; --fail: #dc2626; --border: #e8e5df; --surface: #fff; --mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; --sans: system-ui, -apple-system, sans-serif; --serif: Georgia, serif }
|
||||
* { box-sizing: border-box; margin: 0 } body { background: var(--bg); color: var(--text); font-family: var(--serif); line-height: 1.6; font-size: 15px; padding: 40px 24px }
|
||||
.wrap { max-width: 1100px; margin: 0 auto }
|
||||
h1 { font-family: var(--sans); font-size: 28px; font-weight: 700; margin-bottom: 8px; letter-spacing: -.02em }
|
||||
h2 { font-family: var(--sans); font-size: 20px; font-weight: 650; margin: 32px 0 12px; letter-spacing: -.01em }
|
||||
h3 { font-family: var(--sans); font-size: 17px; font-weight: 650; margin: 24px 0 8px }
|
||||
h4 { font-family: var(--sans); font-size: 14px; font-weight: 600; margin: 16px 0 6px; color: var(--muted) }
|
||||
p { margin-bottom: 10px }
|
||||
.meta { font-family: var(--mono); font-size: 12px; color: var(--muted); margin-bottom: 24px }
|
||||
.summary { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap }
|
||||
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 18px; flex: 1; min-width: 140px }
|
||||
.stat .num { font-family: var(--sans); font-size: 28px; font-weight: 700; color: var(--accent) }
|
||||
.stat .label { font-family: var(--mono); font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--sans); margin: 8px 0 }
|
||||
th { text-align: left; font-family: var(--mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); border-bottom: 2px solid var(--border); padding: 6px 8px; font-weight: 700 }
|
||||
td { border-bottom: 1px solid var(--border); padding: 7px 8px; vertical-align: top }
|
||||
td.path { font-family: var(--mono); font-size: 12px; color: var(--muted); max-width: 300px; overflow: hidden; text-overflow: ellipsis }
|
||||
.pass { color: var(--good); font-weight: 600 } .warn { color: var(--warn); font-weight: 600 } .fail { color: var(--fail); font-weight: 600 }
|
||||
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 12px 0 }
|
||||
@media(max-width:700px) { .comparison { grid-template-columns: 1fr } }
|
||||
.col { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px }
|
||||
.col h4 { margin-top: 0 }
|
||||
pre { font-family: var(--mono); font-size: 12px; background: #1b1b18; color: #d4d0c8; border-radius: 6px; padding: 12px 14px; overflow-x: auto; margin: 6px 0; line-height: 1.5 }
|
||||
pre.file-list { background: var(--bg); color: var(--muted); border: 1px solid var(--border) }
|
||||
pre.index { white-space: pre; }
|
||||
.block-detail { border-top: 1px solid var(--border); padding-top: 20px; margin-top: 20px }
|
||||
.verdict { margin-top: 24px; padding: 16px 20px; border-radius: 8px; font-family: var(--sans); font-size: 15px }
|
||||
.verdict.ship { background: #edfbf0; border: 1px solid #1a7a3a; color: #1a7a3a }
|
||||
.verdict.wait { background: #fff3ec; border: 1px solid #d94f04; color: #d94f04 }
|
||||
</style>
|
||||
<div class="wrap">
|
||||
<h1>media-use eval report</h1>
|
||||
<p class="meta">${new Date().toISOString().slice(0, 10)} · ${all.length} blocks evaluated · baseline vs. media-use --adopt</p>
|
||||
|
||||
<div class="summary">
|
||||
<div class="stat"><div class="num">${all.length}</div><div class="label">blocks tested</div></div>
|
||||
<div class="stat"><div class="num">${passed.length}</div><div class="label">with assets</div></div>
|
||||
<div class="stat"><div class="num">${all.reduce((s, r) => s + r.adopted.count, 0)}</div><div class="label">assets adopted</div></div>
|
||||
<div class="stat"><div class="num">${all.filter((r) => r.manifest.some((m) => m.duration || m.width)).length}</div><div class="label">with ffprobe metadata</div></div>
|
||||
<div class="stat"><div class="num">${(() => {
|
||||
const refs = all.flatMap((r) => r.assetRefs);
|
||||
const covered = refs.filter((c) => c.covered).length;
|
||||
return refs.length > 0 ? Math.round((covered / refs.length) * 100) + "%" : "—";
|
||||
})()}</div><div class="label">composition coverage</div></div>
|
||||
</div>
|
||||
|
||||
<h2>Results matrix</h2>
|
||||
<table>
|
||||
<thead><tr><th>Block</th><th>Baseline</th><th>Adopted</th><th>Metadata</th><th>Cache hit</th><th>Miss handling</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Before / after comparisons</h2>
|
||||
${details}
|
||||
|
||||
<div class="verdict ${passed.length >= 3 ? "ship" : "wait"}">
|
||||
${
|
||||
passed.length >= 3
|
||||
? `<strong>Ship it.</strong> ${passed.length}/${all.length} blocks adopted successfully with metadata. Resolve cache hits work. Miss handling is clean.`
|
||||
: `<strong>Needs work.</strong> Only ${passed.length} blocks adopted. Check the failures above.`
|
||||
}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
console.log("media-use eval · running against registry blocks...\n");
|
||||
|
||||
const results = [];
|
||||
for (const block of TEST_BLOCKS) {
|
||||
const fullPath = join(REPO_ROOT, block);
|
||||
if (!existsSync(fullPath)) {
|
||||
console.log(` skip ${basename(block)} (not found)`);
|
||||
results.push(null);
|
||||
continue;
|
||||
}
|
||||
process.stdout.write(` ${basename(block)}...`);
|
||||
const result = evalBlock(block);
|
||||
if (result) {
|
||||
console.log(
|
||||
` ${result.adopted.count} adopted, ${result.manifest.filter((m) => m.duration || m.width).length} with metadata`,
|
||||
);
|
||||
} else {
|
||||
console.log(" failed");
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
const report = generateReport(results);
|
||||
const outPath = join(SCRIPT_DIR, "..", "eval-report.html");
|
||||
writeFileSync(outPath, report);
|
||||
console.log(`\nReport: ${outPath}`);
|
||||
@@ -0,0 +1,45 @@
|
||||
// The one lock on ~/.hyperframes/config.json, for the CLI, its auto-update step and media-use. The auto-update step
|
||||
// embeds this function's source, so it must use only its arguments and globals.
|
||||
export function withFileLock(lockPath, fs, task) {
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const started = Date.now();
|
||||
for (;;) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(lockPath, "wx");
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
// Never taken over: a lock older than any hold was left by a process that stopped, and a person removes it.
|
||||
let leftover = false;
|
||||
try {
|
||||
leftover = Date.now() - fs.statSync(lockPath).mtimeMs > 5000;
|
||||
} catch {}
|
||||
if (leftover || Date.now() - started > 10000)
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Settings are locked by another hyperframes process. If none is running, delete ${lockPath}`,
|
||||
),
|
||||
{ code: "HF_SETTINGS_LOCKED" },
|
||||
);
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.writeSync(fd, token);
|
||||
} catch (error) {
|
||||
fs.rmSync(lockPath, { force: true });
|
||||
throw error;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
break;
|
||||
}
|
||||
try {
|
||||
return task();
|
||||
} finally {
|
||||
// No code removes another's lock, so release needs no check (only a person deleting a live one defeats it).
|
||||
try {
|
||||
fs.rmSync(lockPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { normalizeWords } from "./words.mjs";
|
||||
|
||||
const MIN_SEGMENT_SECONDS = 0.2;
|
||||
const SILENCE_PAD_SECONDS = 0.15;
|
||||
|
||||
export function compileCutList(transcript, opts = {}) {
|
||||
const words = normalizeWords(transcript);
|
||||
if (opts.keep != null && hasRemovalSource(opts)) {
|
||||
throw new Error("--keep is mutually exclusive with removal options");
|
||||
}
|
||||
|
||||
if (opts.keep != null) {
|
||||
const duration = durationFrom(words, opts);
|
||||
const ranges = parseTimeRanges(opts.keep);
|
||||
return finalizeKept(duration != null ? clampRanges(ranges, duration) : ranges);
|
||||
}
|
||||
|
||||
const duration = durationFrom(words, opts);
|
||||
if (!duration) return [];
|
||||
|
||||
const removals = [
|
||||
...parseTimeRanges(opts.remove),
|
||||
...wordIndexRanges(words, opts.removeWords),
|
||||
...fillerRanges(words, opts.removeFillers),
|
||||
...silenceRanges(words, opts.cutSilence),
|
||||
];
|
||||
const mergedRemovals = mergeRanges(clampRanges(removals, duration));
|
||||
return finalizeKept(invertRanges(mergedRemovals, duration));
|
||||
}
|
||||
|
||||
function hasRemovalSource(opts) {
|
||||
return (
|
||||
opts.remove != null ||
|
||||
opts.removeWords != null ||
|
||||
opts.removeFillers != null ||
|
||||
opts.cutSilence != null
|
||||
);
|
||||
}
|
||||
|
||||
function durationFrom(words, opts) {
|
||||
const explicit = Number(opts.duration ?? opts.totalDuration);
|
||||
if (Number.isFinite(explicit) && explicit > 0) return explicit;
|
||||
const last = words.at(-1);
|
||||
return last && Number.isFinite(last.end) && last.end > 0 ? last.end : null;
|
||||
}
|
||||
|
||||
function parseTimeRanges(value) {
|
||||
if (value == null || value === false || value === "") return [];
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map(parseRangeString);
|
||||
}
|
||||
if (!Array.isArray(value)) throw new Error("range list must be a string or array");
|
||||
return value.map((range) => {
|
||||
if (Array.isArray(range)) return cleanRange(Number(range[0]), Number(range[1]));
|
||||
return cleanRange(Number(range?.start), Number(range?.end));
|
||||
});
|
||||
}
|
||||
|
||||
function parseRangeString(value) {
|
||||
const match = value.match(/^([0-9]*\.?[0-9]+)\s*-\s*([0-9]*\.?[0-9]+)$/);
|
||||
if (!match) throw new Error(`invalid range: ${value}`);
|
||||
return cleanRange(Number(match[1]), Number(match[2]));
|
||||
}
|
||||
|
||||
function cleanRange(start, end) {
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) {
|
||||
throw new Error("range start/end must be finite numbers");
|
||||
}
|
||||
if (end < start) throw new Error(`range end ${end} is before start ${start}`);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function wordIndexRanges(words, value) {
|
||||
if (value == null || value === false || value === "") return [];
|
||||
const ranges = typeof value === "string" ? value.split(",") : value;
|
||||
if (!Array.isArray(ranges)) throw new Error("--remove-words must be a string or array");
|
||||
return ranges
|
||||
.map((range) => (typeof range === "string" ? range.trim() : range))
|
||||
.filter(Boolean)
|
||||
.map((range) => {
|
||||
const [first, last = first] =
|
||||
typeof range === "string" ? range.split("-").map((n) => n.trim()) : range;
|
||||
const startIndex = Number(first);
|
||||
const endIndex = Number(last);
|
||||
if (!Number.isInteger(startIndex) || !Number.isInteger(endIndex)) {
|
||||
throw new Error(`invalid word range: ${range}`);
|
||||
}
|
||||
if (startIndex < 0 || endIndex < startIndex || endIndex >= words.length) {
|
||||
throw new Error(`word range out of bounds: ${range}`);
|
||||
}
|
||||
return { start: words[startIndex].start, end: words[endIndex].end };
|
||||
});
|
||||
}
|
||||
|
||||
function fillerRanges(words, value) {
|
||||
if (value == null || value === false || value === "") return [];
|
||||
const fillers = Array.isArray(value)
|
||||
? value
|
||||
: String(value)
|
||||
.split(",")
|
||||
.map((s) => s.trim());
|
||||
const set = new Set(fillers.filter(Boolean).map(bareToken));
|
||||
if (set.size === 0) return [];
|
||||
// Whisper emits words with attached punctuation and arbitrary case
|
||||
// ("UM," / "Um."), so compare bare tokens.
|
||||
return words
|
||||
.filter((word) => set.has(bareToken(word.text)))
|
||||
.map((word) => ({ start: word.start, end: word.end }));
|
||||
}
|
||||
|
||||
function bareToken(text) {
|
||||
return String(text)
|
||||
.toLowerCase()
|
||||
.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
|
||||
}
|
||||
|
||||
function silenceRanges(words, value) {
|
||||
if (value == null || value === false || value === "") return [];
|
||||
const threshold = Number(value);
|
||||
if (!Number.isFinite(threshold) || threshold <= 0) {
|
||||
throw new Error("--cut-silence must be a positive number");
|
||||
}
|
||||
const ranges = [];
|
||||
for (let i = 0; i < words.length - 1; i++) {
|
||||
const current = words[i];
|
||||
const next = words[i + 1];
|
||||
const gap = next.start - current.end;
|
||||
if (gap <= threshold) continue;
|
||||
const start = current.end + SILENCE_PAD_SECONDS;
|
||||
const end = next.start - SILENCE_PAD_SECONDS;
|
||||
if (end > start) ranges.push({ start, end });
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function clampRanges(ranges, duration) {
|
||||
return ranges
|
||||
.map((range) => ({
|
||||
start: Math.max(0, Math.min(duration, range.start)),
|
||||
end: Math.max(0, Math.min(duration, range.end)),
|
||||
}))
|
||||
.filter((range) => range.end > range.start);
|
||||
}
|
||||
|
||||
function mergeRanges(ranges) {
|
||||
const sorted = ranges
|
||||
.map((range) => ({ start: round6(range.start), end: round6(range.end) }))
|
||||
.sort((a, b) => a.start - b.start || a.end - b.end);
|
||||
const merged = [];
|
||||
for (const range of sorted) {
|
||||
const prev = merged.at(-1);
|
||||
if (prev && range.start <= prev.end) {
|
||||
prev.end = Math.max(prev.end, range.end);
|
||||
} else {
|
||||
merged.push({ ...range });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function invertRanges(removals, duration) {
|
||||
const kept = [];
|
||||
let cursor = 0;
|
||||
for (const range of removals) {
|
||||
if (range.start > cursor) kept.push({ start: cursor, end: range.start });
|
||||
cursor = Math.max(cursor, range.end);
|
||||
}
|
||||
if (cursor < duration) kept.push({ start: cursor, end: duration });
|
||||
return kept;
|
||||
}
|
||||
|
||||
// Range bounds are rounded only to shed float noise, never to the millisecond:
|
||||
// these values become ffmpeg's -ss/-to, where a millisecond is wide enough to
|
||||
// step a boundary onto the next frame at 29.97/30/60 fps.
|
||||
function finalizeKept(ranges) {
|
||||
return mergeRanges(ranges)
|
||||
.map((range) => ({ start: round6(range.start), end: round6(range.end) }))
|
||||
.filter((range) => round3(range.end - range.start) >= MIN_SEGMENT_SECONDS);
|
||||
}
|
||||
|
||||
function round6(n) {
|
||||
return Math.round(Number(n) * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
function round3(n) {
|
||||
return Math.round(Number(n) * 1000) / 1000;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { wordListsFromMediaMeta } from "./words.mjs";
|
||||
|
||||
// audio_meta.json word times are relative to each line's own file.
|
||||
export function speechSpans(meta, { mergeGap = 0.6, offsets, sequential = false, gap = 0 } = {}) {
|
||||
const merge = Number(mergeGap);
|
||||
const lists = wordListsFromMediaMeta(meta);
|
||||
const voices = Array.isArray(meta?.voices) ? meta.voices : [];
|
||||
if (lists.filter((words) => words.length > 0).length > 1 && !offsets && !sequential) {
|
||||
throw new Error(
|
||||
"audio_meta has multiple voice lines with file-relative times; pass --sequential or --offsets so spans land at composition time",
|
||||
);
|
||||
}
|
||||
const intervals = [];
|
||||
let cursor = 0;
|
||||
for (let i = 0; i < lists.length; i++) {
|
||||
const voice = voices[i];
|
||||
let offset = 0;
|
||||
if (offsets) {
|
||||
if (lists[i].length === 0) continue;
|
||||
const id = voice?.id ?? String(i);
|
||||
if (!(id in offsets)) throw new Error(`--offsets is missing voice "${id}"`);
|
||||
offset = Number(offsets[id]) || 0;
|
||||
} else if (sequential) {
|
||||
offset = cursor;
|
||||
const lineDuration = Number(voice?.duration_s) || Math.max(...lists[i].map((w) => w.end), 0);
|
||||
cursor += lineDuration + (Number(gap) || 0);
|
||||
}
|
||||
for (const word of lists[i]) {
|
||||
if (word.end > word.start)
|
||||
intervals.push({ start: word.start + offset, end: word.end + offset });
|
||||
}
|
||||
}
|
||||
return mergeIntervals(intervals, Number.isFinite(merge) && merge >= 0 ? merge : 0.6);
|
||||
}
|
||||
|
||||
export function duckKeyframes(
|
||||
spans,
|
||||
{ duck = 0.25, attack = 0.15, release = 0.4, baseVolume = 1 } = {},
|
||||
) {
|
||||
const base = finiteOr(baseVolume, 1);
|
||||
const ducked = round3(base * finiteOr(duck, 0.25));
|
||||
const keyframes = [];
|
||||
for (const span of spans) {
|
||||
keyframes.push({
|
||||
time: round3(Math.max(0, finiteOr(span.start, 0))),
|
||||
volume: ducked,
|
||||
duration: round3(finiteOr(attack, 0.15)),
|
||||
});
|
||||
keyframes.push({
|
||||
time: round3(Math.max(0, finiteOr(span.end, 0))),
|
||||
volume: round3(base),
|
||||
duration: round3(finiteOr(release, 0.4)),
|
||||
});
|
||||
}
|
||||
return keyframes.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
/** Volume lane for `data-automation`: composition-time keyframes as clip-local ramps. */
|
||||
export function duckLane(keyframes, { clipStart = 0, baseVolume = 1 } = {}) {
|
||||
const start = finiteOr(clipStart, 0);
|
||||
const points = [{ t: 0, v: round3(finiteOr(baseVolume, 1)) }];
|
||||
const push = (t, v) => {
|
||||
if (t > points.at(-1).t) points.push({ t: round3(t), v });
|
||||
};
|
||||
for (const kf of keyframes) {
|
||||
const t = Math.max(0, kf.time - start);
|
||||
push(t, points.at(-1).v);
|
||||
push(t + kf.duration, kf.volume);
|
||||
}
|
||||
return { version: 1, lanes: [{ target: "volume", points }] };
|
||||
}
|
||||
|
||||
function mergeIntervals(intervals, mergeGap) {
|
||||
const sorted = intervals
|
||||
.map((range) => ({ start: round3(range.start), end: round3(range.end) }))
|
||||
.sort((a, b) => a.start - b.start || a.end - b.end);
|
||||
const merged = [];
|
||||
for (const range of sorted) {
|
||||
const prev = merged.at(-1);
|
||||
if (prev && (range.start <= prev.end || range.start - prev.end < mergeGap)) {
|
||||
prev.end = Math.max(prev.end, range.end);
|
||||
} else {
|
||||
merged.push({ ...range });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function finiteOr(value, fallback) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function round3(n) {
|
||||
return Math.round(Number(n) * 1000) / 1000;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
export const ERROR_DIFFUSION_ALGORITHMS = {
|
||||
"floyd-steinberg": {
|
||||
kernel: [
|
||||
[1, 0, 7],
|
||||
[-1, 1, 3],
|
||||
[0, 1, 5],
|
||||
[1, 1, 1],
|
||||
],
|
||||
divisor: 16,
|
||||
},
|
||||
atkinson: {
|
||||
kernel: [
|
||||
[1, 0, 1],
|
||||
[2, 0, 1],
|
||||
[-1, 1, 1],
|
||||
[0, 1, 1],
|
||||
[1, 1, 1],
|
||||
[0, 2, 1],
|
||||
],
|
||||
divisor: 8,
|
||||
},
|
||||
"jarvis-judice-ninke": {
|
||||
kernel: [
|
||||
[1, 0, 7],
|
||||
[2, 0, 5],
|
||||
[-2, 1, 3],
|
||||
[-1, 1, 5],
|
||||
[0, 1, 7],
|
||||
[1, 1, 5],
|
||||
[2, 1, 3],
|
||||
[-2, 2, 1],
|
||||
[-1, 2, 3],
|
||||
[0, 2, 5],
|
||||
[1, 2, 3],
|
||||
[2, 2, 1],
|
||||
],
|
||||
divisor: 48,
|
||||
},
|
||||
stucki: {
|
||||
kernel: [
|
||||
[1, 0, 8],
|
||||
[2, 0, 4],
|
||||
[-2, 1, 2],
|
||||
[-1, 1, 4],
|
||||
[0, 1, 8],
|
||||
[1, 1, 4],
|
||||
[2, 1, 2],
|
||||
[-2, 2, 1],
|
||||
[-1, 2, 2],
|
||||
[0, 2, 4],
|
||||
[1, 2, 2],
|
||||
[2, 2, 1],
|
||||
],
|
||||
divisor: 42,
|
||||
},
|
||||
burkes: {
|
||||
kernel: [
|
||||
[1, 0, 8],
|
||||
[2, 0, 4],
|
||||
[-2, 1, 2],
|
||||
[-1, 1, 4],
|
||||
[0, 1, 8],
|
||||
[1, 1, 4],
|
||||
[2, 1, 2],
|
||||
],
|
||||
divisor: 32,
|
||||
},
|
||||
sierra: {
|
||||
kernel: [
|
||||
[1, 0, 5],
|
||||
[2, 0, 3],
|
||||
[-2, 1, 2],
|
||||
[-1, 1, 4],
|
||||
[0, 1, 5],
|
||||
[1, 1, 4],
|
||||
[2, 1, 2],
|
||||
[-1, 2, 2],
|
||||
[0, 2, 3],
|
||||
[1, 2, 2],
|
||||
],
|
||||
divisor: 32,
|
||||
},
|
||||
"sierra-lite": {
|
||||
kernel: [
|
||||
[1, 0, 2],
|
||||
[-1, 1, 1],
|
||||
[0, 1, 1],
|
||||
],
|
||||
divisor: 4,
|
||||
},
|
||||
"two-row-sierra": {
|
||||
kernel: [
|
||||
[1, 0, 4],
|
||||
[2, 0, 3],
|
||||
[-2, 1, 1],
|
||||
[-1, 1, 2],
|
||||
[0, 1, 3],
|
||||
[1, 1, 2],
|
||||
[2, 1, 1],
|
||||
],
|
||||
divisor: 16,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULTS = {
|
||||
algorithm: "floyd-steinberg",
|
||||
brightness: 1,
|
||||
contrast: 1.2,
|
||||
detail: 1,
|
||||
palette: ["#000000", "#ffffff"],
|
||||
pointSize: 3,
|
||||
};
|
||||
|
||||
export function errorDiffusionBufferLength(width, height, pointSize) {
|
||||
return Math.ceil(width / pointSize) * Math.ceil(height / pointSize) * 3;
|
||||
}
|
||||
|
||||
export function applyErrorDiffusionRgba(data, width, height, options = {}, errorBuffer) {
|
||||
if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) {
|
||||
throw new Error("width and height must be positive integers");
|
||||
}
|
||||
if (!data || data.length !== width * height * 4) {
|
||||
throw new Error(`RGBA data must contain ${width * height * 4} bytes`);
|
||||
}
|
||||
|
||||
const algorithm = options.algorithm ?? DEFAULTS.algorithm;
|
||||
const diffusion = ERROR_DIFFUSION_ALGORITHMS[algorithm];
|
||||
if (!diffusion) throw new Error(`unknown error-diffusion algorithm: ${algorithm}`);
|
||||
|
||||
const pointSize = integerInRange(options.pointSize ?? DEFAULTS.pointSize, 1, 20, "pointSize");
|
||||
const brightness = numberInRange(options.brightness ?? DEFAULTS.brightness, 0.5, 2, "brightness");
|
||||
const contrast = numberInRange(options.contrast ?? DEFAULTS.contrast, 0.5, 2, "contrast");
|
||||
const detail = numberInRange(options.detail ?? DEFAULTS.detail, 0.1, 1, "detail");
|
||||
const palette = parsePalette(options.palette ?? DEFAULTS.palette);
|
||||
const blockColumns = Math.ceil(width / pointSize);
|
||||
const blockRows = Math.ceil(height / pointSize);
|
||||
const errorLength = errorDiffusionBufferLength(width, height, pointSize);
|
||||
const errors = errorBuffer ?? new Float32Array(errorLength);
|
||||
if (!(errors instanceof Float32Array) || errors.length !== errorLength) {
|
||||
throw new Error(`errorBuffer must be a Float32Array of length ${errorLength}`);
|
||||
}
|
||||
errors.fill(0);
|
||||
|
||||
const centerOffset = Math.floor(pointSize / 2);
|
||||
for (let blockRow = 0; blockRow < blockRows; blockRow++) {
|
||||
const blockY = blockRow * pointSize;
|
||||
for (let blockColumn = 0; blockColumn < blockColumns; blockColumn++) {
|
||||
const blockX = blockColumn * pointSize;
|
||||
const centerX = Math.min(blockX + centerOffset, width - 1);
|
||||
const centerY = Math.min(blockY + centerOffset, height - 1);
|
||||
const rgbaIndex = (centerY * width + centerX) * 4;
|
||||
const errorIndex = (blockRow * blockColumns + blockColumn) * 3;
|
||||
const red = correctedChannel(data[rgbaIndex], errors[errorIndex], brightness, contrast);
|
||||
const green = correctedChannel(
|
||||
data[rgbaIndex + 1],
|
||||
errors[errorIndex + 1],
|
||||
brightness,
|
||||
contrast,
|
||||
);
|
||||
const blue = correctedChannel(
|
||||
data[rgbaIndex + 2],
|
||||
errors[errorIndex + 2],
|
||||
brightness,
|
||||
contrast,
|
||||
);
|
||||
const luminance = 0.299 * red + 0.587 * green + 0.114 * blue;
|
||||
const output = palette[Math.min(palette.length - 1, Math.floor(luminance * palette.length))];
|
||||
|
||||
for (let y = blockY; y < Math.min(blockY + pointSize, height); y++) {
|
||||
for (let x = blockX; x < Math.min(blockX + pointSize, width); x++) {
|
||||
const outputIndex = (y * width + x) * 4;
|
||||
data[outputIndex] = Math.round(output[0] * 255);
|
||||
data[outputIndex + 1] = Math.round(output[1] * 255);
|
||||
data[outputIndex + 2] = Math.round(output[2] * 255);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [dx, dy, weight] of diffusion.kernel) {
|
||||
const targetColumn = blockColumn + dx;
|
||||
const targetRow = blockRow + dy;
|
||||
if (
|
||||
targetColumn < 0 ||
|
||||
targetColumn >= blockColumns ||
|
||||
targetRow < 0 ||
|
||||
targetRow >= blockRows
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const target = (targetRow * blockColumns + targetColumn) * 3;
|
||||
const scale = (weight / diffusion.divisor) * detail;
|
||||
errors[target] += (red - output[0]) * scale;
|
||||
errors[target + 1] += (green - output[1]) * scale;
|
||||
errors[target + 2] += (blue - output[2]) * scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function correctedChannel(byte, error, brightness, contrast) {
|
||||
return Math.min(1, Math.max(0, ((byte / 255 - 0.5) * contrast + 0.5) * brightness + error));
|
||||
}
|
||||
|
||||
function parsePalette(colors) {
|
||||
if (!Array.isArray(colors) || colors.length < 2 || colors.length > 6) {
|
||||
throw new Error("palette must contain 2 to 6 colors");
|
||||
}
|
||||
return colors.map((color) => {
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(color);
|
||||
if (!match) throw new Error(`palette color must use #rrggbb: ${color}`);
|
||||
const value = Number.parseInt(match[1], 16);
|
||||
return [(value >> 16) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];
|
||||
});
|
||||
}
|
||||
|
||||
function numberInRange(value, min, max, name) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number < min || number > max) {
|
||||
throw new Error(`${name} must be between ${min} and ${max}`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function integerInRange(value, min, max, name) {
|
||||
const number = Number(value);
|
||||
if (!Number.isInteger(number) || number < min || number > max) {
|
||||
throw new Error(`${name} must be an integer between ${min} and ${max}`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { currentRecords, indexPath } from "./manifest.mjs";
|
||||
|
||||
function pad(str, len) {
|
||||
return String(str ?? "").padEnd(len);
|
||||
}
|
||||
|
||||
function formatDur(record) {
|
||||
if (record.duration == null) return "—";
|
||||
return `${record.duration}s`;
|
||||
}
|
||||
|
||||
function formatDims(record) {
|
||||
if (record.width && record.height) return `${record.width}×${record.height}`;
|
||||
if (record.type === "icon" && record.transparent) return "svg";
|
||||
return "—";
|
||||
}
|
||||
|
||||
export function generateIndexContent(records) {
|
||||
const count = records.length;
|
||||
const header = `# .media · ${count} asset${count === 1 ? "" : "s"}\n`;
|
||||
if (count === 0) return header;
|
||||
|
||||
const cols = { id: 4, type: 5, dur: 4, dims: 5, path: 5, desc: 11 };
|
||||
for (const r of records) {
|
||||
cols.id = Math.max(cols.id, (r.id ?? "").length);
|
||||
cols.type = Math.max(cols.type, (r.type ?? "").length);
|
||||
cols.dur = Math.max(cols.dur, formatDur(r).length);
|
||||
cols.dims = Math.max(cols.dims, formatDims(r).length);
|
||||
cols.path = Math.max(cols.path, (r.path ?? "").length);
|
||||
}
|
||||
|
||||
const heading =
|
||||
pad("id", cols.id + 2) +
|
||||
pad("type", cols.type + 2) +
|
||||
pad("dur", cols.dur + 2) +
|
||||
pad("dims", cols.dims + 2) +
|
||||
pad("path", cols.path + 2) +
|
||||
"description";
|
||||
|
||||
const lines = [header, heading];
|
||||
for (const r of records) {
|
||||
lines.push(
|
||||
pad(r.id, cols.id + 2) +
|
||||
pad(r.type, cols.type + 2) +
|
||||
pad(formatDur(r), cols.dur + 2) +
|
||||
pad(formatDims(r), cols.dims + 2) +
|
||||
pad(r.path, cols.path + 2) +
|
||||
(r.description ?? ""),
|
||||
);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
export function regenerateIndex(projectDir) {
|
||||
const records = currentRecords(projectDir);
|
||||
const content = generateIndexContent(records);
|
||||
const p = indexPath(projectDir);
|
||||
mkdirSync(dirname(p), { recursive: true });
|
||||
writeFileSync(p, content);
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
readFileSync,
|
||||
appendFileSync,
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
readdirSync,
|
||||
openSync,
|
||||
closeSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
const MANIFEST_FILE = "manifest.jsonl";
|
||||
const INDEX_FILE = "index.md";
|
||||
|
||||
const TYPE_DIRS = {
|
||||
bgm: "audio/bgm",
|
||||
sfx: "audio/sfx",
|
||||
voice: "audio/voice",
|
||||
image: "images",
|
||||
icon: "images",
|
||||
logo: "images",
|
||||
brand: "images",
|
||||
video: "video",
|
||||
grade: "luts",
|
||||
lut: "luts",
|
||||
recipe: "recipes",
|
||||
};
|
||||
|
||||
export function mediaDir(projectDir) {
|
||||
return join(projectDir, ".media");
|
||||
}
|
||||
|
||||
export function manifestPath(projectDir) {
|
||||
return join(mediaDir(projectDir), MANIFEST_FILE);
|
||||
}
|
||||
|
||||
export function indexPath(projectDir) {
|
||||
return join(mediaDir(projectDir), INDEX_FILE);
|
||||
}
|
||||
|
||||
export function typeSubdir(type) {
|
||||
const sub = TYPE_DIRS[type];
|
||||
if (!sub) throw new Error(`unknown media type: ${type}`);
|
||||
return sub;
|
||||
}
|
||||
|
||||
export function typeDirPath(projectDir, type) {
|
||||
return join(mediaDir(projectDir), typeSubdir(type));
|
||||
}
|
||||
|
||||
export function readManifest(projectDir) {
|
||||
const p = manifestPath(projectDir);
|
||||
if (!existsSync(p)) return [];
|
||||
const raw = readFileSync(p, "utf8");
|
||||
const records = [];
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
records.push(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// ponytail: skip malformed lines, don't crash
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
export function appendRecord(projectDir, record) {
|
||||
const dir = mediaDir(projectDir);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const typeDir = typeDirPath(projectDir, record.type);
|
||||
mkdirSync(typeDir, { recursive: true });
|
||||
|
||||
const p = manifestPath(projectDir);
|
||||
const line = JSON.stringify(record) + "\n";
|
||||
appendFileSync(p, line);
|
||||
}
|
||||
|
||||
/** Sources that mean the agent made or fetched the file; any other file is the person's own. */
|
||||
export const AGENT_SOURCES = ["generated", "search", "bundled"];
|
||||
|
||||
/** The record a path has now: the manifest only appends, so the last one for a path wins. */
|
||||
export function latestRecordFor(projectDir, path) {
|
||||
return readManifest(projectDir).findLast((record) => record.path === path);
|
||||
}
|
||||
|
||||
/** Records with every older record for the same path dropped, since the last one for a path is its record. */
|
||||
export function currentRecords(projectDir) {
|
||||
const records = readManifest(projectDir);
|
||||
const last = new Map(records.map((record, index) => [record.path, index]));
|
||||
return records.filter((record, index) => !record.path || last.get(record.path) === index);
|
||||
}
|
||||
|
||||
/** Records a file already in the project where it is, unless its current record already says the same thing. */
|
||||
export function recordInPlace(
|
||||
projectDir,
|
||||
{ type, path, source, description, duration, provenance },
|
||||
) {
|
||||
const fields = {
|
||||
type,
|
||||
path,
|
||||
source,
|
||||
description: description || basename(path),
|
||||
...(duration != null && { duration: Math.round(duration * 10) / 10 }),
|
||||
};
|
||||
mkdirSync(mediaDir(projectDir), { recursive: true });
|
||||
return withLock(mediaDir(projectDir), () => {
|
||||
const latest = latestRecordFor(projectDir, path);
|
||||
const same = ["source", "description", "duration"].every(
|
||||
(key) => latest?.[key] === fields[key],
|
||||
);
|
||||
if (latest && same) return latest;
|
||||
const record = { id: nextFreeId(projectDir, type), ...fields, provenance };
|
||||
appendRecord(projectDir, record);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
// Match prompts forgivingly. Agents rarely re-emit a byte-identical intent, so
|
||||
// keying cache lookups on exact equality meant "Calm piano" and "calm piano"
|
||||
// re-searched and re-downloaded. Normalize (trim, lowercase, collapse internal
|
||||
// whitespace) on both sides; the raw prompt is still stored for audit.
|
||||
export function normalizePrompt(prompt) {
|
||||
return String(prompt ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
export function findByPrompt(projectDir, prompt, type) {
|
||||
const key = normalizePrompt(prompt);
|
||||
if (!key) return null;
|
||||
const records = currentRecords(projectDir);
|
||||
return (
|
||||
records.find(
|
||||
(r) => normalizePrompt(r.provenance?.prompt) === key && (type == null || r.type === type),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
export function findByEntity(projectDir, entity) {
|
||||
const lower = entity.toLowerCase();
|
||||
const records = currentRecords(projectDir);
|
||||
return records.find((r) => r.entity && r.entity.toLowerCase() === lower) || null;
|
||||
}
|
||||
|
||||
export function nextId(projectDir, type) {
|
||||
const records = readManifest(projectDir);
|
||||
const prefix = type;
|
||||
let max = 0;
|
||||
for (const r of records) {
|
||||
if (r.type !== type) continue;
|
||||
const m = r.id?.match(new RegExp(`^${prefix}_(\\d+)$`));
|
||||
if (m) max = Math.max(max, parseInt(m[1], 10));
|
||||
}
|
||||
return `${prefix}_${String(max + 1).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
// Sync sleep (no busy-spin) for the allocation lock retry.
|
||||
function sleepMs(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
// Coarse per-project lock so concurrent resolves don't race on id allocation.
|
||||
// ponytail: one lock file with a 15s stale-steal (a crashed holder can't wedge
|
||||
// the project); fine for agent-scale concurrency — revisit if throughput needs
|
||||
// finer locking. Date.now() is available here (a normal Node CLI, not a
|
||||
// workflow DSL), so mtime-based staleness is safe.
|
||||
const LOCK_STALE_MS = 15000;
|
||||
const LOCK_TIMEOUT_MS = 20000;
|
||||
|
||||
function withLock(dir, fn) {
|
||||
const lock = join(dir, ".lock");
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
try {
|
||||
closeSync(openSync(lock, "wx")); // O_EXCL: atomic acquire
|
||||
break;
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") throw err;
|
||||
try {
|
||||
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
||||
rmSync(lock, { force: true }); // steal a stale lock from a dead holder
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
continue; // lock vanished between check and stat — retry the acquire
|
||||
}
|
||||
if (Date.now() - start > LOCK_TIMEOUT_MS) {
|
||||
throw new Error("media-use: timed out acquiring .media/.lock");
|
||||
}
|
||||
sleepMs(25);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
rmSync(lock, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Atomically allocate the next free id for `type` AND reserve it on disk, so a
|
||||
// slow download/copy between allocation and appendRecord can't let a concurrent
|
||||
// caller grab the same id (the MU-23 clobber). Under the lock we take the max id
|
||||
// across BOTH the manifest and any already-reserved files in the type dir, then
|
||||
// O_EXCL-create an empty marker under core's atomic temp name, which Studio's
|
||||
// project history skips. Returns { id, localPath, markerPath }.
|
||||
export function allocateId(projectDir, type, ext) {
|
||||
mkdirSync(mediaDir(projectDir), { recursive: true });
|
||||
const typeDir = typeDirPath(projectDir, type);
|
||||
mkdirSync(typeDir, { recursive: true });
|
||||
return withLock(mediaDir(projectDir), () => {
|
||||
const id = nextFreeId(projectDir, type);
|
||||
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
|
||||
// Same shape as core atomicTempPath: an empty file at the final name would be committed.
|
||||
const markerPath = `${join(projectDir, localPath)}.hf${randomBytes(3).toString("hex")}.tmp`;
|
||||
writeFileSync(markerPath, "", { flag: "wx" }); // durable reservation
|
||||
return { id, localPath, markerPath };
|
||||
});
|
||||
}
|
||||
|
||||
// Call under the lock: counts recorded ids and ids reserved by a file not yet recorded.
|
||||
function nextFreeId(projectDir, type) {
|
||||
const re = new RegExp(`^${type}_(\\d+)`);
|
||||
let max = 0;
|
||||
for (const r of readManifest(projectDir)) {
|
||||
if (r.type !== type) continue;
|
||||
const m = r.id?.match(re);
|
||||
if (m) max = Math.max(max, parseInt(m[1], 10));
|
||||
}
|
||||
const typeDir = typeDirPath(projectDir, type);
|
||||
for (const f of existsSync(typeDir) ? readdirSync(typeDir) : []) {
|
||||
const m = f.match(re);
|
||||
if (m) max = Math.max(max, parseInt(m[1], 10));
|
||||
}
|
||||
return `${type}_${String(max + 1).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function reservedFile(projectDir, type, ext) {
|
||||
const allocation = allocateId(projectDir, type, ext);
|
||||
return { ...allocation, fullPath: join(projectDir, allocation.localPath) };
|
||||
}
|
||||
|
||||
function releaseReservation(reservation, committed) {
|
||||
if (!committed) rmSync(reservation.fullPath, { force: true });
|
||||
rmSync(reservation.markerPath, { force: true });
|
||||
}
|
||||
|
||||
// A reservation is committed only when populate returns a non-null value.
|
||||
// Throwing/rejecting or returning null means no usable asset was produced, so
|
||||
// the reservation and any partial asset are released. Keeping this transaction
|
||||
// beside allocateId prevents provider/cache/LUT paths from forgetting the rollback.
|
||||
export function withReservedFileSync(projectDir, type, ext, populate) {
|
||||
const reservation = reservedFile(projectDir, type, ext);
|
||||
try {
|
||||
const result = populate(reservation);
|
||||
releaseReservation(reservation, result != null);
|
||||
return result;
|
||||
} catch (error) {
|
||||
releaseReservation(reservation, false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function withReservedFile(projectDir, type, ext, populate) {
|
||||
const reservation = reservedFile(projectDir, type, ext);
|
||||
try {
|
||||
const result = await populate(reservation);
|
||||
releaseReservation(reservation, result != null);
|
||||
return result;
|
||||
} catch (error) {
|
||||
releaseReservation(reservation, false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Media downloads use public HTTP(S) URLs. Validate every redirect target;
|
||||
// a provider result must meet the same host policy as a direct ingest URL.
|
||||
// Public HTTPS-to-HTTP redirects are allowed, matching direct HTTP support.
|
||||
// This is a literal-host policy, not DNS pinning: DNS resolution remains trusted.
|
||||
|
||||
import { BlockList, isIP } from "node:net";
|
||||
|
||||
const blocked = new BlockList();
|
||||
for (const [network, prefix] of [
|
||||
["0.0.0.0", 8],
|
||||
["10.0.0.0", 8],
|
||||
["100.64.0.0", 10],
|
||||
["127.0.0.0", 8],
|
||||
["169.254.0.0", 16],
|
||||
["172.16.0.0", 12],
|
||||
["192.0.0.0", 24],
|
||||
["192.0.2.0", 24],
|
||||
["192.88.99.0", 24],
|
||||
["192.168.0.0", 16],
|
||||
["198.18.0.0", 15],
|
||||
["198.51.100.0", 24],
|
||||
["203.0.113.0", 24],
|
||||
["224.0.0.0", 4],
|
||||
["240.0.0.0", 4],
|
||||
])
|
||||
blocked.addSubnet(network, prefix, "ipv4");
|
||||
for (const [network, prefix] of [
|
||||
["::", 128],
|
||||
["::1", 128],
|
||||
["fc00::", 7],
|
||||
["fe80::", 10],
|
||||
["fec0::", 10],
|
||||
["ff00::", 8],
|
||||
["2001:db8::", 32],
|
||||
])
|
||||
blocked.addSubnet(network, prefix, "ipv6");
|
||||
|
||||
export function isPublicMediaUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
||||
const host = url.hostname.replace(/\.$/, "");
|
||||
if (
|
||||
host === "localhost" ||
|
||||
host.endsWith(".localhost") ||
|
||||
host.endsWith(".local") ||
|
||||
host.endsWith(".internal")
|
||||
)
|
||||
return false;
|
||||
const address = host.replace(/^\[|\]$/g, "");
|
||||
const family = isIP(address);
|
||||
return family === 0 || !blocked.check(address, family === 4 ? "ipv4" : "ipv6");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMedia(url, { method = "GET", signal, fetchImpl = fetch } = {}) {
|
||||
let current = String(url);
|
||||
for (let hop = 0; hop <= 5; hop++) {
|
||||
if (!isPublicMediaUrl(current))
|
||||
throw new Error("Media download blocked: URL is not public HTTP(S)");
|
||||
const response = await fetchImpl(current, { method, signal, redirect: "manual" });
|
||||
if (!(response.status >= 300 && response.status < 400)) return response;
|
||||
const location = response.headers.get("location");
|
||||
if (!location) return response;
|
||||
await response.body?.cancel();
|
||||
current = new URL(location, current).href;
|
||||
}
|
||||
throw new Error("Media download exceeded redirect limit");
|
||||
}
|
||||
|
||||
// Stream and abort once the cap is crossed, so a lying or chunked body can't buffer past it.
|
||||
export async function readCappedBody(res, maxBytes, label) {
|
||||
const declared = Number(res.headers.get("content-length"));
|
||||
if (declared > maxBytes) throw new Error(`${label}: ${declared} bytes exceeds ${maxBytes} cap`);
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for await (const chunk of res.body) {
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) throw new Error(`${label}: stream exceeds ${maxBytes} cap`);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// The one owner of where the person's media library lives. This repo's test runs set
|
||||
// HYPERFRAMES_MEDIA_HOME_REQUIRED, so a test that forgot HYPERFRAMES_MEDIA_HOME fails instead
|
||||
// of writing fixtures into the media library apps list as the person's own files.
|
||||
export function mediaHome() {
|
||||
const override = process.env.HYPERFRAMES_MEDIA_HOME;
|
||||
if (override) return override;
|
||||
if (process.env.HYPERFRAMES_MEDIA_HOME_REQUIRED) {
|
||||
throw new Error(
|
||||
"media-use: a test reached the real ~/.media; set HYPERFRAMES_MEDIA_HOME to a temp dir",
|
||||
);
|
||||
}
|
||||
return homedir();
|
||||
}
|
||||
|
||||
export function globalMediaDir() {
|
||||
return join(mediaHome(), ".media");
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
function resolveNpxCliPath(env, pathExists) {
|
||||
const npmExecPath = env.npm_execpath;
|
||||
const nodeExecPath = env.npm_node_execpath || process.execPath;
|
||||
if (npmExecPath) {
|
||||
const fileName = npmExecPath.replace(/\\/g, "/").split("/").pop()?.toLowerCase();
|
||||
const candidate =
|
||||
fileName === "npx-cli.js" ? npmExecPath : join(dirname(npmExecPath), "npx-cli.js");
|
||||
if (pathExists(candidate)) return candidate;
|
||||
}
|
||||
const besideNode = join(dirname(nodeExecPath), "node_modules", "npm", "bin", "npx-cli.js");
|
||||
return pathExists(besideNode) ? besideNode : null;
|
||||
}
|
||||
|
||||
// Sync-spawn analog of the audio engine's spawnP, for execFileSync call sites
|
||||
// that must hard-fail (rather than fall through to another provider) when npx
|
||||
// cannot be resolved. On Windows a bare "npx" is npx.cmd, which
|
||||
// execFileSync/spawnSync cannot exec (spawnSync npx ENOENT) —
|
||||
// resolveSpawnCommand reroutes it through node + npx-cli.js, no shell:true.
|
||||
//
|
||||
// `platform`/`env`/`pathExists` params (defaulting to the real values) exist
|
||||
// so tests can exercise the win32 branch without mocking node:child_process
|
||||
// (its ESM exports are non-configurable) — same idiom as spawnP and
|
||||
// localTtsGenerate.
|
||||
export function resolveNpxInvocation(
|
||||
argv,
|
||||
opts,
|
||||
platform = process.platform,
|
||||
env = process.env,
|
||||
pathExists = existsSync,
|
||||
) {
|
||||
const resolved =
|
||||
platform !== "win32"
|
||||
? { cmd: "npx", args: argv, opts: { stdio: "ignore", ...opts } }
|
||||
: (() => {
|
||||
const nodeExecPath = env.npm_node_execpath || process.execPath;
|
||||
const npxCliPath = resolveNpxCliPath(env, pathExists);
|
||||
return npxCliPath
|
||||
? {
|
||||
cmd: nodeExecPath,
|
||||
args: [npxCliPath, ...argv.map((arg) => String(arg))],
|
||||
opts: { stdio: "ignore", windowsHide: true, ...opts },
|
||||
}
|
||||
: null;
|
||||
})();
|
||||
if (!resolved) {
|
||||
// npx-on-win32 with no resolvable npx-cli.js — same terminal condition
|
||||
// spawnP warns about, surfaced as a throw for callers with no fallback.
|
||||
throw new Error(
|
||||
"cannot run npx on Windows: npm's npx-cli.js was not found " +
|
||||
"(install npm with Node, or run via npx/npm run so npm_execpath is set)",
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Merge Parakeet-MLX token timestamps into word timestamps.
|
||||
//
|
||||
// parakeet-mlx JSON emits SUB-WORD tokens (" H", "ello", ...) with per-token
|
||||
// start/end. Captions + transcript-cut need WORD timestamps, so join tokens
|
||||
// into words on the space boundary: a token whose text starts with a space
|
||||
// (or the very first token) begins a new word; the rest append. Output matches
|
||||
// the { words: [{ text, start, end }] } shape the rest of media-use consumes
|
||||
// (see words.mjs / cutlist.mjs).
|
||||
|
||||
export function mergeTokensToWords(parakeet) {
|
||||
const sentences = Array.isArray(parakeet?.sentences) ? parakeet.sentences : [];
|
||||
const words = [];
|
||||
for (const s of sentences) {
|
||||
for (const t of s.tokens ?? []) {
|
||||
const raw = typeof t.text === "string" ? t.text : "";
|
||||
const startsWord = raw.startsWith(" ") || words.length === 0;
|
||||
if (startsWord) {
|
||||
words.push({ text: raw.trim(), start: t.start, end: t.end });
|
||||
} else {
|
||||
const w = words[words.length - 1];
|
||||
w.text += raw;
|
||||
w.end = t.end;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { text: (parakeet?.text ?? "").trim(), words: words.filter((w) => w.text.length > 0) };
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { globalMediaDir } from "./media-home.mjs";
|
||||
|
||||
/**
|
||||
* Remembered defaults — the lightweight tier of HyperFrames user memory.
|
||||
*
|
||||
* Two files, same shape as the rest of media-use's storage split:
|
||||
* - project `.media/preferences.json` — committed with the repo, so the whole
|
||||
* team inherits it; written every time a brief answer is confirmed.
|
||||
* - user `~/.media/preferences.json` — personal, cross-repo. A key is promoted
|
||||
* here only once the same value has been confirmed in two different projects
|
||||
* (`PROMOTE_AT`), so a one-off choice never pollutes the global defaults.
|
||||
* Pre-promotion evidence accumulates in the user file's `sightings` ledger —
|
||||
* project files can't see each other, so the cross-project count has to live
|
||||
* user-side.
|
||||
*
|
||||
* Consumption contract (brief-contract § 2, Remembered defaults): a remembered
|
||||
* value becomes the recommended option with a receipt naming its source — it
|
||||
* never skips a question, and explicit request content always wins.
|
||||
*/
|
||||
|
||||
const PREFS_FILE = "preferences.json";
|
||||
|
||||
/** Keys the brief contract records; `style_preset` is stored per workflow. */
|
||||
export const PREFERENCE_KEYS = [
|
||||
"destination",
|
||||
"aspect",
|
||||
"language",
|
||||
"flow",
|
||||
"storyboard",
|
||||
"voice",
|
||||
"style_preset",
|
||||
];
|
||||
|
||||
/** A value must be confirmed in this many distinct projects to go user-tier. */
|
||||
export const PROMOTE_AT = 2;
|
||||
|
||||
export function projectPrefsPath(projectDir) {
|
||||
return join(resolve(projectDir), ".media", PREFS_FILE);
|
||||
}
|
||||
|
||||
export function userPrefsPath() {
|
||||
return join(globalMediaDir(), PREFS_FILE);
|
||||
}
|
||||
|
||||
function emptyFile() {
|
||||
return { version: 1, preferences: {}, sightings: {} };
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Tolerant read — a missing or malformed file counts as empty. */
|
||||
function readPrefsFile(path) {
|
||||
try {
|
||||
if (!existsSync(path)) return emptyFile();
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isRecord(parsed)) return emptyFile();
|
||||
return {
|
||||
version: 1,
|
||||
preferences: isRecord(parsed.preferences) ? parsed.preferences : {},
|
||||
sightings: isRecord(parsed.sightings) ? parsed.sightings : {},
|
||||
};
|
||||
} catch {
|
||||
return emptyFile();
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomic write (tmp + rename) so a crash never leaves a torn file. */
|
||||
function writePrefsFile(path, file) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp`;
|
||||
writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`);
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/** `style_preset` entries are stored per workflow as `style_preset.<workflow>`. */
|
||||
export function preferenceKeyFor(key, workflow) {
|
||||
return key === "style_preset" && workflow ? `style_preset.${workflow}` : key;
|
||||
}
|
||||
|
||||
function validEntry(entry) {
|
||||
return isRecord(entry) && typeof entry.value === "string" && entry.value.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The merged view the brief reads: user-tier promoted entries first, project
|
||||
* entries on top (project wins). Each entry carries `source` plus the receipt
|
||||
* material (`confirmed_in`, `updated_at`).
|
||||
*/
|
||||
export function mergedPreferences(projectDir) {
|
||||
const user = readPrefsFile(userPrefsPath());
|
||||
const project = readPrefsFile(projectPrefsPath(projectDir));
|
||||
const merged = {};
|
||||
for (const [key, entry] of Object.entries(user.preferences)) {
|
||||
if (validEntry(entry)) merged[key] = { ...entry, source: "user" };
|
||||
}
|
||||
for (const [key, entry] of Object.entries(project.preferences)) {
|
||||
if (validEntry(entry)) merged[key] = { ...entry, source: "project" };
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function dedupe(list) {
|
||||
return [...new Set(list)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Project tier: same value accumulates confirmations; a changed value starts
|
||||
* provenance over (the old confirmations vouched for the old value).
|
||||
*/
|
||||
function recordProjectTier(projectDir, fullKey, value, projectName, now) {
|
||||
const path = projectPrefsPath(projectDir);
|
||||
const file = readPrefsFile(path);
|
||||
const previous = file.preferences[fullKey];
|
||||
const keepProvenance = validEntry(previous) && previous.value === value;
|
||||
const confirmedIn = keepProvenance
|
||||
? dedupe([...(Array.isArray(previous.confirmed_in) ? previous.confirmed_in : []), projectName])
|
||||
: [projectName];
|
||||
file.preferences[fullKey] = { value, confirmed_in: confirmedIn, updated_at: now };
|
||||
writePrefsFile(path, file);
|
||||
return confirmedIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* User tier: accumulate this sighting in the ledger, and promote the key once
|
||||
* the same value has been confirmed in PROMOTE_AT distinct projects.
|
||||
*/
|
||||
function recordUserSighting(fullKey, value, projectName, now) {
|
||||
const path = userPrefsPath();
|
||||
const file = readPrefsFile(path);
|
||||
const keySightings = isRecord(file.sightings[fullKey]) ? file.sightings[fullKey] : {};
|
||||
const seenIn = dedupe([
|
||||
...(Array.isArray(keySightings[value]) ? keySightings[value] : []),
|
||||
projectName,
|
||||
]);
|
||||
keySightings[value] = seenIn;
|
||||
file.sightings[fullKey] = keySightings;
|
||||
const promoted = seenIn.length >= PROMOTE_AT;
|
||||
if (promoted) {
|
||||
file.preferences[fullKey] = { value, confirmed_in: seenIn, updated_at: now };
|
||||
}
|
||||
writePrefsFile(path, file);
|
||||
return promoted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one confirmed brief answer. Always writes the project tier; feeds the
|
||||
* user tier's sightings ledger and promotes once the same value has been
|
||||
* confirmed in PROMOTE_AT distinct projects. Idempotent per project.
|
||||
*/
|
||||
export function recordPreference({ projectDir, key, value, workflow }) {
|
||||
if (!PREFERENCE_KEYS.includes(key)) {
|
||||
throw new Error(`unknown preference key: "${key}" (known: ${PREFERENCE_KEYS.join(", ")})`);
|
||||
}
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error("a preference needs a non-empty string value");
|
||||
}
|
||||
if (key === "style_preset" && (!workflow || !String(workflow).trim())) {
|
||||
throw new Error("style_preset is stored per workflow — pass --workflow <w>");
|
||||
}
|
||||
const fullKey = preferenceKeyFor(key, workflow);
|
||||
const projectName = basename(resolve(projectDir));
|
||||
const trimmed = value.trim();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const confirmedIn = recordProjectTier(projectDir, fullKey, trimmed, projectName, now);
|
||||
|
||||
// Best-effort — a read-only home directory must never fail a brief.
|
||||
let promoted = false;
|
||||
try {
|
||||
promoted = recordUserSighting(fullKey, trimmed, projectName, now);
|
||||
} catch {
|
||||
// The project record already landed; promotion just waits for next time.
|
||||
}
|
||||
|
||||
return { key: fullKey, value: trimmed, confirmed_in: confirmedIn, promoted };
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { globalMediaDir } from "./media-home.mjs";
|
||||
import { appendRecord, mediaDir, nextId } from "./manifest.mjs";
|
||||
import { regenerateIndex } from "./index-gen.mjs";
|
||||
import { mergedPreferences } from "./prefs-store.mjs";
|
||||
|
||||
/**
|
||||
* Recipes — the heavyweight tier of HyperFrames user memory.
|
||||
*
|
||||
* A recipe is the full confirmed bundle for one video type: the frozen design
|
||||
* spec (`frame.md`), the storyboard skeleton (structure with the content
|
||||
* blanked), and the confirmed brief values — frozen after the run's final
|
||||
* approval, reused to start the next video of the same type from everything
|
||||
* already approved.
|
||||
*
|
||||
* Storage is **named folders**, not content-addressed cache entries: a recipe
|
||||
* is an evolving bundle with a `version`, so re-freezing the same name bumps
|
||||
* the version and archives the old folder as `<name>@v<N>`. Two tiers, same
|
||||
* split as everything else in media-use: project `.media/recipes/<name>/`
|
||||
* (committed) and user `~/.media/recipes/<name>/` (a freeze is already a
|
||||
* confirmed bundle, so it promotes immediately — no two-project rule here).
|
||||
*/
|
||||
|
||||
/** Frontmatter keys that describe THIS video, not the reusable type. */
|
||||
const FRONTMATTER_CONTENT_KEYS = new Set(["message", "audience", "mode"]);
|
||||
|
||||
/** BRIEF.md frontmatter keys that describe this run, not the reusable type —
|
||||
* a recipe never locks the run's shape, so the intent layer always re-asks. */
|
||||
const BRIEF_CONTENT_KEYS = new Set(["flow", "storyboard", "message", "audience"]);
|
||||
|
||||
/** Per-frame metadata that is content, not structure. */
|
||||
const FRAME_CONTENT_KEYS = new Set([
|
||||
"voiceover",
|
||||
"vo",
|
||||
"voice_over",
|
||||
"narration",
|
||||
"scene",
|
||||
"description",
|
||||
"summary",
|
||||
"caption",
|
||||
"asset_candidates",
|
||||
]);
|
||||
|
||||
const FRAME_HEADING_RE = /^(#{2,3})\s+(?:frame|beat|scene)\s+\d+/i;
|
||||
|
||||
export function projectRecipesDir(projectDir) {
|
||||
return join(mediaDir(projectDir), "recipes");
|
||||
}
|
||||
|
||||
export function userRecipesDir() {
|
||||
return join(globalMediaDir(), "recipes");
|
||||
}
|
||||
|
||||
export function slugifyRecipeName(name) {
|
||||
const slug = String(name ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
if (!slug) throw new Error(`recipe name "${name}" has no usable characters`);
|
||||
return slug;
|
||||
}
|
||||
|
||||
function frameTitle(headingLine) {
|
||||
const dash = headingLine.split(/\s+—\s+/)[1];
|
||||
if (dash && dash.trim()) return dash.trim();
|
||||
return headingLine.replace(/^#+\s*/, "").trim();
|
||||
}
|
||||
|
||||
/** Frontmatter: drop the content keys, keep structure/style keys verbatim. */
|
||||
function skeletonFrontmatter(lines, out, contentKeys = FRONTMATTER_CONTENT_KEYS) {
|
||||
if (lines[0]?.trim() !== "---") return 0;
|
||||
out.push(lines[0]);
|
||||
let i = 1;
|
||||
while (i < lines.length && lines[i].trim() !== "---") {
|
||||
const key = lines[i].match(/^(\w+)\s*:/)?.[1]?.toLowerCase();
|
||||
if (!key || !contentKeys.has(key)) out.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) {
|
||||
out.push(lines[i]); // closing ---
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/** One line inside a frame section — returns the replacement lines (may be none). */
|
||||
function skeletonFrameLine(line, state, out) {
|
||||
const bulletKey = line.match(/^-\s+(\w+)\s*:/)?.[1]?.toLowerCase();
|
||||
if (bulletKey) {
|
||||
if (bulletKey === "status") out.push("- status: outline");
|
||||
else if (!FRAME_CONTENT_KEYS.has(bulletKey)) out.push(line);
|
||||
return;
|
||||
}
|
||||
if (!line.trim()) {
|
||||
out.push(line);
|
||||
return;
|
||||
}
|
||||
// Frame prose: one placeholder per frame in place of the narrative.
|
||||
if (!state.proseReplaced) {
|
||||
out.push(
|
||||
`<fill in: this video's content for the "${state.title}" beat — keep the layout role, replace the words.>`,
|
||||
);
|
||||
state.proseReplaced = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeletonize a STORYBOARD.md: keep the reusable structure (frame count,
|
||||
* durations, transitions, src paths, the Video direction block, style-ish
|
||||
* frontmatter), reset every status to `outline`, and blank the content
|
||||
* (message/audience, narration guides, per-frame prose) down to a fill-in
|
||||
* placeholder that names the frame's role.
|
||||
*/
|
||||
/**
|
||||
* Skeletonize a BRIEF.md: keep the frontmatter's reusable keys (workflow,
|
||||
* destination, aspect, language, length, angle…), drop the run-shape and
|
||||
* content keys (flow, storyboard, message, audience), and blank each body
|
||||
* section down to a fill-in placeholder under its kept heading.
|
||||
*/
|
||||
export function skeletonizeBrief(source) {
|
||||
const lines = String(source ?? "").split(/\r?\n/);
|
||||
const out = [];
|
||||
let i = skeletonFrontmatter(lines, out, BRIEF_CONTENT_KEYS);
|
||||
for (; i < lines.length; i++) {
|
||||
const heading = lines[i].match(/^##\s+(.+)$/);
|
||||
if (heading) {
|
||||
out.push(lines[i], "");
|
||||
out.push(
|
||||
`<fill in: this video's ${heading[1].trim().toLowerCase()} — the recipe keeps the shape, this run supplies the specifics.>`,
|
||||
);
|
||||
out.push("");
|
||||
}
|
||||
}
|
||||
return out.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
export function skeletonizeStoryboard(source) {
|
||||
const lines = String(source ?? "").split(/\r?\n/);
|
||||
const out = [];
|
||||
const state = { inFrame: false, proseReplaced: false, title: "" };
|
||||
for (let i = skeletonFrontmatter(lines, out); i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (/^#{2,3}\s/.test(line)) {
|
||||
state.inFrame = FRAME_HEADING_RE.test(line);
|
||||
state.proseReplaced = false;
|
||||
state.title = state.inFrame ? frameTitle(line) : "";
|
||||
out.push(line);
|
||||
} else if (!state.inFrame) {
|
||||
out.push(line);
|
||||
} else {
|
||||
skeletonFrameLine(line, state, out);
|
||||
}
|
||||
}
|
||||
return out.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
/** The run's workflow as BRIEF.md records it — the source of truth a freeze
|
||||
* must not contradict. Undefined when no BRIEF.md (or no `workflow:`) exists. */
|
||||
function briefWorkflow(root) {
|
||||
const brief = join(root, "BRIEF.md");
|
||||
if (!existsSync(brief)) return undefined;
|
||||
const lines = readFileSync(brief, "utf8").split(/\r?\n/);
|
||||
if (lines[0]?.trim() !== "---") return undefined;
|
||||
for (let i = 1; i < lines.length && lines[i].trim() !== "---"; i++) {
|
||||
const match = lines[i].match(/^workflow\s*:\s*(.+?)\s*$/);
|
||||
if (match) return match[1].replace(/^["']|["']$/g, "") || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readRecipeJson(dir) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(join(dir, "recipe.json"), "utf8"));
|
||||
if (typeof parsed !== "object" || parsed === null || typeof parsed.name !== "string") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function prefValue(prefs, key) {
|
||||
return prefs[key]?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze the current project's approved run as a named recipe. Writes the
|
||||
* project-tier folder + a manifest record, then copies to the user tier (a
|
||||
* freeze is already confirmed — it promotes immediately).
|
||||
*/
|
||||
export function freezeRecipe({ projectDir, name, workflow, blocks }) {
|
||||
const slug = slugifyRecipeName(name);
|
||||
const root = resolve(projectDir);
|
||||
const fromBrief = briefWorkflow(root);
|
||||
const fromFlag = workflow && String(workflow).trim() ? String(workflow).trim() : undefined;
|
||||
// BRIEF.md decides; the flag only covers projects briefed before it existed.
|
||||
const resolvedWorkflow = fromBrief ?? fromFlag;
|
||||
if (!resolvedWorkflow) {
|
||||
throw new Error("no workflow found — BRIEF.md names none and no --workflow was given");
|
||||
}
|
||||
const frameSpec = join(root, "frame.md");
|
||||
const storyboard = join(root, "STORYBOARD.md");
|
||||
if (!existsSync(frameSpec)) throw new Error("no frame.md to freeze — run the design step first");
|
||||
if (!existsSync(storyboard)) throw new Error("no STORYBOARD.md to freeze");
|
||||
|
||||
const dir = join(projectRecipesDir(root), slug);
|
||||
let version = 1;
|
||||
const previous = existsSync(dir) ? readRecipeJson(dir) : null;
|
||||
if (previous) {
|
||||
version = (Number.isInteger(previous.version) ? previous.version : 1) + 1;
|
||||
const archive = `${dir}@v${previous.version ?? 1}`;
|
||||
rmSync(archive, { recursive: true, force: true });
|
||||
renameSync(dir, archive);
|
||||
}
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const prefs = mergedPreferences(root);
|
||||
const recipe = {
|
||||
version,
|
||||
name: slug,
|
||||
workflow: resolvedWorkflow,
|
||||
approved_at: new Date().toISOString(),
|
||||
source_project: basename(root),
|
||||
destination: prefValue(prefs, "destination"),
|
||||
aspect: prefValue(prefs, "aspect"),
|
||||
language: prefValue(prefs, "language"),
|
||||
voice: prefValue(prefs, "voice"),
|
||||
// The bare-key fallback tolerates records made before the store required
|
||||
// style_preset to be workflow-scoped.
|
||||
style_preset:
|
||||
prefValue(prefs, `style_preset.${resolvedWorkflow}`) ?? prefValue(prefs, "style_preset"),
|
||||
blocks: Array.isArray(blocks) && blocks.length > 0 ? blocks : undefined,
|
||||
};
|
||||
|
||||
writeFileSync(join(dir, "recipe.json"), `${JSON.stringify(recipe, null, 2)}\n`);
|
||||
cpSync(frameSpec, join(dir, "frame.md"));
|
||||
writeFileSync(
|
||||
join(dir, "storyboard-skeleton.md"),
|
||||
`${skeletonizeStoryboard(readFileSync(storyboard, "utf8")).trimEnd()}\n`,
|
||||
);
|
||||
|
||||
// Best-effort fourth artifact — projects briefed before BRIEF.md existed
|
||||
// (or by workflows that don't write one) freeze fine without it.
|
||||
const brief = join(root, "BRIEF.md");
|
||||
const briefSkeleton = existsSync(brief);
|
||||
if (briefSkeleton) {
|
||||
writeFileSync(
|
||||
join(dir, "brief-skeleton.md"),
|
||||
`${skeletonizeBrief(readFileSync(brief, "utf8")).trimEnd()}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = nextId(root, "recipe");
|
||||
appendRecord(root, {
|
||||
id,
|
||||
type: "recipe",
|
||||
path: `.media/recipes/${slug}/recipe.json`,
|
||||
entity: slug,
|
||||
description: `recipe: ${slug} (${recipe.workflow}, v${version})`,
|
||||
provenance: { provider: "recipe.freeze", version, source_project: recipe.source_project },
|
||||
});
|
||||
regenerateIndex(root);
|
||||
|
||||
// User tier — best-effort, like every other promotion.
|
||||
try {
|
||||
const userDir = join(userRecipesDir(), slug);
|
||||
mkdirSync(userDir, { recursive: true });
|
||||
cpSync(dir, userDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// The project-tier freeze already landed.
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
slug,
|
||||
version,
|
||||
dir,
|
||||
briefSkeleton,
|
||||
workflow: resolvedWorkflow,
|
||||
workflowOverridden: Boolean(fromBrief && fromFlag && fromBrief !== fromFlag),
|
||||
};
|
||||
}
|
||||
|
||||
function scanRecipesDir(dir, source) {
|
||||
if (!existsSync(dir)) return [];
|
||||
const found = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name.includes("@v")) continue;
|
||||
const recipe = readRecipeJson(join(dir, entry.name));
|
||||
if (recipe) found.push({ ...recipe, source, dir: join(dir, entry.name) });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Two-tier merged listing (project wins), newest approval first. */
|
||||
export function listRecipes({ projectDir, workflow }) {
|
||||
const merged = new Map();
|
||||
for (const recipe of scanRecipesDir(userRecipesDir(), "user")) merged.set(recipe.name, recipe);
|
||||
for (const recipe of scanRecipesDir(projectRecipesDir(resolve(projectDir)), "project")) {
|
||||
merged.set(recipe.name, recipe);
|
||||
}
|
||||
let list = [...merged.values()];
|
||||
if (workflow) list = list.filter((r) => r.workflow === workflow);
|
||||
return list.sort((a, b) =>
|
||||
String(b.approved_at ?? "").localeCompare(String(a.approved_at ?? "")),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a recipe into the current project: import the folder from the user
|
||||
* tier when the project doesn't have it, copy its frame.md over the project's,
|
||||
* and hand back the values + the skeleton path for the storyboard draft.
|
||||
*/
|
||||
export function useRecipe({ projectDir, name }) {
|
||||
const slug = slugifyRecipeName(name);
|
||||
const root = resolve(projectDir);
|
||||
let dir = join(projectRecipesDir(root), slug);
|
||||
|
||||
if (!readRecipeJson(dir)) {
|
||||
const userDir = join(userRecipesDir(), slug);
|
||||
if (!readRecipeJson(userDir)) {
|
||||
const known = listRecipes({ projectDir: root }).map((r) => r.name);
|
||||
throw new Error(
|
||||
`no recipe named "${slug}"${known.length ? ` (known: ${known.join(", ")})` : ""}`,
|
||||
);
|
||||
}
|
||||
mkdirSync(dir, { recursive: true });
|
||||
cpSync(userDir, dir, { recursive: true, force: true });
|
||||
const imported = readRecipeJson(dir);
|
||||
appendRecord(root, {
|
||||
id: nextId(root, "recipe"),
|
||||
type: "recipe",
|
||||
path: `.media/recipes/${slug}/recipe.json`,
|
||||
entity: slug,
|
||||
description: `recipe: ${slug} (${imported.workflow}, v${imported.version})`,
|
||||
provenance: { provider: "recipe.local", imported_from: "user-tier" },
|
||||
});
|
||||
regenerateIndex(root);
|
||||
}
|
||||
|
||||
const recipe = readRecipeJson(dir);
|
||||
cpSync(join(dir, "frame.md"), join(root, "frame.md"));
|
||||
return {
|
||||
recipe,
|
||||
dir,
|
||||
frameSpecPath: "frame.md",
|
||||
skeletonPath: `.media/recipes/${slug}/storyboard-skeleton.md`,
|
||||
// Recipes frozen before BRIEF.md existed have no brief skeleton — degrade.
|
||||
briefSkeletonPath: existsSync(join(dir, "brief-skeleton.md"))
|
||||
? `.media/recipes/${slug}/brief-skeleton.md`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Usage tracking shares the CLI and Studio identity. Properties stay coarse and
|
||||
// never carry intent text, file names, or paths.
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { withFileLock } from "./config-lock.mjs";
|
||||
import { globalMediaDir } from "./media-home.mjs";
|
||||
|
||||
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||
const POSTHOG_HOST = "https://us.i.posthog.com";
|
||||
const TIMEOUT_MS = 1500;
|
||||
let identifiedAccount = false;
|
||||
let warnedNonDefaultHost = false;
|
||||
|
||||
function isTestOrCiContext() {
|
||||
return (
|
||||
process.env.CI === "true" ||
|
||||
process.env.CI === "1" ||
|
||||
process.env.NODE_ENV === "test" ||
|
||||
process.env.NODE_ENV === "development"
|
||||
);
|
||||
}
|
||||
|
||||
function posthogHost() {
|
||||
const override = process.env.MEDIA_USE_TELEMETRY_HOST;
|
||||
if (override && !warnedNonDefaultHost && !isTestOrCiContext()) {
|
||||
warnedNonDefaultHost = true;
|
||||
console.error(
|
||||
`media-use: telemetry is redirected to a non-default host via MEDIA_USE_TELEMETRY_HOST (${override}) — unset it unless this is intentional.`,
|
||||
);
|
||||
}
|
||||
return override || POSTHOG_HOST;
|
||||
}
|
||||
|
||||
/** True when telemetry must NOT be sent (opt-out envs, CI, dev, shared config). */
|
||||
export function optedOut() {
|
||||
return (
|
||||
// `hyperframes telemetry disable` writes telemetryEnabled: false to the
|
||||
// shared config; one opt-out must cover the CLI and the media-use scripts.
|
||||
readSharedConfig().telemetryEnabled === false ||
|
||||
process.env.HYPERFRAMES_NO_TELEMETRY === "1" ||
|
||||
process.env.DO_NOT_TRACK === "1" ||
|
||||
process.env.CI === "true" ||
|
||||
process.env.CI === "1" ||
|
||||
process.env.NODE_ENV === "development"
|
||||
);
|
||||
}
|
||||
|
||||
// Read and write the shared config so media-use keeps one identity per install.
|
||||
function sharedConfigPath() {
|
||||
return join(homedir(), ".hyperframes", "config.json");
|
||||
}
|
||||
|
||||
function readSharedConfig() {
|
||||
try {
|
||||
const file = sharedConfigPath();
|
||||
if (existsSync(file)) {
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
||||
}
|
||||
} catch {
|
||||
// unreadable config → treat as empty; never throw
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Best effort: skipped when the lock cannot be taken or the file cannot be read, never written unlocked.
|
||||
function updateSharedConfig(patch) {
|
||||
const file = sharedConfigPath();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
try {
|
||||
withFileLock(`${file}.lock`, fs, () => {
|
||||
let config = {};
|
||||
try {
|
||||
config = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") return;
|
||||
}
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) return;
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify({ ...config, ...patch }, null, 2) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, file);
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Adopt a pre-existing media-use-only id (~/.media/anon-id from before this
|
||||
// change) so upgraders keep their PostHog persona instead of resetting to a new
|
||||
// one — otherwise cross-surface continuity would start over on upgrade.
|
||||
function legacyMediaAnonId() {
|
||||
try {
|
||||
const file = join(globalMediaDir(), "anon-id");
|
||||
if (existsSync(file)) {
|
||||
const id = readFileSync(file, "utf8").trim();
|
||||
if (id) return id;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stable per-machine id from the shared config; seeds it (adopting a legacy
|
||||
// media-use id when present) if absent.
|
||||
function anonymousId() {
|
||||
try {
|
||||
const config = readSharedConfig();
|
||||
if (typeof config.anonymousId === "string" && config.anonymousId.trim()) {
|
||||
return config.anonymousId.trim();
|
||||
}
|
||||
const id = legacyMediaAnonId() || randomUUID();
|
||||
updateSharedConfig({ anonymousId: id });
|
||||
return id;
|
||||
} catch {
|
||||
return "anon"; // best-effort; a shared bucket is fine if the fs is read-only
|
||||
}
|
||||
}
|
||||
|
||||
function heygenAccountDistinctId() {
|
||||
const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
|
||||
try {
|
||||
if (!existsSync(file)) return null;
|
||||
const raw = readFileSync(file, "utf8").trim();
|
||||
if (!raw.startsWith("{")) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
const user = parsed.user;
|
||||
if (!user || typeof user !== "object" || Array.isArray(user)) return null;
|
||||
const id = typeof user.email === "string" && user.email.trim() ? user.email : user.username;
|
||||
// Lowercased so this joins with the CLI's own identify call regardless of
|
||||
// the account's stored email casing — two different-case distinct ids
|
||||
// would otherwise split one person across two PostHog profiles.
|
||||
return typeof id === "string" && id.trim() ? id.trim().toLowerCase() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function showTelemetryNotice() {
|
||||
if (optedOut()) return;
|
||||
try {
|
||||
const config = readSharedConfig();
|
||||
// Shared with the CLI (config.telemetryNoticeShown): shown once per person
|
||||
// across surfaces, not once per tool.
|
||||
if (config.telemetryNoticeShown === true) return;
|
||||
console.error(
|
||||
[
|
||||
"media-use sends usage telemetry: media type, resolution source, and provider; never intent text, file names, or paths.",
|
||||
"If you sign in to HeyGen, usage links to your account email or username. Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.",
|
||||
].join("\n"),
|
||||
);
|
||||
updateSharedConfig({ telemetryNoticeShown: true });
|
||||
} catch {
|
||||
// notice is best-effort; never surface into the command
|
||||
}
|
||||
}
|
||||
|
||||
async function postBatch(batch) {
|
||||
try {
|
||||
await fetch(`${posthogHost()}/batch/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Connection: "close" },
|
||||
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
} catch {
|
||||
// telemetry is best-effort; never surface into the command
|
||||
}
|
||||
}
|
||||
|
||||
async function postEvent(event, properties, distinctId) {
|
||||
await postBatch([
|
||||
{
|
||||
event,
|
||||
properties: { ...properties, surface: "media-use", $ip: null },
|
||||
distinct_id: distinctId,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function identifyAccount(anonId) {
|
||||
if (optedOut() || identifiedAccount) return;
|
||||
const distinctId = heygenAccountDistinctId();
|
||||
if (!distinctId) return;
|
||||
identifiedAccount = true;
|
||||
await postEvent("$identify", { $anon_distinct_id: anonId }, distinctId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget a single event to PostHog. Best-effort: awaited with a short
|
||||
* timeout so a short-lived script flushes before exit, but any failure (offline,
|
||||
* opted out) is swallowed. `properties` must be non-PII (no intent/paths).
|
||||
*/
|
||||
export async function track(event, properties = {}) {
|
||||
if (optedOut()) return;
|
||||
showTelemetryNotice();
|
||||
const anonId = anonymousId();
|
||||
await identifyAccount(anonId);
|
||||
await postEvent(event, properties, anonId);
|
||||
}
|
||||
|
||||
export function __anonymousIdForTest() {
|
||||
return anonymousId();
|
||||
}
|
||||
|
||||
export function __resetTelemetryForTest() {
|
||||
identifiedAccount = false;
|
||||
warnedNonDefaultHost = false;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { optedOut } from "./telemetry.mjs";
|
||||
|
||||
// optedOut() reads ~/.hyperframes/config.json and env vars. Point HOME at a temp
|
||||
// dir so the run never touches the real config, and pin the env opt-outs to a
|
||||
// neutral state; restore both afterwards.
|
||||
const ENV_KEYS = [
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"HYPERFRAMES_NO_TELEMETRY",
|
||||
"DO_NOT_TRACK",
|
||||
"CI",
|
||||
"NODE_ENV",
|
||||
];
|
||||
const savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
||||
const home = mkdtempSync(join(tmpdir(), "mu-telemetry-"));
|
||||
const configPath = join(home, ".hyperframes", "config.json");
|
||||
|
||||
function writeConfig(contents) {
|
||||
if (contents === null) {
|
||||
rmSync(configPath, { force: true });
|
||||
} else {
|
||||
mkdirSync(dirname(configPath), { recursive: true });
|
||||
writeFileSync(configPath, contents);
|
||||
}
|
||||
}
|
||||
|
||||
test.before(() => {
|
||||
for (const key of ENV_KEYS) delete process.env[key];
|
||||
process.env.HOME = process.env.USERPROFILE = home;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (savedEnv[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = savedEnv[key];
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("optedOut is false with no config and no opt-out env", () => {
|
||||
writeConfig(null);
|
||||
assert.equal(optedOut(), false);
|
||||
});
|
||||
|
||||
test("optedOut honors telemetryEnabled: false from the shared config", () => {
|
||||
writeConfig(JSON.stringify({ telemetryEnabled: false }));
|
||||
assert.equal(optedOut(), true);
|
||||
});
|
||||
|
||||
test("optedOut is false when the shared config enables telemetry", () => {
|
||||
writeConfig(JSON.stringify({ telemetryEnabled: true }));
|
||||
assert.equal(optedOut(), false);
|
||||
});
|
||||
|
||||
test("optedOut treats an unreadable shared config as no opt-out", () => {
|
||||
writeConfig("{not json");
|
||||
assert.equal(optedOut(), false);
|
||||
});
|
||||
|
||||
test("optedOut still honors the env opt-outs", () => {
|
||||
writeConfig(JSON.stringify({ telemetryEnabled: true }));
|
||||
process.env.DO_NOT_TRACK = "1";
|
||||
try {
|
||||
assert.equal(optedOut(), true);
|
||||
} finally {
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
function round3(n) {
|
||||
return Math.round(Number(n) * 1000) / 1000;
|
||||
}
|
||||
|
||||
// 30ms in/out ramps kill the click at every concat boundary. A segment shorter
|
||||
// than 4x the ramp would spend its whole length fading, so scale down there and
|
||||
// skip entirely on a degenerate one. fadeIn/fadeOut are false at the export's
|
||||
// own true start/end, where there is no splice to smooth.
|
||||
export function fadeFilterFor(durationSeconds, { fadeIn, fadeOut }) {
|
||||
if (!fadeIn && !fadeOut) return null;
|
||||
const FADE_SECONDS = 0.03;
|
||||
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0.01) return null;
|
||||
const d = Math.min(FADE_SECONDS, durationSeconds / 4);
|
||||
const parts = [];
|
||||
if (fadeIn) parts.push(`afade=t=in:st=0:d=${round3(d)}`);
|
||||
if (fadeOut) {
|
||||
const out = round3(durationSeconds - d);
|
||||
if (out > 0) parts.push(`afade=t=out:st=${out}:d=${round3(d)}`);
|
||||
}
|
||||
return parts.length ? parts.join(",") : null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function normalizeWords(input) {
|
||||
const raw = Array.isArray(input) ? input : Array.isArray(input?.words) ? input.words : [];
|
||||
return raw
|
||||
.map((w, index) => {
|
||||
const text = String(w?.text ?? w?.word ?? "").trim();
|
||||
const start = Number(w?.start);
|
||||
const end = Number(w?.end);
|
||||
if (!text || !Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
return { id: w?.id ?? `w${index}`, text, start, end };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function wordListsFromMediaMeta(input) {
|
||||
if (Array.isArray(input) || Array.isArray(input?.words)) return [normalizeWords(input)];
|
||||
if (!Array.isArray(input?.voices)) return [];
|
||||
return input.voices.map((voice) => normalizeWords(voice));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Remembered defaults CLI — the lightweight tier of HyperFrames user memory.
|
||||
*
|
||||
* node prefs.mjs get --hyperframes . [--json]
|
||||
* Print the merged view (project `.media/preferences.json` over user
|
||||
* `~/.media/preferences.json`), each key with its source and the receipt
|
||||
* material (confirmed_in, updated_at).
|
||||
*
|
||||
* node prefs.mjs record --hyperframes . --key destination --value x-feed [--workflow <w>]
|
||||
* Record one confirmed brief answer into the project tier; the same value
|
||||
* confirmed in two different projects promotes the key to the user tier.
|
||||
*
|
||||
* Consumption rules live in hyperframes/references/brief-contract.md § 2
|
||||
* (Remembered defaults): a remembered value becomes the recommended option
|
||||
* with a receipt — it never skips a question.
|
||||
*/
|
||||
import { parseArgs } from "node:util";
|
||||
import { mergedPreferences, recordPreference } from "./lib/prefs-store.mjs";
|
||||
|
||||
const { values: args, positionals } = parseArgs({
|
||||
options: {
|
||||
hyperframes: { type: "string", default: "." },
|
||||
key: { type: "string" },
|
||||
value: { type: "string" },
|
||||
workflow: { type: "string" },
|
||||
json: { type: "boolean", default: false },
|
||||
},
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
const verb = positionals[0];
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (verb === "get") {
|
||||
const merged = mergedPreferences(args.hyperframes);
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(merged, null, 2));
|
||||
} else if (Object.keys(merged).length === 0) {
|
||||
console.log("no remembered preferences yet");
|
||||
} else {
|
||||
for (const [key, entry] of Object.entries(merged)) {
|
||||
console.log(
|
||||
`${key} = ${entry.value} (${entry.source}; confirmed in ${entry.confirmed_in.join(", ")})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (verb === "record") {
|
||||
if (!args.key || !args.value) fail("record needs --key and --value");
|
||||
try {
|
||||
const result = recordPreference({
|
||||
projectDir: args.hyperframes,
|
||||
key: args.key,
|
||||
value: args.value,
|
||||
workflow: args.workflow,
|
||||
});
|
||||
const promotion = result.promoted ? "; promoted to user tier" : "";
|
||||
console.log(`recorded ${result.key} = ${result.value} (project${promotion})`);
|
||||
} catch (err) {
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} else {
|
||||
fail(
|
||||
"usage: prefs.mjs <get|record> --hyperframes . [--key <k> --value <v> --workflow <w>] [--json]",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Recipes CLI — the heavyweight tier of HyperFrames user memory.
|
||||
*
|
||||
* node recipe.mjs freeze --hyperframes . --name <n> [--workflow <w>] [--blocks a,b,c]
|
||||
* Freeze the current approved run as a named recipe: frame.md + the
|
||||
* storyboard skeleton (structure kept, content blanked) + the brief
|
||||
* skeleton (when BRIEF.md exists) + the confirmed brief values.
|
||||
* The workflow is read from BRIEF.md; --workflow only covers projects
|
||||
* briefed before BRIEF.md existed. Re-freezing the same name bumps the
|
||||
* version and archives the old folder as <name>@v<N>. Promotes to
|
||||
* ~/.media/recipes/ immediately.
|
||||
*
|
||||
* node recipe.mjs list --hyperframes . [--workflow <w>] [--json]
|
||||
* Two-tier merged listing (project wins), newest approval first.
|
||||
*
|
||||
* node recipe.mjs use --hyperframes . --name <n> [--json]
|
||||
* Adopt a recipe: import it from the user tier if needed, copy its
|
||||
* frame.md over the project's, and print the brief values + skeleton path.
|
||||
*
|
||||
* When recipes are offered/consumed is the review loop's and the intent
|
||||
* layer's business — see hyperframes/references/review-loop.md § 4 and
|
||||
* the intent layer's recipe check (hyperframes SKILL.md § 4).
|
||||
*/
|
||||
import { parseArgs } from "node:util";
|
||||
import { freezeRecipe, listRecipes, useRecipe } from "./lib/recipe-store.mjs";
|
||||
|
||||
const { values: args, positionals } = parseArgs({
|
||||
options: {
|
||||
hyperframes: { type: "string", default: "." },
|
||||
name: { type: "string" },
|
||||
workflow: { type: "string" },
|
||||
blocks: { type: "string" },
|
||||
json: { type: "boolean", default: false },
|
||||
},
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
const verb = positionals[0];
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
if (verb === "freeze") {
|
||||
if (!args.name) fail("freeze needs --name");
|
||||
const frozen = freezeRecipe({
|
||||
projectDir: args.hyperframes,
|
||||
name: args.name,
|
||||
workflow: args.workflow,
|
||||
blocks: args.blocks
|
||||
? args.blocks
|
||||
.split(",")
|
||||
.map((b) => b.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
});
|
||||
if (args.json) console.log(JSON.stringify({ ok: true, ...frozen }));
|
||||
else {
|
||||
console.log(
|
||||
`froze recipe ${frozen.slug} (v${frozen.version}, ${frozen.workflow}) → ${frozen.dir}`,
|
||||
);
|
||||
if (frozen.workflowOverridden)
|
||||
console.log(` (BRIEF.md says "${frozen.workflow}" — the --workflow flag was ignored)`);
|
||||
if (!frozen.briefSkeleton)
|
||||
console.log(" (no BRIEF.md in the project — brief skeleton skipped)");
|
||||
}
|
||||
} else if (verb === "list") {
|
||||
const list = listRecipes({ projectDir: args.hyperframes, workflow: args.workflow });
|
||||
if (args.json) console.log(JSON.stringify(list));
|
||||
else if (list.length === 0) console.log("no recipes yet");
|
||||
else {
|
||||
for (const r of list) {
|
||||
console.log(
|
||||
`${r.name} v${r.version} ${r.workflow} (${r.source}; approved ${String(r.approved_at ?? "?").slice(0, 10)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (verb === "use") {
|
||||
if (!args.name) fail("use needs --name");
|
||||
const used = useRecipe({ projectDir: args.hyperframes, name: args.name });
|
||||
if (args.json) console.log(JSON.stringify({ ok: true, ...used }));
|
||||
else {
|
||||
console.log(
|
||||
`using recipe ${used.recipe.name} (v${used.recipe.version}, ${used.recipe.workflow})`,
|
||||
);
|
||||
console.log(` frame spec → ${used.frameSpecPath} (copied over)`);
|
||||
console.log(` storyboard skeleton → ${used.skeletonPath}`);
|
||||
if (used.briefSkeletonPath) console.log(` brief skeleton → ${used.briefSkeletonPath}`);
|
||||
for (const key of ["destination", "aspect", "language", "voice", "style_preset"]) {
|
||||
if (used.recipe[key]) console.log(` ${key}: ${used.recipe[key]}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fail(
|
||||
"usage: recipe.mjs <freeze|list|use> --hyperframes . [--name <n>] [--workflow <w>] [--blocks a,b] [--json]",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
|
||||
test(
|
||||
"plugin resolver uses the release CLI without a repository checkout",
|
||||
{ skip: process.platform === "win32" },
|
||||
(t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-resolve-plugin-"));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
mkdirSync(join(dir, "lib"));
|
||||
for (const file of ["resolve.mjs", "lib/npx-sync.mjs"]) {
|
||||
copyFileSync(fileURLToPath(new URL(file, import.meta.url)), join(dir, file));
|
||||
}
|
||||
writeFileSync(
|
||||
join(dir, "npx"),
|
||||
`#!${process.execPath}\nconsole.log(JSON.stringify({args:process.argv.slice(2),skip:process.env.HYPERFRAMES_SKIP_SKILLS}));process.exit(7);`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const result = spawnSync(process.execPath, [join(dir, "resolve.mjs"), "--intent", "a & b"], {
|
||||
cwd: dir,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${dir}${delimiter}${process.env.PATH}`,
|
||||
HYPERFRAMES_PLUGIN_VERSION: "1.2.3",
|
||||
HYPERFRAMES_SKIP_SKILLS: "1",
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 7, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), {
|
||||
args: ["--yes", "hyperframes@1.2.3", "media-use", "resolve", "--intent", "a & b"],
|
||||
skip: "1",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("helper package override keeps a contributor's local CLI", (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-resolve-local-"));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const scripts = join(root, "skills/media-use/scripts");
|
||||
mkdirSync(join(scripts, "lib"), { recursive: true });
|
||||
mkdirSync(join(root, "packages/cli/dist"), { recursive: true });
|
||||
for (const file of ["resolve.mjs", "lib/npx-sync.mjs"]) {
|
||||
copyFileSync(fileURLToPath(new URL(file, import.meta.url)), join(scripts, file));
|
||||
}
|
||||
writeFileSync(join(root, "packages/cli/dist/cli.js"), 'console.log("local-cli");');
|
||||
const result = spawnSync(process.execPath, [join(scripts, "resolve.mjs")], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
HYPERFRAMES_PLUGIN_VERSION: "",
|
||||
HYPERFRAMES_SKILL_PKG_VERSION: "0.8.79",
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout.trim(), "local-cli");
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveNpxInvocation } from "./lib/npx-sync.mjs";
|
||||
|
||||
// Installed plugins have no monorepo packages/ tree. The plugin launcher passes
|
||||
// the release version and suppression environment through to this legacy shim.
|
||||
const pluginVersion = process.env.HYPERFRAMES_PLUGIN_VERSION;
|
||||
if (pluginVersion) {
|
||||
const invocation = resolveNpxInvocation(
|
||||
["--yes", `hyperframes@${pluginVersion}`, "media-use", "resolve", ...process.argv.slice(2)],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
const result = spawnSync(invocation.cmd, invocation.args, invocation.opts);
|
||||
if (result.error) console.error(result.error.message);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
const root = join(fileURLToPath(new URL("../../../", import.meta.url)));
|
||||
const dist = join(root, "packages/cli/dist/cli.js");
|
||||
const result = existsSync(dist)
|
||||
? spawnSync(process.execPath, [dist, "media-use", "resolve", ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
: spawnSync(
|
||||
"bun",
|
||||
[join(root, "packages/cli/src/cli.ts"), "media-use", "resolve", ...process.argv.slice(2)],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
copyFileSync,
|
||||
renameSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { basename, extname, join, resolve } from "node:path";
|
||||
import { parseArgs, stripVTControlCharacters } from "node:util";
|
||||
import { mergeTokensToWords } from "./lib/parakeet-words.mjs";
|
||||
import { track } from "./lib/telemetry.mjs";
|
||||
import { resolveNpxInvocation } from "./lib/npx-sync.mjs";
|
||||
|
||||
// The DEFAULT local transcription path. Prefers NVIDIA Parakeet-TDT via
|
||||
// parakeet-mlx, which beats whisper.cpp on the Open ASR Leaderboard (~6.05% vs
|
||||
// 7.44% avg WER, and 4.73% vs 5.96% on noisy test-other) and is 5-10x faster
|
||||
// with native punctuation. Emits { text, words:[{text,start,end}] } (word
|
||||
// timestamps merged from Parakeet's sub-word tokens) for transcript-cut /
|
||||
// captions / the audio engine.
|
||||
//
|
||||
// Parakeet v3 covers English + 25 European languages. For other languages, or
|
||||
// when parakeet-mlx is not installed, it falls back to whisper.cpp via
|
||||
// `hyperframes transcribe` (99 languages; the CLI resolves/builds whisper.cpp
|
||||
// on first use — it is not bundled). `--engine` forces one.
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
input: { type: "string", short: "i" },
|
||||
out: { type: "string", short: "o" },
|
||||
engine: { type: "string", default: "auto" }, // auto | parakeet | whisper
|
||||
model: { type: "string", default: "mlx-community/parakeet-tdt-0.6b-v3" },
|
||||
json: { type: "boolean", default: false },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
if (args.help) {
|
||||
console.log(`media-use transcribe: better-than-whisper local ASR (Parakeet), whisper.cpp fallback
|
||||
|
||||
Usage:
|
||||
node transcribe.mjs --input audio.wav [--out audio.transcribe.json] [--engine auto|parakeet|whisper]
|
||||
|
||||
Parakeet (default) beats whisper.cpp on accuracy + speed for English/European
|
||||
languages; whisper.cpp (99 languages) is the fallback. Install Parakeet once:
|
||||
npx hyperframes models install parakeet (macOS, Windows, Linux with glibc 2.32+)
|
||||
uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx (Apple Silicon)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!args.input) {
|
||||
console.error("error: --input is required");
|
||||
process.exit(2);
|
||||
}
|
||||
const inputPath = resolve(args.input);
|
||||
if (!existsSync(inputPath)) {
|
||||
console.error(`error: input not found: ${inputPath}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const outPath = resolve(
|
||||
args.out || `${inputPath.slice(0, -extname(inputPath).length)}.transcribe.json`,
|
||||
);
|
||||
|
||||
// Locate the parakeet-mlx runner the same way the CLI does: env override, then
|
||||
// the documented ~/.venvs/parakeet install, then PATH. Checking the venv (not
|
||||
// just PATH) is what keeps a user who followed the install docs verbatim from
|
||||
// silently falling through to whisper. Returns the runner path, or null.
|
||||
function resolveParakeet() {
|
||||
for (const p of [
|
||||
process.env.HYPERFRAMES_PARAKEET,
|
||||
join(homedir(), ".venvs", "parakeet", "bin", "parakeet-mlx"),
|
||||
]) {
|
||||
if (p && existsSync(p)) return p;
|
||||
}
|
||||
try {
|
||||
execFileSync("parakeet-mlx", ["--help"], {
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
timeout: 20000,
|
||||
});
|
||||
return "parakeet-mlx";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Write via a sibling temp + atomic rename so a SIGKILL mid-write can't leave a
|
||||
// truncated transcript at outPath (downstream reads it as valid JSON).
|
||||
function atomicWrite(target, data) {
|
||||
const tmp = `${target}.tmp-${process.pid}`;
|
||||
writeFileSync(tmp, data);
|
||||
renameSync(tmp, target);
|
||||
}
|
||||
|
||||
function report(engine, wordCount) {
|
||||
if (args.json) console.log(JSON.stringify({ ok: true, out: outPath, engine, words: wordCount }));
|
||||
else
|
||||
console.log(
|
||||
`transcribed ${basename(inputPath)} -> ${outPath}${wordCount != null ? ` (${wordCount} words,` : " ("}${engine})`,
|
||||
);
|
||||
}
|
||||
|
||||
function runParakeet(runner) {
|
||||
const workDir = mkdtempSync(join(tmpdir(), "media-use-asr-"));
|
||||
try {
|
||||
execFileSync(
|
||||
runner,
|
||||
[inputPath, "--model", args.model, "--output-format", "json", "--output-dir", workDir],
|
||||
{ stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 },
|
||||
);
|
||||
const jsonPath = join(workDir, `${basename(inputPath, extname(inputPath))}.json`);
|
||||
if (!existsSync(jsonPath)) throw new Error("parakeet produced no JSON");
|
||||
const merged = mergeTokensToWords(JSON.parse(readFileSync(jsonPath, "utf8")));
|
||||
atomicWrite(outPath, JSON.stringify(merged, null, 2));
|
||||
report("parakeet", merged.words.length);
|
||||
} finally {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// The hyperframes CLI (its Parakeet, else whisper.cpp built on first use — see
|
||||
// SKILL.md): writes transcript.json into --dir; relocate to --out.
|
||||
function runCli(cliEngine) {
|
||||
const workDir = mkdtempSync(join(tmpdir(), "media-use-whisper-"));
|
||||
try {
|
||||
// On Windows a bare "npx" is npx.cmd, which execFileSync cannot exec
|
||||
// (spawnSync npx ENOENT) — resolveNpxInvocation reroutes it through
|
||||
// node + npx-cli.js (and throws actionably when it can't), same
|
||||
// mechanism as the audio engine's TTS spawns.
|
||||
// Under auto or parakeet the CLI may run its own Parakeet; --json says which engine ran.
|
||||
const resolved = resolveNpxInvocation(
|
||||
["hyperframes", "transcribe", inputPath, "--dir", workDir, "--engine", cliEngine, "--json"],
|
||||
{ stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 },
|
||||
);
|
||||
let stdout;
|
||||
try {
|
||||
stdout = String(execFileSync(resolved.cmd, resolved.args, resolved.opts));
|
||||
} catch (e) {
|
||||
// stderr holds spinner redraws and --json prints the reason on stdout, so keep the last lines.
|
||||
const tail = stripVTControlCharacters(`${e.stderr ?? ""}\n${e.stdout ?? ""}`)
|
||||
.split(/[\r\n]+/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && l !== "│")
|
||||
.slice(-3)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`hyperframes transcribe failed (exit ${e.status ?? e.code})${tail ? `:\n${tail}` : ""}`,
|
||||
);
|
||||
}
|
||||
const produced = join(workDir, "transcript.json");
|
||||
if (!existsSync(produced))
|
||||
throw new Error("hyperframes transcribe produced no transcript.json");
|
||||
const tmp = `${outPath}.tmp-${process.pid}`;
|
||||
copyFileSync(produced, tmp);
|
||||
renameSync(tmp, outPath); // atomic publish
|
||||
let words;
|
||||
try {
|
||||
const t = JSON.parse(readFileSync(outPath, "utf8"));
|
||||
// The CLI writes a flat word array, not { words }.
|
||||
words = Array.isArray(t) ? t.length : undefined;
|
||||
} catch {
|
||||
/* leave undefined */
|
||||
}
|
||||
let ran = "whisper";
|
||||
try {
|
||||
ran = JSON.parse(stdout.trim().split("\n").at(-1)).engine === "parakeet" ? "parakeet" : ran;
|
||||
} catch {
|
||||
/* no JSON line: keep whisper */
|
||||
}
|
||||
report(ran, words);
|
||||
return ran;
|
||||
} finally {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parakeetBin = resolveParakeet();
|
||||
const asked = args.engine === "parakeet" || args.engine === "whisper" ? args.engine : "auto";
|
||||
// Without parakeet-mlx, the CLI's own Parakeet (any OS, `hyperframes models install parakeet`).
|
||||
let engine = "parakeet";
|
||||
if (asked !== "whisper" && parakeetBin) runParakeet(parakeetBin);
|
||||
else engine = runCli(asked);
|
||||
await track("media_use_transcribe", { engine });
|
||||
} catch (err) {
|
||||
if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
|
||||
else console.error(`error: transcription failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// The stub stands in for `npx hyperframes transcribe <in> --dir <dir> ...`: it records its args,
|
||||
// writes the CLI's flat array and prints the CLI's --json line naming `cliEngine`.
|
||||
function runScript(engine, cliEngine) {
|
||||
const root = mkdtempSync(join(tmpdir(), "media-use-transcribe-"));
|
||||
try {
|
||||
const bin = join(root, "bin");
|
||||
mkdirSync(bin);
|
||||
writeFileSync(
|
||||
join(bin, "npx"),
|
||||
`#!/bin/sh\necho "$@" > "${root}/args"\nwhile [ "$1" != "--dir" ]; do shift; done\n` +
|
||||
`echo '[{"text":"a","start":0,"end":1},{"text":"b","start":1,"end":2}]' > "$2/transcript.json"\n` +
|
||||
`echo '{"ok":true,"engine":"${cliEngine}"}'\n`,
|
||||
);
|
||||
chmodSync(join(bin, "npx"), 0o755);
|
||||
const input = join(root, "in.wav");
|
||||
writeFileSync(input, "");
|
||||
const script = fileURLToPath(new URL("./transcribe.mjs", import.meta.url));
|
||||
const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, HOME: root };
|
||||
delete env.HYPERFRAMES_PARAKEET;
|
||||
const out = execFileSync(
|
||||
process.execPath,
|
||||
[script, "--input", input, "--engine", engine, "--json"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: { ...env, HYPERFRAMES_MEDIA_HOME: join(root, "home"), HYPERFRAMES_NO_TELEMETRY: "1" },
|
||||
},
|
||||
);
|
||||
return { result: JSON.parse(out.trim()), args: readFileSync(join(root, "args"), "utf8") };
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test(
|
||||
"--engine whisper makes the CLI run whisper and reports the flat transcript's word count",
|
||||
{ skip: process.platform === "win32" },
|
||||
() => {
|
||||
const { result, args } = runScript("whisper", "whisper");
|
||||
assert.match(args, /--engine whisper --json/);
|
||||
assert.equal(result.words, 2);
|
||||
assert.equal(result.engine, "whisper");
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"auto reports the engine the CLI says it ran, Parakeet included",
|
||||
{ skip: process.platform === "win32" },
|
||||
() => {
|
||||
const { result, args } = runScript("auto", "parakeet");
|
||||
assert.match(args, /--engine auto --json/);
|
||||
assert.equal(result.engine, "parakeet");
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"--engine parakeet without parakeet-mlx asks the CLI for its Parakeet",
|
||||
{ skip: process.platform === "win32" },
|
||||
() => {
|
||||
const { result, args } = runScript("parakeet", "parakeet");
|
||||
assert.match(args, /--engine parakeet --json/);
|
||||
assert.equal(result.engine, "parakeet");
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"a failed --engine parakeet run reports the reason the CLI printed on stdout",
|
||||
{ skip: process.platform === "win32" },
|
||||
() => {
|
||||
const root = mkdtempSync(join(tmpdir(), "media-use-transcribe-"));
|
||||
try {
|
||||
const bin = join(root, "bin");
|
||||
mkdirSync(bin);
|
||||
const reason =
|
||||
"Parakeet is not installed. Install it with: hyperframes models install parakeet";
|
||||
writeFileSync(
|
||||
join(bin, "npx"),
|
||||
`#!/bin/sh\necho '{"ok":false,"error":"${reason}"}'\nexit 1\n`,
|
||||
);
|
||||
chmodSync(join(bin, "npx"), 0o755);
|
||||
const input = join(root, "in.wav");
|
||||
writeFileSync(input, "");
|
||||
const script = fileURLToPath(new URL("./transcribe.mjs", import.meta.url));
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[script, "--input", input, "--engine", "parakeet", "--json"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
HYPERFRAMES_PARAKEET: "",
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
HOME: root,
|
||||
HYPERFRAMES_MEDIA_HOME: join(root, "home"),
|
||||
HYPERFRAMES_NO_TELEMETRY: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(JSON.parse(res.stdout.trim()).error, /hyperframes models install parakeet/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { compileCutList } from "./lib/cutlist.mjs";
|
||||
import { fadeFilterFor } from "./lib/transcriptCutFade.mjs";
|
||||
import { track } from "./lib/telemetry.mjs";
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
input: { type: "string" },
|
||||
transcript: { type: "string" },
|
||||
remove: { type: "string" },
|
||||
"remove-words": { type: "string" },
|
||||
"remove-fillers": { type: "string" },
|
||||
"cut-silence": { type: "string" },
|
||||
keep: { type: "string" },
|
||||
copy: { type: "boolean", default: false },
|
||||
plan: { type: "boolean", default: false },
|
||||
out: { type: "string" },
|
||||
json: { type: "boolean", default: false },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
if (args.help) {
|
||||
console.log(`media-use transcript-cut — compile transcript edits into video cuts
|
||||
|
||||
Usage:
|
||||
node transcript-cut.mjs --input in.mp4 --transcript transcript.json --remove "12-15" --out out.mp4
|
||||
|
||||
Options:
|
||||
--input Source video/audio file
|
||||
--transcript JSON word transcript, array or { words: [...] }
|
||||
--remove Time ranges to remove, seconds: a-b,c-d
|
||||
--remove-words Word-index ranges to remove: 12-18,40-41
|
||||
--remove-fillers Comma list of filler words to remove
|
||||
--cut-silence Remove inter-word gaps longer than this many seconds
|
||||
--keep Inverse mode: direct kept ranges, mutually exclusive with removal
|
||||
--copy Use stream copy for faster, keyframe-snapped cuts
|
||||
--plan Print kept segment JSON and exit without ffmpeg
|
||||
--out Output file
|
||||
--json Output JSON status
|
||||
--help, -h Show this help`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
run();
|
||||
await track("media_use_transcript_cut", {
|
||||
mode: args.plan ? "plan" : "encode",
|
||||
remove_fillers: !!args["remove-fillers"],
|
||||
cut_silence: !!args["cut-silence"],
|
||||
ranges: !!args.remove,
|
||||
keep: !!args.keep,
|
||||
});
|
||||
} catch (err) {
|
||||
if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
|
||||
else console.error(`error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (!args.transcript) throw new Error("--transcript is required");
|
||||
const transcript = JSON.parse(readFileSync(resolve(args.transcript), "utf8"));
|
||||
const segments = compileCutList(transcript, {
|
||||
remove: args.remove,
|
||||
removeWords: args["remove-words"],
|
||||
removeFillers: args["remove-fillers"],
|
||||
cutSilence: args["cut-silence"],
|
||||
keep: args.keep,
|
||||
});
|
||||
|
||||
if (args.plan) {
|
||||
console.log(JSON.stringify(segments));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.input || !args.out)
|
||||
throw new Error("--input and --out are required unless --plan is set");
|
||||
if (segments.length === 0) throw new Error("cut list has no kept segments");
|
||||
|
||||
const inputPath = resolve(args.input);
|
||||
const outPath = resolve(args.out);
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "media-use-cut-"));
|
||||
const keptSeconds = sumDurations(segments);
|
||||
const totalSeconds = probeDuration(inputPath);
|
||||
|
||||
try {
|
||||
const parts = segments.map((segment, index) => {
|
||||
// Intermediates carry PCM audio, not the final codec. Encoding each
|
||||
// segment to a lossy codec separately makes the encoder pad every segment
|
||||
// with priming silence (~25-35ms for AAC), which concat then bakes in as a
|
||||
// gap at each cut -- a defect distinct from, and surviving, the fades
|
||||
// below. PCM has no priming, so audio is encoded exactly once, at concat.
|
||||
// The video container is .mov, not .mkv: ffmpeg writes Matroska
|
||||
// timestamps in whole milliseconds, which moves frames off the source's
|
||||
// frame grid, and the concat copy below carries that into the output.
|
||||
const ext = args.copy ? extname(outPath) || ".mp4" : isAudioOnly(outPath) ? ".wav" : ".mov";
|
||||
const out = join(tmpDir, `segment-${String(index).padStart(4, "0")}${ext}`);
|
||||
// --copy stays fade-free (stream copy cannot filter). A segment's true
|
||||
// start/end (index 0's start, the last segment's end) borders nothing
|
||||
// kept, so only an interior splice edge gets a ramp.
|
||||
const fade = args.copy
|
||||
? null
|
||||
: fadeFilterFor(segment.end - segment.start, {
|
||||
fadeIn: index > 0,
|
||||
fadeOut: index < segments.length - 1,
|
||||
});
|
||||
cutSegment(inputPath, segment, out, args.copy, fade);
|
||||
return out;
|
||||
});
|
||||
const listPath = join(tmpDir, "list.txt");
|
||||
writeFileSync(
|
||||
listPath,
|
||||
parts.map((part) => `file '${escapeConcatPath(part)}'`).join("\n") + "\n",
|
||||
);
|
||||
// Encode to a sibling temp (same extension so ffmpeg picks the right muxer),
|
||||
// then atomic-rename so a SIGKILL mid-encode can't leave a truncated outPath.
|
||||
const tmpOut = `${outPath}.part${extname(outPath) || ".mp4"}`;
|
||||
// --copy already produced final-codec segments, so concat can stream-copy.
|
||||
// Otherwise the PCM intermediates are encoded here, once, for the whole file.
|
||||
const concatCodecs = args.copy
|
||||
? ["-c", "copy"]
|
||||
: isAudioOnly(outPath)
|
||||
? encodeArgsFor(extname(outPath).toLowerCase())
|
||||
: ["-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"];
|
||||
execFileSync(
|
||||
"ffmpeg",
|
||||
["-y", "-f", "concat", "-safe", "0", "-i", listPath, ...concatCodecs, tmpOut],
|
||||
{
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
renameSync(tmpOut, outPath);
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Stream copy can only cut on keyframes; on sparse-keyframe footage the snap
|
||||
// can silently swallow the whole cut. Compare, then surface the drift in BOTH
|
||||
// the stderr warning (human) and the --json result (pipelines).
|
||||
let copyDrift = null;
|
||||
if (args.copy) {
|
||||
const outSeconds = probeDuration(outPath);
|
||||
if (Math.abs(outSeconds - keptSeconds) > 1) {
|
||||
copyDrift = { produced_s: round3(outSeconds), expected_s: round3(keptSeconds) };
|
||||
if (!args.json) {
|
||||
console.error(
|
||||
`warning: --copy keyframe snapping produced ${round3(outSeconds)}s instead of ${round3(keptSeconds)}s kept; drop --copy for frame-accurate cuts`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
input: inputPath,
|
||||
out: outPath,
|
||||
segments,
|
||||
kept_s: round3(keptSeconds),
|
||||
total_s: round3(totalSeconds),
|
||||
...(copyDrift && { copy_drift: copyDrift }),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`cut ${inputPath} -> ${outPath} (${segments.length} segments, ${fmt(keptSeconds)}s kept of ${fmt(
|
||||
totalSeconds,
|
||||
)}s)`,
|
||||
);
|
||||
console.log(`next: resolve --from ${outPath} --type <type>`);
|
||||
}
|
||||
|
||||
function cutSegment(inputPath, segment, outPath, copy, fade) {
|
||||
const argv = [
|
||||
"-y",
|
||||
"-nostdin",
|
||||
"-ss",
|
||||
seekTime(segment.start),
|
||||
"-i",
|
||||
inputPath,
|
||||
"-to",
|
||||
seekTime(segment.end - segment.start),
|
||||
];
|
||||
if (copy) {
|
||||
argv.push("-c", "copy", "-avoid_negative_ts", "make_zero");
|
||||
} else if (extname(outPath).toLowerCase() === ".mov") {
|
||||
// Concat splices raw segment edges together; without a short ramp the
|
||||
// waveform steps discontinuously at every boundary and you hear a click.
|
||||
if (fade) argv.push("-af", fade);
|
||||
// Video intermediate: keep the picture cheap and the audio uncompressed.
|
||||
argv.push("-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-c:a", "pcm_s16le");
|
||||
// A keyframe every second. The renderer seeks into this footage, and x264
|
||||
// on its own leaves up to 250 frames between keyframes.
|
||||
argv.push("-force_key_frames", "expr:gte(t,n_forced)");
|
||||
// Constant frame rate from the first frame. Left to itself, ffmpeg 7 starts
|
||||
// a range that begins between two source frames one frame late, and .mov
|
||||
// rounds that lead-in to the millisecond.
|
||||
argv.push("-fps_mode", "cfr");
|
||||
} else {
|
||||
if (fade) argv.push("-af", fade);
|
||||
argv.push("-c:a", "pcm_s16le");
|
||||
}
|
||||
argv.push(outPath);
|
||||
execFileSync("ffmpeg", argv, { stdio: "ignore" });
|
||||
}
|
||||
|
||||
// Codec set per output container. Audio-only outputs must not get the
|
||||
// video-centric aac/x264 set (aac inside .wav breaks timing entirely).
|
||||
function isAudioOnly(filePath) {
|
||||
return [".wav", ".mp3", ".m4a", ".aac", ".flac"].includes(extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
function encodeArgsFor(ext) {
|
||||
if (ext === ".wav") return ["-c:a", "pcm_s16le"];
|
||||
if (ext === ".mp3") return ["-c:a", "libmp3lame", "-q:a", "2"];
|
||||
if (ext === ".m4a" || ext === ".aac") return ["-c:a", "aac"];
|
||||
if (ext === ".flac") return ["-c:a", "flac"];
|
||||
return [
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"18",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
];
|
||||
}
|
||||
|
||||
function probeDuration(filePath) {
|
||||
const raw = execFileSync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
"--",
|
||||
filePath,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const duration = Number(raw.trim());
|
||||
if (!Number.isFinite(duration) || duration <= 0)
|
||||
throw new Error(`could not probe duration: ${filePath}`);
|
||||
return duration;
|
||||
}
|
||||
|
||||
function escapeConcatPath(filePath) {
|
||||
return filePath.replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
function sumDurations(segments) {
|
||||
return segments.reduce((sum, segment) => sum + (segment.end - segment.start), 0);
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
return round3(n)
|
||||
.toFixed(3)
|
||||
.replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
// -ss/-to name frames, and ffmpeg's accurate seek drops everything before the
|
||||
// time it is given: at 30 fps, frame 386 sits at 12.866666 s, and a 3-decimal
|
||||
// -ss of 12.867 starts that segment on frame 387 instead, leaving the cut a
|
||||
// frame short from the join on. So the values ffmpeg gets keep frame precision,
|
||||
// while fmt() above stays at 3 decimals for the times that are only printed.
|
||||
function seekTime(n) {
|
||||
return Number(n).toFixed(6);
|
||||
}
|
||||
|
||||
function round3(n) {
|
||||
return Math.round(Number(n) * 1000) / 1000;
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL("./transcript-cut.mjs", import.meta.url));
|
||||
const HAS_FFMPEG =
|
||||
spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0 &&
|
||||
spawnSync("ffprobe", ["-version"], { stdio: "ignore" }).status === 0;
|
||||
|
||||
const SAMPLE_RATE = 44100;
|
||||
// A continuous tone's own slope between samples never exceeds amplitude * 2*pi*f/rate
|
||||
// (~2054 for 440Hz at full scale here); a raw splice between two independently-cut
|
||||
// points on the same tone lands at two unrelated phases, so an unfaded join jumps far
|
||||
// past that. This threshold sits well above normal tone motion and well below a splice.
|
||||
const MAX_CONTINUOUS_STEP = 6000;
|
||||
const MIN_SUSPICIOUS_ZERO_RUN = 20;
|
||||
|
||||
function fixture() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "media-use-transcript-cut-"));
|
||||
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
function run(args) {
|
||||
return spawnSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function readPcm(filePath) {
|
||||
const raw = execFileSync("ffmpeg", [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
filePath,
|
||||
"-f",
|
||||
"s16le",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
String(SAMPLE_RATE),
|
||||
"-",
|
||||
]);
|
||||
const samples = new Int16Array(raw.buffer, raw.byteOffset, raw.length / 2);
|
||||
return samples;
|
||||
}
|
||||
|
||||
// Three kept ranges, none starting or ending on a frame boundary of any rate below.
|
||||
const UNALIGNED_KEEP = "0.137-1.913,2.845-4.111,4.9-5.47";
|
||||
const UNALIGNED_KEPT_SECONDS = 1.776 + 1.266 + 0.57;
|
||||
|
||||
function cutVideo(t, rate, keep = UNALIGNED_KEEP) {
|
||||
const { dir, cleanup } = fixture();
|
||||
t.after(cleanup);
|
||||
|
||||
const source = join(dir, "source.mp4");
|
||||
execFileSync("ffmpeg", [
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`testsrc2=size=160x90:rate=${rate}:duration=6`,
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`sine=frequency=440:duration=6:sample_rate=${SAMPLE_RATE}`,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
source,
|
||||
]);
|
||||
|
||||
const transcriptPath = join(dir, "transcript.json");
|
||||
writeFileSync(transcriptPath, JSON.stringify({ words: [{ text: "tone", start: 0, end: 6 }] }));
|
||||
|
||||
const output = join(dir, "out.mp4");
|
||||
const result = run([
|
||||
"--input",
|
||||
source,
|
||||
"--transcript",
|
||||
transcriptPath,
|
||||
"--keep",
|
||||
keep,
|
||||
"--out",
|
||||
output,
|
||||
"--json",
|
||||
]);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
return output;
|
||||
}
|
||||
|
||||
function readVideoTimestamps(filePath) {
|
||||
const probe = JSON.parse(
|
||||
execFileSync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=time_base:packet=pts",
|
||||
"-of",
|
||||
"json",
|
||||
"--",
|
||||
filePath,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
),
|
||||
);
|
||||
const [tickNum, tickDen] = probe.streams[0].time_base.split("/").map(Number);
|
||||
// Packets come in decode order; B-frames put that out of presentation order.
|
||||
const pts = probe.packets.map((packet) => Number(packet.pts)).sort((a, b) => a - b);
|
||||
return { tickNum, tickDen, pts };
|
||||
}
|
||||
|
||||
for (const rate of ["30", "30000/1001", "60"]) {
|
||||
test(
|
||||
`puts every frame of a ${rate} fps video cut on the frame grid, with no gaps`,
|
||||
{ skip: !HAS_FFMPEG },
|
||||
(t) => {
|
||||
const [rateNum, rateDen = 1] = rate.split("/").map(Number);
|
||||
const { tickNum, tickDen, pts } = readVideoTimestamps(cutVideo(t, rate));
|
||||
assert.ok(pts.length > rateNum / rateDen, "expected several seconds of video");
|
||||
|
||||
// Frame i belongs at exactly i / rate seconds. Compared as integers, so a
|
||||
// timestamp rounded to the millisecond (0.067 for 2/30) cannot pass.
|
||||
const offGrid = pts.filter(
|
||||
(value, index) => value * tickNum * rateNum !== index * rateDen * tickDen,
|
||||
);
|
||||
assert.equal(
|
||||
offGrid.length,
|
||||
0,
|
||||
`${offGrid.length} of ${pts.length} frames are off the ${rate} fps grid (timestamps in units of ${tickNum}/${tickDen} s)`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"keeps the keyframes of a video cut no more than a second apart",
|
||||
{ skip: !HAS_FFMPEG },
|
||||
(t) => {
|
||||
// One kept range of almost four seconds, so only the encoder decides where keyframes go.
|
||||
const output = cutVideo(t, "30", "0.137-4.111,4.9-5.47");
|
||||
const probe = JSON.parse(
|
||||
execFileSync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"packet=pts_time,flags",
|
||||
"-of",
|
||||
"json",
|
||||
"--",
|
||||
output,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
),
|
||||
);
|
||||
const keyframes = probe.packets
|
||||
.filter((packet) => packet.flags.startsWith("K"))
|
||||
.map((packet) => Number(packet.pts_time))
|
||||
.sort((a, b) => a - b);
|
||||
const gaps = keyframes.slice(1).map((time, index) => time - keyframes[index]);
|
||||
const widest = Math.max(...gaps);
|
||||
// A frame of slack: the forced keyframe is the first frame at or after each second.
|
||||
assert.ok(widest < 1.05, `keyframes are up to ${widest.toFixed(3)}s apart`);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"does not pad the audio of a video cut with encoder priming at each splice",
|
||||
{ skip: !HAS_FFMPEG },
|
||||
(t) => {
|
||||
const seconds = readPcm(cutVideo(t, "30")).length / SAMPLE_RATE;
|
||||
// The single AAC encode may round the end up by a frame or two (23 ms each).
|
||||
// Encoding every segment separately instead adds about 25 ms per splice on top.
|
||||
assert.ok(
|
||||
Math.abs(seconds - UNALIGNED_KEPT_SECONDS) < 0.05,
|
||||
`audio runs ${seconds.toFixed(3)}s for ${UNALIGNED_KEPT_SECONDS.toFixed(3)}s kept`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"keeps a spliced tone continuous at every cut, with no raw phase jump or silence gap",
|
||||
{ skip: !HAS_FFMPEG },
|
||||
(t) => {
|
||||
const { dir, cleanup } = fixture();
|
||||
t.after(cleanup);
|
||||
|
||||
const source = join(dir, "tone.wav");
|
||||
execFileSync("ffmpeg", [
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`sine=frequency=440:duration=6:sample_rate=${SAMPLE_RATE}`,
|
||||
source,
|
||||
]);
|
||||
|
||||
const transcriptPath = join(dir, "transcript.json");
|
||||
writeFileSync(transcriptPath, JSON.stringify({ words: [{ text: "tone", start: 0, end: 6 }] }));
|
||||
|
||||
const output = join(dir, "out.wav");
|
||||
// Two "ugly" removal ranges: neither aligned to the 440Hz period, so the kept
|
||||
// segments' cut edges land at unrelated phases of the same continuous tone --
|
||||
// exactly the shape that clicks without a fade, and the shape jrusso1020's
|
||||
// review proved this branch never actually fades.
|
||||
const result = run([
|
||||
"--input",
|
||||
source,
|
||||
"--transcript",
|
||||
transcriptPath,
|
||||
"--remove",
|
||||
"1.37-1.83,3.29-3.71",
|
||||
"--out",
|
||||
output,
|
||||
"--json",
|
||||
]);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
|
||||
const samples = readPcm(output);
|
||||
assert.ok(samples.length > SAMPLE_RATE, "expected several seconds of audio");
|
||||
|
||||
let maxStep = 0;
|
||||
let zeroRun = 0;
|
||||
let maxZeroRun = 0;
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
const step = Math.abs(samples[i] - samples[i - 1]);
|
||||
if (step > maxStep) maxStep = step;
|
||||
if (samples[i] === 0) {
|
||||
zeroRun++;
|
||||
if (zeroRun > maxZeroRun) maxZeroRun = zeroRun;
|
||||
} else {
|
||||
zeroRun = 0;
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
maxStep <= MAX_CONTINUOUS_STEP,
|
||||
`largest sample-to-sample step was ${maxStep}, expected <= ${MAX_CONTINUOUS_STEP} (a raw, unfaded splice)`,
|
||||
);
|
||||
assert.ok(
|
||||
maxZeroRun < MIN_SUSPICIOUS_ZERO_RUN,
|
||||
`found a run of ${maxZeroRun} consecutive zero samples, expected < ${MIN_SUSPICIOUS_ZERO_RUN} (a priming-silence gap)`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Frame 32 at 30 fps sits at 1.0666666 s. A -ss rounded to 1.067 lands just past
|
||||
// it, so ffmpeg's accurate seek starts that segment on frame 33 and the cut comes
|
||||
// out a frame short from the join on.
|
||||
const THIRTY_FPS_FRAME_32 = 32 / 30;
|
||||
|
||||
test("a kept range starting on a 30 fps frame keeps that frame", { skip: !HAS_FFMPEG }, (t) => {
|
||||
const { dir, cleanup } = fixture();
|
||||
t.after(cleanup);
|
||||
|
||||
const source = join(dir, "src.mp4");
|
||||
execFileSync("ffmpeg", [
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=320x180:rate=30",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`sine=frequency=440:sample_rate=${SAMPLE_RATE}`,
|
||||
"-t",
|
||||
"4",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
source,
|
||||
]);
|
||||
|
||||
const transcriptPath = join(dir, "transcript.json");
|
||||
writeFileSync(transcriptPath, JSON.stringify([{ text: "a", start: 0.1, end: 3.9 }]));
|
||||
|
||||
const output = join(dir, "out.mp4");
|
||||
const result = run([
|
||||
"--input",
|
||||
source,
|
||||
"--transcript",
|
||||
transcriptPath,
|
||||
"--keep",
|
||||
`0-1,${THIRTY_FPS_FRAME_32}-3`,
|
||||
"--out",
|
||||
output,
|
||||
"--json",
|
||||
]);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
|
||||
// Frames 0-29 for 0-1s, then frames 32-89 for the second segment. Rounding
|
||||
// -ss to the millisecond drops frame 32 and yields 87.
|
||||
const frames = Number(
|
||||
execFileSync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v",
|
||||
"-count_packets",
|
||||
"-show_entries",
|
||||
"stream=nb_read_packets",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
"--",
|
||||
output,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
).trim(),
|
||||
);
|
||||
assert.equal(frames, 88, `expected 88 frames, got ${frames}`);
|
||||
});
|
||||
Reference in new issue
Block a user