Skip to content

Commit f3cef41

Browse files
committed
fix(cli): guard play proxy requests for alpha
1 parent 0ea1b1a commit f3cef41

2 files changed

Lines changed: 46 additions & 0 deletions

File tree

packages/cli/src/commands/play.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,25 @@ const mocks = vi.hoisted(() => {
5252
});
5353
const FakeProxyTranscodeError = mocks.ProxyTranscodeError;
5454

55+
const mediaMocks = vi.hoisted(() => ({
56+
probeAssetCodec: vi.fn(async () => ({
57+
codecName: "hevc",
58+
browserHostile: true,
59+
representativeMime: "video/mp4",
60+
hasAlpha: false,
61+
})),
62+
decideMediaProxyEligibility: vi.fn(
63+
(facts: { hasAlpha: boolean; browserHostile: boolean } | null) =>
64+
facts?.hasAlpha ? { eligible: false, reason: "alpha_source" } : { eligible: true },
65+
),
66+
}));
67+
5568
vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({
5669
resolveProxy: mocks.resolveProxy,
5770
ProxyTranscodeError: mocks.ProxyTranscodeError,
5871
ProxyCapacityError: mocks.ProxyCapacityError,
5972
}));
73+
vi.mock("@hyperframes/studio-server/media-codec-map", () => mediaMocks);
6074

6175
// The shared injection helper ships as a self-contained dist bundle (its copy
6276
// of scanProjectMediaCodecMap is inlined), so it must be mocked wholesale —
@@ -88,11 +102,35 @@ afterEach(() => {
88102
mocks.resolveProxy.mockReset();
89103
mocks.resolveProxy.mockResolvedValue("/unused-prewarm-proxy-path");
90104
mocks.scanProjectMediaCodecMap.mockReset();
105+
mediaMocks.probeAssetCodec.mockClear();
106+
mediaMocks.decideMediaProxyEligibility.mockClear();
91107
mocks.scanProjectMediaCodecMap.mockResolvedValue({});
92108
if (dir) rmSync(dir, { recursive: true, force: true });
93109
dir = undefined;
94110
});
95111

112+
it("rejects direct proxy requests for alpha sources", async () => {
113+
const project = tmpProject();
114+
writeFileSync(join(project.dir, "clip.mov"), "alpha-prores");
115+
mediaMocks.probeAssetCodec.mockResolvedValueOnce({
116+
codecName: "prores",
117+
browserHostile: true,
118+
representativeMime: "video/quicktime",
119+
hasAlpha: true,
120+
});
121+
mediaMocks.decideMediaProxyEligibility.mockReturnValueOnce({
122+
eligible: false,
123+
reason: "alpha_source",
124+
});
125+
const app = await buildApp(project, true);
126+
127+
const res = await app.request("/composition/clip.mov?hf-proxy=h264");
128+
129+
expect(res.status).toBe(422);
130+
expect(await res.text()).toContain("alpha_source");
131+
expect(mocks.resolveProxy).not.toHaveBeenCalled();
132+
});
133+
96134
async function buildApp(project: ProjectDir, autoProxy: boolean): Promise<Hono> {
97135
const app = new Hono();
98136
await registerCompositionRoute(app, project, autoProxy);

packages/cli/src/commands/play.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ import {
4242
ProxyCapacityError,
4343
ProxyTranscodeError,
4444
} from "@hyperframes/studio-server/proxy-transcoder";
45+
import {
46+
decideMediaProxyEligibility,
47+
probeAssetCodec,
48+
} from "@hyperframes/studio-server/media-codec-map";
4549

4650
export default defineCommand({
4751
meta: { name: "play", description: "Play a composition in a lightweight browser player" },
@@ -229,6 +233,10 @@ export async function registerCompositionRoute(
229233
// transcode; a missing asset already 404'd above.
230234
if (!autoProxy || !contentType.startsWith("video/")) return ctx.text("Not found", 404);
231235
try {
236+
const eligibility = decideMediaProxyEligibility(await probeAssetCodec(filePath));
237+
if (!eligibility.eligible) {
238+
return ctx.text(`Media proxy unavailable: ${eligibility.reason}`, 422);
239+
}
232240
const proxyPath = await resolveProxy(project.dir, filePath);
233241
// The proxy IS an mp4 regardless of the source's extension (.mov,
234242
// .mkv, ...) — serve its real type, matching the preview route.

0 commit comments

Comments
 (0)