mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(cli): persist authoring skill in hyperframes.json for durable render attribution (#2762)
* fix(cli): persist authoring skill in hyperframes.json for durable render attribution authoring_skill was stamped only on the first render through a workflow passing --skill, so re-renders, `npm run render`, --batch, existing-project renders, and general-video lost it — leaving 77-96% of real-human render volume un-attributed and the skills-penetration metric misleadingly low. Persist the owning skill in hyperframes.json: `init --skill` stamps it at creation, `render` resolves the flag then falls back to the stored value, and an explicit --skill seeds it (seed-once, never overwriting the creating workflow's identity). Activate all render-producing creation workflows to declare their skill at init. Forward-only: does not rewrite historical telemetry. * fix(cli): patch hyperframes.json in place when seeding the authoring skill seedProjectAuthoringSkill is the only writer that touches an already existing hyperframes.json — every other writeProjectConfig call site is guarded to write only when the file is absent, which made the whole-file overwrite safe by construction. Round-tripping the seed through normalizeConfig broke that: it rebuilds the object from a field whitelist with no rest-spread, so any key outside the schema was silently dropped, a media block was materialized in projects that never had one, and key order was rewritten. hyperframes.json is normally committed, so a render introduced a diff the user never asked for, and any field added to the schema later would be deleted by a render on an older CLI. Parse the raw JSON, set authoringSkill, write it back, reusing the file's own indentation. Unknown keys and formatting survive; the only delta is the key being added. A corrupt config is now left untouched instead of clobbered. Seed-once semantics are unchanged, still normalized so a hand-edited garbage slug neither reaches telemetry nor wedges the seed. Reported independently by both reviewers on #2762. * fix(cli): create the docker build context with mkdtempSync The `--docker` build context was created at a guessable path derived from `Date.now()` in the world-writable OS temp dir. Another local user can pre-create or symlink that path and have the build read a Dockerfile they control. mkdtempSync gets a random suffix and 0o700 from the kernel, and it creates the directory itself, so the separate mkdirSync goes away. Pre-existing on main (alert #432, 2026-06-04, packages/cli/src/commands/render.ts), surfaced against this branch only because the seed commit shifted line numbers in the same file. Fixed here to unblock the CodeQL gate on #2762 rather than left for a follow-up; the remaining 10 js/insecure-temporary-file alerts elsewhere in the repo are untouched and still want their own pass. * fix(cli): drop the check-then-use race when seeding the authoring skill The seed tested for the config with existsSync and then wrote, which is a check-then-use race: the file can be created or swapped between the check and the write (CodeQL js/file-system-race). Read once and branch on the failure reason instead. Only ENOENT creates a config from scratch; any other read failure (permissions, I/O) now leaves an existing file alone rather than overwriting it with a default, so this is also strictly safer than the version it replaces. Also replaces the `as Record<string, unknown>` assertion with an isJsonObject type guard, per the repo's no-assertion convention. Behaviour unchanged: all 4 seed regression tests still pass, and the create/preserve/seed-once/corrupt-untouched paths were re-verified end to end.
This commit is contained in:
@@ -51,6 +51,11 @@
|
|||||||
"description": "Automatically create H.264 proxies for browser-hostile video codecs on supported preview surfaces. Defaults to true."
|
"description": "Automatically create H.264 proxies for browser-hostile video codecs on supported preview surfaces. Defaults to true."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"authoringSkill": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9][a-z0-9-]{0,63}$",
|
||||||
|
"description": "Owning authoring-workflow skill slug (e.g. product-launch-video). Set by `hyperframes init --skill` or seeded from the first `hyperframes render --skill`; every render of this project is then attributed to it on anonymous telemetry, without re-passing the flag."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -556,6 +556,7 @@ async function scaffoldProject(
|
|||||||
durationSeconds?: number,
|
durationSeconds?: number,
|
||||||
tailwind = false,
|
tailwind = false,
|
||||||
resolution?: CanvasResolution,
|
resolution?: CanvasResolution,
|
||||||
|
authoringSkill?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
mkdirSync(destDir, { recursive: true });
|
mkdirSync(destDir, { recursive: true });
|
||||||
|
|
||||||
@@ -588,10 +589,17 @@ async function scaffoldProject(
|
|||||||
|
|
||||||
// Write hyperframes.json so `hyperframes add` knows which registry to use
|
// Write hyperframes.json so `hyperframes add` knows which registry to use
|
||||||
// and where to drop block/component files. Overwritten only if absent.
|
// and where to drop block/component files. Overwritten only if absent.
|
||||||
|
// When the scaffolding workflow declared itself via --skill, stamp the owning
|
||||||
|
// skill here so every later render of this project is attributed to it.
|
||||||
if (!existsSync(resolve(destDir, "hyperframes.json"))) {
|
if (!existsSync(resolve(destDir, "hyperframes.json"))) {
|
||||||
const { writeProjectConfig, DEFAULT_PROJECT_CONFIG } =
|
const { writeProjectConfig, DEFAULT_PROJECT_CONFIG } =
|
||||||
await import("../utils/projectConfig.js");
|
await import("../utils/projectConfig.js");
|
||||||
writeProjectConfig(destDir, DEFAULT_PROJECT_CONFIG);
|
const { normalizeSkillSlug } = await import("../telemetry/skill.js");
|
||||||
|
const skill = normalizeSkillSlug(authoringSkill);
|
||||||
|
writeProjectConfig(
|
||||||
|
destDir,
|
||||||
|
skill ? { ...DEFAULT_PROJECT_CONFIG, authoringSkill: skill } : DEFAULT_PROJECT_CONFIG,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
writeDefaultPackageJson(destDir, name);
|
writeDefaultPackageJson(destDir, name);
|
||||||
@@ -728,6 +736,13 @@ export default defineCommand({
|
|||||||
description:
|
description:
|
||||||
"Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. Default: keep template dimensions (typically 1920x1080).",
|
"Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. Default: keep template dimensions (typically 1920x1080).",
|
||||||
},
|
},
|
||||||
|
skill: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Owning authoring workflow slug (e.g. product-launch-video). Stamped into " +
|
||||||
|
"hyperframes.json so every render of this project is attributed to it on " +
|
||||||
|
"anonymous telemetry, without re-passing --skill on each render. Ignored unless it is a slug.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
if (args.template !== undefined) {
|
if (args.template !== undefined) {
|
||||||
@@ -898,6 +913,7 @@ export default defineCommand({
|
|||||||
videoDuration,
|
videoDuration,
|
||||||
tailwind,
|
tailwind,
|
||||||
resolutionPreset,
|
resolutionPreset,
|
||||||
|
args.skill,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -1112,6 +1128,7 @@ export default defineCommand({
|
|||||||
videoDuration,
|
videoDuration,
|
||||||
tailwind,
|
tailwind,
|
||||||
resolutionPreset,
|
resolutionPreset,
|
||||||
|
args.skill,
|
||||||
);
|
);
|
||||||
if (!isBundled) {
|
if (!isBundled) {
|
||||||
spin.stop(c.success(`Downloaded ${templateId}`));
|
spin.stop(c.success(`Downloaded ${templateId}`));
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { failCommand, requestCliExit } from "../utils/commandResult.js";
|
import { failCommand, requestCliExit } from "../utils/commandResult.js";
|
||||||
import { defineCommand } from "citty";
|
import { defineCommand } from "citty";
|
||||||
import type { Example } from "./_examples.js";
|
import type { Example } from "./_examples.js";
|
||||||
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
|
import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
|
||||||
import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js";
|
import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js";
|
||||||
|
import { seedProjectAuthoringSkill } from "../utils/projectConfig.js";
|
||||||
import { presentRenderPlan } from "./render/present.js";
|
import { presentRenderPlan } from "./render/present.js";
|
||||||
import { executeRenderPlan, renderLintContinuationHint } from "./render/execute.js";
|
import { executeRenderPlan, renderLintContinuationHint } from "./render/execute.js";
|
||||||
// Test-only seams retained at the command boundary for render behavior tests.
|
// Test-only seams retained at the command boundary for render behavior tests.
|
||||||
@@ -351,6 +352,9 @@ export default defineCommand({
|
|||||||
// Keep the transport adapter thin: each phase has one ownership boundary.
|
// Keep the transport adapter thin: each phase has one ownership boundary.
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
const plan = createRenderPlan(args);
|
const plan = createRenderPlan(args);
|
||||||
|
// Teach the project its owning skill from an explicit --skill so every
|
||||||
|
// later flag-less render (re-render, `npm run render`, batch) inherits it.
|
||||||
|
seedProjectAuthoringSkill(plan.project.dir, args.skill);
|
||||||
await presentRenderPlan(plan);
|
await presentRenderPlan(plan);
|
||||||
await executeRenderPlan(plan, {
|
await executeRenderPlan(plan, {
|
||||||
renderDocker,
|
renderDocker,
|
||||||
@@ -558,9 +562,12 @@ function ensureDockerImage(version: string, platform: string, quiet: boolean): s
|
|||||||
|
|
||||||
const dockerfilePath = resolveDockerfilePath();
|
const dockerfilePath = resolveDockerfilePath();
|
||||||
|
|
||||||
// Copy Dockerfile to a temp build context so docker build has a clean context
|
// Copy Dockerfile to a temp build context so docker build has a clean context.
|
||||||
const tmpDir = join(tmpdir(), `hyperframes-docker-${Date.now()}`);
|
// mkdtempSync (not a `Date.now()`-derived name) so the path is unpredictable
|
||||||
mkdirSync(tmpDir, { recursive: true });
|
// and created 0o700 by the kernel — a guessable temp dir in a world-writable
|
||||||
|
// tmpdir is pre-creatable by another local user, who could then swap in their
|
||||||
|
// own Dockerfile or symlink the path (CodeQL js/insecure-temporary-file).
|
||||||
|
const tmpDir = mkdtempSync(join(tmpdir(), "hyperframes-docker-"));
|
||||||
writeFileSync(join(tmpDir, "Dockerfile"), readFileSync(dockerfilePath));
|
writeFileSync(join(tmpDir, "Dockerfile"), readFileSync(dockerfilePath));
|
||||||
|
|
||||||
// Platform is now derived from the host arch (see resolveDockerPlatform).
|
// Platform is now derived from the host arch (see resolveDockerPlatform).
|
||||||
|
|||||||
@@ -81,4 +81,22 @@ describe("createRenderPlan", () => {
|
|||||||
const plan = createRenderPlan({ dir: projectDir, "frames-cache-dir": "OFF" });
|
const plan = createRenderPlan({ dir: projectDir, "frames-cache-dir": "OFF" });
|
||||||
expect(plan.environment.HYPERFRAMES_EXTRACT_CACHE_DIR).toBe("OFF");
|
expect(plan.environment.HYPERFRAMES_EXTRACT_CACHE_DIR).toBe("OFF");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attributes a flag-less render to the skill persisted in hyperframes.json", () => {
|
||||||
|
writeFileSync(
|
||||||
|
join(projectDir, "hyperframes.json"),
|
||||||
|
JSON.stringify({ authoringSkill: "product-launch-video" }),
|
||||||
|
);
|
||||||
|
const plan = createRenderPlan({ dir: projectDir });
|
||||||
|
expect(plan.authoringSkill).toBe("product-launch-video");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an explicit --skill flag override the persisted project owner", () => {
|
||||||
|
writeFileSync(
|
||||||
|
join(projectDir, "hyperframes.json"),
|
||||||
|
JSON.stringify({ authoringSkill: "product-launch-video" }),
|
||||||
|
);
|
||||||
|
const plan = createRenderPlan({ dir: projectDir, skill: "motion-graphics" });
|
||||||
|
expect(plan.authoringSkill).toBe("motion-graphics");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
resolveDefaultFpsArg,
|
resolveDefaultFpsArg,
|
||||||
} from "../../utils/renderArgs.js";
|
} from "../../utils/renderArgs.js";
|
||||||
import { normalizeSkillSlug } from "../../telemetry/skill.js";
|
import { normalizeSkillSlug } from "../../telemetry/skill.js";
|
||||||
|
import { loadProjectConfig } from "../../utils/projectConfig.js";
|
||||||
|
|
||||||
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
|
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
|
||||||
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const;
|
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const;
|
||||||
@@ -192,9 +193,14 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
|
|||||||
}
|
}
|
||||||
const quality = qualityRaw as RenderQuality;
|
const quality = qualityRaw as RenderQuality;
|
||||||
|
|
||||||
const authoringSkill = normalizeSkillSlug(args.skill);
|
// Attribution resolves the explicit --skill flag first, then falls back to
|
||||||
|
// the owning skill persisted in hyperframes.json — so re-renders, batch
|
||||||
|
// renders, and `npm run render` (which never re-pass the flag) stay
|
||||||
|
// attributed to the workflow that created the project.
|
||||||
|
const flagSkill = normalizeSkillSlug(args.skill);
|
||||||
|
const authoringSkill = flagSkill ?? loadProjectConfig(project.dir).authoringSkill;
|
||||||
const invalidAuthoringSkill =
|
const invalidAuthoringSkill =
|
||||||
typeof args.skill === "string" && args.skill.trim() !== "" && !authoringSkill
|
typeof args.skill === "string" && args.skill.trim() !== "" && !flagSkill
|
||||||
? args.skill
|
? args.skill
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
projectConfigPath,
|
projectConfigPath,
|
||||||
readProjectConfig,
|
readProjectConfig,
|
||||||
resolveAutoProxy,
|
resolveAutoProxy,
|
||||||
|
seedProjectAuthoringSkill,
|
||||||
writeProjectConfig,
|
writeProjectConfig,
|
||||||
PROJECT_CONFIG_FILENAME,
|
PROJECT_CONFIG_FILENAME,
|
||||||
} from "./projectConfig.js";
|
} from "./projectConfig.js";
|
||||||
@@ -84,6 +85,18 @@ describe("projectConfig", () => {
|
|||||||
const result = normalizeConfig({ media: "nope" as unknown as never });
|
const result = normalizeConfig({ media: "nope" as unknown as never });
|
||||||
expect(result.media).toEqual({ autoProxy: true });
|
expect(result.media).toEqual({ autoProxy: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a valid authoringSkill slug", () => {
|
||||||
|
const result = normalizeConfig({ authoringSkill: "product-launch-video" });
|
||||||
|
expect(result.authoringSkill).toBe("product-launch-video");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops an invalid authoringSkill (never reaches telemetry)", () => {
|
||||||
|
const result = normalizeConfig({
|
||||||
|
authoringSkill: "Not A Slug!" as unknown as never,
|
||||||
|
});
|
||||||
|
expect(result.authoringSkill).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("readProjectConfig", () => {
|
describe("readProjectConfig", () => {
|
||||||
@@ -228,4 +241,127 @@ describe("projectConfig", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("seedProjectAuthoringSkill", () => {
|
||||||
|
it("stamps the owning skill into a fresh project (creates the config)", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
try {
|
||||||
|
seedProjectAuthoringSkill(dir, "faceless-explainer");
|
||||||
|
expect(loadProjectConfig(dir).authoringSkill).toBe("faceless-explainer");
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves other config fields when stamping an existing config", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
try {
|
||||||
|
writeProjectConfig(dir, {
|
||||||
|
...DEFAULT_PROJECT_CONFIG,
|
||||||
|
registry: "https://custom.example.com",
|
||||||
|
});
|
||||||
|
seedProjectAuthoringSkill(dir, "pr-to-video");
|
||||||
|
const read = readProjectConfig(dir);
|
||||||
|
expect(read?.authoringSkill).toBe("pr-to-video");
|
||||||
|
expect(read?.registry).toBe("https://custom.example.com");
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is seed-once: a later --skill never overwrites the project owner", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
try {
|
||||||
|
seedProjectAuthoringSkill(dir, "product-launch-video");
|
||||||
|
seedProjectAuthoringSkill(dir, "motion-graphics");
|
||||||
|
expect(loadProjectConfig(dir).authoringSkill).toBe("product-launch-video");
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an invalid slug and writes nothing", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
try {
|
||||||
|
seedProjectAuthoringSkill(dir, "Not A Slug!");
|
||||||
|
expect(readProjectConfig(dir)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The seed is the only writer that touches an existing hyperframes.json,
|
||||||
|
// which is normally committed — a render must not diff it beyond the one
|
||||||
|
// key being added. Guards against round-tripping through normalizeConfig.
|
||||||
|
it("preserves config keys outside the known schema", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
try {
|
||||||
|
writeFileSync(
|
||||||
|
projectConfigPath(dir),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
registry: "https://example.com/my-registry",
|
||||||
|
myTeamSetting: { reviewer: "wenbo", keep: true },
|
||||||
|
futureSchemaKey: 42,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
seedProjectAuthoringSkill(dir, "product-launch-video");
|
||||||
|
const raw = JSON.parse(readFileSync(projectConfigPath(dir), "utf-8"));
|
||||||
|
expect(raw.authoringSkill).toBe("product-launch-video");
|
||||||
|
expect(raw.myTeamSetting).toEqual({ reviewer: "wenbo", keep: true });
|
||||||
|
expect(raw.futureSchemaKey).toBe(42);
|
||||||
|
expect(raw.registry).toBe("https://example.com/my-registry");
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not materialize a media block the user never wrote", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
try {
|
||||||
|
writeFileSync(
|
||||||
|
projectConfigPath(dir),
|
||||||
|
JSON.stringify({ registry: "https://example.com/r" }, null, 2),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
seedProjectAuthoringSkill(dir, "motion-graphics");
|
||||||
|
const raw = JSON.parse(readFileSync(projectConfigPath(dir), "utf-8"));
|
||||||
|
expect(raw.media).toBeUndefined();
|
||||||
|
expect(raw.$schema).toBeUndefined();
|
||||||
|
expect(Object.keys(raw)).toEqual(["registry", "authoringSkill"]);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses the file's own indentation", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
try {
|
||||||
|
writeFileSync(
|
||||||
|
projectConfigPath(dir),
|
||||||
|
JSON.stringify({ registry: "https://example.com/r" }, null, 4),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
seedProjectAuthoringSkill(dir, "pr-to-video");
|
||||||
|
expect(readFileSync(projectConfigPath(dir), "utf-8")).toContain('\n "registry"');
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a corrupt config untouched rather than clobbering it", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
try {
|
||||||
|
writeFileSync(projectConfigPath(dir), "{ not valid json", "utf-8");
|
||||||
|
seedProjectAuthoringSkill(dir, "faceless-explainer");
|
||||||
|
expect(readFileSync(projectConfigPath(dir), "utf-8")).toBe("{ not valid json");
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { readFileSync, writeFileSync } from "node:fs";
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { DEFAULT_REGISTRY_URL } from "../registry/index.js";
|
import { DEFAULT_REGISTRY_URL } from "../registry/index.js";
|
||||||
|
import { normalizeSkillSlug } from "../telemetry/skill.js";
|
||||||
|
|
||||||
export const PROJECT_CONFIG_FILENAME = "hyperframes.json";
|
export const PROJECT_CONFIG_FILENAME = "hyperframes.json";
|
||||||
const PROJECT_CONFIG_SCHEMA_URL = "https://hyperframes.heygen.com/schema/hyperframes.json";
|
const PROJECT_CONFIG_SCHEMA_URL = "https://hyperframes.heygen.com/schema/hyperframes.json";
|
||||||
@@ -40,6 +41,14 @@ export interface ProjectConfig {
|
|||||||
paths: ProjectConfigPaths;
|
paths: ProjectConfigPaths;
|
||||||
/** Media handling options (e.g. auto-proxying of browser-hostile codecs). */
|
/** Media handling options (e.g. auto-proxying of browser-hostile codecs). */
|
||||||
media?: ProjectConfigMedia;
|
media?: ProjectConfigMedia;
|
||||||
|
/**
|
||||||
|
* Owning authoring-workflow skill slug (e.g. "product-launch-video"). Stamped
|
||||||
|
* by `hyperframes init --skill` or seeded from the first `hyperframes render
|
||||||
|
* --skill`, then read back so every later render of this project — re-render,
|
||||||
|
* `npm run render`, `--batch`, preview — is attributed to it on anonymous
|
||||||
|
* telemetry without the caller re-passing the flag.
|
||||||
|
*/
|
||||||
|
authoringSkill?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
|
export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
|
||||||
@@ -92,6 +101,9 @@ export function normalizeConfig(partial: Partial<ProjectConfig>): ProjectConfig
|
|||||||
? partial.media.autoProxy
|
? partial.media.autoProxy
|
||||||
: DEFAULT_PROJECT_CONFIG.media?.autoProxy,
|
: DEFAULT_PROJECT_CONFIG.media?.autoProxy,
|
||||||
},
|
},
|
||||||
|
// Slug-gate on read so a hand-edited or corrupt value never reaches the
|
||||||
|
// telemetry stream; an invalid slug simply drops the attribution.
|
||||||
|
authoringSkill: normalizeSkillSlug(partial.authoringSkill),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,3 +139,74 @@ export function resolveAutoProxy(projectDir: string, flagValue: boolean | undefi
|
|||||||
}
|
}
|
||||||
return loadProjectConfig(projectDir).media?.autoProxy ?? true;
|
return loadProjectConfig(projectDir).media?.autoProxy ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A parsed JSON value that can carry arbitrary keys — narrowed, not asserted. */
|
||||||
|
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a thrown filesystem error reports the path as absent. */
|
||||||
|
function isFileNotFound(error: unknown): boolean {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the owning authoring-skill slug into `hyperframes.json` so every
|
||||||
|
* later render of this project — re-render, `npm run render`, `--batch`,
|
||||||
|
* preview — is attributed to the workflow that created it, without the caller
|
||||||
|
* re-passing `--skill`.
|
||||||
|
*
|
||||||
|
* Seed-once: an existing stamp is never overwritten (the creating workflow owns
|
||||||
|
* the identity; a one-off `--skill` on a later render still governs that
|
||||||
|
* render's telemetry but does not rewrite the project's owner). An invalid or
|
||||||
|
* empty slug is ignored. Best effort: a read-only or missing project directory
|
||||||
|
* never fails the render it rode in on.
|
||||||
|
*
|
||||||
|
* This is the only writer that touches an ALREADY EXISTING `hyperframes.json`
|
||||||
|
* (every other `writeProjectConfig` call site is guarded to write only when the
|
||||||
|
* file is absent), so it must not round-trip through {@link normalizeConfig}:
|
||||||
|
* that rebuilds the object from a field whitelist, which would drop keys it
|
||||||
|
* does not know about and materialize defaults the user never wrote. The file
|
||||||
|
* is normally committed, so a render must not introduce a diff beyond the one
|
||||||
|
* key being added. Patch the parsed JSON in place instead, reusing the file's
|
||||||
|
* own indentation.
|
||||||
|
*/
|
||||||
|
export function seedProjectAuthoringSkill(projectDir: string, rawSkill: unknown): void {
|
||||||
|
const skill = normalizeSkillSlug(rawSkill);
|
||||||
|
if (!skill) return;
|
||||||
|
const path = projectConfigPath(projectDir);
|
||||||
|
|
||||||
|
// Read once and branch on why the read failed, rather than testing for the
|
||||||
|
// file first: an `existsSync`-then-write pair is a check-then-use race, and
|
||||||
|
// only a genuinely absent config may be created from scratch — any other
|
||||||
|
// read failure (permissions, I/O) must leave an existing file alone instead
|
||||||
|
// of overwriting it with a default.
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = readFileSync(path, "utf-8");
|
||||||
|
} catch (error) {
|
||||||
|
if (isFileNotFound(error)) {
|
||||||
|
try {
|
||||||
|
writeProjectConfig(projectDir, { ...DEFAULT_PROJECT_CONFIG, authoringSkill: skill });
|
||||||
|
} catch {
|
||||||
|
// Read-only or missing project directory — best effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(text);
|
||||||
|
// A malformed config is left untouched rather than clobbered by a render.
|
||||||
|
if (!isJsonObject(parsed)) return;
|
||||||
|
// Seed-once. Normalized so a hand-edited garbage slug neither reaches
|
||||||
|
// telemetry nor wedges the seed — the next `--skill` render heals it.
|
||||||
|
if (normalizeSkillSlug(parsed.authoringSkill)) return;
|
||||||
|
parsed.authoringSkill = skill;
|
||||||
|
const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " ";
|
||||||
|
writeFileSync(path, JSON.stringify(parsed, null, indent) + "\n", "utf-8");
|
||||||
|
} catch {
|
||||||
|
// Corrupt JSON, or a read-only file: attribution is best-effort telemetry,
|
||||||
|
// never a render blocker.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
"source": "heygen-com/hyperframes",
|
"source": "heygen-com/hyperframes",
|
||||||
"skills": {
|
"skills": {
|
||||||
"embedded-captions": {
|
"embedded-captions": {
|
||||||
"hash": "a30d5691b4389ec1",
|
"hash": "ed4dc7b850b92ff5",
|
||||||
"files": 140
|
"files": 140
|
||||||
},
|
},
|
||||||
"faceless-explainer": {
|
"faceless-explainer": {
|
||||||
"hash": "15fc5a20e2a44bbe",
|
"hash": "b772a9b6c8118c2c",
|
||||||
"files": 22
|
"files": 22
|
||||||
},
|
},
|
||||||
"figma": {
|
"figma": {
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"files": 2
|
"files": 2
|
||||||
},
|
},
|
||||||
"general-video": {
|
"general-video": {
|
||||||
"hash": "e8c377a79bd57a69",
|
"hash": "87f292b572cad3f9",
|
||||||
"files": 4
|
"files": 4
|
||||||
},
|
},
|
||||||
"hyperframes": {
|
"hyperframes": {
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"files": 121
|
"files": 121
|
||||||
},
|
},
|
||||||
"hyperframes-cli": {
|
"hyperframes-cli": {
|
||||||
"hash": "a4db0693ff88e760",
|
"hash": "8c791e330873b1bd",
|
||||||
"files": 11
|
"files": 11
|
||||||
},
|
},
|
||||||
"hyperframes-core": {
|
"hyperframes-core": {
|
||||||
@@ -50,19 +50,19 @@
|
|||||||
"files": 151
|
"files": 151
|
||||||
},
|
},
|
||||||
"motion-graphics": {
|
"motion-graphics": {
|
||||||
"hash": "da65c1864debfe11",
|
"hash": "0212a19069119e72",
|
||||||
"files": 23
|
"files": 23
|
||||||
},
|
},
|
||||||
"music-to-video": {
|
"music-to-video": {
|
||||||
"hash": "562656e2a2f3a193",
|
"hash": "55a2b5fdcd6f892c",
|
||||||
"files": 132
|
"files": 132
|
||||||
},
|
},
|
||||||
"pr-to-video": {
|
"pr-to-video": {
|
||||||
"hash": "0eb5abe09844cee4",
|
"hash": "44a9877e7ea1289e",
|
||||||
"files": 29
|
"files": 29
|
||||||
},
|
},
|
||||||
"product-launch-video": {
|
"product-launch-video": {
|
||||||
"hash": "84b33f077fff496d",
|
"hash": "8cce4a32487eaf80",
|
||||||
"files": 26
|
"files": 26
|
||||||
},
|
},
|
||||||
"remotion-to-hyperframes": {
|
"remotion-to-hyperframes": {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ Read the samples. Refuse if:
|
|||||||
## Pipeline — 5 steps
|
## Pipeline — 5 steps
|
||||||
|
|
||||||
```
|
```
|
||||||
1. hyperframes init <project> --non-interactive --video <video.mp4>
|
1. hyperframes init <project> --non-interactive --video <video.mp4> --skill=embedded-captions
|
||||||
2. bash scripts/prepare.sh <project> # matte ∥ transcribe (parallel) → safe-zones. One command.
|
2. bash scripts/prepare.sh <project> # matte ∥ transcribe (parallel) → safe-zones. One command.
|
||||||
# → frames_fg/ transcript.json safe-zones.json
|
# → frames_fg/ transcript.json safe-zones.json
|
||||||
3. [AGENT STEP — the only creative step] author a small JSON; see below by mode
|
3. [AGENT STEP — the only creative step] author a small JSON; see below by mode
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ For a new video that's clearly similar to an existing canonical example:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Scaffold the project
|
# 1. Scaffold the project
|
||||||
hyperframes init <project> --non-interactive --video <video.mp4>
|
hyperframes init <project> --non-interactive --video <video.mp4> --skill=embedded-captions
|
||||||
|
|
||||||
# 2. Matte + transcribe
|
# 2. Matte + transcribe
|
||||||
node scripts/matte.cjs <project>
|
node scripts/matte.cjs <project>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Goal: Enter with a confirmed brief, create the HyperFrames project, and make the
|
|||||||
|
|
||||||
Initialize only if `hyperframes.json` is missing. Name `<project>` from the topic in kebab-case, such as `compound-interest-explained`; never use workspace name or timestamp.
|
Initialize only if `hyperframes.json` is missing. Name `<project>` from the topic in kebab-case, such as `compound-interest-explained`; never use workspace name or timestamp.
|
||||||
|
|
||||||
`npx hyperframes init "videos/<project>" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.
|
`npx hyperframes init "videos/<project>" --non-interactive --example=blank --skill=faceless-explainer` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.
|
||||||
|
|
||||||
After init, let `<PROJECT_ROOT>` be `videos/<project>` and run every subsequent relative-path command with that directory as its working directory. In the commands below, `.` means `<PROJECT_ROOT>`; never write `.media`, `capture`, or output files in the caller directory.
|
After init, let `<PROJECT_ROOT>` be `videos/<project>` and run every subsequent relative-path command with that directory as its working directory. In the commands below, `.` means `<PROJECT_ROOT>`; never write `.media`, `capture`, or output files in the caller directory.
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Apply the first matching row; do not evaluate lower state rows:
|
|||||||
For a new project, choose a kebab-case directory name from the brief and scaffold before writing the brief:
|
For a new project, choose a kebab-case directory name from the brief and scaffold before writing the brief:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx hyperframes init "videos/<project>" --non-interactive --example=blank
|
npx hyperframes init "videos/<project>" --non-interactive --example=blank --skill=general-video
|
||||||
```
|
```
|
||||||
|
|
||||||
Then write `BRIEF.md` at the project root using `../hyperframes-core/references/brief-format.md`. In an existing project, the root is the directory containing `hyperframes.json`. Record only the confirmed preference-backed fields named by the brief format, using `node <MEDIA_DIR>/scripts/prefs.mjs record --hyperframes <PROJECT_ROOT>`; never record inferred defaults. Here `<MEDIA_DIR>` is the installed `/media-use` skill directory and `<PROJECT_ROOT>` is the directory containing `hyperframes.json`. If the intent layer adopted a recipe, apply it now with `node <MEDIA_DIR>/scripts/recipe.mjs use --hyperframes <PROJECT_ROOT> --name <name>` and do not ask again.
|
Then write `BRIEF.md` at the project root using `../hyperframes-core/references/brief-format.md`. In an existing project, the root is the directory containing `hyperframes.json`. Record only the confirmed preference-backed fields named by the brief format, using `node <MEDIA_DIR>/scripts/prefs.mjs record --hyperframes <PROJECT_ROOT>`; never record inferred defaults. Here `<MEDIA_DIR>` is the installed `/media-use` skill directory and `<PROJECT_ROOT>` is the directory containing `hyperframes.json`. If the intent layer adopted a recipe, apply it now with `node <MEDIA_DIR>/scripts/recipe.mjs use --hyperframes <PROJECT_ROOT> --name <name>` and do not ask again.
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ Use `selection.target.hfId` when available, otherwise its selector and source fi
|
|||||||
| Self-managed distributed AWS render | `npx hyperframes lambda render <project> --width 1920 --height 1080 --wait` |
|
| Self-managed distributed AWS render | `npx hyperframes lambda render <project> --width 1920 --height 1080 --wait` |
|
||||||
| Self-managed distributed GCP render | `npx hyperframes cloudrun render <project> --width 1920 --height 1080 --wait` |
|
| Self-managed distributed GCP render | `npx hyperframes cloudrun render <project> --width 1920 --height 1080 --wait` |
|
||||||
|
|
||||||
|
Skill attribution is automatic — the examples above need no `--skill`. A project scaffolded by a workflow (`hyperframes init --skill=<workflow>`) records its owning skill in `hyperframes.json`, and every later render inherits it on anonymous telemetry: re-renders, `npm run render`, and `--batch` alike. Pass `--skill=<slug>` explicitly only to stamp a project that was not created through a workflow (its first render then persists it).
|
||||||
|
|
||||||
Use cloud rendering when the user wants hosted rendering without local Chrome, FFmpeg, or AWS. Use Lambda only when AWS ownership is a requirement. Use Cloud Run only when GCP ownership is a requirement. Read the matching reference before running any cloud path.
|
Use cloud rendering when the user wants hosted rendering without local Chrome, FFmpeg, or AWS. Use Lambda only when AWS ownership is a requirement. Use Cloud Run only when GCP ownership is a requirement. Read the matching reference before running any cloud path.
|
||||||
|
|
||||||
After verifying a successful render, send one feedback report unless telemetry is disabled or the user opted out:
|
After verifying a successful render, send one feedback report unless telemetry is disabled or the user opted out:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Templates: `blank`, `warm-grain`, `play-mode`, `swiss-grid`, `vignelli`, `decisi
|
|||||||
Other useful flags:
|
Other useful flags:
|
||||||
|
|
||||||
- `--resolution` — preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k`, `portrait-4k`, `square` (1080×1080), `square-4k`. Aliases: `1080p`, `4k`, `uhd`, `1080p-square`, `4k-square`.
|
- `--resolution` — preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k`, `portrait-4k`, `square` (1080×1080), `square-4k`. Aliases: `1080p`, `4k`, `uhd`, `1080p-square`, `4k-square`.
|
||||||
|
- `--skill=<slug>` — record the owning authoring workflow (e.g. `product-launch-video`) in `hyperframes.json`, so every later render of this project — re-renders, `npm run render`, `--batch` — is attributed to it on anonymous telemetry without re-passing the flag. Creation workflows set this automatically; you rarely pass it by hand.
|
||||||
- `--skip-skills` — **temporarily ignored**: `init` always checks AI coding skills against GitHub while the skills.sh registry catches up. To opt out (CI/tests), set the `HYPERFRAMES_SKIP_SKILLS=1` env var instead.
|
- `--skip-skills` — **temporarily ignored**: `init` always checks AI coding skills against GitHub while the skills.sh registry catches up. To opt out (CI/tests), set the `HYPERFRAMES_SKIP_SKILLS=1` env var instead.
|
||||||
- `--skip-transcribe` — don't auto-transcribe `--audio` / `--video` with Whisper.
|
- `--skip-transcribe` — don't auto-transcribe `--audio` / `--video` with Whisper.
|
||||||
- `--model`, `--language` — Whisper model / language for the auto-transcription.
|
- `--model`, `--language` — Whisper model / language for the auto-transcription.
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ Only when `$PROJECT_DIR/hyperframes.json` is absent:
|
|||||||
```bash
|
```bash
|
||||||
PROJECT_DIR="${MOTION_GRAPHICS_DIR:-videos/<project-name>}"
|
PROJECT_DIR="${MOTION_GRAPHICS_DIR:-videos/<project-name>}"
|
||||||
mkdir -p "$(dirname "$PROJECT_DIR")"
|
mkdir -p "$(dirname "$PROJECT_DIR")"
|
||||||
npx hyperframes init "$PROJECT_DIR" --non-interactive --example=blank
|
npx hyperframes init "$PROJECT_DIR" --non-interactive --example=blank --skill=motion-graphics
|
||||||
```
|
```
|
||||||
|
|
||||||
`init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.
|
`init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ If no offline provider can satisfy the required music capability, surface the bl
|
|||||||
Initialize only if `hyperframes.json` is missing. Name `<project>` from the brief in kebab-case, such as `midnight-drive-loop` — never a timestamp. `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.
|
Initialize only if `hyperframes.json` is missing. Name `<project>` from the brief in kebab-case, such as `midnight-drive-loop` — never a timestamp. `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx hyperframes init "videos/<project>" --non-interactive --example=blank
|
npx hyperframes init "videos/<project>" --non-interactive --example=blank --skill=music-to-video
|
||||||
mkdir -p "$PROJECT_DIR/assets" "$PROJECT_DIR/renders"
|
mkdir -p "$PROJECT_DIR/assets" "$PROJECT_DIR/renders"
|
||||||
cp "<user-music>" "$PROJECT_DIR/assets/bgm.mp3" # extract from a video first if needed
|
cp "<user-music>" "$PROJECT_DIR/assets/bgm.mp3" # extract from a video first if needed
|
||||||
# only if the user gave you images/videos:
|
# only if the user gave you images/videos:
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ The capability preflight runs before fetch, story work, audio, or frame dispatch
|
|||||||
|
|
||||||
Initialize only if `$PROJECT_DIR/hyperframes.json` is missing. Its basename comes from the PR, such as `acme-sdk-pr-1842`; never use the workspace name or a timestamp.
|
Initialize only if `$PROJECT_DIR/hyperframes.json` is missing. Its basename comes from the PR, such as `acme-sdk-pr-1842`; never use the workspace name or a timestamp.
|
||||||
|
|
||||||
`npx hyperframes init "$PROJECT_DIR" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.
|
`npx hyperframes init "$PROJECT_DIR" --non-interactive --example=blank --skill=pr-to-video` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.
|
||||||
|
|
||||||
Every relative-path command below runs with `$PROJECT_DIR` as its working directory. Examples without an explicit subshell mean `(cd "$PROJECT_DIR" && …)`; never change the caller repository's working tree.
|
Every relative-path command below runs with `$PROJECT_DIR` as its working directory. Examples without an explicit subshell mean `(cd "$PROJECT_DIR" && …)`; never change the caller repository's working tree.
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Goal: Enter with a confirmed brief, create the HyperFrames project, and make the
|
|||||||
|
|
||||||
Initialize only if `hyperframes.json` is missing. Name `<project>` from the brand or domain in kebab-case, such as `acme-promo`; never use workspace name or timestamp.
|
Initialize only if `hyperframes.json` is missing. Name `<project>` from the brand or domain in kebab-case, such as `acme-promo`; never use workspace name or timestamp.
|
||||||
|
|
||||||
`npx hyperframes init "videos/<project>" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.
|
`npx hyperframes init "videos/<project>" --non-interactive --example=blank --skill=product-launch-video` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.
|
||||||
|
|
||||||
After init, let `<PROJECT_ROOT>` be `videos/<project>` and run every subsequent relative-path command with that directory as its working directory. In the commands below, `.` means `<PROJECT_ROOT>`; never write `.media`, `capture`, or output files in the caller directory.
|
After init, let `<PROJECT_ROOT>` be `videos/<project>` and run every subsequent relative-path command with that directory as its working directory. In the commands below, `.` means `<PROJECT_ROOT>`; never write `.media`, `capture`, or output files in the caller directory.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user