Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/cli/src/media-use/lib/ff-binaries.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { execFileSync } from "node:child_process";
import { resolve } from "node:path";

// The binaries media-use spawns: HYPERFRAMES_FFMPEG_PATH / HYPERFRAMES_FFPROBE_PATH when set, else PATH.
// A set path that is not a working ffmpeg/ffprobe throws, so a broken override never reads as "no metadata".
const runsByPath = new Map();

export class FfBinarySettingError extends Error {}

function configuredOr(name, envVar) {
const setting = process.env[envVar]?.trim();
if (!setting) return name;
const path = resolve(setting);
if (!runsByPath.has(path)) runsByPath.set(path, isWorking(path, name));
if (!runsByPath.get(path)) {
const why = `${envVar} names "${path}", which is not a working ${name}: fix it or unset it.`;
throw new FfBinarySettingError(why);
}
return path;
}

function isWorking(path, name) {
try {
const out = execFileSync(path, ["-version"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 15000,
windowsHide: true,
});
return out.startsWith(`${name} version`);
} catch {
return false;
}
}

export const ffmpegBinary = () => configuredOr("ffmpeg", "HYPERFRAMES_FFMPEG_PATH");
export const ffprobeBinary = () => configuredOr("ffprobe", "HYPERFRAMES_FFPROBE_PATH");
4 changes: 3 additions & 1 deletion packages/cli/src/media-use/lib/probe.mjs
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { execFileSync } from "node:child_process";
import { extname } from "node:path";
import { ffprobeBinary } from "./ff-binaries.mjs";

const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico"]);

export function probe(filePath) {
const ext = extname(filePath).toLowerCase();
if (ext === ".svg") return { width: null, height: null, duration: null, codec: "svg" };

const ffprobe = ffprobeBinary();
try {
// execFileSync (no shell) so a hostile filename like `"; rm -rf ~; ".png`
// can't break out of the quoting — filePath is passed as a literal argv entry.
const raw = execFileSync(
"ffprobe",
ffprobe,
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "--", filePath],
{ encoding: "utf8", timeout: 5000 },
);
Expand Down
119 changes: 118 additions & 1 deletion packages/cli/src/media-use/lib/probe.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { chmodSync, mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { probe } from "./probe.mjs";
Expand Down Expand Up @@ -29,3 +29,120 @@ test("probe does not execute shell metacharacters in a filename", () => {
rmSync(dir, { recursive: true, force: true });
}
});

test(
"probe runs the ffprobe HYPERFRAMES_FFPROBE_PATH names",
{ skip: process.platform === "win32" },
() => {
const dir = mkdtempSync(join(tmpdir(), "probe-ffprobe-path-"));
const fake = join(dir, "fake-ffprobe");
writeFileSync(
fake,
`#!/bin/sh\n[ "$1" = -version ] && echo 'ffprobe version fake' && exit 0\necho '{"streams":[{"width":7,"height":3,"codec_name":"fake"}]}'\n`,
);
chmodSync(fake, 0o755);
const configured = process.env.HYPERFRAMES_FFPROBE_PATH;
process.env.HYPERFRAMES_FFPROBE_PATH = fake;
try {
assert.deepEqual(probe(join(dir, "clip.png")), {
duration: null,
width: 7,
height: 3,
codec: "fake",
});
} finally {
if (configured === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
else process.env.HYPERFRAMES_FFPROBE_PATH = configured;
rmSync(dir, { recursive: true, force: true });
}
},
);

test("probe refuses an HYPERFRAMES_FFPROBE_PATH that cannot run instead of reporting no metadata", () => {
const configured = process.env.HYPERFRAMES_FFPROBE_PATH;
process.env.HYPERFRAMES_FFPROBE_PATH = join(tmpdir(), "no-ffprobe-here", "ffprobe");
try {
assert.throws(
() => probe("clip.wav"),
/HYPERFRAMES_FFPROBE_PATH names ".*no-ffprobe-here.*fix it or unset it/,
);
} finally {
if (configured === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
else process.env.HYPERFRAMES_FFPROBE_PATH = configured;
}
});

test("probe refuses an HYPERFRAMES_FFPROBE_PATH that names a folder", () => {
const configured = process.env.HYPERFRAMES_FFPROBE_PATH;
process.env.HYPERFRAMES_FFPROBE_PATH = tmpdir();
try {
assert.throws(() => probe("clip.wav"), /HYPERFRAMES_FFPROBE_PATH names .*fix it or unset it/);
} finally {
if (configured === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
else process.env.HYPERFRAMES_FFPROBE_PATH = configured;
}
});

function withFfprobePath(value, run) {
const configured = process.env.HYPERFRAMES_FFPROBE_PATH;
process.env.HYPERFRAMES_FFPROBE_PATH = value;
try {
return run();
} finally {
if (configured === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
else process.env.HYPERFRAMES_FFPROBE_PATH = configured;
}
}

test(
"probe refuses a configured ffprobe that cannot start, exits with an error, or is another tool",
{
skip: process.platform === "win32",
},
() => {
const dir = mkdtempSync(join(tmpdir(), "probe-broken-ffprobe-"));
try {
const noInterpreter = join(dir, "no-interpreter");
writeFileSync(noInterpreter, "#!/nonexistent/interpreter\n");
const failing = join(dir, "failing");
writeFileSync(failing, "#!/bin/sh\nexit 3\n");
const otherTool = join(dir, "other-tool");
writeFileSync(otherTool, "#!/bin/sh\necho 'ffmpeg version 9.9'\n");
for (const broken of [noInterpreter, failing, otherTool]) {
chmodSync(broken, 0o755);
withFfprobePath(broken, () =>
assert.throws(
() => probe("clip.wav"),
/HYPERFRAMES_FFPROBE_PATH names .*fix it or unset it/,
),
);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);

test(
"probe runs a relative HYPERFRAMES_FFPROBE_PATH from the working folder, not from PATH",
{
skip: process.platform === "win32",
},
() => {
const dir = mkdtempSync(join(tmpdir(), "probe-relative-ffprobe-"));
const prevCwd = process.cwd();
try {
writeFileSync(
join(dir, "local-ffprobe"),
`#!/bin/sh\n[ "$1" = -version ] && echo 'ffprobe version fake' && exit 0\necho '{"streams":[{"width":5,"height":4,"codec_name":"local"}]}'\n`,
);
chmodSync(join(dir, "local-ffprobe"), 0o755);
process.chdir(dir);
const meta = withFfprobePath("local-ffprobe", () => probe("clip.png"));
assert.deepEqual(meta, { duration: null, width: 5, height: 4, codec: "local" });
} finally {
process.chdir(prevCwd);
rmSync(dir, { recursive: true, force: true });
}
},
);
8 changes: 5 additions & 3 deletions packages/cli/src/media-use/lib/tts-local-provider.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
import { existsSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffprobeBinary } from "./ff-binaries.mjs";
import { resolveSpawnCommand } from "../../audio/scripts/lib/tts.mjs";

// Local voiceover via the packaged Kokoro-82M TTS (the `hyperframes tts` CLI),
Expand All @@ -13,10 +14,10 @@ import { resolveSpawnCommand } from "../../audio/scripts/lib/tts.mjs";
// Delegated to the hyperframes CLI (same as transcribe / remove-background), not
// re-implemented here. ffprobe reads the duration back for the ledger.

function probeDurationSeconds(file) {
function probeDurationSeconds(ffprobe, file) {
try {
const out = execFileSync(
"ffprobe",
ffprobe,
["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", "--", file],
{ encoding: "utf8", timeout: 15000 },
);
Expand All @@ -39,6 +40,7 @@ export async function localTtsGenerate(
env = process.env,
pathExists = existsSync,
) {
const ffprobe = ffprobeBinary();
const outPath = join(tmpdir(), `media-use-kokoro-${process.pid}-${Date.now()}.wav`);
const argv = ["hyperframes", "tts", intent, "--output", outPath];
if (ctx?.voice) argv.push("--voice", ctx.voice);
Expand Down Expand Up @@ -85,7 +87,7 @@ export async function localTtsGenerate(
metadata: {
description: intent,
provider: "kokoro.local",
duration: probeDurationSeconds(outPath),
duration: probeDurationSeconds(ffprobe, outPath),
provenance: { engine: "kokoro-82m", prompt: intent },
},
};
Expand Down
64 changes: 45 additions & 19 deletions packages/cli/src/media-use/resolve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { heygenAuthMethod } from "../audio/scripts/lib/heygen.mjs";
import { buildCube, paramsFromIntent } from "./lib/cube-build.mjs";
import { validateCubeFile } from "./lib/cube-validate.mjs";
import { analyzeMediaGrade, formatMeasuredNote } from "./lib/grade-analyzer.mjs";
import { FfBinarySettingError, ffmpegBinary, ffprobeBinary } from "./lib/ff-binaries.mjs";
import {
freezeLibraryLut,
isLibraryLutOfflineMiss,
Expand Down Expand Up @@ -144,7 +145,12 @@ const entity = args.entity || null;

if (args.adopt) {
const { adoptExistingAssets } = await import("./lib/adopt.mjs");
const adopted = adoptExistingAssets(projectDir);
let adopted;
try {
adopted = adoptExistingAssets(projectDir);
} catch (err) {
exitError(err.message);
}
if (args.json) {
console.log(JSON.stringify({ ok: true, adopted: adopted.length, assets: adopted }));
} else if (adopted.length === 0) {
Expand Down Expand Up @@ -218,7 +224,15 @@ if (args.analyze) {
console.error(`error: --for file not found: ${mediaPath}`);
process.exit(2);
}
const analysis = analyzeMediaGrade(mediaPath);
let analysis;
try {
analysis = analyzeMediaGrade(mediaPath, {
ffmpegPath: ffmpegBinary(),
ffprobePath: ffprobeBinary(),
});
} catch (err) {
exitError(err.message);
}
if (args.json) {
console.log(JSON.stringify({ ok: true, type: "grade-analysis", ...analysis }));
} else {
Expand Down Expand Up @@ -503,7 +517,8 @@ async function run() {
// brand stays local: no frame.md/design.md -> upsell the HyperFrames design
// flow rather than reporting a generic miss (B5).
const msg =
providerFailure instanceof BundledSfxAssetsError
providerFailure instanceof BundledSfxAssetsError ||
providerFailure instanceof FfBinarySettingError
? providerFailure.message
: type === "brand"
? "no brand spec found — add a frame.md or design.md (colors/font/logo) to this project. Run the HyperFrames design flow to create one; brand tokens are read locally for deterministic rendering."
Expand Down Expand Up @@ -594,7 +609,10 @@ function mergeSmartAdjust(block) {
const mediaPath = resolve(args.for);
// Clear upfront error beats an ffmpeg "No such file" stack on a typo'd path.
if (!existsSync(mediaPath)) throw new Error(`--for file not found: ${mediaPath}`);
const analysis = analyzeMediaGrade(mediaPath);
const analysis = analyzeMediaGrade(mediaPath, {
ffmpegPath: ffmpegBinary(),
ffprobePath: ffprobeBinary(),
});
console.error(formatMeasuredNote(mediaPath, analysis.measured));
return {
...block,
Expand Down Expand Up @@ -1060,21 +1078,7 @@ function runDoctor() {
checks.push(heygenAuthCheck());
}

const ffmpegProbe = runCommand("ffmpeg", ["-version"]);
checks.push({
name: "ffmpeg on PATH",
ok: ffmpegProbe.status === 0,
detail: ffmpegProbe.status === 0 ? firstLine(ffmpegProbe.stdout) : "ffmpeg not found",
fix: ffmpegProbe.status === 0 ? "" : "brew install ffmpeg",
});

const ffprobeProbe = runCommand("ffprobe", ["-version"]);
checks.push({
name: "ffprobe on PATH",
ok: ffprobeProbe.status === 0,
detail: ffprobeProbe.status === 0 ? firstLine(ffprobeProbe.stdout) : "ffprobe not found",
fix: ffprobeProbe.status === 0 ? "" : "brew install ffmpeg",
});
checks.push(ffDoctorCheck("ffmpeg", ffmpegBinary), ffDoctorCheck("ffprobe", ffprobeBinary));

const nodeOk = !versionLessThan(process.versions.node, MIN_NODE_VERSION);
checks.push({
Expand Down Expand Up @@ -1140,6 +1144,28 @@ function printMap(label, values) {
for (const [key, value] of entries) console.log(` ${key}: ${value}`);
}

function ffDoctorCheck(name, binary) {
let bin;
try {
bin = binary();
} catch (err) {
return {
name: `${name} on PATH`,
ok: false,
detail: err.message,
fix: "fix or unset that variable",
};
}
const probe = runCommand(bin, ["-version"]);
const ok = probe.status === 0;
return {
name: `${name} on PATH`,
ok,
detail: ok ? firstLine(probe.stdout) : `${name} not found`,
fix: ok ? "" : "brew install ffmpeg",
};
}

function runCommand(bin, argv) {
return spawnSync(bin, argv, {
encoding: "utf8",
Expand Down
Loading
Loading