diff --git a/packages/cli/src/registry/localSemantic.test.ts b/packages/cli/src/registry/localSemantic.test.ts index 287e225442..b26b4188db 100644 --- a/packages/cli/src/registry/localSemantic.test.ts +++ b/packages/cli/src/registry/localSemantic.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fetchLocalVectors } from "./localSemantic.js"; +import { LOCAL_MODEL_DIMENSIONS } from "./localModel.js"; describe("fetchLocalVectors", () => { const dir = join(tmpdir(), `hf-vec-${process.pid}`); @@ -12,22 +13,41 @@ describe("fetchLocalVectors", () => { vi.unstubAllGlobals(); }); - it("writes both files into the cache directory", async () => { + /** A metadata/matrix pair that agrees: one name, one row of the real width. */ + const servePair = (names: string[], dimensions: number, floats: number) => vi.stubGlobal( "fetch", vi.fn(async (url: string) => ({ ok: true, arrayBuffer: async () => url.endsWith(".json") - ? new TextEncoder().encode(JSON.stringify({ names: [], dimensions: 1 })).buffer - : new Uint8Array([1, 2, 3, 4]).buffer, + ? new TextEncoder().encode(JSON.stringify({ names, dimensions })).buffer + : new Float32Array(floats).buffer, })), ); + + it("writes both files into the cache directory", async () => { + servePair(["whip-pan"], LOCAL_MODEL_DIMENSIONS, LOCAL_MODEL_DIMENSIONS); expect(await fetchLocalVectors("http://registry.test/", dir)).toBe(true); expect(existsSync(join(dir, "local-vectors.bin"))).toBe(true); expect(existsSync(join(dir, "local-vectors.json"))).toBe(true); }); + it("caches nothing when the matrix is short of the names it claims", async () => { + // Half a download is the case worth refusing: written, it loads as an + // error on every later search until someone clears the cache by hand. + servePair(["whip-pan", "rack-focus"], LOCAL_MODEL_DIMENSIONS, LOCAL_MODEL_DIMENSIONS); + expect(await fetchLocalVectors("http://registry.test/", dir)).toBe(false); + expect(existsSync(join(dir, "local-vectors.bin"))).toBe(false); + expect(existsSync(join(dir, "local-vectors.json"))).toBe(false); + }); + + it("caches nothing when the vectors came from a different model", async () => { + servePair(["whip-pan"], 1536, 1536); + expect(await fetchLocalVectors("http://registry.test/", dir)).toBe(false); + expect(existsSync(join(dir, "local-vectors.json"))).toBe(false); + }); + it("reports failure instead of throwing, so the command survives", async () => { // A tier the user switched on that silently never runs is the failure // being guarded: the caller needs a false to be able to say so. diff --git a/packages/cli/src/registry/localSemantic.ts b/packages/cli/src/registry/localSemantic.ts index e8dc4702ca..db7551cbfb 100644 --- a/packages/cli/src/registry/localSemantic.ts +++ b/packages/cli/src/registry/localSemantic.ts @@ -43,13 +43,33 @@ function localVectorDirectory(): string { * Returns false rather than throwing. A download that fails costs the offline * tier, never the command, and the caller says so. */ +/** + * Do a freshly fetched metadata/matrix pair describe the same index? + * + * `names.length * dimensions` floats, at four bytes each, is the whole + * contract. A pair that fails it is a truncated download or a different + * model, never something worth caching. + */ +function vectorPairAgrees(fetched: Array<[string, Buffer]>): boolean { + const meta = fetched.find(([file]) => file === "local-vectors.json")?.[1]; + const bin = fetched.find(([file]) => file === "local-vectors.bin")?.[1]; + if (!meta || !bin) return false; + try { + const parsed = JSON.parse(meta.toString("utf-8")) as { names?: string[]; dimensions?: number }; + if (parsed.dimensions !== LOCAL_MODEL_DIMENSIONS) return false; + return bin.byteLength === (parsed.names?.length ?? -1) * LOCAL_MODEL_DIMENSIONS * 4; + } catch { + return false; + } +} + export async function fetchLocalVectors( registryBaseUrl: string, directory = localVectorDirectory(), ): Promise { const base = registryBaseUrl.replace(/\/+$/, ""); try { - mkdirSync(directory, { recursive: true }); + mkdirSync(directory, { recursive: true, mode: 0o700 }); // Downloaded in full before anything is written. The two files have to // agree on how many rows there are, so a fetch that fails halfway through // must leave the previous pair intact rather than pairing a new name list @@ -60,7 +80,16 @@ export async function fetchLocalVectors( if (!response.ok) return false; fetched.push([file, Buffer.from(await response.arrayBuffer())]); } - for (const [file, bytes] of fetched) writeFileSync(join(directory, file), bytes); + // Check the pair agrees BEFORE either file lands. Writing first and + // discovering the mismatch at load time leaves a cache that fails every + // subsequent search until someone deletes it by hand, and it is the only + // point where a truncated or wrong-model response can still be refused. + if (!vectorPairAgrees(fetched)) return false; + // 0o600: the cache is this user's, and the directory may be world-writable + // when the caller overrides it. + for (const [file, bytes] of fetched) { + writeFileSync(join(directory, file), bytes, { mode: 0o600 }); + } return hasLocalVectors(directory); } catch { return false; diff --git a/scripts/catalog/build-local-vectors.ts b/scripts/catalog/build-local-vectors.ts index cd24819822..02a4077e75 100644 --- a/scripts/catalog/build-local-vectors.ts +++ b/scripts/catalog/build-local-vectors.ts @@ -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, + embedder: Awaited>, +): Promise { + 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 { const dir = arg("artifact") ?? "registry/catalog-artifact"; const registryDir = arg("registry") ?? "registry"; @@ -53,22 +88,8 @@ async function main(): Promise { 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( diff --git a/scripts/generate-catalog-previews.ts b/scripts/generate-catalog-previews.ts index 35c332fd01..77cb3f0e20 100644 --- a/scripts/generate-catalog-previews.ts +++ b/scripts/generate-catalog-previews.ts @@ -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); diff --git a/scripts/generate-template-previews.ts b/scripts/generate-template-previews.ts index d00ce6efeb..5b974e78c5 100644 --- a/scripts/generate-template-previews.ts +++ b/scripts/generate-template-previews.ts @@ -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( diff --git a/scripts/preview-capture.ts b/scripts/preview-capture.ts new file mode 100644 index 0000000000..8557209588 --- /dev/null +++ b/scripts/preview-capture.ts @@ -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>; + session: Awaited>; + /** 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 { + 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; + } +}