mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 12:08:50 +00:00
Merge pull request #1514 from heygen-com/feat/color-grading-runtime
feat(runtime): apply color grading in preview and render
This commit is contained in:
@@ -17,6 +17,8 @@ export const HYPERFRAME_CONTROL_ACTIONS = [
|
||||
"seek",
|
||||
"set-muted",
|
||||
"set-playback-rate",
|
||||
"set-color-grading",
|
||||
"set-color-grading-compare",
|
||||
"enable-pick-mode",
|
||||
"disable-pick-mode",
|
||||
] as const;
|
||||
|
||||
@@ -11,6 +11,8 @@ function createMockDeps() {
|
||||
onSetVolume: vi.fn(),
|
||||
onSetMediaOutputMuted: vi.fn(),
|
||||
onSetPlaybackRate: vi.fn(),
|
||||
onSetColorGrading: vi.fn(),
|
||||
onSetColorGradingCompare: vi.fn(),
|
||||
onEnablePickMode: vi.fn(),
|
||||
onDisablePickMode: vi.fn(),
|
||||
};
|
||||
@@ -111,6 +113,24 @@ describe("installRuntimeControlBridge", () => {
|
||||
expect(deps.onSetPlaybackRate).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("dispatches set-color-grading command with target and grading payload", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
const grading = { preset: "warm-clean", intensity: 0.7 };
|
||||
const target = { id: "hero-video", selectorIndex: 0 };
|
||||
handler(makeControlMessage("set-color-grading", { target, grading }));
|
||||
expect(deps.onSetColorGrading).toHaveBeenCalledWith(target, grading);
|
||||
});
|
||||
|
||||
it("dispatches set-color-grading-compare command with target and compare payload", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
const compare = { enabled: true, position: 0.42 };
|
||||
const target = { id: "hero-video", selectorIndex: 0 };
|
||||
handler(makeControlMessage("set-color-grading-compare", { target, compare }));
|
||||
expect(deps.onSetColorGradingCompare).toHaveBeenCalledWith(target, compare);
|
||||
});
|
||||
|
||||
it("dispatches tick command", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { swallow } from "./diagnostics";
|
||||
import type { HfColorGradingTarget } from "../colorGrading";
|
||||
import type { RuntimeBridgeControlMessage, RuntimeOutboundMessage } from "./types";
|
||||
|
||||
type BridgeDeps = {
|
||||
@@ -10,6 +11,11 @@ type BridgeDeps = {
|
||||
onSetVolume: (volume: number) => void;
|
||||
onSetMediaOutputMuted: (muted: boolean) => void;
|
||||
onSetPlaybackRate: (rate: number) => void;
|
||||
onSetColorGrading: (target: HfColorGradingTarget | string | null, grading: unknown) => void;
|
||||
onSetColorGradingCompare: (
|
||||
target: HfColorGradingTarget | string | null,
|
||||
compare: unknown,
|
||||
) => void;
|
||||
onEnablePickMode: () => void;
|
||||
onDisablePickMode: () => void;
|
||||
};
|
||||
@@ -60,6 +66,14 @@ export function installRuntimeControlBridge(deps: BridgeDeps): (event: MessageEv
|
||||
deps.onSetPlaybackRate(Number(data.playbackRate ?? 1));
|
||||
return;
|
||||
}
|
||||
if (action === "set-color-grading") {
|
||||
deps.onSetColorGrading(data.target ?? null, data.grading ?? null);
|
||||
return;
|
||||
}
|
||||
if (action === "set-color-grading-compare") {
|
||||
deps.onSetColorGradingCompare(data.target ?? null, data.compare ?? null);
|
||||
return;
|
||||
}
|
||||
if (action === "enable-pick-mode") {
|
||||
deps.onEnablePickMode();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HF_COLOR_GRADING_ATTR, serializeHfColorGrading } from "../colorGrading";
|
||||
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
|
||||
|
||||
let lastUniform1f: ReturnType<typeof vi.fn> | null = null;
|
||||
let lastUniform3f: ReturnType<typeof vi.fn> | null = null;
|
||||
|
||||
const IDENTITY_2 = `
|
||||
LUT_3D_SIZE 2
|
||||
0 0 0
|
||||
1 0 0
|
||||
0 1 0
|
||||
1 1 0
|
||||
0 0 1
|
||||
1 0 1
|
||||
0 1 1
|
||||
1 1 1
|
||||
`;
|
||||
|
||||
function createMockWebGl(): WebGLRenderingContext {
|
||||
const shader = {};
|
||||
const program = {};
|
||||
const texture = {};
|
||||
const buffer = {};
|
||||
const uniform1f = vi.fn();
|
||||
const uniform3f = vi.fn();
|
||||
lastUniform1f = uniform1f;
|
||||
lastUniform3f = uniform3f;
|
||||
return {
|
||||
VERTEX_SHADER: 0x8b31,
|
||||
FRAGMENT_SHADER: 0x8b30,
|
||||
COMPILE_STATUS: 0x8b81,
|
||||
LINK_STATUS: 0x8b82,
|
||||
TEXTURE_2D: 0x0de1,
|
||||
TEXTURE_WRAP_S: 0x2802,
|
||||
TEXTURE_WRAP_T: 0x2803,
|
||||
TEXTURE_MIN_FILTER: 0x2801,
|
||||
TEXTURE_MAG_FILTER: 0x2800,
|
||||
CLAMP_TO_EDGE: 0x812f,
|
||||
LINEAR: 0x2601,
|
||||
NEAREST: 0x2600,
|
||||
RGBA: 0x1908,
|
||||
UNSIGNED_BYTE: 0x1401,
|
||||
ARRAY_BUFFER: 0x8892,
|
||||
STATIC_DRAW: 0x88e4,
|
||||
TEXTURE0: 0x84c0,
|
||||
TEXTURE1: 0x84c1,
|
||||
FLOAT: 0x1406,
|
||||
TRIANGLE_STRIP: 0x0005,
|
||||
UNPACK_FLIP_Y_WEBGL: 0x9240,
|
||||
createShader: vi.fn(() => shader),
|
||||
shaderSource: vi.fn(),
|
||||
compileShader: vi.fn(),
|
||||
getShaderParameter: vi.fn(() => true),
|
||||
getShaderInfoLog: vi.fn(() => ""),
|
||||
deleteShader: vi.fn(),
|
||||
createProgram: vi.fn(() => program),
|
||||
attachShader: vi.fn(),
|
||||
linkProgram: vi.fn(),
|
||||
getProgramParameter: vi.fn(() => true),
|
||||
getProgramInfoLog: vi.fn(() => ""),
|
||||
deleteProgram: vi.fn(),
|
||||
createTexture: vi.fn(() => texture),
|
||||
bindTexture: vi.fn(),
|
||||
texParameteri: vi.fn(),
|
||||
texImage2D: vi.fn(),
|
||||
createBuffer: vi.fn(() => buffer),
|
||||
bindBuffer: vi.fn(),
|
||||
bufferData: vi.fn(),
|
||||
getAttribLocation: vi.fn(() => 0),
|
||||
getUniformLocation: vi.fn((_program, name: string) => name),
|
||||
viewport: vi.fn(),
|
||||
useProgram: vi.fn(),
|
||||
activeTexture: vi.fn(),
|
||||
pixelStorei: vi.fn(),
|
||||
uniform1i: vi.fn(),
|
||||
uniform2f: vi.fn(),
|
||||
uniform1f,
|
||||
uniform3f,
|
||||
enableVertexAttribArray: vi.fn(),
|
||||
vertexAttribPointer: vi.fn(),
|
||||
drawArrays: vi.fn(),
|
||||
deleteTexture: vi.fn(),
|
||||
} as unknown as WebGLRenderingContext;
|
||||
}
|
||||
|
||||
function makeDrawableVideo(): HTMLVideoElement {
|
||||
const video = document.createElement("video");
|
||||
video.id = "hero-video";
|
||||
video.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ adjust: { exposure: 0.5 } }));
|
||||
Object.defineProperty(video, "readyState", {
|
||||
value: HTMLMediaElement.HAVE_CURRENT_DATA,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(video, "videoWidth", { value: 640, configurable: true });
|
||||
Object.defineProperty(video, "videoHeight", { value: 360, configurable: true });
|
||||
Object.defineProperty(video, "offsetWidth", { value: 640, configurable: true });
|
||||
Object.defineProperty(video, "offsetHeight", { value: 360, configurable: true });
|
||||
Object.defineProperty(video, "offsetLeft", { value: 0, configurable: true });
|
||||
Object.defineProperty(video, "offsetTop", { value: 0, configurable: true });
|
||||
video.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 640,
|
||||
bottom: 360,
|
||||
width: 640,
|
||||
height: 360,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
return video;
|
||||
}
|
||||
|
||||
function stubCubeLutFetch(): ReturnType<typeof vi.fn> {
|
||||
const fetchMock = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(IDENTITY_2),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
describe("createColorGradingRuntime", () => {
|
||||
let getContextSpy: ReturnType<typeof vi.spyOn>;
|
||||
let runtime: RuntimeColorGradingApi | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
lastUniform1f = null;
|
||||
lastUniform3f = null;
|
||||
getContextSpy = vi
|
||||
.spyOn(HTMLCanvasElement.prototype, "getContext")
|
||||
.mockImplementation((type: string) =>
|
||||
type === "webgl" ? createMockWebGl() : null,
|
||||
) as ReturnType<typeof vi.spyOn>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
runtime?.destroy();
|
||||
runtime = null;
|
||||
vi.unstubAllGlobals();
|
||||
getContextSpy.mockRestore();
|
||||
delete window.__hfVariables;
|
||||
delete window.__hfVariablesByComp;
|
||||
document.head.innerHTML = "";
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function startRuntimeWithVideo(video = makeDrawableVideo()): {
|
||||
video: HTMLVideoElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
} {
|
||||
document.body.appendChild(video);
|
||||
runtime = createColorGradingRuntime();
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("[data-hf-color-grading-canvas]");
|
||||
if (!canvas) throw new Error("Expected color grading canvas");
|
||||
return { video, canvas };
|
||||
}
|
||||
|
||||
async function flushLutLoad(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
runtime?.redraw();
|
||||
}
|
||||
|
||||
it("re-hides source media after timeline visibility sync", () => {
|
||||
const { video, canvas } = startRuntimeWithVideo();
|
||||
|
||||
expect(video.style.getPropertyValue("visibility")).toBe("");
|
||||
expect(video.style.getPropertyValue("opacity")).toBe("0");
|
||||
expect(video.style.getPropertyPriority("opacity")).toBe("important");
|
||||
expect(video.hasAttribute("data-hf-color-grading-source-hidden")).toBe(true);
|
||||
expect(canvas?.style.visibility).toBe("visible");
|
||||
expect(canvas?.style.opacity).toBe("1");
|
||||
|
||||
video.style.visibility = "visible";
|
||||
runtime.setSourceVisibility(video, true);
|
||||
runtime.redraw();
|
||||
|
||||
expect(video.style.getPropertyValue("visibility")).toBe("visible");
|
||||
expect(video.style.getPropertyValue("opacity")).toBe("0");
|
||||
expect(video.style.getPropertyPriority("opacity")).toBe("important");
|
||||
expect(canvas?.style.visibility).toBe("visible");
|
||||
|
||||
video.style.visibility = "hidden";
|
||||
runtime.setSourceVisibility(video, false);
|
||||
runtime.redraw();
|
||||
|
||||
expect(video.style.getPropertyValue("visibility")).toBe("hidden");
|
||||
expect(video.style.getPropertyValue("opacity")).toBe("0");
|
||||
expect(video.style.getPropertyPriority("opacity")).toBe("important");
|
||||
expect(canvas?.style.visibility).toBe("hidden");
|
||||
});
|
||||
|
||||
it("resolves grading values from the nearest sub-composition variable scope", () => {
|
||||
window.__hfVariables = {
|
||||
exposure: -0.25,
|
||||
};
|
||||
window.__hfVariablesByComp = {
|
||||
card__hf1: {
|
||||
exposure: 0.75,
|
||||
},
|
||||
};
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-id", "card__hf1");
|
||||
const video = makeDrawableVideo();
|
||||
video.id = "first-video";
|
||||
video.setAttribute(
|
||||
HF_COLOR_GRADING_ATTR,
|
||||
JSON.stringify({ adjust: { exposure: "$exposure" } }),
|
||||
);
|
||||
host.appendChild(video);
|
||||
document.body.appendChild(host);
|
||||
|
||||
runtime = createColorGradingRuntime();
|
||||
|
||||
if (!lastUniform1f) throw new Error("Expected WebGL uniform calls");
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_exposure", 0.75);
|
||||
});
|
||||
|
||||
it("falls back to top-level variables for root media color grading", () => {
|
||||
window.__hfVariables = {
|
||||
exposure: 0.35,
|
||||
};
|
||||
const video = makeDrawableVideo();
|
||||
video.setAttribute(
|
||||
HF_COLOR_GRADING_ATTR,
|
||||
JSON.stringify({ adjust: { exposure: "${exposure}" } }),
|
||||
);
|
||||
document.body.appendChild(video);
|
||||
|
||||
runtime = createColorGradingRuntime();
|
||||
|
||||
if (!lastUniform1f) throw new Error("Expected WebGL uniform calls");
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_exposure", 0.35);
|
||||
});
|
||||
|
||||
it("keeps the last shader frame visible while a video seek is waiting for a drawable frame", () => {
|
||||
const { video, canvas } = startRuntimeWithVideo();
|
||||
|
||||
expect(canvas.style.display).toBe("block");
|
||||
|
||||
Object.defineProperty(video, "readyState", {
|
||||
value: HTMLMediaElement.HAVE_METADATA,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
runtime.redraw();
|
||||
|
||||
expect(canvas.style.display).toBe("block");
|
||||
expect(video.style.getPropertyValue("opacity")).toBe("0");
|
||||
expect(video.style.getPropertyPriority("opacity")).toBe("important");
|
||||
});
|
||||
|
||||
it("updates before-after compare uniforms without changing the source grading", () => {
|
||||
const video = makeDrawableVideo();
|
||||
document.body.appendChild(video);
|
||||
|
||||
runtime = createColorGradingRuntime();
|
||||
const updated = runtime.setCompare("#hero-video", {
|
||||
enabled: true,
|
||||
position: 0.25,
|
||||
lineWidth: 4,
|
||||
});
|
||||
|
||||
if (!lastUniform1f) throw new Error("Expected WebGL uniform calls");
|
||||
expect(updated).toBe(true);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_compareEnabled", 1);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_comparePosition", 0.25);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_compareLineWidth", 4);
|
||||
expect(video.getAttribute(HF_COLOR_GRADING_ATTR)).toBe(
|
||||
serializeHfColorGrading({ adjust: { exposure: 0.5 } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads cube LUTs and enables LUT uniforms", async () => {
|
||||
const fetchMock = stubCubeLutFetch();
|
||||
const origin = window.location.origin;
|
||||
document.head.innerHTML = `<base href="${origin}/api/projects/demo/preview/">`;
|
||||
const video = makeDrawableVideo();
|
||||
video.setAttribute(
|
||||
HF_COLOR_GRADING_ATTR,
|
||||
serializeHfColorGrading({ lut: { src: "assets/luts/identity.cube", intensity: 0.4 } }),
|
||||
);
|
||||
document.body.appendChild(video);
|
||||
|
||||
runtime = createColorGradingRuntime();
|
||||
await flushLutLoad();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${origin}/api/projects/demo/preview/assets/luts/identity.cube`,
|
||||
{ credentials: "same-origin" },
|
||||
);
|
||||
if (!lastUniform1f || !lastUniform3f) throw new Error("Expected WebGL uniform calls");
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_lutEnabled", 1);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_lutSize", 2);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_lutIntensity", 0.4);
|
||||
expect(lastUniform3f).toHaveBeenCalledWith("u_lutDomainMin", 0, 0, 0);
|
||||
expect(lastUniform3f).toHaveBeenCalledWith("u_lutDomainMax", 1, 1, 1);
|
||||
expect(runtime.getStatus("#hero-video").message).toBe("Shader + LUT active");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ import { createRuntimeStartTimeResolver } from "./startResolver";
|
||||
import { createClipTree } from "./clipTree";
|
||||
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
|
||||
import { applyCaptionOverrides } from "./captionOverrides";
|
||||
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
|
||||
import { TransportClock } from "./clock";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
|
||||
@@ -36,6 +37,7 @@ const AUTHORED_END_ATTR = "data-hf-authored-end";
|
||||
|
||||
export function initSandboxRuntimeModular(): void {
|
||||
const state = createRuntimeState();
|
||||
let colorGradingRuntime: RuntimeColorGradingApi | null = null;
|
||||
let runtimeErrorListener: ((event: ErrorEvent) => void) | null = null;
|
||||
let runtimeUnhandledRejectionListener: ((event: PromiseRejectionEvent) => void) | null = null;
|
||||
const runtimeCleanupCallbacks: Array<() => void> = [];
|
||||
@@ -1534,6 +1536,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
}
|
||||
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
|
||||
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
|
||||
colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1678,6 +1683,13 @@ export function initSandboxRuntimeModular(): void {
|
||||
});
|
||||
picker.installPickerApi();
|
||||
|
||||
const colorGrading = createColorGradingRuntime();
|
||||
colorGradingRuntime = colorGrading;
|
||||
registerRuntimeCleanup(() => {
|
||||
colorGrading.destroy();
|
||||
colorGradingRuntime = null;
|
||||
});
|
||||
|
||||
const applyPlaybackRate = (nextRate: number) => {
|
||||
const parsed = Number(nextRate);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
@@ -1735,7 +1747,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
},
|
||||
onDeterministicPause: () => runAdapters("pause"),
|
||||
onDeterministicPlay: () => runAdapters("play"),
|
||||
onRenderFrameSeek: () => {},
|
||||
onRenderFrameSeek: () => {
|
||||
colorGrading.redraw();
|
||||
},
|
||||
onShowNativeVideos: () => {},
|
||||
getSafeDuration: () => getSafeTimelineDurationSeconds(state.capturedTimeline, 0),
|
||||
});
|
||||
@@ -1801,6 +1815,12 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
|
||||
applyWebAudioRate();
|
||||
},
|
||||
onSetColorGrading: (target, grading) => {
|
||||
colorGrading.setGrading(target, grading);
|
||||
},
|
||||
onSetColorGradingCompare: (target, compare) => {
|
||||
colorGrading.setCompare(target, compare);
|
||||
},
|
||||
onTick: () => {
|
||||
if (state.tornDown || !clock.isPlaying()) return;
|
||||
const t = clock.now();
|
||||
@@ -2288,6 +2308,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (webAudioReady) scheduleWebAudioForActiveClips();
|
||||
runAdapters("play");
|
||||
syncMediaForCurrentState();
|
||||
colorGrading.redraw();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
@@ -2304,6 +2325,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (tl) tl.pause();
|
||||
runAdapters("pause");
|
||||
syncMediaForCurrentState();
|
||||
colorGrading.redraw();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
@@ -2325,6 +2347,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
seekTimelineAndAdapters(state.currentTime);
|
||||
runAdapters("pause");
|
||||
syncMediaForCurrentState();
|
||||
colorGrading.redraw();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
@@ -2340,6 +2363,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
state.mediaForceSyncNextTick = true;
|
||||
seekTimelineAndAdapters(state.currentTime, { activateChildren: true });
|
||||
syncMediaForCurrentState();
|
||||
colorGrading.redraw();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const PICKER_BLOCK_SELECTOR = [
|
||||
"[data-hyperframes-picker-block]",
|
||||
"[data-hyper-shader-loading]",
|
||||
].join(",");
|
||||
const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden";
|
||||
|
||||
export type PickerModule = {
|
||||
enablePickMode: () => void;
|
||||
@@ -67,7 +68,12 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
if (computed.display === "none" || computed.visibility === "hidden") return true;
|
||||
if (computed.pointerEvents === "none") return true;
|
||||
const opacity = Number.parseFloat(computed.opacity);
|
||||
if (Number.isFinite(opacity) && opacity <= 0.01) return true;
|
||||
if (
|
||||
Number.isFinite(opacity) &&
|
||||
opacity <= 0.01 &&
|
||||
!current.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR)
|
||||
)
|
||||
return true;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { HfColorGradingTarget } from "../colorGrading";
|
||||
|
||||
export type RuntimeJson =
|
||||
| string
|
||||
| number
|
||||
@@ -24,6 +26,9 @@ export type RuntimeBridgeControlMessage = {
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
playbackRate?: number;
|
||||
target?: HfColorGradingTarget | string | null;
|
||||
grading?: RuntimeJson;
|
||||
compare?: RuntimeJson;
|
||||
seekMode?: "drag" | "commit";
|
||||
};
|
||||
|
||||
|
||||
+5
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeTimelineMessage, RuntimeTimelineLike } from "./types";
|
||||
import type { RuntimeColorGradingApi } from "./colorGrading";
|
||||
import type { HyperframePickerApi } from "../inline-scripts/pickerApi";
|
||||
import type { PlayerAPI } from "../core.types";
|
||||
import type { ClipTree } from "./clipTree";
|
||||
@@ -31,6 +32,10 @@ declare global {
|
||||
__player?: PlayerAPI;
|
||||
__clipManifest?: RuntimeTimelineMessage;
|
||||
__clipTree?: ClipTree;
|
||||
__hf?: {
|
||||
colorGrading?: RuntimeColorGradingApi;
|
||||
onSwallowed?: (label: string, err: unknown) => void;
|
||||
};
|
||||
__playerReady?: boolean;
|
||||
__renderReady?: boolean;
|
||||
__hfRuntimeTeardown?: (() => void) | null;
|
||||
|
||||
@@ -326,7 +326,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
return c.text("not found", 404);
|
||||
}
|
||||
const contentType = getMimeType(subPath);
|
||||
const isText = /\.(html|css|js|json|svg|txt|md)$/i.test(subPath);
|
||||
const isText = /\.(html|css|js|json|svg|txt|md|cube)$/i.test(subPath);
|
||||
|
||||
const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`;
|
||||
const cacheHeaders: Record<string, string> = isText
|
||||
|
||||
@@ -272,11 +272,10 @@ describe("createVideoFrameInjector cache hygiene against page-side skips", () =>
|
||||
injectVideoFramesBatchMock.mockResolvedValueOnce(["facet"]);
|
||||
await hook!(page, 1.5);
|
||||
|
||||
expect(evaluate).toHaveBeenCalledTimes(1);
|
||||
// Re-render is requested at the same time as the seek.
|
||||
expect(evaluate.mock.calls[0]![1]).toBe(1.5);
|
||||
const reseekCall = evaluate.mock.calls.find((call) => call[1] === 1.5);
|
||||
expect(reseekCall).toBeDefined();
|
||||
// The evaluated page function invokes window.__hfReseekGpu(time).
|
||||
const pageFn = evaluate.mock.calls[0]![0] as (t: number) => void;
|
||||
const pageFn = reseekCall![0] as (t: number) => void;
|
||||
const reseek = vi.fn();
|
||||
(globalThis as unknown as { window?: unknown }).window = { __hfReseekGpu: reseek };
|
||||
pageFn(1.5);
|
||||
@@ -296,6 +295,6 @@ describe("createVideoFrameInjector cache hygiene against page-side skips", () =>
|
||||
injectVideoFramesBatchMock.mockResolvedValueOnce([]);
|
||||
await hook!(page, 1.5);
|
||||
|
||||
expect(evaluate).not.toHaveBeenCalled();
|
||||
expect(evaluate.mock.calls.some((call) => call[1] === 1.5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,6 +146,25 @@ function createFrameSourceCache(
|
||||
|
||||
export const __testing = { createFrameSourceCache };
|
||||
|
||||
async function redrawRuntimeColorGrading(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const hf = (
|
||||
window as Window & {
|
||||
__hf?: {
|
||||
colorGrading?: { redraw?: () => unknown };
|
||||
};
|
||||
}
|
||||
).__hf;
|
||||
const redraw = hf?.colorGrading?.redraw;
|
||||
if (typeof redraw !== "function") return;
|
||||
try {
|
||||
redraw();
|
||||
} catch {
|
||||
// Optional page-side shader layer.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a BeforeCaptureHook that injects pre-extracted video frames
|
||||
* into the page, replacing native <video> elements with frame images.
|
||||
@@ -226,6 +245,7 @@ export function createVideoFrameInjector(
|
||||
}, time);
|
||||
}
|
||||
}
|
||||
await redrawRuntimeColorGrading(page);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,22 @@ import { type DirectTimelineAdapter } from "./timeline-adapters.js";
|
||||
const MIN_PLAYBACK_RATE = 0.1;
|
||||
const MAX_PLAYBACK_RATE = 5;
|
||||
|
||||
export type ColorGradingTarget =
|
||||
| string
|
||||
| {
|
||||
id?: string | null;
|
||||
hfId?: string | null;
|
||||
selector?: string | null;
|
||||
selectorIndex?: number | null;
|
||||
};
|
||||
|
||||
export type ColorGradingCompareState = {
|
||||
enabled: boolean;
|
||||
position?: number;
|
||||
softness?: number;
|
||||
lineWidth?: number;
|
||||
};
|
||||
|
||||
function clampPlaybackRate(rate: number): number {
|
||||
if (!Number.isFinite(rate) || rate <= 0) return 1;
|
||||
return Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, rate));
|
||||
@@ -303,6 +319,25 @@ class HyperframesPlayer extends HTMLElement {
|
||||
this.controlsApi?.updateTime(this._currentTime, this._duration);
|
||||
}
|
||||
|
||||
setColorGrading(target: ColorGradingTarget, grading: unknown) {
|
||||
this._sendControl("set-color-grading", { target, grading });
|
||||
}
|
||||
|
||||
clearColorGrading(target: ColorGradingTarget) {
|
||||
this._sendControl("set-color-grading", { target, grading: null });
|
||||
}
|
||||
|
||||
setColorGradingCompare(target: ColorGradingTarget, compare: ColorGradingCompareState) {
|
||||
this._sendControl("set-color-grading-compare", { target, compare });
|
||||
}
|
||||
|
||||
clearColorGradingCompare(target: ColorGradingTarget) {
|
||||
this._sendControl("set-color-grading-compare", {
|
||||
target,
|
||||
compare: { enabled: false },
|
||||
});
|
||||
}
|
||||
|
||||
get currentTime() {
|
||||
return this._currentTime;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,39 @@ describe("closeFileServerSafely", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function withFileServer(
|
||||
projectDir: string,
|
||||
run: (server: Awaited<ReturnType<typeof createFileServer>>) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const server = await createFileServer({
|
||||
projectDir,
|
||||
preHeadScripts: [],
|
||||
headScripts: [],
|
||||
bodyScripts: [],
|
||||
});
|
||||
try {
|
||||
await run(server);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
function writeEmptyIndex(projectDir: string): void {
|
||||
writeFileSync(join(projectDir, "index.html"), "<!doctype html><html></html>");
|
||||
}
|
||||
|
||||
async function expectTextResponse(
|
||||
url: string,
|
||||
options: { contentType?: string; bodyIncludes: string },
|
||||
): Promise<void> {
|
||||
const response = await fetch(url);
|
||||
expect(response.status).toBe(200);
|
||||
if (options.contentType) {
|
||||
expect(response.headers.get("content-type")).toContain(options.contentType);
|
||||
}
|
||||
expect(await response.text()).toContain(options.bodyIncludes);
|
||||
}
|
||||
|
||||
describe("injectScriptsIntoHtml", () => {
|
||||
it("injects the virtual time shim into head content before authored scripts", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
@@ -202,29 +235,19 @@ describe("createFileServer", () => {
|
||||
try {
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
mkdirSync(sharedDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, "index.html"), "<!doctype html><html></html>");
|
||||
writeEmptyIndex(projectDir);
|
||||
writeFileSync(
|
||||
join(sharedDir, "brand.css"),
|
||||
".aisplus-glass { backdrop-filter: blur(28px); }",
|
||||
);
|
||||
symlinkSync("../shared", join(projectDir, "shared"));
|
||||
|
||||
const server = await createFileServer({
|
||||
projectDir,
|
||||
preHeadScripts: [],
|
||||
headScripts: [],
|
||||
bodyScripts: [],
|
||||
await withFileServer(projectDir, async (server) => {
|
||||
await expectTextResponse(`${server.url}/shared/brand.css`, {
|
||||
contentType: "text/css",
|
||||
bodyIncludes: ".aisplus-glass",
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${server.url}/shared/brand.css`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("text/css");
|
||||
expect(await response.text()).toContain(".aisplus-glass");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -236,23 +259,14 @@ describe("createFileServer", () => {
|
||||
try {
|
||||
const subDir = join(projectDir, "video#1");
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, "index.html"), "<!doctype html><html></html>");
|
||||
writeEmptyIndex(projectDir);
|
||||
writeFileSync(join(subDir, "frame.jpg"), "fake-jpg");
|
||||
|
||||
const server = await createFileServer({
|
||||
projectDir,
|
||||
preHeadScripts: [],
|
||||
headScripts: [],
|
||||
bodyScripts: [],
|
||||
await withFileServer(projectDir, async (server) => {
|
||||
await expectTextResponse(`${server.url}/video%231/frame.jpg`, {
|
||||
bodyIncludes: "fake-jpg",
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`${server.url}/video%231/frame.jpg`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("fake-jpg");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ const MIME_TYPES: Record<string, string> = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".cube": "text/plain; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
|
||||
@@ -30,7 +30,15 @@ async function getSharedBrowser(): Promise<import("puppeteer-core").Browser | nu
|
||||
_browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
executablePath,
|
||||
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--enable-webgl",
|
||||
"--ignore-gpu-blocklist",
|
||||
"--use-gl=angle",
|
||||
"--use-angle=swiftshader",
|
||||
"--enable-unsafe-swiftshader",
|
||||
],
|
||||
});
|
||||
_browserLaunchPromise = null;
|
||||
return _browser;
|
||||
|
||||
Reference in New Issue
Block a user