diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 1b1cd54a84..60cd514688 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -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 | 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", diff --git a/packages/cli/src/commands/media-treatment.test.ts b/packages/cli/src/commands/media-treatment.test.ts index d5e62b20f7..4a5377c3f7 100644 --- a/packages/cli/src/commands/media-treatment.test.ts +++ b/packages/cli/src/commands/media-treatment.test.ts @@ -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); diff --git a/packages/cli/src/commands/media-treatment.ts b/packages/cli/src/commands/media-treatment.ts index 09b5f1747e..79e6fa9a16 100644 --- a/packages/cli/src/commands/media-treatment.ts +++ b/packages/cli/src/commands/media-treatment.ts @@ -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 or --clear"); } if (raw === undefined) throw new Error("--apply requires --grading "); - 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)", diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index eee473e92b..5e1c4a123f 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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", diff --git a/packages/cli/src/utils/jsonFlag.test.ts b/packages/cli/src/utils/jsonFlag.test.ts new file mode 100644 index 0000000000..2d786b6665 --- /dev/null +++ b/packages/cli/src/utils/jsonFlag.test.ts @@ -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 }); + } + }); +}); diff --git a/packages/cli/src/utils/jsonFlag.ts b/packages/cli/src/utils/jsonFlag.ts new file mode 100644 index 0000000000..d46dc1d160 --- /dev/null +++ b/packages/cli/src/utils/jsonFlag.ts @@ -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 = {}): 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): 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}`); + } +} diff --git a/packages/cli/src/utils/jsonFlag.windows.test.ts b/packages/cli/src/utils/jsonFlag.windows.test.ts new file mode 100644 index 0000000000..3cb69cc77d --- /dev/null +++ b/packages/cli/src/utils/jsonFlag.windows.test.ts @@ -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" }); + }); + }, +); diff --git a/packages/cli/src/utils/variables.test.ts b/packages/cli/src/utils/variables.test.ts index 5961fd3488..c4237f5410 100644 --- a/packages/cli/src/utils/variables.test.ts +++ b/packages/cli/src/utils/variables.test.ts @@ -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"; diff --git a/packages/cli/src/utils/variables.ts b/packages/cli/src/utils/variables.ts index 453207cbcd..7eddaeba34 100644 --- a/packages/cli/src/utils/variables.ts +++ b/packages/cli/src/utils/variables.ts @@ -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 { diff --git a/skills-manifest.json b/skills-manifest.json index 53a058ea2c..7423e41fee 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -18,7 +18,7 @@ "files": 13 }, "media-use": { - "hash": "7603d1411b14d829", + "hash": "938c00af915a46b5", "files": 116 }, "remotion-to-hyperframes": { diff --git a/skills/media-use/references/grading.md b/skills/media-use/references/grading.md index 0e1d8eeffc..f1fadff29f 100644 --- a/skills/media-use/references/grading.md +++ b/skills/media-use/references/grading.md @@ -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: diff --git a/skills/media-use/references/local.md b/skills/media-use/references/local.md index 7fcce2b117..5c9576dddd 100644 --- a/skills/media-use/references/local.md +++ b/skills/media-use/references/local.md @@ -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 '' --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/.html`. - Grain, blur, vignette, presets, palettes: `npx hyperframes media-treatment --capabilities --json`, then `--capability ` 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.