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