From 8a0b8a9c2224fc0fb4e9d239556fc63e7c6411d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 31 Oct 2026 22:26:35 +0000 Subject: [PATCH] fix(cli): measure ink, one output-folder flag spelling, logo wordmark variant (#6891) * feat(cli): measure ink reads text size, --out and --output both work, logo --variant wordmark measure ink reports the bounds, size and lines of text in a region of one frame, so a rebuild can be sized without a script. measure and snapshot take --out and --output for the same folder. media-use resolve --type logo --variant wordmark returns the brand's wordmark when theSVG has one. * fix(cli): measure ink stays under the complexity and comment limits, one projectOf * fix(cli): snapshot and measure also accept --output-dir * fix(cli): projectOf moves without adding comment lines * fix(cli): measure output alias list type-checks as an argument * fix(cli): a logo variant never answers a default logo request; test output flags * style(cli): format the new tests --- packages/cli/src/commands/measure.ts | 3 +- packages/cli/src/commands/measure/extract.ts | 21 ++++---- packages/cli/src/commands/measure/ink.ts | 41 ++++++++++++++++ packages/cli/src/commands/snapshot.ts | 2 +- packages/cli/src/heygen/generate.ts | 10 +--- packages/cli/src/measure/command.ts | 1 + packages/cli/src/measure/drawn.ts | 2 +- packages/cli/src/measure/ink.test.ts | 30 ++++++++++++ packages/cli/src/measure/ink.ts | 49 +++++++++++++++++++ packages/cli/src/measure/media.ts | 9 +++- packages/cli/src/measure/outFlags.test.ts | 24 +++++++++ .../cli/src/media-use/lib/logo-provider.mjs | 11 +++-- .../src/media-use/lib/logo-provider.test.mjs | 14 ++++++ packages/cli/src/media-use/resolve.mjs | 16 ++++-- packages/cli/src/media-use/resolve.test.mjs | 37 ++++++++++++++ 15 files changed, 239 insertions(+), 31 deletions(-) create mode 100644 packages/cli/src/commands/measure/ink.ts create mode 100644 packages/cli/src/measure/ink.test.ts create mode 100644 packages/cli/src/measure/ink.ts create mode 100644 packages/cli/src/measure/outFlags.test.ts diff --git a/packages/cli/src/commands/measure.ts b/packages/cli/src/commands/measure.ts index 586c6a9284..1ca5df10f7 100644 --- a/packages/cli/src/commands/measure.ts +++ b/packages/cli/src/commands/measure.ts @@ -17,7 +17,7 @@ export default defineCommand({ meta: { name: "measure", description: - "Measure a video file as JSON: clock, cuts, events, frames, crops, eases, motion energy, look, faces, lip sync", + "Measure a video file as JSON: clock, cuts, events, frames, crops, eases, motion energy, ink, look, faces, lip sync", }, subCommands: { probe: () => import("./measure/probe.js").then((m) => m.default), @@ -27,6 +27,7 @@ export default defineCommand({ extract: () => import("./measure/extract.js").then((m) => m.default), curve: () => import("./measure/curve.js").then((m) => m.default), energy: () => import("./measure/energy.js").then((m) => m.default), + ink: () => import("./measure/ink.js").then((m) => m.default), look: () => import("./measure/look.js").then((m) => m.default), face: () => import("./measure/face.js").then((m) => m.default), sync: () => import("./measure/sync.js").then((m) => m.default), diff --git a/packages/cli/src/commands/measure/extract.ts b/packages/cli/src/commands/measure/extract.ts index 28f1ba5b75..3cb301c68f 100644 --- a/packages/cli/src/commands/measure/extract.ts +++ b/packages/cli/src/commands/measure/extract.ts @@ -1,13 +1,21 @@ // Pixels from the original, as project media: a still at one moment or a clip over a window, cropped to a region, // frame-exact, recorded in the media ledger as cut from the original. A rebuild reuses footage, photos and people // this way instead of drawing or generating them. -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { defineCommand } from "../../utils/defineCommand.js"; import type { Example } from "../_examples.js"; import { jsonArg, num, roiArg, runMeasure, videoAndRoi, videoArg } from "../../measure/command.js"; -import { Fix, frameAt, round, runFfmpeg, type Roi, type VideoInfo } from "../../measure/media.js"; +import { + Fix, + frameAt, + projectOf, + round, + runFfmpeg, + type Roi, + type VideoInfo, +} from "../../measure/media.js"; export const examples: Example[] = [ [ @@ -27,14 +35,6 @@ export const examples: Example[] = [ /** H.264 needs even sizes: shrink an odd width or height by a pixel. */ const even = ([x, y, w, h]: Roi): Roi => [x, y, w - (w % 2), h - (h % 2)]; -/** The nearest folder at or above `from` holding index.html: the project the file belongs to. */ -function projectOf(from: string): string | null { - for (let dir = resolve(from); ; dir = dirname(dir)) { - if (existsSync(join(dir, "index.html"))) return dir; - if (dirname(dir) === dir) return null; - } -} - async function record( out: string, type: "image" | "video" | "bgm", @@ -151,6 +151,7 @@ export default defineCommand({ video: videoArg, out: { type: "string", + alias: ["output", "output-dir"], description: "The file to write: .png/.jpg for a still, .mp4 for a clip", required: true, }, diff --git a/packages/cli/src/commands/measure/ink.ts b/packages/cli/src/commands/measure/ink.ts new file mode 100644 index 0000000000..33316b06cb --- /dev/null +++ b/packages/cli/src/commands/measure/ink.ts @@ -0,0 +1,41 @@ +import { defineCommand } from "../../utils/defineCommand.js"; +import type { Example } from "../_examples.js"; +import { jsonArg, num, roiArg, runMeasure, videoAndRoi, videoArg } from "../../measure/command.js"; +import { measureInk } from "../../measure/ink.js"; +import { Fix, readFrameAt } from "../../measure/media.js"; + +export const examples: Example[] = [ + [ + "How big the title's letters are, in the video's pixels", + "hyperframes measure ink ref.mp4 --at 3 --roi 200,400,1200,300", + ], +]; + +export default defineCommand({ + meta: { + name: "ink", + description: + "The bounds, size and lines of the text or marks drawn in a region of one frame, in the video's pixels", + }, + args: { + video: videoArg, + at: { type: "string", description: "The second to read (default 0)" }, + roi: { ...roiArg, description: "Region x,y,w,h in the video's pixels around the text" }, + json: jsonArg, + }, + async run({ args }) { + await runMeasure(() => { + const { info, roi } = videoAndRoi(args.video, args.roi); + const at = num(args.at, "--at", 0); + const px = readFrameAt(info, at, { roi }).frames[0]; + if (!px) throw new Fix(`no frame at ${at}s`, "pick a time inside the video"); + const ink = measureInk(px, roi); + return { + video: info.path, + at, + roi, + ink: ink ?? "none: nothing stands out from its surroundings", + }; + }); + }, +}); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 097c9fbcf3..7005a745f5 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -1624,7 +1624,7 @@ export default defineCommand({ }, output: { type: "string", - alias: "o", + alias: ["o", "out", "output-dir"], description: "Directory to write snapshots into (default: unique directory under /.hyperframes/artifacts/snapshots)", }, diff --git a/packages/cli/src/heygen/generate.ts b/packages/cli/src/heygen/generate.ts index 8b87af506b..dae88496a3 100644 --- a/packages/cli/src/heygen/generate.ts +++ b/packages/cli/src/heygen/generate.ts @@ -13,7 +13,7 @@ import { import { tmpdir } from "node:os"; import { dirname, extname, join, relative, resolve } from "node:path"; import { findCuts, signatures } from "../measure/cuts.js"; -import { Fix, probeVideo, round, runFfmpeg, videoClip } from "../measure/media.js"; +import { Fix, probeVideo, projectOf, round, runFfmpeg, videoClip } from "../measure/media.js"; import { idempotencyKey, runJob, type Job } from "./v3.js"; export type GenerateType = "image" | "video" | "avatar" | "voice" | "music"; @@ -444,14 +444,6 @@ export function checkRequest(r: GenerateRequest): void { checkGuide(r); } -/** The nearest folder at or above `from` holding index.html. */ -function projectOf(from: string): string | null { - for (let dir = resolve(from); ; dir = dirname(dir)) { - if (existsSync(join(dir, "index.html"))) return dir; - if (dirname(dir) === dir) return null; - } -} - export interface Generated { out: string; type: GenerateType; diff --git a/packages/cli/src/measure/command.ts b/packages/cli/src/measure/command.ts index f953e78ca6..ce06ee0617 100644 --- a/packages/cli/src/measure/command.ts +++ b/packages/cli/src/measure/command.ts @@ -41,6 +41,7 @@ export const roiArg = { } as const; export const outArg = { type: "string", + alias: ["output", "output-dir"] as string[], description: "Folder for pictures (default .hyperframes/measure/)", } as const; export const jsonArg = { diff --git a/packages/cli/src/measure/drawn.ts b/packages/cli/src/measure/drawn.ts index 9d74b76e6a..02b2888376 100644 --- a/packages/cli/src/measure/drawn.ts +++ b/packages/cli/src/measure/drawn.ts @@ -74,7 +74,7 @@ export function contrast(px: Uint8Array, w: number, h: number): Float32Array { return out; } -const inkOf = (px: Uint8Array, w: number, h: number) => { +export const inkOf = (px: Uint8Array, w: number, h: number) => { const c = contrast(px, w, h); return Uint8Array.from(c, (v) => (v > INK_CONTRAST ? 1 : 0)); }; diff --git a/packages/cli/src/measure/ink.test.ts b/packages/cli/src/measure/ink.test.ts new file mode 100644 index 0000000000..b30f3845f0 --- /dev/null +++ b/packages/cli/src/measure/ink.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { measureInk } from "./ink.js"; + +/** A grey region with white boxes [x, y, w, h] standing in for letters. */ +function region(w: number, h: number, boxes: [number, number, number, number][]): Uint8Array { + const px = new Uint8Array(w * h * 3).fill(40); + for (const [bx, by, bw, bh] of boxes) + for (let y = by; y < by + bh; y++) + for (let x = bx; x < bx + bw; x++) px.fill(255, (y * w + x) * 3, (y * w + x) * 3 + 3); + return px; +} + +describe("measureInk", () => { + it("reports the ink bounds and one band per line, shifted to the region's place in the frame", () => { + const px = region(120, 60, [ + [10, 8, 40, 12], + [30, 36, 50, 12], + ]); + const ink = measureInk(px, [100, 200, 120, 60]); + expect(ink?.bounds).toEqual([110, 208, 70, 40]); + expect(ink?.lines).toEqual([ + { y: 208, height: 12 }, + { y: 236, height: 12 }, + ]); + }); + + it("is null when nothing stands out", () => { + expect(measureInk(region(80, 40, []), [0, 0, 80, 40])).toBeNull(); + }); +}); diff --git a/packages/cli/src/measure/ink.ts b/packages/cli/src/measure/ink.ts new file mode 100644 index 0000000000..9f345aadd6 --- /dev/null +++ b/packages/cli/src/measure/ink.ts @@ -0,0 +1,49 @@ +import { inkOf } from "./drawn.js"; +import type { Roi } from "./media.js"; + +export interface Ink { + /** x, y, width, height of all the ink, in the video's pixels. */ + bounds: Roi; + /** Ink pixels over the region's pixels. */ + share: number; + /** One band of ink rows per line of text. */ + lines: { y: number; height: number }[]; +} + +/** The ink of an RGB crop at `roi` (its place in the frame), or null when it holds none. */ +export function measureInk(px: Uint8Array, roi: Roi): Ink | null { + const [x0, y0, w, h] = roi; + const ink = inkOf(px, w, h); + const cols = new Set(); + const rows: number[] = []; + let count = 0; + for (let y = 0; y < h; y++) { + let any = false; + for (let x = 0; x < w; x++) { + if (!ink[y * w + x]) continue; + cols.add(x); + count++; + any = true; + } + if (any) rows.push(y); + } + if (!count) return null; + const [minX, maxX] = [Math.min(...cols), Math.max(...cols)]; + const [minY, maxY] = [rows[0]!, rows.at(-1)!]; + return { + bounds: [x0 + minX, y0 + minY, maxX - minX + 1, maxY - minY + 1], + share: Math.round((count / (w * h)) * 1e4) / 1e4, + lines: bands(rows).map(([a, b]) => ({ y: y0 + a, height: b - a + 1 })), + }; +} + +/** Runs of consecutive numbers as [first, last]. */ +function bands(sorted: number[]): [number, number][] { + const out: [number, number][] = []; + for (const n of sorted) { + const last = out.at(-1); + if (last && last[1] === n - 1) last[1] = n; + else out.push([n, n]); + } + return out; +} diff --git a/packages/cli/src/measure/media.ts b/packages/cli/src/measure/media.ts index 1f10ee20b5..6b42f86c01 100644 --- a/packages/cli/src/measure/media.ts +++ b/packages/cli/src/measure/media.ts @@ -2,7 +2,7 @@ // Ported from the Studio's viral-clone media.mjs and tool.mjs. import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js"; const MAX_BUFFER = 1 << 30; @@ -357,3 +357,10 @@ export function pearson(a: number[], b: number[]): number | null { } return da && db ? num / Math.sqrt(da * db) : null; } + +export function projectOf(from: string): string | null { + for (let dir = resolve(from); ; dir = dirname(dir)) { + if (existsSync(join(dir, "index.html"))) return dir; + if (dirname(dir) === dir) return null; + } +} diff --git a/packages/cli/src/measure/outFlags.test.ts b/packages/cli/src/measure/outFlags.test.ts new file mode 100644 index 0000000000..3b35e91185 --- /dev/null +++ b/packages/cli/src/measure/outFlags.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import extract from "../commands/measure/extract.js"; +import frames from "../commands/measure/frames.js"; +import events from "../commands/measure/events.js"; +import snapshot from "../commands/snapshot.js"; + +const aliases = (def: { args?: unknown }, key: string): string[] => { + const arg = (def.args as Record)[key]; + return [arg?.alias ?? []].flat(); +}; + +describe("output folder flags", () => { + it.each([ + ["measure frames", frames, "out"], + ["measure events", events, "out"], + ["measure extract", extract, "out"], + ["snapshot", snapshot, "output"], + ])("%s takes --out, --output and --output-dir", (_name, def, key) => { + const spellings = new Set([key, ...aliases(def, key)]); + expect([...spellings].sort()).toEqual( + ["out", "output", "output-dir"].concat(key === "output" ? ["o"] : []).sort(), + ); + }); +}); diff --git a/packages/cli/src/media-use/lib/logo-provider.mjs b/packages/cli/src/media-use/lib/logo-provider.mjs index 127a8fd6a6..2b2db64080 100644 --- a/packages/cli/src/media-use/lib/logo-provider.mjs +++ b/packages/cli/src/media-use/lib/logo-provider.mjs @@ -169,18 +169,21 @@ export async function thesvgSearch(intent, ctx = {}) { const hit = thesvgMatch(icons, entity); if (!hit) return null; // theSVG also ships mono, light/dark and wordmark variants; default is the full-color mark. - const route = hit.variants.default; + // A named variant the brand lacks is a miss, never the mark in its place. + const variant = ctx.variant || "default"; + const route = hit.variants[variant]; + if (!route) return null; return { url: `${THESVG_CDN}/public${route.startsWith("/") ? "" : "/"}${route}`, ext: ".svg", source: "search", metadata: { - description: `${hit.title} logo (official mark)`, + description: `${hit.title} ${variant === "default" ? "logo (official mark)" : `logo (${variant})`}`, provider: "thesvg", provenance: { entity, slug: hit.slug, - variant: "default", + variant, variants: Object.keys(hit.variants), collection: hit.collection, license: hit.license, @@ -191,6 +194,7 @@ export async function thesvgSearch(intent, ctx = {}) { } export async function githubAvatarSearch(intent, ctx = {}) { + if (ctx.variant) return null; // an avatar or favicon is never a named variant const entity = entityFrom(intent, ctx.entity); const org = githubOrgFor(entity); if (!org) return null; @@ -213,6 +217,7 @@ export async function githubAvatarSearch(intent, ctx = {}) { } export async function faviconSearch(intent, ctx = {}) { + if (ctx.variant) return null; const entity = entityFrom(intent, ctx.entity); const domain = faviconDomainFor(entity); const url = `https://icons.duckduckgo.com/ip3/${domain}.ico`; diff --git a/packages/cli/src/media-use/lib/logo-provider.test.mjs b/packages/cli/src/media-use/lib/logo-provider.test.mjs index 437f6da336..a3567c35bf 100644 --- a/packages/cli/src/media-use/lib/logo-provider.test.mjs +++ b/packages/cli/src/media-use/lib/logo-provider.test.mjs @@ -296,3 +296,17 @@ test("thesvgSearch rejects a manifest redirect to a private host", async (t) => assert.equal(await thesvgSearch("vercel logo"), null); assert.equal(seen.length, 1); }); + +test("thesvgSearch returns the wordmark when asked, and a miss when the brand has none", async (t) => { + resetThesvgManifest(); + t.mock.method(globalThis, "fetch", async () => json(ICONS)); + const slack = await thesvgSearch("Slack logo", { variant: "wordmark" }); + assert.match(slack.url, /\/icons\/slack\/wordmark\.svg$/); + assert.equal(slack.metadata.provenance.variant, "wordmark"); + assert.equal(await thesvgSearch("Next.js logo", { variant: "wordmark" }), null); +}); + +test("a named variant never falls through to an avatar or favicon", async () => { + assert.equal(await githubAvatarSearch("Slack logo", { variant: "wordmark" }), null); + assert.equal(await faviconSearch("Slack logo", { variant: "wordmark" }), null); +}); diff --git a/packages/cli/src/media-use/resolve.mjs b/packages/cli/src/media-use/resolve.mjs index 363486fab1..4e58823160 100644 --- a/packages/cli/src/media-use/resolve.mjs +++ b/packages/cli/src/media-use/resolve.mjs @@ -89,6 +89,7 @@ const { values: args } = parseArgs({ type: { type: "string", short: "t" }, intent: { type: "string", short: "i" }, entity: { type: "string", short: "e" }, + variant: { type: "string" }, project: { type: "string", short: "p", default: "." }, adopt: { type: "boolean", default: false }, candidates: { type: "boolean", default: false }, @@ -124,6 +125,8 @@ Options: --type, -t Media type (required) --intent, -i What you need (required) --entity, -e Entity name for cache matching (optional) + --variant With --type logo: the brand's other artwork, e.g. wordmark, + mono, light, dark (default: the full-colour mark) --project, -p Project directory (default: .) --adopt Adopt all existing assets/ files into the manifest --candidates List reusable assets (project + global cache) for --type; no @@ -153,6 +156,7 @@ Options: const projectDir = resolve(args.project); const type = args.type; const intent = args.intent; +const variant = args.variant || null; const entity = args.entity || null; if (args.adopt) { @@ -345,8 +349,9 @@ function localizeImportedRecord(record, localPath) { } async function run() { - // A forced provider bypasses reuse and pins the provider cascade. - const forced = !!args.provider; + // A forced provider bypasses reuse and pins the provider cascade; so does a variant, + // since reuse is keyed on the intent and would hand back the default artwork. + const forced = !!args.provider || !!variant; // 1. project manifest — exact-prompt match const projectHit = forced ? null : findByPrompt(projectDir, intent, type); @@ -422,6 +427,7 @@ async function run() { const localOnly = args["local-only"]; const ctx = { entity, + variant, projectDir, localOnly, provider: args.provider, @@ -582,10 +588,10 @@ async function run() { ...(searchResult.metadata?.transparent != null && { transparent: searchResult.metadata.transparent, }), - ...(entity && { entity }), + ...(entity && !variant && { entity }), provenance: { provider: searchResult.metadata?.provider || "unknown", - prompt: intent, + prompt: variant ? `${intent} [${variant}]` : intent, // Keep auth method sparse for non-HeyGen providers. ...heygenAuthMethodFor(searchResult.metadata?.provider), ...searchResult.metadata?.provenance, @@ -596,7 +602,7 @@ async function run() { regenerateIndex(projectDir); // Promote fetched assets into the global cache for reuse. try { - cachePut(fullPath, record); + if (!variant) cachePut(fullPath, record); } catch { // promotion is best-effort; a resolve still succeeds locally } diff --git a/packages/cli/src/media-use/resolve.test.mjs b/packages/cli/src/media-use/resolve.test.mjs index c1f366db35..685e1f3842 100644 --- a/packages/cli/src/media-use/resolve.test.mjs +++ b/packages/cli/src/media-use/resolve.test.mjs @@ -1533,3 +1533,40 @@ async function main() { } main(); + +// A variant's file must never answer a later default request for the same intent or entity. +test("a logo variant is not reused for the default logo, in the project or the global cache", () => { + setup(); + const icons = [ + { + slug: "slack", + title: "Slack", + aliases: [], + collection: "brands", + license: "MIT", + variants: { default: "/icons/slack/default.svg", wordmark: "/icons/slack/wordmark.svg" }, + }, + ]; + const stub = `const real = globalThis.fetch; +globalThis.fetch = async (url, init) => { + const u = String(url); + if (u.endsWith("/src/data/icons.json")) return Response.json(${JSON.stringify(icons)}); + if (u.includes("/public/icons/slack/")) return new Response(""); + return real(url, init); +};`; + const nodeArgs = [`--import=data:text/javascript,${encodeURIComponent(stub)}`]; + const base = ["--type", "logo", "--intent", "Slack logo", "--entity", "Slack", "--json"]; + const variant = JSON.parse( + spawnResolve([...base, "--variant", "wordmark", "--project", tmp], { nodeArgs }).stdout.trim(), + ); + assert.equal(variant.provenance.variant, "wordmark"); + const otherProject = mkdtempSync(join(tmpdir(), "mu-resolve-other-")); + for (const project of [tmp, otherProject]) { + const plain = JSON.parse( + spawnResolve([...base, "--project", project], { nodeArgs }).stdout.trim(), + ); + assert.equal(plain.provenance.variant, "default", `default logo in ${project}`); + } + rmSync(otherProject, { recursive: true, force: true }); + cleanup(); +});