Skip to content

Commit ae3104c

Browse files
authored
Merge pull request #47 from diffusionstudio/headless-export
Headless export
2 parents 585fb01 + 3b0c260 commit ae3104c

13 files changed

Lines changed: 309 additions & 14 deletions

File tree

‎apps/cli/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@diffusionstudio/cli",
33
"private": true,
4-
"version": "0.202.1",
4+
"version": "0.203.0",
55
"license": "MPL-2.0",
66
"bin": {
77
"dapi": "./dist/index.js"

‎apps/cli/src/cli-channels.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,56 @@ export type CheckResult = {
119119
issues: CheckIssue[];
120120
};
121121

122+
export type ExportFormat = "mp4" | "webm" | "ogg" | "mov";
123+
124+
// The settings shape mirrors the scene's `diffusion.export.<id>` entry in the
125+
// project's package.json (see the web app's engine/project-config), spelled
126+
// out here so the wire seam stays dependency-free. Codecs are strings on the
127+
// wire; the app validates them against what the encoder accepts.
128+
export type ExportVideoSettings = {
129+
enabled?: boolean;
130+
codec?: string;
131+
bitrate?: number;
132+
fps?: number;
133+
resolution?: number;
134+
};
135+
136+
export type ExportAudioSettings = {
137+
enabled?: boolean;
138+
codec?: string;
139+
sampleRate?: number;
140+
bitrate?: number;
141+
};
142+
143+
export type ExportSettings = {
144+
format?: ExportFormat;
145+
video?: ExportVideoSettings;
146+
audio?: ExportAudioSettings;
147+
};
148+
149+
/**
150+
* `path` is the absolute output file, whose extension picks the container;
151+
* omitted, the app writes `exports/<id>.<format>` in the project folder.
152+
* Everything else — codecs, bitrates, resolution — is read from the scene's
153+
* export entry in the project's package.json.
154+
*/
155+
export type ExportRequest = { id: string; path?: string };
156+
157+
/**
158+
* `config` echoes the settings the export was made with — the package.json
159+
* entry (or the defaults), with the container the extension resolved to — so
160+
* a caller sees what its config edit actually did. `width`/`height` are the
161+
* encoded pixel size (0×0 for an audio-only export); `duration` is seconds.
162+
*/
163+
export type ExportResult = {
164+
path: string;
165+
width: number;
166+
height: number;
167+
duration: number;
168+
size: number;
169+
config: ExportSettings;
170+
};
171+
122172
export type GeneratedAsset = { id: string; name: string; type: string };
123173

124174
export type ModelsRequest = { type?: "image" | "video" | "audio" };

‎apps/cli/src/cli-client.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { AppRouter } from "../../web/src/context/dapi";
1515

1616
const DEFAULT_TIMEOUT_MS = 60000;
1717
export const GENERATE_TIMEOUT_MS = 600000;
18+
export const EXPORT_TIMEOUT_MS = 3600000;
1819

1920
// Asks the app (via the unix socket) to have the renderer dial our WebSocket
2021
// server. Main replies once the connect info has been delivered, so a

‎apps/cli/src/index.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
1111
import { Command } from "commander";
1212
import { version } from "../../../package.json";
1313
import { parseTime, TIME_FPS } from "@diffusionstudio/jsx";
14-
import { editor, errnoCode, GENERATE_TIMEOUT_MS, waitForCliSocket } from "./cli-client";
14+
import { editor, errnoCode, EXPORT_TIMEOUT_MS, GENERATE_TIMEOUT_MS, waitForCliSocket } from "./cli-client";
1515
import { listLocalFonts } from "./fonts";
1616
import { buildIssueBody, createIssue } from "./report";
1717
import { fetchVideo } from "./ytdlp";
@@ -20,6 +20,7 @@ import type { AssetRef, FrameQuality, LogEntry, LogLevel, TimecodedImage } from
2020

2121
// Long-running commands (renders, AI generation) override the default 60s.
2222
const GENERATE = { context: { timeoutMs: GENERATE_TIMEOUT_MS } };
23+
const EXPORT = { context: { timeoutMs: EXPORT_TIMEOUT_MS } };
2324

2425
const APP_NAME = "Diffusion Studio";
2526

@@ -302,6 +303,22 @@ async function captureNode(id: string, opts: CaptureOptions): Promise<void> {
302303
}
303304
}
304305

306+
async function exportScene(id: string, output: string | undefined): Promise<void> {
307+
// The app owns everything else: settings come from the project's
308+
// package.json, the extension check and the default output path need the
309+
// config and project folder, which live on its side of the socket.
310+
const path = output !== undefined ? resolve(process.cwd(), output) : undefined;
311+
const stop = startSpinner("Exporting scene");
312+
try {
313+
const result = await editor.export.mutate({ id, path }, EXPORT);
314+
stop();
315+
console.log(JSON.stringify(result));
316+
} catch (e) {
317+
stop();
318+
handleSocketError(e);
319+
}
320+
}
321+
305322
async function checkNode(id: string): Promise<void> {
306323
try {
307324
const result = await editor.check.query({ id });
@@ -624,6 +641,18 @@ program
624641
.option("-o, --output <dir>", "directory to write the PNGs into (default: a fresh dir in the system temp dir)")
625642
.action((id: string, opts: CaptureOptions) => captureNode(id, opts));
626643

644+
program
645+
.command("export")
646+
.description(
647+
`Encode a scene to a video file — the same render the app's export runs, covering the scene's workarea. Settings come from the scene's \`diffusion.export.<id>\` entry in the project's package.json (the entry the app's export panel writes); a scene without one exports with the defaults (1080p H.264 MP4, AAC audio). The [output] extension picks the container, overriding the configured format. Prints one JSON object with the written path and the settings used. One export runs at a time; progress shows in the app.`,
648+
)
649+
.argument("<id>", 'scene id to export or `file:id` when two files use the same id')
650+
.argument(
651+
"[output]",
652+
'output file path, ffmpeg-style; its extension picks the container (default: "exports/<id>.<format>" in the project folder)',
653+
)
654+
.action((id: string, output: string | undefined) => exportScene(id, output));
655+
627656
program
628657
.command("check")
629658
.description(

‎apps/desktop/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@diffusionstudio/desktop",
33
"private": true,
4-
"version": "0.202.1",
4+
"version": "0.203.0",
55
"license": "MPL-2.0",
66
"repository": {
77
"type": "git",

‎apps/web/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@diffusionstudio/web",
33
"private": true,
4-
"version": "0.202.1",
4+
"version": "0.203.0",
55
"license": "MPL-2.0",
66
"type": "module",
77
"scripts": {

‎apps/web/src/context/dapi/api.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import { useWorld } from '@diffusionstudio/koota-solid';
88
import { Project } from '@diffusionstudio/runtime';
99
import { useProject } from '@/context/project';
1010
import { useAuth } from '@/context/auth';
11+
import { useEngineContext } from '@/engine';
1112
import { t, q, q0, m } from "@/lib/cli-rpc";
1213
import { editorSession, requireEditorSession, setEditorSession } from "./session";
1314
import { handleContextGet } from "./context";
1415
import { createAssetResolver, handleMediaProbe, handleMediaFrame, handleMediaTranscribe, handleMediaFilmstrip, handleMediaWaveform, handleMediaListen } from "./media";
1516
import { handleCapture } from "./capture";
1617
import { handleCheck } from "./check";
18+
import { handleExport } from "./export";
1719
import { handleLogs } from "./logs";
1820
import { handleModels } from "./models";
1921
import { handleVoices } from "./voices";
@@ -76,11 +78,12 @@ export function EditorApiProvider(props: EditorApiProviderProps) {
7678
const project = useProject();
7779
const isFullscreen = useFullscreenState();
7880
const world = useWorld();
81+
const engine = useEngineContext();
7982

8083
createEffect(() => {
8184
if (!window.desktop || project.id() !== world.get(Project)?.id) return;
8285

83-
setEditorSession({ world, project });
86+
setEditorSession({ world, project, engine });
8487
onCleanup(() => setEditorSession(null));
8588
});
8689

@@ -117,6 +120,7 @@ function createAppRouter({ navigate, getUser, requireAuth }: AppRouterDeps) {
117120
context: q0(handleContextGet(editorSession)),
118121
capture: q(handleCapture(requireEditorSession)),
119122
check: q(handleCheck(requireEditorSession)),
123+
export: m(handleExport(requireEditorSession)),
120124
models: q(handleModels()),
121125
logs: q(handleLogs()),
122126
screenshot: q0(handleWindowScreenshot()),
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { canEncodeVideo } from "mediabunny";
6+
import { computeOutputSize } from "@diffusionstudio/encoder";
7+
import { Computed, FrameRate, getParentNode, isScene, Source, Workarea } from "@diffusionstudio/runtime";
8+
9+
import { renderOverlay, renderScene } from "@/context/render";
10+
import { ElectronWritableFileHandle } from "@/lib/electron-file-writable";
11+
import { ProjectConfig as ProjectConfigTrait } from "@/engine/traits";
12+
import { sceneConfigKey } from "@/engine/project-config";
13+
import {
14+
getDefaultExportTemplate,
15+
VIDEO_FORMAT_OPTIONS,
16+
} from "@/components/sidebar-right/inspector/export-templates";
17+
import { mainBridge } from "@/lib/ipc";
18+
import { MAIN_CHANNELS } from "@desktop/main-channels";
19+
import { resolveNode } from "./nodes";
20+
21+
import type { ExportRequest, ExportResult, ExportSettings } from "@diffusionstudio/cli/channels";
22+
import type { ContainerFormat, ExportConfig } from "@/engine/project-config";
23+
import type { EditorSession } from "./session";
24+
25+
/**
26+
* The container the export writes: the output extension when a path was
27+
* given (ffmpeg's rule — the file must be what its name says), the config's
28+
* format otherwise, mp4 when neither says. A given path settles it entirely,
29+
* so a bad config format only fails an export that would actually use it.
30+
*/
31+
function resolveFormat(path: string | undefined, config: ExportConfig): ContainerFormat {
32+
const supported = VIDEO_FORMAT_OPTIONS.map((format) => `.${format}`).join(", ");
33+
if (path !== undefined) {
34+
const extension = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase();
35+
if (!extension || !VIDEO_FORMAT_OPTIONS.includes(extension as ContainerFormat)) {
36+
throw new Error(`The output path must end in a container extension: ${supported}.`);
37+
}
38+
return extension as ContainerFormat;
39+
}
40+
const format = config.format ?? "mp4";
41+
if (!VIDEO_FORMAT_OPTIONS.includes(format)) {
42+
throw new Error(`The export entry names an unknown format "${format}" — use one of ${supported.replaceAll(".", "")}.`);
43+
}
44+
return format;
45+
}
46+
47+
export function handleExport(session: () => EditorSession) {
48+
return async ({ id, path }: ExportRequest): Promise<ExportResult> => {
49+
const { world, project, engine } = session();
50+
51+
const scene = resolveNode(world, id);
52+
if (!isScene(scene)) {
53+
let parent = getParentNode(scene);
54+
while (parent !== null && !isScene(parent)) parent = getParentNode(parent);
55+
const stamp = parent?.get(Source)?.value;
56+
throw new Error(
57+
stamp
58+
? `"${id}" is not a scene — a scene is the unit an export renders. Export its scene "${stamp}" instead.`
59+
: `"${id}" is not a scene — a scene is the unit an export renders, so export takes a scene id.`,
60+
);
61+
}
62+
63+
// The scene's entry in the project's package.json (`diffusion.export.<id>`)
64+
// — the same one the app's export panel writes — so a CLI export
65+
// reproduces the in-app one; a scene without an entry uses the default
66+
// template, the way ⌘E does. `template` is only the preset's label.
67+
const base = world.get(ProjectConfigTrait)?.exportOf(scene) ?? getDefaultExportTemplate();
68+
const settings: ExportConfig = { format: base.format, video: base.video, audio: base.audio };
69+
70+
const format = resolveFormat(path, settings);
71+
const key = sceneConfigKey(scene) ?? id;
72+
const target = path ?? `${project.dir()}/exports/${key.replace(/[^\w.-]+/g, "-")}.${format}`;
73+
74+
// Fail before the render machinery spins up: an unencodable configuration
75+
// is known right away (same precheck the UI export runs).
76+
const videoEnabled = format !== "ogg" && settings.video?.enabled !== false;
77+
const computed = scene.get(Computed);
78+
const { width, height } = computeOutputSize(
79+
computed?.width || 1920,
80+
computed?.height || 1080,
81+
settings.video?.resolution ?? 1080,
82+
);
83+
if (videoEnabled) {
84+
const codec = settings.video?.codec ?? "avc";
85+
const encodable = await canEncodeVideo(codec, {
86+
width,
87+
height,
88+
bitrate: settings.video?.bitrate ?? 10e6,
89+
});
90+
if (!encodable) {
91+
throw new Error(
92+
`Cannot encode ${codec.toUpperCase()} at ${width}×${height}. ` +
93+
"Set a lower resolution, a lower bitrate, or another codec in the scene's export entry.",
94+
);
95+
}
96+
}
97+
98+
const workarea = scene.get(Workarea);
99+
const frames = workarea ? workarea.end - workarea.start : computed?.duration ?? 0;
100+
const duration = frames / (world.get(FrameRate)?.value || 30);
101+
102+
// renderScene owns the one render slot (it stops the live engine and
103+
// raises the progress overlay), so refuse a second export rather than
104+
// interleave. No await sits between this check and renderScene claiming
105+
// the overlay, so two racing requests cannot both pass.
106+
if (renderOverlay()) {
107+
throw new Error("An export is already running — wait for it to finish, or cancel it in the app.");
108+
}
109+
110+
const handle = new ElectronWritableFileHandle(target);
111+
try {
112+
const result = await renderScene(engine, {
113+
scene,
114+
target: handle,
115+
config: { ...settings, format },
116+
dir: project.dir(),
117+
});
118+
if (result.type === "canceled") throw new Error("Export canceled in the app");
119+
if (result.type === "error") throw result.error;
120+
} catch (error) {
121+
// Close the fd and drop the partial file; a failed export leaves nothing.
122+
await handle.dispose().catch(() => {});
123+
throw error;
124+
}
125+
126+
const stat = await mainBridge.call(MAIN_CHANNELS.PROJECTS_FS_STAT, {
127+
dir: project.dir(),
128+
source: target,
129+
});
130+
131+
const config: ExportSettings = { format, video: settings.video, audio: settings.audio };
132+
return {
133+
path: target,
134+
width: videoEnabled ? width : 0,
135+
height: videoEnabled ? height : 0,
136+
duration,
137+
size: stat?.size ?? 0,
138+
config,
139+
};
140+
};
141+
}

‎apps/web/src/context/dapi/session.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { assert } from "@/utils/common";
1212

1313
import type { Accessor } from "solid-js";
1414
import type { World } from "koota";
15+
import type { Engine } from "@/engine";
1516

1617
/**
1718
* The open project, as the editor knows it. Structural on purpose: this is
@@ -22,8 +23,8 @@ export type OpenProject = {
2223
dir: () => string;
2324
};
2425

25-
/** What only an open project can offer: the world drawing it, and which project that is. */
26-
export type EditorSession = { world: World; project: OpenProject };
26+
/** What only an open project can offer: the world drawing it, the engine running it, and which project that is. */
27+
export type EditorSession = { world: World; project: OpenProject; engine: Engine };
2728

2829
const [session, setSession] = createSignal<EditorSession | null>(null);
2930

‎package-lock.json‎

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)