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:
WaterrrForever
2026-07-28 19:27:09 +08:00
committed by GitHub
parent 2bb6205177
commit d287e5244c
18 changed files with 298 additions and 23 deletions
+5
View File
@@ -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."
} }
} }
} }
+18 -1
View File
@@ -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}`));
+11 -4
View File
@@ -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");
});
}); });
+8 -2
View File
@@ -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 });
}
});
});
}); });
+83
View File
@@ -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.
}
}
+8 -8
View File
@@ -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": {
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+2
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.