diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-30 18:17:45 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-30 18:18:15 +0200 |
| commit | ed02ba872c02eafed45f406c68d389d0a1112a4e (patch) | |
| tree | 40530d77cfe306621915f067c66531cb9e783a4d /apps/web/src/services/renderer | |
| parent | e837e7504379e100ff3b56282577ec67bb88bec9 (diff) | |
| download | opencut-ed02ba872c02eafed45f406c68d389d0a1112a4e.tar.gz | |
feat: Electron desktop app, native ffmpeg export, fully static web
- apps/desktop: Electron shell serving the static web build over a
privileged opencut:// protocol; exports stream frames over IPC to a
bundled ffmpeg (libx264/libvpx-vp9, BT.709 tagged) with backpressure.
electron-builder packaging: AppImage, deb, rpm.
- apps/web: dead backend deleted (better-auth, drizzle/pg, upstash,
botid, freesound proxy, blog, rss, env validation); next build emits
a zero-env static export; /editor/[project_id] -> /editor?project=.
- Native exporter mirrors SceneExporter; activates when the preload
bridge is present; web keeps the mediabunny path.
- Docker self-hosting now builds and serves the static export via nginx.
- Release workflow: v* tags publish static web tarball + Linux packages.
- Changelog 0.4.0; README/AGENTS/CONTRIBUTING/roadmap updated.
- GPUI/Rust rewrite parked on branch rust-rewrite.
Diffstat (limited to 'apps/web/src/services/renderer')
| -rw-r--r-- | apps/web/src/services/renderer/native-exporter.ts | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/apps/web/src/services/renderer/native-exporter.ts b/apps/web/src/services/renderer/native-exporter.ts new file mode 100644 index 00000000..f525f050 --- /dev/null +++ b/apps/web/src/services/renderer/native-exporter.ts @@ -0,0 +1,159 @@ +import EventEmitter from "eventemitter3"; + +import type { FrameRate } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { frameRateToFloat } from "@/fps/utils"; +import { getDesktopBridge } from "@/desktop/bridge"; +import type { ExportFormat, ExportQuality } from "@/export"; +import { CanvasRenderer } from "./canvas-renderer"; +import type { RootNode } from "./nodes/root-node"; + +type NativeExportParams = { + width: number; + height: number; + fps: FrameRate; + format: ExportFormat; + quality: ExportQuality; + shouldIncludeAudio?: boolean; + audioBuffer?: AudioBuffer; + fileName: string; +}; + +export type NativeExporterEvents = { + progress: [progress: number]; + complete: []; + error: [error: Error]; + cancelled: []; +}; + +export class NativeExporter extends EventEmitter<NativeExporterEvents> { + private renderer: CanvasRenderer; + private format: ExportFormat; + private quality: ExportQuality; + private shouldIncludeAudio: boolean; + private audioBuffer?: AudioBuffer; + private fileName: string; + + private isCancelled = false; + + constructor({ + width, + height, + fps, + format, + quality, + shouldIncludeAudio, + audioBuffer, + fileName, + }: NativeExportParams) { + super(); + this.renderer = new CanvasRenderer({ + width, + height, + fps, + }); + + this.format = format; + this.quality = quality; + this.shouldIncludeAudio = shouldIncludeAudio ?? false; + this.audioBuffer = audioBuffer; + this.fileName = fileName; + } + + cancel(): void { + this.isCancelled = true; + } + + private buildAudioPcm(): Float32Array | null { + if (!this.shouldIncludeAudio || !this.audioBuffer) return null; + + const { numberOfChannels, length } = this.audioBuffer; + const pcm = new Float32Array(length * numberOfChannels); + for (let channel = 0; channel < numberOfChannels; channel++) { + const channelData = this.audioBuffer.getChannelData(channel); + for (let i = 0; i < length; i++) { + pcm[i * numberOfChannels + channel] = channelData[i]; + } + } + return pcm; + } + + async export({ rootNode }: { rootNode: RootNode }): Promise<string | null> { + const bridge = getDesktopBridge(); + if (!bridge) { + throw new Error("Desktop bridge is not available"); + } + + const fps = this.renderer.fps; + const fpsFloat = frameRateToFloat(fps); + const ticksPerFrame = Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ); + const frameCount = Math.floor(rootNode.duration / ticksPerFrame); + + const pcm = this.buildAudioPcm(); + + const begin = await bridge.exportBegin({ + width: this.renderer.width, + height: this.renderer.height, + fpsNumerator: fps.numerator, + fpsDenominator: fps.denominator, + format: this.format, + quality: this.quality, + hasAudio: pcm !== null, + sampleRate: this.audioBuffer?.sampleRate ?? 48000, + channels: this.audioBuffer?.numberOfChannels ?? 2, + defaultFileName: this.fileName, + }); + + if (!begin) { + this.emit("cancelled"); + return null; + } + + const { id, filePath } = begin; + + try { + if (pcm) { + await bridge.exportWriteAudio(id, pcm.buffer as ArrayBuffer); + } + + for (let i = 0; i < frameCount; i++) { + if (this.isCancelled) { + await bridge.exportCancel(id); + this.emit("cancelled"); + return null; + } + + const timeTicks = i * ticksPerFrame; + await this.renderer.render({ node: rootNode, time: timeTicks }); + + const frame = new VideoFrame(this.renderer.getOutputCanvas(), { + timestamp: Math.round((i * 1e6) / fpsFloat), + duration: Math.round(1e6 / fpsFloat), + }); + const size = frame.allocationSize({ format: "RGBA" }); + const buffer = new ArrayBuffer(size); + await frame.copyTo(buffer, { format: "RGBA" }); + frame.close(); + + await bridge.exportWriteFrame(id, buffer); + this.emit("progress", i / frameCount); + } + + if (this.isCancelled) { + await bridge.exportCancel(id); + this.emit("cancelled"); + return null; + } + + await bridge.exportFinish(id); + this.emit("progress", 1); + this.emit("complete"); + return filePath; + } catch (error) { + await bridge.exportCancel(id).catch(() => {}); + throw error; + } + } +} |
