Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/schema/hyperframes.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@
"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."
}
}
}
19 changes: 18 additions & 1 deletion packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,7 @@ async function scaffoldProject(
durationSeconds?: number,
tailwind = false,
resolution?: CanvasResolution,
authoringSkill?: string,
): Promise<void> {
mkdirSync(destDir, { recursive: true });

Expand Down Expand Up @@ -588,10 +589,17 @@ async function scaffoldProject(

// Write hyperframes.json so `hyperframes add` knows which registry to use
// 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"))) {
const { writeProjectConfig, DEFAULT_PROJECT_CONFIG } =
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);
Expand Down Expand Up @@ -728,6 +736,13 @@ export default defineCommand({
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).",
},
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 }) {
if (args.template !== undefined) {
Expand Down Expand Up @@ -898,6 +913,7 @@ export default defineCommand({
videoDuration,
tailwind,
resolutionPreset,
args.skill,
);
} catch (err) {
console.error(
Expand Down Expand Up @@ -1112,6 +1128,7 @@ export default defineCommand({
videoDuration,
tailwind,
resolutionPreset,
args.skill,
);
if (!isBundled) {
spin.stop(c.success(`Downloaded ${templateId}`));
Expand Down
15 changes: 11 additions & 4 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { failCommand, requestCliExit } from "../utils/commandResult.js";
import { defineCommand } from "citty";
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 { seedProjectAuthoringSkill } from "../utils/projectConfig.js";
import { presentRenderPlan } from "./render/present.js";
import { executeRenderPlan, renderLintContinuationHint } from "./render/execute.js";
// Test-only seams retained at the command boundary for render behavior tests.
Expand Down Expand Up @@ -351,6 +352,9 @@ export default defineCommand({
// Keep the transport adapter thin: each phase has one ownership boundary.
async run({ 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 executeRenderPlan(plan, {
renderDocker,
Expand Down Expand Up @@ -558,9 +562,12 @@ function ensureDockerImage(version: string, platform: string, quiet: boolean): s

const dockerfilePath = resolveDockerfilePath();

// Copy Dockerfile to a temp build context so docker build has a clean context
const tmpDir = join(tmpdir(), `hyperframes-docker-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
// Copy Dockerfile to a temp build context so docker build has a clean context.
// mkdtempSync (not a `Date.now()`-derived name) so the path is unpredictable
// 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));

// Platform is now derived from the host arch (see resolveDockerPlatform).
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/render/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,22 @@ describe("createRenderPlan", () => {
const plan = createRenderPlan({ dir: projectDir, "frames-cache-dir": "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");
});
});
10 changes: 8 additions & 2 deletions packages/cli/src/commands/render/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
resolveDefaultFpsArg,
} from "../../utils/renderArgs.js";
import { normalizeSkillSlug } from "../../telemetry/skill.js";
import { loadProjectConfig } from "../../utils/projectConfig.js";

const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const;
Expand Down Expand Up @@ -192,9 +193,14 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
}
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 =
typeof args.skill === "string" && args.skill.trim() !== "" && !authoringSkill
typeof args.skill === "string" && args.skill.trim() !== "" && !flagSkill
? args.skill
: undefined;

Expand Down
136 changes: 136 additions & 0 deletions packages/cli/src/utils/projectConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
projectConfigPath,
readProjectConfig,
resolveAutoProxy,
seedProjectAuthoringSkill,
writeProjectConfig,
PROJECT_CONFIG_FILENAME,
} from "./projectConfig.js";
Expand Down Expand Up @@ -84,6 +85,18 @@ describe("projectConfig", () => {
const result = normalizeConfig({ media: "nope" as unknown as never });
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", () => {
Expand Down Expand Up @@ -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 });
}
});
});
});
Loading
Loading