diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index f479c30cf6..971fc9226b 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -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: diff --git a/packages/cli/src/server/studioRenderTelemetry.ts b/packages/cli/src/server/studioRenderTelemetry.ts index b0b1073bfd..67f52df086 100644 --- a/packages/cli/src/server/studioRenderTelemetry.ts +++ b/packages/cli/src/server/studioRenderTelemetry.ts @@ -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. diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts index 6094df01b1..dccc65ba5b 100644 --- a/packages/cli/src/server/studioServer.test.ts +++ b/packages/cli/src/server/studioServer.test.ts @@ -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; diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 418beae6a6..13e1d32f11 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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 */ diff --git a/packages/studio-server/src/helpers/encoderOptions.ts b/packages/studio-server/src/helpers/encoderOptions.ts index c99c9b1efa..0e2da70f5a 100644 --- a/packages/studio-server/src/helpers/encoderOptions.ts +++ b/packages/studio-server/src/helpers/encoderOptions.ts @@ -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 { diff --git a/packages/studio-server/src/history/uncopied.test.ts b/packages/studio-server/src/history/uncopied.test.ts index a3e935487d..4e8f6c13e1 100644 --- a/packages/studio-server/src/history/uncopied.test.ts +++ b/packages/studio-server/src/history/uncopied.test.ts @@ -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"); diff --git a/packages/studio-server/src/routes/render.test.ts b/packages/studio-server/src/routes/render.test.ts index 612a28936f..c408a8d976 100644 --- a/packages/studio-server/src/routes/render.test.ts +++ b/packages/studio-server/src/routes/render.test.ts @@ -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(); + } + }); +}); diff --git a/packages/studio-server/src/routes/render.ts b/packages/studio-server/src/routes/render.ts index 228ecf6bce..2b5e15fcb3 100644 --- a/packages/studio-server/src/routes/render.ts +++ b/packages/studio-server/src/routes/render.ts @@ -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 = { ".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 = { 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 = { + 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; diff --git a/packages/studio-server/src/types.ts b/packages/studio-server/src/types.ts index d0d5a3cc71..a779faa24a 100644 --- a/packages/studio-server/src/types.ts +++ b/packages/studio-server/src/types.ts @@ -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"; diff --git a/packages/studio/src/components/renders/GifLoopInput.tsx b/packages/studio/src/components/renders/GifLoopInput.tsx new file mode 100644 index 0000000000..3a9db280a2 --- /dev/null +++ b/packages/studio/src/components/renders/GifLoopInput.tsx @@ -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 ( + + ); +} diff --git a/packages/studio/src/components/renders/GpuEncodingToggle.tsx b/packages/studio/src/components/renders/GpuEncodingToggle.tsx index eb06bf971d..27a8ebb7c6 100644 --- a/packages/studio/src/components/renders/GpuEncodingToggle.tsx +++ b/packages/studio/src/components/renders/GpuEncodingToggle.tsx @@ -4,7 +4,7 @@ export function GpuEncodingToggle({ disabled, onToggle, }: { - format: "mp4" | "webm" | "mov"; + format: "mp4" | "webm" | "mov" | "gif"; checked: boolean; disabled: boolean; onToggle: () => void; diff --git a/packages/studio/src/components/renders/RenderDurationNote.tsx b/packages/studio/src/components/renders/RenderDurationNote.tsx new file mode 100644 index 0000000000..92a2deafa7 --- /dev/null +++ b/packages/studio/src/components/renders/RenderDurationNote.tsx @@ -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 ( +

+ Last render took {formatEta(durationMs)} +

+ ); +} diff --git a/packages/studio/src/components/renders/RenderPreview.tsx b/packages/studio/src/components/renders/RenderPreview.tsx new file mode 100644 index 0000000000..c5b6adf5c5 --- /dev/null +++ b/packages/studio/src/components/renders/RenderPreview.tsx @@ -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 {filename}; + } + return ( + <> + {hovered && ( +