Files
hyperframes/scripts/generate-template-previews.ts
Vance Ingalls de4062a933 fix: create temp dirs with mkdtemp, not a name built from Date.now() (#3241)
* fix: create temp dirs with mkdtemp, not a name built from Date.now()

Closes nine open `js/insecure-temporary-file` alerts — the technically
correct ones. An audit of all 29 open alerts for that rule split them
three ways:

- 19 false positives: the write lands inside a directory the caller
  already made with `mkdtempSync`, and CodeQL's dataflow reaches
  `tmpdir()` without seeing the mkdtemp in between.
- 1 mitigated: `fontCompression.ts` writes with `flag: "wx"` and only
  takes the tmpdir branch inside Lambda, where /tmp is single-tenant.
- 9 real, and these are them. A name built from `Date.now()` under the
  shared temp dir, followed by `mkdirSync`, is guessable to the
  millisecond AND leaves a window between choosing the name and creating
  it, so on a shared machine another user can pre-create or symlink the
  path first.

`mkdtempSync` closes both halves: it picks the random suffix and creates
the directory 0700 in one syscall. Same shape, one line shorter, and the
alerts go away rather than being dismissed.

Six sites in `normalize.test.ts` (its `mkdirSync` import goes with them),
one in `generate-catalog-previews.ts` — that single construction accounted
for three alerts, since the other two were writes into the directory it
made.

No shared helper. `mkdtempSync` is already the stdlib primitive for
exactly this, and the two callers live in different packages, so a wrapper
would need a home in core to serve one CLI test and one build script —
more indirection than the line it saves.

Deliberately not touching the other 20: excluding the rule repo-wide would
hide this class of bug from future code, which is the reason these are
fixed rather than silenced.

* fix: track the wav temp dir for cleanup and finish the mkdtemp sweep

The wav helper pushed the file path into `dirs`, so `afterEach` removed
`tone.wav` and left the directory it had just made — four per suite run.
Push the directory and derive the file path from it. Measured: the old code
leaks 4 directories per run, the new code leaks 0.

Three sites still built a predictable name and then created it. CodeQL never
flagged them — its dataflow reaches the template preview writes through a
`readdir` walk and does not connect them back to the `tmpdir()` root — so the
alert list was narrower than the pattern, and closing only the alerts would
turn the rule green while the shape survived where nothing would re-flag it.
`generate-template-previews.ts` is the near-twin of the file this change
started from, and the other two are producer dev entry points. All three use
the path only through the variable, so the random suffix changes nothing.

Catalog previews now call the existing `createCatalogPreviewTempDir` instead
of repeating its body. That test was in no runner, so it pinned uniqueness and
mode 0700 on a function nothing called; adding it to `test:scripts` alongside
a real caller makes it load-bearing. The rationale for the primitive moves to
the helper, which is now the only place it lives.

* ci: re-run catalog previews when the temp-dir module changes

Routing the renderer through `createCatalogPreviewTempDir` made that module
part of its runtime path, and the workflow already states the rule for the
sibling case: a module the renderer imports has to appear in the trigger, or a
change to it alone never re-runs the job that exercises it. Add it to the
`paths:` filter and to the renderer canary, so a PR touching only the temp-dir
allocation still renders both shape canaries.

Verified against this branch's own range: the previous argument list does not
report the file, so a helper-only PR was invisible to both checks.
2026-08-14 11:20:37 -07:00

205 lines
6.5 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Generate Template Preview Images + Videos
*
* Uses the producer package to render PNG thumbnails and short MP4 preview
* videos of each built-in template.
*
* Output: docs/images/templates/<id>.png + <id>.mp4
* (docs/images/ is gitignored — files are served from the CDN. After running
* this script, run `bun run upload:docs-images` to publish.)
*
* Usage:
* bun run generate:previews # all templates (PNG + MP4)
* bun run generate:previews --only warm-grain
* bun run generate:previews --skip-video # thumbnails only (faster)
*/
import {
readdirSync,
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
mkdtempSync,
cpSync,
rmSync,
} from "node:fs";
import { execFileSync } from "node:child_process";
import { join, resolve, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
// Import from source — scripts are typechecked before workspace packages are built.
import {
captureFrame,
closeCaptureSession,
createRenderJob,
executeRenderJob,
} from "../packages/producer/src/index.js";
import { openOpaqueCapture } from "./preview-capture.js";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
const bundledTemplatesDir = resolve(repoRoot, "packages/cli/src/templates");
const remoteTemplatesDir = resolve(repoRoot, "registry/examples");
const outputDir = resolve(repoRoot, "docs/images/templates");
if (!process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH) {
process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = resolve(
repoRoot,
"packages/core/dist/hyperframe.manifest.json",
);
}
const SKIP_TEMPLATES = new Set(["blank"]);
const DEFAULT_CONFIG = { width: 1920, height: 1080, captureTime: 2.0 };
const TEMPLATE_CONFIG: Record<string, { width: number; height: number; captureTime: number }> = {
vignelli: { width: 1080, height: 1920, captureTime: 2.0 },
};
function patchTemplateHtml(dir: string, durationSeconds: number): void {
const htmlEntries = readdirSync(dir, { withFileTypes: true, recursive: true }).filter(
(e) => e.isFile() && e.name.endsWith(".html"),
);
for (const entry of htmlEntries) {
const file = join(entry.parentPath, entry.name);
let content = readFileSync(file, "utf-8");
content = content.replace(/<video[^>]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/video>/g, "");
content = content.replace(/<video[^>]*src="__VIDEO_SRC__"[^>]*>/g, "");
content = content.replace(/<audio[^>]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/audio>/g, "");
content = content.replace(/<audio[^>]*src="__VIDEO_SRC__"[^>]*>/g, "");
const dur = String(Math.round(durationSeconds * 100) / 100);
content = content.replaceAll("__VIDEO_DURATION__", dur);
writeFileSync(file, content, "utf-8");
}
}
function parseArgs(): { only: string | null; skipVideo: boolean } {
let only: string | null = null;
let skipVideo = false;
for (let i = 2; i < process.argv.length; i++) {
if (process.argv[i] === "--only" && process.argv[i + 1]) {
i++;
only = process.argv[i] ?? null;
}
if (process.argv[i] === "--skip-video") skipVideo = true;
}
return { only, skipVideo };
}
function resolveTemplateDir(templateId: string): string | null {
for (const base of [bundledTemplatesDir, remoteTemplatesDir]) {
const dir = join(base, templateId);
if (existsSync(join(dir, "index.html"))) return dir;
}
return null;
}
function discoverTemplates(only: string | null): string[] {
const seen = new Set<string>();
const all: string[] = [];
for (const dir of [bundledTemplatesDir, remoteTemplatesDir]) {
if (!existsSync(dir)) continue;
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (
e.isDirectory() &&
e.name !== "_shared" &&
!SKIP_TEMPLATES.has(e.name) &&
!seen.has(e.name) &&
existsSync(join(dir, e.name, "index.html"))
) {
seen.add(e.name);
all.push(e.name);
}
}
}
if (only) {
if (!all.includes(only)) {
console.error(`Template "${only}" not found. Available: ${all.join(", ")}`);
process.exit(1);
}
return [only];
}
return all;
}
function prepareTemplateDir(templateId: string): string {
const tmpDir = mkdtempSync(join(tmpdir(), `hf-preview-${templateId}-`));
const src = resolveTemplateDir(templateId);
if (!src) throw new Error(`Template directory not found for "${templateId}"`);
cpSync(src, tmpDir, { recursive: true });
patchTemplateHtml(tmpDir, 5);
return tmpDir;
}
async function generateThumbnail(templateId: string, projectDir: string): Promise<void> {
const config = TEMPLATE_CONFIG[templateId] ?? DEFAULT_CONFIG;
const framesDir = join(projectDir, "_thumb_frames");
const { fileServer, session, duration } = await openOpaqueCapture({
projectDir,
width: config.width,
height: config.height,
});
try {
const t = Math.min(config.captureTime, duration * 0.8);
const result = await captureFrame(session, 0, t);
execFileSync(
"ffmpeg",
["-v", "error", "-y", "-i", result.path, join(outputDir, `${templateId}.png`)],
{ stdio: "inherit" },
);
console.log(` ✓ ${templateId}.png (${result.captureTimeMs}ms)`);
await closeCaptureSession(session);
} finally {
fileServer.close();
rmSync(framesDir, { recursive: true, force: true });
}
}
async function generateVideo(templateId: string, projectDir: string): Promise<void> {
const outMp4 = join(outputDir, `${templateId}.mp4`);
const job = createRenderJob({
fps: 24,
quality: "draft",
format: "mp4",
});
await executeRenderJob(job, projectDir, outMp4);
console.log(` ✓ ${templateId}.mp4`);
}
async function main(): Promise<void> {
const { only, skipVideo } = parseArgs();
const templates = discoverTemplates(only);
console.log(
`Generating previews for ${templates.length} templates${skipVideo ? " (thumbnails only)" : " + videos"}...\n`,
);
mkdirSync(outputDir, { recursive: true });
for (const templateId of templates) {
const projectDir = prepareTemplateDir(templateId);
try {
await generateThumbnail(templateId, projectDir);
if (!skipVideo) {
await generateVideo(templateId, projectDir);
}
} catch (err) {
console.error(` ✗ ${templateId}: ${err instanceof Error ? err.message : err}`);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
}
console.log(`\nDone. Output: ${outputDir}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});