mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): take JSON flags from a file or stdin so PowerShell can pass them (#6518)
* fix(cli): take JSON flags from a file or stdin so PowerShell can pass them * fix(cli): read BOM-prefixed and UTF-16 JSON from a file or stdin * style(cli): format the JSON flag reader * test(cli): feed the bytes PowerShell pipes to the JSON reader * style(cli): shorten the JSON flag reader's comments * style(cli): one line for the JSON flag reader's comment
This commit is contained in:
@@ -42,6 +42,7 @@ import {
|
||||
createProjectConfig,
|
||||
} from "../utils/projectConfig.js";
|
||||
import { copyToClipboard } from "../utils/clipboard.js";
|
||||
import { readJsonFlag } from "../utils/jsonFlag.js";
|
||||
import { trackRegistryItemAdded } from "../telemetry/events.js";
|
||||
|
||||
// ── Target-path resolution ──────────────────────────────────────────────────
|
||||
@@ -161,15 +162,17 @@ export function tagAddJson(
|
||||
};
|
||||
}
|
||||
|
||||
/** `--vars` is JSON an agent or the catalog page produced; a malformed one is
|
||||
* worth a clear error rather than a snippet that silently drops the values. */
|
||||
/** `--vars` is JSON, a file or `-`; a malformed one is an error, not a snippet that drops the values. */
|
||||
export function parseVariableValues(raw: string | undefined): Record<string, unknown> | null {
|
||||
if (raw === undefined) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new AddError("--vars must be a JSON object of variable values", "invalid-vars");
|
||||
parsed = readJsonFlag("--vars", raw);
|
||||
} catch (error: unknown) {
|
||||
throw new AddError(
|
||||
`--vars must be a JSON object of variable values (${error instanceof Error ? error.message : String(error)})`,
|
||||
"invalid-vars",
|
||||
);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new AddError("--vars must be a JSON object of variable values", "invalid-vars");
|
||||
@@ -478,7 +481,7 @@ export default defineCommand({
|
||||
vars: {
|
||||
type: "string",
|
||||
description:
|
||||
"JSON of variable values to bake into the printed snippet, as copied from a catalog page",
|
||||
"JSON of variable values to bake into the printed snippet, as copied from a catalog page; or a JSON file path, or - for stdin",
|
||||
},
|
||||
force: {
|
||||
type: "boolean",
|
||||
|
||||
@@ -662,6 +662,42 @@ describe("applyMediaTreatmentToHtml", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("takes --grading from a file, and tells a quote-stripped value to use one", async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "hf-media-grading-file-"));
|
||||
writeFileSync(join(project, "index.html"), VIDEO);
|
||||
writeFileSync(join(project, "g.json"), '{"preset":"warm-daylight","intensity":0.8}');
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const run = (grading: string) =>
|
||||
runCommand(mediaTreatmentCommand, {
|
||||
rawArgs: [
|
||||
"--project",
|
||||
project,
|
||||
"--selector",
|
||||
"#hero",
|
||||
"--grading",
|
||||
grading,
|
||||
"--dry-run",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
try {
|
||||
await run(join(project, "g.json"));
|
||||
expect(JSON.parse(String(log.mock.calls.at(-1)?.[0]))).toMatchObject({
|
||||
ok: true,
|
||||
changed: true,
|
||||
});
|
||||
await expect(run("{preset:warm-daylight,intensity:0.8}")).rejects.toThrow(CliRuntimeError);
|
||||
expect(JSON.stringify([...error.mock.calls, ...log.mock.calls])).toContain(
|
||||
"--grading payload.json",
|
||||
);
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
error.mockRestore();
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("finds a clip inside a mounted scene and edits it in its own file", async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "hf-media-scene-"));
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
|
||||
@@ -33,6 +33,7 @@ import { failCommand } from "../utils/commandResult.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { readOptionalString } from "../utils/pathArgs.js";
|
||||
import { projectCompositionFiles } from "../utils/mountedCompositions.js";
|
||||
import { readJsonFlag } from "../utils/jsonFlag.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { withMeta } from "../utils/updateCheck.js";
|
||||
import { analyzeMediaTreatment } from "./media-treatment-analysis.js";
|
||||
@@ -577,11 +578,7 @@ function parseGrading(
|
||||
throw new Error("Use --apply with --grading <json> or --clear");
|
||||
}
|
||||
if (raw === undefined) throw new Error("--apply requires --grading <json>");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Could not parse --grading JSON: ${normalizeErrorMessage(error)}`);
|
||||
}
|
||||
return readJsonFlag("--grading", raw);
|
||||
}
|
||||
|
||||
function mutationVerb(action: "apply" | "clear", changed: boolean, dryRun: boolean): string {
|
||||
@@ -811,7 +808,10 @@ export const mediaTreatmentCommand = defineCommand({
|
||||
type: "string",
|
||||
description: "Zero-based match index to patch only one of several matches",
|
||||
},
|
||||
grading: { type: "string", description: "Canonical color-grading JSON patch" },
|
||||
grading: {
|
||||
type: "string",
|
||||
description: "Canonical color-grading JSON patch, a path to a JSON file, or - for stdin",
|
||||
},
|
||||
apply: {
|
||||
type: "boolean",
|
||||
description: "Apply the validated grading patch (explicit agent form)",
|
||||
|
||||
@@ -313,7 +313,7 @@ export default defineCommand({
|
||||
variables: {
|
||||
type: "string",
|
||||
description:
|
||||
'JSON object of variable values, merged over the composition\'s data-composition-variables defaults. Example: --variables \'{"title":"Hello"}\'. Read inside the composition via window.__hyperframes.getVariables().',
|
||||
'JSON object of variable values, merged over the composition\'s data-composition-variables defaults. Example: --variables \'{"title":"Hello"}\'. Also a JSON file path, or - for stdin. Read inside the composition via window.__hyperframes.getVariables().',
|
||||
},
|
||||
"variables-file": {
|
||||
type: "string",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readJsonFlag } from "./jsonFlag.js";
|
||||
|
||||
const io = {
|
||||
readFile: (path: string) => {
|
||||
if (path !== "g.json") throw new Error("ENOENT: no such file");
|
||||
return '{"preset":"warm-daylight"}';
|
||||
},
|
||||
readStdin: () => '{"preset":"skin-soft"}',
|
||||
};
|
||||
|
||||
describe("readJsonFlag", () => {
|
||||
it("reads inline JSON, a file path and - for stdin the same way", () => {
|
||||
expect(readJsonFlag("--grading", '{"preset":"a"}', io)).toEqual({ preset: "a" });
|
||||
expect(readJsonFlag("--grading", "g.json", io)).toEqual({ preset: "warm-daylight" });
|
||||
expect(readJsonFlag("--grading", "-", io)).toEqual({ preset: "skin-soft" });
|
||||
});
|
||||
|
||||
it("points the quote-stripped value PowerShell passes at the file and stdin forms", () => {
|
||||
expect(() => readJsonFlag("--grading", "{preset:skin-soft,intensity:0.6}", io)).toThrow(
|
||||
/PowerShell.*--grading payload\.json.*--grading -/,
|
||||
);
|
||||
});
|
||||
|
||||
it("names the flag and path when the file cannot be read", () => {
|
||||
expect(() => readJsonFlag("--grading", "missing.json", io)).toThrow(
|
||||
/Could not read --grading: missing\.json: ENOENT/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readJsonFlag file encodings", () => {
|
||||
it("reads a UTF-8 file with a BOM and a UTF-16LE file, as PowerShell writes them", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-json-flag-enc-"));
|
||||
try {
|
||||
writeFileSync(join(dir, "bom.json"), Buffer.from('\uFEFF{"a":1}', "utf8"));
|
||||
writeFileSync(join(dir, "u16.json"), Buffer.from('\uFEFF{"a":2}', "utf16le"));
|
||||
expect(readJsonFlag("--grading", join(dir, "bom.json"))).toEqual({ a: 1 });
|
||||
expect(readJsonFlag("--grading", join(dir, "u16.json"))).toEqual({ a: 2 });
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/** A JSON flag: inline JSON, `-` for stdin or a file path; PowerShell drops inline quotes sent to a `.cmd`. */
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export interface JsonFlagIO {
|
||||
readFile: (path: string) => string;
|
||||
readStdin: () => string;
|
||||
}
|
||||
|
||||
// PowerShell 5.1 writes redirected files as UTF-16LE with a BOM and pipes UTF-8 with one.
|
||||
function decode(bytes: Buffer): string {
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xfe)
|
||||
return bytes.toString("utf16le").replace(/^\uFEFF/, "");
|
||||
return bytes.toString("utf8").replace(/^\uFEFF/, "");
|
||||
}
|
||||
|
||||
const defaultIO: JsonFlagIO = {
|
||||
readFile: (path) => decode(readFileSync(resolve(path))),
|
||||
readStdin: () => decode(readFileSync(0)),
|
||||
};
|
||||
|
||||
export class JsonFlagReadError extends Error {
|
||||
constructor(
|
||||
readonly path: string,
|
||||
readonly cause: string,
|
||||
) {
|
||||
super(`${path}: ${cause}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isJsonScalar(text: string): boolean {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
export function readJsonFlagText(value: string, partialIo: Partial<JsonFlagIO> = {}): string {
|
||||
const io = { ...defaultIO, ...partialIo };
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[") || isJsonScalar(trimmed)) return value;
|
||||
const source = trimmed === "-" ? "stdin" : value;
|
||||
try {
|
||||
return trimmed === "-" ? io.readStdin() : io.readFile(value);
|
||||
} catch (error: unknown) {
|
||||
throw new JsonFlagReadError(source, errorText(error));
|
||||
}
|
||||
}
|
||||
|
||||
export const inlineJsonHint = (flag: string) =>
|
||||
`PowerShell strips the quotes from inline JSON; pass a file (${flag} payload.json) or pipe it (${flag} -)`;
|
||||
|
||||
/** The parsed value of a JSON flag; an Error names the flag and, for inline JSON, the Windows fix. */
|
||||
export function readJsonFlag(flag: string, value: string, io?: Partial<JsonFlagIO>): unknown {
|
||||
let text: string;
|
||||
try {
|
||||
text = readJsonFlagText(value, io);
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof JsonFlagReadError)) throw error;
|
||||
throw new Error(`Could not read ${flag}: ${error.message}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error: unknown) {
|
||||
const hint = /^\s*[{[]/.test(text) ? ` (${inlineJsonHint(flag)})` : "";
|
||||
throw new Error(`Could not parse ${flag} JSON: ${errorText(error)}${hint}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { readJsonFlag } from "./jsonFlag.js";
|
||||
|
||||
// A .cmd launcher run from PowerShell, as npm's hyperframes.cmd is.
|
||||
describe.runIf(process.platform === "win32")(
|
||||
"JSON flags through a PowerShell .cmd launcher",
|
||||
() => {
|
||||
let dir = "";
|
||||
let launcher = "";
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-json-flag-"));
|
||||
launcher = join(dir, "launch.cmd");
|
||||
writeFileSync(
|
||||
join(dir, "echo.mjs"),
|
||||
'import { readFileSync } from "node:fs";\nprocess.stdout.write(process.argv[2] === "-" ? readFileSync(0).toString("base64") : process.argv[2]);\n',
|
||||
);
|
||||
writeFileSync(launcher, '@node "%~dp0echo.mjs" %*\r\n');
|
||||
writeFileSync(join(dir, "g.json"), '{"preset":"warm-daylight","intensity":0.6}');
|
||||
});
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
const powershell = (script: string) =>
|
||||
spawnSync(
|
||||
"powershell.exe",
|
||||
["-NoProfile", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")],
|
||||
{ encoding: "utf8" },
|
||||
).stdout;
|
||||
|
||||
it("delivers a file path intact", () => {
|
||||
const arg = powershell(`& '${launcher}' '${join(dir, "g.json")}'`);
|
||||
expect(readJsonFlag("--grading", arg)).toEqual({ preset: "warm-daylight", intensity: 0.6 });
|
||||
});
|
||||
|
||||
it("delivers piped JSON through -, in whatever bytes PowerShell pipes", () => {
|
||||
const piped = Buffer.from(
|
||||
powershell(`'{"preset":"skin-soft"}' | & '${launcher}' -`),
|
||||
"base64",
|
||||
);
|
||||
const file = join(dir, "piped.json");
|
||||
writeFileSync(file, piped);
|
||||
expect(readJsonFlag("--grading", file)).toEqual({ preset: "skin-soft" });
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -58,6 +58,19 @@ describe("parseVariablesArg", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reads --variables from a file path or stdin", () => {
|
||||
const readFile = () => '{"a":1}';
|
||||
const readStdin = () => '{"b":2}';
|
||||
expect(parseVariablesArg("v.json", undefined, readFile, readStdin)).toEqual({
|
||||
ok: true,
|
||||
value: { a: 1 },
|
||||
});
|
||||
expect(parseVariablesArg(undefined, "-", readFile, readStdin)).toEqual({
|
||||
ok: true,
|
||||
value: { b: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces filesystem errors from --variables-file", () => {
|
||||
const err = expectErr<{
|
||||
kind: "read-error";
|
||||
|
||||
@@ -14,7 +14,6 @@ import { failCommand } from "./commandResult.js";
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
extractCompositionMetadata,
|
||||
formatVariableValidationIssue,
|
||||
@@ -24,6 +23,12 @@ import {
|
||||
import { ensureDOMParser } from "./dom.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { errorBox } from "../ui/format.js";
|
||||
import {
|
||||
inlineJsonHint,
|
||||
JsonFlagReadError,
|
||||
readJsonFlagText,
|
||||
type JsonFlagIO,
|
||||
} from "./jsonFlag.js";
|
||||
|
||||
export type VariablesParseError =
|
||||
| { kind: "conflict" }
|
||||
@@ -48,29 +53,21 @@ export type VariablesParseResult =
|
||||
export function parseVariablesArg(
|
||||
inline: string | undefined,
|
||||
filePath: string | undefined,
|
||||
readFile: (path: string) => string = (p) => readFileSync(resolve(p), "utf8"),
|
||||
readFile?: JsonFlagIO["readFile"],
|
||||
readStdin?: JsonFlagIO["readStdin"],
|
||||
): VariablesParseResult {
|
||||
if (inline != null && filePath != null) {
|
||||
return { ok: false, error: { kind: "conflict" } };
|
||||
}
|
||||
const value = inline ?? filePath;
|
||||
const source = inline != null ? "inline" : "file";
|
||||
let raw: string | undefined;
|
||||
let source: "inline" | "file" | undefined;
|
||||
if (inline != null) {
|
||||
raw = inline;
|
||||
source = "inline";
|
||||
} else if (filePath != null) {
|
||||
if (value != null) {
|
||||
try {
|
||||
raw = readFile(filePath);
|
||||
source = "file";
|
||||
raw = readJsonFlagText(value, { readFile, readStdin });
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "read-error",
|
||||
path: filePath,
|
||||
cause: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
if (!(error instanceof JsonFlagReadError)) throw error;
|
||||
return { ok: false, error: { kind: "read-error", path: error.path, cause: error.cause } };
|
||||
}
|
||||
}
|
||||
if (raw == null) return { ok: true, value: undefined };
|
||||
@@ -83,7 +80,7 @@ export function parseVariablesArg(
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "parse-error",
|
||||
source: source ?? "inline",
|
||||
source,
|
||||
cause: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
@@ -103,7 +100,7 @@ function variablesErrorMessage(error: VariablesParseError): { title: string; mes
|
||||
};
|
||||
case "read-error":
|
||||
return {
|
||||
title: "Could not read --variables-file",
|
||||
title: "Could not read the variables JSON",
|
||||
message: `${error.path}: ${error.cause}`,
|
||||
};
|
||||
case "parse-error":
|
||||
@@ -112,7 +109,10 @@ function variablesErrorMessage(error: VariablesParseError): { title: string; mes
|
||||
error.source === "file"
|
||||
? "Invalid JSON in --variables-file"
|
||||
: "Invalid JSON in --variables",
|
||||
message: error.cause,
|
||||
message:
|
||||
error.source === "inline"
|
||||
? `${error.cause} (${inlineJsonHint("--variables")})`
|
||||
: error.cause,
|
||||
};
|
||||
case "shape-error":
|
||||
return {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"files": 13
|
||||
},
|
||||
"media-use": {
|
||||
"hash": "7603d1411b14d829",
|
||||
"hash": "938c00af915a46b5",
|
||||
"files": 116
|
||||
},
|
||||
"remotion-to-hyperframes": {
|
||||
|
||||
@@ -40,6 +40,8 @@ hyperframes media-treatment --project . --file index.html \
|
||||
--grading '{"preset":"warm-daylight","intensity":1}' --apply --json
|
||||
```
|
||||
|
||||
On Windows PowerShell, put the payload in a file and pass `--grading grading.json` (or pipe it with `--grading -`); inline JSON loses its quotes there.
|
||||
|
||||
Use `--dry-run` before writing when scope is uncertain and `--clear` to remove
|
||||
the treatment. The low-level persisted result is still normal HTML:
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ These run on this machine, free, and write into the project.
|
||||
## Grade and style footage and images
|
||||
|
||||
- Measure, then correct: `npx hyperframes media-treatment --selector '#hero' --analyze --json`, then `--grading '<json>' --apply`. `--dry-run` previews; `--clear` removes the treatment.
|
||||
- On Windows PowerShell, inline JSON loses its quotes: write the JSON to a file and pass its path, `--grading grading.json`, or pipe it with `--grading -`. The same goes for `--variables` and `--vars`.
|
||||
- `--file` defaults to `index.html`: for media inside a scene, pass `--file compositions/<scene>.html`.
|
||||
- Grain, blur, vignette, presets, palettes: `npx hyperframes media-treatment --capabilities --json`, then `--capability <id>` for one family's controls and ranges.
|
||||
- The result is a `data-color-grading` attribute on the media element. It treats the whole element in SDR, so it can't blur one region such as a face.
|
||||
|
||||
Reference in New Issue
Block a user