From 6458807066bd4e0c1a6f573423879e97b91ad1c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 16 Jul 2026 23:01:14 -0400 Subject: [PATCH] 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 --- docs/schema/hyperframes.json | 11 ++ packages/cli/src/commands/preview.ts | 28 ++++- packages/cli/src/server/studioServer.test.ts | 52 ++++++++- packages/cli/src/server/studioServer.ts | 23 +++- packages/cli/src/utils/projectConfig.test.ts | 105 ++++++++++++++++++ packages/cli/src/utils/projectConfig.ts | 35 ++++++ packages/cli/src/utils/studioProxyEnv.test.ts | 15 +++ packages/cli/src/utils/studioProxyEnv.ts | 9 ++ packages/studio/vite.adapter.proxy.test.ts | 10 ++ packages/studio/vite.adapter.ts | 9 ++ 10 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/utils/studioProxyEnv.test.ts create mode 100644 packages/cli/src/utils/studioProxyEnv.ts create mode 100644 packages/studio/vite.adapter.proxy.test.ts diff --git a/docs/schema/hyperframes.json b/docs/schema/hyperframes.json index f75be0264..70475a8a1 100644 --- a/docs/schema/hyperframes.json +++ b/docs/schema/hyperframes.json @@ -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." + } + } } } } diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 9879f0c51..43140cdc0 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -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; @@ -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 { 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); + }); +}); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 57a4a444b..358189ad6 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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 { @@ -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 }; } diff --git a/packages/cli/src/utils/projectConfig.test.ts b/packages/cli/src/utils/projectConfig.test.ts index c3a874ef9..7e738979b 100644 --- a/packages/cli/src/utils/projectConfig.test.ts +++ b/packages/cli/src/utils/projectConfig.test.ts @@ -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 }); + } + }); + }); }); diff --git a/packages/cli/src/utils/projectConfig.ts b/packages/cli/src/utils/projectConfig.ts index 90e44c135..d9dc78759 100644 --- a/packages/cli/src/utils/projectConfig.ts +++ b/packages/cli/src/utils/projectConfig.ts @@ -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 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; +} diff --git a/packages/cli/src/utils/studioProxyEnv.test.ts b/packages/cli/src/utils/studioProxyEnv.test.ts new file mode 100644 index 000000000..9b95a2d43 --- /dev/null +++ b/packages/cli/src/utils/studioProxyEnv.test.ts @@ -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", + }); + }); +}); diff --git a/packages/cli/src/utils/studioProxyEnv.ts b/packages/cli/src/utils/studioProxyEnv.ts new file mode 100644 index 000000000..cd19e4060 --- /dev/null +++ b/packages/cli/src/utils/studioProxyEnv.ts @@ -0,0 +1,9 @@ +export function studioProxyEnv( + autoProxy: boolean, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return { + ...baseEnv, + HYPERFRAMES_AUTO_PROXY: autoProxy ? "true" : "false", + }; +} diff --git a/packages/studio/vite.adapter.proxy.test.ts b/packages/studio/vite.adapter.proxy.test.ts new file mode 100644 index 000000000..a2fa1956a --- /dev/null +++ b/packages/studio/vite.adapter.proxy.test.ts @@ -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); + }); +}); diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index 7bf662d91..6bad725b7 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -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 [];