mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(studio): support GIF export with loop count (#5593)
* feat(studio): support GIF export with loop count * fix(studio): cap GIF exports at 30 fps * fix(studio): complete GIF export review follow-ups
This commit is contained in:
@@ -17,6 +17,14 @@ The whole loop: the project, the render command, progress, and the finished file
|
|||||||
playing. The flags shown are the ones people actually reach for — format,
|
playing. The flags shown are the ones people actually reach for — format,
|
||||||
resolution, frame rate, quality.
|
resolution, frame rate, quality.
|
||||||
|
|
||||||
|
## Export a GIF from Studio
|
||||||
|
|
||||||
|
Open the **Renders** panel, choose **GIF**, set the loop count, and click **Export**.
|
||||||
|
Use `0` to loop forever or an integer from `1` to `65535` for a finite loop count.
|
||||||
|
GIF exports have no audio and are capped at 30 fps; the 60 fps option is disabled
|
||||||
|
while GIF is selected. Finished GIFs preview in the render list and can be
|
||||||
|
opened, downloaded, or deleted there.
|
||||||
|
|
||||||
## Render a normal video
|
## Render a normal video
|
||||||
|
|
||||||
From the project folder:
|
From the project folder:
|
||||||
|
|||||||
@@ -21,15 +21,10 @@ import type { RenderCpuTelemetryPayload } from "../telemetry/renderCpu.js";
|
|||||||
export interface StudioRenderOpts {
|
export interface StudioRenderOpts {
|
||||||
fps: Fps;
|
fps: Fps;
|
||||||
quality: string;
|
quality: string;
|
||||||
/** Output container format; Studio requests are constrained to these three. */
|
format?: "mp4" | "webm" | "mov" | "gif";
|
||||||
format?: "mp4" | "webm" | "mov";
|
|
||||||
/** Named canvas preset the Studio request asked for; undefined renders at the composition's native dimensions. */
|
/** Named canvas preset the Studio request asked for; undefined renders at the composition's native dimensions. */
|
||||||
outputResolution?: CanvasResolution;
|
outputResolution?: CanvasResolution;
|
||||||
useGpu?: boolean;
|
useGpu?: boolean;
|
||||||
// gif_fps_capped/hdr_mode/video_frame_format are deliberately absent: Studio
|
|
||||||
// has no gif/png-sequence option and createRenderJob never receives an
|
|
||||||
// hdrMode or videoFrameFormat from the Studio request type, so there is no
|
|
||||||
// per-request value: see the PR description for the full reasoning.
|
|
||||||
// Telemetry id of the browser user who triggered the render, so the render
|
// Telemetry id of the browser user who triggered the render, so the render
|
||||||
// outcome joins their studio_session_start / studio_render_start events.
|
// outcome joins their studio_session_start / studio_render_start events.
|
||||||
// Undefined for older studio clients → falls back to the install anonymousId.
|
// Undefined for older studio clients → falls back to the install anonymousId.
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
waitForRenderCancellation,
|
||||||
|
cancelAndVerifyArtifacts,
|
||||||
|
} from "../../../../test-utils/renderCancellation";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
@@ -408,6 +412,46 @@ describe("createStudioServer shutdown", () => {
|
|||||||
rmSync(outDir, { recursive: true, force: true });
|
rmSync(outDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards GIF loop count and writes a metadata sidecar without overwriting output", async () => {
|
||||||
|
producerState.executeRenderJob = async (job, _dir, outputPath) => {
|
||||||
|
expect(job).toMatchObject({ format: "gif", gifLoop: 3 });
|
||||||
|
writeFileSync(outputPath, "GIF89a");
|
||||||
|
};
|
||||||
|
const projectDir = tmpProject();
|
||||||
|
const outputPath = join(projectDir, "out.gif");
|
||||||
|
server = createStudioServer({ projectDir });
|
||||||
|
const state = server.adapter.startRender({
|
||||||
|
...startRenderOpts("gif-job", outputPath),
|
||||||
|
format: "gif",
|
||||||
|
gifLoop: 3,
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(state.status, state.error).toBe("complete"));
|
||||||
|
expect(readFileSync(outputPath, "utf8")).toBe("GIF89a");
|
||||||
|
expect(JSON.parse(readFileSync(join(projectDir, "out.meta.json"), "utf8"))).toMatchObject({
|
||||||
|
status: "complete",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
"removes cancelled GIF output and sidecar (late completion: %s)",
|
||||||
|
async (lateCompletion) => {
|
||||||
|
const projectDir = tmpProject();
|
||||||
|
const outputPath = join(projectDir, "out.gif");
|
||||||
|
const metaPath = join(projectDir, "out.meta.json");
|
||||||
|
producerState.executeRenderJob = async (_job, _dir, output, _progress, signal) => {
|
||||||
|
writeFileSync(output, "partial-gif");
|
||||||
|
writeFileSync(metaPath, "{}");
|
||||||
|
await waitForRenderCancellation(signal, lateCompletion);
|
||||||
|
};
|
||||||
|
server = createStudioServer({ projectDir });
|
||||||
|
const state = server.adapter.startRender({
|
||||||
|
...startRenderOpts("gif-job", outputPath),
|
||||||
|
format: "gif",
|
||||||
|
});
|
||||||
|
await cancelAndVerifyArtifacts(state, outputPath, metaPath);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("cancels an in-flight render's signal and waits for it before draining the browser pool", async () => {
|
it("cancels an in-flight render's signal and waits for it before draining the browser pool", async () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
let started!: () => void;
|
let started!: () => void;
|
||||||
|
|||||||
@@ -646,7 +646,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
|||||||
state.status = "cancelled";
|
state.status = "cancelled";
|
||||||
for (const suffix of ["", ".meta.json"]) {
|
for (const suffix of ["", ".meta.json"]) {
|
||||||
const fp = suffix
|
const fp = suffix
|
||||||
? opts.outputPath.replace(/\.(mp4|webm|mov)$/, suffix)
|
? opts.outputPath.replace(/\.(mp4|webm|mov|gif)$/, suffix)
|
||||||
: opts.outputPath;
|
: opts.outputPath;
|
||||||
try {
|
try {
|
||||||
if (existsSync(fp)) unlinkSync(fp);
|
if (existsSync(fp)) unlinkSync(fp);
|
||||||
@@ -670,6 +670,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
|||||||
fps: opts.fps,
|
fps: opts.fps,
|
||||||
quality: opts.quality as "draft" | "standard" | "high",
|
quality: opts.quality as "draft" | "standard" | "high",
|
||||||
format: opts.format,
|
format: opts.format,
|
||||||
|
...(opts.gifLoop !== undefined ? { gifLoop: opts.gifLoop } : {}),
|
||||||
outputResolution: opts.outputResolution,
|
outputResolution: opts.outputResolution,
|
||||||
...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}),
|
...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}),
|
||||||
...(opts.composition ? { entryFile: opts.composition } : {}),
|
...(opts.composition ? { entryFile: opts.composition } : {}),
|
||||||
@@ -702,7 +703,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
|||||||
if (job.audioLoweredDb !== undefined) state.audioLoweredDb = job.audioLoweredDb;
|
if (job.audioLoweredDb !== undefined) state.audioLoweredDb = job.audioLoweredDb;
|
||||||
state.status = "complete";
|
state.status = "complete";
|
||||||
state.progress = 100;
|
state.progress = 100;
|
||||||
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
|
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov|gif)$/, ".meta.json");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
metaPath,
|
metaPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -736,7 +737,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
|||||||
cpu.stop(),
|
cpu.stop(),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
|
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov|gif)$/, ".meta.json");
|
||||||
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
|
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const VIDEO_FRAME_FORMATS = ["auto", "jpg", "png"] as const;
|
|||||||
const HDR_MODES = ["auto", "force-hdr", "force-sdr"] as const;
|
const HDR_MODES = ["auto", "force-hdr", "force-sdr"] as const;
|
||||||
|
|
||||||
interface EncoderOptions {
|
interface EncoderOptions {
|
||||||
|
gifLoop?: number;
|
||||||
crf?: number;
|
crf?: number;
|
||||||
videoBitrate?: string;
|
videoBitrate?: string;
|
||||||
videoFrameFormat?: (typeof VIDEO_FRAME_FORMATS)[number];
|
videoFrameFormat?: (typeof VIDEO_FRAME_FORMATS)[number];
|
||||||
@@ -11,6 +12,7 @@ interface EncoderOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface EncoderOptionsBody {
|
export interface EncoderOptionsBody {
|
||||||
|
gifLoop?: unknown;
|
||||||
crf?: unknown;
|
crf?: unknown;
|
||||||
videoBitrate?: unknown;
|
videoBitrate?: unknown;
|
||||||
videoFrameFormat?: unknown;
|
videoFrameFormat?: unknown;
|
||||||
@@ -55,6 +57,17 @@ export function parseEncoderOptions(body: EncoderOptionsBody, format: string): P
|
|||||||
const rate = parseRateControl(body, format);
|
const rate = parseRateControl(body, format);
|
||||||
if (!rate.ok) return rate;
|
if (!rate.ok) return rate;
|
||||||
const options: EncoderOptions = { ...rate.options };
|
const options: EncoderOptions = { ...rate.options };
|
||||||
|
if (isSet(body.gifLoop)) {
|
||||||
|
if (
|
||||||
|
typeof body.gifLoop !== "number" ||
|
||||||
|
!Number.isInteger(body.gifLoop) ||
|
||||||
|
body.gifLoop < 0 ||
|
||||||
|
body.gifLoop > 65535
|
||||||
|
) {
|
||||||
|
return { ok: false, error: "gifLoop must be an integer from 0 to 65535" };
|
||||||
|
}
|
||||||
|
if (format === "gif") options.gifLoop = body.gifLoop;
|
||||||
|
}
|
||||||
if (isSet(body.videoFrameFormat)) {
|
if (isSet(body.videoFrameFormat)) {
|
||||||
if (!oneOf(body.videoFrameFormat, VIDEO_FRAME_FORMATS)) {
|
if (!oneOf(body.videoFrameFormat, VIDEO_FRAME_FORMATS)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -71,6 +71,17 @@ describe("project history and media files", () => {
|
|||||||
expect(blobBytes(historyRoot, history)).toBeLessThan(1024);
|
expect(blobBytes(historyRoot, history)).toBeLessThan(1024);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves GIF render outputs out of project history", async () => {
|
||||||
|
const { history, historyRoot, projectDir, write } = await project({ "index.html": "A" });
|
||||||
|
const render = await history.beginWindow(you, "Export GIF");
|
||||||
|
write("renders/export.gif", Buffer.alloc(2 * MiB, 7));
|
||||||
|
const entry = await render.close();
|
||||||
|
expect(entry?.files ?? []).toEqual([]);
|
||||||
|
expect(history.peek("start")).not.toHaveProperty(["renders/export.gif"]);
|
||||||
|
expect(statSync(join(projectDir, "renders/export.gif")).size).toBe(2 * MiB);
|
||||||
|
expect(blobBytes(historyRoot, history)).toBeLessThan(1024);
|
||||||
|
});
|
||||||
|
|
||||||
it("copies no bytes of media written or replaced while history is open", async () => {
|
it("copies no bytes of media written or replaced while history is open", async () => {
|
||||||
const { history, historyRoot, write } = await project({ "index.html": "A" });
|
const { history, historyRoot, write } = await project({ "index.html": "A" });
|
||||||
const window = await history.beginWindow(you, "Render");
|
const window = await history.beginWindow(you, "Render");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
|
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, existsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
|
import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
|
||||||
@@ -1044,3 +1044,126 @@ describe("audioLoweredDb — the limiter's attenuation reaches the host", () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Studio GIF renders", () => {
|
||||||
|
it.each([undefined, null, 0, 1, 65535])("accepts GIF with gifLoop %j", async (gifLoop) => {
|
||||||
|
const spy = vi.fn();
|
||||||
|
const { app, cleanup } = buildApp(spy);
|
||||||
|
try {
|
||||||
|
const res = await app.request("/projects/demo/render", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ format: "gif", gifLoop, gpu: true }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
format: "gif",
|
||||||
|
outputPath: expect.stringMatching(/\.gif$/),
|
||||||
|
useGpu: false,
|
||||||
|
...(typeof gifLoop === "number" ? { gifLoop } : {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (gifLoop == null) expect(spy.mock.calls[0][0]).not.toHaveProperty("gifLoop");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([-1, 65536, 1.5, "2", true, {}, []])(
|
||||||
|
"rejects invalid gifLoop %j before starting",
|
||||||
|
async (gifLoop) => {
|
||||||
|
const spy = vi.fn();
|
||||||
|
const { app, cleanup } = buildApp(spy);
|
||||||
|
try {
|
||||||
|
const res = await app.request("/projects/demo/render", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ format: "gif", gifLoop }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ error: "gifLoop must be an integer from 0 to 65535" });
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("leaves video exports unchanged when a valid GIF loop option is supplied", async () => {
|
||||||
|
const spy = vi.fn();
|
||||||
|
const { app, cleanup } = buildApp(spy);
|
||||||
|
try {
|
||||||
|
const res = await app.request("/projects/demo/render", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ format: "mp4", gifLoop: 3 }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(spy.mock.calls[0][0]).not.toHaveProperty("gifLoop");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists a saved GIF after restart, serves image bytes, and deletes its sidecar", async () => {
|
||||||
|
const { app, rendersDir, cleanup } = buildApp(vi.fn());
|
||||||
|
try {
|
||||||
|
const output = join(rendersDir, "saved.gif");
|
||||||
|
const meta = join(rendersDir, "saved.meta.json");
|
||||||
|
writeFileSync(output, "GIF89a");
|
||||||
|
writeFileSync(meta, JSON.stringify({ status: "complete", durationMs: 42 }));
|
||||||
|
const list = await app.request("/projects/demo/renders");
|
||||||
|
expect(await list.json()).toMatchObject({
|
||||||
|
renders: [{ id: "saved", filename: "saved.gif", durationMs: 42 }],
|
||||||
|
});
|
||||||
|
for (const url of [
|
||||||
|
"/projects/demo/renders/file/saved.gif",
|
||||||
|
"/render/saved/view",
|
||||||
|
"/render/saved/download",
|
||||||
|
]) {
|
||||||
|
const res = await app.request(url);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("content-type")).toBe("image/gif");
|
||||||
|
expect(await res.text()).toBe("GIF89a");
|
||||||
|
expect(res.headers.get("content-disposition")).toContain(
|
||||||
|
url.endsWith("download") ? "attachment" : "inline",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect((await app.request("/render/saved", { method: "DELETE" })).status).toBe(200);
|
||||||
|
expect(existsSync(output)).toBe(false);
|
||||||
|
expect(existsSync(meta)).toBe(false);
|
||||||
|
expect(await (await app.request("/projects/demo/renders")).json()).toEqual({ renders: [] });
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Studio GIF frame-rate cap", () => {
|
||||||
|
it.each([
|
||||||
|
{ format: "gif", fps: 60, expected: { num: 30, den: 1 } },
|
||||||
|
{ format: "gif", fps: "60000/1001", expected: { num: 30, den: 1 } },
|
||||||
|
{ format: "gif", fps: "30001/1000", expected: { num: 30, den: 1 } },
|
||||||
|
{ format: "gif", fps: "30000/1001", expected: { num: 30000, den: 1001 } },
|
||||||
|
{ format: "gif", fps: 30, expected: { num: 30, den: 1 } },
|
||||||
|
{ format: "gif", fps: 24, expected: { num: 24, den: 1 } },
|
||||||
|
{ format: "mp4", fps: 60, expected: { num: 60, den: 1 } },
|
||||||
|
{ format: "webm", fps: 60, expected: { num: 60, den: 1 } },
|
||||||
|
{ format: "mov", fps: 60, expected: { num: 60, den: 1 } },
|
||||||
|
])("forwards $format at $fps fps as $expected", async ({ format, fps, expected }) => {
|
||||||
|
const spy = vi.fn();
|
||||||
|
const { app, cleanup } = buildApp(spy);
|
||||||
|
try {
|
||||||
|
const res = await app.request("/projects/demo/render", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ format, fps }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ format, fps: expected }));
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { existsSync, lstatSync, readFileSync, unlinkSync, readdirSync, statSync
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import type { StudioApiAdapter, RenderJobState } from "../types.js";
|
import type { StudioApiAdapter, RenderJobState } from "../types.js";
|
||||||
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers";
|
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers";
|
||||||
import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
|
import { formatRenderOutputTimestamp, parseFps, fpsToNumber } from "@hyperframes/core";
|
||||||
import {
|
import {
|
||||||
folderGone,
|
folderGone,
|
||||||
isPrivateProjectFile,
|
isPrivateProjectFile,
|
||||||
@@ -20,6 +20,7 @@ const RENDER_MIME: Record<string, string> = {
|
|||||||
".mp4": "video/mp4",
|
".mp4": "video/mp4",
|
||||||
".webm": "video/webm",
|
".webm": "video/webm",
|
||||||
".mov": "video/quicktime",
|
".mov": "video/quicktime",
|
||||||
|
".gif": "image/gif",
|
||||||
};
|
};
|
||||||
const RENDER_EXTENSIONS = Object.keys(RENDER_MIME);
|
const RENDER_EXTENSIONS = Object.keys(RENDER_MIME);
|
||||||
const RENDER_ARTIFACT_EXTENSIONS = [...RENDER_EXTENSIONS, ".meta.json"];
|
const RENDER_ARTIFACT_EXTENSIONS = [...RENDER_EXTENSIONS, ".meta.json"];
|
||||||
@@ -111,16 +112,23 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
|||||||
previewMotionBlur?: boolean;
|
previewMotionBlur?: boolean;
|
||||||
pageMotionBlur?: unknown;
|
pageMotionBlur?: unknown;
|
||||||
};
|
};
|
||||||
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
|
const VALID_FORMATS = ["mp4", "webm", "mov", "gif"] as const;
|
||||||
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
|
const FORMAT_EXT: Record<string, string> = {
|
||||||
const format = VALID_FORMATS.has(body.format ?? "") ? (body.format as string) : "mp4";
|
mp4: ".mp4",
|
||||||
|
webm: ".webm",
|
||||||
|
mov: ".mov",
|
||||||
|
gif: ".gif",
|
||||||
|
};
|
||||||
|
const format = VALID_FORMATS.find((value) => value === body.format) ?? "mp4";
|
||||||
|
|
||||||
// Default to 30 fps when unset or unparseable. The route stays lenient on
|
// Default to 30 fps when unset or unparseable. The route stays lenient on
|
||||||
// invalid fps values (matching the lenient handling of `resolution` and
|
// invalid fps values (matching the lenient handling of `resolution` and
|
||||||
// `quality` already in this file) — the producer surfaces a clearer error
|
// `quality` already in this file) — the producer surfaces a clearer error
|
||||||
// message if the caller really did mean to fail loudly.
|
// message if the caller really did mean to fail loudly.
|
||||||
const fpsParse = body.fps === undefined ? null : parseFps(body.fps);
|
const fpsParse = body.fps === undefined ? null : parseFps(body.fps);
|
||||||
const fps = fpsParse && fpsParse.ok ? fpsParse.value : { num: 30, den: 1 };
|
const requestedFps = fpsParse && fpsParse.ok ? fpsParse.value : { num: 30, den: 1 };
|
||||||
|
const fps =
|
||||||
|
format === "gif" && fpsToNumber(requestedFps) > 30 ? { num: 30, den: 1 } : requestedFps;
|
||||||
const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
|
const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
|
||||||
? (body.quality as string)
|
? (body.quality as string)
|
||||||
: "standard";
|
: "standard";
|
||||||
@@ -171,7 +179,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
|||||||
const jobState = adapter.startRender({
|
const jobState = adapter.startRender({
|
||||||
project,
|
project,
|
||||||
outputPath,
|
outputPath,
|
||||||
format: format as "mp4" | "webm" | "mov",
|
format,
|
||||||
fps,
|
fps,
|
||||||
quality,
|
quality,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -346,14 +354,14 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
|||||||
const rendersDir = adapter.rendersDir(project);
|
const rendersDir = adapter.rendersDir(project);
|
||||||
if (!existsSync(rendersDir)) return c.json({ renders: [] });
|
if (!existsSync(rendersDir)) return c.json({ renders: [] });
|
||||||
const files = readdirSync(rendersDir)
|
const files = readdirSync(rendersDir)
|
||||||
.filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov"))
|
.filter((f) => RENDER_EXTENSIONS.some((extension) => f.endsWith(extension)))
|
||||||
.flatMap((f) => {
|
.flatMap((f) => {
|
||||||
const fp = resolveWithinProject(rendersDir, f);
|
const fp = resolveWithinProject(rendersDir, f);
|
||||||
return fp && !isPrivateProjectFile(project.dir, fp) ? [{ f, fp }] : [];
|
return fp && !isPrivateProjectFile(project.dir, fp) ? [{ f, fp }] : [];
|
||||||
})
|
})
|
||||||
.map(({ f, fp }) => {
|
.map(({ f, fp }) => {
|
||||||
const stat = statSync(fp);
|
const stat = statSync(fp);
|
||||||
const rid = f.replace(/\.(mp4|webm|mov)$/, "");
|
const rid = f.replace(/\.(mp4|webm|mov|gif)$/, "");
|
||||||
const metaPath = join(rendersDir, `${rid}.meta.json`);
|
const metaPath = join(rendersDir, `${rid}.meta.json`);
|
||||||
let status: "complete" | "failed" = "complete";
|
let status: "complete" | "failed" = "complete";
|
||||||
let durationMs: number | undefined;
|
let durationMs: number | undefined;
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export interface StudioApiAdapter {
|
|||||||
startRender(opts: {
|
startRender(opts: {
|
||||||
project: ResolvedProject;
|
project: ResolvedProject;
|
||||||
outputPath: string;
|
outputPath: string;
|
||||||
format: "mp4" | "webm" | "mov";
|
format: "mp4" | "webm" | "mov" | "gif";
|
||||||
/**
|
/**
|
||||||
* Frame rate as an exact rational. The HTTP layer (POST
|
* Frame rate as an exact rational. The HTTP layer (POST
|
||||||
* `/projects/:id/render`) accepts either a JSON number (integer fps,
|
* `/projects/:id/render`) accepts either a JSON number (integer fps,
|
||||||
@@ -175,6 +175,7 @@ export interface StudioApiAdapter {
|
|||||||
*/
|
*/
|
||||||
fps: import("@hyperframes/core").Fps;
|
fps: import("@hyperframes/core").Fps;
|
||||||
quality: string;
|
quality: string;
|
||||||
|
gifLoop?: number;
|
||||||
crf?: number;
|
crf?: number;
|
||||||
videoBitrate?: string;
|
videoBitrate?: string;
|
||||||
videoFrameFormat?: "auto" | "jpg" | "png";
|
videoFrameFormat?: "auto" | "jpg" | "png";
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export function isInvalidGifLoop(format: string, value: string): boolean {
|
||||||
|
return format === "gif" && !isValidGifLoop(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidGifLoop(value: string): boolean {
|
||||||
|
const count = Number(value);
|
||||||
|
return value.trim() !== "" && Number.isInteger(count) && count >= 0 && count <= 65535;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GifLoopInput({
|
||||||
|
format,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
invalid,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
format: string;
|
||||||
|
value: string;
|
||||||
|
disabled: boolean;
|
||||||
|
invalid: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
if (format !== "gif") return null;
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col gap-1 text-step-10 text-text-4">
|
||||||
|
GIF loop count (0 = forever, max 65535)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={65535}
|
||||||
|
step={1}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
className="rounded-sm bg-input border border-border-input px-2 py-1 text-text-1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ export function GpuEncodingToggle({
|
|||||||
disabled,
|
disabled,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
format: "mp4" | "webm" | "mov";
|
format: "mp4" | "webm" | "mov" | "gif";
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
function formatEta(ms: number): string {
|
||||||
|
const s = Math.round(ms / 1000);
|
||||||
|
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenderDurationNote({
|
||||||
|
durationMs,
|
||||||
|
isRendering,
|
||||||
|
}: {
|
||||||
|
durationMs?: number;
|
||||||
|
isRendering: boolean;
|
||||||
|
}) {
|
||||||
|
if (durationMs === undefined || isRendering) return null;
|
||||||
|
return (
|
||||||
|
<p className="text-step-9 text-text-5 text-center -mt-1.5">
|
||||||
|
Last render took {formatEta(durationMs)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
||||||
|
|
||||||
|
export function RenderPreview({
|
||||||
|
filename,
|
||||||
|
src,
|
||||||
|
hovered,
|
||||||
|
ready,
|
||||||
|
onReady,
|
||||||
|
}: {
|
||||||
|
filename: string;
|
||||||
|
src: string;
|
||||||
|
hovered: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
onReady: () => void;
|
||||||
|
}) {
|
||||||
|
if (filename.endsWith(".gif")) {
|
||||||
|
return <img src={src} alt={filename} className="w-full h-full object-contain" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hovered && (
|
||||||
|
<video
|
||||||
|
src={src}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
onCanPlay={onReady}
|
||||||
|
className="absolute inset-0 w-full h-full object-contain transition-opacity duration-150"
|
||||||
|
style={{ opacity: ready ? 1 : 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 transition-opacity duration-150"
|
||||||
|
style={{ opacity: hovered && ready ? 0 : 1 }}
|
||||||
|
>
|
||||||
|
<VideoFrameThumbnail src={src} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -275,3 +275,80 @@ describe("RenderQueue FFmpeg gate", () => {
|
|||||||
expect(exportButtonIn(host).disabled).toBe(false);
|
expect(exportButtonIn(host).disabled).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function mountGifQueue() {
|
||||||
|
localStorage.setItem("hf-studio-render-settings", JSON.stringify({ format: "gif" }));
|
||||||
|
const onStartRender: Mock<StartRenderHandler> = vi.fn();
|
||||||
|
return { onStartRender, host: mountRenderQueue(onStartRender) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterGifLoop(host: HTMLElement, value: string) {
|
||||||
|
const input = host.querySelector<HTMLInputElement>('input[type="number"]');
|
||||||
|
if (!input) throw new Error("missing loop count");
|
||||||
|
act(() => {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||||
|
setter?.call(input, value);
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GIF export controls", () => {
|
||||||
|
it("offers GIF, persists the selection, and exports with infinite looping", async () => {
|
||||||
|
const onStartRender: Mock<StartRenderHandler> = vi.fn();
|
||||||
|
const host = mountRenderQueue(onStartRender);
|
||||||
|
await chooseByArrowing(triggerFor(host, "Format"), 3);
|
||||||
|
expect(getPersistedRenderSettings().format).toBe("gif");
|
||||||
|
const input = host.querySelector<HTMLInputElement>('input[type="number"]');
|
||||||
|
expect(input?.value).toBe("0");
|
||||||
|
act(() => exportButtonIn(host).click());
|
||||||
|
expect(onStartRender).toHaveBeenCalledWith("gif", "standard", "auto", 30, false, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores GIF settings and exports a finite loop count", () => {
|
||||||
|
const { host, onStartRender } = mountGifQueue();
|
||||||
|
enterGifLoop(host, "3");
|
||||||
|
act(() => exportButtonIn(host).click());
|
||||||
|
expect(onStartRender).toHaveBeenCalledWith("gif", "standard", "auto", 30, false, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["", "-1", "1.5", "65536"])("blocks export for invalid GIF loop count %j", (value) => {
|
||||||
|
const { host, onStartRender } = mountGifQueue();
|
||||||
|
const input = enterGifLoop(host, value);
|
||||||
|
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
expect(exportButtonIn(host).disabled).toBe(true);
|
||||||
|
act(() => exportButtonIn(host).click());
|
||||||
|
expect(onStartRender).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GIF frame-rate controls", () => {
|
||||||
|
it("caps a saved GIF frame rate of 60 and disables that option", async () => {
|
||||||
|
localStorage.setItem("hf-studio-render-settings", JSON.stringify({ format: "gif", fps: 60 }));
|
||||||
|
const onStartRender: Mock<StartRenderHandler> = vi.fn();
|
||||||
|
const host = mountRenderQueue(onStartRender);
|
||||||
|
const fps = triggerFor(host, "Frame rate");
|
||||||
|
expect(fps.textContent).toContain("30 fps");
|
||||||
|
act(() => exportButtonIn(host).click());
|
||||||
|
expect(onStartRender).toHaveBeenCalledWith("gif", "standard", "auto", 30, false, 0);
|
||||||
|
act(() => fps.click());
|
||||||
|
await settle();
|
||||||
|
const sixty = Array.from(document.querySelectorAll('[role="option"]')).find((option) =>
|
||||||
|
option.textContent?.includes("60 fps"),
|
||||||
|
);
|
||||||
|
expect(sixty?.getAttribute("aria-disabled")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches a 60 fps video to a 30 fps GIF and preserves 60 fps for video", async () => {
|
||||||
|
localStorage.setItem("hf-studio-render-settings", JSON.stringify({ format: "mp4", fps: 60 }));
|
||||||
|
const onStartRender: Mock<StartRenderHandler> = vi.fn();
|
||||||
|
const host = mountRenderQueue(onStartRender);
|
||||||
|
act(() => exportButtonIn(host).click());
|
||||||
|
expect(onStartRender).toHaveBeenLastCalledWith("mp4", "standard", "auto", 60, false);
|
||||||
|
await chooseByArrowing(triggerFor(host, "Format"), 3);
|
||||||
|
expect(triggerFor(host, "Frame rate").textContent).toContain("30 fps");
|
||||||
|
expect(getPersistedRenderSettings()).toMatchObject({ format: "gif", fps: 30 });
|
||||||
|
act(() => exportButtonIn(host).click());
|
||||||
|
expect(onStartRender).toHaveBeenLastCalledWith("gif", "standard", "auto", 30, false, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo, useState, useRef, useEffect, useLayoutEffect, useId } from "react";
|
import { memo, useState, useRef, useEffect, useLayoutEffect, useId } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { CANVAS_DIMENSIONS } from "@hyperframes/parsers";
|
import { CANVAS_DIMENSIONS } from "@hyperframes/parsers";
|
||||||
|
import { RenderDurationNote } from "./RenderDurationNote";
|
||||||
import { RenderQueueItem } from "./RenderQueueItem";
|
import { RenderQueueItem } from "./RenderQueueItem";
|
||||||
import { FfmpegRequiredNotice } from "./FfmpegRequiredNotice";
|
import { FfmpegRequiredNotice } from "./FfmpegRequiredNotice";
|
||||||
import type { FfmpegStatus } from "./useFfmpegStatus";
|
import type { FfmpegStatus } from "./useFfmpegStatus";
|
||||||
@@ -10,8 +11,18 @@ import { Select, type SelectOption } from "../ui/Select";
|
|||||||
import { Tooltip } from "../ui/Tooltip";
|
import { Tooltip } from "../ui/Tooltip";
|
||||||
import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel";
|
import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel";
|
||||||
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
||||||
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
|
import {
|
||||||
|
FORMAT_INFO,
|
||||||
|
resolveRenderFps,
|
||||||
|
getRenderFpsOptions,
|
||||||
|
getPersistedRenderSettings,
|
||||||
|
persistRenderSettings,
|
||||||
|
resolveRenderFormat,
|
||||||
|
startConfiguredRender,
|
||||||
|
isRenderExportBlocked,
|
||||||
|
} from "./renderSettings";
|
||||||
import { GpuEncodingToggle } from "./GpuEncodingToggle";
|
import { GpuEncodingToggle } from "./GpuEncodingToggle";
|
||||||
|
import { GifLoopInput, isInvalidGifLoop } from "./GifLoopInput";
|
||||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||||
|
|
||||||
export interface CompositionDimensions {
|
export interface CompositionDimensions {
|
||||||
@@ -20,11 +31,12 @@ export interface CompositionDimensions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type StartRenderHandler = (
|
export type StartRenderHandler = (
|
||||||
format: "mp4" | "webm" | "mov",
|
format: "mp4" | "webm" | "mov" | "gif",
|
||||||
quality: "draft" | "standard" | "high",
|
quality: "draft" | "standard" | "high",
|
||||||
resolution: ResolutionPreset | "auto",
|
resolution: ResolutionPreset | "auto",
|
||||||
fps: 24 | 30 | 60,
|
fps: 24 | 30 | 60,
|
||||||
gpu?: boolean,
|
gpu?: boolean,
|
||||||
|
gifLoop?: number,
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
|
|
||||||
export interface RenderQueueProps {
|
export interface RenderQueueProps {
|
||||||
@@ -136,21 +148,9 @@ function scaleOptionLabel(
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
|
|
||||||
mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
|
|
||||||
mov: {
|
|
||||||
label: "MOV (ProRes 4444)",
|
|
||||||
desc: "Transparent video. Works in Final Cut Pro, DaVinci Resolve, and most video editors. Large files.",
|
|
||||||
},
|
|
||||||
webm: {
|
|
||||||
label: "WebM (VP9)",
|
|
||||||
desc: "Transparent video for web. Smaller than MOV but limited editor support.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Estimated, like COLOR_PICKER_SIZE in propertyPanelColor: only the flip
|
// Estimated, like COLOR_PICKER_SIZE in propertyPanelColor: only the flip
|
||||||
// decision uses the height, and the clamp keeps the panel on screen either way.
|
// decision uses the height, and the clamp keeps the panel on screen either way.
|
||||||
const FORMAT_PANEL_SIZE = { width: 208, height: 150 };
|
const FORMAT_PANEL_SIZE = { width: 208, height: 210 };
|
||||||
|
|
||||||
// Rich format guidance in a keyboard-reachable disclosure: the trigger is a
|
// Rich format guidance in a keyboard-reachable disclosure: the trigger is a
|
||||||
// real button (focusable, labelled), the panel is tied to it via
|
// real button (focusable, labelled), the panel is tied to it via
|
||||||
@@ -158,7 +158,7 @@ const FORMAT_PANEL_SIZE = { width: 208, height: 150 };
|
|||||||
// for the one-line ui/Tooltip primitive, so this stays a local popover.
|
// for the one-line ui/Tooltip primitive, so this stays a local popover.
|
||||||
// It renders in a portal because the right panel is overflow-hidden: an
|
// It renders in a portal because the right panel is overflow-hidden: an
|
||||||
// in-flow absolute panel gets clipped at the panel edge.
|
// in-flow absolute panel gets clipped at the panel edge.
|
||||||
function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" | "gif" }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [position, setPosition] = useState<FloatingPosition | null>(null);
|
const [position, setPosition] = useState<FloatingPosition | null>(null);
|
||||||
const triggerRef = useRef<HTMLDivElement>(null);
|
const triggerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -243,7 +243,7 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
|||||||
<p className="text-step-10 font-semibold text-text-1 mb-0.5">{info.label}</p>
|
<p className="text-step-10 font-semibold text-text-1 mb-0.5">{info.label}</p>
|
||||||
<p className="text-step-9 text-text-3 leading-tight">{info.desc}</p>
|
<p className="text-step-9 text-text-3 leading-tight">{info.desc}</p>
|
||||||
<div className="mt-1.5 pt-1.5 border-t border-border">
|
<div className="mt-1.5 pt-1.5 border-t border-border">
|
||||||
{(["mp4", "mov", "webm"] as const)
|
{(["mp4", "mov", "webm", "gif"] as const)
|
||||||
.filter((f) => f !== format)
|
.filter((f) => f !== format)
|
||||||
.map((f) => (
|
.map((f) => (
|
||||||
<p key={f} className="text-step-9 text-text-4 leading-relaxed">
|
<p key={f} className="text-step-9 text-text-4 leading-relaxed">
|
||||||
@@ -264,6 +264,7 @@ const FORMAT_OPTIONS: SelectOption[] = [
|
|||||||
{ value: "mp4", label: "MP4" },
|
{ value: "mp4", label: "MP4" },
|
||||||
{ value: "mov", label: "MOV (ProRes)" },
|
{ value: "mov", label: "MOV (ProRes)" },
|
||||||
{ value: "webm", label: "WebM" },
|
{ value: "webm", label: "WebM" },
|
||||||
|
{ value: "gif", label: "GIF" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const QUALITY_OPTIONS: SelectOption[] = [
|
const QUALITY_OPTIONS: SelectOption[] = [
|
||||||
@@ -272,17 +273,6 @@ const QUALITY_OPTIONS: SelectOption[] = [
|
|||||||
{ value: "high", label: "High Quality" },
|
{ value: "high", label: "High Quality" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FPS_OPTIONS: SelectOption[] = [
|
|
||||||
{ value: "24", label: "24 fps" },
|
|
||||||
{ value: "30", label: "30 fps" },
|
|
||||||
{ value: "60", label: "60 fps" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatEta(ms: number): string {
|
|
||||||
const s = Math.round(ms / 1000);
|
|
||||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormatExportButton({
|
function FormatExportButton({
|
||||||
onStartRender,
|
onStartRender,
|
||||||
isRendering,
|
isRendering,
|
||||||
@@ -301,17 +291,22 @@ function FormatExportButton({
|
|||||||
onRecheckFfmpeg: () => void;
|
onRecheckFfmpeg: () => void;
|
||||||
}) {
|
}) {
|
||||||
const persisted = getPersistedRenderSettings();
|
const persisted = getPersistedRenderSettings();
|
||||||
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
|
const [format, setFormat] = useState<"mp4" | "webm" | "mov" | "gif">(persisted.format);
|
||||||
const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality);
|
const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality);
|
||||||
const [resolution, setResolution] = useState<RenderScale>("auto");
|
const [resolution, setResolution] = useState<RenderScale>("auto");
|
||||||
const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps);
|
const [requestedFps, setFps] = useState<24 | 30 | 60>(persisted.fps);
|
||||||
|
const fps = resolveRenderFps(format, requestedFps);
|
||||||
const [gpu, setGpu] = useState(persisted.gpu);
|
const [gpu, setGpu] = useState(persisted.gpu);
|
||||||
|
const [gifLoop, setGifLoop] = useState("0");
|
||||||
|
const loopCount = Number(gifLoop);
|
||||||
|
const invalidGifLoop = isInvalidGifLoop(format, gifLoop);
|
||||||
|
|
||||||
// Only a definite "not installed" blocks Export. A null status means the
|
// Only a definite "not installed" blocks Export. A null status means the
|
||||||
// probe gave no answer, and refusing to export on no answer would break
|
// probe gave no answer, and refusing to export on no answer would break
|
||||||
// setups that are perfectly fine. Holding the narrowed value rather than a
|
// setups that are perfectly fine. Holding the narrowed value rather than a
|
||||||
// boolean keeps the notice from re-testing what this line already decided.
|
// boolean keeps the notice from re-testing what this line already decided.
|
||||||
const missingFfmpeg = ffmpeg && !ffmpeg.ok ? ffmpeg : null;
|
const missingFfmpeg = ffmpeg && !ffmpeg.ok ? ffmpeg : null;
|
||||||
|
const exportBlocked = isRenderExportBlocked(isRendering, missingFfmpeg !== null, invalidGifLoop);
|
||||||
|
|
||||||
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
|
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
|
||||||
const showQuality = format !== "mov";
|
const showQuality = format !== "mov";
|
||||||
@@ -337,9 +332,9 @@ function FormatExportButton({
|
|||||||
options={FORMAT_OPTIONS}
|
options={FORMAT_OPTIONS}
|
||||||
disabled={isRendering}
|
disabled={isRendering}
|
||||||
onCommit={(next) => {
|
onCommit={(next) => {
|
||||||
const v = next as "mp4" | "webm" | "mov";
|
const v = resolveRenderFormat(next);
|
||||||
setFormat(v);
|
setFormat(v);
|
||||||
persistRenderSettings(v, quality, fps);
|
persistRenderSettings(v, quality, resolveRenderFps(v, requestedFps));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,7 +357,7 @@ function FormatExportButton({
|
|||||||
<Select
|
<Select
|
||||||
label="Frame rate"
|
label="Frame rate"
|
||||||
value={String(fps)}
|
value={String(fps)}
|
||||||
options={FPS_OPTIONS}
|
options={getRenderFpsOptions(format)}
|
||||||
disabled={isRendering}
|
disabled={isRendering}
|
||||||
onCommit={(next) => {
|
onCommit={(next) => {
|
||||||
const v = Number(next) as 24 | 30 | 60;
|
const v = Number(next) as 24 | 30 | 60;
|
||||||
@@ -388,6 +383,13 @@ function FormatExportButton({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<GifLoopInput
|
||||||
|
format={format}
|
||||||
|
value={gifLoop}
|
||||||
|
disabled={isRendering}
|
||||||
|
invalid={invalidGifLoop}
|
||||||
|
onChange={setGifLoop}
|
||||||
|
/>
|
||||||
<GpuEncodingToggle
|
<GpuEncodingToggle
|
||||||
format={format}
|
format={format}
|
||||||
checked={gpu}
|
checked={gpu}
|
||||||
@@ -402,15 +404,22 @@ function FormatExportButton({
|
|||||||
size="md"
|
size="md"
|
||||||
data-testid="renders-export"
|
data-testid="renders-export"
|
||||||
loading={isRendering}
|
loading={isRendering}
|
||||||
disabled={missingFfmpeg !== null}
|
disabled={exportBlocked}
|
||||||
title={missingFfmpeg ? "Install FFmpeg to export. See the note above." : undefined}
|
title={missingFfmpeg ? "Install FFmpeg to export. See the note above." : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// loading already disables the button; this guard also stops a
|
// loading already disables the button; this guard also stops a
|
||||||
// double-click in the same frame from enqueueing two renders.
|
// double-click in the same frame from enqueueing two renders.
|
||||||
if (isRendering || missingFfmpeg) return;
|
if (exportBlocked) return;
|
||||||
const outputResolution = resolveResolution(resolution, compositionDimensions);
|
const outputResolution = resolveResolution(resolution, compositionDimensions);
|
||||||
trackStudioEvent("render_start", { format, quality, resolution: outputResolution, fps });
|
trackStudioEvent("render_start", { format, quality, resolution: outputResolution, fps });
|
||||||
void onStartRender(format, quality, outputResolution, fps, gpu);
|
void startConfiguredRender(onStartRender, {
|
||||||
|
format,
|
||||||
|
quality,
|
||||||
|
resolution: outputResolution,
|
||||||
|
fps,
|
||||||
|
gpu,
|
||||||
|
gifLoop: loopCount,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
// Width only. A type size or a weight here would win the merge against
|
// Width only. A type size or a weight here would win the merge against
|
||||||
// the size recipe and leave this Export a step away from the header's
|
// the size recipe and leave this Export a step away from the header's
|
||||||
@@ -419,11 +428,7 @@ function FormatExportButton({
|
|||||||
>
|
>
|
||||||
{isRendering ? "Rendering…" : "Export"}
|
{isRendering ? "Rendering…" : "Export"}
|
||||||
</Button>
|
</Button>
|
||||||
{lastRenderDurationMs !== undefined && !isRendering && (
|
<RenderDurationNote durationMs={lastRenderDurationMs} isRendering={isRendering} />
|
||||||
<p className="text-step-9 text-text-5 text-center -mt-1.5">
|
|
||||||
Last render took {formatEta(lastRenderDurationMs)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { RenderJob } from "./useRenderQueue";
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
const s = Math.round(ms / 1000);
|
||||||
|
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(timestamp: number): string {
|
||||||
|
const diff = Date.now() - timestamp;
|
||||||
|
if (diff < 60000) return "just now";
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||||
|
return `${Math.floor(diff / 3600000)}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenderQueueInfo({ job }: { job: RenderJob }) {
|
||||||
|
const isRendering = job.status === "rendering";
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-step-11 font-medium text-text-2 truncate">{job.filename}</span>
|
||||||
|
{job.durationMs && (
|
||||||
|
<span className="text-step-9 text-text-5 shrink-0">{formatDuration(job.durationMs)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRendering && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
|
<span className="text-step-9 text-text-4">{job.stage || "Rendering"}</span>
|
||||||
|
<span className="text-step-9 font-mono text-accent-ink">{job.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-full h-1 bg-border rounded-full overflow-hidden"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={job.progress}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={`Render progress: ${job.progress}%`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${job.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.status === "failed" && job.error && (
|
||||||
|
<span className="text-step-9 text-danger-ink mt-0.5 block">{job.error}</span>
|
||||||
|
)}
|
||||||
|
{job.status === "cancelled" && (
|
||||||
|
<span className="text-step-9 text-text-4 mt-0.5 block">Cancelled</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isRendering && (
|
||||||
|
<span className="text-step-9 text-text-5">{formatTimeAgo(job.createdAt)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { RenderQueueItem } from "./RenderQueueItem";
|
||||||
|
|
||||||
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||||
|
|
||||||
|
describe("GIF render preview", () => {
|
||||||
|
it("uses an image instead of video playback, with open, download and delete actions", () => {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
try {
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<RenderQueueItem
|
||||||
|
job={{ id: "j1", filename: "j1.gif", status: "complete", progress: 100, createdAt: 0 }}
|
||||||
|
projectId="demo"
|
||||||
|
onDelete={onDelete}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(host.querySelector("img")?.getAttribute("src")).toBe(
|
||||||
|
"/api/projects/demo/renders/file/j1.gif",
|
||||||
|
);
|
||||||
|
expect(host.querySelector("video")).toBeNull();
|
||||||
|
expect(host.querySelector('[aria-label="Open j1.gif in a new tab"]')).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="Download j1.gif"]')?.disabled,
|
||||||
|
).toBe(false);
|
||||||
|
const deleteButton = host.querySelector<HTMLButtonElement>('[aria-label="Delete j1.gif"]');
|
||||||
|
act(() => deleteButton?.click());
|
||||||
|
const confirm = Array.from(host.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent === "Delete?",
|
||||||
|
);
|
||||||
|
act(() => confirm?.click());
|
||||||
|
expect(onDelete).toHaveBeenCalledOnce();
|
||||||
|
} finally {
|
||||||
|
act(() => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { buildProjectApiPath } from "../../utils/projectRouting";
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
||||||
import { memo, useCallback, useState } from "react";
|
import { memo, useCallback, useState } from "react";
|
||||||
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
import { RenderQueueInfo } from "./RenderQueueInfo";
|
||||||
|
import { RenderPreview } from "./RenderPreview";
|
||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
import { IconButton } from "../ui/IconButton";
|
import { IconButton } from "../ui/IconButton";
|
||||||
import { Tooltip } from "../ui/Tooltip";
|
import { Tooltip } from "../ui/Tooltip";
|
||||||
@@ -13,19 +14,6 @@ interface RenderQueueItemProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (ms < 1000) return `${ms}ms`;
|
|
||||||
const s = Math.round(ms / 1000);
|
|
||||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimeAgo(timestamp: number): string {
|
|
||||||
const diff = Date.now() - timestamp;
|
|
||||||
if (diff < 60000) return "just now";
|
|
||||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
||||||
return `${Math.floor(diff / 3600000)}h ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Static frame extracted once via hidden video + canvas. */
|
/** Static frame extracted once via hidden video + canvas. */
|
||||||
|
|
||||||
export const RenderQueueItem = memo(function RenderQueueItem({
|
export const RenderQueueItem = memo(function RenderQueueItem({
|
||||||
@@ -85,28 +73,13 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{isComplete && (
|
{isComplete && (
|
||||||
<>
|
<RenderPreview
|
||||||
{/* Live video — fades in over the static frame once it can play */}
|
filename={job.filename}
|
||||||
{hovered && (
|
src={viewSrc}
|
||||||
<video
|
hovered={hovered}
|
||||||
src={viewSrc}
|
ready={videoReady}
|
||||||
autoPlay
|
onReady={() => setVideoReady(true)}
|
||||||
muted
|
/>
|
||||||
loop
|
|
||||||
playsInline
|
|
||||||
onCanPlay={() => setVideoReady(true)}
|
|
||||||
className="absolute inset-0 w-full h-full object-contain transition-opacity duration-150"
|
|
||||||
style={{ opacity: videoReady ? 1 : 0 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Static frame — visible when not hovering */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 transition-opacity duration-150"
|
|
||||||
style={{ opacity: hovered && videoReady ? 0 : 1 }}
|
|
||||||
>
|
|
||||||
<VideoFrameThumbnail src={viewSrc} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{isRendering && (
|
{isRendering && (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
@@ -125,50 +98,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Info */}
|
<RenderQueueInfo job={job} />
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-step-11 font-medium text-text-2 truncate">{job.filename}</span>
|
|
||||||
{job.durationMs && (
|
|
||||||
<span className="text-step-9 text-text-5 shrink-0">
|
|
||||||
{formatDuration(job.durationMs)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isRendering && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<div className="flex items-center justify-between mb-0.5">
|
|
||||||
<span className="text-step-9 text-text-4">{job.stage || "Rendering"}</span>
|
|
||||||
<span className="text-step-9 font-mono text-accent-ink">{job.progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="w-full h-1 bg-border rounded-full overflow-hidden"
|
|
||||||
role="progressbar"
|
|
||||||
aria-valuenow={job.progress}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={100}
|
|
||||||
aria-label={`Render progress: ${job.progress}%`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-full bg-accent rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${job.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{job.status === "failed" && job.error && (
|
|
||||||
<span className="text-step-9 text-danger-ink mt-0.5 block">{job.error}</span>
|
|
||||||
)}
|
|
||||||
{job.status === "cancelled" && (
|
|
||||||
<span className="text-step-9 text-text-4 mt-0.5 block">Cancelled</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isRendering && (
|
|
||||||
<span className="text-step-9 text-text-5">{formatTimeAgo(job.createdAt)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions — always visible to prevent layout shifts */}
|
{/* Actions — always visible to prevent layout shifts */}
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const RenderQueuePanel = memo(function RenderQueuePanel() {
|
|||||||
ffmpeg={renderQueue.ffmpeg}
|
ffmpeg={renderQueue.ffmpeg}
|
||||||
ffmpegChecking={renderQueue.ffmpegChecking}
|
ffmpegChecking={renderQueue.ffmpegChecking}
|
||||||
onRecheckFfmpeg={renderQueue.recheckFfmpeg}
|
onRecheckFfmpeg={renderQueue.recheckFfmpeg}
|
||||||
onStartRender={async (format, quality, resolution, fps, gpu) => {
|
onStartRender={async (format, quality, resolution, fps, gpu, gifLoop) => {
|
||||||
await waitForPendingDomEditSaves();
|
await waitForPendingDomEditSaves();
|
||||||
// No `composition`: startRender targets the active one by default.
|
// No `composition`: startRender targets the active one by default.
|
||||||
await renderQueue.startRender({
|
await renderQueue.startRender({
|
||||||
@@ -38,6 +38,7 @@ export const RenderQueuePanel = memo(function RenderQueuePanel() {
|
|||||||
format,
|
format,
|
||||||
resolution,
|
resolution,
|
||||||
gpu,
|
gpu,
|
||||||
|
gifLoop,
|
||||||
// Render what the user is previewing: active variable overrides
|
// Render what the user is previewing: active variable overrides
|
||||||
// from the Variables panel ride along (undefined = defaults).
|
// from the Variables panel ride along (undefined = defaults).
|
||||||
variables: usePreviewVariablesStore.getState().values ?? undefined,
|
variables: usePreviewVariablesStore.getState().values ?? undefined,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import type { SelectOption } from "../ui/Select";
|
||||||
|
import type { StartRenderHandler } from "./RenderQueue";
|
||||||
|
import type { ResolutionPreset } from "./useRenderQueue";
|
||||||
|
|
||||||
const RENDER_SETTINGS_KEY = "hf-studio-render-settings";
|
const RENDER_SETTINGS_KEY = "hf-studio-render-settings";
|
||||||
|
|
||||||
export interface PersistedRenderSettings {
|
export interface PersistedRenderSettings {
|
||||||
format: "mp4" | "webm" | "mov";
|
format: "mp4" | "webm" | "mov" | "gif";
|
||||||
quality: "draft" | "standard" | "high";
|
quality: "draft" | "standard" | "high";
|
||||||
fps: 24 | 30 | 60;
|
fps: 24 | 30 | 60;
|
||||||
gpu: boolean;
|
gpu: boolean;
|
||||||
@@ -13,7 +17,7 @@ export function getPersistedRenderSettings(): PersistedRenderSettings {
|
|||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
return {
|
return {
|
||||||
format: ["mp4", "webm", "mov"].includes(parsed.format) ? parsed.format : "mp4",
|
format: ["mp4", "webm", "mov", "gif"].includes(parsed.format) ? parsed.format : "mp4",
|
||||||
quality: ["draft", "standard", "high"].includes(parsed.quality)
|
quality: ["draft", "standard", "high"].includes(parsed.quality)
|
||||||
? parsed.quality
|
? parsed.quality
|
||||||
: "standard",
|
: "standard",
|
||||||
@@ -39,3 +43,58 @@ export function persistRenderSettings(
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveRenderFormat(value: string): PersistedRenderSettings["format"] {
|
||||||
|
return (["mp4", "webm", "mov", "gif"] as const).find((format) => format === value) ?? "mp4";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startConfiguredRender(
|
||||||
|
onStartRender: StartRenderHandler,
|
||||||
|
settings: PersistedRenderSettings & { resolution: ResolutionPreset | "auto"; gifLoop: number },
|
||||||
|
): void | Promise<void> {
|
||||||
|
const { format, quality, resolution, fps, gpu, gifLoop } = settings;
|
||||||
|
if (format === "gif") return onStartRender(format, quality, resolution, fps, false, gifLoop);
|
||||||
|
return onStartRender(format, quality, resolution, fps, gpu);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRenderExportBlocked(
|
||||||
|
isRendering: boolean,
|
||||||
|
missingFfmpeg: boolean,
|
||||||
|
invalidGifLoop: boolean,
|
||||||
|
): boolean {
|
||||||
|
return isRendering || missingFfmpeg || invalidGifLoop;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FORMAT_INFO: Record<"mp4" | "webm" | "mov" | "gif", { label: string; desc: string }> =
|
||||||
|
{
|
||||||
|
gif: { label: "GIF", desc: "Animated image for docs and chat. No audio; up to 30 fps." },
|
||||||
|
mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
|
||||||
|
mov: {
|
||||||
|
label: "MOV (ProRes 4444)",
|
||||||
|
desc: "Transparent video. Works in Final Cut Pro, DaVinci Resolve, and most video editors. Large files.",
|
||||||
|
},
|
||||||
|
webm: {
|
||||||
|
label: "WebM (VP9)",
|
||||||
|
desc: "Transparent video for web. Smaller than MOV but limited editor support.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const FPS_OPTIONS: SelectOption[] = [
|
||||||
|
{ value: "24", label: "24 fps" },
|
||||||
|
{ value: "30", label: "30 fps" },
|
||||||
|
{ value: "60", label: "60 fps" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function resolveRenderFps(
|
||||||
|
format: PersistedRenderSettings["format"],
|
||||||
|
fps: PersistedRenderSettings["fps"],
|
||||||
|
): PersistedRenderSettings["fps"] {
|
||||||
|
return format === "gif" && fps === 60 ? 30 : fps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRenderFpsOptions(format: PersistedRenderSettings["format"]): SelectOption[] {
|
||||||
|
return FPS_OPTIONS.map((option) => ({
|
||||||
|
...option,
|
||||||
|
disabled: format === "gif" && option.value === "60",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,10 +29,11 @@ export type ResolutionPreset = CanvasResolution;
|
|||||||
export interface StartRenderOptions {
|
export interface StartRenderOptions {
|
||||||
fps?: number;
|
fps?: number;
|
||||||
quality?: "draft" | "standard" | "high";
|
quality?: "draft" | "standard" | "high";
|
||||||
format?: "mp4" | "webm" | "mov";
|
format?: "mp4" | "webm" | "mov" | "gif";
|
||||||
/** `"auto"` (default) renders at the composition's authored dimensions. */
|
/** `"auto"` (default) renders at the composition's authored dimensions. */
|
||||||
resolution?: ResolutionPreset | "auto";
|
resolution?: ResolutionPreset | "auto";
|
||||||
gpu?: boolean;
|
gpu?: boolean;
|
||||||
|
gifLoop?: number;
|
||||||
/**
|
/**
|
||||||
* Render a specific composition file. Omit it to render the composition the
|
* Render a specific composition file. Omit it to render the composition the
|
||||||
* user currently has open — only the sidebar's per-composition Render button
|
* user currently has open — only the sidebar's per-composition Render button
|
||||||
@@ -236,6 +237,7 @@ export function useRenderQueue(
|
|||||||
composition?: string;
|
composition?: string;
|
||||||
variables?: Record<string, unknown>;
|
variables?: Record<string, unknown>;
|
||||||
gpu?: boolean;
|
gpu?: boolean;
|
||||||
|
gifLoop?: number;
|
||||||
telemetryDistinctId?: string;
|
telemetryDistinctId?: string;
|
||||||
telemetryOptOut?: boolean;
|
telemetryOptOut?: boolean;
|
||||||
} = {
|
} = {
|
||||||
@@ -261,6 +263,7 @@ export function useRenderQueue(
|
|||||||
if (resolution && resolution !== "auto") body.resolution = resolution;
|
if (resolution && resolution !== "auto") body.resolution = resolution;
|
||||||
if (composition) body.composition = composition;
|
if (composition) body.composition = composition;
|
||||||
if (opts.gpu) body.gpu = true;
|
if (opts.gpu) body.gpu = true;
|
||||||
|
if (format === "gif" && opts.gifLoop !== undefined) body.gifLoop = opts.gifLoop;
|
||||||
if (opts.variables && Object.keys(opts.variables).length > 0) {
|
if (opts.variables && Object.keys(opts.variables).length > 0) {
|
||||||
body.variables = opts.variables;
|
body.variables = opts.variables;
|
||||||
}
|
}
|
||||||
@@ -304,7 +307,12 @@ export function useRenderQueue(
|
|||||||
}
|
}
|
||||||
const { jobId } = await res.json();
|
const { jobId } = await res.json();
|
||||||
|
|
||||||
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
|
const FORMAT_EXT: Record<string, string> = {
|
||||||
|
mp4: ".mp4",
|
||||||
|
webm: ".webm",
|
||||||
|
mov: ".mov",
|
||||||
|
gif: ".gif",
|
||||||
|
};
|
||||||
const ext = FORMAT_EXT[format] ?? ".mp4";
|
const ext = FORMAT_EXT[format] ?? ".mp4";
|
||||||
const job: RenderJob = {
|
const job: RenderJob = {
|
||||||
id: jobId,
|
id: jobId,
|
||||||
|
|||||||
@@ -65,3 +65,16 @@ describe("GPU encoding", () => {
|
|||||||
expect(body["gpu"]).toBeUndefined();
|
expect(body["gpu"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GIF export transport", () => {
|
||||||
|
it.each([0, 3, 65535])("forwards loop count %i and creates a GIF queue item", async (gifLoop) => {
|
||||||
|
const body = await renderBody(null, { format: "gif", gifLoop });
|
||||||
|
expect(body).toMatchObject({ format: "gif", gifLoop });
|
||||||
|
expect(queue?.api().jobs).toEqual([expect.objectContaining({ filename: "j1.gif" })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits GIF options for video exports", async () => {
|
||||||
|
const body = await renderBody(null, { format: "mp4", gifLoop: 3 });
|
||||||
|
expect(body).not.toHaveProperty("gifLoop");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,56 +1,105 @@
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import {
|
||||||
|
waitForRenderCancellation,
|
||||||
|
cancelAndVerifyArtifacts,
|
||||||
|
} from "../../test-utils/renderCancellation";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { ViteDevServer } from "vite";
|
import { createServer, type ViteDevServer } from "vite";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter";
|
import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const producer = vi.hoisted(() => ({
|
||||||
createRenderJob: vi.fn(() => ({ id: "job", perfSummary: undefined })),
|
createRenderJob: vi.fn((config: Record<string, unknown>) => ({ config })),
|
||||||
executeRenderJob: vi.fn(async () => {}),
|
executeRenderJob: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@hyperframes/producer", () => producer);
|
||||||
|
vi.mock("./vite.producer", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("./vite.producer")>()),
|
||||||
|
ensureProducerDist: () => ({ built: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@hyperframes/producer", () => mocks);
|
const dirs: string[] = [];
|
||||||
|
let server: ViteDevServer | undefined;
|
||||||
const roots: string[] = [];
|
afterEach(async () => {
|
||||||
afterEach(() => {
|
await server?.close();
|
||||||
|
server = undefined;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
function startRender(extra: Record<string, unknown>) {
|
type RenderOptions = Parameters<ReturnType<typeof createViteAdapter>["startRender"]>[0];
|
||||||
const root = mkdtempSync(join(tmpdir(), "hf-vite-render-"));
|
|
||||||
roots.push(root);
|
async function fixture(extra: Partial<RenderOptions> = {}) {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "hf-vite-gif-"));
|
||||||
|
dirs.push(dir);
|
||||||
|
server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
server: { middlewareMode: true, watch: null, ws: false },
|
||||||
|
});
|
||||||
const adapter = createViteAdapter(
|
const adapter = createViteAdapter(
|
||||||
root,
|
dir,
|
||||||
{} as ViteDevServer,
|
server,
|
||||||
createProjectSignatureCache({ compute: () => "test" }),
|
createProjectSignatureCache({ compute: () => "test" }),
|
||||||
{ historyRoot: join(root, "history") },
|
|
||||||
);
|
);
|
||||||
adapter.startRender({
|
const outputPath = join(dir, `out.${extra.format ?? "gif"}`);
|
||||||
project: { dir: root, id: "p", title: "p" },
|
const state = adapter.startRender({
|
||||||
outputPath: join(root, "out.mp4"),
|
project: { id: "demo", dir },
|
||||||
format: "mp4",
|
outputPath,
|
||||||
|
format: "gif",
|
||||||
|
gifLoop: 3,
|
||||||
fps: { num: 30, den: 1 },
|
fps: { num: 30, den: 1 },
|
||||||
quality: "draft",
|
quality: "draft",
|
||||||
jobId: "job",
|
jobId: "gif-job",
|
||||||
...extra,
|
...extra,
|
||||||
} as never);
|
|
||||||
return vi.waitFor(() => expect(mocks.createRenderJob).toHaveBeenCalledTimes(1), {
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
});
|
||||||
|
return { dir, state, outputPath, metaPath: join(dir, "out.meta.json") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("Vite GIF rendering", () => {
|
||||||
|
it("forwards loop count and writes a sidecar without overwriting the GIF", async () => {
|
||||||
|
producer.executeRenderJob.mockImplementation(async (_job, _dir, outputPath: string) => {
|
||||||
|
writeFileSync(outputPath, "GIF89a");
|
||||||
|
});
|
||||||
|
const { state, outputPath, metaPath } = await fixture();
|
||||||
|
await vi.waitFor(() => expect(state.status, state.error).toBe("complete"));
|
||||||
|
expect(producer.createRenderJob).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ format: "gif", gifLoop: 3 }),
|
||||||
|
);
|
||||||
|
expect(readFileSync(outputPath, "utf8")).toBe("GIF89a");
|
||||||
|
expect(JSON.parse(readFileSync(metaPath, "utf8"))).toMatchObject({ status: "complete" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
"cleans up cancelled GIF output and metadata (late completion: %s)",
|
||||||
|
async (lateCompletion) => {
|
||||||
|
producer.executeRenderJob.mockImplementation(
|
||||||
|
async (_job, _dir, outputPath: string, _progress, signal: AbortSignal) => {
|
||||||
|
writeFileSync(outputPath, "partial-gif");
|
||||||
|
writeFileSync(outputPath.replace(/\.gif$/, ".meta.json"), "{}");
|
||||||
|
await waitForRenderCancellation(signal, lateCompletion);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { state, outputPath, metaPath } = await fixture();
|
||||||
|
await cancelAndVerifyArtifacts(state, outputPath, metaPath);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe("Studio dev server render jobs", { timeout: 30_000 }, () => {
|
describe("Studio dev server render jobs", { timeout: 30_000 }, () => {
|
||||||
it("hands the route's pageMotionBlur to the job, so a preview that could not blur renders unblurred", async () => {
|
it("hands the route's pageMotionBlur to the job, so a preview that could not blur renders unblurred", async () => {
|
||||||
await startRender({ pageMotionBlur: "off" });
|
producer.executeRenderJob.mockResolvedValue(undefined);
|
||||||
expect(mocks.createRenderJob).toHaveBeenCalledWith(
|
const { state } = await fixture({ format: "mp4", pageMotionBlur: "off" });
|
||||||
|
await vi.waitFor(() => expect(state.status, state.error).toBe("complete"));
|
||||||
|
expect(producer.createRenderJob).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ pageMotionBlur: "off" }),
|
expect.objectContaining({ pageMotionBlur: "off" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves the job's blur to the composition when the route sends none", async () => {
|
it("leaves the job's blur to the composition when the route sends none", async () => {
|
||||||
await startRender({});
|
producer.executeRenderJob.mockResolvedValue(undefined);
|
||||||
expect(mocks.createRenderJob.mock.calls[0]?.[0]).not.toHaveProperty("pageMotionBlur");
|
const { state } = await fixture({ format: "mp4" });
|
||||||
|
await vi.waitFor(() => expect(state.status, state.error).toBe("complete"));
|
||||||
|
expect(producer.createRenderJob.mock.calls[0]?.[0]).not.toHaveProperty("pageMotionBlur");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ export function createViteAdapter(
|
|||||||
state.status = "cancelled";
|
state.status = "cancelled";
|
||||||
for (const fp of [
|
for (const fp of [
|
||||||
opts.outputPath,
|
opts.outputPath,
|
||||||
opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json"),
|
opts.outputPath.replace(/\.(mp4|webm|mov|gif)$/, ".meta.json"),
|
||||||
]) {
|
]) {
|
||||||
try {
|
try {
|
||||||
if (existsSync(fp)) unlinkSync(fp);
|
if (existsSync(fp)) unlinkSync(fp);
|
||||||
@@ -347,6 +347,7 @@ export function createViteAdapter(
|
|||||||
fps: opts.fps,
|
fps: opts.fps,
|
||||||
quality: opts.quality as "draft" | "standard" | "high",
|
quality: opts.quality as "draft" | "standard" | "high",
|
||||||
format: opts.format,
|
format: opts.format,
|
||||||
|
...(opts.gifLoop !== undefined ? { gifLoop: opts.gifLoop } : {}),
|
||||||
...(renderBodyScripts.length > 0 ? { renderBodyScripts } : {}),
|
...(renderBodyScripts.length > 0 ? { renderBodyScripts } : {}),
|
||||||
outputResolution: opts.outputResolution,
|
outputResolution: opts.outputResolution,
|
||||||
...(opts.composition ? { entryFile: opts.composition } : {}),
|
...(opts.composition ? { entryFile: opts.composition } : {}),
|
||||||
@@ -378,7 +379,7 @@ export function createViteAdapter(
|
|||||||
if (job.audioLoweredDb !== undefined) state.audioLoweredDb = job.audioLoweredDb;
|
if (job.audioLoweredDb !== undefined) state.audioLoweredDb = job.audioLoweredDb;
|
||||||
state.status = "complete";
|
state.status = "complete";
|
||||||
state.progress = 100;
|
state.progress = 100;
|
||||||
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
|
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov|gif)$/, ".meta.json");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
metaPath,
|
metaPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -395,7 +396,7 @@ export function createViteAdapter(
|
|||||||
state.status = "failed";
|
state.status = "failed";
|
||||||
state.error = err instanceof Error ? err.message : String(err);
|
state.error = err instanceof Error ? err.message : String(err);
|
||||||
try {
|
try {
|
||||||
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
|
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov|gif)$/, ".meta.json");
|
||||||
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
|
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { expect, vi } from "vitest";
|
||||||
|
|
||||||
|
export function waitForRenderCancellation(
|
||||||
|
signal: AbortSignal,
|
||||||
|
lateCompletion: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
if (lateCompletion) resolve();
|
||||||
|
else reject(Object.assign(new Error("cancelled"), { name: "AbortError" }));
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelAndVerifyArtifacts(
|
||||||
|
state: { status: string; cancel?: () => void },
|
||||||
|
outputPath: string,
|
||||||
|
metaPath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await vi.waitFor(() => expect(existsSync(metaPath)).toBe(true));
|
||||||
|
state.cancel?.();
|
||||||
|
await vi.waitFor(() => expect(state.status).toBe("cancelled"));
|
||||||
|
expect(existsSync(outputPath)).toBe(false);
|
||||||
|
expect(existsSync(metaPath)).toBe(false);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user