fix(cli): refuse a half-downloaded vector cache

The two artifact files have to agree on how many rows there are, and until now nothing checked that before writing them. A truncated or wrong-model response landed in the cache and only failed at load, on every later search, until someone cleared it by hand. The pair is now checked first and refused as a unit, and the cache is created 0o700 with 0o600 files rather than inheriting the umask of a directory the caller may have pointed anywhere.

Also lifts the capture setup the two preview generators had drifted into sharing into scripts/preview-capture.ts, and splits the vector builders batching and packing out of main. Both were findings the audit attributed to this branch.
This commit is contained in:
Miguel Ángel
2026-08-09 18:23:04 +00:00
parent 0eed6a87c4
commit 9f893e7db2
6 changed files with 172 additions and 83 deletions
+37 -16
View File
@@ -32,6 +32,41 @@ function arg(name: string): string | undefined {
return index === -1 ? undefined : process.argv[index + 1];
}
/**
* Embed in batches, in `names` order.
*
* Batch size is part of the artifact's identity, not a tuning knob: padding
* within a batch changes the quantized result, so re-embedding the same text
* at a different batch size does not reproduce the shipped rows.
*/
async function embedInBatches(
names: string[],
catalog: Record<string, string>,
embedder: Awaited<ReturnType<typeof loadLocalEmbedder>>,
): Promise<number[][]> {
const vectors: number[][] = [];
for (let start = 0; start < names.length; start += BATCH) {
const slice = names.slice(start, start + BATCH);
// Passages carry no query instruction; only queries do.
vectors.push(...(await embedder.embed(slice.map((name) => catalog[name] as string))));
process.stdout.write(`\r embedded ${Math.min(start + BATCH, names.length)}/${names.length}`);
}
process.stdout.write("\n");
return vectors;
}
/** Row-major Float32 payload. Row N belongs to `names[N]`, with no header. */
function packVectors(names: string[], vectors: number[][]): Float32Array {
const flat = new Float32Array(names.length * LOCAL_MODEL_DIMENSIONS);
vectors.forEach((vector, row) => {
if (vector.length !== LOCAL_MODEL_DIMENSIONS) {
throw new Error(`vector for ${names[row]} has ${vector.length} dimensions`);
}
flat.set(vector, row * LOCAL_MODEL_DIMENSIONS);
});
return flat;
}
async function main(): Promise<void> {
const dir = arg("artifact") ?? "registry/catalog-artifact";
const registryDir = arg("registry") ?? "registry";
@@ -53,22 +88,8 @@ async function main(): Promise<void> {
if (names.length === 0) throw new Error(`no registry items found under ${registryDir}`);
const embedder = await loadLocalEmbedder();
const vectors: number[][] = [];
for (let start = 0; start < names.length; start += BATCH) {
const slice = names.slice(start, start + BATCH);
// Passages carry no query instruction; only queries do.
vectors.push(...(await embedder.embed(slice.map((name) => catalog[name] as string))));
process.stdout.write(`\r embedded ${Math.min(start + BATCH, names.length)}/${names.length}`);
}
process.stdout.write("\n");
const flat = new Float32Array(names.length * LOCAL_MODEL_DIMENSIONS);
vectors.forEach((vector, row) => {
if (vector.length !== LOCAL_MODEL_DIMENSIONS) {
throw new Error(`vector for ${names[row]} has ${vector.length} dimensions`);
}
flat.set(vector, row * LOCAL_MODEL_DIMENSIONS);
});
const vectors = await embedInBatches(names, catalog, embedder);
const flat = packVectors(names, vectors);
writeFileSync(join(dir, "local-vectors.bin"), Buffer.from(flat.buffer));
writeFileSync(
+2 -33
View File
@@ -35,17 +35,14 @@ import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
// Import from source — bun workspace linking doesn't resolve for scripts outside packages/.
import {
createFileServer,
createCaptureSession,
initializeSession,
captureFrame,
getCompositionDuration,
closeCaptureSession,
createRenderJob,
executeRenderJob,
} from "../packages/producer/src/index.js";
import { compileForRender } from "../packages/producer/src/services/htmlCompiler.js";
import { resolveContainedCopies } from "./registry-target-paths.mjs";
import { openOpaqueCapture } from "./preview-capture.js";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
@@ -294,36 +291,8 @@ async function generateThumbnail(item: CatalogItem, projectDir: string): Promise
const height = wrapperDimension(wrapperHtml, "height", 1080);
const framesDir = join(projectDir, "_thumb_frames");
mkdirSync(framesDir, { recursive: true });
const fileServer = await createFileServer({
projectDir,
port: 0,
fps: { num: 30, den: 1 },
});
const { fileServer, session, duration } = await openOpaqueCapture({ projectDir, width, height });
try {
// `format: "png"` is the engine's TRANSPARENT capture mode: it forces
// `background-image: none !important` on every `[data-composition-id]`, so
// any block whose scene paints its own backdrop (the VS Code snippets sit
// on a desktop wallpaper) loses it and the poster comes out empty. These
// posters are opaque page images, never a compositing layer — capture
// opaque and transcode to the .png the catalog pages reference.
const session = await createCaptureSession(fileServer.url, framesDir, {
width,
height,
fps: { num: 30, den: 1 },
format: "jpeg",
quality: 95,
});
await initializeSession(session);
let duration: number;
try {
duration = await getCompositionDuration(session);
} catch {
duration = 5;
}
// Capture after the treatment appears, capped for long compositions.
const captureTime = Math.min(3.0, duration * 0.6);
const result = await captureFrame(session, 0, captureTime);
+4 -29
View File
@@ -29,15 +29,12 @@ import { join, resolve, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import {
createFileServer,
createCaptureSession,
initializeSession,
captureFrame,
getCompositionDuration,
closeCaptureSession,
createRenderJob,
executeRenderJob,
} from "@hyperframes/producer";
import { openOpaqueCapture } from "./preview-capture.js";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
@@ -141,34 +138,12 @@ async function generateThumbnail(templateId: string, projectDir: string): Promis
const config = TEMPLATE_CONFIG[templateId] ?? DEFAULT_CONFIG;
const framesDir = join(projectDir, "_thumb_frames");
mkdirSync(framesDir, { recursive: true });
const fileServer = await createFileServer({
const { fileServer, session, duration } = await openOpaqueCapture({
projectDir,
port: 0,
fps: { num: 30, den: 1 },
width: config.width,
height: config.height,
});
try {
// Opaque capture, for the reason spelled out in generate-catalog-previews.ts:
// `format: "png"` is the engine's TRANSPARENT mode and forces
// `background-image: none !important` on every composition root, silently
// dropping any backdrop the template paints for itself.
const session = await createCaptureSession(fileServer.url, framesDir, {
width: config.width,
height: config.height,
fps: { num: 30, den: 1 },
format: "jpeg",
quality: 95,
});
await initializeSession(session);
let duration: number;
try {
duration = await getCompositionDuration(session);
} catch {
duration = 5;
}
const t = Math.min(config.captureTime, duration * 0.8);
const result = await captureFrame(session, 0, t);
execFileSync(
+75
View File
@@ -0,0 +1,75 @@
/**
* The capture setup both preview generators need, in one place.
*
* They diverged only in how they picked width and height, so keeping two copies
* meant the opaque-capture rationale below had to be re-derived, and re-read,
* every time either script changed.
*/
import { mkdirSync } from "node:fs";
import { join } from "node:path";
// Import from source — bun workspace linking doesn't resolve for scripts outside packages/.
import {
createFileServer,
createCaptureSession,
initializeSession,
getCompositionDuration,
} from "../packages/producer/src/index.js";
const FPS = { num: 30, den: 1 } as const;
/** Duration to assume when the composition does not report one. */
const FALLBACK_DURATION_SECONDS = 5;
export interface OpaqueCapture {
fileServer: Awaited<ReturnType<typeof createFileServer>>;
session: Awaited<ReturnType<typeof createCaptureSession>>;
/** Seconds. Falls back to {@link FALLBACK_DURATION_SECONDS}. */
duration: number;
}
/**
* Serve `projectDir`, open a capture session over it and read its duration.
*
* Capture is opaque on purpose. `format: "png"` is the engine's TRANSPARENT
* mode: it forces `background-image: none !important` on every
* `[data-composition-id]`, so any composition painting its own backdrop (the
* VS Code snippets sit on a desktop wallpaper) loses it and the poster comes
* out empty. These posters are page images, never a compositing layer, so
* capture opaque and transcode to the .png the pages reference.
*
* The caller owns both handles and must close them.
*/
export async function openOpaqueCapture(params: {
projectDir: string;
width: number;
height: number;
}): Promise<OpaqueCapture> {
const framesDir = join(params.projectDir, "_thumb_frames");
mkdirSync(framesDir, { recursive: true });
const fileServer = await createFileServer({ projectDir: params.projectDir, port: 0, fps: FPS });
try {
const session = await createCaptureSession(fileServer.url, framesDir, {
width: params.width,
height: params.height,
fps: FPS,
format: "jpeg",
quality: 95,
});
await initializeSession(session);
let duration: number;
try {
duration = await getCompositionDuration(session);
} catch {
duration = FALLBACK_DURATION_SECONDS;
}
return { fileServer, session, duration };
} catch (error) {
// The session never reached the caller, so nothing else will close the
// server this function opened.
fileServer.close();
throw error;
}
}