mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 22:08:03 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user