feat(cli): let projects opt out of automatic proxying (#2591)

* feat(studio-server): serve H.264 proxies from the preview route

Wires the codec manifest and the transcoder into the preview surface: the route
negotiates a proxy via a query param and serves it through the existing range
and ETag machinery, composition HTML carries a codec map for the runtime, and
hostile assets pre-warm so a first play does not wait on a cold transcode.
Exposes the three subpath exports the CLI surfaces consume upstack.

Drops the TEMP fallow entry added with the transcoder: it has real importers now.

* fix(studio-server): publish media proxy exports

* fix(parsers): scan HTML comments linearly

* feat(cli): let projects opt out of automatic proxying

Adds media.autoProxy to hyperframes.json plus --proxy/--no-proxy flags, and
forwards the resolved value into the studio and preview servers and the vite
adapter. Lands before the runtime slice that turns auto-proxying on, so the
switch exists before there is any behavior to switch off.

* fix(cli): align media config schema
This commit is contained in:
Miguel Ángel
2026-07-16 23:01:14 -04:00
committed by GitHub
parent 67eab59f44
commit 6458807066
10 changed files with 292 additions and 5 deletions
+11
View File
@@ -40,6 +40,17 @@
"description": "Where asset files (images, fonts, videos) land. Defaults to `assets`."
}
}
},
"media": {
"type": "object",
"description": "Media handling options.",
"additionalProperties": false,
"properties": {
"autoProxy": {
"type": "boolean",
"description": "Automatically create H.264 proxies for browser-hostile video codecs on supported preview surfaces. Defaults to true."
}
}
}
}
}
+27 -1
View File
@@ -21,6 +21,10 @@ export const examples: Example[] = [
],
["List all active preview servers", "hyperframes preview --list"],
["Kill all active preview servers", "hyperframes preview --kill-all"],
[
"Disable auto-proxying of browser-hostile video codecs (HEVC, ProRes, AV1)",
"hyperframes preview --no-proxy",
],
];
import {
existsSync,
@@ -56,6 +60,8 @@ import {
} from "../server/portUtils.js";
import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js";
import { resolveProject } from "../utils/project.js";
import { resolveAutoProxy } from "../utils/projectConfig.js";
import { studioProxyEnv } from "../utils/studioProxyEnv.js";
import {
readBackgroundPreviewStatus,
startBackgroundPreview,
@@ -72,10 +78,12 @@ interface BrowserLaunchOptions {
interface StudioLaunchOptions extends BrowserLaunchOptions {
projectName?: string;
autoProxy?: boolean;
}
interface EmbeddedStudioOptions extends StudioLaunchOptions {
forceNew?: boolean;
autoProxy?: boolean;
}
type StudioChildProcess = ChildProcessByStdio<null, Readable, Readable>;
@@ -181,6 +189,12 @@ export default defineCommand({
description:
"Launch the opened browser with --disable-gpu (requires --browser-path). For hosts where hardware acceleration crashes the graphics driver (e.g. NVIDIA Xid resets); with the system default browser use --no-open instead.",
},
proxy: {
type: "boolean",
description:
"Auto-transcode browser-hostile video codecs (HEVC, ProRes, AV1) to a cached H.264 proxy for preview (default: on; overrides hyperframes.json's media.autoProxy)",
negativeDescription: "Disable auto-proxying of browser-hostile video codecs",
},
},
async run({ args }) {
const startPort = parseInt(args.port ?? "3002", 10);
@@ -321,6 +335,9 @@ export default defineCommand({
process.exitCode = 1;
return;
}
// Resolve once so embedded, monorepo-dev, and locally installed Studio
// modes all receive identical --proxy/--no-proxy + config semantics.
const autoProxy = resolveAutoProxy(dir, args.proxy as boolean | undefined);
if (isDevMode()) {
if (args.background) {
@@ -335,6 +352,7 @@ export default defineCommand({
userDataDir,
remoteDebuggingPort,
browserNoGpu,
autoProxy,
});
}
@@ -352,6 +370,7 @@ export default defineCommand({
userDataDir,
remoteDebuggingPort,
browserNoGpu,
autoProxy,
});
}
@@ -391,6 +410,7 @@ export default defineCommand({
return runEmbeddedMode(dir, startPort, {
projectName,
forceNew,
autoProxy,
noOpen,
browserPath,
userDataDir,
@@ -924,6 +944,7 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<v
const child = spawn("bun", ["run", "dev"], {
cwd: studioPkgDir,
stdio: ["ignore", "pipe", "pipe"],
env: studioProxyEnv(options?.autoProxy ?? true),
});
attachStudioReadyHandler(child, s, pName, dir, options);
@@ -973,6 +994,7 @@ async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): P
const child = spawn(viteCommand.command, viteCommand.args, {
cwd: studioPkgPath,
stdio: ["ignore", "pipe", "pipe"],
env: studioProxyEnv(options?.autoProxy ?? true),
});
attachStudioReadyHandler(child, s, pName, dir, options);
@@ -1019,7 +1041,11 @@ async function runEmbeddedMode(
return;
}
const { app } = createStudioServer({ projectDir: dir, projectName: pName });
const { app } = createStudioServer({
projectDir: dir,
projectName: pName,
autoProxy: options?.autoProxy,
});
const serverBuildSignature = await loadPreviewServerBuildSignature();
let result: FindPortResult;
+51 -1
View File
@@ -1,9 +1,59 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
import { loadRuntimeSource } from "./runtimeSource.js";
import { createStudioServer, type StudioServer } from "./studioServer.js";
describe("loadRuntimeSource", () => {
it("loads runtime source from the published core entrypoint", async () => {
await expect(loadRuntimeSource()).resolves.toBe(loadHyperframeRuntimeSource());
});
});
describe("createStudioServer autoProxy plumbing", () => {
const dirs: string[] = [];
let server: StudioServer | undefined;
function tmpProject(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-studio-server-test-"));
dirs.push(dir);
return dir;
}
afterEach(() => {
server?.watcher.close();
server = undefined;
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it("hyperframes.json media.autoProxy=false flows through to the adapter", () => {
const projectDir = tmpProject();
writeFileSync(
join(projectDir, "hyperframes.json"),
JSON.stringify({ media: { autoProxy: false } }),
);
server = createStudioServer({ projectDir });
expect(server.adapter.autoProxy).toBe(false);
});
it("defaults the adapter to autoProxy=true when neither option nor config disables it", () => {
server = createStudioServer({ projectDir: tmpProject() });
expect(server.adapter.autoProxy).toBe(true);
});
it("an explicit option (the preview command's resolved --proxy flag) wins over config", () => {
const projectDir = tmpProject();
writeFileSync(
join(projectDir, "hyperframes.json"),
JSON.stringify({ media: { autoProxy: false } }),
);
server = createStudioServer({ projectDir, autoProxy: true });
expect(server.adapter.autoProxy).toBe(true);
});
});
+20 -3
View File
@@ -26,11 +26,12 @@ import {
createBackgroundRemovalJob,
consumeFileWriteReceipt,
getMimeType,
type StudioApiAdapter,
type PreviewApiAdapter,
type ResolvedProject,
type RenderJobState,
type BackgroundRemovalRender,
} from "@hyperframes/studio-server";
import { resolveAutoProxy } from "../utils/projectConfig.js";
import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
import type { ScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
import type { RenderJob } from "@hyperframes/producer";
@@ -229,11 +230,21 @@ export interface StudioServerOptions {
projectDir: string;
/** Display name for the project. Defaults to basename of projectDir. */
projectName?: string;
/**
* Auto-transcode browser-hostile video codecs to a cached H.264 preview
* proxy. The preview command passes its resolved `--proxy`/`--no-proxy` +
* `hyperframes.json` value; when omitted, the project's `media.autoProxy`
* config (default true) applies.
*/
autoProxy?: boolean | undefined;
}
export interface StudioServer {
app: Hono;
watcher: ProjectWatcher;
/** Exposed for tests: the adapter handed to the shared studio API (carries
* the resolved `autoProxy` flag the preview routes read). */
adapter: PreviewApiAdapter;
}
export async function loadPreviewServerBuildSignature(): Promise<string> {
@@ -303,7 +314,13 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
cachedProjectSignature = null;
});
const adapter: StudioApiAdapter = {
const adapter: PreviewApiAdapter = {
// Explicit option wins (preview's resolved --proxy/--no-proxy + config);
// otherwise honor the project's hyperframes.json media.autoProxy so every
// createStudioServer caller (e.g. the background preview child) gets the
// configured behavior without its own plumbing.
autoProxy: options.autoProxy ?? resolveAutoProxy(projectDir, undefined),
listProjects: () => [project],
resolveProject: (id: string) => (id === projectId ? project : null),
@@ -785,5 +802,5 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
return c.html(html);
});
return { app, watcher };
return { app, watcher, adapter };
}
@@ -8,6 +8,7 @@ import {
normalizeConfig,
projectConfigPath,
readProjectConfig,
resolveAutoProxy,
writeProjectConfig,
PROJECT_CONFIG_FILENAME,
} from "./projectConfig.js";
@@ -36,6 +37,7 @@ describe("projectConfig", () => {
$schema: DEFAULT_PROJECT_CONFIG.$schema,
registry: "https://example.com/my-registry",
paths: { blocks: "src/blocks", components: "src/fx", assets: "media" },
media: { autoProxy: true },
};
writeProjectConfig(dir, custom);
const read = readProjectConfig(dir);
@@ -60,6 +62,28 @@ describe("projectConfig", () => {
expect(result.paths.components).toBe(DEFAULT_PROJECT_CONFIG.paths.components);
expect(result.paths.assets).toBe(DEFAULT_PROJECT_CONFIG.paths.assets);
});
it("defaults media.autoProxy to true when media is absent", () => {
const result = normalizeConfig({ registry: "https://alt.example.com" });
expect(result.media).toEqual({ autoProxy: true });
});
it("preserves an explicit media.autoProxy: false", () => {
const result = normalizeConfig({ media: { autoProxy: false } });
expect(result.media).toEqual({ autoProxy: false });
});
it("falls back to the default when media.autoProxy is malformed", () => {
const result = normalizeConfig({
media: { autoProxy: "nope" } as unknown as never,
});
expect(result.media).toEqual({ autoProxy: true });
});
it("falls back to the default when media itself is malformed", () => {
const result = normalizeConfig({ media: "nope" as unknown as never });
expect(result.media).toEqual({ autoProxy: true });
});
});
describe("readProjectConfig", () => {
@@ -123,4 +147,85 @@ describe("projectConfig", () => {
}
});
});
describe("resolveAutoProxy", () => {
it("defaults to true when no config file exists and no flag is passed", () => {
const dir = tmp();
try {
expect(resolveAutoProxy(dir, undefined)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("returns false when the config sets media.autoProxy: false", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ media: { autoProxy: false } }),
"utf-8",
);
expect(resolveAutoProxy(dir, undefined)).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("defaults to true when the config file is partial and omits media", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://only-this.example.com" }),
"utf-8",
);
expect(resolveAutoProxy(dir, undefined)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("an explicit false flag wins over a config that enables it", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ media: { autoProxy: true } }),
"utf-8",
);
expect(resolveAutoProxy(dir, false)).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("an explicit true flag wins over a config that disables it", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ media: { autoProxy: false } }),
"utf-8",
);
expect(resolveAutoProxy(dir, true)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("falls back to the default when the on-disk media value is malformed", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ media: { autoProxy: "nope" } }),
"utf-8",
);
expect(resolveAutoProxy(dir, undefined)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
});
+35
View File
@@ -23,12 +23,23 @@ export interface ProjectConfigPaths {
assets: string;
}
export interface ProjectConfigMedia {
/**
* Auto-transcode browser-hostile video codecs (e.g. HEVC) to a cached
* H.264 proxy for supported preview surfaces. Render always uses the
* original file regardless of this setting. Default true.
*/
autoProxy?: boolean;
}
export interface ProjectConfig {
$schema?: string;
/** Base URL of the registry to pull items from. */
registry: string;
/** Target paths for each item type. */
paths: ProjectConfigPaths;
/** Media handling options (e.g. auto-proxying of browser-hostile codecs). */
media?: ProjectConfigMedia;
}
export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
@@ -39,6 +50,9 @@ export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
components: "compositions/components",
assets: "assets",
},
media: {
autoProxy: true,
},
};
/** Path to the config file for a project rooted at `projectDir`. */
@@ -72,6 +86,12 @@ export function normalizeConfig(partial: Partial<ProjectConfig>): ProjectConfig
components: partial.paths?.components ?? DEFAULT_PROJECT_CONFIG.paths.components,
assets: partial.paths?.assets ?? DEFAULT_PROJECT_CONFIG.paths.assets,
},
media: {
autoProxy:
typeof partial.media?.autoProxy === "boolean"
? partial.media.autoProxy
: DEFAULT_PROJECT_CONFIG.media?.autoProxy,
},
};
}
@@ -92,3 +112,18 @@ export function writeProjectConfig(
export function loadProjectConfig(projectDir: string): ProjectConfig {
return readProjectConfig(projectDir) ?? DEFAULT_PROJECT_CONFIG;
}
/**
* Resolve whether auto-proxying of browser-hostile video codecs (HEVC, etc.)
* is enabled for a project's live-preview surfaces. A caller's explicit
* `--proxy`/`--no-proxy` flag always wins over the project config, in either
* direction. Falls back to the committed `hyperframes.json`
* `media.autoProxy` setting, and finally to `true` when neither is set.
* Render is never affected by this setting: it always uses the original file.
*/
export function resolveAutoProxy(projectDir: string, flagValue: boolean | undefined): boolean {
if (typeof flagValue === "boolean") {
return flagValue;
}
return loadProjectConfig(projectDir).media?.autoProxy ?? true;
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { studioProxyEnv } from "./studioProxyEnv.js";
describe("studioProxyEnv", () => {
it("forwards an explicit --proxy decision to a Studio child process", () => {
expect(studioProxyEnv(true, { KEEP: "yes" })).toEqual({
KEEP: "yes",
HYPERFRAMES_AUTO_PROXY: "true",
});
expect(studioProxyEnv(false, { KEEP: "yes" })).toEqual({
KEEP: "yes",
HYPERFRAMES_AUTO_PROXY: "false",
});
});
});
+9
View File
@@ -0,0 +1,9 @@
export function studioProxyEnv(
autoProxy: boolean,
baseEnv: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
return {
...baseEnv,
HYPERFRAMES_AUTO_PROXY: autoProxy ? "true" : "false",
};
}
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { resolveViteAutoProxy } from "./vite.adapter";
describe("resolveViteAutoProxy", () => {
it("honors the CLI child environment and defaults direct Vite launches on", () => {
expect(resolveViteAutoProxy("true")).toBe(true);
expect(resolveViteAutoProxy("false")).toBe(false);
expect(resolveViteAutoProxy(undefined)).toBe(true);
});
});
+9
View File
@@ -33,6 +33,10 @@ export function isPathWithin(parentDir: string, childPath: string): boolean {
);
}
export function resolveViteAutoProxy(value: string | undefined): boolean {
return value !== "false";
}
export function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter {
let _bundler:
| ((
@@ -99,6 +103,11 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
};
return {
// The CLI resolves --proxy/--no-proxy against hyperframes.json before it
// launches Vite. Direct `bun run dev` keeps the historical default-on
// behavior when the child environment is absent.
autoProxy: resolveViteAutoProxy(process.env.HYPERFRAMES_AUTO_PROXY),
// fallow-ignore-next-line complexity
listProjects() {
if (!existsSync(dataDir)) return [];