fix(runtime): honor render fps when seeking (#1739)
This commit is contained in:
@@ -109,6 +109,7 @@ describe("initSandboxRuntimeModular", () => {
|
||||
delete window.__player;
|
||||
delete window.__playerReady;
|
||||
delete window.__renderReady;
|
||||
delete (window as { __HF_EXPORT_RENDER_SEEK_CONFIG?: unknown }).__HF_EXPORT_RENDER_SEEK_CONFIG;
|
||||
delete window.__hfTimelinesBuilding;
|
||||
delete (window as { THREE?: unknown }).THREE;
|
||||
vi.restoreAllMocks();
|
||||
@@ -146,6 +147,99 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(child.style.visibility).toBe("visible");
|
||||
});
|
||||
|
||||
it("uses export render fps when quantizing renderSeek", () => {
|
||||
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-duration", "1");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const timeline = createMockTimeline(1);
|
||||
window.__timelines = { main: timeline };
|
||||
(
|
||||
window as {
|
||||
__HF_EXPORT_RENDER_SEEK_CONFIG?: { fps: number; fpsSource: "render-options" };
|
||||
}
|
||||
).__HF_EXPORT_RENDER_SEEK_CONFIG = {
|
||||
fps: 60,
|
||||
fpsSource: "render-options",
|
||||
};
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
window.__player?.renderSeek(1 / 60);
|
||||
|
||||
expect(timeline.time()).toBeCloseTo(1 / 60, 6);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
"[hyperframes] render runtime fps",
|
||||
expect.objectContaining({
|
||||
canonicalFps: 60,
|
||||
source: "render-options",
|
||||
rawFpsSource: "render-options",
|
||||
rawFps: 60,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces unknown export render fps sources without collapsing them to render-options", () => {
|
||||
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-duration", "1");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
window.__timelines = { main: createMockTimeline(1) };
|
||||
(
|
||||
window as {
|
||||
__HF_EXPORT_RENDER_SEEK_CONFIG?: { fps: number; fpsSource: string };
|
||||
}
|
||||
).__HF_EXPORT_RENDER_SEEK_CONFIG = {
|
||||
fps: 60,
|
||||
fpsSource: "future-source",
|
||||
};
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
"[hyperframes] render runtime fps",
|
||||
expect.objectContaining({
|
||||
canonicalFps: 60,
|
||||
source: "unknown",
|
||||
rawFpsSource: "future-source",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the default 30fps renderSeek grid when export render fps is absent", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-duration", "1");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const timeline = createMockTimeline(1);
|
||||
window.__timelines = { main: timeline };
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
// This is the originally broken 60fps render sample under the historical
|
||||
// 30fps runtime default: floor((1 / 60) * 30) / 30 = 0.
|
||||
window.__player?.renderSeek(1 / 60);
|
||||
|
||||
expect(timeline.time()).toBe(0);
|
||||
});
|
||||
|
||||
it("uses live child timeline duration when a composition host has no authored duration", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
|
||||
@@ -40,8 +40,49 @@ import { swallow } from "./diagnostics";
|
||||
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
|
||||
const AUTHORED_END_ATTR = "data-hf-authored-end";
|
||||
|
||||
type ExportRenderFpsResolution = {
|
||||
fps: number | null;
|
||||
source: "render-options" | "default" | "unknown";
|
||||
rawFpsSource: unknown;
|
||||
rawFps: unknown;
|
||||
fallbackReason?: "missing" | "invalid";
|
||||
};
|
||||
|
||||
function resolveExportRenderFps(): ExportRenderFpsResolution {
|
||||
const config = window.__HF_EXPORT_RENDER_SEEK_CONFIG;
|
||||
const rawFps = config?.fps;
|
||||
const rawFpsSource = config?.fpsSource;
|
||||
const fps = Number(rawFps);
|
||||
if (!config || rawFps == null) {
|
||||
return { fps: null, source: "default", rawFpsSource, rawFps, fallbackReason: "missing" };
|
||||
}
|
||||
if (!Number.isFinite(fps) || fps <= 0) {
|
||||
return { fps: null, source: "default", rawFpsSource, rawFps, fallbackReason: "invalid" };
|
||||
}
|
||||
const source =
|
||||
rawFpsSource === "render-options" || rawFpsSource === "default" ? rawFpsSource : "unknown";
|
||||
return {
|
||||
fps,
|
||||
source,
|
||||
rawFpsSource,
|
||||
rawFps,
|
||||
fallbackReason: config.fpsFallbackReason,
|
||||
};
|
||||
}
|
||||
|
||||
export function initSandboxRuntimeModular(): void {
|
||||
const state = createRuntimeState();
|
||||
const exportRenderFps = resolveExportRenderFps();
|
||||
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
|
||||
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
|
||||
console.info("[hyperframes] render runtime fps", {
|
||||
canonicalFps: state.canonicalFps,
|
||||
source: exportRenderFps.source,
|
||||
rawFpsSource: exportRenderFps.rawFpsSource,
|
||||
rawFps: exportRenderFps.rawFps,
|
||||
fallbackReason: exportRenderFps.fallbackReason,
|
||||
});
|
||||
}
|
||||
let colorGradingRuntime: RuntimeColorGradingApi | null = null;
|
||||
let runtimeErrorListener: ((event: ErrorEvent) => void) | null = null;
|
||||
let runtimeUnhandledRejectionListener: ((event: PromiseRejectionEvent) => void) | null = null;
|
||||
|
||||
+11
@@ -39,7 +39,18 @@ declare global {
|
||||
__playerReady?: boolean;
|
||||
__renderReady?: boolean;
|
||||
__hfRuntimeTeardown?: (() => void) | null;
|
||||
__HF_EXPORT_RENDER_SEEK_CONFIG?: {
|
||||
mode?: string;
|
||||
diagnostics?: boolean;
|
||||
step?: number;
|
||||
offsetFraction?: number;
|
||||
fps?: number;
|
||||
fpsSource?: "render-options" | "default";
|
||||
fpsFallbackReason?: "missing" | "invalid";
|
||||
owner?: string;
|
||||
};
|
||||
__HF_PARITY_MODE?: boolean;
|
||||
/** Legacy debug-only fps hint. Render-mode runtime fps uses __HF_EXPORT_RENDER_SEEK_CONFIG.fps. */
|
||||
__HF_FPS?: number;
|
||||
__HF_MAX_DURATION_SEC?: number;
|
||||
__hfThreeTime?: number;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatHttpErrorDiagnostic,
|
||||
formatConsoleDiagnostic,
|
||||
formatNavigationFailureDiagnostic,
|
||||
formatNavigationStartDiagnostic,
|
||||
formatRequestFailureDiagnostic,
|
||||
@@ -118,6 +119,37 @@ describe("isFontResourceError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatConsoleDiagnostic", () => {
|
||||
it("surfaces HyperFrames page logs with a dedicated host prefix", () => {
|
||||
expect(
|
||||
formatConsoleDiagnostic("info", "[hyperframes] render runtime fps JSHandle@object", ""),
|
||||
).toEqual({
|
||||
text: "[HyperFrames] render runtime fps JSHandle@object",
|
||||
suppressHostLog: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps font load errors in diagnostics but suppresses host log noise", () => {
|
||||
expect(
|
||||
formatConsoleDiagnostic(
|
||||
"error",
|
||||
"Failed to load resource: net::ERR_FAILED",
|
||||
"https://fonts.googleapis.com/css2?family=Inter",
|
||||
),
|
||||
).toEqual({
|
||||
text: "[Browser] Failed to load resource: net::ERR_FAILED",
|
||||
suppressHostLog: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing browser prefixes for generic logs", () => {
|
||||
expect(formatConsoleDiagnostic("warn", "careful", "")).toEqual({
|
||||
text: "[Browser:WARN] careful",
|
||||
suppressHostLog: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation diagnostics", () => {
|
||||
it("redacts credentials, query strings, and fragments from diagnostic URLs", () => {
|
||||
expect(
|
||||
|
||||
@@ -506,6 +506,36 @@ export function isFontResourceError(type: string, text: string, locationUrl: str
|
||||
);
|
||||
}
|
||||
|
||||
export function formatConsoleDiagnostic(
|
||||
type: string,
|
||||
text: string,
|
||||
locationUrl: string,
|
||||
): { text: string; suppressHostLog: boolean } {
|
||||
const isFontLoadError = isFontResourceError(type, text, locationUrl);
|
||||
if (isFontLoadError) return { text: `[Browser] ${text}`, suppressHostLog: true };
|
||||
|
||||
if (text.startsWith("[hyperframes]")) {
|
||||
return {
|
||||
text: `[HyperFrames] ${text.slice("[hyperframes]".length).trim()}`,
|
||||
suppressHostLog: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Other "Failed to load resource" 404s are typically non-blocking (e.g.
|
||||
// favicon, sourcemaps, optional assets). Prefix them so users know they
|
||||
// are harmless and don't confuse them with real render errors.
|
||||
const isResourceLoadError = type === "error" && text.startsWith("Failed to load resource");
|
||||
const prefix = isResourceLoadError
|
||||
? "[non-blocking]"
|
||||
: type === "error"
|
||||
? "[Browser:ERROR]"
|
||||
: type === "warn"
|
||||
? "[Browser:WARN]"
|
||||
: "[Browser]";
|
||||
|
||||
return { text: `${prefix} ${text}`, suppressHostLog: false };
|
||||
}
|
||||
|
||||
async function pollPageExpression(
|
||||
page: Page,
|
||||
expression: string,
|
||||
@@ -866,32 +896,15 @@ async function waitForOptionalTailwindReady(page: Page, timeoutMs: number): Prom
|
||||
export async function initializeSession(session: CaptureSession): Promise<void> {
|
||||
const { page, serverUrl } = session;
|
||||
|
||||
// Forward browser console to host with [Browser] prefix
|
||||
// fallow-ignore-next-line complexity
|
||||
// Forward browser console to host. HyperFrames runtime logs get a dedicated
|
||||
// prefix so page-context observability is visible in producer stdout.
|
||||
page.on("console", (msg: ConsoleMessage) => {
|
||||
const type = msg.type();
|
||||
const text = msg.text();
|
||||
const locationUrl = msg.location()?.url ?? "";
|
||||
const isFontLoadError = isFontResourceError(type, text, locationUrl);
|
||||
|
||||
// Other "Failed to load resource" 404s are typically non-blocking (e.g.
|
||||
// favicon, sourcemaps, optional assets). Prefix them so users know they
|
||||
// are harmless and don't confuse them with real render errors.
|
||||
const isResourceLoadError =
|
||||
type === "error" && text.startsWith("Failed to load resource") && !isFontLoadError;
|
||||
|
||||
const prefix = isResourceLoadError
|
||||
? "[non-blocking]"
|
||||
: type === "error"
|
||||
? "[Browser:ERROR]"
|
||||
: type === "warn"
|
||||
? "[Browser:WARN]"
|
||||
: "[Browser]";
|
||||
if (!isFontLoadError) {
|
||||
console.log(`${prefix} ${text}`);
|
||||
}
|
||||
|
||||
appendBrowserDiagnostic(session, `${prefix} ${text}`);
|
||||
const diagnostic = formatConsoleDiagnostic(type, text, locationUrl);
|
||||
if (!diagnostic.suppressHostLog) console.log(diagnostic.text);
|
||||
appendBrowserDiagnostic(session, diagnostic.text);
|
||||
});
|
||||
|
||||
page.on("pageerror", (err) => {
|
||||
|
||||
@@ -500,6 +500,9 @@ export async function renderChunk(
|
||||
compiledDir,
|
||||
port: 0,
|
||||
preHeadScripts: [buildVirtualTimeShim({ seedRandomFromFrame: true })],
|
||||
// These dimensions are frozen by the controller from the render job, so
|
||||
// chunk runtime seek quantization stays on the same fps grid as capture.
|
||||
fps: { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
|
||||
});
|
||||
|
||||
const captureOptions: CaptureOptions = {
|
||||
|
||||
@@ -315,6 +315,77 @@ describe("parseRangeHeader", () => {
|
||||
});
|
||||
|
||||
describe("createFileServer", () => {
|
||||
async function expectInjectedRenderFps(
|
||||
fps: Parameters<typeof createFileServer>[0]["fps"],
|
||||
expected: {
|
||||
value: string;
|
||||
source: "render-options" | "default";
|
||||
fallbackReason?: "missing" | "invalid";
|
||||
},
|
||||
): Promise<void> {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-render-fps-"));
|
||||
|
||||
try {
|
||||
writeEmptyIndex(projectDir);
|
||||
const server = await createFileServer({
|
||||
projectDir,
|
||||
preHeadScripts: [],
|
||||
headScripts: [],
|
||||
...(fps ? { fps } : {}),
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${server.url}/index.html`);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
expect(html).toContain("window.__HF_EXPORT_RENDER_SEEK_CONFIG");
|
||||
expect(html).toContain(`var __renderFps = ${expected.value}`);
|
||||
expect(html).toContain(`var __renderFpsSource = "${expected.source}"`);
|
||||
if (expected.fallbackReason) {
|
||||
expect(html).toContain(`var __renderFpsFallbackReason = "${expected.fallbackReason}"`);
|
||||
} else {
|
||||
expect(html).toContain("var __renderFpsFallbackReason = null");
|
||||
}
|
||||
expect(html).toContain("fps: __renderFps");
|
||||
expect(html).toContain("fpsSource: __renderFpsSource");
|
||||
expect(html).not.toContain("[hyperframes] render fps defaulted");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it("injects the requested render fps into the page render config", async () => {
|
||||
await expectInjectedRenderFps({ num: 60, den: 1 }, { value: "60", source: "render-options" });
|
||||
});
|
||||
|
||||
it("injects fractional render fps without rounding", async () => {
|
||||
await expectInjectedRenderFps(
|
||||
{ num: 24000, den: 1001 },
|
||||
{ value: "23.976023976023978", source: "render-options" },
|
||||
);
|
||||
});
|
||||
|
||||
it("marks missing render fps as an explicit 30fps default", async () => {
|
||||
await expectInjectedRenderFps(undefined, {
|
||||
value: "30",
|
||||
source: "default",
|
||||
fallbackReason: "missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks invalid render fps as an explicit 30fps default", async () => {
|
||||
await expectInjectedRenderFps(
|
||||
{ num: 60, den: 0 },
|
||||
{
|
||||
value: "30",
|
||||
source: "default",
|
||||
fallbackReason: "invalid",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("serves asset files through project-root symlinked directories", async () => {
|
||||
const workspaceDir = mkdtempSync(join(tmpdir(), "hf-file-server-symlink-assets-"));
|
||||
const adsDir = join(workspaceDir, "Ads");
|
||||
|
||||
@@ -16,6 +16,7 @@ import { readFile } from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { join, extname, resolve, sep } from "node:path";
|
||||
import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler";
|
||||
import { fpsToNumber, type Fps } from "@hyperframes/core";
|
||||
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
|
||||
import { getHfEarlyStub } from "../generated/hf-early-stub-inline.js";
|
||||
import { defaultLogger, type ProducerLogger } from "../logger.js";
|
||||
@@ -390,7 +391,22 @@ const RENDER_SEEK_OFFSET_FRACTION = Math.max(
|
||||
Math.min(0.95, Number(process.env.PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION || 0.5)),
|
||||
);
|
||||
|
||||
const RENDER_MODE_SCRIPT = `(function() {
|
||||
function resolveRenderFpsConfig(fps: Fps | undefined): {
|
||||
value: number;
|
||||
source: "render-options" | "default";
|
||||
fallbackReason?: "missing" | "invalid";
|
||||
} {
|
||||
if (!fps) return { value: 30, source: "default", fallbackReason: "missing" };
|
||||
const value = fpsToNumber(fps);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return { value: 30, source: "default", fallbackReason: "invalid" };
|
||||
}
|
||||
return { value, source: "render-options" };
|
||||
}
|
||||
|
||||
function buildRenderModeScript(fps: Fps | undefined): string {
|
||||
const renderFps = resolveRenderFpsConfig(fps);
|
||||
return `(function() {
|
||||
var __realSetTimeout =
|
||||
window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.originalSetTimeout === "function"
|
||||
? window.__HF_VIRTUAL_TIME__.originalSetTimeout
|
||||
@@ -399,11 +415,17 @@ const RENDER_MODE_SCRIPT = `(function() {
|
||||
var __seekDiagnostics = ${RENDER_SEEK_DIAGNOSTICS ? "true" : "false"};
|
||||
var __seekStep = ${RENDER_SEEK_STEP};
|
||||
var __seekOffsetFraction = ${RENDER_SEEK_OFFSET_FRACTION};
|
||||
var __renderFps = ${renderFps.value};
|
||||
var __renderFpsSource = ${JSON.stringify(renderFps.source)};
|
||||
var __renderFpsFallbackReason = ${JSON.stringify(renderFps.fallbackReason ?? null)};
|
||||
window.__HF_EXPORT_RENDER_SEEK_CONFIG = {
|
||||
mode: __seekMode,
|
||||
diagnostics: __seekDiagnostics,
|
||||
step: __seekStep,
|
||||
offsetFraction: __seekOffsetFraction,
|
||||
fps: __renderFps,
|
||||
fpsSource: __renderFpsSource,
|
||||
fpsFallbackReason: __renderFpsFallbackReason || undefined,
|
||||
owner: "runtime",
|
||||
};
|
||||
function installMediaFallbackPlayer() {
|
||||
@@ -499,6 +521,7 @@ const RENDER_MODE_SCRIPT = `(function() {
|
||||
}
|
||||
waitForPlayer();
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Early stub: ensures `window.__hf` exists *before* any user `<script>` in
|
||||
@@ -640,6 +663,8 @@ export interface FileServerOptions {
|
||||
headScripts?: string[];
|
||||
/** Scripts injected before </body> of index.html. Default: render mode extension. */
|
||||
bodyScripts?: string[];
|
||||
/** Actual render fps so page-side runtime quantization matches the output container. */
|
||||
fps?: Fps;
|
||||
/** Strip embedded runtime scripts from HTML before injection. Default: true. */
|
||||
stripEmbeddedRuntime?: boolean;
|
||||
}
|
||||
@@ -687,7 +712,7 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
|
||||
const preHeadScripts = [HF_EARLY_STUB, ...(options.preHeadScripts ?? [])];
|
||||
// Default scripts: Hyperframe runtime in <head>, render mode in </body>
|
||||
const headScripts = options.headScripts ?? [getVerifiedHyperframeRuntimeSource()];
|
||||
const bodyScripts = options.bodyScripts ?? [RENDER_MODE_SCRIPT, HF_BRIDGE_SCRIPT];
|
||||
const bodyScripts = options.bodyScripts ?? [buildRenderModeScript(options.fps), HF_BRIDGE_SCRIPT];
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
compiledDir: join(workDir, "compiled"),
|
||||
port: 0,
|
||||
preHeadScripts: [VIRTUAL_TIME_SHIM],
|
||||
fps: job.config.fps,
|
||||
});
|
||||
assertNotAborted();
|
||||
|
||||
|
||||
@@ -1227,6 +1227,7 @@ export async function executeRenderJob(
|
||||
compiledDir: join(workDir, "compiled"),
|
||||
port: 0,
|
||||
preHeadScripts: [VIRTUAL_TIME_SHIM],
|
||||
fps: job.config.fps,
|
||||
});
|
||||
assertNotAborted();
|
||||
observability.stageEnd("file_server", fileServerStart);
|
||||
|
||||
@@ -238,7 +238,11 @@ async function generateThumbnail(item: CatalogItem, projectDir: string): Promise
|
||||
const framesDir = join(projectDir, "_thumb_frames");
|
||||
mkdirSync(framesDir, { recursive: true });
|
||||
|
||||
const fileServer = await createFileServer({ projectDir, port: 0 });
|
||||
const fileServer = await createFileServer({
|
||||
projectDir,
|
||||
port: 0,
|
||||
fps: { num: 30, den: 1 },
|
||||
});
|
||||
try {
|
||||
const session = await createCaptureSession(fileServer.url, framesDir, {
|
||||
width,
|
||||
|
||||
@@ -141,7 +141,11 @@ async function generateThumbnail(templateId: string, projectDir: string): Promis
|
||||
const framesDir = join(projectDir, "_thumb_frames");
|
||||
mkdirSync(framesDir, { recursive: true });
|
||||
|
||||
const fileServer = await createFileServer({ projectDir, port: 0 });
|
||||
const fileServer = await createFileServer({
|
||||
projectDir,
|
||||
port: 0,
|
||||
fps: { num: 30, den: 1 },
|
||||
});
|
||||
try {
|
||||
const session = await createCaptureSession(fileServer.url, framesDir, {
|
||||
width: config.width,
|
||||
|
||||
Reference in New Issue
Block a user