Merge origin/main into the audio stack

Twelve of the stack's feature commits landed on main as squashes (#3274
through #3292, plus #3401's canary removal); the 96 review-and-fix commits
that followed them here did not, and main moved 64 commits on in the
meantime. This reconciles the two.

58 files conflicted. 44 were audio-only — main's side there is the
squashed form of commits this branch already carries and has since
superseded, so the branch side stands. The rest needed real work, in both
directions:

**Taken from main, absent here.**
- `ensureAudioGroupInertStyle` (#3278's review). An `<hf-audio-group>` is
  an unknown custom element, so it still takes a flex/grid slot and can
  open a line box — adding a group shifted authored layout. The helper and
  its `init.ts` call never came back to the branch, and this branch is
  what emits the element.
- `#3383`'s ended-audio replay: `canSeekEndedMediaBackward` and its five
  siblings in `media.ts`, with all six tests. Not present here in any
  form.
- `#3380`'s `asetpts=N/SR/TB` between `apad` and `atrim`. Also applied to
  `mixGroupMembers`, the group submix, which is new on this branch and so
  had the same bug in a path main's fix could not reach: delayed members
  padded then amix'd, where a group of four or more silently loses one.
- `#3401`'s `displayNumber` thread. The header derives its row from the
  group-aware order and the undo label from ascending element keys, so
  once a group exists the same click said "Hide track 2" and recorded
  "Hide track 1".
- `#3413`/`#3421`'s viewport handling — the popover's height cap and
  `inset()`, and `resolveFloatingPanelPosition` for the grouping dialog,
  which lives in a track header at the bottom of the window.
- Two extractions this branch had inline and at exactly the 600-line cap:
  `useTimelineDeleteOps` and `editingModeSlice`. Bodies were identical.

**Kept from the branch, against main.** Mute and solo are gone by
deliberate breaking change (`remove mute and solo from tracks and groups`,
`remove the group volume slider and level meter`), so eight files main
still carries are deleted again, `PlayerControls` keeps no
`previewIframeRef` (it existed only to feed `SoloBanner`), the
group-levels branch comes out of main's new `previewMessageRouter`, and
`STRIP_H` goes with the bus strip it sized. Main's
`TimelineTrackPlainHeader.test.tsx` is rewritten against the control that
actually exists — the visibility eye, withheld from an audible audio row
and offered back once hidden, which is the only way out of `data-hidden`.

**Unioned.** `TimelineFxPopover` — main's positioning, this branch's
audition telemetry (`auditionPresetChain`, `storedChain`,
`onAuditionTracked`); `SKILL.md` — main's #3416 "keep the carve group a
voice group" beside this branch's bus section, with the canary paragraph
dropped since the canaries no longer exist.

Every port is mutation-checked. core 2508, studio 4460, lint 528, engine
1630, cli 2813, sdk 549, producer green; tsc, oxlint, oxfmt, fallow and
the 600-line cap clean.
This commit is contained in:
Vance Ingalls
2026-08-23 03:04:47 -07:00
268 changed files with 10008 additions and 2807 deletions
+17 -2
View File
@@ -20,7 +20,22 @@ async function loadPuppeteerBrowsers(): Promise<PuppeteerBrowsers> {
}
}
const CHROME_VERSION = "152.0.7928.2";
// Bumped from 152.0.7928.2 on 2026-08-10 to pick up crbug 522872457's fix
// (CL 8032671, "Force-merge PendingLayer for canvas child descendant", merged
// 2026-07-07 — after the 152.0.7935.0 canary cut, so the old pin predated it).
//
// Measured on the shipping headless-shell binary, drawElementImage vs a CDP
// screenshot of the identical state:
// sibling content dropped from 3D captures — FIXED
// background lost from 3D captures — FIXED
// backface-visibility:hidden ignored — STILL BROKEN (1.4 dB -> 14.8 dB;
// better, still far under the
// 32 dB floor)
// So the 3D compile gate must stay. See PRINFRA-486.
//
// Deliberately Beta, not Canary. 153.0.8000.0 measured identical to this build
// on every probe variant, so crossing a major buys nothing measurable.
const CHROME_VERSION = "152.0.7977.30";
const CACHE_ROOT_DIR = join(homedir(), ".cache", "hyperframes");
const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome");
// Puppeteer's managed cache — where `@puppeteer/browsers install
@@ -332,7 +347,7 @@ async function findFromCache(): Promise<CacheLookupResult> {
// this is the non-`preferManagedChrome` path, which exists so a user who
// installed chrome-headless-shell separately (via `@puppeteer/browsers
// install`) keeps using that binary instead of being silently switched to
// the HF-pinned one. Note `CHROME_VERSION` (above) is a Dev-channel pin
// the HF-pinned one. Note `CHROME_VERSION` (above) is a pre-Stable pin
// that may be NEWER than a user's puppeteer-cache Stable build — this is
// about respecting an explicit prior choice, not "newest wins".
const fromPuppeteer = findFromPuppeteerCache();
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { isWindowsChromeCrashError, windowsChromeCrashRemediation } from "./windowsCrash.js";
describe("isWindowsChromeCrashError", () => {
it("matches Puppeteer launch-failure wrapper + decimal exit code", () => {
expect(
isWindowsChromeCrashError(
"Failed to launch the browser process! Code: 3221225595, with no stderr.",
),
).toBe(true);
});
it("matches Puppeteer launch-failure wrapper + hex exit code", () => {
expect(
isWindowsChromeCrashError("Failed to launch the browser process (exited with 0xC0000409)"),
).toBe(true);
});
it("matches Puppeteer launch-failure wrapper + named Windows status symbol", () => {
expect(
isWindowsChromeCrashError(
"Failed to launch the browser process — STATUS_STACK_BUFFER_OVERRUN",
),
).toBe(true);
});
it("does not match a launch failure without the crash-code signal", () => {
// Linux shared-library launch failures land in the sibling
// chromeLaunchRemediation path, not this one.
expect(
isWindowsChromeCrashError(
"Failed to launch the browser process (libnss3.so: cannot open shared object file)",
),
).toBe(false);
});
it("does not match the crash code alone without the launch-failure wrapper", () => {
expect(isWindowsChromeCrashError("some other Windows process exited with 3221225595")).toBe(
false,
);
});
it("does not match unrelated errors", () => {
expect(isWindowsChromeCrashError("Composition HTML is empty")).toBe(false);
});
});
describe("windowsChromeCrashRemediation", () => {
it("returns undefined off Windows even for a matching error", () => {
if (process.platform === "win32") return;
expect(
windowsChromeCrashRemediation("Failed to launch the browser process! Code: 3221225595"),
).toBeUndefined();
});
it("returns undefined for non-launch errors on any platform", () => {
expect(windowsChromeCrashRemediation("Composition HTML is empty")).toBeUndefined();
});
it("returns undefined for a launch failure without the crash code on any platform", () => {
expect(
windowsChromeCrashRemediation(
"Failed to launch the browser process (libnss3.so cannot open)",
),
).toBeUndefined();
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* Detection + remediation for Windows chrome-headless-shell launch crashes.
*
* Field feedback (#hyperframes-cli-feedback ts=1784116246, win32/x64,
* HyperFrames CLI 0.7.58) hit the exact error
* `Failed to launch the browser process ... Code: 3221225595` with no stderr.
* Exit code 3221225595 == 0xC0000409 == STATUS_STACK_BUFFER_OVERRUN — a Windows
* stack-corruption fatal reported against the pinned chrome-headless-shell
* binary on some Win10/Win11 hosts (typically pre-24H2 or particular AV/EDR
* combinations). The reporter recovered by pointing `HYPERFRAMES_BROWSER_PATH`
* at their system Chrome; the render then used the screenshot fallback and
* produced a complete MP4.
*
* The generic "Try --docker" hint the CLI already emits doesn't name that env
* var, so the workaround is undiscoverable unaided. Sibling failure mode to
* the download-time hint added in #2443 and the closed-with-invite #2078
* (SIGTRAP at launch on macOS arm64); same `HYPERFRAMES_BROWSER_PATH`
* remediation, different trigger + platform.
*
* The match is gated on both the Puppeteer launch-failure wrapper text AND the
* specific crash-code signal (decimal, hex, or symbol name) so unrelated
* Windows launch failures — which need different remediation — don't
* mis-fire this hint.
*/
const STATUS_STACK_BUFFER_OVERRUN_DEC = "3221225595";
const STATUS_STACK_BUFFER_OVERRUN_HEX = /0x[cC]0000409/;
const STATUS_STACK_BUFFER_OVERRUN_NAME = /STATUS_STACK_BUFFER_OVERRUN/i;
export function isWindowsChromeCrashError(errorMessage: string): boolean {
if (!/Failed to launch the browser process/i.test(errorMessage)) return false;
return (
errorMessage.includes(STATUS_STACK_BUFFER_OVERRUN_DEC) ||
STATUS_STACK_BUFFER_OVERRUN_HEX.test(errorMessage) ||
STATUS_STACK_BUFFER_OVERRUN_NAME.test(errorMessage)
);
}
export function windowsChromeCrashRemediation(errorMessage: string): string | undefined {
if (process.platform !== "win32") return undefined;
if (!isWindowsChromeCrashError(errorMessage)) return undefined;
return [
"chrome-headless-shell crashed at launch (Windows STATUS_STACK_BUFFER_OVERRUN, exit 0xC0000409 / 3221225595).",
"The pinned Chromium build is not stable on this Windows host; point hyperframes at your installed Chrome instead:",
"",
' set HYPERFRAMES_BROWSER_PATH="C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"',
"",
"Then re-run your command. Any Chrome build works for the screenshot capture path; install a real chrome-headless-shell later if you need the perf-optimized BeginFrame path.",
].join("\n");
}
+51
View File
@@ -165,6 +165,57 @@ describe("CLI lifecycle", () => {
}
});
it("does not let a post-validation throw doom a render whose artifact is on disk", async () => {
// The gap the field reports land in: a teardown step throws AFTER the
// artifact validated, the command wrapper catches it, and the result
// carries exitCode 1. The uncaughtException / unhandledRejection handlers
// both consult isRenderSucceeded(), but a caught throw never reaches them,
// so nothing sanitized the code and a valid MP4 was reported as a failure.
const trackCommandResult = vi.fn();
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
try {
const successState = await import("./utils/render-success-state.js");
mockInitCommand(() => {
successState.markRenderSucceeded();
throw new Error("post-render teardown blew up");
});
mockTelemetry({ trackCommandResult });
process.argv = ["node", "cli.ts", "init", "--json"];
await import("./cli.js");
expect(process.exitCode).toBe(0);
expect(trackCommandResult).toHaveBeenCalledWith(
expect.objectContaining({ success: true, exitCode: 0 }),
);
successState._resetRenderSuccessForTests();
} finally {
exitSpy.mockRestore();
}
});
it("still exits non-zero when a command throws and no render ever validated", async () => {
// The guard on the sanitizer above: it must key off a validated artifact,
// not merely off the command having finished. Without this, every caught
// throw would silently become exit 0.
const trackCommandResult = vi.fn();
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
try {
mockInitCommand(() => {
throw new Error("genuine failure");
});
mockTelemetry({ trackCommandResult });
process.argv = ["node", "cli.ts", "init", "--json"];
await import("./cli.js");
expect(process.exitCode).not.toBe(0);
expect(trackCommandResult).toHaveBeenCalledWith(expect.objectContaining({ success: false }));
} finally {
exitSpy.mockRestore();
}
});
it("still scores an EPIPE before the artifact is validated as a failure", async () => {
const trackCommandResult = vi.fn();
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
+13 -4
View File
@@ -283,12 +283,21 @@ let finalized = false;
async function finalizeCli(result: CommandResult): Promise<void> {
if (finalized) return;
finalized = true;
commandFailed ||= result.exitCode !== 0;
// Once the artifact has validated and been committed to disk, the run
// delivered — anything recorded as a failure after that is teardown noise.
// The uncaughtException / unhandledRejection handlers already consult
// isRenderSucceeded(), but a post-render throw that the command wrapper
// CATCHES never reaches them: it becomes an ordinary non-zero
// CommandResult, and a valid render is reported as a failure. Sanitizing
// here, once, is what those handlers cannot cover, and it keeps the exit
// code and the telemetry record from disagreeing about the same run.
const exitCode = isRenderSucceeded() ? 0 : result.exitCode;
commandFailed ||= exitCode !== 0;
await telemetryReady.catch(() => {});
_trackCommandResult?.({
command,
success: result.exitCode === 0 && commandSucceededForTelemetry(),
exitCode: result.exitCode,
success: exitCode === 0 && commandSucceededForTelemetry(),
exitCode,
durationMs: Date.now() - commandStart,
runId,
});
@@ -298,7 +307,7 @@ async function finalizeCli(result: CommandResult): Promise<void> {
_printStalePinNotice?.();
_printSkillsUpdateNotice?.();
}
process.exitCode = result.exitCode;
process.exitCode = exitCode;
}
registerRootExitRequester((exitCode) => {
+67 -2
View File
@@ -1,6 +1,16 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { parseUpdateTarget } from "./publish.js";
const publishState = vi.hoisted(() => ({ publish: vi.fn() }));
vi.mock("../utils/publishProject.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils/publishProject.js")>()),
publishProjectArchive: publishState.publish,
}));
import publishCommand, { parseUpdateTarget } from "./publish.js";
describe("parseUpdateTarget", () => {
it("extracts the id from a full published URL", () => {
@@ -25,3 +35,58 @@ describe("parseUpdateTarget", () => {
expect(parseUpdateTarget("https://example.com/foo/hfp_abc123")).toBe("hfp_abc123");
});
});
describe("publish default-entry preflight", () => {
async function runEntryMismatch(candidate: string): Promise<string> {
const project = mkdtempSync(join(tmpdir(), "hf-publish-entry-mismatch-"));
const candidatePath = join(project, candidate);
mkdirSync(dirname(candidatePath), { recursive: true });
writeFileSync(
join(project, "index.html"),
`<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div></body></html>`,
);
writeFileSync(
candidatePath,
`<html><body><div data-composition-id="authored" data-width="1920" data-height="1080" data-start="0" data-duration="5"><div class="clip" data-start="0" data-duration="5">Visible</div></div></body></html>`,
);
publishState.publish.mockReset();
publishState.publish.mockResolvedValue({
title: "test",
fileCount: 2,
claimed: true,
projectId: "project-id",
url: "https://hyperframes.dev/p/project-id",
claimToken: "",
});
const lines: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => {
lines.push(parts.map(String).join(" "));
});
try {
await expect(
publishCommand.run?.({ args: { dir: project, yes: true, proxy: false } } as never),
).rejects.toMatchObject({ name: "CliRuntimeError" });
expect(publishState.publish).not.toHaveBeenCalled();
return lines.join("\n");
} finally {
log.mockRestore();
rmSync(project, { recursive: true, force: true });
}
}
it("suggests a nested index.html directory with the re-rooting caveat", async () => {
const output = await runEntryMismatch("compositions/brand/index.html");
expect(output).toContain("hyperframes publish <project>/compositions/brand");
expect(output).toContain("assets are self-contained under that directory");
});
it("does not suggest a directory for a standalone file that is not index.html", async () => {
const output = await runEntryMismatch("compositions/card.html");
expect(output).toContain("compositions/card.html");
expect(output).not.toContain("hyperframes publish <project>/compositions");
expect(output).toContain("publish accepts project directories, not individual HTML files");
});
});
+28 -3
View File
@@ -1,12 +1,16 @@
import { join, relative, resolve } from "node:path";
import { setCommandExitCode } from "../utils/commandResult.js";
import { join, posix, relative, resolve } from "node:path";
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
import { existsSync } from "node:fs";
import { defineCommand } from "citty";
import * as clack from "@clack/prompts";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { lintProject } from "../utils/lintProject.js";
import {
definitiveEntryMismatchComposition,
hasDefinitiveEntryMismatch,
lintProject,
} from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
import {
buildPublishFileMap,
@@ -92,6 +96,27 @@ export default defineCommand({
for (const line of formatLintFindings(lintResult)) console.log(line);
console.log();
}
if (hasDefinitiveEntryMismatch(lintResult)) {
const candidate = definitiveEntryMismatchComposition(lintResult);
console.log(c.error(" Aborting publish because the default index.html entry is blank."));
if (candidate && posix.basename(candidate) === "index.html") {
const candidateDir = posix.dirname(candidate);
const target = `<project>/${candidateDir}`;
console.log(
c.dim(
` Move or mount the authored file, or publish its directory directly: hyperframes publish ${target}. Only use the directory form when its assets are self-contained under that directory; otherwise mount it from the project root.`,
),
);
} else if (candidate) {
console.log(
c.dim(
` Move or mount ${candidate} as a project index.html before publishing; publish accepts project directories, not individual HTML files.`,
),
);
}
console.log();
failCommand();
}
}
if (args.yes !== true) {
+42
View File
@@ -222,6 +222,7 @@ describe("renderLocal browser GPU config", () => {
renderLocal,
resolveBrowserGpuForCli,
renderLintContinuationHint,
runRenderLint,
__resetDeParallelRouterTrialStateForTests: resetTrialState,
} = renderModule;
@@ -234,6 +235,47 @@ describe("renderLocal browser GPU config", () => {
expect(renderLintContinuationHint(false)).toContain("Use --strict to block errors");
});
it("aborts the real render lint preflight on a default-entry mismatch without --strict", async () => {
const lintResult = {
results: [
{
file: "index.html",
contentHash: "abc",
result: {
ok: false,
errorCount: 1,
warningCount: 0,
infoCount: 0,
findings: [
{
code: "blank_root_with_standalone_composition",
severity: "error" as const,
message: "wrong entry",
},
],
},
},
],
totalErrors: 1,
totalWarnings: 0,
totalInfos: 0,
};
await expect(
runRenderLint(
{
project: { dir: "/tmp/project" },
entryFile: undefined,
renderTarget: "/tmp/project/index.html",
strictErrors: false,
strictAll: false,
effectiveQuiet: true,
} as never,
async () => lintResult,
),
).rejects.toMatchObject({ name: "CliRuntimeError" });
});
function setEnv(key: string, value: string) {
if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]);
process.env[key] = value;
+21 -3
View File
@@ -5,9 +5,9 @@ import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync
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";
import { executeRenderPlan, renderLintContinuationHint, runRenderLint } from "./render/execute.js";
// Test-only seams retained at the command boundary for render behavior tests.
export { resolveBrowserGpuForCli, renderLintContinuationHint };
export { resolveBrowserGpuForCli, renderLintContinuationHint, runRenderLint };
export const examples: Example[] = [
["Render to MP4", "hyperframes render --output output.mp4"],
@@ -79,6 +79,7 @@ import { runEnvironmentChecks } from "../browser/preflight.js";
import { detectH264EncoderMode } from "../browser/ffmpeg.js";
import { chromeLaunchRemediation } from "../browser/linuxDeps.js";
import { macosOldChromeCrashRemediation } from "../browser/macosOldChromeCrash.js";
import { windowsChromeCrashRemediation } from "../browser/windowsCrash.js";
import { killOrphanedProcesses } from "../utils/orphanCleanup.js";
import {
markRenderSucceeded,
@@ -901,12 +902,15 @@ export async function renderLocal(
await producer.executeRenderJob(job, projectDir, outputPath, onProgress);
} catch (error: unknown) {
maybeConsumeDeParallelRouterTrial(deParallelRouterActive, job, options.quiet);
// The render container sets `ENV CONTAINER=true`; suggesting `--docker`
// from inside it is a misdirection (heygen-com/hyperframes#3370).
const inContainer = process.env.CONTAINER === "true";
handleRenderError(
error,
options,
startTime,
false,
"Try --docker for containerized rendering",
inContainer ? "" : "Try --docker for containerized rendering",
job.failedStage,
job,
);
@@ -1433,6 +1437,20 @@ function handleRenderError(
errorBox("Render failed — Chrome could not launch", message, macosRemediation);
failCommand();
}
// Windows chrome-headless-shell can crash at launch with
// STATUS_STACK_BUFFER_OVERRUN (exit 0xC0000409 / 3221225595). Same
// HYPERFRAMES_BROWSER_PATH remediation as the download-time hint (#2443)
// and the closed-with-invite arm64 macOS sibling (#2078). Field feedback
// ts=1784116246.
const windowsRemediation = windowsChromeCrashRemediation(message);
if (windowsRemediation) {
errorBox(
"Render failed — chrome-headless-shell crashed at launch",
message,
windowsRemediation,
);
failCommand();
}
errorBox("Render failed", message, hint);
failCommand();
}
+33 -15
View File
@@ -3,7 +3,12 @@ import type { CanvasResolution, OutputResolutionIssueKind } from "@hyperframes/c
import { c } from "../../ui/colors.js";
import { errorBox, formatBytes } from "../../ui/format.js";
import { formatLintFindings } from "../../utils/lintFormat.js";
import { lintProject, shouldBlockRender } from "../../utils/lintProject.js";
import {
hasDefinitiveEntryMismatch,
lintProject,
shouldBlockRender,
type ProjectLintResult,
} from "../../utils/lintProject.js";
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
import { failCommand, setCommandExitCode } from "../../utils/commandResult.js";
import {
@@ -40,6 +45,17 @@ export function renderLintContinuationHint(strictErrors: boolean): string {
: " Continuing render despite lint issues. Use --strict to block errors.";
}
function renderLintShouldAbort(
strictErrors: boolean,
strictAll: boolean,
lintResult: ProjectLintResult,
): boolean {
return (
hasDefinitiveEntryMismatch(lintResult) ||
shouldBlockRender(strictErrors, strictAll, lintResult.totalErrors, lintResult.totalWarnings)
);
}
/** Execute a validated plan. Output and process lifecycle stay outside parsing. */
export async function executeRenderPlan(
plan: RenderPlan,
@@ -158,22 +174,19 @@ async function ensureRenderBrowser(plan: RenderPlan): Promise<string> {
}
// fallow-ignore-next-line complexity
async function runRenderLint(plan: RenderPlan): Promise<void> {
export async function runRenderLint(
plan: RenderPlan,
runLint: (projectDir: string, entryFile?: string) => Promise<ProjectLintResult> = lintProject,
): Promise<void> {
// lintProject's explicit-entry contract is an absolute source path;
// entryFile remains project-relative for the producer.
const explicitEntry = plan.entryFile ? plan.renderTarget : undefined;
const lintResult = await lintProject(plan.project.dir, explicitEntry);
const lintResult = await runLint(plan.project.dir, explicitEntry);
if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) return;
presentRenderLintFindings(lintResult, plan.effectiveQuiet);
if (
shouldBlockRender(
plan.strictErrors,
plan.strictAll,
lintResult.totalErrors,
lintResult.totalWarnings,
)
) {
presentRenderLintAbort(plan);
const definitiveEntryMismatch = hasDefinitiveEntryMismatch(lintResult);
if (renderLintShouldAbort(plan.strictErrors, plan.strictAll, lintResult)) {
presentRenderLintAbort(plan, definitiveEntryMismatch);
failCommand();
}
presentRenderLintContinuation(plan);
@@ -188,11 +201,16 @@ function presentRenderLintFindings(
for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line);
}
function presentRenderLintAbort(plan: RenderPlan): void {
function presentRenderLintAbort(plan: RenderPlan, definitiveEntryMismatch: boolean): void {
if (plan.effectiveQuiet) return;
const mode = plan.strictAll ? "--strict-all" : "--strict";
console.log("");
console.log(c.error(` Aborting render due to lint issues (${mode} mode).`));
console.log(
c.error(
definitiveEntryMismatch
? " Aborting render because the default index.html entry is blank."
: ` Aborting render due to lint issues (${plan.strictAll ? "--strict-all" : "--strict"} mode).`,
),
);
console.log("");
}
+74 -3
View File
@@ -1,6 +1,28 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import {
import { describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
const snapshotState = vi.hoisted(() => ({
openSettledPage: vi.fn(async () => {
throw new Error("browser capture reached");
}),
closeServer: vi.fn(async () => undefined),
}));
vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../capture/captureCompositionFrame.js")>()),
openSettledCompositionPage: snapshotState.openSettledPage,
}));
vi.mock("../utils/staticProjectServer.js", () => ({
serveStaticProjectHtml: vi.fn(async () => ({
url: "http://127.0.0.1:1",
close: snapshotState.closeServer,
})),
}));
import snapshotCommand, {
computeSnapshotTimes,
formatSnapshotTimestamp,
parseZoomScale,
@@ -62,6 +84,55 @@ describe("transparent snapshot capture", () => {
});
});
describe("snapshot lint preflight", () => {
async function runEntryMismatch(candidate: string): Promise<string> {
const project = mkdtempSync(join(tmpdir(), "hf-snapshot-entry-mismatch-"));
const candidatePath = join(project, candidate);
mkdirSync(dirname(candidatePath), { recursive: true });
writeFileSync(
join(project, "index.html"),
`<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div></body></html>`,
);
writeFileSync(
candidatePath,
`<html><body><div data-composition-id="authored" data-width="1920" data-height="1080" data-start="0" data-duration="5"><div class="clip" data-start="0" data-duration="5">Visible</div></div></body></html>`,
);
snapshotState.openSettledPage.mockClear();
const lines: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => {
lines.push(parts.map(String).join(" "));
});
try {
await expect(
snapshotCommand.run?.({ args: { dir: project } } as never),
).rejects.toMatchObject({
name: "CliRuntimeError",
});
expect(snapshotState.openSettledPage).not.toHaveBeenCalled();
return lines.join("\n");
} finally {
log.mockRestore();
rmSync(project, { recursive: true, force: true });
}
}
it("does not suggest a directory for a standalone file that is not index.html", async () => {
const output = await runEntryMismatch("compositions/card.html");
expect(output).toContain("compositions/card.html");
expect(output).not.toContain("hyperframes snapshot <project>/compositions");
expect(output).toContain("snapshot accepts project directories, not individual HTML files");
});
it("suggests the reported index.html directory with the re-rooting caveat", async () => {
const output = await runEntryMismatch("compositions/index.html");
expect(output).toContain("hyperframes snapshot <project>/compositions");
expect(output).toContain("assets are self-contained under that directory");
});
});
describe("resolveSnapshotVideoFrameTime", () => {
it("keeps media active at the inclusive clip end and samples its last decodable frame", () => {
expect(
+34 -1
View File
@@ -3,7 +3,7 @@ import { failCommand } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve, join, relative, isAbsolute, basename } from "node:path";
import { resolve, join, relative, isAbsolute, basename, posix } from "node:path";
import {
DEFAULT_ZOOM_SCALE,
captureRegionCrop,
@@ -15,6 +15,12 @@ import {
type ZoomTarget,
} from "../capture/captureCompositionFrame.js";
import { resolveProject } from "../utils/project.js";
import {
definitiveEntryMismatchComposition,
hasDefinitiveEntryMismatch,
lintProject,
} from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { c } from "../ui/colors.js";
@@ -650,6 +656,33 @@ export default defineCommand({
},
async run({ args }) {
const project = resolveProject(args.dir);
const lintResult = await lintProject(project.dir);
if (hasDefinitiveEntryMismatch(lintResult)) {
const candidate = definitiveEntryMismatchComposition(lintResult);
console.log("");
for (const line of formatLintFindings(lintResult, { errorsFirst: true })) {
console.log(line);
}
console.log("");
console.log(c.error(" Aborting snapshot because the default index.html entry is blank."));
if (candidate && posix.basename(candidate) === "index.html") {
const candidateDir = posix.dirname(candidate);
const target = `<project>/${candidateDir}`;
console.log(
c.dim(
` Move or mount the authored file, or snapshot its directory directly: hyperframes snapshot ${target}. Only use the directory form when its assets are self-contained under that directory; otherwise mount it from the project root.`,
),
);
} else if (candidate) {
console.log(
c.dim(
` Move or mount ${candidate} as a project index.html before snapshotting; snapshot accepts project directories, not individual HTML files.`,
),
);
}
console.log("");
failCommand();
}
const frames = parseInt(args.frames as string, 10) || 5;
const timeout = parseInt(args.timeout as string, 10) || 5000;
const atTimestamps = args.at
@@ -8,7 +8,15 @@ import { describe, expect, it } from "vitest";
const blocksDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../registry/blocks");
interface RegistryManifest {
files: Array<{ path: string; type: string }>;
name: string;
tags?: string[];
files: Array<{ path: string; target: string; type: string }>;
}
const promotedTemplateTag = "ad-template";
function loadRegistryManifest(itemDir: string): RegistryManifest {
return JSON.parse(readFileSync(join(itemDir, "registry-item.json"), "utf8")) as RegistryManifest;
}
function findMissingLocalScripts(itemDir: string, manifest: RegistryManifest): string[] {
@@ -32,6 +40,37 @@ function findMissingLocalScripts(itemDir: string, manifest: RegistryManifest): s
}
describe("registry blocks", () => {
it("ships an editing contract and declared variables for every promoted template", () => {
const promotedManifests = readdirSync(blocksDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => ({
itemDir: join(blocksDir, entry.name),
manifest: loadRegistryManifest(join(blocksDir, entry.name)),
}))
.filter(({ manifest }) => manifest.tags?.includes(promotedTemplateTag));
expect(promotedManifests.length).toBeGreaterThan(0);
for (const { itemDir, manifest } of promotedManifests) {
const templateId = manifest.name;
const contractFiles = manifest.files.filter(
(file) =>
file.path === "TEMPLATE.md" &&
file.target === "TEMPLATE.md" &&
file.type === "hyperframes:asset",
);
const composition = manifest.files.find((file) => file.type === "hyperframes:composition");
expect(contractFiles, templateId).toHaveLength(1);
expect(composition, templateId).toBeDefined();
const html = readFileSync(join(itemDir, composition?.path ?? ""), "utf8");
const { document } = parseHTML(html);
const declarations = JSON.parse(
document.documentElement.getAttribute("data-composition-variables") ?? "[]",
) as unknown[];
expect(declarations.length, templateId).toBeGreaterThan(0);
}
});
it("installs every local script referenced by a block composition", () => {
const missing: string[] = [];
@@ -39,9 +78,7 @@ describe("registry blocks", () => {
if (!entry.isDirectory()) continue;
const itemDir = join(blocksDir, entry.name);
const manifest = JSON.parse(
readFileSync(join(itemDir, "registry-item.json"), "utf8"),
) as RegistryManifest;
const manifest = loadRegistryManifest(itemDir);
for (const src of findMissingLocalScripts(itemDir, manifest)) {
missing.push(`${entry.name}: ${src}`);
+13 -1
View File
@@ -1,4 +1,6 @@
import { watch, type FSWatcher } from "node:fs";
import { join } from "node:path";
import { affectsProjectSignature } from "@hyperframes/studio-server";
export type FileChangeListener = (relativePath: string) => void;
@@ -42,7 +44,17 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
watcher = watch(projectDir, { recursive: true }, (_event, filename) => {
if (!filename) return;
const relativePath = filename.toString();
if (!shouldWatchProjectFile(relativePath)) return;
// The reload filter excludes all of `.hyperframes/`, but two files in
// there feed the preview signature and Studio writes one of them at
// runtime — dropping those at ingest left the CLI server's ETag stale
// until restart. Admit them here and let the reload listener re-apply
// its own filter, so what triggers a browser reload is unchanged.
if (
!shouldWatchProjectFile(relativePath) &&
!affectsProjectSignature(projectDir, join(projectDir, relativePath))
) {
return;
}
pendingPaths.add(relativePath);
if (debounceTimer) clearTimeout(debounceTimer);
+31 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
@@ -84,6 +84,36 @@ describe("createStudioServer autoProxy plumbing", () => {
});
});
describe("Studio project lint endpoint", () => {
it("surfaces findings that require the complete project graph", async () => {
const projectDir = tmpProject();
mkdirSync(join(projectDir, "compositions"));
mkdirSync(join(projectDir, "scenes"));
writeFileSync(
join(projectDir, "index.html"),
`<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div></body></html>`,
);
writeFileSync(
join(projectDir, "compositions", "index.html"),
`<html><body><div data-composition-id="authored" data-width="1920" data-height="1080" data-start="0" data-duration="5"><div class="clip" data-start="0" data-duration="5">Visible</div></div></body></html>`,
);
writeFileSync(join(projectDir, "scenes", "intro.html"), "<html><body>Intro</body></html>");
server = createStudioServer({ projectDir, projectName: "demo" });
const response = await server.app.request("http://localhost/api/projects/demo/lint");
const payload = (await response.json()) as {
findings?: Array<{ code?: string; file?: string }>;
};
expect(response.status).toBe(200);
expect(payload.findings).toContainEqual(
expect.objectContaining({ code: "blank_root_with_standalone_composition" }),
);
expect(payload.findings).toContainEqual(expect.objectContaining({ file: "scenes/intro.html" }));
expect(payload.findings?.every((finding) => !finding.file?.startsWith(projectDir))).toBe(true);
});
});
describe("host guarding on identity-bearing responses", () => {
// NOTE: the SPA-injection branch itself is covered in telemetryIdentity.test.ts
// via buildStudioHeadScriptsForHost. It cannot be asserted here: this route
+20 -4
View File
@@ -9,7 +9,11 @@ import { Hono, type Context } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, writeFileSync, statSync, unlinkSync } from "node:fs";
import { resolve, join, basename } from "node:path";
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
import {
createProjectWatcher,
shouldWatchProjectFile,
type ProjectWatcher,
} from "./fileWatcher.js";
import {
hashSignatureParts,
loadRuntimeSource,
@@ -32,6 +36,7 @@ import {
consumeFileWriteReceipt,
fileContentVersion,
getMimeType,
affectsProjectSignature,
type PreviewApiAdapter,
thumbnailDeviceScaleFactor,
type ResolvedProject,
@@ -370,8 +375,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId };
let cachedProjectSignature: string | null = null;
watcher.addListener(() => {
cachedProjectSignature = null;
watcher.addListener((changedPath) => {
if (affectsProjectSignature(projectDir, join(projectDir, changedPath))) {
cachedProjectSignature = null;
}
});
const adapter: PreviewApiAdapter = {
@@ -442,6 +449,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
return await lintHyperframeHtml(html, opts);
},
async lintProject(dir: string) {
const { lintProject } = await import("@hyperframes/lint");
return await lintProject(dir);
},
runtimeUrl: "/api/runtime.js",
rendersDir: () => join(projectDir, "renders"),
@@ -776,7 +788,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
.writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) })
.catch(() => {});
};
watcher.addListener(listener);
// Re-applied here because the watcher now also emits the signature
// manifest files, which must not trigger a browser reload.
watcher.addListener((changedPath) => {
if (shouldWatchProjectFile(changedPath)) listener(changedPath);
});
while (true) {
await stream.sleep(30000);
}
+46 -1
View File
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { HyperframeLintFinding } from "@hyperframes/core/lint";
import { lintProject, shouldBlockRender } from "./lintProject.js";
import { hasDefinitiveEntryMismatch, lintProject, shouldBlockRender } from "./lintProject.js";
function tmpProject(name: string): string {
return mkdtempSync(join(tmpdir(), `hf-test-${name}-`));
@@ -249,6 +249,51 @@ describe("lintProject", () => {
});
});
describe("hasDefinitiveEntryMismatch", () => {
it("distinguishes the blank-default-entry failure from ordinary lint errors", () => {
const result = {
results: [
{
file: "index.html",
contentHash: "abc",
result: {
ok: false,
errorCount: 1,
warningCount: 0,
infoCount: 0,
findings: [
{
code: "blank_root_with_standalone_composition",
severity: "error" as const,
message: "wrong entry",
},
],
},
},
],
totalErrors: 1,
totalWarnings: 0,
totalInfos: 0,
};
expect(hasDefinitiveEntryMismatch(result)).toBe(true);
expect(
hasDefinitiveEntryMismatch({
...result,
results: [
{
...result.results[0]!,
result: {
...result.results[0]!.result,
findings: [{ code: "media_missing_id", severity: "error", message: "missing" }],
},
},
],
}),
).toBe(false);
});
});
function validHtmlWithAudio(compId = "main"): string {
return `<html><body>
<div data-composition-id="${compId}" data-width="1920" data-height="1080">
+21 -1
View File
@@ -1,3 +1,23 @@
// ponytail: thin re-export — lintProject lives in @hyperframes/lint so it's usable without the CLI
// CLI facade: the linter stays reusable without the CLI, while command-specific gates live here.
export { lintProject, shouldBlockRender } from "@hyperframes/lint";
export type { ProjectLintResult } from "@hyperframes/lint";
import type { ProjectLintResult } from "@hyperframes/lint";
export function hasDefinitiveEntryMismatch(result: ProjectLintResult): boolean {
return result.results.some((entry) =>
entry.result.findings.some(
(finding) => finding.code === "blank_root_with_standalone_composition",
),
);
}
export function definitiveEntryMismatchComposition(result: ProjectLintResult): string | undefined {
for (const entry of result.results) {
const finding = entry.result.findings.find(
(candidate) => candidate.code === "blank_root_with_standalone_composition",
);
if (finding) return finding.suggestedComposition;
}
return undefined;
}