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:
Miguel Ángel
2026-10-27 06:33:50 +00:00
committed by GitHub
parent 859cd2fb9c
commit 808d642bce
12 changed files with 256 additions and 34 deletions
+9 -6
View File
@@ -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);
+6 -6
View File
@@ -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)",
+1 -1
View File
@@ -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",
+47
View File
@@ -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 });
}
});
});
+72
View File
@@ -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" });
});
},
);
+13
View File
@@ -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";
+20 -20
View File
@@ -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 {
+1 -1
View File
@@ -18,7 +18,7 @@
"files": 13
},
"media-use": {
"hash": "7603d1411b14d829",
"hash": "938c00af915a46b5",
"files": 116
},
"remotion-to-hyperframes": {
+2
View File
@@ -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:
+1
View File
@@ -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.