Add HyperFrames skills (from heygen-com/hyperframes, Apache-2.0)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXVsD1DgdzZLiPuwtKzMSY
This commit is contained in:
Claude committed 2026-10-21 23:18:32 +00:00
1 parent 3e3f41ee5f
commit daac216bb9
300 files changed
+49175

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,41 @@
import { cpSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import test from "node:test";
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 });
}
});
+328
View File
@@ -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/);
});
+369
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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,282 @@
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 }),
};
return withMediaLock(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 });
}
}
/** Runs `fn` holding the project's `.media/.lock`, so concurrent resolves take turns on what it guards. */
export function withMediaLock(projectDir, fn) {
mkdirSync(mediaDir(projectDir), { recursive: true });
return withLock(mediaDir(projectDir), fn);
}
// 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(typeDirPath(projectDir, type), { recursive: true });
return withMediaLock(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 (media-use references/memory.md): 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,370 @@
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";
// A filter keeps Node 22 off its native copy, which writes nothing under a non-ASCII path on Windows.
const NON_ASCII_SAFE_DIR_COPY = { recursive: true, force: true, filter: () => true };
/**
* 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 these are always asked again. */
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, NON_ASCII_SAFE_DIR_COPY);
} 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, NON_ASCII_SAFE_DIR_COPY);
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,69 @@
#!/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 rule (references/memory.md): 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]",
);
}
+101
View File
@@ -0,0 +1,101 @@
#!/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 and consumed: references/memory.md.
*/
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}`);
});