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
This commit is contained in:
Miguel Ángel
2026-10-31 22:26:35 +00:00
committed by GitHub
parent bb3c3b2118
commit 8a0b8a9c22
15 changed files with 239 additions and 31 deletions
+2 -1
View File
@@ -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),
+11 -10
View File
@@ -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,
},
+41
View File
@@ -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",
};
});
},
});
+1 -1
View File
@@ -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 <project>/.hyperframes/artifacts/snapshots)",
},
+1 -9
View File
@@ -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;
+1
View File
@@ -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/<op>)",
} as const;
export const jsonArg = {
+1 -1
View File
@@ -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));
};
+30
View File
@@ -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();
});
});
+49
View File
@@ -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<number>();
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;
}
+8 -1
View File
@@ -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;
}
}
+24
View File
@@ -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<string, { alias?: string | string[] }>)[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(),
);
});
});
@@ -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`;
@@ -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);
});
+11 -5
View File
@@ -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
}
@@ -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("<svg xmlns='http://www.w3.org/2000/svg'/>");
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();
});