mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user