From e64a22893a161823c2b9f03522cf9d1246e96a9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 17 Jul 2026 21:56:27 -0400 Subject: [PATCH] fix(studio): preserve alpha proxy playback (#2625) * fix(studio): preserve alpha proxy playback * test(cli): pin VP8 alpha proxy pre-resolution * fix(studio): emit textarea field-sizing CSS * test(studio): verify textarea CSS generation --- packages/cli/src/commands/play.test.ts | 18 ++++----- packages/cli/src/utils/checkBrowser.test.ts | 11 ++++-- .../cli/src/utils/publishProxyBake.test.ts | 10 ++--- packages/cli/src/utils/publishProxyBake.ts | 4 +- .../cli/src/utils/staticProjectServer.test.ts | 18 ++++----- packages/core/src/runtime/mediaProxy.test.ts | 8 ++-- packages/core/src/runtime/mediaProxy.ts | 6 +-- .../src/helpers/mediaCodecMap.test.ts | 10 ++--- .../src/helpers/mediaCodecMap.ts | 16 +++----- .../src/helpers/mediaProxyPreview.ts | 2 +- .../src/helpers/proxyTranscoder.test.ts | 25 ++++++------ .../src/helpers/proxyTranscoder.ts | 18 ++++----- .../studio-server/src/routes/preview.test.ts | 24 ++++++------ .../propertyPanelFlatTextSection.test.tsx | 39 +++++++++++++++++++ .../editor/propertyPanelSections.tsx | 4 +- 15 files changed, 125 insertions(+), 88 deletions(-) diff --git a/packages/cli/src/commands/play.test.ts b/packages/cli/src/commands/play.test.ts index 68f1d9675..fda0351e6 100644 --- a/packages/cli/src/commands/play.test.ts +++ b/packages/cli/src/commands/play.test.ts @@ -62,16 +62,16 @@ const mediaMocks = vi.hoisted(() => ({ decideMediaProxyEligibility: vi.fn((facts: { browserHostile: boolean } | null) => facts?.browserHostile ? { eligible: true } : { eligible: false, reason: "browser_safe_codec" }, ), - isProxyVariant: (value: string) => value === "h264" || value === "vp9", - isProxyVariantRequest: (value: string) => value === "auto" || value === "h264" || value === "vp9", - proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp9" : "h264"), - resolveProxyVariantRequest: (request: "auto" | "h264" | "vp9", facts: { hasAlpha?: boolean }) => { - const expected = facts.hasAlpha ? "vp9" : "h264"; + isProxyVariant: (value: string) => value === "h264" || value === "vp8", + isProxyVariantRequest: (value: string) => value === "auto" || value === "h264" || value === "vp8", + proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp8" : "h264"), + resolveProxyVariantRequest: (request: "auto" | "h264" | "vp8", facts: { hasAlpha?: boolean }) => { + const expected = facts.hasAlpha ? "vp8" : "h264"; return request === "auto" || request === expected ? expected : null; }, PROXY_VARIANT_CONFIG: { h264: { extension: ".mp4", contentType: "video/mp4" }, - vp9: { extension: ".webm", contentType: "video/webm" }, + vp8: { extension: ".webm", contentType: "video/webm" }, }, })); @@ -119,7 +119,7 @@ afterEach(() => { dir = undefined; }); -it("serves direct VP9 proxy requests for alpha sources", async () => { +it("serves direct VP8 proxy requests for alpha sources", async () => { const project = tmpProject(); writeFileSync(join(project.dir, "clip.mov"), "alpha-prores"); mediaMocks.probeAssetCodec.mockResolvedValueOnce({ @@ -129,7 +129,7 @@ it("serves direct VP9 proxy requests for alpha sources", async () => { hasAlpha: true, }); const proxyPath = join(project.dir, "proxy.webm"); - writeFileSync(proxyPath, "vp9-alpha-proxy"); + writeFileSync(proxyPath, "vp8-alpha-proxy"); mocks.resolveProxy.mockResolvedValueOnce(proxyPath); const app = await buildApp(project, true); @@ -140,7 +140,7 @@ it("serves direct VP9 proxy requests for alpha sources", async () => { expect(mocks.resolveProxy).toHaveBeenCalledWith( project.dir, join(project.dir, "clip.mov"), - "vp9", + "vp8", ); }); diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index 25127376e..3af4cad8a 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -69,7 +69,7 @@ vi.mock("./staticProjectServer.js", () => ({ vi.mock("@hyperframes/studio-server/media-codec-map", async (importOriginal) => ({ ...(await importOriginal()), scanProjectMediaCodecMap: mocks.scanProjectMediaCodecMap, - proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp9" : "h264"), + proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp8" : "h264"), })); vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({ resolveProxy: mocks.resolveProxy, @@ -502,7 +502,7 @@ describe("preResolveHostileMediaProxies", () => { ); }); - it("does not pre-resolve a hostile asset rejected by the shared proxy policy", async () => { + it("pre-resolves an alpha VP9 asset through the Chromium-compatible VP8 proxy", async () => { const projectDir = mkProjectDir(); mocks.scanProjectMediaCodecMap.mockResolvedValue({ "/alpha.webm": { @@ -515,7 +515,12 @@ describe("preResolveHostileMediaProxies", () => { await preResolveHostileMediaProxies(projectDir, ""); - expect(mocks.resolveProxy).not.toHaveBeenCalled(); + expect(mocks.resolveProxy).toHaveBeenCalledTimes(1); + expect(mocks.resolveProxy).toHaveBeenCalledWith( + projectDir, + join(projectDir, "alpha.webm"), + "vp8", + ); }); it("is a no-op when the codec map has no hostile entries", async () => { diff --git a/packages/cli/src/utils/publishProxyBake.test.ts b/packages/cli/src/utils/publishProxyBake.test.ts index db0c93395..b98f98ccb 100644 --- a/packages/cli/src/utils/publishProxyBake.test.ts +++ b/packages/cli/src/utils/publishProxyBake.test.ts @@ -49,7 +49,7 @@ vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({ vi.mock("@hyperframes/studio-server/media-codec-map", () => ({ scanProjectMediaCodecMap: mocks.scanProjectMediaCodecMap, - proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp9" : "h264"), + proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp8" : "h264"), })); const { bakeMediaProxies, PROXY_ARCHIVE_PREFIX } = await import("./publishProxyBake.js"); @@ -196,7 +196,7 @@ describe("bakeMediaProxies", () => { expect(html).not.toContain("assets/my%20clip.mp4"); }); - it("bakes an alpha-bearing hostile asset as a VP9 WebM proxy", async () => { + it("bakes an alpha-bearing hostile asset as a VP8 WebM proxy", async () => { mocks.scanProjectMediaCodecMap.mockResolvedValue({ "/clip.mov": { codecName: "prores", @@ -205,7 +205,7 @@ describe("bakeMediaProxies", () => { hasAlpha: true, }, }); - const proxyPath = tmpProxyFile("PROXY_VP9_ALPHA_BYTES", ".webm"); + const proxyPath = tmpProxyFile("PROXY_VP8_ALPHA_BYTES", ".webm"); mocks.resolveProxy.mockResolvedValue(proxyPath); const fileContents = new Map([ ["index.html", indexHtml(``)], @@ -217,13 +217,13 @@ describe("bakeMediaProxies", () => { expect(mocks.resolveProxy).toHaveBeenCalledWith( PROJECT_DIR, join(PROJECT_DIR, "clip.mov"), - "vp9", + "vp8", ); const proxyEntries = [...fileContents.keys()].filter((key) => key.startsWith(`${PROXY_ARCHIVE_PREFIX}/`), ); expect(proxyEntries).toEqual([`${PROXY_ARCHIVE_PREFIX}/proxy.webm`]); - expect(fileContents.get(proxyEntries[0]!)?.toString("utf-8")).toBe("PROXY_VP9_ALPHA_BYTES"); + expect(fileContents.get(proxyEntries[0]!)?.toString("utf-8")).toBe("PROXY_VP8_ALPHA_BYTES"); expect(fileContents.get("index.html")?.toString("utf-8")).toContain(proxyEntries[0]!); expect(manifest).toEqual({ proxied: ["/clip.mov"], skippedAlpha: [], failed: [] }); }); diff --git a/packages/cli/src/utils/publishProxyBake.ts b/packages/cli/src/utils/publishProxyBake.ts index f5094908c..17d2b3c32 100644 --- a/packages/cli/src/utils/publishProxyBake.ts +++ b/packages/cli/src/utils/publishProxyBake.ts @@ -20,7 +20,7 @@ * `cloud render` never calls this: it builds and zips the file map without an * intermediate baking transform (R2 in the plan). * - * Alpha-bearing sources bake as VP9/WebM so transparency survives. A failed + * Alpha-bearing sources bake as VP8/WebM so transparency survives. A failed * hostile transcode aborts publish with a * structured manifest rather than silently shipping an unplayable asset. */ @@ -52,7 +52,7 @@ export const PROXY_ARCHIVE_PREFIX = "_proxy"; export interface ProxyBakeManifest { proxied: string[]; - /** @deprecated Alpha sources are proxied as VP9; retained as an always-empty compatibility field. */ + /** @deprecated Alpha sources are proxied as VP8; retained as an always-empty compatibility field. */ skippedAlpha: string[]; failed: Array<{ path: string; error: string }>; } diff --git a/packages/cli/src/utils/staticProjectServer.test.ts b/packages/cli/src/utils/staticProjectServer.test.ts index 917b0bdd4..700301bf0 100644 --- a/packages/cli/src/utils/staticProjectServer.test.ts +++ b/packages/cli/src/utils/staticProjectServer.test.ts @@ -69,16 +69,16 @@ vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({ vi.mock("@hyperframes/studio-server/media-codec-map", () => ({ probeAssetCodec: mocks.probeAssetCodec, decideMediaProxyEligibility: mocks.decideMediaProxyEligibility, - isProxyVariant: (value: string) => value === "h264" || value === "vp9", - isProxyVariantRequest: (value: string) => value === "auto" || value === "h264" || value === "vp9", - proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp9" : "h264"), - resolveProxyVariantRequest: (request: "auto" | "h264" | "vp9", facts: { hasAlpha?: boolean }) => { - const expected = facts.hasAlpha ? "vp9" : "h264"; + isProxyVariant: (value: string) => value === "h264" || value === "vp8", + isProxyVariantRequest: (value: string) => value === "auto" || value === "h264" || value === "vp8", + proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp8" : "h264"), + resolveProxyVariantRequest: (request: "auto" | "h264" | "vp8", facts: { hasAlpha?: boolean }) => { + const expected = facts.hasAlpha ? "vp8" : "h264"; return request === "auto" || request === expected ? expected : null; }, PROXY_VARIANT_CONFIG: { h264: { extension: ".mp4", contentType: "video/mp4" }, - vp9: { extension: ".webm", contentType: "video/webm" }, + vp8: { extension: ".webm", contentType: "video/webm" }, }, })); @@ -311,7 +311,7 @@ describe("serveStaticProjectHtml transparent media proxies", () => { }, ); - it("serves an alpha-bearing video through a VP9 WebM proxy", async () => { + it("serves an alpha-bearing video through a VP8 WebM proxy", async () => { const projectDir = mk(); writeFileSync(join(projectDir, "clip.mov"), "prores-4444-alpha-bytes"); mocks.probeAssetCodec.mockResolvedValueOnce({ @@ -321,7 +321,7 @@ describe("serveStaticProjectHtml transparent media proxies", () => { representativeMime: null, }); const proxyPath = join(projectDir, "proxy.webm"); - writeFileSync(proxyPath, "vp9-alpha-proxy"); + writeFileSync(proxyPath, "vp8-alpha-proxy"); mocks.resolveProxy.mockResolvedValueOnce(proxyPath); server = await serveStaticProjectHtml(projectDir, ""); @@ -333,7 +333,7 @@ describe("serveStaticProjectHtml transparent media proxies", () => { expect(mocks.resolveProxy).toHaveBeenCalledWith( projectDir, join(projectDir, "clip.mov"), - "vp9", + "vp8", ); }); diff --git a/packages/core/src/runtime/mediaProxy.test.ts b/packages/core/src/runtime/mediaProxy.test.ts index 395a62e03..74294e1b3 100644 --- a/packages/core/src/runtime/mediaProxy.test.ts +++ b/packages/core/src/runtime/mediaProxy.test.ts @@ -174,7 +174,7 @@ describe("maybeProxyProactively", () => { expect(postRuntimeMessageMock).not.toHaveBeenCalled(); }); - it("swaps an alpha-bearing hostile entry to a VP9 proxy", () => { + it("swaps an alpha-bearing hostile entry to a VP8 proxy", () => { window.__HF_MEDIA_CODEC_MAP__ = { "/video.mov": { codecName: "prores", @@ -188,7 +188,7 @@ describe("maybeProxyProactively", () => { maybeProxyProactively(el); - expect(proxyVariant(el)).toBe("vp9"); + expect(proxyVariant(el)).toBe("vp8"); expect(el.load).toHaveBeenCalledTimes(1); }); @@ -237,7 +237,7 @@ describe("handleMetadataForProxy (reactive trigger)", () => { expect(postRuntimeMessageMock).not.toHaveBeenCalled(); }); - it("swaps a mapped alpha entry to a VP9 proxy", () => { + it("swaps a mapped alpha entry to a VP8 proxy", () => { window.__HF_MEDIA_CODEC_MAP__ = { "/video.mov": { codecName: "prores", @@ -251,7 +251,7 @@ describe("handleMetadataForProxy (reactive trigger)", () => { handleMetadataForProxy(el); - expect(proxyVariant(el)).toBe("vp9"); + expect(proxyVariant(el)).toBe("vp8"); expect(el.load).toHaveBeenCalledTimes(1); }); diff --git a/packages/core/src/runtime/mediaProxy.ts b/packages/core/src/runtime/mediaProxy.ts index 3ab27b5d5..83bfe1954 100644 --- a/packages/core/src/runtime/mediaProxy.ts +++ b/packages/core/src/runtime/mediaProxy.ts @@ -15,7 +15,7 @@ export type MediaCodecMapEntry = { codecName: string; browserHostile: boolean; representativeMime: string | null; - /** Source carries an alpha channel and therefore needs a VP9/WebM proxy. + /** Source carries an alpha channel and therefore needs a VP8/WebM proxy. * Optional so pre-alpha-aware maps stay assignable; absent means "no alpha detected". */ hasAlpha?: boolean; }; @@ -163,7 +163,7 @@ function lookupCodecMapEntry( function appendProxyParam(src: string, entry: MediaCodecMapEntry | null): string { const url = new URL(src, document.baseURI); - url.searchParams.set(PROXY_QUERY_PARAM, entry ? (entry.hasAlpha ? "vp9" : "h264") : "auto"); + url.searchParams.set(PROXY_QUERY_PARAM, entry ? (entry.hasAlpha ? "vp8" : "h264") : "auto"); return url.href; } @@ -291,7 +291,7 @@ export function maybeProxyProactively(el: HTMLMediaElement): void { * auto-proxying is enabled and served, so its absence means a `?hf-proxy=` * request would 404 — never swap there. When the map is present but has no * entry for this key, swapping stays allowed (unlisted-asset rescue). A - * mapped alpha entries select the VP9/WebM proxy variant. + * mapped alpha entries select the VP8/WebM proxy variant. */ export function handleMetadataForProxy(el: HTMLMediaElement): void { if (isRenderMode(el)) return; diff --git a/packages/studio-server/src/helpers/mediaCodecMap.test.ts b/packages/studio-server/src/helpers/mediaCodecMap.test.ts index 94a0d617a..2f1d6de46 100644 --- a/packages/studio-server/src/helpers/mediaCodecMap.test.ts +++ b/packages/studio-server/src/helpers/mediaCodecMap.test.ts @@ -215,7 +215,7 @@ describe("decideMediaProxyEligibility", () => { }); }); - it("does not recycle an alpha VP9 source into the same proxy codec", () => { + it("proxies an alpha VP9 source to the Chromium-compatible VP8 variant", () => { expect( decideMediaProxyEligibility({ codecName: "vp9", @@ -223,19 +223,19 @@ describe("decideMediaProxyEligibility", () => { representativeMime: 'video/webm; codecs="vp09.00.10.08"', hasAlpha: true, }), - ).toEqual({ eligible: false, reason: "proxy_target_codec" }); + ).toEqual({ eligible: true }); }); }); describe("proxyVariantFor", () => { - it("uses VP9 for alpha and H.264 otherwise", () => { + it("uses VP8 for alpha and H.264 otherwise", () => { const facts = { codecName: "prores", browserHostile: true, representativeMime: null, hasAlpha: true, }; - expect(proxyVariantFor(facts)).toBe("vp9"); + expect(proxyVariantFor(facts)).toBe("vp8"); expect(proxyVariantFor({ ...facts, hasAlpha: false })).toBe("h264"); }); }); @@ -248,7 +248,7 @@ describe("resolveProxyVariantRequest", () => { representativeMime: null, hasAlpha: true, }; - expect(resolveProxyVariantRequest("auto", facts)).toBe("vp9"); + expect(resolveProxyVariantRequest("auto", facts)).toBe("vp8"); expect(resolveProxyVariantRequest("h264", facts)).toBeNull(); }); }); diff --git a/packages/studio-server/src/helpers/mediaCodecMap.ts b/packages/studio-server/src/helpers/mediaCodecMap.ts index 40977fd5c..cd1c63e29 100644 --- a/packages/studio-server/src/helpers/mediaCodecMap.ts +++ b/packages/studio-server/src/helpers/mediaCodecMap.ts @@ -23,7 +23,7 @@ export interface AssetCodecFacts { * the runtime always proxies rather than probing `canPlayType`). */ representativeMime: string | null; /** Source carries an alpha channel (ffprobe pix_fmt). Alpha sources use a - * VP9/WebM proxy so their transparency is preserved. */ + * VP8/WebM proxy so their transparency is preserved across Chromium builds. */ hasAlpha: boolean; } @@ -47,7 +47,7 @@ export const BROWSER_HOSTILE_CODECS: Record = { vp9: 'video/webm; codecs="vp09.00.10.08"', }; -export type ProxyVariant = "h264" | "vp9"; +export type ProxyVariant = "h264" | "vp8"; export type ProxyVariantRequest = ProxyVariant | "auto"; export const PROXY_VARIANT_CONFIG: Record< @@ -55,7 +55,7 @@ export const PROXY_VARIANT_CONFIG: Record< { extension: ".mp4" | ".webm"; contentType: "video/mp4" | "video/webm" } > = { h264: { extension: ".mp4", contentType: "video/mp4" }, - vp9: { extension: ".webm", contentType: "video/webm" }, + vp8: { extension: ".webm", contentType: "video/webm" }, }; export function isProxyVariant(value: string): value is ProxyVariant { @@ -67,7 +67,7 @@ export function isProxyVariantRequest(value: string): value is ProxyVariantReque } export function proxyVariantFor(facts: AssetCodecFacts): ProxyVariant { - return facts.hasAlpha ? "vp9" : "h264"; + return facts.hasAlpha ? "vp8" : "h264"; } export function resolveProxyVariantRequest( @@ -78,10 +78,7 @@ export function resolveProxyVariantRequest( return request === "auto" || request === expected ? expected : null; } -export type MediaProxyIneligibilityReason = - | "browser_safe_codec" - | "proxy_target_codec" - | "unknown_codec"; +export type MediaProxyIneligibilityReason = "browser_safe_codec" | "unknown_codec"; export type MediaProxyEligibility = | { eligible: true } @@ -91,9 +88,6 @@ export type MediaProxyEligibility = export function decideMediaProxyEligibility(facts: AssetCodecFacts | null): MediaProxyEligibility { if (!facts) return { eligible: false, reason: "unknown_codec" }; if (!facts.browserHostile) return { eligible: false, reason: "browser_safe_codec" }; - if (facts.hasAlpha && facts.codecName === "vp9") { - return { eligible: false, reason: "proxy_target_codec" }; - } return { eligible: true }; } diff --git a/packages/studio-server/src/helpers/mediaProxyPreview.ts b/packages/studio-server/src/helpers/mediaProxyPreview.ts index 1434b899e..9ff53c1a7 100644 --- a/packages/studio-server/src/helpers/mediaProxyPreview.ts +++ b/packages/studio-server/src/helpers/mediaProxyPreview.ts @@ -73,7 +73,7 @@ function injectScriptTagIntoHead(html: string, scriptTag: string): string { * responses). No second concurrency limiter here — the transcoder's own * global bound throttles both pre-warm and element-triggered calls. * Pre-warm failures are swallowed; an actual `?hf-proxy=` request surfaces - * them as a 502. Alpha-bearing entries pre-warm their VP9/WebM variant. + * them as a 502. Alpha-bearing entries pre-warm their VP8/WebM variant. * * The single shared implementation for every auto-proxy surface — the studio * preview route (via `injectMediaCodecMap` below) and the CLI's composition / diff --git a/packages/studio-server/src/helpers/proxyTranscoder.test.ts b/packages/studio-server/src/helpers/proxyTranscoder.test.ts index 83080db71..e335b2e4f 100644 --- a/packages/studio-server/src/helpers/proxyTranscoder.test.ts +++ b/packages/studio-server/src/helpers/proxyTranscoder.test.ts @@ -157,7 +157,7 @@ describe("resolveProxy", () => { expect(cacheDirEntries).toEqual([expectedCachePath.split("/").at(-1)]); }); - it("uses VP9 alpha-safe args and a distinct WebM cache path", async () => { + it("uses Chromium-compatible VP8 alpha args and a distinct WebM cache path", async () => { const { spawn, calls } = createSpawnSpy(); const { resolveProxy, getProxyCachePath } = await loadModule(spawn, FFMPEG_PATH); const projectDir = tmpProject(); @@ -165,15 +165,16 @@ describe("resolveProxy", () => { writeFileSync(sourcePath, "source-bytes"); const h264Path = getProxyCachePath(projectDir, sourcePath, "h264"); - const vp9Path = getProxyCachePath(projectDir, sourcePath, "vp9"); - const resultPromise = resolveProxy(projectDir, sourcePath, "vp9"); + const vp8Path = getProxyCachePath(projectDir, sourcePath, "vp8"); + const resultPromise = resolveProxy(projectDir, sourcePath, "vp8"); await flush(); - expect(vp9Path).not.toBe(h264Path); - expect(vp9Path).toMatch(/\.webm$/); + expect(vp8Path).not.toBe(h264Path); + expect(vp8Path).toMatch(/\.webm$/); expect(h264Path).toMatch(/\.mp4$/); const args = calls[0]!.args; - expect(args).toContain("libvpx-vp9"); + expect(args).toContain("libvpx"); + expect(args).not.toContain("libvpx-vp9"); expect(args).toContain("yuva420p"); expect(args).toContain("libopus"); expect(args[args.indexOf("-b:v") + 1]).toBe("0"); @@ -182,14 +183,14 @@ describe("resolveProxy", () => { expect(args[args.indexOf("-auto-alt-ref") + 1]).toBe("0"); expect(args[args.indexOf("-metadata:s:v:0") + 1]).toBe("alpha_mode=1"); expect(args[args.indexOf("-ac") + 1]).toBe("2"); - expect(args).toContain("-row-mt"); + expect(args).not.toContain("-row-mt"); expect(args).toContain("-cpu-used"); expect(args).not.toContain("-movflags"); expect(args).not.toContain("+faststart"); expect(args[args.indexOf("-vf") + 1]).toContain("format=yuva420p"); - succeed(calls[0]!, "fake-vp9-bytes"); - await expect(resultPromise).resolves.toBe(vp9Path); + succeed(calls[0]!, "fake-vp8-bytes"); + await expect(resultPromise).resolves.toBe(vp8Path); }); it("returns without spawning on a cache hit", async () => { @@ -236,21 +237,21 @@ describe("resolveProxy", () => { await result; }); - it("preserves alpha on HDR-tagged VP9 proxies by bypassing the opaque tonemap chain", async () => { + it("preserves alpha on HDR-tagged VP8 proxies by bypassing the opaque tonemap chain", async () => { const { spawn, calls } = createSpawnSpy(); const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH, true); const projectDir = tmpProject(); const sourcePath = join(projectDir, "hdr-alpha.mov"); writeFileSync(sourcePath, "source-bytes"); - const result = resolveProxy(projectDir, sourcePath, "vp9"); + const result = resolveProxy(projectDir, sourcePath, "vp8"); await flush(); expect(calls).toHaveLength(1); const filter = calls[0]!.args[calls[0]!.args.indexOf("-vf") + 1]; expect(filter).toContain("format=yuva420p"); expect(filter).not.toContain("tonemap="); - succeed(calls[0]!, "fake-vp9-alpha-bytes"); + succeed(calls[0]!, "fake-vp8-alpha-bytes"); await result; }); diff --git a/packages/studio-server/src/helpers/proxyTranscoder.ts b/packages/studio-server/src/helpers/proxyTranscoder.ts index bb1402c43..097453df6 100644 --- a/packages/studio-server/src/helpers/proxyTranscoder.ts +++ b/packages/studio-server/src/helpers/proxyTranscoder.ts @@ -32,7 +32,7 @@ import { PROXY_VARIANT_CONFIG, type ProxyVariant } from "./mediaCodecMap.js"; * entry still lands for the next request. */ -export const PROXY_PARAMS_VERSION = "v3"; +export const PROXY_PARAMS_VERSION = "v4"; const CACHE_DIR_NAME = ".transcode-cache"; @@ -314,13 +314,13 @@ async function runFfmpeg( if (!ffmpegPath) { throw new FfmpegUnavailableError(); } - // The HDR tonemap filters discard alpha. VP9 is the alpha-preserving proxy + // The HDR tonemap filters discard alpha. VP8 is the alpha-preserving proxy // variant, so retain its source color values instead of making it opaque. - if (metadata.color.isHdr && variant !== "vp9") await ensureHdrFilters(ffmpegPath); + if (metadata.color.isHdr && variant !== "vp8") await ensureHdrFilters(ffmpegPath); const evenScale = "scale=trunc(iw/2)*2:trunc(ih/2)*2"; - const pixelFormat = variant === "vp9" ? "yuva420p" : "yuv420p"; + const pixelFormat = variant === "vp8" ? "yuva420p" : "yuv420p"; const videoFilter = - metadata.color.isHdr && variant !== "vp9" + metadata.color.isHdr && variant !== "vp8" ? [ "zscale=t=linear:npl=100", "tonemap=hable:desat=0", @@ -354,9 +354,9 @@ async function runFfmpeg( "-movflags", "+faststart", ]; - const vp9Args = [ + const vp8Args = [ "-c:v", - "libvpx-vp9", + "libvpx", "-b:v", "0", "-crf", @@ -371,8 +371,6 @@ async function runFfmpeg( "bt709", "-color_trc", "bt709", - "-row-mt", - "1", "-cpu-used", "4", "-auto-alt-ref", @@ -384,7 +382,7 @@ async function runFfmpeg( "-c:a", "libopus", ]; - const args = [...commonArgs, ...(variant === "vp9" ? vp9Args : h264Args), outputPath]; + const args = [...commonArgs, ...(variant === "vp8" ? vp8Args : h264Args), outputPath]; // Hard ceiling so a hung ffmpeg can never permanently occupy one of the // global transcode slots: the child is killed and the slot released via diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index beecbb091..257e747db 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -686,7 +686,7 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { resolveProxyImpl?: ( projectDir: string, absoluteSourcePath: string, - variant?: "h264" | "vp9", + variant?: "h264" | "vp8", ) => Promise; scanMapImpl?: ScanMapImpl; probeAssetCodecImpl?: () => Promise<{ @@ -736,20 +736,20 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { } return { eligible: true }; }, - isProxyVariant: (value: string) => value === "h264" || value === "vp9", + isProxyVariant: (value: string) => value === "h264" || value === "vp8", isProxyVariantRequest: (value: string) => - value === "auto" || value === "h264" || value === "vp9", - proxyVariantFor: (facts: { hasAlpha: boolean }) => (facts.hasAlpha ? "vp9" : "h264"), + value === "auto" || value === "h264" || value === "vp8", + proxyVariantFor: (facts: { hasAlpha: boolean }) => (facts.hasAlpha ? "vp8" : "h264"), resolveProxyVariantRequest: ( - request: "auto" | "h264" | "vp9", + request: "auto" | "h264" | "vp8", facts: { hasAlpha: boolean }, ) => { - const expected = facts.hasAlpha ? "vp9" : "h264"; + const expected = facts.hasAlpha ? "vp8" : "h264"; return request === "auto" || request === expected ? expected : null; }, PROXY_VARIANT_CONFIG: { h264: { extension: ".mp4", contentType: "video/mp4" }, - vp9: { extension: ".webm", contentType: "video/webm" }, + vp8: { extension: ".webm", contentType: "video/webm" }, }, })); return import("./preview.js"); @@ -863,12 +863,12 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { expect(resolveProxyMock).not.toHaveBeenCalled(); }); - it("serves an alpha asset as a VP9 WebM proxy", async () => { + it("serves an alpha asset as a VP8 WebM proxy", async () => { const projectDir = createProjectDir(); writeFileSync(join(projectDir, "clip.mov"), "alpha-video-bytes"); const resolveProxyMock = vi.fn(async () => { const proxyPath = join(projectDir, "proxy.webm"); - writeFileSync(proxyPath, "vp9-alpha-proxy"); + writeFileSync(proxyPath, "vp8-alpha-proxy"); return proxyPath; }); const { registerPreviewRoutes: register } = await loadPreviewModule({ @@ -883,14 +883,14 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { const app = new Hono(); register(app, createAdapter(projectDir)); - const res = await app.request("http://localhost/projects/demo/preview/clip.mov?hf-proxy=vp9"); + const res = await app.request("http://localhost/projects/demo/preview/clip.mov?hf-proxy=vp8"); expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toBe("video/webm"); expect(resolveProxyMock).toHaveBeenCalledWith( projectDir, join(projectDir, "clip.mov"), - "vp9", + "vp8", ); }); @@ -904,7 +904,7 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { const app = new Hono(); register(app, createAdapter(projectDir)); - const res = await app.request("http://localhost/projects/demo/preview/clip.mp4?hf-proxy=vp9"); + const res = await app.request("http://localhost/projects/demo/preview/clip.mp4?hf-proxy=vp8"); expect(res.status).toBe(422); expect(resolveProxyMock).not.toHaveBeenCalled(); diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx index b4b7ac173..56a7714d7 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx @@ -2,6 +2,8 @@ import React, { act, useState } from "react"; import { createRoot } from "react-dom/client"; +import postcss from "postcss"; +import tailwindcss from "tailwindcss"; import { afterEach, describe, expect, it, vi } from "vitest"; import { FlatTextLayerList, FlatTextSection } from "./propertyPanelFlatTextSection"; import type { DomEditSelection, DomEditTextField } from "./domEditingTypes"; @@ -466,4 +468,41 @@ describe("FlatTextSection — multi-field", () => { act(() => root.unmount()); }); + + it("keeps Content expandable and grows it with multiline text", async () => { + const element = makeMultiFieldElement(); + element.textFields[0].value = "First line\nSecond line\nThird line"; + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + + const contentTextarea = host.querySelector("textarea"); + expect(contentTextarea?.value).toBe("First line\nSecond line\nThird line"); + expect(contentTextarea?.classList).toContain("resize-y"); + expect(contentTextarea?.classList).toContain("overflow-y-auto"); + + const compiled = await postcss([ + tailwindcss({ + content: [{ raw: host.innerHTML, extension: "html" }], + corePlugins: { preflight: false }, + }), + ]).process("@tailwind utilities;", { from: undefined }); + expect(compiled.css).toContain("field-sizing: content"); + + act(() => root.unmount()); + }); }); diff --git a/packages/studio/src/components/editor/propertyPanelSections.tsx b/packages/studio/src/components/editor/propertyPanelSections.tsx index 5eaf4d2c3..5f346f787 100644 --- a/packages/studio/src/components/editor/propertyPanelSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelSections.tsx @@ -147,7 +147,7 @@ export function TextAreaField({ onFocus={handleFocus} onChange={handleChange} onBlur={handleBlur} - className="w-full resize-none bg-transparent font-mono text-[11px] leading-normal text-panel-text-0 outline-none disabled:cursor-not-allowed disabled:text-panel-text-4" + className="[field-sizing:content] max-h-[40vh] min-h-12 w-full resize-y overflow-x-hidden overflow-y-auto bg-transparent font-mono text-[11px] leading-normal text-panel-text-0 outline-none disabled:cursor-not-allowed disabled:text-panel-text-4" /> ); @@ -165,7 +165,7 @@ export function TextAreaField({ onFocus={handleFocus} onChange={handleChange} onBlur={handleBlur} - className="w-full resize-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600" + className="[field-sizing:content] max-h-[40vh] min-h-20 w-full resize-y overflow-x-hidden overflow-y-auto bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600" />