diff --git a/packages/cli/src/commands/play.test.ts b/packages/cli/src/commands/play.test.ts index 7af3fccf99c..6762ccfbe29 100644 --- a/packages/cli/src/commands/play.test.ts +++ b/packages/cli/src/commands/play.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -31,10 +32,8 @@ const mocks = vi.hoisted(() => { } return { resolveProxy: vi.fn<(projectDir: string, absoluteSourcePath: string) => Promise>( - // Benign default so `injectMediaCodecMap`'s fire-and-forget pre-warm - // (called for every hostile map entry) always gets a promise to - // `.catch()`, even in tests that don't care about the proxy path. - async () => "/unused-prewarm-proxy-path", + // Benign default for tests that don't care about the proxy path. + async () => "/unused-proxy-path", ), scanProjectMediaCodecMap: vi.fn< ( @@ -64,7 +63,6 @@ const mediaMocks = vi.hoisted(() => ({ ), isProxyVariant: (value: string) => value === "h264" || value === "vp8", isProxyVariantRequest: (value: string) => value === "auto" || value === "h264" || value === "vp8", - recordProxyRequest: () => {}, proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp8" : "h264"), resolveProxyVariantRequest: (request: "auto" | "h264" | "vp8", facts: { hasAlpha?: boolean }) => { const expected = facts.hasAlpha ? "vp8" : "h264"; @@ -111,7 +109,7 @@ function tmpProject(): ProjectDir { afterEach(() => { mocks.resolveProxy.mockReset(); - mocks.resolveProxy.mockResolvedValue("/unused-prewarm-proxy-path"); + mocks.resolveProxy.mockResolvedValue("/unused-proxy-path"); mocks.scanProjectMediaCodecMap.mockReset(); mediaMocks.probeAssetCodec.mockClear(); mediaMocks.decideMediaProxyEligibility.mockClear(); diff --git a/packages/cli/src/commands/play.ts b/packages/cli/src/commands/play.ts index ec1dc48e71f..48850b40203 100644 --- a/packages/cli/src/commands/play.ts +++ b/packages/cli/src/commands/play.ts @@ -49,7 +49,6 @@ import { decideMediaProxyEligibility, isProxyVariantRequest, probeAssetCodec, - recordProxyRequest, resolveProxyVariantRequest, PROXY_VARIANT_CONFIG, } from "@hyperframes/studio-server/media-codec-map"; @@ -265,7 +264,6 @@ export async function registerCompositionRoute( if (!proxyVariant) { return ctx.text("Media proxy variant does not match asset", 422); } - recordProxyRequest(); const proxyPath = await resolveProxy(project.dir, filePath, proxyVariant); return buildRangeResponse( proxyPath, diff --git a/packages/cli/src/utils/staticProjectServer.test.ts b/packages/cli/src/utils/staticProjectServer.test.ts index abd60a17aa0..d39088d96ef 100644 --- a/packages/cli/src/utils/staticProjectServer.test.ts +++ b/packages/cli/src/utils/staticProjectServer.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -35,10 +36,8 @@ const mocks = vi.hoisted(() => { } return { resolveProxy: vi.fn<(projectDir: string, absoluteSourcePath: string) => Promise>( - // Benign default so `injectMediaCodecMap`'s fire-and-forget pre-warm - // (called for every hostile map entry) always gets a promise to - // `.catch()`, even in tests that don't care about the proxy path. - async () => "/unused-prewarm-proxy-path", + // Benign default for tests that don't care about the proxy path. + async () => "/unused-proxy-path", ), scanProjectMediaCodecMap: vi.fn< ( @@ -76,7 +75,6 @@ vi.mock("@hyperframes/studio-server/media-codec-map", () => ({ decideMediaProxyEligibility: mocks.decideMediaProxyEligibility, isProxyVariant: (value: string) => value === "h264" || value === "vp8", isProxyVariantRequest: (value: string) => value === "auto" || value === "h264" || value === "vp8", - recordProxyRequest: () => {}, proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp8" : "h264"), resolveProxyVariantRequest: (request: "auto" | "h264" | "vp8", facts: { hasAlpha?: boolean }) => { const expected = facts.hasAlpha ? "vp8" : "h264"; @@ -114,7 +112,7 @@ afterEach(async () => { if (dir) rmSync(dir, { recursive: true, force: true }); dir = undefined; mocks.resolveProxy.mockReset(); - mocks.resolveProxy.mockResolvedValue("/unused-prewarm-proxy-path"); + mocks.resolveProxy.mockResolvedValue("/unused-proxy-path"); mocks.scanProjectMediaCodecMap.mockReset(); mocks.scanProjectMediaCodecMap.mockResolvedValue({}); mocks.probeAssetCodec.mockClear(); diff --git a/packages/cli/src/utils/staticProjectServer.ts b/packages/cli/src/utils/staticProjectServer.ts index 0031a8a130b..1c46ee454d1 100644 --- a/packages/cli/src/utils/staticProjectServer.ts +++ b/packages/cli/src/utils/staticProjectServer.ts @@ -15,7 +15,6 @@ import { decideMediaProxyEligibility, isProxyVariantRequest, probeAssetCodec, - recordProxyRequest, resolveProxyVariantRequest, PROXY_VARIANT_CONFIG, type ProxyVariantRequest, @@ -234,7 +233,6 @@ async function serveProxyRequest( res.end("media proxy variant does not match asset"); return; } - recordProxyRequest(); const proxyPath = await resolveProxy(projectDir, filePath, variant); // The await above can span a whole transcode; the client may be gone. if (res.writableEnded || res.destroyed) return; diff --git a/packages/core/src/runtime/init.mediaClipIndex.test.ts b/packages/core/src/runtime/init.mediaClipIndex.test.ts index a1b54a65703..e4ffbc05966 100644 --- a/packages/core/src/runtime/init.mediaClipIndex.test.ts +++ b/packages/core/src/runtime/init.mediaClipIndex.test.ts @@ -34,6 +34,7 @@ vi.mock("./media", async (importOriginal) => { }); const { initSandboxRuntimeModular } = await import("./init"); +const { swapToProxy } = await import("./mediaProxy"); /** * `count` back-to-back one-second clips, so the transport crosses exactly one @@ -224,6 +225,33 @@ describe("per-seek media clip index", () => { expect(indexed[0]).toMatchObject({ currentTime: 3 }); }); + it("keeps the index through a preview copy's own src swap, and puts the copy on the playhead", async () => { + mountClips(16); + const clip = document.querySelector("#clip3")!; + clip.setAttribute("src", "/clip3.mp4"); + // A real load starts the new file at 0. + clip.load = vi.fn(() => { + clip.currentTime = 0; + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("x", { status: 206 })), + ); + initSandboxRuntimeModular(); + window.__player!.seek(3.5); + const placed = vi.spyOn(clip, "currentTime", "set"); + mediaSpy.visits = []; + + swapToProxy(clip, null, "reactive"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(clip.getAttribute("src")).toContain("hf-proxy=auto"); + expect(mediaSpy.visits.length).toBeGreaterThan(0); + expect(Math.max(...mediaSpy.visits)).toBeLessThan(16); + expect(placed).toHaveBeenLastCalledWith(0.5); + vi.unstubAllGlobals(); + }); + it("re-derives the index when a clip is moved on the timeline", () => { mountClips(16); initSandboxRuntimeModular(); diff --git a/packages/core/src/runtime/init.seekCopies.test.ts b/packages/core/src/runtime/init.seekCopies.test.ts index 0a398f8e6bf..f25a12cf171 100644 --- a/packages/core/src/runtime/init.seekCopies.test.ts +++ b/packages/core/src/runtime/init.seekCopies.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { initSandboxRuntimeModular } from "./init"; import { noteSeekCopyPlayback, startSeekCopies, type SeekCopyHost } from "./mediaProxy"; +import { swappedElements } from "./proxySrc"; import { createMockTimeline, resetRuntimeFixtureDom } from "./runtimeSeekFixture.test-helpers"; vi.mock("./mediaProxy", async (importOriginal) => ({ @@ -32,6 +33,20 @@ it("starts seek copies after the first frame, on the transport's clips, and repo expect(host.playhead()).toBe(7); }); +it("tells Studio an element's authored src, not the preview copy it plays", () => { + resetRuntimeFixtureDom(); + document.body.innerHTML = + '
'; + const video = document.querySelector("video")!; + video.load = vi.fn(); + swappedElements.set(video, "/clip.mp4"); + window.__timelines = { main: createMockTimeline(5) }; + + initSandboxRuntimeModular(); + + expect(window.__hf?.unproxiedSrc?.(video)).toBe("/clip.mp4"); +}); + afterEach(() => { window.__hfRuntimeTeardown?.(); vi.restoreAllMocks(); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 060a7450041..a96f6210839 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -69,6 +69,7 @@ import { handleMetadataForProxy, maybeProxyProactively, noteSeekCopyPlayback, + setOwnMediaWrite, startSeekCopies, setProxyDisplayScale, } from "./mediaProxy"; @@ -1389,6 +1390,13 @@ export function initSandboxRuntimeModular(): void { invalidateCompositionTimingCaches(); }; + // A copy swap moves no clip; the forced sync lands the new file on the playhead even while paused. + setOwnMediaWrite((el, write) => { + withoutOwnTimingRecords(el, write); + state.mediaForceSyncNextTick = true; + syncMediaForCurrentState(); + }); + const watchCompositionTimingInputs = () => { if (compositionTimingObserver || typeof MutationObserver === "undefined") return; compositionTimingObserver = new MutationObserver(invalidateCompositionTimingCaches); @@ -3250,6 +3258,7 @@ export function initSandboxRuntimeModular(): void { wakeTransport(); }; window.__hf.leasePausedMedia = leasePausedMedia; + window.__hf.unproxiedSrc = unproxiedSrc; window.__hf.releasePausedMedia = releasePausedMedia; window.__hf.setLoopStart = (seconds) => { loopStartSeconds = seconds != null && Number.isFinite(seconds) && seconds >= 0 ? seconds : null; diff --git a/packages/core/src/runtime/mediaProxy.test.ts b/packages/core/src/runtime/mediaProxy.test.ts index a2a5867e8ee..5b11bae6361 100644 --- a/packages/core/src/runtime/mediaProxy.test.ts +++ b/packages/core/src/runtime/mediaProxy.test.ts @@ -24,6 +24,7 @@ vi.mock("./bridge", () => ({ })); import { postRuntimeMessage } from "./bridge"; +import { collectRuntimeTimelinePayload } from "./timeline"; import { resetSeekDispatchState, waitForSeekCompletion } from "./adapters/seek-dispatch"; const postRuntimeMessageMock = vi.mocked(postRuntimeMessage); @@ -817,6 +818,27 @@ describe("when a served copy replaces what the element plays", () => { }); }); +describe("the authored source outside the preview", () => { + it("keeps the original src in the clip list after a copy swaps in", async () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + const el = createVideo("/video.mp4"); + el.id = "clip"; + el.setAttribute("data-start", "0"); + el.setAttribute("data-duration", "5"); + root.appendChild(el); + + swapToProxy(el, HEVC_ENTRY, "proactive"); + await settle(); + expect(proxyVariant(el)).toBe("h264"); + + const { clips } = collectRuntimeTimelinePayload({ canonicalFps: 30 }); + expect(clips[0]?.assetUrl).toBe(new URL("/video.mp4", document.baseURI).href); + }); +}); + describe("deriveCodecMapKey", () => { it("returns the decoded, query-stripped pathname for a same-origin src", () => { const el = createVideo("/assets/my%20clip.mp4?foo=bar"); diff --git a/packages/core/src/runtime/mediaProxy.ts b/packages/core/src/runtime/mediaProxy.ts index fe45badaa0a..a740ca134c9 100644 --- a/packages/core/src/runtime/mediaProxy.ts +++ b/packages/core/src/runtime/mediaProxy.ts @@ -165,6 +165,13 @@ function keepSized(el: HTMLMediaElement, scale: number): void { }); } +let ownMediaWrite = (_el: Element, write: () => void): void => write(); + +/** The runtime's writer for a src it swaps itself, so the swap is not read as an authored edit. */ +export function setOwnMediaWrite(writer: (el: Element, write: () => void) => void): void { + ownMediaWrite = writer; +} + function loadProxy( el: HTMLMediaElement, href: string, @@ -176,8 +183,10 @@ function loadProxy( else boxedElements.delete(el); // Sync state measured on the previous file would read the new file's buffering as drift. evictMediaSyncState(el); - el.src = href; - el.load(); + ownMediaWrite(el, () => { + el.src = href; + el.load(); + }); } /** diff --git a/packages/core/src/runtime/picker.test.ts b/packages/core/src/runtime/picker.test.ts index b354508b3a6..bfd3cbc6260 100644 --- a/packages/core/src/runtime/picker.test.ts +++ b/packages/core/src/runtime/picker.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; import { createPickerModule } from "./picker"; +import { swappedElements } from "./proxySrc"; // jsdom does not implement CSS.escape — polyfill a compact spec-adjacent // version. Parallel (simpler) polyfills already live in compositionLoader.test.ts @@ -194,6 +195,16 @@ describe("createPickerModule", () => { } }); + it("reports a picked video's authored src, not the preview copy it plays", () => { + const picker = createPickerModule({ postMessage: createMockPostMessage() }); + picker.installPickerApi(); + document.body.innerHTML = ``; + const video = document.getElementById("clip")!; + swappedElements.set(video, "/clip.mp4"); + + expect(candidatesUnder(() => [video, ...ancestorsOf(video)], "src")[0]).toBe("/clip.mp4"); + }); + it("picks inside a mounted section whose root sets pointer-events:none", () => { const picker = createPickerModule({ postMessage: createMockPostMessage() }); picker.installPickerApi(); diff --git a/packages/core/src/runtime/picker.ts b/packages/core/src/runtime/picker.ts index 2d2d942e87c..fb8789f2c1d 100644 --- a/packages/core/src/runtime/picker.ts +++ b/packages/core/src/runtime/picker.ts @@ -2,6 +2,7 @@ import type { RuntimeJson, RuntimeOutboundMessage, RuntimePickerElementInfo } fr import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "../colorGrading"; import { swallow } from "./diagnostics"; import { isElementNode } from "./domRealm"; +import { unproxiedSrc } from "./proxySrc"; import { createDrawnProbe } from "./pickerDrawn"; type PickerModuleDeps = { @@ -308,7 +309,7 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule { label: buildElementLabel(el), boundingBox: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, textContent: el.textContent ? el.textContent.trim().slice(0, 200) : null, - src: el.getAttribute("src") || el.getAttribute("data-composition-src") || null, + src: unproxiedSrc(el) || el.getAttribute("data-composition-src") || null, dataAttributes, }; } diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 21805116b71..ec31d63949f 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -12,6 +12,7 @@ import { resolveAuthoredTimingWindow, } from "./authoredTiming"; import { swallow } from "./diagnostics"; +import { unproxiedSrc } from "./proxySrc"; import { readTimelineDurationSeconds } from "./timelineDuration"; import { readElementPlaybackRate, readElementPlaybackStart } from "./media"; import { @@ -170,13 +171,13 @@ function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null } function resolveNodeAssetUrl(node: Element): string | null { - const src = node.getAttribute("src") ?? node.getAttribute("data-src"); + const src = unproxiedSrc(node) ?? node.getAttribute("data-src"); if (src) return toAbsoluteAssetUrl(src); const compositionSrc = node.getAttribute("data-composition-src"); if (compositionSrc) return toAbsoluteAssetUrl(compositionSrc); const mediaDescendant = node.querySelector("img[src], video[src], audio[src], source[src]"); if (!mediaDescendant) return null; - return toAbsoluteAssetUrl(mediaDescendant.getAttribute("src")); + return toAbsoluteAssetUrl(unproxiedSrc(mediaDescendant)); } function getFirstClassToken(node: Element): string | null { diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index c086ab80e8f..ba33bcfb94f 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -73,6 +73,8 @@ declare global { * runtime's paused-side enforcement leaves it alone. Always release. */ leasePausedMedia?: (el: HTMLMediaElement) => void; releasePausedMedia?: (el: HTMLMediaElement) => void; + /** An element's authored src, never the preview copy the runtime plays in its place. */ + unproxiedSrc?: (el: Element) => string | null; /** Where Studio's loop wraps to while it plays, or null: the clips due there stay loaded. */ setLoopStart?: (seconds: number | null) => void; /** Read-only level taps for the Studio meters: nothing exists until diff --git a/packages/studio-server/src/helpers/mediaCodecMap.test.ts b/packages/studio-server/src/helpers/mediaCodecMap.test.ts index 2d8c01ec291..7ec73f47055 100644 --- a/packages/studio-server/src/helpers/mediaCodecMap.test.ts +++ b/packages/studio-server/src/helpers/mediaCodecMap.test.ts @@ -11,7 +11,6 @@ import { proxyVariantFor, resolveProxyVariantRequest, scanProjectMediaCodecMap, - shouldPrewarmProxy, type AssetCodecFacts, } from "./mediaCodecMap.js"; @@ -288,40 +287,9 @@ describe("BROWSER_HOSTILE_CODECS representative mimes", () => { }); }); -describe("shouldPrewarmProxy", () => { - function facts(codecName: string): AssetCodecFacts { - return { codecName, browserHostile: true, representativeMime: null, hasAlpha: false }; - } - - it.each(["hevc", "prores", "theora"])( - "pre-warms %s, which has no cross-platform browser decode", - (codecName) => { - expect(shouldPrewarmProxy(facts(codecName))).toBe(true); - }, - ); - - it.each(["vp9", "av1"])( - "does not pre-warm %s, which every mainstream engine decodes itself", - (codecName) => { - expect(shouldPrewarmProxy(facts(codecName))).toBe(false); - }, - ); - - it("does not pre-warm a browser-safe codec", () => { - expect( - shouldPrewarmProxy({ - codecName: "h264", - browserHostile: false, - representativeMime: null, - hasAlpha: false, - }), - ).toBe(false); - }); - - // `shouldPrewarmProxy` alone cannot catch a missing `Object.hasOwn` guard — - // `.prewarm === true` is already strict against `Object.prototype.constructor`. - // The input that misbehaves goes through `codecFactsFor`, which would report - // an ordinary asset as browser-hostile. +describe("BROWSER_HOSTILE_CODECS lookup", () => { + // Without the `Object.hasOwn` guard, `codecFactsFor` would report an + // ordinary asset as browser-hostile. it("does not treat a codec named after an Object.prototype key as hostile", async () => { const project = tmpProject(); const videoPath = join(project, "clip.mp4"); @@ -335,7 +303,6 @@ describe("shouldPrewarmProxy", () => { representativeMime: null, hasAlpha: false, }); - expect(shouldPrewarmProxy(facts!)).toBe(false); }); }); diff --git a/packages/studio-server/src/helpers/mediaCodecMap.ts b/packages/studio-server/src/helpers/mediaCodecMap.ts index e433c7011c9..f7012ef3fc4 100644 --- a/packages/studio-server/src/helpers/mediaCodecMap.ts +++ b/packages/studio-server/src/helpers/mediaCodecMap.ts @@ -51,12 +51,6 @@ export interface BrowserHostileCodec { * (ProRes: browsers never decode it, so the runtime always proxies rather * than probing `canPlayType`). */ representativeMime: string | null; - /** - * Whether the server may transcode before any browser asks. True only where - * no cross-platform decode exists, so some client is sure to need the - * substitute; false where the first `?hf-proxy=` request can do it lazily. - */ - prewarm: boolean; } /** @@ -64,19 +58,15 @@ export interface BrowserHostileCodec { * one-line change. `ffprobe` cannot emit exact RFC 6381 codec strings, so * these `representativeMime` values are deliberately coarse (a false * positive costs one proxy transcode, never correctness; a false negative is - * rescued by the runtime's reactive zero-videoWidth swap). + * rescued by the runtime's reactive zero-videoWidth swap). The page's + * `canPlayType` decides whether a copy is made; the server never guesses. */ export const BROWSER_HOSTILE_CODECS: Record = { - // HEVC decode is platform-bound, not absent: macOS Chrome answers - // canPlayType with "probably" and keeps the source, while Chrome on - // Windows/Linux and Firefox everywhere need the substitute. - hevc: { representativeMime: 'video/mp4; codecs="hvc1.1.6.L120.B0"', prewarm: true }, - prores: { representativeMime: null, prewarm: true }, - theora: { representativeMime: 'video/ogg; codecs="theora"', prewarm: true }, - // Every mainstream engine decodes AV1 and VP9, so canPlayType keeps the - // original and only the rare browser that cannot pays for a transcode. - av1: { representativeMime: 'video/mp4; codecs="av01.0.08M.08"', prewarm: false }, - vp9: { representativeMime: 'video/webm; codecs="vp09.00.10.08"', prewarm: false }, + hevc: { representativeMime: 'video/mp4; codecs="hvc1.1.6.L120.B0"' }, + prores: { representativeMime: null }, + theora: { representativeMime: 'video/ogg; codecs="theora"' }, + av1: { representativeMime: 'video/mp4; codecs="av01.0.08M.08"' }, + vp9: { representativeMime: 'video/webm; codecs="vp09.00.10.08"' }, }; /** `Object.hasOwn` rather than a bare index: a codec named `constructor` or @@ -87,72 +77,6 @@ function hostileCodecEntry(codecName: string): BrowserHostileCodec | undefined { : undefined; } -/** The pre-warm gate: true only for codecs with no cross-platform browser - * decode, so some client will ask. See `BrowserHostileCodec.prewarm`. */ -export function shouldPrewarmProxy(facts: AssetCodecFacts): boolean { - return hostileCodecEntry(facts.codecName)?.prewarm === true; -} - -// Per process, and deliberately in one unit — a call to `resolveProxy` — so -// the pair reads as a ratio. Summarised at exit on stderr, in the same -// `[hyperframes:] {json}` shape as `writeUrlDownloadTelemetry`. -const proxyDemand = { prewarmsRequested: 0, proxyRequests: 0 }; - -/** Snapshot of this process's pre-warm demand counters. */ -export function mediaProxyDemand(): { prewarmsRequested: number; proxyRequests: number } { - return { ...proxyDemand }; -} - -function writeDemandLine(event: "prewarm_requested" | "summary"): void { - try { - process.stderr.write( - `[hyperframes:media-proxy] ${JSON.stringify({ event, ...proxyDemand })}\n`, - ); - } catch { - // Observability must never change proxy correctness. - } -} - -/** Mirrors `isGpuProbeDebugEnabled` in packages/engine/src/utils/gpuEncoder.ts. */ -function isMediaProxyDebugEnabled(): boolean { - const value = process.env.HYPERFRAMES_DEBUG_MEDIA_PROXY; - return value === "1" || value === "true"; -} - -// Registered on the first pre-warm rather than at import, so a process that -// never pre-warms adds no handler and prints nothing. Sync-only, mirroring the -// `process.on("exit")` shutdown hooks in packages/cli/src/cli.ts:331 and -// packages/cli/src/commands/preview.ts:1329. -let summaryHookInstalled = false; - -/** - * One proxy asked for before any browser wanted it. "Requested", not - * "started": a warm cache makes `resolveProxy` a no-op and this counter cannot - * see that, so it is an upper bound on transcodes, not a measure of CPU. The - * number it does answer exactly is the one that decides policy — a nonzero - * count beside `proxyRequests: 0` means nothing ever redeemed the pre-warm. - * - * The per-asset line is debug-only: a composition with fifty hostile clips - * would otherwise print fifty JSON lines into a clack-formatted terminal on - * every re-render. The exit summary carries the same numbers unconditionally. - */ -export function recordProxyPrewarm(): void { - proxyDemand.prewarmsRequested++; - if (!summaryHookInstalled) { - summaryHookInstalled = true; - process.once("exit", () => writeDemandLine("summary")); - } - if (isMediaProxyDebugEnabled()) writeDemandLine("prewarm_requested"); -} - -/** One proxy resolved for a browser that asked, counted on the path that calls - * `resolveProxy` so it shares a unit with `prewarmsRequested`. A 304 does not - * count; an unconditional Range refill still does, so read it as zero versus - * nonzero. Never logged per event, only in the exit summary. */ -export function recordProxyRequest(): void { - proxyDemand.proxyRequests++; -} - export type ProxyVariant = "h264" | "vp8"; export type ProxyVariantRequest = ProxyVariant | "auto"; diff --git a/packages/studio-server/src/helpers/mediaProxyPreview.test.ts b/packages/studio-server/src/helpers/mediaProxyPreview.test.ts index 60125cd8603..74e8aa7dead 100644 --- a/packages/studio-server/src/helpers/mediaProxyPreview.test.ts +++ b/packages/studio-server/src/helpers/mediaProxyPreview.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { tmpdir } from "node:os"; import type { AssetCodecFacts, MediaCodecMap } from "./mediaCodecMap.js"; @@ -25,24 +25,22 @@ function hostile(codecName: string, representativeMime: string | null): AssetCod async function loadHelper(map: MediaCodecMap): Promise<{ injectMediaCodecMapIntoHtml: typeof import("./mediaProxyPreview.js").injectMediaCodecMapIntoHtml; - mediaProxyDemand: typeof import("./mediaCodecMap.js").mediaProxyDemand; resolveProxy: ReturnType; }> { vi.resetModules(); - const resolveProxy = vi.fn(async () => "/unused-prewarm-proxy-path"); + const resolveProxy = vi.fn(async () => "/unused-proxy-path"); vi.doMock("./proxyTranscoder.js", () => ({ resolveProxy, PROXY_PARAMS_VERSION: "v1" })); - // The real codec table and pre-warm gate; only the ffprobe-backed scan is - // replaced, so this asserts the shipped policy rather than a copy of it. + // The real codec table; only the ffprobe-backed scan is replaced, so this + // asserts the shipped policy rather than a copy of it. vi.doMock("./mediaCodecMap.js", async () => ({ ...(await vi.importActual("./mediaCodecMap.js")), scanProjectMediaCodecMap: async () => map, })); const { injectMediaCodecMapIntoHtml } = await import("./mediaProxyPreview.js"); - const { mediaProxyDemand } = await import("./mediaCodecMap.js"); - return { injectMediaCodecMapIntoHtml, mediaProxyDemand, resolveProxy }; + return { injectMediaCodecMapIntoHtml, resolveProxy }; } -/** The pre-warm is fire-and-forget, so let its microtasks run before asserting. */ +/** Lets any fire-and-forget work queued while serving run before asserting. */ async function settle(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -55,12 +53,13 @@ afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); -describe("injectMediaCodecMapIntoHtml pre-warm", () => { - it("pre-warms the codec with no cross-platform decode and leaves the widely-decoded one lazy", async () => { +describe("serving a page", () => { + it("starts no copy, even for codecs some browsers cannot play: the page asks if it needs one", async () => { const projectDir = tmpProject(); const { injectMediaCodecMapIntoHtml, resolveProxy } = await loadHelper({ "/videos/avatar.webm": hostile("vp9", 'video/webm; codecs="vp09.00.10.08"'), "/videos/broll.mov": hostile("hevc", 'video/mp4; codecs="hvc1.1.6.L120.B0"'), + "/videos/master.mov": hostile("prores", null), }); const html = await injectMediaCodecMapIntoHtml( @@ -70,45 +69,10 @@ describe("injectMediaCodecMapIntoHtml pre-warm", () => { ); await settle(); - expect(resolveProxy).toHaveBeenCalledTimes(1); - expect(resolveProxy).toHaveBeenCalledWith( - projectDir, - resolve(projectDir, "videos/broll.mov"), - "h264", - ); - // Both entries still reach the client: the browser-side canPlayType check - // owns whether a VP9 asset needs a proxy, and that contract is unchanged. - expect(html).toContain("/videos/avatar.webm"); - expect(html).toContain("/videos/broll.mov"); - }); - - it("counts the pre-warms it requests, and none for a widely-decoded codec", async () => { - const projectDir = tmpProject(); - const { injectMediaCodecMapIntoHtml, mediaProxyDemand } = await loadHelper({ - "/videos/avatar.webm": hostile("vp9", 'video/webm; codecs="vp09.00.10.08"'), - "/videos/broll.mov": hostile("hevc", 'video/mp4; codecs="hvc1.1.6.L120.B0"'), - }); - - await injectMediaCodecMapIntoHtml("", projectDir, [ - { html: "" }, - ]); - await settle(); - - expect(mediaProxyDemand()).toEqual({ prewarmsRequested: 1, proxyRequests: 0 }); - }); - - it("pre-warms ProRes, which no browser decodes at all", async () => { - const projectDir = tmpProject(); - const { injectMediaCodecMapIntoHtml, resolveProxy } = await loadHelper({ - "/videos/master.mov": hostile("prores", null), - }); - - await injectMediaCodecMapIntoHtml("", projectDir, [ - { html: "" }, - ]); - await settle(); - - expect(resolveProxy).toHaveBeenCalledTimes(1); + expect(resolveProxy).not.toHaveBeenCalled(); + for (const path of ["/videos/avatar.webm", "/videos/broll.mov", "/videos/master.mov"]) { + expect(html).toContain(path); + } }); }); diff --git a/packages/studio-server/src/helpers/mediaProxyPreview.ts b/packages/studio-server/src/helpers/mediaProxyPreview.ts index e46e141754d..84c1fac43f1 100644 --- a/packages/studio-server/src/helpers/mediaProxyPreview.ts +++ b/packages/studio-server/src/helpers/mediaProxyPreview.ts @@ -1,17 +1,13 @@ -import { resolve } from "node:path"; import { insertBeforeCloseTag } from "@hyperframes/core/compiler/html-document"; import type { StudioApiAdapter } from "../types.js"; import { createMediaCodecProbeCache, - proxyVariantFor, - recordProxyPrewarm, scanProjectMediaCodecMap, - shouldPrewarmProxy, type HtmlSourceLike, type MediaCodecMap, type MediaCodecProbeCache, } from "./mediaCodecMap.js"; -import { resolveProxy, PROXY_PARAMS_VERSION } from "./proxyTranscoder.js"; +import { PROXY_PARAMS_VERSION } from "./proxyTranscoder.js"; /** * Transparent-media-proxy wiring shared by `routes/preview.ts` @@ -69,16 +65,8 @@ export function proxyEtagSalt(raw: string | undefined, servedCopy?: string): str /** * Injects `window.__HF_MEDIA_CODEC_MAP__` (the U1 codec-facts scan) into - * served composition HTML, and fire-and-forget pre-warms `resolveProxy` for - * every entry whose codec no browser decodes, so an element's proactive swap - * usually hits a warm cache (KTD: protects the per-origin connection budget - * under held responses). Conditionally hostile codecs are injected but NOT - * pre-warmed: the requesting browser usually plays them, and the transcode - * runs concurrently with its first layout. 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 and transcodes lazily. - * Alpha-bearing entries pre-warm their VP8/WebM variant. + * served composition HTML. It starts no copy: only the page's own `?hf-proxy=` + * request does, after its `canPlayType` says it needs one. * * The single shared implementation for every auto-proxy surface — the studio * preview route (via `injectMediaCodecMap` below) and the CLI's composition / @@ -91,7 +79,6 @@ export async function injectMediaCodecMapIntoHtml( projectDir: string, htmlSources: HtmlSourceLike[], probeCache?: MediaCodecProbeCache, - prewarm = true, seekCopies = false, ): Promise { let map: MediaCodecMap; @@ -110,18 +97,6 @@ export async function injectMediaCodecMapIntoHtml( map = Object.fromEntries(Object.entries(map).filter(([, facts]) => facts.browserHostile)); } if (Object.keys(map).length === 0) return html; - for (const [rootRelativePathname, facts] of Object.entries(map)) { - if (!prewarm || !shouldPrewarmProxy(facts)) continue; - recordProxyPrewarm(); - resolveProxy( - projectDir, - resolve(projectDir, rootRelativePathname.replace(/^\/+/, "")), - proxyVariantFor(facts), - ).catch(() => { - // Swallowed: the pre-warm is best-effort. A real `?hf-proxy=` request - // for this asset hears the remembered failure (502) or re-attempts it. - }); - } // <-escape prevents a src path containing "" from breaking out of // the injected tag, mirroring injectPreviewVariables in helpers/previewVariables.ts. const json = JSON.stringify(map) @@ -144,12 +119,5 @@ export async function injectMediaCodecMap( probeCache: MediaCodecProbeCache, ): Promise { if (!isAutoProxyEnabled(adapter)) return html; - return injectMediaCodecMapIntoHtml( - html, - projectDir, - [{ html, compSrcPath }], - probeCache, - false, - true, - ); + return injectMediaCodecMapIntoHtml(html, projectDir, [{ html, compSrcPath }], probeCache, true); } diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index fbf5ef3078f..871e97b2c00 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -1360,9 +1360,9 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { PROXY_PARAMS_VERSION: "v1", getProxyCachePath: () => "", })); - // Spread the real module first so the pre-warm gate (`shouldPrewarmProxy` - // and its codec table) is the production one — a hand-written copy of that - // rule would let the table and this suite drift apart. The explicit keys + // Spread the real module first so the codec table and eligibility gate are + // the production ones — a hand-written copy of either would let the table + // and this suite drift apart. The explicit keys // below still replace everything that would touch ffprobe or ffmpeg. vi.doMock("../helpers/mediaCodecMap.js", async () => ({ ...(await vi.importActual( @@ -1608,37 +1608,6 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { expect(fresh.headers.get("ETag")).toBeNull(); }); - it("counts one proxy request per resolved proxy, not per HTTP request", async () => { - const projectDir = createProjectDir(); - writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes"); - pastSettleWindow(); - const resolveProxyMock = vi.fn(async () => { - const proxyPath = join(projectDir, "proxy.mp4"); - writeFileSync(proxyPath, "0123456789proxybytes"); - return proxyPath; - }); - const { registerPreviewRoutes: register } = await loadPreviewModule({ - resolveProxyImpl: resolveProxyMock, - }); - const { mediaProxyDemand } = await import("../helpers/mediaCodecMap.js"); - const before = mediaProxyDemand().proxyRequests; - - const app = new Hono(); - register(app, createAdapter(projectDir)); - - const first = await app.request( - "http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264", - ); - const etag = first.headers.get("ETag"); - // A 304 revalidation is the same asset already served; counting it would - // put this on a different scale from `prewarmsRequested`. - await app.request("http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264", { - headers: { "If-None-Match": etag! }, - }); - - expect(mediaProxyDemand().proxyRequests - before).toBe(1); - }); - it("returns 404 without transcoding when the asset is missing", async () => { const projectDir = createProjectDir(); const resolveProxyMock = vi.fn(async () => "should-not-be-called"); @@ -1869,8 +1838,6 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { const { registerPreviewRoutes: register } = await loadPreviewModule({ resolveProxyImpl: () => transcode, }); - const { mediaProxyDemand } = await import("../helpers/mediaCodecMap.js"); - const before = mediaProxyDemand().proxyRequests; const app = new Hono(); register(app, createAdapter(projectDir)); const url = "http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264"; @@ -1884,13 +1851,11 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { expect(cold.status).toBe(202); expect(cold.headers.get("Retry-After")).toBe("2"); expect(cold.headers.get("Cache-Control")).toBe("no-store"); - expect(mediaProxyDemand().proxyRequests - before).toBe(0); landCopy(); const ready = await app.request(url); expect(ready.status).toBe(200); expect(await ready.text()).toBe("proxy-bytes"); - expect(mediaProxyDemand().proxyRequests - before).toBe(1); }); it("rejects a path-traversal attempt through the proxied path (404, no transcode)", async () => { diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index 28e41c8acff..c3875805ff0 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -67,7 +67,6 @@ import { decideMediaProxyEligibility, isProxyVariantRequest, probeAssetCodec, - recordProxyRequest, resolveProxyVariantRequest, PROXY_VARIANT_CONFIG, type ProxyVariant, @@ -756,9 +755,6 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo const message = err instanceof ProxyTranscodeError ? err.message : "proxy transcode failed"; return c.text(message, 502); } - // After the wait, not at the eligibility gate: one count per served proxy shares - // a unit with `prewarmsRequested`; a 304, a 202 or a failure serves none. - recordProxyRequest(); servedContentType = PROXY_VARIANT_CONFIG[proxyVariant].contentType; } diff --git a/packages/studio/src/components/editor/carveOutput.ts b/packages/studio/src/components/editor/carveOutput.ts index e605c6025ec..039f36f7c7d 100644 --- a/packages/studio/src/components/editor/carveOutput.ts +++ b/packages/studio/src/components/editor/carveOutput.ts @@ -8,6 +8,7 @@ import { isCarveVoiceElement } from "./useFxCarveGrouping.js"; import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAutomation"; import type { CarveClip } from "./useFxCarveNodes.js"; import { readClipClock } from "./clipAudioClock.js"; +import { authoredSrc } from "../../utils/authoredSrc"; /** Lanes belonging to nodes the carve generated, which a re-run replaces. */ export function withoutCarveLanes(automation: HfAutomation, chain: HfAudioFxChain): HfAutomation { @@ -26,7 +27,7 @@ export function resolveCarveVoices(doc: Document, sources: readonly string[]): C const el = doc.getElementById(id); // By tag name: iframe-realm elements fail `instanceof HTMLAudioElement`. if (!isCarveVoiceElement(el)) continue; - const src = el.getAttribute("src"); + const src = authoredSrc(el); if (!src) continue; const clock = readClipClock((name) => el.getAttribute(name)); voices.push({ src, start: el.getAttribute("data-start"), clock }); diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index 93439868d15..abc8ddd1139 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -55,6 +55,7 @@ import { } from "@hyperframes/core/audio-groups"; import { useFxLevelling } from "./useFxLevelling.js"; import { isCarveVoiceElement } from "./useFxCarveGrouping.js"; +import { authoredSrc } from "../../utils/authoredSrc"; function auditionSpan(startRaw: string | undefined, durationRaw: string | undefined) { const start = Number.parseFloat(startRaw ?? ""); @@ -379,7 +380,7 @@ export function AudioFxGroup({ // What this track sounds like, by the same reading that decides which // OTHER tracks a carve may listen to. The shelf uses it to stop offering // "My voice sounds amateur" on a music bed. - trackKind={classifyAudioName(element.id, element.element?.getAttribute("src"))} + trackKind={classifyAudioName(element.id, authoredSrc(element.element))} onLevel={() => void runLeveller()} onRemoveLevel={removeLeveller} levelled={chain.nodes.some((n) => n.fromLeveller)} diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 7a33eed8e3c..a5930a8a41d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -37,6 +37,7 @@ import { parseGainInput, parseRateInput, parseSecondsInput } from "./audioInspec import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes"; import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits"; import { authoredSrcPath } from "../../player/components/thumbnailUtils"; +import { authoredSrc } from "../../utils/authoredSrc"; // fallow-ignore-next-line complexity export function FlatMediaSection({ @@ -119,7 +120,7 @@ export function FlatMediaSection({ const objectFit = styles["object-fit"] || "contain"; const objectPosition = styles["object-position"] || "center"; - const srcAttr = el.getAttribute("src") ?? ""; + const srcAttr = authoredSrc(el) ?? ""; const [copied, setCopied] = useState(false); const [removeBusy, setRemoveBusy] = useState(false); const [removeProgress, setRemoveProgress] = useState(null); diff --git a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx index dc0a66250fa..5e22bf94dc5 100644 --- a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx @@ -26,6 +26,7 @@ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes"; import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits"; import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits"; import { authoredSrcPath } from "../../player/components/thumbnailUtils"; +import { authoredSrc } from "../../utils/authoredSrc"; // fallow-ignore-next-line complexity export function MediaSection({ @@ -91,7 +92,7 @@ export function MediaSection({ 0; const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10)); - const srcAttr = el.getAttribute("src") ?? ""; + const srcAttr = authoredSrc(el) ?? ""; const [copied, setCopied] = useState(false); const [removeBusy, setRemoveBusy] = useState(false); const [removeProgress, setRemoveProgress] = useState(null); diff --git a/packages/studio/src/components/editor/useColorGradingController.ts b/packages/studio/src/components/editor/useColorGradingController.ts index 084991808a7..bd25bf581ea 100644 --- a/packages/studio/src/components/editor/useColorGradingController.ts +++ b/packages/studio/src/components/editor/useColorGradingController.ts @@ -31,6 +31,7 @@ import { } from "./useColorGradingPreviews"; import { studioApiFetch } from "../../utils/studioApiFetch"; import { authoredSrcPath } from "../../player/components/thumbnailUtils"; +import { authoredSrc } from "../../utils/authoredSrc"; export type { ColorGradingPresetPreviews, ColorGradingPreviewOptions }; @@ -114,7 +115,7 @@ function resolveProjectAssetPath( function selectedMediaAssetPath(element: DomEditSelection, projectId: string): string | null { if (element.tagName !== "video" && element.tagName !== "img") return null; const media = element.element as HTMLImageElement | HTMLVideoElement; - const src = media.getAttribute("src") || media.currentSrc || ""; + const src = authoredSrc(media) || media.currentSrc || ""; return resolveProjectAssetPath(element.sourceFile || "index.html", src, projectId); } diff --git a/packages/studio/src/components/editor/useFxCarve.ts b/packages/studio/src/components/editor/useFxCarve.ts index 8f5752850c8..5d5ade5ae72 100644 --- a/packages/studio/src/components/editor/useFxCarve.ts +++ b/packages/studio/src/components/editor/useFxCarve.ts @@ -37,6 +37,7 @@ import { carveLanes, measureCarve, mintCarveNodes } from "./useFxCarveNodes.js"; import { readClipClock } from "./clipAudioClock.js"; import { spanOf } from "./propertyPanelAudioFxGroupUtils.js"; import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js"; +import { authoredSrc } from "../../utils/authoredSrc"; /** * Which carve setting actually moved, by comparing the two snapshots. @@ -305,7 +306,7 @@ export function useFxCarve( setAnalysing(true); try { const measured = await measureCarve(doc, voices, active.strength, { - src: element.element?.getAttribute("src"), + src: authoredSrc(element.element), start: element.dataAttributes?.["start"], clock: readClipClock((name) => element.dataAttributes?.[name.slice(5)]), }); diff --git a/packages/studio/src/components/editor/useFxCarveGrouping.ts b/packages/studio/src/components/editor/useFxCarveGrouping.ts index 5fa3a6a8c44..5b1acac13fe 100644 --- a/packages/studio/src/components/editor/useFxCarveGrouping.ts +++ b/packages/studio/src/components/editor/useFxCarveGrouping.ts @@ -21,6 +21,7 @@ import { resolveCarveSourceIds, } from "@hyperframes/core/audio-groups"; import { isAudibleVideoNode } from "../../player/lib/timelineElementHelpers"; +import { authoredSrc } from "../../utils/authoredSrc"; export function isCarveVoiceElement(el: Element | null): el is Element { return el !== null && (el.tagName === "AUDIO" || isAudibleVideoNode(el)); @@ -154,7 +155,7 @@ export function collectCarveCandidates( described.push({ id: a.id, label: a.id, - kind: classifyAudioName(a.id, a.getAttribute("src")), + kind: classifyAudioName(a.id, authoredSrc(a)), }); } continue; @@ -172,10 +173,7 @@ export function collectCarveCandidates( described.push({ id: group.id, label: `${group.label} (${members.length})`, - kind: classifyAudioName( - group.label, - ...members.flatMap((m) => [m.id, m.getAttribute("src")]), - ), + kind: classifyAudioName(group.label, ...members.flatMap((m) => [m.id, authoredSrc(m)])), }); } return described; @@ -225,7 +223,7 @@ export function carveBedRoles( if (node?.tagName?.toLowerCase() === HF_AUDIO_GROUP_TAG) { return { couldBeBed: false, autoBed: false }; } - const parts = [id, node?.getAttribute("src"), node?.getAttribute("data-label")]; + const parts = [id, authoredSrc(node), node?.getAttribute("data-label")]; return { couldBeBed: couldBeCarveBed(...parts), autoBed: isNamedCarveBed(...parts) }; } diff --git a/packages/studio/src/components/editor/useFxChainObserved.ts b/packages/studio/src/components/editor/useFxChainObserved.ts index 169dfba2220..0ca8f8eae19 100644 --- a/packages/studio/src/components/editor/useFxChainObserved.ts +++ b/packages/studio/src/components/editor/useFxChainObserved.ts @@ -12,6 +12,7 @@ import { classifyAudioName, type HfCarveSettings } from "@hyperframes/core/audio import type { HfAutomation } from "@hyperframes/core/audio-automation"; import { trackChainObserved } from "./audioFxTelemetry.js"; import type { DomEditSelection } from "./domEditingTypes"; +import { authoredSrc } from "../../utils/authoredSrc"; /** * This is the only way agent-applied effects become visible. An agent asked to @@ -70,7 +71,7 @@ export function useFxChainObserved( hasAutomation: automation.lanes.length > 0, }, { - trackKind: classifyAudioName(element.id, element.element?.getAttribute("src")) ?? undefined, + trackKind: classifyAudioName(element.id, authoredSrc(element.element)) ?? undefined, }, ); // Each observation is measured against the edits made SINCE the last one, so diff --git a/packages/studio/src/components/editor/useFxLevelling.ts b/packages/studio/src/components/editor/useFxLevelling.ts index 351bb4a515b..ad8386dce63 100644 --- a/packages/studio/src/components/editor/useFxLevelling.ts +++ b/packages/studio/src/components/editor/useFxLevelling.ts @@ -27,6 +27,7 @@ import type { DomEditSelection } from "./domEditingTypes"; import { clipSourceWindow, readClipClock } from "./clipAudioClock.js"; import { useAuditionTransport } from "./useAuditionTransport.js"; import { studioApiFetch } from "../../utils/studioApiFetch"; +import { authoredSrc } from "../../utils/authoredSrc"; /** * Rate the track is decoded at. Analysis is self-consistent because it reads @@ -54,7 +55,7 @@ export function useFxLevelling( const decodeTrack = async (): Promise<{ samples: Float32Array; sampleRate: number } | null> => { const el = element.element; - const src = el?.getAttribute("src"); + const src = authoredSrc(el); const doc = el?.ownerDocument; if (!src || !doc) return null; const cached = decoded.current; diff --git a/packages/studio/src/player/components/clipMenuDuck.ts b/packages/studio/src/player/components/clipMenuDuck.ts index 4af9a1ccac8..f53ce856762 100644 --- a/packages/studio/src/player/components/clipMenuDuck.ts +++ b/packages/studio/src/player/components/clipMenuDuck.ts @@ -33,6 +33,7 @@ import { HF_AUDIO_AUTOMATION_ATTR, } from "../../components/editor/propertyPanelAutomation"; import { spanOf } from "../../components/editor/propertyPanelAudioFxGroupUtils"; +import { authoredSrc } from "../../utils/authoredSrc"; export type WriteBedAttribute = (attr: string, value: string | null) => Promise; export type GroupClips = (clipIds: readonly string[], groupId: string) => Promise; @@ -98,7 +99,7 @@ async function writeMeasuredCarve( const voices = resolveCarveVoices(doc, expanded); if (voices.length === 0) return; const measured = await measureCarve(doc, voices, settings.strength, { - src: bed.getAttribute("src"), + src: authoredSrc(bed), start: bed.getAttribute("data-start"), clock: readClipClock((name) => bed.getAttribute(name)), }); diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index 1aa1ae8dbfc..8ba02f93d53 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -61,6 +61,7 @@ export { resolveIframe, buildMissingCompositionElements, } from "./timelineIframeHelpers"; +import { authoredSrc } from "../../utils/authoredSrc"; // --------------------------------------------------------------------------- // TimelineElement factories @@ -146,7 +147,7 @@ export function createTimelineElementFromManifestClip(params: { if (hostEl) { applyMediaMetadataFromElement(entry, hostEl); if (!entry.src) { - const rawSrc = hostEl.getAttribute("src"); + const rawSrc = authoredSrc(hostEl); if (rawSrc) entry.src = new URL(rawSrc, hostEl.baseURI).href; } if (hostEl.hasAttribute("data-hidden")) entry.hidden = true; @@ -196,7 +197,7 @@ export function createTimelineElementFromManifestClip(params: { } else if (hostEl) { const innerVideo = hostEl.querySelector("video[src]"); if (innerVideo) { - entry.src = innerVideo.getAttribute("src") || undefined; + entry.src = authoredSrc(innerVideo) || undefined; entry.tag = "video"; } } @@ -376,7 +377,7 @@ export function parseTimelineFromDOM( // Inline composition — expose inner video or image for thumbnails const innerMedia = el.querySelector("video[src], img[src]"); if (innerMedia) { - entry.src = innerMedia.getAttribute("src") || undefined; + entry.src = authoredSrc(innerMedia) || undefined; entry.tag = innerMedia.tagName === "IMG" ? "img" : "video"; } } diff --git a/packages/studio/src/player/lib/timelineElementHelpers.test.ts b/packages/studio/src/player/lib/timelineElementHelpers.test.ts index b90217f6dc5..511fb92506e 100644 --- a/packages/studio/src/player/lib/timelineElementHelpers.test.ts +++ b/packages/studio/src/player/lib/timelineElementHelpers.test.ts @@ -48,6 +48,21 @@ describe("preview nodes built in another realm", () => { applyMediaMetadataFromElement(entry, el); expect(entry.sourceDuration).toBe(12); }); + + it("lists a video under its authored src while the preview plays a copy of it", () => { + const el = foreignNode(``); + const view = el.ownerDocument.defaultView as unknown as { __hf?: unknown }; + view.__hf = { unproxiedSrc: () => "a.mp4" }; + const entry = { id: "v", tag: "div", start: 0, duration: 4, track: 0 } as TimelineElement; + + try { + applyMediaMetadataFromElement(entry, el); + } finally { + delete view.__hf; + } + + expect(entry.src).toBe("a.mp4"); + }); }); describe("readTimelineText", () => { diff --git a/packages/studio/src/player/lib/timelineElementHelpers.ts b/packages/studio/src/player/lib/timelineElementHelpers.ts index 4979642e07d..a85eef172e4 100644 --- a/packages/studio/src/player/lib/timelineElementHelpers.ts +++ b/packages/studio/src/player/lib/timelineElementHelpers.ts @@ -21,6 +21,7 @@ import { clampPlaybackRate, readMediaOffsetSeconds, } from "@hyperframes/parsers/media-duration"; +import { authoredSrc } from "../../utils/authoredSrc"; // --------------------------------------------------------------------------- // Layer-reveal lift transparency @@ -245,7 +246,7 @@ export function applyMediaMetadataFromElement(entry: TimelineElement, el: Elemen if (!mediaEl) return; entry.tag = mediaEl.tagName.toLowerCase(); - const src = mediaEl.getAttribute("src"); + const src = authoredSrc(mediaEl); if (src) entry.src = src; if (!isMediaElement(mediaEl)) return; diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.ts b/packages/studio/src/player/lib/timelineIframeHelpers.ts index e0dca7cdaa7..8b1eeddd560 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.ts @@ -26,6 +26,7 @@ import { } from "./timelineElementHelpers"; import { postRuntimeControlMessage } from "./runtimeProtocol"; import { transitionLabelsForDocument } from "./timelineTransitionMetadata"; +import { authoredSrc } from "../../utils/authoredSrc"; // --------------------------------------------------------------------------- // Viewport / DOM normalisation @@ -404,7 +405,7 @@ function attachCompositionSource( if (compositionSrc) return { ...entry, compositionSrc }; const innerVideo = element.querySelector("video[src]"); if (!innerVideo) return entry; - return { ...entry, src: optionalNonEmpty(innerVideo.getAttribute("src")), tag: "video" }; + return { ...entry, src: optionalNonEmpty(authoredSrc(innerVideo)), tag: "video" }; } function buildMissingCompositionEntry(params: { @@ -468,8 +469,8 @@ function buildMissingCompositionEntry(params: { selectorIndex, sourceFile, zIndex: readTimelineElementZIndex(element), - src: optionalNonEmpty(element.getAttribute("src")) - ? new URL(element.getAttribute("src")!, element.baseURI).href + src: optionalNonEmpty(authoredSrc(element)) + ? new URL(authoredSrc(element)!, element.baseURI).href : undefined, }; return attachCompositionSource(entry, element, compositionSrc); diff --git a/packages/studio/src/utils/authoredSource.test.ts b/packages/studio/src/utils/authoredSource.test.ts index ba2ac6445bd..8fe76bb5ae6 100644 --- a/packages/studio/src/utils/authoredSource.test.ts +++ b/packages/studio/src/utils/authoredSource.test.ts @@ -8,6 +8,18 @@ import { import { liveMarkupWithoutPreviewMarks } from "./authoredSource"; describe("liveMarkupWithoutPreviewMarks", () => { + it("copies a video under its authored src, not the preview copy it plays", () => { + const live = document.createElement("div"); + live.innerHTML = ``; + const view = window as unknown as { __hf?: unknown }; + view.__hf = { unproxiedSrc: (el: Element) => (el.tagName === "VIDEO" ? "clip.mp4" : null) }; + try { + expect(liveMarkupWithoutPreviewMarks(live)).toBe('
'); + } finally { + delete view.__hf; + } + }); + it("drops the preview's raster mark from an element and its descendants", () => { const live = document.createElement("div"); live.innerHTML = `
LOUD
`; diff --git a/packages/studio/src/utils/authoredSource.ts b/packages/studio/src/utils/authoredSource.ts index 8068d816bbd..e0ab621df00 100644 --- a/packages/studio/src/utils/authoredSource.ts +++ b/packages/studio/src/utils/authoredSource.ts @@ -3,6 +3,7 @@ import { PREVIEW_ONLY_ATTRS, STUDIO_PREVIEW_LAZY_ATTR, } from "@hyperframes/core/studio-preview-mark"; +import { authoredSrc } from "./authoredSrc"; // Stamped as the preview stamps the files it serves, so a live element's hf-id finds its source. export function parseSavedSource(html: string): Document { @@ -97,9 +98,12 @@ export function authoredMarkup(authored: Element, live: Element, sourceFile: str export function liveMarkupWithoutPreviewMarks(live: Element): string { const copy = live.cloneNode(true) as Element; - for (const el of [copy, ...Array.from(copy.querySelectorAll("*"))]) { + const lives = [live, ...Array.from(live.querySelectorAll("*"))]; + [copy, ...Array.from(copy.querySelectorAll("*"))].forEach((el, index) => { if (el.hasAttribute(STUDIO_PREVIEW_LAZY_ATTR)) el.removeAttribute("loading"); for (const attr of PREVIEW_ONLY_ATTRS) el.removeAttribute(attr); - } + const src = authoredSrc(lives[index]); + if (src !== null && el.hasAttribute("src")) el.setAttribute("src", src); + }); return copy.outerHTML; } diff --git a/packages/studio/src/utils/authoredSrc.ts b/packages/studio/src/utils/authoredSrc.ts new file mode 100644 index 00000000000..890b4156b1b --- /dev/null +++ b/packages/studio/src/utils/authoredSrc.ts @@ -0,0 +1,7 @@ +type PreviewRealm = { __hf?: { unproxiedSrc?: (el: Element) => string | null } }; + +export function authoredSrc(el: Element | null | undefined): string | null { + if (!el) return null; + const hf = (el.ownerDocument.defaultView as PreviewRealm | null)?.__hf; + return hf?.unproxiedSrc ? hf.unproxiedSrc(el) : el.getAttribute("src"); +} diff --git a/packages/studio/src/utils/timelineInspector.ts b/packages/studio/src/utils/timelineInspector.ts index 1f652b123cf..94626baba78 100644 --- a/packages/studio/src/utils/timelineInspector.ts +++ b/packages/studio/src/utils/timelineInspector.ts @@ -1,5 +1,6 @@ import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups"; import type { TimelineElement } from "../player"; +import { authoredSrc } from "./authoredSrc"; const AUDIO_TIMELINE_TAGS = new Set(["audio", "music", "sfx", "sound", "narration"]); const AUDIO_SOURCE_EXT_RE = /\.(aac|flac|m4a|mp3|ogg|opus|wav)(?:[?#].*)?$/i; @@ -21,7 +22,7 @@ export function isAudioDomElement(node: Element | null | undefined): boolean { if (node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return true; return isAudioTimelineElement({ tag: node.tagName, - src: node.getAttribute("src") ?? undefined, + src: authoredSrc(node) ?? undefined, }); } diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index dd3c8c92533..17426986f90 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -12,5 +12,5 @@ "@hyperframes/sdk": { "packed": 90662, "unpacked": 325069 }, "@hyperframes/shader-transitions": { "packed": 212864, "unpacked": 825937 }, "@hyperframes/studio": { "packed": 5998328, "unpacked": 24360208 }, - "@hyperframes/studio-server": { "packed": 149157, "unpacked": 559199 } + "@hyperframes/studio-server": { "packed": 141718, "unpacked": 559199 } }