feat(cli): surface extract-cache dir in doctor + add --frames-cache-dir sugar

Windows users with the OS temp dir on a small system drive have hit
C: exhaustion mid-render (Slack ts=1784219488 · CLI v0.7.58 · win32
15 GB / 8-core, ~5500 frames). The engine already honors
HYPERFRAMES_EXTRACT_CACHE_DIR for relocation, but the knob was
undocumented and invisible in diagnostics — the reporter had to piece
together a 4-flag compound workaround including EXTRACT_CACHE_DIR=off.

Changes:
- Extract the env-var resolver into a public engine API
  (resolveExtractCacheDir, defaultExtractCacheDir,
  EXTRACT_CACHE_DIR_DISABLED_ALIASES) with a typed resolution shape
  distinguishing "disabled by user" vs "default" vs "env override".
- Add a Frames-cache check to `hyperframes doctor` that reports the
  effective directory, its free space, source (env or default), and
  fails with a relocation hint when <2 GB free at that mount.
- Add `hyperframes render --frames-cache-dir <path>` as discoverable
  CLI sugar for the env var, including the opt-out aliases
  (off/none/false/0) and CWD-safe absolute-path resolution.
- Document the flag in docs/packages/cli.mdx with the field-signal
  citation, and add a render example row for the Windows workflow.
- Cover both surfaces with unit tests (6 doctor cases + 4 engine
  cases including all disabled-alias variants).

Refs Slack #hyperframes-cli-feedback ts=1784219488 (win32 v0.7.58).

Co-authored-by: Via <via-heygen[bot]@users.noreply.github.com>
This commit is contained in:
Via
2026-07-16 18:24:43 +00:00
co-authored by Via
parent 3bb26b0f08
commit ca35227506
7 changed files with 289 additions and 19 deletions
+78 -1
View File
@@ -1,5 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { buildDoctorReport, redactHome, parseToolVersion, type CheckOutcome } from "./doctor.js";
import {
buildDoctorReport,
checkFramesCache,
redactHome,
parseToolVersion,
type CheckOutcome,
} from "./doctor.js";
// ── Fixtures ────────────────────────────────────────────────────────────────
@@ -195,3 +201,74 @@ describe("buildDoctorReport", () => {
});
});
});
describe("checkFramesCache", () => {
it("reports disabled state with the raw env value when the user opts out", () => {
const result = checkFramesCache(
{ HYPERFRAMES_EXTRACT_CACHE_DIR: "off" },
() => 100_000,
() => true,
);
expect(result.ok).toBe(true);
expect(result.detail).toContain("Disabled (off)");
expect(result.detail).toContain("per-render workDir");
});
it("reports the effective directory + free space + default source when env is unset", () => {
const result = checkFramesCache(
{},
() => 50_000, // ~48.8 GB
() => true,
);
expect(result.ok).toBe(true);
expect(result.detail).toContain("48.8 GB free");
expect(result.detail).toContain("default");
});
it("reports a positive env override with source: HYPERFRAMES_EXTRACT_CACHE_DIR", () => {
const result = checkFramesCache(
{ HYPERFRAMES_EXTRACT_CACHE_DIR: "/mnt/scratch/hf" },
() => 10_000,
() => true,
);
expect(result.ok).toBe(true);
expect(result.detail).toContain("/mnt/scratch/hf");
expect(result.detail).toContain("HYPERFRAMES_EXTRACT_CACHE_DIR");
});
it("fails with a relocation hint when free space at the cache location is <2 GB", () => {
const result = checkFramesCache(
{ HYPERFRAMES_EXTRACT_CACHE_DIR: "/tmp/hf" },
() => 512, // 0.5 GB
() => true,
);
expect(result.ok).toBe(false);
expect(result.hint).toContain("--frames-cache-dir");
expect(result.hint).toContain("HYPERFRAMES_EXTRACT_CACHE_DIR");
});
it("falls back to the first existing ancestor when the cache dir does not exist yet", () => {
const seen: string[] = [];
const result = checkFramesCache(
{ HYPERFRAMES_EXTRACT_CACHE_DIR: "/mnt/scratch/newly/created/subdir" },
(path) => {
seen.push(path);
return 20_000;
},
(path) => path === "/mnt/scratch",
);
expect(result.ok).toBe(true);
// statfs was called against the walked-up ancestor, not the raw dir.
expect(seen).toContain("/mnt/scratch");
});
it("handles a null free-space reading without failing the check", () => {
const result = checkFramesCache(
{ HYPERFRAMES_EXTRACT_CACHE_DIR: "/mnt/scratch" },
() => null,
() => true,
);
expect(result.ok).toBe(true);
expect(result.detail).toContain("free space unknown");
});
});
+68
View File
@@ -1,7 +1,10 @@
// fallow-ignore-file complexity
import { defineCommand } from "citty";
import { execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { platform } from "node:os";
import { dirname } from "node:path";
import { resolveExtractCacheDir } from "@hyperframes/engine";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { parseToolVersion, runEnvironmentChecks } from "../browser/preflight.js";
@@ -133,6 +136,70 @@ function checkDisk(): CheckResult {
return { ok: true, detail: `${freeGb} GB free` };
}
/**
* Report the effective extracted-frame cache directory. Long renders can
* accumulate multi-GB of extracted video frames here; on Windows the OS
* `%TEMP%` default lives on C:, so users with output on a data drive but the
* OS on a small SSD have hit disk-exhaustion mid-render (field signal
* `ts=1784219488` · CLI 0.7.58 · 15GB/8-core). Surfacing the effective path
* + free space at that path lets `hyperframes doctor` catch the mismatch
* before the render, and reminds users the relocation knob exists.
*
* `statfsSync` requires the path to exist. When the configured cache dir has
* not been created yet, walk up to the nearest existing ancestor and report
* the free space there (which is the same filesystem in practice — free space
* is per-mount, not per-directory).
*/
export function checkFramesCache(
env: Record<string, string | undefined> = process.env,
freeDiskMb: (path: string) => number | null = getFreeDiskMb,
fileExists: (path: string) => boolean = existsSync,
): CheckResult {
const resolution = resolveExtractCacheDir(env);
if (resolution.disabled) {
return {
ok: true,
detail: `Disabled (${resolution.rawValue}) — frames extract into per-render workDir`,
};
}
const dir = resolution.dir;
const probePath = firstExistingAncestor(dir, fileExists) ?? dir;
const freeMb = freeDiskMb(probePath);
if (freeMb === null) {
return {
ok: true,
detail: `${dir} (free space unknown; ${sourceLabel(resolution.source)})`,
};
}
const freeGb = (freeMb / 1024).toFixed(1);
const suffix = `${freeGb} GB free at ${probePath} · ${sourceLabel(resolution.source)}`;
if (freeMb < 2048) {
return {
ok: false,
detail: `${dir} · ${suffix}`,
hint:
"Low free space at the extract cache location — long renders can exhaust the drive. " +
"Relocate via HYPERFRAMES_EXTRACT_CACHE_DIR=<path> or `hyperframes render --frames-cache-dir <path>`.",
};
}
return { ok: true, detail: `${dir} · ${suffix}` };
}
function firstExistingAncestor(path: string, fileExists: (p: string) => boolean): string | null {
let current = path;
for (let i = 0; i < 64; i += 1) {
if (fileExists(current)) return current;
const parent = dirname(current);
if (parent === current) return null;
current = parent;
}
return null;
}
function sourceLabel(source: "env" | "default"): string {
return source === "env" ? "HYPERFRAMES_EXTRACT_CACHE_DIR" : "default";
}
function commandExists(command: string): boolean {
try {
execFileSync("which", [command], { stdio: "ignore", timeout: 5000 });
@@ -269,6 +336,7 @@ export default defineCommand({
{ name: "CPU", run: checkCPU },
{ name: "Memory", run: checkMemory },
{ name: "Disk", run: checkDisk },
{ name: "Frames cache", run: () => checkFramesCache() },
{ name: "Archive extractor", run: checkArchiveExtractor },
];
+29
View File
@@ -35,6 +35,10 @@ export const examples: Example[] = [
["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
["Opt out of browser GPU render", "hyperframes render --no-browser-gpu --output cpu.mp4"],
[
"Relocate frame cache off C: (Windows) or another small partition",
"hyperframes render --frames-cache-dir D:/hf-cache --output out.mp4",
],
["HDR output (auto-detected)", "hyperframes render --output hdr-output.mp4"],
[
"Override composition variables (parametrized render)",
@@ -396,6 +400,18 @@ export default defineCommand({
// guard below leaves PRODUCER_EXPERIMENTAL_FAST_CAPTURE untouched and the
// env fallback survives (matches the --low-memory-mode idiom).
},
"frames-cache-dir": {
type: "string",
description:
"Directory for the content-addressed extracted-frame cache. " +
"Use to relocate the cache off the system drive when the OS temp " +
"directory lives on a small partition (e.g. Windows C: exhaustion " +
'during long renders). Pass "off" / "none" / "false" / "0" to ' +
"disable caching entirely (frames extract into the render's workDir " +
"and are cleaned up when the render ends). Default: " +
"<tmpdir>/hyperframes-extract-cache-<uid>. " +
"Env: HYPERFRAMES_EXTRACT_CACHE_DIR.",
},
},
// `run` is the citty handler for `hyperframes render` — sequential flag
// validation + render dispatch. Inherited CRITICAL on main (CRAP 1290);
@@ -579,6 +595,19 @@ export default defineCommand({
: "false";
}
// ── Override: extracted-frame cache directory ────────────────────────
// Sugar for HYPERFRAMES_EXTRACT_CACHE_DIR. Set BEFORE resolveConfig() so
// the env resolver picks up the CLI-supplied value. Disabling aliases
// (off/none/false/0) pass through verbatim — the engine helper canonicalizes.
// Positive paths are resolved to absolute so CWD changes downstream can't
// stale them.
if (typeof args["frames-cache-dir"] === "string" && args["frames-cache-dir"].trim() !== "") {
const raw = args["frames-cache-dir"].trim();
const normalized = raw.toLowerCase();
const isDisableAlias = ["off", "none", "false", "0"].includes(normalized);
process.env.HYPERFRAMES_EXTRACT_CACHE_DIR = isDisableAlias ? raw : resolve(raw);
}
// ── Validate max-concurrent-renders ─────────────────────────────────
if (args["max-concurrent-renders"] != null) {
const parsed = parseInt(args["max-concurrent-renders"], 10);