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,
|
||||
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
|
||||
|
||||
From the project folder:
|
||||
|
||||
@@ -21,15 +21,10 @@ import type { RenderCpuTelemetryPayload } from "../telemetry/renderCpu.js";
|
||||
export interface StudioRenderOpts {
|
||||
fps: Fps;
|
||||
quality: string;
|
||||
/** Output container format; Studio requests are constrained to these three. */
|
||||
format?: "mp4" | "webm" | "mov";
|
||||
format?: "mp4" | "webm" | "mov" | "gif";
|
||||
/** Named canvas preset the Studio request asked for; undefined renders at the composition's native dimensions. */
|
||||
outputResolution?: CanvasResolution;
|
||||
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
|
||||
// outcome joins their studio_session_start / studio_render_start events.
|
||||
// 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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -408,6 +412,46 @@ describe("createStudioServer shutdown", () => {
|
||||
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 () => {
|
||||
const events: string[] = [];
|
||||
let started!: () => void;
|
||||
|
||||
@@ -646,7 +646,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
state.status = "cancelled";
|
||||
for (const suffix of ["", ".meta.json"]) {
|
||||
const fp = suffix
|
||||
? opts.outputPath.replace(/\.(mp4|webm|mov)$/, suffix)
|
||||
? opts.outputPath.replace(/\.(mp4|webm|mov|gif)$/, suffix)
|
||||
: opts.outputPath;
|
||||
try {
|
||||
if (existsSync(fp)) unlinkSync(fp);
|
||||
@@ -670,6 +670,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
fps: opts.fps,
|
||||
quality: opts.quality as "draft" | "standard" | "high",
|
||||
format: opts.format,
|
||||
...(opts.gifLoop !== undefined ? { gifLoop: opts.gifLoop } : {}),
|
||||
outputResolution: opts.outputResolution,
|
||||
...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}),
|
||||
...(opts.composition ? { entryFile: opts.composition } : {}),
|
||||
@@ -702,7 +703,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
if (job.audioLoweredDb !== undefined) state.audioLoweredDb = job.audioLoweredDb;
|
||||
state.status = "complete";
|
||||
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(
|
||||
metaPath,
|
||||
JSON.stringify({
|
||||
@@ -736,7 +737,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
cpu.stop(),
|
||||
);
|
||||
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" }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
|
||||
@@ -4,6 +4,7 @@ const VIDEO_FRAME_FORMATS = ["auto", "jpg", "png"] as const;
|
||||
const HDR_MODES = ["auto", "force-hdr", "force-sdr"] as const;
|
||||
|
||||
interface EncoderOptions {
|
||||
gifLoop?: number;
|
||||
crf?: number;
|
||||
videoBitrate?: string;
|
||||
videoFrameFormat?: (typeof VIDEO_FRAME_FORMATS)[number];
|
||||
@@ -11,6 +12,7 @@ interface EncoderOptions {
|
||||
}
|
||||
|
||||
export interface EncoderOptionsBody {
|
||||
gifLoop?: unknown;
|
||||
crf?: unknown;
|
||||
videoBitrate?: unknown;
|
||||
videoFrameFormat?: unknown;
|
||||
@@ -55,6 +57,17 @@ export function parseEncoderOptions(body: EncoderOptionsBody, format: string): P
|
||||
const rate = parseRateControl(body, format);
|
||||
if (!rate.ok) return rate;
|
||||
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 (!oneOf(body.videoFrameFormat, VIDEO_FRAME_FORMATS)) {
|
||||
return {
|
||||
|
||||
@@ -71,6 +71,17 @@ describe("project history and media files", () => {
|
||||
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 () => {
|
||||
const { history, historyRoot, write } = await project({ "index.html": "A" });
|
||||
const window = await history.beginWindow(you, "Render");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
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 { join } from "node:path";
|
||||
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 type { StudioApiAdapter, RenderJobState } from "../types.js";
|
||||
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers";
|
||||
import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
|
||||
import { formatRenderOutputTimestamp, parseFps, fpsToNumber } from "@hyperframes/core";
|
||||
import {
|
||||
folderGone,
|
||||
isPrivateProjectFile,
|
||||
@@ -20,6 +20,7 @@ const RENDER_MIME: Record<string, string> = {
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
".gif": "image/gif",
|
||||
};
|
||||
const RENDER_EXTENSIONS = Object.keys(RENDER_MIME);
|
||||
const RENDER_ARTIFACT_EXTENSIONS = [...RENDER_EXTENSIONS, ".meta.json"];
|
||||
@@ -111,16 +112,23 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
previewMotionBlur?: boolean;
|
||||
pageMotionBlur?: unknown;
|
||||
};
|
||||
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
|
||||
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
|
||||
const format = VALID_FORMATS.has(body.format ?? "") ? (body.format as string) : "mp4";
|
||||
const VALID_FORMATS = ["mp4", "webm", "mov", "gif"] as const;
|
||||
const FORMAT_EXT: Record<string, string> = {
|
||||
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
|
||||
// invalid fps values (matching the lenient handling of `resolution` and
|
||||
// `quality` already in this file) — the producer surfaces a clearer error
|
||||
// message if the caller really did mean to fail loudly.
|
||||
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 ?? "")
|
||||
? (body.quality as string)
|
||||
: "standard";
|
||||
@@ -171,7 +179,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
const jobState = adapter.startRender({
|
||||
project,
|
||||
outputPath,
|
||||
format: format as "mp4" | "webm" | "mov",
|
||||
format,
|
||||
fps,
|
||||
quality,
|
||||
jobId,
|
||||
@@ -346,14 +354,14 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
const rendersDir = adapter.rendersDir(project);
|
||||
if (!existsSync(rendersDir)) return c.json({ renders: [] });
|
||||
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) => {
|
||||
const fp = resolveWithinProject(rendersDir, f);
|
||||
return fp && !isPrivateProjectFile(project.dir, fp) ? [{ f, fp }] : [];
|
||||
})
|
||||
.map(({ f, 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`);
|
||||
let status: "complete" | "failed" = "complete";
|
||||
let durationMs: number | undefined;
|
||||
|
||||
@@ -165,7 +165,7 @@ export interface StudioApiAdapter {
|
||||
startRender(opts: {
|
||||
project: ResolvedProject;
|
||||
outputPath: string;
|
||||
format: "mp4" | "webm" | "mov";
|
||||
format: "mp4" | "webm" | "mov" | "gif";
|
||||
/**
|
||||
* Frame rate as an exact rational. The HTTP layer (POST
|
||||
* `/projects/:id/render`) accepts either a JSON number (integer fps,
|
||||
@@ -175,6 +175,7 @@ export interface StudioApiAdapter {
|
||||
*/
|
||||
fps: import("@hyperframes/core").Fps;
|
||||
quality: string;
|
||||
gifLoop?: number;
|
||||
crf?: number;
|
||||
videoBitrate?: string;
|
||||
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,
|
||||
onToggle,
|
||||
}: {
|
||||
format: "mp4" | "webm" | "mov";
|
||||
format: "mp4" | "webm" | "mov" | "gif";
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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 { createPortal } from "react-dom";
|
||||
import { CANVAS_DIMENSIONS } from "@hyperframes/parsers";
|
||||
import { RenderDurationNote } from "./RenderDurationNote";
|
||||
import { RenderQueueItem } from "./RenderQueueItem";
|
||||
import { FfmpegRequiredNotice } from "./FfmpegRequiredNotice";
|
||||
import type { FfmpegStatus } from "./useFfmpegStatus";
|
||||
@@ -10,8 +11,18 @@ import { Select, type SelectOption } from "../ui/Select";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel";
|
||||
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 { GifLoopInput, isInvalidGifLoop } from "./GifLoopInput";
|
||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
|
||||
export interface CompositionDimensions {
|
||||
@@ -20,11 +31,12 @@ export interface CompositionDimensions {
|
||||
}
|
||||
|
||||
export type StartRenderHandler = (
|
||||
format: "mp4" | "webm" | "mov",
|
||||
format: "mp4" | "webm" | "mov" | "gif",
|
||||
quality: "draft" | "standard" | "high",
|
||||
resolution: ResolutionPreset | "auto",
|
||||
fps: 24 | 30 | 60,
|
||||
gpu?: boolean,
|
||||
gifLoop?: number,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export interface RenderQueueProps {
|
||||
@@ -136,21 +148,9 @@ function scaleOptionLabel(
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
// It renders in a portal because the right panel is overflow-hidden: an
|
||||
// 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 [position, setPosition] = useState<FloatingPosition | null>(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-9 text-text-3 leading-tight">{info.desc}</p>
|
||||
<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)
|
||||
.map((f) => (
|
||||
<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: "mov", label: "MOV (ProRes)" },
|
||||
{ value: "webm", label: "WebM" },
|
||||
{ value: "gif", label: "GIF" },
|
||||
];
|
||||
|
||||
const QUALITY_OPTIONS: SelectOption[] = [
|
||||
@@ -272,17 +273,6 @@ const QUALITY_OPTIONS: SelectOption[] = [
|
||||
{ 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({
|
||||
onStartRender,
|
||||
isRendering,
|
||||
@@ -301,17 +291,22 @@ function FormatExportButton({
|
||||
onRecheckFfmpeg: () => void;
|
||||
}) {
|
||||
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 [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 [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
|
||||
// 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
|
||||
// boolean keeps the notice from re-testing what this line already decided.
|
||||
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.
|
||||
const showQuality = format !== "mov";
|
||||
@@ -337,9 +332,9 @@ function FormatExportButton({
|
||||
options={FORMAT_OPTIONS}
|
||||
disabled={isRendering}
|
||||
onCommit={(next) => {
|
||||
const v = next as "mp4" | "webm" | "mov";
|
||||
const v = resolveRenderFormat(next);
|
||||
setFormat(v);
|
||||
persistRenderSettings(v, quality, fps);
|
||||
persistRenderSettings(v, quality, resolveRenderFps(v, requestedFps));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -362,7 +357,7 @@ function FormatExportButton({
|
||||
<Select
|
||||
label="Frame rate"
|
||||
value={String(fps)}
|
||||
options={FPS_OPTIONS}
|
||||
options={getRenderFpsOptions(format)}
|
||||
disabled={isRendering}
|
||||
onCommit={(next) => {
|
||||
const v = Number(next) as 24 | 30 | 60;
|
||||
@@ -388,6 +383,13 @@ function FormatExportButton({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<GifLoopInput
|
||||
format={format}
|
||||
value={gifLoop}
|
||||
disabled={isRendering}
|
||||
invalid={invalidGifLoop}
|
||||
onChange={setGifLoop}
|
||||
/>
|
||||
<GpuEncodingToggle
|
||||
format={format}
|
||||
checked={gpu}
|
||||
@@ -402,15 +404,22 @@ function FormatExportButton({
|
||||
size="md"
|
||||
data-testid="renders-export"
|
||||
loading={isRendering}
|
||||
disabled={missingFfmpeg !== null}
|
||||
disabled={exportBlocked}
|
||||
title={missingFfmpeg ? "Install FFmpeg to export. See the note above." : undefined}
|
||||
onClick={() => {
|
||||
// loading already disables the button; this guard also stops a
|
||||
// double-click in the same frame from enqueueing two renders.
|
||||
if (isRendering || missingFfmpeg) return;
|
||||
if (exportBlocked) return;
|
||||
const outputResolution = resolveResolution(resolution, compositionDimensions);
|
||||
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
|
||||
// the size recipe and leave this Export a step away from the header's
|
||||
@@ -419,11 +428,7 @@ function FormatExportButton({
|
||||
>
|
||||
{isRendering ? "Rendering…" : "Export"}
|
||||
</Button>
|
||||
{lastRenderDurationMs !== undefined && !isRendering && (
|
||||
<p className="text-step-9 text-text-5 text-center -mt-1.5">
|
||||
Last render took {formatEta(lastRenderDurationMs)}
|
||||
</p>
|
||||
)}
|
||||
<RenderDurationNote durationMs={lastRenderDurationMs} isRendering={isRendering} />
|
||||
</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 { memo, useCallback, useState } from "react";
|
||||
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
||||
import { RenderQueueInfo } from "./RenderQueueInfo";
|
||||
import { RenderPreview } from "./RenderPreview";
|
||||
import { Button } from "../ui/Button";
|
||||
import { IconButton } from "../ui/IconButton";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
@@ -13,19 +14,6 @@ interface RenderQueueItemProps {
|
||||
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. */
|
||||
|
||||
export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
@@ -85,28 +73,13 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
].join(" ")}
|
||||
>
|
||||
{isComplete && (
|
||||
<>
|
||||
{/* Live video — fades in over the static frame once it can play */}
|
||||
{hovered && (
|
||||
<video
|
||||
src={viewSrc}
|
||||
autoPlay
|
||||
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>
|
||||
</>
|
||||
<RenderPreview
|
||||
filename={job.filename}
|
||||
src={viewSrc}
|
||||
hovered={hovered}
|
||||
ready={videoReady}
|
||||
onReady={() => setVideoReady(true)}
|
||||
/>
|
||||
)}
|
||||
{isRendering && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
@@ -125,50 +98,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<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>
|
||||
<RenderQueueInfo job={job} />
|
||||
|
||||
{/* Actions — always visible to prevent layout shifts */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
|
||||
@@ -29,7 +29,7 @@ export const RenderQueuePanel = memo(function RenderQueuePanel() {
|
||||
ffmpeg={renderQueue.ffmpeg}
|
||||
ffmpegChecking={renderQueue.ffmpegChecking}
|
||||
onRecheckFfmpeg={renderQueue.recheckFfmpeg}
|
||||
onStartRender={async (format, quality, resolution, fps, gpu) => {
|
||||
onStartRender={async (format, quality, resolution, fps, gpu, gifLoop) => {
|
||||
await waitForPendingDomEditSaves();
|
||||
// No `composition`: startRender targets the active one by default.
|
||||
await renderQueue.startRender({
|
||||
@@ -38,6 +38,7 @@ export const RenderQueuePanel = memo(function RenderQueuePanel() {
|
||||
format,
|
||||
resolution,
|
||||
gpu,
|
||||
gifLoop,
|
||||
// Render what the user is previewing: active variable overrides
|
||||
// from the Variables panel ride along (undefined = defaults).
|
||||
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";
|
||||
|
||||
export interface PersistedRenderSettings {
|
||||
format: "mp4" | "webm" | "mov";
|
||||
format: "mp4" | "webm" | "mov" | "gif";
|
||||
quality: "draft" | "standard" | "high";
|
||||
fps: 24 | 30 | 60;
|
||||
gpu: boolean;
|
||||
@@ -13,7 +17,7 @@ export function getPersistedRenderSettings(): PersistedRenderSettings {
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
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)
|
||||
? parsed.quality
|
||||
: "standard",
|
||||
@@ -39,3 +43,58 @@ export function persistRenderSettings(
|
||||
/* 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 {
|
||||
fps?: number;
|
||||
quality?: "draft" | "standard" | "high";
|
||||
format?: "mp4" | "webm" | "mov";
|
||||
format?: "mp4" | "webm" | "mov" | "gif";
|
||||
/** `"auto"` (default) renders at the composition's authored dimensions. */
|
||||
resolution?: ResolutionPreset | "auto";
|
||||
gpu?: boolean;
|
||||
gifLoop?: number;
|
||||
/**
|
||||
* Render a specific composition file. Omit it to render the composition the
|
||||
* user currently has open — only the sidebar's per-composition Render button
|
||||
@@ -236,6 +237,7 @@ export function useRenderQueue(
|
||||
composition?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
gpu?: boolean;
|
||||
gifLoop?: number;
|
||||
telemetryDistinctId?: string;
|
||||
telemetryOptOut?: boolean;
|
||||
} = {
|
||||
@@ -261,6 +263,7 @@ export function useRenderQueue(
|
||||
if (resolution && resolution !== "auto") body.resolution = resolution;
|
||||
if (composition) body.composition = composition;
|
||||
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) {
|
||||
body.variables = opts.variables;
|
||||
}
|
||||
@@ -304,7 +307,12 @@ export function useRenderQueue(
|
||||
}
|
||||
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 job: RenderJob = {
|
||||
id: jobId,
|
||||
|
||||
@@ -65,3 +65,16 @@ describe("GPU encoding", () => {
|
||||
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 { join } from "node:path";
|
||||
import type { ViteDevServer } from "vite";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createRenderJob: vi.fn(() => ({ id: "job", perfSummary: undefined })),
|
||||
executeRenderJob: vi.fn(async () => {}),
|
||||
const producer = vi.hoisted(() => ({
|
||||
createRenderJob: vi.fn((config: Record<string, unknown>) => ({ config })),
|
||||
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 roots: string[] = [];
|
||||
afterEach(() => {
|
||||
const dirs: string[] = [];
|
||||
let server: ViteDevServer | undefined;
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
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>) {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-vite-render-"));
|
||||
roots.push(root);
|
||||
type RenderOptions = Parameters<ReturnType<typeof createViteAdapter>["startRender"]>[0];
|
||||
|
||||
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(
|
||||
root,
|
||||
{} as ViteDevServer,
|
||||
dir,
|
||||
server,
|
||||
createProjectSignatureCache({ compute: () => "test" }),
|
||||
{ historyRoot: join(root, "history") },
|
||||
);
|
||||
adapter.startRender({
|
||||
project: { dir: root, id: "p", title: "p" },
|
||||
outputPath: join(root, "out.mp4"),
|
||||
format: "mp4",
|
||||
const outputPath = join(dir, `out.${extra.format ?? "gif"}`);
|
||||
const state = adapter.startRender({
|
||||
project: { id: "demo", dir },
|
||||
outputPath,
|
||||
format: "gif",
|
||||
gifLoop: 3,
|
||||
fps: { num: 30, den: 1 },
|
||||
quality: "draft",
|
||||
jobId: "job",
|
||||
jobId: "gif-job",
|
||||
...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 }, () => {
|
||||
it("hands the route's pageMotionBlur to the job, so a preview that could not blur renders unblurred", async () => {
|
||||
await startRender({ pageMotionBlur: "off" });
|
||||
expect(mocks.createRenderJob).toHaveBeenCalledWith(
|
||||
producer.executeRenderJob.mockResolvedValue(undefined);
|
||||
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" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the job's blur to the composition when the route sends none", async () => {
|
||||
await startRender({});
|
||||
expect(mocks.createRenderJob.mock.calls[0]?.[0]).not.toHaveProperty("pageMotionBlur");
|
||||
producer.executeRenderJob.mockResolvedValue(undefined);
|
||||
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";
|
||||
for (const fp of [
|
||||
opts.outputPath,
|
||||
opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json"),
|
||||
opts.outputPath.replace(/\.(mp4|webm|mov|gif)$/, ".meta.json"),
|
||||
]) {
|
||||
try {
|
||||
if (existsSync(fp)) unlinkSync(fp);
|
||||
@@ -347,6 +347,7 @@ export function createViteAdapter(
|
||||
fps: opts.fps,
|
||||
quality: opts.quality as "draft" | "standard" | "high",
|
||||
format: opts.format,
|
||||
...(opts.gifLoop !== undefined ? { gifLoop: opts.gifLoop } : {}),
|
||||
...(renderBodyScripts.length > 0 ? { renderBodyScripts } : {}),
|
||||
outputResolution: opts.outputResolution,
|
||||
...(opts.composition ? { entryFile: opts.composition } : {}),
|
||||
@@ -378,7 +379,7 @@ export function createViteAdapter(
|
||||
if (job.audioLoweredDb !== undefined) state.audioLoweredDb = job.audioLoweredDb;
|
||||
state.status = "complete";
|
||||
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(
|
||||
metaPath,
|
||||
JSON.stringify({
|
||||
@@ -395,7 +396,7 @@ export function createViteAdapter(
|
||||
state.status = "failed";
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
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" }));
|
||||
} catch {
|
||||
/* 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