From 0e4c1ca5aa321d7abb0c1a43ccce076a0fde15e6 Mon Sep 17 00:00:00 2001 From: lirik Date: Fri, 8 May 2026 13:15:22 +0700 Subject: [PATCH 1/2] feat: add CLI recording and rendering --- cli/openscreen.mjs | 645 ++++++++++++++++++ electron/electron-env.d.ts | 24 + electron/ipc/handlers.ts | 4 + electron/main.ts | 289 +++++++- electron/preload.ts | 12 + package-lock.json | 8 +- package.json | 4 + src/App.tsx | 6 + .../cli-record/CliRecordRenderer.tsx | 224 ++++++ .../cli-render/CliRenderRenderer.tsx | 277 ++++++++ 10 files changed, 1489 insertions(+), 4 deletions(-) create mode 100755 cli/openscreen.mjs create mode 100644 src/components/cli-record/CliRecordRenderer.tsx create mode 100644 src/components/cli-render/CliRenderRenderer.tsx diff --git a/cli/openscreen.mjs b/cli/openscreen.mjs new file mode 100755 index 00000000..dc82e1a8 --- /dev/null +++ b/cli/openscreen.mjs @@ -0,0 +1,645 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, ".."); + +const BOOLEAN_FLAGS = new Set(["gif-loop", "json", "overwrite", "show-blur", "system-audio"]); + +const ASPECT_RATIOS = new Set(["16:9", "9:16", "1:1", "4:3", "4:5", "16:10", "10:16", "native"]); +const ZOOM_DEPTHS = new Set([1, 2, 3, 4, 5, 6]); +const ROTATION_PRESETS = new Set(["iso", "left", "right"]); + +function usage() { + console.log(`OpenScreen CLI + +Usage: + openscreen record --duration [options] + openscreen project create --video --output [options] + openscreen project info --project [--json] + openscreen project edit --project [options] + openscreen zoom add --project --start --end [options] + openscreen zoom list --project [--json] + openscreen zoom remove --project --id + openscreen trim add --project --start --end + openscreen trim list --project [--json] + openscreen trim remove --project --id + openscreen speed add --project --start --end --speed + openscreen speed list --project [--json] + openscreen speed remove --project --id + openscreen render --project --output [options] + +Record options: + --source Source id, exact name, or name substring. + --source-type screen, window, or any. Default: any. + --system-audio Try to include system audio. + +Project/edit options: + --aspect-ratio 16:9, 9:16, 1:1, 4:3, 4:5, 16:10, 10:16, native. + --wallpaper Wallpaper path, color, gradient, data URL, or file URL. + --padding Padding 0-100. + --border-radius Border radius. + --shadow-intensity Shadow intensity. + --show-blur / --no-show-blur + --motion-blur Motion blur amount 0-1. + --export-quality medium, good, source. + --export-format mp4 or gif. + +Render options: + --format mp4 or gif. Defaults from output extension/project. + --quality medium, good, source. + --gif-frame-rate 15, 20, 25, 30. + --gif-size-preset medium, large, original. + --gif-loop / --no-gif-loop + --overwrite Replace existing output. + --json Emit machine-readable JSON. + +Before recording/rendering: + npm install + npm run build-vite +`); +} + +function parseFlags(argv) { + const options = { _: [] }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith("--")) { + options._.push(arg); + continue; + } + + let name = arg.slice(2); + if (name.startsWith("no-")) { + name = name.slice(3); + options[name] = false; + continue; + } + + if (BOOLEAN_FLAGS.has(name)) { + options[name] = true; + continue; + } + + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + throw new Error(`Missing value for --${name}`); + } + options[name] = next; + i++; + } + + return options; +} + +function requireOption(options, name) { + const value = options[name]; + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Missing --${name}`); + } + return value; +} + +function parseNumber(value, name) { + const number = Number(value); + if (!Number.isFinite(number)) { + throw new Error(`--${name} must be a number.`); + } + return number; +} + +function parseMs(value, name) { + if (typeof value !== "string") { + throw new Error(`Missing --${name}`); + } + const normalized = value.trim().toLowerCase(); + const number = normalized.endsWith("s") + ? Number(normalized.slice(0, -1)) * 1000 + : Number(normalized); + if (!Number.isFinite(number) || number < 0) { + throw new Error(`--${name} must be a non-negative millisecond value or seconds value like 2s.`); + } + return Math.round(number); +} + +function parseRange(options) { + const start = parseMs(options.start ?? options["start-ms"], "start"); + const end = parseMs(options.end ?? options["end-ms"], "end"); + if (end <= start) { + throw new Error("--end must be greater than --start."); + } + return { startMs: start, endMs: end }; +} + +function parseAspectRatio(value) { + if (value === undefined) return undefined; + if (!ASPECT_RATIOS.has(value)) { + throw new Error(`Unsupported aspect ratio: ${value}`); + } + return value; +} + +function parseExportQuality(value) { + if (value === undefined) return undefined; + if (value !== "medium" && value !== "good" && value !== "source") { + throw new Error("--quality/--export-quality must be medium, good, or source."); + } + return value; +} + +function parseExportFormat(value) { + if (value === undefined) return undefined; + if (value !== "mp4" && value !== "gif") { + throw new Error("--format/--export-format must be mp4 or gif."); + } + return value; +} + +function findElectronBinary() { + const localElectron = path.join(projectRoot, "node_modules", ".bin", "electron"); + if (fs.existsSync(localElectron)) return localElectron; + return "electron"; +} + +function findMainJs() { + const mainJs = path.join(projectRoot, "dist-electron", "main.js"); + if (fs.existsSync(mainJs)) return mainJs; + throw new Error("Cannot find dist-electron/main.js. Run `npm run build-vite` first."); +} + +function ensureParentDir(filePath) { + fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true }); +} + +function createDefaultEditor(overrides = {}) { + return { + wallpaper: "/wallpapers/wallpaper1.jpg", + shadowIntensity: 0, + showBlur: false, + motionBlurAmount: 0, + borderRadius: 0, + padding: 50, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + zoomRegions: [], + trimRegions: [], + speedRegions: [], + annotationRegions: [], + aspectRatio: "16:9", + webcamLayoutPreset: "picture-in-picture", + webcamMaskShape: "rectangle", + webcamSizePreset: 25, + webcamPosition: null, + exportQuality: "good", + exportFormat: "mp4", + gifFrameRate: 15, + gifLoop: true, + gifSizePreset: "medium", + cursorHighlight: { + enabled: false, + style: "ring", + sizePx: 24, + color: "#FFD700", + opacity: 0.9, + onlyOnClicks: false, + clickEmphasisDurationMs: 350, + offsetXNorm: 0, + offsetYNorm: 0, + }, + ...overrides, + }; +} + +function createDefaultProject(sessionOrMedia, editorOverrides = {}) { + const media = { + screenVideoPath: sessionOrMedia.screenVideoPath, + ...(sessionOrMedia.webcamVideoPath ? { webcamVideoPath: sessionOrMedia.webcamVideoPath } : {}), + }; + + return { + version: 2, + media, + editor: createDefaultEditor(editorOverrides), + }; +} + +function normalizeProject(project) { + if (!project || typeof project !== "object") { + throw new Error("Invalid project data."); + } + const media = + project.media && typeof project.media.screenVideoPath === "string" + ? project.media + : typeof project.videoPath === "string" + ? { screenVideoPath: project.videoPath } + : null; + if (!media) { + throw new Error("Project does not reference a screen video."); + } + + return { + version: typeof project.version === "number" ? project.version : 2, + media, + editor: createDefaultEditor(project.editor ?? {}), + }; +} + +function loadProject(projectPath) { + const absolutePath = path.resolve(projectPath); + const raw = fs.readFileSync(absolutePath, "utf-8"); + return normalizeProject(JSON.parse(raw)); +} + +function saveProject(projectPath, project) { + const absolutePath = path.resolve(projectPath); + ensureParentDir(absolutePath); + fs.writeFileSync(absolutePath, JSON.stringify(normalizeProject(project), null, 2), "utf-8"); + return absolutePath; +} + +function printResult(result, json) { + if (json) { + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (typeof result === "string") { + process.stdout.write(`${result}\n`); + return; + } + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +function deriveNextId(prefix, regions) { + let max = 0; + for (const region of regions) { + const match = + typeof region.id === "string" ? region.id.match(new RegExp(`^${prefix}-(\\d+)$`)) : null; + if (match) max = Math.max(max, Number(match[1])); + } + return `${prefix}-${max + 1}`; +} + +function applyEditorOptions(editor, options) { + const updates = {}; + const aspectRatio = parseAspectRatio(options["aspect-ratio"]); + if (aspectRatio) updates.aspectRatio = aspectRatio; + if (typeof options.wallpaper === "string") updates.wallpaper = options.wallpaper; + if (options.padding !== undefined) updates.padding = parseNumber(options.padding, "padding"); + if (options["border-radius"] !== undefined) { + updates.borderRadius = parseNumber(options["border-radius"], "border-radius"); + } + if (options["shadow-intensity"] !== undefined) { + updates.shadowIntensity = parseNumber(options["shadow-intensity"], "shadow-intensity"); + } + if (options["show-blur"] !== undefined) updates.showBlur = Boolean(options["show-blur"]); + if (options["motion-blur"] !== undefined) { + updates.motionBlurAmount = parseNumber(options["motion-blur"], "motion-blur"); + } + const exportQuality = parseExportQuality(options["export-quality"] ?? options.quality); + if (exportQuality) updates.exportQuality = exportQuality; + const exportFormat = parseExportFormat(options["export-format"] ?? options.format); + if (exportFormat) updates.exportFormat = exportFormat; + + return createDefaultEditor({ ...editor, ...updates }); +} + +function copyCompanionFile(sourceVideoPath, outputVideoPath, suffix) { + const from = `${sourceVideoPath}${suffix}`; + if (!fs.existsSync(from)) return; + fs.copyFileSync(from, `${outputVideoPath}${suffix}`); +} + +async function runElectronMode(mode, config, options) { + const configPath = path.join(os.tmpdir(), `openscreen-cli-${mode}-${randomUUID()}.json`); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); + + let done = null; + let lastError = null; + + try { + await new Promise((resolve, reject) => { + const child = spawn( + findElectronBinary(), + [findMainJs(), `--cli-${mode}`, "--config", configPath, "--no-sandbox"], + { + cwd: projectRoot, + env: { + ...process.env, + HEADLESS: "true", + ELECTRON_DISABLE_SECURITY_WARNINGS: "true", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + child.stdout.on("data", (chunk) => { + for (const line of chunk.toString().split("\n").filter(Boolean)) { + try { + const message = JSON.parse(line); + if (!message.__cli) continue; + + if (message.type === "status" && !options.json) { + process.stderr.write(`${message.data?.message ?? ""}\n`); + } + if (message.type === "warning" && !options.json) { + process.stderr.write(`Warning: ${message.data?.message ?? ""}\n`); + } + if (message.type === "progress" && !options.json) { + const current = Number(message.data?.currentFrame ?? 0); + const total = Number(message.data?.totalFrames ?? 0); + if (total > 0) { + process.stderr.write(`\r${Math.round((current / total) * 100)}%`); + } + } + if (message.type === "done") done = message.data; + if (message.type === "error") { + lastError = message.data?.message ?? `Unknown ${mode} error`; + } + } catch { + // Ignore non-JSON logs from Electron. + } + } + }); + + child.stderr.on("data", (chunk) => { + const text = chunk.toString().trim(); + if (text.includes("Error") || text.includes("ERROR")) { + lastError = text; + } + }); + + child.on("error", reject); + child.on("close", (code) => { + if (code === 0 && done) { + resolve(); + } else { + reject(new Error(lastError || `OpenScreen ${mode} process exited with code ${code}`)); + } + }); + }); + } finally { + try { + fs.unlinkSync(configPath); + } catch { + // Ignore cleanup failures. + } + } + + if (!options.json && mode === "render") { + process.stderr.write("\n"); + } + return done; +} + +async function runRecord(options) { + const durationSeconds = Number(requireOption(options, "duration")); + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) { + throw new Error("--duration must be a positive number of seconds."); + } + + const sourceType = options["source-type"] ?? "any"; + if (!["screen", "window", "any"].includes(sourceType)) { + throw new Error("--source-type must be screen, window, or any."); + } + + const done = await runElectronMode( + "record", + { + durationMs: Math.round(durationSeconds * 1000), + source: options.source, + sourceType, + systemAudio: Boolean(options["system-audio"]), + }, + options, + ); + + if (options.output) { + const outputPath = path.resolve(options.output); + ensureParentDir(outputPath); + fs.copyFileSync(done.path, outputPath); + copyCompanionFile(done.path, outputPath, ".cursor.json"); + done.path = outputPath; + done.session = { ...done.session, screenVideoPath: outputPath }; + } + + if (options.project) { + const projectPath = path.resolve(options.project); + saveProject(projectPath, createDefaultProject(done.session)); + done.projectPath = projectPath; + } + + printResult( + options.json + ? { success: true, ...done } + : `Recorded: ${done.path}${done.projectPath ? `\nProject: ${done.projectPath}` : ""}`, + options.json, + ); +} + +function commandProject(action, options) { + if (action === "create") { + const videoPath = path.resolve(requireOption(options, "video")); + if (!fs.existsSync(videoPath)) throw new Error(`Video file not found: ${videoPath}`); + const media = { screenVideoPath: videoPath }; + if (options.webcam) { + const webcamPath = path.resolve(options.webcam); + if (!fs.existsSync(webcamPath)) throw new Error(`Webcam file not found: ${webcamPath}`); + media.webcamVideoPath = webcamPath; + } + const outputPath = saveProject( + requireOption(options, "output"), + createDefaultProject(media, applyEditorOptions({}, options)), + ); + printResult({ success: true, path: outputPath }, options.json); + return; + } + + const projectPath = requireOption(options, "project"); + const project = loadProject(projectPath); + + if (action === "info") { + printResult( + { + version: project.version, + media: project.media, + settings: { + aspectRatio: project.editor.aspectRatio, + padding: project.editor.padding, + wallpaper: project.editor.wallpaper, + exportQuality: project.editor.exportQuality, + exportFormat: project.editor.exportFormat, + }, + regions: { + zooms: project.editor.zoomRegions.length, + trims: project.editor.trimRegions.length, + speeds: project.editor.speedRegions.length, + annotations: project.editor.annotationRegions.length, + }, + }, + options.json, + ); + return; + } + + if (action === "validate") { + printResult({ success: true, valid: true }, options.json); + return; + } + + if (action === "edit") { + project.editor = applyEditorOptions(project.editor, options); + const savedPath = saveProject(projectPath, project); + printResult({ success: true, path: savedPath }, options.json); + return; + } + + throw new Error(`Unknown project command: ${action}`); +} + +function getRegionList(editor, kind) { + if (kind === "zoom") return editor.zoomRegions; + if (kind === "trim") return editor.trimRegions; + if (kind === "speed") return editor.speedRegions; + throw new Error(`Unknown region kind: ${kind}`); +} + +function commandRegion(kind, action, options) { + const projectPath = requireOption(options, "project"); + const project = loadProject(projectPath); + const regions = getRegionList(project.editor, kind); + + if (action === "list") { + printResult(regions, options.json); + return; + } + + if (action === "remove") { + const id = requireOption(options, "id"); + const index = regions.findIndex((region) => region.id === id); + if (index === -1) throw new Error(`Region not found: ${id}`); + regions.splice(index, 1); + const savedPath = saveProject(projectPath, project); + printResult({ success: true, path: savedPath, removed: id }, options.json); + return; + } + + if (action !== "add") { + throw new Error(`Unknown ${kind} command: ${action}`); + } + + const range = parseRange(options); + const id = options.id ?? deriveNextId(kind, regions); + let region; + + if (kind === "zoom") { + const depth = Number(options.depth ?? 3); + if (!ZOOM_DEPTHS.has(depth)) throw new Error("--depth must be 1-6."); + const focusX = + options["focus-x"] !== undefined ? parseNumber(options["focus-x"], "focus-x") : 0.5; + const focusY = + options["focus-y"] !== undefined ? parseNumber(options["focus-y"], "focus-y") : 0.5; + const rotationPreset = options["rotation-preset"]; + if (rotationPreset !== undefined && !ROTATION_PRESETS.has(rotationPreset)) { + throw new Error("--rotation-preset must be iso, left, or right."); + } + region = { + id, + ...range, + depth, + focus: { cx: focusX, cy: focusY }, + focusMode: options["focus-mode"] === "auto" ? "auto" : "manual", + ...(rotationPreset ? { rotationPreset } : {}), + }; + } else if (kind === "trim") { + region = { id, ...range }; + } else { + const speed = parseNumber(requireOption(options, "speed"), "speed"); + if (speed < 0.1 || speed > 16) throw new Error("--speed must be between 0.1 and 16."); + region = { id, ...range, speed }; + } + + regions.push(region); + const savedPath = saveProject(projectPath, project); + printResult({ success: true, path: savedPath, region }, options.json); +} + +async function commandRender(options) { + const projectPath = requireOption(options, "project"); + const outputPath = path.resolve(requireOption(options, "output")); + if (fs.existsSync(outputPath) && !options.overwrite) { + throw new Error(`Output file already exists: ${outputPath}. Use --overwrite to replace.`); + } + + const project = loadProject(projectPath); + const extension = path.extname(outputPath).toLowerCase(); + const format = + parseExportFormat(options.format) ?? + (extension === ".gif" ? "gif" : project.editor.exportFormat); + const quality = parseExportQuality(options.quality) ?? project.editor.exportQuality; + const gifFrameRate = + options["gif-frame-rate"] !== undefined + ? Number(options["gif-frame-rate"]) + : project.editor.gifFrameRate; + if (![15, 20, 25, 30].includes(gifFrameRate)) { + throw new Error("--gif-frame-rate must be 15, 20, 25, or 30."); + } + const gifSizePreset = options["gif-size-preset"] ?? project.editor.gifSizePreset; + if (!["medium", "large", "original"].includes(gifSizePreset)) { + throw new Error("--gif-size-preset must be medium, large, or original."); + } + + const done = await runElectronMode( + "render", + { + project, + output: outputPath, + format, + quality, + gifFrameRate, + gifSizePreset, + gifLoop: options["gif-loop"] ?? project.editor.gifLoop, + }, + options, + ); + + printResult(options.json ? { success: true, ...done } : `Rendered: ${done.path}`, options.json); +} + +async function main() { + const argv = process.argv.slice(2); + if (argv.length === 0 || argv.includes("--help") || argv[0] === "help") { + usage(); + return; + } + + const [command, maybeAction, ...rest] = argv; + if (command === "record") { + await runRecord(parseFlags([maybeAction, ...rest].filter(Boolean))); + return; + } + if (command === "render") { + await commandRender(parseFlags([maybeAction, ...rest].filter(Boolean))); + return; + } + if (command === "project") { + commandProject(maybeAction, parseFlags(rest)); + return; + } + if (command === "zoom" || command === "trim" || command === "speed") { + commandRegion(command, maybeAction, parseFlags(rest)); + return; + } + + throw new Error(`Unknown command: ${command}`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index d9ebab27..a89fe788 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -145,6 +145,30 @@ interface Window { showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; hideCountdownOverlay: (runId: number) => Promise; + getCliRecordConfig: () => Promise<{ + durationMs: number; + source?: string; + sourceType?: "screen" | "window" | "any"; + systemAudio?: boolean; + }>; + cliRecordMessage: (message: { type: string; data: unknown }) => void; + getCliRenderConfig: () => Promise<{ + project: { + media?: { + screenVideoPath?: string; + webcamVideoPath?: string; + }; + videoPath?: string; + editor: unknown; + }; + output: string; + format: "mp4" | "gif"; + quality?: "medium" | "good" | "source"; + gifFrameRate?: 15 | 20 | 25 | 30; + gifSizePreset?: "medium" | "large" | "original"; + gifLoop?: boolean; + }>; + cliRenderMessage: (message: { type: string; data: unknown }) => void; onCountdownOverlayValue: (callback: (value: number | null) => void) => () => void; setMicrophoneExpanded: (expanded: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 7361b26f..06001d0a 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -44,6 +44,10 @@ function approveFilePath(filePath: string): void { approvedPaths.add(path.resolve(filePath)); } +export function approveReadablePath(filePath: string): void { + approveFilePath(filePath); +} + function getAllowedReadDirs(): string[] { return [RECORDINGS_DIR]; } diff --git a/electron/main.ts b/electron/main.ts index c2bee861..7e141b0a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { app, BrowserWindow, @@ -13,7 +13,7 @@ import { Tray, } from "electron"; import { mainT, setMainLocale } from "./i18n"; -import { registerIpcHandlers } from "./ipc/handlers"; +import { approveReadablePath, registerIpcHandlers } from "./ipc/handlers"; import { createCountdownOverlayWindow, createEditorWindow, @@ -74,6 +74,10 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST; +function getAssetBaseUrlArg() { + return `--asset-base-url=${pathToFileURL(`${process.env.VITE_PUBLIC}${path.sep}`).toString()}`; +} + // Window references let mainWindow: BrowserWindow | null = null; let sourceSelectorWindow: BrowserWindow | null = null; @@ -444,8 +448,289 @@ app.on("activate", () => { } }); +// CLI record mode runs the existing Electron capture stack without showing the HUD. +// The Node CLI owns argument parsing and project output; the renderer owns +// getUserMedia/MediaRecorder so capture behavior stays aligned with the app. +const isCliRecord = process.argv.includes("--cli-record"); +const isCliRender = process.argv.includes("--cli-render"); + +type CliRecordConfig = { + durationMs: number; + source?: string; + sourceType?: "screen" | "window" | "any"; + systemAudio?: boolean; +}; + +type CliRenderConfig = { + project: { + media?: { + screenVideoPath?: string; + webcamVideoPath?: string; + }; + videoPath?: string; + editor: unknown; + }; + output: string; + format: "mp4" | "gif"; + quality?: "medium" | "good" | "source"; + gifFrameRate?: 15 | 20 | 25 | 30; + gifSizePreset?: "medium" | "large" | "original"; + gifLoop?: boolean; +}; + +function getCliArg(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index !== -1 && index + 1 < process.argv.length ? process.argv[index + 1] : undefined; +} + +function writeCliMessage(type: string, data: unknown) { + process.stdout.write(`${JSON.stringify({ __cli: true, type, data })}\n`); +} + +async function readCliRecordConfig(): Promise { + const configPath = getCliArg("--config"); + if (!configPath) { + throw new Error("Missing --config argument."); + } + + const rawConfig = await fs.readFile(configPath, "utf8"); + const parsed = JSON.parse(rawConfig) as Partial; + const durationMs = Number(parsed.durationMs); + if (!Number.isFinite(durationMs) || durationMs <= 0) { + throw new Error("CLI record config requires a positive durationMs."); + } + + const sourceType = + parsed.sourceType === "screen" || parsed.sourceType === "window" || parsed.sourceType === "any" + ? parsed.sourceType + : "any"; + + return { + durationMs, + source: typeof parsed.source === "string" ? parsed.source : undefined, + sourceType, + systemAudio: parsed.systemAudio === true, + }; +} + +async function runCliRecord() { + const config = await readCliRecordConfig(); + await ensureRecordingsDir(); + + const createHiddenWindow = () => + new BrowserWindow({ + width: 1, + height: 1, + show: false, + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + }, + }); + + registerIpcHandlers( + () => { + /* no editor window in CLI mode */ + }, + createHiddenWindow, + createHiddenWindow, + () => null, + () => null, + () => null, + ); + + ipcMain.handle("get-cli-record-config", () => config); + + let exiting = false; + let safetyTimer: ReturnType | undefined; + const finish = (exitCode: number) => { + if (exiting) return; + exiting = true; + if (safetyTimer) clearTimeout(safetyTimer); + setTimeout(() => app.exit(exitCode), 250); + }; + + ipcMain.on("cli-record-message", (_, message: { type: string; data: unknown }) => { + writeCliMessage(message.type, message.data); + if (message.type === "done") finish(0); + if (message.type === "error") finish(1); + }); + + const win = new BrowserWindow({ + width: 1280, + height: 720, + show: false, + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, + backgroundThrottling: false, + }, + }); + + if (VITE_DEV_SERVER_URL) { + await win.loadURL(`${VITE_DEV_SERVER_URL}?windowType=cli-record`); + } else { + await win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "cli-record" }, + }); + } + + safetyTimer = setTimeout( + () => { + writeCliMessage("error", { message: "Recording timed out." }); + finish(1); + }, + Math.max(config.durationMs + 120_000, 180_000), + ); +} + +async function readCliRenderConfig(): Promise { + const configPath = getCliArg("--config"); + if (!configPath) { + throw new Error("Missing --config argument."); + } + + approveReadablePath(configPath); + const rawConfig = await fs.readFile(configPath, "utf8"); + const parsed = JSON.parse(rawConfig) as Partial; + if (!parsed.project || typeof parsed.project !== "object") { + throw new Error("CLI render config requires a project."); + } + if (typeof parsed.output !== "string" || !parsed.output.trim()) { + throw new Error("CLI render config requires an output path."); + } + + const output = path.resolve(parsed.output); + await fs.mkdir(path.dirname(output), { recursive: true }); + + const screenVideoPath = + typeof parsed.project.media?.screenVideoPath === "string" + ? parsed.project.media.screenVideoPath + : typeof parsed.project.videoPath === "string" + ? parsed.project.videoPath + : undefined; + if (screenVideoPath) { + approveReadablePath(screenVideoPath); + } + if (typeof parsed.project.media?.webcamVideoPath === "string") { + approveReadablePath(parsed.project.media.webcamVideoPath); + } + + return { + project: parsed.project as CliRenderConfig["project"], + output, + format: parsed.format === "gif" ? "gif" : "mp4", + quality: parsed.quality === "medium" || parsed.quality === "source" ? parsed.quality : "good", + gifFrameRate: parsed.gifFrameRate, + gifSizePreset: + parsed.gifSizePreset === "large" || parsed.gifSizePreset === "original" + ? parsed.gifSizePreset + : "medium", + gifLoop: parsed.gifLoop, + }; +} + +async function runCliRender() { + const config = await readCliRenderConfig(); + await ensureRecordingsDir(); + + registerIpcHandlers( + () => { + /* no editor window in CLI mode */ + }, + () => new BrowserWindow({ show: false }), + () => new BrowserWindow({ show: false }), + () => null, + () => null, + () => null, + ); + + ipcMain.handle("get-cli-render-config", () => config); + ipcMain.removeHandler("save-exported-video"); + ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer) => { + try { + await fs.writeFile(config.output, Buffer.from(videoData)); + return { success: true, path: config.output, message: "Export saved" }; + } catch (error) { + return { success: false, message: String(error) }; + } + }); + + let exiting = false; + let safetyTimer: ReturnType | undefined; + const finish = (exitCode: number) => { + if (exiting) return; + exiting = true; + if (safetyTimer) clearTimeout(safetyTimer); + setTimeout(() => app.exit(exitCode), 250); + }; + + ipcMain.on("cli-render-message", (_, message: { type: string; data: unknown }) => { + writeCliMessage(message.type, message.data); + if (message.type === "done") finish(0); + if (message.type === "error") finish(1); + }); + + const win = new BrowserWindow({ + width: 1920, + height: 1080, + show: false, + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + additionalArguments: [getAssetBaseUrlArg()], + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, + backgroundThrottling: false, + }, + }); + + if (VITE_DEV_SERVER_URL) { + await win.loadURL(`${VITE_DEV_SERVER_URL}?windowType=cli-render`); + } else { + await win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "cli-render" }, + }); + } + + safetyTimer = setTimeout( + () => { + writeCliMessage("error", { message: "Render timed out." }); + finish(1); + }, + 10 * 60 * 1000, + ); +} + // Register all IPC handlers when app is ready app.whenReady().then(async () => { + if (isCliRecord) { + try { + await runCliRecord(); + } catch (error) { + writeCliMessage("error", { + message: error instanceof Error ? error.message : String(error), + }); + app.exit(1); + } + return; + } + + if (isCliRender) { + try { + await runCliRender(); + } catch (error) { + writeCliMessage("error", { + message: error instanceof Error ? error.message : String(error), + }); + app.exit(1); + } + return; + } + // Force the app into "regular" activation policy so the Dock icon appears. // The HUD overlay (transparent + frameless + skipTaskbar) is the first // window we open, and AppKit otherwise classifies us as an accessory app. diff --git a/electron/preload.ts b/electron/preload.ts index 6c705d7b..6aa6bbc5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -149,6 +149,18 @@ contextBridge.exposeInMainWorld("electronAPI", { hideCountdownOverlay: (runId: number) => { return ipcRenderer.invoke("countdown-overlay-hide", runId); }, + getCliRecordConfig: () => { + return ipcRenderer.invoke("get-cli-record-config"); + }, + cliRecordMessage: (message: { type: string; data: unknown }) => { + ipcRenderer.send("cli-record-message", message); + }, + getCliRenderConfig: () => { + return ipcRenderer.invoke("get-cli-render-config"); + }, + cliRenderMessage: (message: { type: string; data: unknown }) => { + ipcRenderer.send("cli-render-message", message); + }, onCountdownOverlayValue: (callback: (value: number | null) => void) => { const listener = (_event: unknown, value: number | null) => callback(value); ipcRenderer.on("countdown-overlay-value", listener); diff --git a/package-lock.json b/package-lock.json index e823ad1c..3ce9ac8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "openscreen", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.3.0", + "version": "1.4.0", + "hasInstallScript": true, "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", @@ -51,6 +52,9 @@ "uuid": "^13.0.0", "web-demuxer": "^4.0.0" }, + "bin": { + "openscreen": "cli/openscreen.mjs" + }, "devDependencies": { "@biomejs/biome": "^2.4.12", "@electron/rebuild": "^4.0.4", diff --git a/package.json b/package.json index 2ccb0b32..ced37798 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,14 @@ "test:browser": "vitest --config vitest.browser.config.ts --run", "test:browser:install": "playwright install --with-deps chromium-headless-shell", "test:e2e": "playwright test", + "cli": "node cli/openscreen.mjs", "prepare": "husky", "rebuild:native": "node ./scripts/rebuild-native.mjs", "postinstall": "npm run rebuild:native" }, + "bin": { + "openscreen": "cli/openscreen.mjs" + }, "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", diff --git a/src/App.tsx b/src/App.tsx index f5fa7d69..3c7f6d54 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,6 @@ import { useEffect, useState } from "react"; +import { CliRecordRenderer } from "./components/cli-record/CliRecordRenderer"; +import { CliRenderRenderer } from "./components/cli-render/CliRenderRenderer"; import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; @@ -56,6 +58,10 @@ export default function App() { return ; case "countdown-overlay": return ; + case "cli-record": + return ; + case "cli-render": + return ; case "editor": return ( diff --git a/src/components/cli-record/CliRecordRenderer.tsx b/src/components/cli-record/CliRecordRenderer.tsx new file mode 100644 index 00000000..ab3a119f --- /dev/null +++ b/src/components/cli-record/CliRecordRenderer.tsx @@ -0,0 +1,224 @@ +import { fixWebmDuration } from "@fix-webm-duration/fix"; +import { useEffect, useRef } from "react"; + +const TARGET_FRAME_RATE = 60; +const MIN_FRAME_RATE = 30; +const TARGET_WIDTH = 3840; +const TARGET_HEIGHT = 2160; +const RECORDER_TIMESLICE_MS = 1000; +const CHROME_MEDIA_SOURCE = "desktop"; +const VIDEO_FILE_EXTENSION = ".webm"; + +type CliRecordConfig = { + durationMs: number; + source?: string; + sourceType?: "screen" | "window" | "any"; + systemAudio?: boolean; +}; + +type RecorderHandle = { + recorder: MediaRecorder; + recordedBlobPromise: Promise; +}; + +function sendToMain(type: string, data: unknown) { + window.electronAPI?.cliRecordMessage?.({ type, data }); +} + +function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle { + const recorder = new MediaRecorder(stream, options); + const chunks: Blob[] = []; + const mimeType = options.mimeType || "video/webm"; + const recordedBlobPromise = new Promise((resolve, reject) => { + recorder.ondataavailable = (event: BlobEvent) => { + if (event.data && event.data.size > 0) chunks.push(event.data); + }; + recorder.onerror = () => reject(new Error("Recording failed")); + recorder.onstop = () => resolve(new Blob(chunks, { type: mimeType })); + }); + + recorder.start(RECORDER_TIMESLICE_MS); + return { recorder, recordedBlobPromise }; +} + +function selectMimeType() { + const preferred = [ + "video/webm;codecs=h264", + "video/webm;codecs=vp8", + "video/webm;codecs=vp9", + "video/webm;codecs=av1", + "video/webm", + ]; + + return preferred.find((type) => MediaRecorder.isTypeSupported(type)) ?? "video/webm"; +} + +function computeBitrate(width: number, height: number) { + const pixels = width * height; + if (pixels >= 3840 * 2160) return 76_500_000; + if (pixels >= 2560 * 1440) return 47_600_000; + return 30_600_000; +} + +function sourceMatches( + source: ProcessedDesktopSource, + sourceType: CliRecordConfig["sourceType"], + query?: string, +) { + if (sourceType === "screen" && !source.id.startsWith("screen:")) return false; + if (sourceType === "window" && !source.id.startsWith("window:")) return false; + if (!query) return true; + + const normalizedQuery = query.toLowerCase(); + return ( + source.id === query || + source.display_id === query || + source.name.toLowerCase() === normalizedQuery || + source.name.toLowerCase().includes(normalizedQuery) + ); +} + +async function resolveSource(config: CliRecordConfig) { + const sources = await window.electronAPI.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 1, height: 1 }, + fetchWindowIcons: false, + }); + const sourceType = config.sourceType ?? "any"; + const source = sources.find((candidate) => sourceMatches(candidate, sourceType, config.source)); + if (!source) { + const available = sources.map((candidate) => `${candidate.id} ${candidate.name}`).join("; "); + throw new Error(`Recording source not found. Available sources: ${available}`); + } + await window.electronAPI.selectSource(source); + return source; +} + +async function record(config: CliRecordConfig) { + if (!Number.isFinite(config.durationMs) || config.durationMs <= 0) { + throw new Error("durationMs must be a positive number."); + } + + sendToMain("status", { message: "Resolving capture source..." }); + const source = await resolveSource(config); + + sendToMain("status", { message: `Recording ${source.name}...` }); + const videoConstraints = { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: source.id, + maxWidth: TARGET_WIDTH, + maxHeight: TARGET_HEIGHT, + maxFrameRate: TARGET_FRAME_RATE, + minFrameRate: MIN_FRAME_RATE, + }, + }; + + let mediaStream: MediaStream | null = null; + let recorderHandle: RecorderHandle | null = null; + const recordingId = Date.now(); + + try { + try { + mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: config.systemAudio + ? { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: source.id, + }, + } + : false, + video: videoConstraints, + } as unknown as MediaStreamConstraints); + } catch (error) { + if (!config.systemAudio) throw error; + sendToMain("status", { message: "System audio unavailable; retrying video-only..." }); + mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: videoConstraints, + } as unknown as MediaStreamConstraints); + } + + const videoTrack = mediaStream.getVideoTracks()[0]; + if (!videoTrack) throw new Error("Video track is not available."); + + try { + await videoTrack.applyConstraints({ + frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, + width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH }, + height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT }, + }); + } catch { + // Best-effort constraints; Electron may already choose the nearest valid mode. + } + + const settings = videoTrack.getSettings(); + const width = Math.max(1, Math.floor((settings.width ?? 1920) / 2) * 2); + const height = Math.max(1, Math.floor((settings.height ?? 1080) / 2) * 2); + const mimeType = selectMimeType(); + recorderHandle = createRecorderHandle(mediaStream, { + mimeType, + videoBitsPerSecond: computeBitrate(width, height), + ...(mediaStream.getAudioTracks().length > 0 ? { audioBitsPerSecond: 192_000 } : {}), + }); + + await window.electronAPI.setRecordingState(true, recordingId); + const startedAt = Date.now(); + await new Promise((resolve) => window.setTimeout(resolve, config.durationMs)); + recorderHandle.recorder.stop(); + const recordedBlob = await recorderHandle.recordedBlobPromise; + const durationMs = Math.max(1, Date.now() - startedAt); + const fixedBlob = await fixWebmDuration(recordedBlob, durationMs); + const fileName = `recording-${recordingId}${VIDEO_FILE_EXTENSION}`; + const result = await window.electronAPI.storeRecordedSession({ + screen: { + videoData: await fixedBlob.arrayBuffer(), + fileName, + }, + createdAt: recordingId, + }); + + if (!result.success || !result.path || !result.session) { + throw new Error(result.message || result.error || "Failed to store recording."); + } + + sendToMain("done", { + path: result.path, + session: result.session, + durationMs, + source: { + id: source.id, + name: source.name, + display_id: source.display_id, + }, + }); + } finally { + try { + await window.electronAPI.setRecordingState(false); + } catch { + // The CLI result should not be masked by tray/state cleanup. + } + mediaStream?.getTracks().forEach((track) => track.stop()); + } +} + +export function CliRecordRenderer() { + const started = useRef(false); + + useEffect(() => { + if (started.current) return; + started.current = true; + + window.electronAPI + .getCliRecordConfig() + .then((config) => record(config)) + .catch((error) => { + sendToMain("error", { + message: error instanceof Error ? error.message : String(error), + }); + }); + }, []); + + return
; +} diff --git a/src/components/cli-render/CliRenderRenderer.tsx b/src/components/cli-render/CliRenderRenderer.tsx new file mode 100644 index 00000000..df383434 --- /dev/null +++ b/src/components/cli-render/CliRenderRenderer.tsx @@ -0,0 +1,277 @@ +import { useEffect, useRef } from "react"; +import { + calculateOutputDimensions, + type ExportProgress, + type ExportQuality, + GIF_SIZE_PRESETS, + GifExporter, + type GifFrameRate, + type GifSizePreset, + VideoExporter, +} from "@/lib/exporter"; +import type { ProjectMedia } from "@/lib/recordingSession"; +import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils"; +import { + normalizeProjectEditor, + type ProjectEditorState, + resolveProjectMedia, + toFileUrl, +} from "../video-editor/projectPersistence"; + +type CliRenderConfig = Awaited>; + +function sendToMain(type: string, data: unknown) { + window.electronAPI?.cliRenderMessage?.({ type, data }); +} + +async function loadVideoMetadata(videoUrl: string): Promise<{ width: number; height: number }> { + const video = document.createElement("video"); + video.src = videoUrl; + video.preload = "metadata"; + + try { + await new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + video.onloadedmetadata = () => { + clearTimeout(timer); + resolve(); + }; + video.onerror = () => { + clearTimeout(timer); + reject(new Error("Failed to load video metadata")); + }; + timer = setTimeout(() => reject(new Error("Video metadata load timeout")), 30_000); + }); + + return { + width: video.videoWidth || 1920, + height: video.videoHeight || 1080, + }; + } finally { + video.src = ""; + video.load(); + } +} + +function even(value: number) { + return Math.max(2, Math.floor(value / 2) * 2); +} + +function calculateMp4Dimensions( + sourceWidth: number, + sourceHeight: number, + aspectRatioValue: number, + quality: ExportQuality, +) { + if (quality === "source") { + let exportWidth = sourceWidth; + let exportHeight = sourceHeight; + + if (aspectRatioValue === 1) { + const baseDimension = even(Math.min(sourceWidth, sourceHeight)); + exportWidth = baseDimension; + exportHeight = baseDimension; + } else if (aspectRatioValue > 1) { + const baseWidth = even(sourceWidth); + let found = false; + for (let width = baseWidth; width >= 100 && !found; width -= 2) { + const height = Math.round(width / aspectRatioValue); + if (height % 2 === 0 && Math.abs(width / height - aspectRatioValue) < 0.0001) { + exportWidth = width; + exportHeight = height; + found = true; + } + } + if (!found) { + exportWidth = baseWidth; + exportHeight = even(baseWidth / aspectRatioValue); + } + } else { + const baseHeight = even(sourceHeight); + let found = false; + for (let height = baseHeight; height >= 100 && !found; height -= 2) { + const width = Math.round(height * aspectRatioValue); + if (width % 2 === 0 && Math.abs(width / height - aspectRatioValue) < 0.0001) { + exportWidth = width; + exportHeight = height; + found = true; + } + } + if (!found) { + exportHeight = baseHeight; + exportWidth = even(baseHeight * aspectRatioValue); + } + } + + const pixels = exportWidth * exportHeight; + const bitrate = + pixels > 2560 * 1440 ? 80_000_000 : pixels > 1920 * 1080 ? 50_000_000 : 30_000_000; + return { width: exportWidth, height: exportHeight, bitrate }; + } + + const targetHeight = quality === "medium" ? 720 : 1080; + const height = even(targetHeight); + const width = even(height * aspectRatioValue); + const pixels = width * height; + const bitrate = + pixels <= 1280 * 720 ? 10_000_000 : pixels <= 1920 * 1080 ? 20_000_000 : 30_000_000; + + return { width, height, bitrate }; +} + +function buildBaseConfig( + editor: ProjectEditorState, + shared: { + videoUrl: string; + webcamVideoUrl?: string; + previewWidth: number; + previewHeight: number; + cursorTelemetry: import("../video-editor/types").CursorTelemetryPoint[]; + cursorClickTimestamps: number[]; + onProgress: (progress: ExportProgress) => void; + }, +) { + return { + videoUrl: shared.videoUrl, + webcamVideoUrl: shared.webcamVideoUrl, + wallpaper: editor.wallpaper, + zoomRegions: editor.zoomRegions, + trimRegions: editor.trimRegions, + speedRegions: editor.speedRegions, + showShadow: editor.shadowIntensity > 0, + shadowIntensity: editor.shadowIntensity, + showBlur: editor.showBlur, + motionBlurAmount: editor.motionBlurAmount, + borderRadius: editor.borderRadius, + padding: editor.padding, + videoPadding: editor.padding, + cropRegion: editor.cropRegion, + annotationRegions: editor.annotationRegions, + webcamLayoutPreset: editor.webcamLayoutPreset, + webcamMaskShape: editor.webcamMaskShape, + webcamSizePreset: editor.webcamSizePreset, + webcamPosition: editor.webcamPosition, + previewWidth: shared.previewWidth, + previewHeight: shared.previewHeight, + cursorTelemetry: shared.cursorTelemetry, + cursorClickTimestamps: shared.cursorClickTimestamps, + cursorHighlight: editor.cursorHighlight, + onProgress: shared.onProgress, + }; +} + +async function saveExport( + result: { success: boolean; blob?: Blob; error?: string; warnings?: string[] }, + output: string, + format: "mp4" | "gif", +) { + if (!result.success || !result.blob) { + throw new Error(result.error || `${format.toUpperCase()} export failed`); + } + + for (const warning of result.warnings ?? []) { + sendToMain("warning", { message: warning }); + } + + const arrayBuffer = await result.blob.arrayBuffer(); + const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, output); + if (!saveResult.success) { + throw new Error(saveResult.message || "Failed to save exported video"); + } + + sendToMain("done", { + path: saveResult.path || output, + format, + size: arrayBuffer.byteLength, + }); +} + +async function runRender(config: CliRenderConfig) { + const media: ProjectMedia | null = resolveProjectMedia(config.project); + if (!media) { + throw new Error("Project does not reference a screen video."); + } + + const editor = normalizeProjectEditor(config.project.editor as Partial); + const videoUrl = toFileUrl(media.screenVideoPath); + const webcamVideoUrl = media.webcamVideoPath ? toFileUrl(media.webcamVideoPath) : undefined; + + sendToMain("status", { message: "Loading video..." }); + const { width: sourceWidth, height: sourceHeight } = await loadVideoMetadata(videoUrl); + const aspectRatioValue = + editor.aspectRatio === "native" + ? getNativeAspectRatioValue(sourceWidth, sourceHeight, editor.cropRegion) + : getAspectRatioValue(editor.aspectRatio); + + const previewWidth = 1920; + const previewHeight = Math.round(1920 / aspectRatioValue); + const telemetry = await window.electronAPI.getCursorTelemetry(media.screenVideoPath); + const cursorTelemetry = telemetry.success ? telemetry.samples : []; + const cursorClickTimestamps = telemetry.success ? telemetry.clicks : []; + const onProgress = (progress: ExportProgress) => sendToMain("progress", progress); + const base = buildBaseConfig(editor, { + videoUrl, + webcamVideoUrl, + previewWidth, + previewHeight, + cursorTelemetry, + cursorClickTimestamps, + onProgress, + }); + + sendToMain("status", { message: `Rendering ${config.format.toUpperCase()}...` }); + if (config.format === "gif") { + const frameRate = (config.gifFrameRate ?? editor.gifFrameRate) as GifFrameRate; + const sizePreset = (config.gifSizePreset ?? editor.gifSizePreset) as GifSizePreset; + const loop = config.gifLoop ?? editor.gifLoop; + const dimensions = calculateOutputDimensions( + sourceWidth, + sourceHeight, + sizePreset, + GIF_SIZE_PRESETS, + aspectRatioValue, + ); + const exporter = new GifExporter({ + ...base, + width: dimensions.width, + height: dimensions.height, + frameRate, + loop, + sizePreset, + }); + await saveExport(await exporter.export(), config.output, "gif"); + return; + } + + const quality = config.quality ?? editor.exportQuality; + const dimensions = calculateMp4Dimensions(sourceWidth, sourceHeight, aspectRatioValue, quality); + const exporter = new VideoExporter({ + ...base, + width: dimensions.width, + height: dimensions.height, + frameRate: 60, + bitrate: dimensions.bitrate, + codec: "avc1.640033", + }); + await saveExport(await exporter.export(), config.output, "mp4"); +} + +export function CliRenderRenderer() { + const started = useRef(false); + + useEffect(() => { + if (started.current) return; + started.current = true; + + window.electronAPI + .getCliRenderConfig() + .then((config) => runRender(config)) + .catch((error) => { + sendToMain("error", { + message: error instanceof Error ? error.message : String(error), + }); + }); + }, []); + + return
; +} From db9e9b8d4847cac9ce55aa5e5a4f68495790a19d Mon Sep 17 00:00:00 2001 From: lirik Date: Fri, 8 May 2026 14:46:47 +0700 Subject: [PATCH 2/2] feat(cli): support custom mp4 render dimensions --- cli/openscreen.mjs | 21 +++++++++++++++ electron/electron-env.d.ts | 2 ++ electron/main.ts | 4 +++ .../cli-render/CliRenderRenderer.tsx | 26 ++++++++++++++++--- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/cli/openscreen.mjs b/cli/openscreen.mjs index dc82e1a8..12eb56c6 100755 --- a/cli/openscreen.mjs +++ b/cli/openscreen.mjs @@ -54,6 +54,8 @@ Project/edit options: Render options: --format mp4 or gif. Defaults from output extension/project. --quality medium, good, source. + --width MP4 output width. Requires --height. + --height MP4 output height. Requires --width. --gif-frame-rate 15, 20, 25, 30. --gif-size-preset medium, large, original. --gif-loop / --no-gif-loop @@ -162,6 +164,15 @@ function parseExportFormat(value) { return value; } +function parseDimensionOption(options, name) { + if (options[name] === undefined) return undefined; + const dimension = parseNumber(options[name], name); + if (!Number.isInteger(dimension) || dimension < 2) { + throw new Error(`--${name} must be an integer greater than 1.`); + } + return Math.floor(dimension / 2) * 2; +} + function findElectronBinary() { const localElectron = path.join(projectRoot, "node_modules", ".bin", "electron"); if (fs.existsSync(localElectron)) return localElectron; @@ -582,6 +593,14 @@ async function commandRender(options) { parseExportFormat(options.format) ?? (extension === ".gif" ? "gif" : project.editor.exportFormat); const quality = parseExportQuality(options.quality) ?? project.editor.exportQuality; + const width = parseDimensionOption(options, "width"); + const height = parseDimensionOption(options, "height"); + if ((width === undefined) !== (height === undefined)) { + throw new Error("--width and --height must be provided together."); + } + if (format !== "mp4" && (width !== undefined || height !== undefined)) { + throw new Error("--width/--height are only supported for MP4 renders."); + } const gifFrameRate = options["gif-frame-rate"] !== undefined ? Number(options["gif-frame-rate"]) @@ -601,6 +620,8 @@ async function commandRender(options) { output: outputPath, format, quality, + width, + height, gifFrameRate, gifSizePreset, gifLoop: options["gif-loop"] ?? project.editor.gifLoop, diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index a89fe788..029aea9c 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -164,6 +164,8 @@ interface Window { output: string; format: "mp4" | "gif"; quality?: "medium" | "good" | "source"; + width?: number; + height?: number; gifFrameRate?: 15 | 20 | 25 | 30; gifSizePreset?: "medium" | "large" | "original"; gifLoop?: boolean; diff --git a/electron/main.ts b/electron/main.ts index 7e141b0a..9447fb3f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -473,6 +473,8 @@ type CliRenderConfig = { output: string; format: "mp4" | "gif"; quality?: "medium" | "good" | "source"; + width?: number; + height?: number; gifFrameRate?: 15 | 20 | 25 | 30; gifSizePreset?: "medium" | "large" | "original"; gifLoop?: boolean; @@ -624,6 +626,8 @@ async function readCliRenderConfig(): Promise { output, format: parsed.format === "gif" ? "gif" : "mp4", quality: parsed.quality === "medium" || parsed.quality === "source" ? parsed.quality : "good", + width: Number.isFinite(parsed.width) ? Math.floor(Number(parsed.width) / 2) * 2 : undefined, + height: Number.isFinite(parsed.height) ? Math.floor(Number(parsed.height) / 2) * 2 : undefined, gifFrameRate: parsed.gifFrameRate, gifSizePreset: parsed.gifSizePreset === "large" || parsed.gifSizePreset === "original" diff --git a/src/components/cli-render/CliRenderRenderer.tsx b/src/components/cli-render/CliRenderRenderer.tsx index df383434..7bcff03e 100644 --- a/src/components/cli-render/CliRenderRenderer.tsx +++ b/src/components/cli-render/CliRenderRenderer.tsx @@ -57,6 +57,13 @@ function even(value: number) { return Math.max(2, Math.floor(value / 2) * 2); } +function calculateMp4Bitrate(width: number, height: number) { + const pixels = width * height; + if (pixels > 2560 * 1440) return 80_000_000; + if (pixels > 1920 * 1080) return 50_000_000; + return 30_000_000; +} + function calculateMp4Dimensions( sourceWidth: number, sourceHeight: number, @@ -103,9 +110,7 @@ function calculateMp4Dimensions( } } - const pixels = exportWidth * exportHeight; - const bitrate = - pixels > 2560 * 1440 ? 80_000_000 : pixels > 1920 * 1080 ? 50_000_000 : 30_000_000; + const bitrate = calculateMp4Bitrate(exportWidth, exportHeight); return { width: exportWidth, height: exportHeight, bitrate }; } @@ -119,6 +124,16 @@ function calculateMp4Dimensions( return { width, height, bitrate }; } +function calculateCustomMp4Dimensions(width: number, height: number) { + const outputWidth = even(width); + const outputHeight = even(height); + return { + width: outputWidth, + height: outputHeight, + bitrate: calculateMp4Bitrate(outputWidth, outputHeight), + }; +} + function buildBaseConfig( editor: ProjectEditorState, shared: { @@ -244,7 +259,10 @@ async function runRender(config: CliRenderConfig) { } const quality = config.quality ?? editor.exportQuality; - const dimensions = calculateMp4Dimensions(sourceWidth, sourceHeight, aspectRatioValue, quality); + const dimensions = + typeof config.width === "number" && typeof config.height === "number" + ? calculateCustomMp4Dimensions(config.width, config.height) + : calculateMp4Dimensions(sourceWidth, sourceHeight, aspectRatioValue, quality); const exporter = new VideoExporter({ ...base, width: dimensions.width,