From 48ee64495270d617223206d156dc48532fc0734d Mon Sep 17 00:00:00 2001 From: ValentinRapp Date: Fri, 5 Jun 2026 16:46:21 +0200 Subject: [PATCH] refactor --- src/mainview/App.tsx | 1380 ++----------------- src/mainview/components/BitrateHandler.tsx | 54 + src/mainview/components/DownloadPage.tsx | 140 ++ src/mainview/components/EditorPage.tsx | 168 +++ src/mainview/components/EncoderSettings.tsx | 244 ++++ src/mainview/components/ExportPanel.tsx | 79 ++ src/mainview/components/FileDropZone.tsx | 83 ++ src/mainview/components/Header.tsx | 67 + src/mainview/components/LandingPage.tsx | 73 + src/mainview/{ => components}/Player.tsx | 8 +- src/mainview/{ => components}/Timeline.tsx | 9 +- src/mainview/components/ToggleSwitch.tsx | 24 + src/mainview/constants.ts | 27 + src/mainview/env.ts | 8 + src/mainview/helpers.ts | 31 + src/mainview/hooks/useElectrobun.ts | 42 + src/mainview/hooks/useFFmpeg.ts | 163 +++ src/mainview/hooks/useVideoEditor.ts | 379 +++++ 18 files changed, 1700 insertions(+), 1279 deletions(-) create mode 100644 src/mainview/components/BitrateHandler.tsx create mode 100644 src/mainview/components/DownloadPage.tsx create mode 100644 src/mainview/components/EditorPage.tsx create mode 100644 src/mainview/components/EncoderSettings.tsx create mode 100644 src/mainview/components/ExportPanel.tsx create mode 100644 src/mainview/components/FileDropZone.tsx create mode 100644 src/mainview/components/Header.tsx create mode 100644 src/mainview/components/LandingPage.tsx rename src/mainview/{ => components}/Player.tsx (96%) rename src/mainview/{ => components}/Timeline.tsx (93%) create mode 100644 src/mainview/components/ToggleSwitch.tsx create mode 100644 src/mainview/constants.ts create mode 100644 src/mainview/env.ts create mode 100644 src/mainview/helpers.ts create mode 100644 src/mainview/hooks/useElectrobun.ts create mode 100644 src/mainview/hooks/useFFmpeg.ts create mode 100644 src/mainview/hooks/useVideoEditor.ts diff --git a/src/mainview/App.tsx b/src/mainview/App.tsx index 5b56f71..06a51ac 100644 --- a/src/mainview/App.tsx +++ b/src/mainview/App.tsx @@ -1,1284 +1,134 @@ -import { useEffect, useRef, useState } from "react"; -import { Player } from "./Player"; -import { Timeline } from "./Timeline"; -import { FFmpeg } from "@ffmpeg/ffmpeg"; -import { fetchFile, toBlobURL } from "@ffmpeg/util"; -import { type AppRPCType } from "../shared/types"; -import logoUrl from "./assets/logo.png"; - -// ── Constants ───────────────────────────────────────────────────── - -const OUTPUT_FORMATS = [ - { label: "MP4 (.mp4)", ext: "mp4", mime: "video/mp4" }, - { label: "WebM (.webm)", ext: "webm", mime: "video/webm" }, - { label: "MKV (.mkv)", ext: "mkv", mime: "video/x-matroska" }, - { label: "MOV (.mov)", ext: "mov", mime: "video/quicktime" }, - { label: "AVI (.avi)", ext: "avi", mime: "video/x-msvideo" }, - { label: "FLV (.flv)", ext: "flv", mime: "video/x-flv" }, - { label: "MPEG-TS (.ts)", ext: "ts", mime: "video/mp2t" }, - { label: "OGG (.ogg)", ext: "ogg", mime: "video/ogg" }, -] as const; - -const AUDIO_BITRATE_KBPS = 128; -const WASM_FFMPEG_BASE_URL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm"; - -// VP9/Opus are too memory-intensive for WASM — use lighter codecs -const WASM_CODEC_OVERRIDES: Record = { - webm: ["-c:v", "libvpx", "-c:a", "libvorbis"], - ogg: ["-c:v", "libtheora", "-c:a", "libvorbis"], -}; - -// Native FFmpeg: use faster encoding presets (VP9 default is extremely slow) -const NATIVE_CODEC_OVERRIDES: Record = { - webm: ["-c:v", "libvpx-vp9", "-cpu-used", "4", "-deadline", "good", "-row-mt", "1"], - ogg: ["-c:v", "libtheora", "-c:a", "libvorbis"], -}; - -// Detect if running inside Electrobun's native webview -const isStandalone = - typeof window !== "undefined" && "__electrobunSendToHost" in window; - -// Detect if running on macOS (useful for platform-specific styling like traffic lights padding and titlebar drag) -const isMac = - typeof window !== "undefined" && - /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent || navigator.platform || ""); - -const getBestH264Encoder = (supported: string[]): string | null => { - if (supported.includes("h264_nvenc")) return "h264_nvenc"; - if (supported.includes("h264_videotoolbox")) return "h264_videotoolbox"; - if (supported.includes("h264_qsv")) return "h264_qsv"; - if (supported.includes("h264_amf")) return "h264_amf"; - if (supported.includes("h264_mf")) return "h264_mf"; - return null; -}; - -const getBestVP9Encoder = (supported: string[]): string | null => { - if (supported.includes("vp9_nvenc")) return "vp9_nvenc"; - if (supported.includes("vp9_qsv")) return "vp9_qsv"; - return null; -}; - -const getDetectedOS = (): "mac" | "windows" | "linux" => { - if (typeof window === "undefined") return "windows"; - const ua = navigator.userAgent.toLowerCase(); - if (ua.includes("mac")) return "mac"; - if (ua.includes("linux")) return "linux"; - return "windows"; -}; - -// Helper to extract file extension -const getFileExt = (name: string): string => - (name.split(".").pop() || "mp4").toLowerCase(); - -// ── Bitrate Controls ────────────────────────────────────────────── - -interface BitrateHandlerProps { - bitrate: number; - setBitrate: (value: number) => void; - clipDuration: number; - disabled: boolean; -} - -const BitrateHandler = ({ - bitrate, - setBitrate, - clipDuration, - disabled, -}: BitrateHandlerProps) => { - const fileSizeMB = - ((bitrate + AUDIO_BITRATE_KBPS) * clipDuration) / 8192; - - const setFileSizeMB = (mb: number) => - setBitrate((mb * 8192) / (clipDuration || 1) - AUDIO_BITRATE_KBPS); - - return ( -
- - setBitrate(parseFloat(e.target.value) || 0)} - className="w-full border border-mocha-surface2 rounded px-3 py-2 text-sm text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve focus:border-transparent disabled:cursor-not-allowed" - min={100} - max={50000} - disabled={disabled} - /> - - setFileSizeMB(parseFloat(e.target.value) || 0)} - className="w-full border border-mocha-surface2 rounded px-3 py-2 text-sm text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve focus:border-transparent disabled:cursor-not-allowed" - min={0.1} - max={10000} - disabled={disabled} - /> -
- ); -}; - -// ── File Drop Zone ──────────────────────────────────────────────── - -interface FileDropZoneProps { - onFileSelect: (file: File) => void; - onNativeBrowse?: () => void; -} - -const FileDropZone = ({ onFileSelect, onNativeBrowse }: FileDropZoneProps) => { - const [isDragging, setIsDragging] = useState(false); - const fileInputRef = useRef(null); - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - - // In standalone mode, always use native dialog (drop doesn't give us a file path) - if (isStandalone && onNativeBrowse) { - onNativeBrowse(); - return; - } - - const file = e.dataTransfer.files[0]; - if (file && file.type.startsWith("video/")) { - onFileSelect(file); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }; - - const handleClick = () => { - // In standalone mode, always use native dialog - if (isStandalone && onNativeBrowse) { - onNativeBrowse(); - return; - } - fileInputRef.current?.click(); - }; - - const handleInputChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) onFileSelect(file); - }; - - return ( -
setIsDragging(false)} - onClick={handleClick} - > - {/* Browser file input (only used in browser mode) */} - {!isStandalone && ( - - )} -
🎬
-

- {isStandalone - ? "Click to select a video file" - : "Drag & drop a video file here"} -

-

- {isStandalone - ? "opens system file explorer" - : "or click to browse"} -

-
- ); -}; - -// ── Main App ────────────────────────────────────────────────────── +import { useVideoEditor } from "./hooks/useVideoEditor"; +import { Header } from "./components/Header"; +import { DownloadPage } from "./components/DownloadPage"; +import { LandingPage } from "./components/LandingPage"; +import { EditorPage } from "./components/EditorPage"; +import { OUTPUT_FORMATS } from "./constants"; const App = () => { - // File state - const [videoSrc, setVideoSrc] = useState(null); - const [videoFile, setVideoFile] = useState(null); - const [nativeFilePath, setNativeFilePath] = useState(null); - const [sourceFormat, setSourceFormat] = useState("mp4"); - - // Playback state - const [position, setPosition] = useState(0); - const [start, setStart] = useState(0); - const [end, setEnd] = useState(1000); - const [isPlaying, setIsPlaying] = useState(false); - const [videoDuration, setVideoDuration] = useState(0); - - // Export state - const [wasmLoaded, setWasmLoaded] = useState(false); - const [exporting, setExporting] = useState(false); - const [exportProgress, setExportProgress] = useState(0); - const [exportError, setExportError] = useState(null); - const [exportSuccess, setExportSuccess] = useState(null); - - const [currentPage, setCurrentPage] = useState<"editor" | "download">("editor"); - const [activeDownloadTab, setActiveDownloadTab] = useState<"mac" | "windows" | "linux">(getDetectedOS()); - const [copiedCommand, setCopiedCommand] = useState(false); - - // Encoding options - const [bitrate, setBitrate] = useState(1000); - const [originalBitrate, setOriginalBitrate] = useState(1000); - const [reencode, setReencode] = useState(false); - const [outputFormat, setOutputFormat] = useState("mp4"); - const [hwAcc, setHwAcc] = useState(true); - const [supportedEncoders, setSupportedEncoders] = useState([]); - - // Resolution & framerate - const [originalWidth, setOriginalWidth] = useState(1920); - const [originalHeight, setOriginalHeight] = useState(1080); - const [originalFps, setOriginalFps] = useState(null); - const [outputWidth, setOutputWidth] = useState(1920); - const [outputHeight, setOutputHeight] = useState(1080); - const [outputFps, setOutputFps] = useState(null); - const [lockAspectRatio, setLockAspectRatio] = useState(true); - - // Refs - const ffmpegRef = useRef(new FFmpeg()); - const electroviewRef = useRef(null); - - // ── Electrobun RPC init (standalone only) ───────────────────── - - useEffect(() => { - if (!isStandalone) return; - import("electrobun/view") - .then(({ Electroview }: any) => { - const rpc = Electroview.defineRPC({ - maxRequestTime: 600_000, // 10 min — native dialogs block - handlers: { requests: {}, messages: {} }, - }); - const ev = new Electroview({ rpc }); - electroviewRef.current = ev; - console.log("[YAFW] Electrobun RPC initialized"); - - // Detect hardware accelerators - ev.rpc.request.detectHardwareAccelerators({}) - .then((res: any) => { - setSupportedEncoders(res.supportedEncoders || []); - }) - .catch((err: any) => { - console.error("[YAFW] Failed to detect hardware accelerators:", err); - }); - }) - .catch((err: unknown) => { - console.warn("[YAFW] Electrobun unavailable:", err); - }); - }, []); - - // ── Video metadata detection ────────────────────────────────── - - const detectVideoMetadata = (src: string, fileSize?: number) => { - const video = document.createElement("video"); - video.src = src; - video.onloadedmetadata = () => { - setVideoDuration(video.duration); - setStart(0); - setEnd(1000); - setPosition(0); - - // Detect resolution - const w = video.videoWidth || 1920; - const h = video.videoHeight || 1080; - setOriginalWidth(w); - setOriginalHeight(h); - setOutputWidth(w); - setOutputHeight(h); - - const estimateBitrate = (bytes: number) => { - if (video.duration <= 0) return; - const totalKbps = (bytes * 8) / (video.duration * 1000); - const videoKbps = Math.max( - 100, - Math.round(totalKbps - AUDIO_BITRATE_KBPS), - ); - setOriginalBitrate(videoKbps); - setBitrate(videoKbps); - }; - - if (fileSize) { - estimateBitrate(fileSize); - } else { - // Fallback: try HEAD request to get content-length - fetch(src, { method: "HEAD" }) - .then((res) => { - const cl = res.headers.get("content-length"); - if (cl) estimateBitrate(parseInt(cl)); - }) - .catch(() => {}); - } - }; - }; - - // ── File selection handlers ─────────────────────────────────── - - const cleanupVideoSrc = () => { - if (videoSrc && videoSrc.startsWith("blob:")) { - URL.revokeObjectURL(videoSrc); - } - }; - - // Handle file from or drag & drop - const handleFileSelect = (file: File) => { - cleanupVideoSrc(); - const url = URL.createObjectURL(file); - const ext = getFileExt(file.name); - - setOriginalFps(null); - setOutputFps(null); - setVideoSrc(url); - setVideoFile(file); - setNativeFilePath(null); - setSourceFormat(ext); - setOutputFormat(ext); - setExportError(null); - detectVideoMetadata(url, file.size); - - // Probe FPS/bitrate with WASM FFmpeg in the background - probeWithWasm(file); - }; - - // Handle native file browse (standalone only) - const handleNativeBrowse = async () => { - console.log("[YAFW] handleNativeBrowse called"); - const ev = electroviewRef.current; - if (!ev) { - console.error("[YAFW] handleNativeBrowse: electroviewRef is null!"); - return; - } - - try { - console.log("[YAFW] Calling selectInputFile RPC..."); - const result = await ev.rpc.request.selectInputFile({}); - console.log("[YAFW] selectInputFile result:", result); - - if (!result.path) { - console.log("[YAFW] No path returned, user cancelled"); - return; - } - - cleanupVideoSrc(); - const previewUrl = `http://localhost:${result.previewPort}/`; - const ext = getFileExt(result.path); - - console.log("[YAFW] Setting state:", { previewUrl, ext, path: result.path }); - - setOriginalFps(null); - setOutputFps(null); - setVideoSrc(previewUrl); - setVideoFile(null); - setNativeFilePath(result.path); - setSourceFormat(ext); - setOutputFormat(ext); - setExportError(null); - setExportSuccess(null); - detectVideoMetadata(previewUrl); - - // Probe FPS/bitrate with WASM FFmpeg using the localhost preview URL - probeWithWasm(previewUrl, result.path); - - console.log("[YAFW] State set, editor should render"); - } catch (err) { - console.error("[YAFW] handleNativeBrowse error:", err); - } - }; - - // Reset to file selection screen - const handleChangeVideo = () => { - cleanupVideoSrc(); - setVideoSrc(null); - setVideoFile(null); - setNativeFilePath(null); - setVideoDuration(0); - setExportError(null); - }; - - // ── Playback position clamping ──────────────────────────────── - - useEffect(() => { - // Small tolerance for floating-point rounding - if (position < start - 0.1) { - if (position !== start) setPosition(start); - setIsPlaying(false); - } else if (position >= end) { - if (position !== end) setPosition(end); - setIsPlaying(false); - } - }, [position, start, end]); - - // ── Reset bitrate when re-encode toggled off ────────────────── - - useEffect(() => { - if (!reencode) setBitrate(originalBitrate); - }, [reencode, originalBitrate]); - - // ── WASM FFmpeg loader (browser path) ───────────────────────── - - const loadWasmFFmpeg = async () => { - if (wasmLoaded) return; - const ffmpeg = ffmpegRef.current; - ffmpeg.on("log", ({ message }) => - console.log(`[FFmpeg WASM] ${message}`), - ); - await ffmpeg.load({ - coreURL: await toBlobURL( - `${WASM_FFMPEG_BASE_URL}/ffmpeg-core.js`, - "text/javascript", - ), - wasmURL: await toBlobURL( - `${WASM_FFMPEG_BASE_URL}/ffmpeg-core.wasm`, - "application/wasm", - ), - }); - setWasmLoaded(true); - }; - - // ── Probe FPS with WASM FFmpeg (browser path) ────────────────── - - const probeWithWasm = async (source: File | string, fileName?: string) => { - try { - await loadWasmFFmpeg(); - const ffmpeg = ffmpegRef.current; - - // Collect log output - const logs: string[] = []; - const logHandler = ({ message }: { message: string }) => { - logs.push(message); - }; - ffmpeg.on("log", logHandler); - - // Write file to WASM virtual FS - const name = typeof source === "string" ? (fileName || source) : source.name; - const ext = getFileExt(name); - const inputName = `probe_input.${ext}`; - const data = await fetchFile(source); - await ffmpeg.writeFile(inputName, data); - - // Run ffmpeg -i (will fail with exit code 1 since no output, but logs contain info) - try { - await ffmpeg.exec(["-i", inputName]); - } catch { - // Expected to fail — we only need the log output - } - - // Clean up - await ffmpeg.deleteFile(inputName); - ffmpeg.off("log", logHandler); - - // Parse FPS from log lines like "Stream #0:0: Video: h264 ..., 29.97 fps, ..." - const allLogs = logs.join("\n"); - console.log("[YAFW] WASM probe logs:", allLogs); - - let detectedFps = false; - // Match "XX.XX fps" pattern - const fpsMatch = allLogs.match(/(\d+(?:\.\d+)?)\s+fps/); - if (fpsMatch) { - const fps = parseFloat(fpsMatch[1]); - if (fps > 0 && fps < 1000) { - console.log(`[YAFW] WASM detected FPS: ${fps}`); - setOriginalFps(fps); - setOutputFps(fps); - detectedFps = true; - } - } - - if (!detectedFps) { - console.log("[YAFW] WASM probe did not detect FPS, falling back to 30"); - setOriginalFps(30); - setOutputFps(30); - } - - // Also try to parse bitrate from "bitrate: 1234 kb/s" - const brMatch = allLogs.match(/bitrate:\s+(\d+)\s+kb\/s/); - if (brMatch) { - const br = parseInt(brMatch[1]); - if (br > 0) { - console.log(`[YAFW] WASM detected bitrate: ${br} kbps`); - setOriginalBitrate(br); - setBitrate(br); - } - } - } catch (err) { - console.warn("[YAFW] probeWithWasm failed:", err); - setOriginalFps((current) => { - if (current === null) { - setOutputFps((outCurrent) => outCurrent === null ? 30 : outCurrent); - return 30; - } - return current; - }); - } - }; - - // ── Build FFmpeg arguments ──────────────────────────────────── - - const buildFFmpegArgs = (): string[] => { - const startTime = (start / 1000) * videoDuration; - const endTime = (end / 1000) * videoDuration; - const isConverting = outputFormat !== sourceFormat; - - const args: string[] = [ - "-ss", startTime.toString(), - "-to", endTime.toString(), - ]; - - const needsReencode = reencode || isConverting; - - if (needsReencode) { - const br = reencode ? bitrate : originalBitrate; - args.push( - "-b:v", `${Math.round(br)}k`, - "-maxrate", `${Math.round(br)}k`, - "-bufsize", `${Math.round(br * 2)}k`, - ); - - // Resolution (only if changed from original or re-encoding) - if (reencode && (outputWidth !== originalWidth || outputHeight !== originalHeight)) { - // Ensure even dimensions (required by most codecs) - const w = Math.round(outputWidth / 2) * 2; - const h = Math.round(outputHeight / 2) * 2; - args.push("-vf", `scale=${w}:${h}`); - } - - // Framerate (only if changed from original) - if (reencode && outputFps && originalFps && outputFps !== originalFps) { - args.push("-r", outputFps.toString()); - } - } else { - // Same format, no re-encode — stream copy (fastest) - args.push("-c", "copy"); - } - - return args; - }; - - // ── Export: native FFmpeg via Electrobun RPC ─────────────────── - - const exportNative = async () => { - const ev = electroviewRef.current; - if (!ev || !nativeFilePath) { - console.error("[YAFW] exportNative: no electroview or nativeFilePath", { ev: !!ev, nativeFilePath }); - throw new Error("Electrobun RPC or file path not available"); - } - - const args = buildFFmpegArgs(); - const needsReencode = reencode || (outputFormat !== sourceFormat); - - let codecOverrides = [...(NATIVE_CODEC_OVERRIDES[outputFormat] ?? [])]; - if (needsReencode && hwAcc) { - if (["mp4", "mkv", "mov", "flv", "ts"].includes(outputFormat)) { - const h264Encoder = getBestH264Encoder(supportedEncoders); - if (h264Encoder) { - console.log(`[YAFW] Using hardware H.264 encoder: ${h264Encoder}`); - codecOverrides = ["-c:v", h264Encoder]; - } - } else if (outputFormat === "webm") { - const vp9Encoder = getBestVP9Encoder(supportedEncoders); - if (vp9Encoder) { - console.log(`[YAFW] Using hardware VP9 encoder: ${vp9Encoder}`); - codecOverrides = ["-c:v", vp9Encoder]; - } - } - } - - const fullArgs = [...args, ...codecOverrides]; - - console.log("[YAFW] exportNative calling RPC..."); - console.log("[YAFW] inputPath:", nativeFilePath); - console.log("[YAFW] outputExt:", outputFormat); - console.log("[YAFW] clipDuration:", clipDuration); - console.log("[YAFW] ffmpegArgs:", fullArgs.join(" ")); - - const result = await ev.rpc.request.exportVideo({ - inputPath: nativeFilePath, - ffmpegArgs: fullArgs, - outputExt: outputFormat, - clipDuration, - }); - - console.log("[YAFW] exportNative RPC result:", result); - - if (!result.success) { - throw new Error(result.error || "Native FFmpeg export failed"); - } - - // Show success with output path - setExportSuccess(result.outputPath || "Export complete"); - }; - - // ── Export: WASM FFmpeg (browser fallback) ───────────────────── - - const exportWasm = async () => { - await loadWasmFFmpeg(); - const ffmpeg = ffmpegRef.current; - - // Load video into WASM virtual filesystem - const inputFilename = `input.${sourceFormat}`; - if (videoFile) { - const data = await videoFile.arrayBuffer(); - await ffmpeg.writeFile(inputFilename, new Uint8Array(data)); - } else if (videoSrc) { - await ffmpeg.writeFile(inputFilename, await fetchFile(videoSrc)); - } else { - throw new Error("No video file available"); - } - - const args = buildFFmpegArgs(); - const codecOverrides = WASM_CODEC_OVERRIDES[outputFormat] ?? []; - const outputFilename = `output.${outputFormat}`; - const fullArgs = [ - "-i", inputFilename, - ...args, - ...codecOverrides, - outputFilename, - ]; - - console.log(`[YAFW] WASM export: ffmpeg ${fullArgs.join(" ")}`); - - // Listen for WASM progress events - const onProgress = ({ progress }: { progress: number }) => { - setExportProgress(Math.max(0, Math.min(1, progress))); - }; - ffmpeg.on("progress", onProgress); - - try { - await ffmpeg.exec(fullArgs); - } finally { - ffmpeg.off("progress", onProgress); - } - - const data = await ffmpeg.readFile(outputFilename); - const mime = - OUTPUT_FORMATS.find((f) => f.ext === outputFormat)?.mime ?? - "video/mp4"; - const blob = new Blob([(data as Uint8Array).buffer as ArrayBuffer], { - type: mime, - }); - - // Trigger browser download - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `exported-video.${outputFormat}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - // ── Main export handler ─────────────────────────────────────── - - const exportVideo = async () => { - setExporting(true); - setExportProgress(0); - setExportError(null); - setExportSuccess(null); - try { - const useNative = - isStandalone && nativeFilePath && electroviewRef.current; - if (useNative) { - await exportNative(); - } else { - await exportWasm(); - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - setExportError(msg); - console.error("[YAFW] Export failed:", error); - } finally { - setExporting(false); - setExportProgress(0); - } - }; - - // ── Derived state ───────────────────────────────────────────── - - const isConverting = outputFormat !== sourceFormat; - const clipDuration = ((end - start) / 1000) * videoDuration; - const useNativeExport = isStandalone && !!nativeFilePath && !!electroviewRef.current; - - const hasHwAccSupport = - ["mp4", "mkv", "mov", "flv", "ts"].includes(outputFormat) - ? !!getBestH264Encoder(supportedEncoders) - : outputFormat === "webm" - ? !!getBestVP9Encoder(supportedEncoders) - : false; - - // ── Poll native export progress ────────────────────────────── - - useEffect(() => { - if (!exporting || !useNativeExport) return; - const ev = electroviewRef.current; - if (!ev) return; - - const interval = setInterval(async () => { - try { - const { progress } = await ev.rpc.request.getExportProgress({}); - setExportProgress(progress); - } catch {} - }, 300); - - return () => clearInterval(interval); - }, [exporting, useNativeExport]); - - // ── Render ──────────────────────────────────────────────────── + const { + videoSrc, + position, + setPosition, + isPlaying, + setIsPlaying, + start, + setStart, + end, + setEnd, + videoDuration, + reencode, + setReencode, + outputFormat, + setOutputFormat, + bitrate, + setBitrate, + clipDuration, + outputWidth, + setOutputWidth, + outputHeight, + setOutputHeight, + originalWidth, + originalHeight, + lockAspectRatio, + setLockAspectRatio, + originalFps, + outputFps, + setOutputFps, + useNativeExport, + hasHwAccSupport, + hwAcc, + setHwAcc, + exporting, + exportProgress, + exportError, + exportSuccess, + currentPage, + setCurrentPage, + handleFileSelect, + handleNativeBrowse, + handleChangeVideo, + exportVideo, + isConverting, + } = useVideoEditor(); return (
- {/* Background Glows (visible on both pages, adds premium feel) */} + {/* Background Glows (visible on all pages, adds premium feel) */}
{/* Navbar / Header */} -
-
- YAFW Logo -
-

- YAFW -

-

- Yet Another FFmpeg Wrapper -

-
-
-
- {currentPage === "download" ? ( - - ) : ( - <> - {/* Download Standalone Version (Browser mode only) */} - {!isStandalone && ( -
- - - Runs locally with native FFmpeg for 50x faster export - -
- )} - - {/* Change Video Button (Only if video is loaded) */} - {videoSrc && ( - - )} - - )} -
-
+
{/* Main Content Area */} - {currentPage === "download" ? ( -
-
-

- Download & Install YAFW Desktop -

- - {/* Platform tabs */} -
- {(["mac", "windows", "linux"] as const).map((tab) => ( - - ))} -
- - {/* Tab Content */} -
- {activeDownloadTab === "mac" && ( -
-

- The macOS version is packaged specifically for Apple Silicon and is installed via Homebrew Cask. -

-
- - Run this command in your Terminal: - -
- - brew tap valentinrapp/yafw && brew install --cask valentinrapp/yafw/yafw - - -
-
-
-

Requires Homebrew installed. If you don't have it, install it first from brew.sh.

-
-
- )} - - {activeDownloadTab === "windows" && ( -
-
-
-

YAFW for Windows (x64)

-

Portable zip bundle containing the native executable installer.

-
- - 📥 Download for Windows - -
-
-

Installation Steps:

-
    -
  1. Download the stable-win-x64-yafw-Setup.zip archive.
  2. -
  3. Right-click the zip file and select Extract All.
  4. -
  5. Open the extracted folder and run yafw-Setup.exe to launch the app.
  6. -
-
-
- )} - - {activeDownloadTab === "linux" && ( -
-
-
-

YAFW for Linux (x64)

-

Gzipped tarball directory containing the standalone application binary.

-
- - 📥 Download for Linux - -
-
-

Installation Steps:

-
    -
  1. Download the stable-linux-x64-yafw-Setup.tar.gz archive.
  2. -
  3. Extract the tarball using your archive manager or terminal: -
    -													tar -xzf stable-linux-x64-yafw-Setup.tar.gz
    -												
    -
  4. -
  5. Enter the extracted directory and run the installer binary to install the application.
  6. -
-
-
- )} -
- - {/* Footer & Star reminder */} -
-

- and don't forget to star the project on github 😉 -

- -
-
-
- ) : !videoSrc ? ( - // Landing Page -
-
-
- YAFW Logo -

- YAFW -

-

- Yet Another FFmpeg Wrapper -

-

- A modern, lightweight video editor to trim, adjust, and re-encode videos in seconds. Completely private, fast, and gorgeous. -

-
- - {/* Small tip about Standalone version in browser view */} - {!isStandalone && ( -
- - 💡 Tip: Download the Desktop App for hardware-accelerated rendering and 50x faster export speeds. - - -
- )} - -
- +
+ {currentPage === "download" ? ( + setCurrentPage("editor")} /> + ) : ( + setCurrentPage("download")} /> -
- - {/* Features list */} -
-
- - Instant Cuts - Stream copy mode without re-encoding. -
-
- ⚙️ - Pro Encoder - Adjust bitrate, custom resolution, and FPS. -
-
- 📦 - Dual Mode - Runs in browser (WASM) or native (standalone). -
-
+ )}
) : ( - // Editor Page -
- {/* Left column: Player + Timeline */} -
- - -
-

- Timeline & Trim Controls -

- -
-
- - {/* Right column: Encoder & Export Settings Sidebar */} -
-
-

- Encoder Settings -

- - {/* Re-encode toggle */} -
-
- Re-encode Video - Required for custom settings -
- -
- - {/* Hardware Acceleration toggle (desktop standalone only) */} - {useNativeExport && ( -
-
- Hardware Acceleration - - {hasHwAccSupport ? "Speeds up encoding using GPU" : "No compatible GPU encoder detected"} - -
- -
- )} - - {/* Output format selector */} -
- -
- -
- ▼ -
-
- {isConverting && ( -

- Format conversion requires re-encoding -

- )} -
- - {/* Bitrate controls */} -
- -
- - {/* Resolution controls */} -
- -
- { - const w = parseInt(e.target.value) || originalWidth; - setOutputWidth(w); - if (lockAspectRatio && originalWidth > 0) { - setOutputHeight(Math.round(w * (originalHeight / originalWidth))); - } - }} - disabled={!reencode} - className="w-full border border-mocha-surface2 rounded-lg px-2.5 py-1.5 text-xs text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve disabled:opacity-50" - /> - × - { - const h = parseInt(e.target.value) || originalHeight; - setOutputHeight(h); - if (lockAspectRatio && originalHeight > 0) { - setOutputWidth(Math.round(h * (originalWidth / originalHeight))); - } - }} - disabled={!reencode} - className="w-full border border-mocha-surface2 rounded-lg px-2.5 py-1.5 text-xs text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve disabled:opacity-50" - /> - - -
-

- Original: {originalWidth}×{originalHeight} -

-
- - {/* Framerate control */} -
- -
- {originalFps === null ? ( -
- - - - - Detecting... -
- ) : ( - <> - setOutputFps(Math.max(1, parseInt(e.target.value) || originalFps || 30))} - disabled={!reencode} - className="w-full border border-mocha-surface2 rounded-lg px-2.5 py-1.5 text-xs text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve disabled:opacity-50" - /> - fps - - - )} -
-

- Original: {originalFps !== null ? `${originalFps} fps` : "detecting..."} -

-
-
- - {/* Export Panel */} -
-

- Export -

- -

- Trim range: {((start / 1000) * videoDuration).toFixed(2)}s to {((end / 1000) * videoDuration).toFixed(2)}s -

- - {/* Export error */} - {exportError && ( -
- ⚠️ {exportError} -
- )} - - {/* Export success */} - {exportSuccess && ( -
- ✅ {exportSuccess} -
- )} - - {/* Progress bar */} - {exporting && ( -
-
-
-
-

- Exporting: {Math.round(exportProgress * 100)}% -

-
- )} - - - - {/* Footer: runtime info */} - {useNativeExport && ( -

- ⚡ Running native hardware-accelerated FFmpeg -

- )} -
-
-
+ )}
); diff --git a/src/mainview/components/BitrateHandler.tsx b/src/mainview/components/BitrateHandler.tsx new file mode 100644 index 0000000..e63a53d --- /dev/null +++ b/src/mainview/components/BitrateHandler.tsx @@ -0,0 +1,54 @@ +import { AUDIO_BITRATE_KBPS } from "../constants"; + +interface BitrateHandlerProps { + bitrate: number; + setBitrate: (value: number) => void; + clipDuration: number; + disabled: boolean; +} + +export const BitrateHandler = ({ + bitrate, + setBitrate, + clipDuration, + disabled, +}: BitrateHandlerProps) => { + const fileSizeMB = + ((bitrate + AUDIO_BITRATE_KBPS) * clipDuration) / 8192; + + const setFileSizeMB = (mb: number) => + setBitrate((mb * 8192) / (clipDuration || 1) - AUDIO_BITRATE_KBPS); + + return ( +
+ + setBitrate(parseFloat(e.target.value) || 0)} + className="w-full border border-mocha-surface2 rounded px-3 py-2 text-sm text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve focus:border-transparent disabled:cursor-not-allowed" + min={100} + max={50000} + disabled={disabled} + /> + + setFileSizeMB(parseFloat(e.target.value) || 0)} + className="w-full border border-mocha-surface2 rounded px-3 py-2 text-sm text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve focus:border-transparent disabled:cursor-not-allowed" + min={0.1} + max={10000} + disabled={disabled} + /> +
+ ); +}; diff --git a/src/mainview/components/DownloadPage.tsx b/src/mainview/components/DownloadPage.tsx new file mode 100644 index 0000000..4294d62 --- /dev/null +++ b/src/mainview/components/DownloadPage.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { getDetectedOS } from "../helpers"; + +interface DownloadPageProps { + onNavigateEditor: () => void; +} + +export const DownloadPage = ({ onNavigateEditor }: DownloadPageProps) => { + const [activeDownloadTab, setActiveDownloadTab] = useState<"mac" | "windows" | "linux">(getDetectedOS()); + const [copiedCommand, setCopiedCommand] = useState(false); + + return ( + <> +

+ Download & Install YAFW Desktop +

+ + {/* Platform tabs */} +
+ {(["mac", "windows", "linux"] as const).map((tab) => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeDownloadTab === "mac" && ( +
+

+ The macOS version is packaged specifically for Apple Silicon and is installed via Homebrew Cask. +

+
+ + Run this command in your Terminal: + +
+ + brew tap valentinrapp/yafw && brew install --cask valentinrapp/yafw/yafw + + +
+
+
+

Requires Homebrew installed. If you don't have it, install it first from brew.sh.

+
+
+ )} + + {activeDownloadTab === "windows" && ( +
+
+
+

YAFW for Windows (x64)

+

Portable zip bundle containing the native executable installer.

+
+ + 📥 Download for Windows + +
+
+

Installation Steps:

+
    +
  1. Download the stable-win-x64-yafw-Setup.zip archive.
  2. +
  3. Right-click the zip file and select Extract All.
  4. +
  5. Open the extracted folder and run yafw-Setup.exe to launch the app.
  6. +
+
+
+ )} + + {activeDownloadTab === "linux" && ( +
+
+
+

YAFW for Linux (x64)

+

Gzipped tarball directory containing the standalone application binary.

+
+ + 📥 Download for Linux + +
+
+

Installation Steps:

+
    +
  1. Download the stable-linux-x64-yafw-Setup.tar.gz archive.
  2. +
  3. Extract the tarball using your archive manager or terminal: +
    +										tar -xzf stable-linux-x64-yafw-Setup.tar.gz
    +									
    +
  4. +
  5. Enter the extracted directory and run the installer binary to install the application.
  6. +
+
+
+ )} +
+ + {/* Footer & Star reminder */} +
+

+ and don't forget to star the project on github 😉 +

+ +
+ + ); +}; diff --git a/src/mainview/components/EditorPage.tsx b/src/mainview/components/EditorPage.tsx new file mode 100644 index 0000000..78111f7 --- /dev/null +++ b/src/mainview/components/EditorPage.tsx @@ -0,0 +1,168 @@ +import { Player } from "./Player"; +import { Timeline } from "./Timeline"; +import { EncoderSettings } from "./EncoderSettings"; +import { ExportPanel } from "./ExportPanel"; + +interface OutputFormat { + readonly label: string; + readonly ext: string; + readonly mime: string; +} + +interface EditorPageProps { + // Video & playback + videoSrc: string; + position: number; + setPosition: (value: number) => void; + isPlaying: boolean; + setIsPlaying: (value: boolean) => void; + start: number; + setStart: (value: number) => void; + end: number; + setEnd: (value: number) => void; + videoDuration: number; + + // Encoder settings + reencode: boolean; + setReencode: (value: boolean) => void; + outputFormat: string; + setOutputFormat: (value: string) => void; + outputFormats: readonly OutputFormat[]; + isConverting: boolean; + bitrate: number; + setBitrate: (value: number) => void; + clipDuration: number; + outputWidth: number; + setOutputWidth: (value: number) => void; + outputHeight: number; + setOutputHeight: (value: number) => void; + originalWidth: number; + originalHeight: number; + lockAspectRatio: boolean; + setLockAspectRatio: (value: boolean) => void; + originalFps: number | null; + outputFps: number | null; + setOutputFps: (value: number | null) => void; + useNativeExport: boolean; + hasHwAccSupport: boolean; + hwAcc: boolean; + setHwAcc: (value: boolean) => void; + + // Export + exporting: boolean; + exportProgress: number; + exportError: string | null; + exportSuccess: string | null; + onExport: () => void; +} + +export const EditorPage = ({ + videoSrc, + position, + setPosition, + isPlaying, + setIsPlaying, + start, + setStart, + end, + setEnd, + videoDuration, + reencode, + setReencode, + outputFormat, + setOutputFormat, + outputFormats, + isConverting, + bitrate, + setBitrate, + clipDuration, + outputWidth, + setOutputWidth, + outputHeight, + setOutputHeight, + originalWidth, + originalHeight, + lockAspectRatio, + setLockAspectRatio, + originalFps, + outputFps, + setOutputFps, + useNativeExport, + hasHwAccSupport, + hwAcc, + setHwAcc, + exporting, + exportProgress, + exportError, + exportSuccess, + onExport, +}: EditorPageProps) => ( +
+ {/* Left column: Player + Timeline */} +
+ + +
+

+ Timeline & Trim Controls +

+ +
+
+ + {/* Right column: Encoder & Export Settings Sidebar */} +
+ + + +
+
+); diff --git a/src/mainview/components/EncoderSettings.tsx b/src/mainview/components/EncoderSettings.tsx new file mode 100644 index 0000000..ae2eb7f --- /dev/null +++ b/src/mainview/components/EncoderSettings.tsx @@ -0,0 +1,244 @@ +import { ToggleSwitch } from "./ToggleSwitch"; +import { BitrateHandler } from "./BitrateHandler"; + +interface OutputFormat { + readonly label: string; + readonly ext: string; + readonly mime: string; +} + +interface EncoderSettingsProps { + reencode: boolean; + setReencode: (value: boolean) => void; + outputFormat: string; + setOutputFormat: (value: string) => void; + outputFormats: readonly OutputFormat[]; + isConverting: boolean; + bitrate: number; + setBitrate: (value: number) => void; + clipDuration: number; + outputWidth: number; + setOutputWidth: (value: number) => void; + outputHeight: number; + setOutputHeight: (value: number) => void; + originalWidth: number; + originalHeight: number; + lockAspectRatio: boolean; + setLockAspectRatio: (value: boolean) => void; + originalFps: number | null; + outputFps: number | null; + setOutputFps: (value: number | null) => void; + useNativeExport: boolean; + hasHwAccSupport: boolean; + hwAcc: boolean; + setHwAcc: (value: boolean) => void; +} + +export const EncoderSettings = ({ + reencode, + setReencode, + outputFormat, + setOutputFormat, + outputFormats, + isConverting, + bitrate, + setBitrate, + clipDuration, + outputWidth, + setOutputWidth, + outputHeight, + setOutputHeight, + originalWidth, + originalHeight, + lockAspectRatio, + setLockAspectRatio, + originalFps, + outputFps, + setOutputFps, + useNativeExport, + hasHwAccSupport, + hwAcc, + setHwAcc, +}: EncoderSettingsProps) => ( +
+

+ Encoder Settings +

+ + {/* Re-encode toggle */} +
+
+ Re-encode Video + Required for custom settings +
+ setReencode(!reencode)} + /> +
+ + {/* Hardware Acceleration toggle (desktop standalone only) */} + {useNativeExport && ( +
+
+ Hardware Acceleration + + {hasHwAccSupport ? "Speeds up encoding using GPU" : "No compatible GPU encoder detected"} + +
+ setHwAcc(!hwAcc)} + disabled={!hasHwAccSupport} + /> +
+ )} + + {/* Output format selector */} +
+ +
+ +
+ ▼ +
+
+ {isConverting && ( +

+ Format conversion requires re-encoding +

+ )} +
+ + {/* Bitrate controls */} +
+ +
+ + {/* Resolution controls */} +
+ +
+ { + const w = parseInt(e.target.value) || originalWidth; + setOutputWidth(w); + if (lockAspectRatio && originalWidth > 0) { + setOutputHeight(Math.round(w * (originalHeight / originalWidth))); + } + }} + disabled={!reencode} + className="w-full border border-mocha-surface2 rounded-lg px-2.5 py-1.5 text-xs text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve disabled:opacity-50" + /> + × + { + const h = parseInt(e.target.value) || originalHeight; + setOutputHeight(h); + if (lockAspectRatio && originalHeight > 0) { + setOutputWidth(Math.round(h * (originalWidth / originalHeight))); + } + }} + disabled={!reencode} + className="w-full border border-mocha-surface2 rounded-lg px-2.5 py-1.5 text-xs text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve disabled:opacity-50" + /> + + +
+

+ Original: {originalWidth}×{originalHeight} +

+
+ + {/* Framerate control */} +
+ +
+ {originalFps === null ? ( +
+ + + + + Detecting... +
+ ) : ( + <> + setOutputFps(Math.max(1, parseInt(e.target.value) || originalFps || 30))} + disabled={!reencode} + className="w-full border border-mocha-surface2 rounded-lg px-2.5 py-1.5 text-xs text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve disabled:opacity-50" + /> + fps + + + )} +
+

+ Original: {originalFps !== null ? `${originalFps} fps` : "detecting..."} +

+
+
+); diff --git a/src/mainview/components/ExportPanel.tsx b/src/mainview/components/ExportPanel.tsx new file mode 100644 index 0000000..28bf368 --- /dev/null +++ b/src/mainview/components/ExportPanel.tsx @@ -0,0 +1,79 @@ +interface ExportPanelProps { + start: number; + end: number; + videoDuration: number; + outputFormat: string; + exporting: boolean; + exportProgress: number; + exportError: string | null; + exportSuccess: string | null; + useNativeExport: boolean; + onExport: () => void; +} + +export const ExportPanel = ({ + start, + end, + videoDuration, + outputFormat, + exporting, + exportProgress, + exportError, + exportSuccess, + useNativeExport, + onExport, +}: ExportPanelProps) => ( +
+

+ Export +

+ +

+ Trim range: {((start / 1000) * videoDuration).toFixed(2)}s to {((end / 1000) * videoDuration).toFixed(2)}s +

+ + {/* Export error */} + {exportError && ( +
+ ⚠️ {exportError} +
+ )} + + {/* Export success */} + {exportSuccess && ( +
+ ✅ {exportSuccess} +
+ )} + + {/* Progress bar */} + {exporting && ( +
+
+
+
+

+ Exporting: {Math.round(exportProgress * 100)}% +

+
+ )} + + + + {/* Footer: runtime info */} + {useNativeExport && ( +

+ ⚡ Running native hardware-accelerated FFmpeg +

+ )} +
+); diff --git a/src/mainview/components/FileDropZone.tsx b/src/mainview/components/FileDropZone.tsx new file mode 100644 index 0000000..d225a5a --- /dev/null +++ b/src/mainview/components/FileDropZone.tsx @@ -0,0 +1,83 @@ +import { useRef, useState } from "react"; +import { isStandalone } from "../env"; + +interface FileDropZoneProps { + onFileSelect: (file: File) => void; + onNativeBrowse?: () => void; +} + +export const FileDropZone = ({ onFileSelect, onNativeBrowse }: FileDropZoneProps) => { + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + // In standalone mode, always use native dialog (drop doesn't give us a file path) + if (isStandalone && onNativeBrowse) { + onNativeBrowse(); + return; + } + + const file = e.dataTransfer.files[0]; + if (file && file.type.startsWith("video/")) { + onFileSelect(file); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleClick = () => { + // In standalone mode, always use native dialog + if (isStandalone && onNativeBrowse) { + onNativeBrowse(); + return; + } + fileInputRef.current?.click(); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) onFileSelect(file); + }; + + return ( +
setIsDragging(false)} + onClick={handleClick} + > + {/* Browser file input (only used in browser mode) */} + {!isStandalone && ( + + )} +
🎬
+

+ {isStandalone + ? "Click to select a video file" + : "Drag & drop a video file here"} +

+

+ {isStandalone + ? "opens system file explorer" + : "or click to browse"} +

+
+ ); +}; diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx new file mode 100644 index 0000000..682e330 --- /dev/null +++ b/src/mainview/components/Header.tsx @@ -0,0 +1,67 @@ +import { isStandalone, isMac } from "../env"; +import logoUrl from "../assets/logo.png"; + +interface HeaderProps { + currentPage: "editor" | "download"; + setCurrentPage: (page: "editor" | "download") => void; + videoSrc: string | null; + onChangeVideo: () => void; +} + +export const Header = ({ + currentPage, + setCurrentPage, + videoSrc, + onChangeVideo, +}: HeaderProps) => ( +
+
+ YAFW Logo +
+

+ YAFW +

+

+ Yet Another FFmpeg Wrapper +

+
+
+
+ {currentPage === "download" ? ( + + ) : ( + <> + {/* Download Standalone Version (Browser mode only) */} + {!isStandalone && ( +
+ + + Runs locally with native FFmpeg for 50x faster export + +
+ )} + + {/* Change Video Button (Only if video is loaded) */} + {videoSrc && ( + + )} + + )} +
+
+); diff --git a/src/mainview/components/LandingPage.tsx b/src/mainview/components/LandingPage.tsx new file mode 100644 index 0000000..7b87de1 --- /dev/null +++ b/src/mainview/components/LandingPage.tsx @@ -0,0 +1,73 @@ +import { FileDropZone } from "./FileDropZone"; +import { isStandalone } from "../env"; +import logoUrl from "../assets/logo.png"; + +interface LandingPageProps { + onFileSelect: (file: File) => void; + onNativeBrowse: () => void; + onNavigateDownload: () => void; +} + +export const LandingPage = ({ + onFileSelect, + onNativeBrowse, + onNavigateDownload, +}: LandingPageProps) => ( + <> +
+ YAFW Logo +

+ YAFW +

+

+ Yet Another FFmpeg Wrapper +

+

+ A modern, lightweight video editor to trim, adjust, and re-encode videos in seconds. Completely private, fast, and gorgeous. +

+
+ + {/* Small tip about Standalone version in browser view */} + {!isStandalone && ( +
+ + 💡 Tip: Download the Desktop App for hardware-accelerated rendering and 50x faster export speeds. + + +
+ )} + +
+ +
+ + {/* Features list */} +
+
+ + Instant Cuts + Stream copy mode without re-encoding. +
+
+ ⚙️ + Pro Encoder + Adjust bitrate, custom resolution, and FPS. +
+
+ 📦 + Dual Mode + Runs in browser (WASM) or native (standalone). +
+
+ +); diff --git a/src/mainview/Player.tsx b/src/mainview/components/Player.tsx similarity index 96% rename from src/mainview/Player.tsx rename to src/mainview/components/Player.tsx index 36d0db0..8d1b8b3 100644 --- a/src/mainview/Player.tsx +++ b/src/mainview/components/Player.tsx @@ -21,7 +21,6 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en if (!video.paused) { video.pause(); } else { - // If at or past the end, loop back to start if (Math.ceil(positionState.position) >= endState.end) { positionState.setPosition(startState.start); video.currentTime = (startState.start / 1000) * (video.duration || 0); @@ -30,7 +29,6 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en } }, [positionState.position, endState.end, startState.start]); - // Space bar toggles play/pause useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.code === "Space") { @@ -42,7 +40,6 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en return () => window.removeEventListener("keydown", handleKeyDown); }, [togglePlay]); - // Sync video element play state with React state useEffect(() => { const video = videoRef.current; if (!video) return; @@ -54,7 +51,6 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en } }, [isPlayingState.isPlaying]); - // Sync video currentTime with position state useEffect(() => { const video = videoRef.current; if (!video || isNaN(video.duration)) return; @@ -65,7 +61,6 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en } }, [positionState.position]); - // Sync volume and mute settings useEffect(() => { const video = videoRef.current; if (!video) return; @@ -105,7 +100,6 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en className="w-full h-full object-contain cursor-pointer" /> - {/* Dark overlay showing play icon when paused */} {!isPlayingState.isPlaying && (
); -}; \ No newline at end of file +}; diff --git a/src/mainview/Timeline.tsx b/src/mainview/components/Timeline.tsx similarity index 93% rename from src/mainview/Timeline.tsx rename to src/mainview/components/Timeline.tsx index 6897dd1..75dc684 100644 --- a/src/mainview/Timeline.tsx +++ b/src/mainview/components/Timeline.tsx @@ -25,7 +25,6 @@ export const Timeline = ({ startState, endState, positionState, videoDuration }: e.stopPropagation(); setIsDragging(type); - // Immediately jump to position if scrubbing playhead if (type === "playhead") { positionState.setPosition(getValFromEvent(e)); } @@ -41,7 +40,6 @@ export const Timeline = ({ startState, endState, positionState, videoDuration }: } else if (isDragging === "start") { if (val <= endState.end) { startState.setStart(val); - // Keep playhead inside or at start when dragging start handle if (positionState.position < val) { positionState.setPosition(val); } @@ -49,7 +47,6 @@ export const Timeline = ({ startState, endState, positionState, videoDuration }: } else if (isDragging === "end") { if (val >= startState.start) { endState.setEnd(val); - // Keep playhead inside or at end when dragging end handle if (positionState.position > val) { positionState.setPosition(val); } @@ -70,7 +67,6 @@ export const Timeline = ({ startState, endState, positionState, videoDuration }: }; }, [isDragging, startState, endState, positionState]); - // Format time helper (seconds to e.g. 02.45s) const formatTime = (val: number) => { const seconds = (val / 1000) * videoDuration; return `${seconds.toFixed(2)}s`; @@ -81,7 +77,6 @@ export const Timeline = ({ startState, endState, positionState, videoDuration }: const widthPct = ((endState.end - startState.start) / 1000) * 100; const posPct = (positionState.position / 1000) * 100; - // Render ticks for the ruler (e.g. 10 ticks) const renderTicks = () => { const ticks = []; for (let i = 0; i <= 10; i++) { @@ -139,7 +134,7 @@ export const Timeline = ({ startState, endState, positionState, videoDuration }:
e.stopPropagation()} // Prevent clicking the clip from scrubbing + onMouseDown={(e) => e.stopPropagation()} > {/* Left trim handle */}
); -}; \ No newline at end of file +}; diff --git a/src/mainview/components/ToggleSwitch.tsx b/src/mainview/components/ToggleSwitch.tsx new file mode 100644 index 0000000..3dfa703 --- /dev/null +++ b/src/mainview/components/ToggleSwitch.tsx @@ -0,0 +1,24 @@ +interface ToggleSwitchProps { + checked: boolean; + onChange: () => void; + disabled?: boolean; +} + +export const ToggleSwitch = ({ checked, onChange, disabled }: ToggleSwitchProps) => ( + +); diff --git a/src/mainview/constants.ts b/src/mainview/constants.ts new file mode 100644 index 0000000..2e4767b --- /dev/null +++ b/src/mainview/constants.ts @@ -0,0 +1,27 @@ +// ── Constants ───────────────────────────────────────────────────── + +export const OUTPUT_FORMATS = [ + { label: "MP4 (.mp4)", ext: "mp4", mime: "video/mp4" }, + { label: "WebM (.webm)", ext: "webm", mime: "video/webm" }, + { label: "MKV (.mkv)", ext: "mkv", mime: "video/x-matroska" }, + { label: "MOV (.mov)", ext: "mov", mime: "video/quicktime" }, + { label: "AVI (.avi)", ext: "avi", mime: "video/x-msvideo" }, + { label: "FLV (.flv)", ext: "flv", mime: "video/x-flv" }, + { label: "MPEG-TS (.ts)", ext: "ts", mime: "video/mp2t" }, + { label: "OGG (.ogg)", ext: "ogg", mime: "video/ogg" }, +] as const; + +export const AUDIO_BITRATE_KBPS = 128; +export const WASM_FFMPEG_BASE_URL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm"; + +// VP9/Opus are too memory-intensive for WASM — use lighter codecs +export const WASM_CODEC_OVERRIDES: Record = { + webm: ["-c:v", "libvpx", "-c:a", "libvorbis"], + ogg: ["-c:v", "libtheora", "-c:a", "libvorbis"], +}; + +// Native FFmpeg: use faster encoding presets (VP9 default is extremely slow) +export const NATIVE_CODEC_OVERRIDES: Record = { + webm: ["-c:v", "libvpx-vp9", "-cpu-used", "4", "-deadline", "good", "-row-mt", "1"], + ogg: ["-c:v", "libtheora", "-c:a", "libvorbis"], +}; diff --git a/src/mainview/env.ts b/src/mainview/env.ts new file mode 100644 index 0000000..38e318f --- /dev/null +++ b/src/mainview/env.ts @@ -0,0 +1,8 @@ +// Detect if running inside Electrobun's native webview +export const isStandalone = + typeof window !== "undefined" && "__electrobunSendToHost" in window; + +// Detect if running on macOS (useful for platform-specific styling like traffic lights padding and titlebar drag) +export const isMac = + typeof window !== "undefined" && + /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent || navigator.platform || ""); diff --git a/src/mainview/helpers.ts b/src/mainview/helpers.ts new file mode 100644 index 0000000..f40ea6b --- /dev/null +++ b/src/mainview/helpers.ts @@ -0,0 +1,31 @@ +// ── Pure helper functions ───────────────────────────────────────── + +/** Extract file extension from a filename, defaulting to "mp4". */ +export const getFileExt = (name: string): string => + (name.split(".").pop() || "mp4").toLowerCase(); + +/** Detect the user's operating system from the user agent string. */ +export const getDetectedOS = (): "mac" | "windows" | "linux" => { + if (typeof window === "undefined") return "windows"; + const ua = navigator.userAgent.toLowerCase(); + if (ua.includes("mac")) return "mac"; + if (ua.includes("linux")) return "linux"; + return "windows"; +}; + +/** Pick the best available hardware-accelerated H.264 encoder. */ +export const getBestH264Encoder = (supported: string[]): string | null => { + if (supported.includes("h264_nvenc")) return "h264_nvenc"; + if (supported.includes("h264_videotoolbox")) return "h264_videotoolbox"; + if (supported.includes("h264_qsv")) return "h264_qsv"; + if (supported.includes("h264_amf")) return "h264_amf"; + if (supported.includes("h264_mf")) return "h264_mf"; + return null; +}; + +/** Pick the best available hardware-accelerated VP9 encoder. */ +export const getBestVP9Encoder = (supported: string[]): string | null => { + if (supported.includes("vp9_nvenc")) return "vp9_nvenc"; + if (supported.includes("vp9_qsv")) return "vp9_qsv"; + return null; +}; diff --git a/src/mainview/hooks/useElectrobun.ts b/src/mainview/hooks/useElectrobun.ts new file mode 100644 index 0000000..91f1fce --- /dev/null +++ b/src/mainview/hooks/useElectrobun.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef, useState } from "react"; +import { isStandalone } from "../env"; +import { type AppRPCType } from "../../shared/types"; + +export const useElectrobun = () => { + const [supportedEncoders, setSupportedEncoders] = useState([]); + const [electroview, setElectroview] = useState(null); + const electroviewRef = useRef(null); + + useEffect(() => { + if (!isStandalone) return; + import("electrobun/view") + .then(({ Electroview }: any) => { + const rpc = Electroview.defineRPC({ + maxRequestTime: 600_000, // 10 min — native dialogs block + handlers: { requests: {}, messages: {} }, + }); + const ev = new Electroview({ rpc }); + electroviewRef.current = ev; + setElectroview(ev); + console.log("[YAFW] Electrobun RPC initialized"); + + // Detect hardware accelerators + ev.rpc.request.detectHardwareAccelerators({}) + .then((res: any) => { + setSupportedEncoders(res.supportedEncoders || []); + }) + .catch((err: any) => { + console.error("[YAFW] Failed to detect hardware accelerators:", err); + }); + }) + .catch((err: unknown) => { + console.warn("[YAFW] Electrobun unavailable:", err); + }); + }, []); + + return { + electroview, + supportedEncoders, + isStandalone, + }; +}; diff --git a/src/mainview/hooks/useFFmpeg.ts b/src/mainview/hooks/useFFmpeg.ts new file mode 100644 index 0000000..82aa2c4 --- /dev/null +++ b/src/mainview/hooks/useFFmpeg.ts @@ -0,0 +1,163 @@ +import { useRef, useState } from "react"; +import { FFmpeg } from "@ffmpeg/ffmpeg"; +import { fetchFile, toBlobURL } from "@ffmpeg/util"; +import { WASM_FFMPEG_BASE_URL, WASM_CODEC_OVERRIDES, OUTPUT_FORMATS } from "../constants"; +import { getFileExt } from "../helpers"; + +export const useFFmpeg = () => { + const [wasmLoaded, setWasmLoaded] = useState(false); + const ffmpegRef = useRef(new FFmpeg()); + + const loadWasmFFmpeg = async () => { + if (wasmLoaded) return; + const ffmpeg = ffmpegRef.current; + ffmpeg.on("log", ({ message }) => + console.log(`[FFmpeg WASM] ${message}`) + ); + await ffmpeg.load({ + coreURL: await toBlobURL( + `${WASM_FFMPEG_BASE_URL}/ffmpeg-core.js`, + "text/javascript" + ), + wasmURL: await toBlobURL( + `${WASM_FFMPEG_BASE_URL}/ffmpeg-core.wasm`, + "application/wasm" + ), + }); + setWasmLoaded(true); + }; + + const probeWithWasm = async ( + source: File | string, + fileName?: string + ): Promise<{ fps: number | null; bitrate: number | null }> => { + try { + await loadWasmFFmpeg(); + const ffmpeg = ffmpegRef.current; + + // Collect log output + const logs: string[] = []; + const logHandler = ({ message }: { message: string }) => { + logs.push(message); + }; + ffmpeg.on("log", logHandler); + + // Write file to WASM virtual FS + const name = typeof source === "string" ? fileName || source : source.name; + const ext = getFileExt(name); + const inputName = `probe_input.${ext}`; + const data = await fetchFile(source); + await ffmpeg.writeFile(inputName, data); + + // Run ffmpeg -i + try { + await ffmpeg.exec(["-i", inputName]); + } catch { + // Expected to fail since no output specified + } + + // Clean up + await ffmpeg.deleteFile(inputName); + ffmpeg.off("log", logHandler); + + const allLogs = logs.join("\n"); + console.log("[YAFW] WASM probe logs:", allLogs); + + let detectedFps: number | null = null; + const fpsMatch = allLogs.match(/(\d+(?:\.\d+)?)\s+fps/); + if (fpsMatch) { + const fpsValue = parseFloat(fpsMatch[1]); + if (fpsValue > 0 && fpsValue < 1000) { + console.log(`[YAFW] WASM detected FPS: ${fpsValue}`); + detectedFps = fpsValue; + } + } + + let detectedBitrate: number | null = null; + const brMatch = allLogs.match(/bitrate:\s+(\d+)\s+kb\/s/); + if (brMatch) { + const brValue = parseInt(brMatch[1]); + if (brValue > 0) { + console.log(`[YAFW] WASM detected bitrate: ${brValue} kbps`); + detectedBitrate = brValue; + } + } + + return { fps: detectedFps, bitrate: detectedBitrate }; + } catch (err) { + console.warn("[YAFW] probeWithWasm failed:", err); + return { fps: null, bitrate: null }; + } + }; + + const exportWasm = async ( + sourceFormat: string, + outputFormat: string, + videoFile: File | null, + videoSrc: string | null, + ffmpegArgs: string[], + onProgress: (progress: number) => void + ) => { + await loadWasmFFmpeg(); + const ffmpeg = ffmpegRef.current; + + // Load video into WASM virtual filesystem + const inputFilename = `input.${sourceFormat}`; + if (videoFile) { + const data = await videoFile.arrayBuffer(); + await ffmpeg.writeFile(inputFilename, new Uint8Array(data)); + } else if (videoSrc) { + await ffmpeg.writeFile(inputFilename, await fetchFile(videoSrc)); + } else { + throw new Error("No video file available"); + } + + const codecOverrides = WASM_CODEC_OVERRIDES[outputFormat] ?? []; + const outputFilename = `output.${outputFormat}`; + const fullArgs = [ + "-i", + inputFilename, + ...ffmpegArgs, + ...codecOverrides, + outputFilename, + ]; + + console.log(`[YAFW] WASM export: ffmpeg ${fullArgs.join(" ")}`); + + const progressHandler = ({ progress }: { progress: number }) => { + onProgress(Math.max(0, Math.min(1, progress))); + }; + ffmpeg.on("progress", progressHandler); + + try { + await ffmpeg.exec(fullArgs); + } finally { + ffmpeg.off("progress", progressHandler); + } + + const data = await ffmpeg.readFile(outputFilename); + const mime = + OUTPUT_FORMATS.find((f) => f.ext === outputFormat)?.mime ?? + "video/mp4"; + const blob = new Blob([(data as Uint8Array).buffer as ArrayBuffer], { + type: mime, + }); + + // Trigger browser download + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `exported-video.${outputFormat}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return { + wasmLoaded, + loadWasmFFmpeg, + probeWithWasm, + exportWasm, + }; +}; diff --git a/src/mainview/hooks/useVideoEditor.ts b/src/mainview/hooks/useVideoEditor.ts new file mode 100644 index 0000000..b6c0530 --- /dev/null +++ b/src/mainview/hooks/useVideoEditor.ts @@ -0,0 +1,379 @@ +import { useEffect, useState } from "react"; +import { useElectrobun } from "./useElectrobun"; +import { useFFmpeg } from "./useFFmpeg"; +import { getFileExt, getBestH264Encoder, getBestVP9Encoder } from "../helpers"; +import { AUDIO_BITRATE_KBPS, NATIVE_CODEC_OVERRIDES } from "../constants"; + +export const useVideoEditor = () => { + const { electroview, supportedEncoders, isStandalone } = useElectrobun(); + const { wasmLoaded, loadWasmFFmpeg, probeWithWasm, exportWasm } = useFFmpeg(); + + // File state + const [videoSrc, setVideoSrc] = useState(null); + const [videoFile, setVideoFile] = useState(null); + const [nativeFilePath, setNativeFilePath] = useState(null); + const [sourceFormat, setSourceFormat] = useState("mp4"); + + // Playback state + const [position, setPosition] = useState(0); + const [start, setStart] = useState(0); + const [end, setEnd] = useState(1000); + const [isPlaying, setIsPlaying] = useState(false); + const [videoDuration, setVideoDuration] = useState(0); + + // Export state + const [exporting, setExporting] = useState(false); + const [exportProgress, setExportProgress] = useState(0); + const [exportError, setExportError] = useState(null); + const [exportSuccess, setExportSuccess] = useState(null); + + const [currentPage, setCurrentPage] = useState<"editor" | "download">("editor"); + + // Encoding options + const [bitrate, setBitrate] = useState(1000); + const [originalBitrate, setOriginalBitrate] = useState(1000); + const [reencode, setReencode] = useState(false); + const [outputFormat, setOutputFormat] = useState("mp4"); + const [hwAcc, setHwAcc] = useState(true); + + // Resolution & framerate + const [originalWidth, setOriginalWidth] = useState(1920); + const [originalHeight, setOriginalHeight] = useState(1080); + const [originalFps, setOriginalFps] = useState(null); + const [outputWidth, setOutputWidth] = useState(1920); + const [outputHeight, setOutputHeight] = useState(1080); + const [outputFps, setOutputFps] = useState(null); + const [lockAspectRatio, setLockAspectRatio] = useState(true); + + // Derived state + const isConverting = outputFormat !== sourceFormat; + const clipDuration = ((end - start) / 1000) * videoDuration; + const useNativeExport = isStandalone && !!nativeFilePath && !!electroview; + + const hasHwAccSupport = + ["mp4", "mkv", "mov", "flv", "ts"].includes(outputFormat) + ? !!getBestH264Encoder(supportedEncoders) + : outputFormat === "webm" + ? !!getBestVP9Encoder(supportedEncoders) + : false; + + // Clamping playback position + useEffect(() => { + if (position < start - 0.1) { + if (position !== start) setPosition(start); + setIsPlaying(false); + } else if (position >= end) { + if (position !== end) setPosition(end); + setIsPlaying(false); + } + }, [position, start, end]); + + // Reset bitrate when re-encode toggled off + useEffect(() => { + if (!reencode) setBitrate(originalBitrate); + }, [reencode, originalBitrate]); + + // Poll native export progress + useEffect(() => { + if (!exporting || !useNativeExport || !electroview) return; + + const interval = setInterval(async () => { + try { + const { progress } = await electroview.rpc.request.getExportProgress({}); + setExportProgress(progress); + } catch {} + }, 300); + + return () => clearInterval(interval); + }, [exporting, useNativeExport, electroview]); + + const detectVideoMetadata = (src: string, fileSize?: number) => { + const video = document.createElement("video"); + video.src = src; + video.onloadedmetadata = () => { + setVideoDuration(video.duration); + setStart(0); + setEnd(1000); + setPosition(0); + + const w = video.videoWidth || 1920; + const h = video.videoHeight || 1080; + setOriginalWidth(w); + setOriginalHeight(h); + setOutputWidth(w); + setOutputHeight(h); + + const estimateBitrate = (bytes: number) => { + if (video.duration <= 0) return; + const totalKbps = (bytes * 8) / (video.duration * 1000); + const videoKbps = Math.max( + 100, + Math.round(totalKbps - AUDIO_BITRATE_KBPS) + ); + setOriginalBitrate(videoKbps); + setBitrate(videoKbps); + }; + + if (fileSize) { + estimateBitrate(fileSize); + } else { + fetch(src, { method: "HEAD" }) + .then((res) => { + const cl = res.headers.get("content-length"); + if (cl) estimateBitrate(parseInt(cl)); + }) + .catch(() => {}); + } + }; + }; + + const cleanupVideoSrc = () => { + if (videoSrc && videoSrc.startsWith("blob:")) { + URL.revokeObjectURL(videoSrc); + } + }; + + const handleFileSelect = async (file: File) => { + cleanupVideoSrc(); + const url = URL.createObjectURL(file); + const ext = getFileExt(file.name); + + setOriginalFps(null); + setOutputFps(null); + setVideoSrc(url); + setVideoFile(file); + setNativeFilePath(null); + setSourceFormat(ext); + setOutputFormat(ext); + setExportError(null); + detectVideoMetadata(url, file.size); + + const probed = await probeWithWasm(file); + if (probed.fps !== null) { + setOriginalFps(probed.fps); + setOutputFps(probed.fps); + } else { + setOriginalFps(30); + setOutputFps(30); + } + if (probed.bitrate !== null) { + setOriginalBitrate(probed.bitrate); + setBitrate(probed.bitrate); + } + }; + + const handleNativeBrowse = async () => { + console.log("[YAFW] handleNativeBrowse called"); + if (!electroview) { + console.error("[YAFW] handleNativeBrowse: electroview is null!"); + return; + } + + try { + console.log("[YAFW] Calling selectInputFile RPC..."); + const result = await electroview.rpc.request.selectInputFile({}); + console.log("[YAFW] selectInputFile result:", result); + + if (!result.path) { + console.log("[YAFW] No path returned, user cancelled"); + return; + } + + cleanupVideoSrc(); + const previewUrl = `http://localhost:${result.previewPort}/`; + const ext = getFileExt(result.path); + + setOriginalFps(null); + setOutputFps(null); + setVideoSrc(previewUrl); + setVideoFile(null); + setNativeFilePath(result.path); + setSourceFormat(ext); + setOutputFormat(ext); + setExportError(null); + setExportSuccess(null); + detectVideoMetadata(previewUrl); + + const probed = await probeWithWasm(previewUrl, result.path); + if (probed.fps !== null) { + setOriginalFps(probed.fps); + setOutputFps(probed.fps); + } else { + setOriginalFps(30); + setOutputFps(30); + } + if (probed.bitrate !== null) { + setOriginalBitrate(probed.bitrate); + setBitrate(probed.bitrate); + } + } catch (err) { + console.error("[YAFW] handleNativeBrowse error:", err); + } + }; + + const handleChangeVideo = () => { + cleanupVideoSrc(); + setVideoSrc(null); + setVideoFile(null); + setNativeFilePath(null); + setVideoDuration(0); + setExportError(null); + setExportSuccess(null); + }; + + const buildFFmpegArgs = (): string[] => { + const startTime = (start / 1000) * videoDuration; + const endTime = (end / 1000) * videoDuration; + const isConverting = outputFormat !== sourceFormat; + + const args: string[] = [ + "-ss", startTime.toString(), + "-to", endTime.toString(), + ]; + + const needsReencode = reencode || isConverting; + + if (needsReencode) { + const br = reencode ? bitrate : originalBitrate; + args.push( + "-b:v", `${Math.round(br)}k`, + "-maxrate", `${Math.round(br)}k`, + "-bufsize", `${Math.round(br * 2)}k`, + ); + + if (reencode && (outputWidth !== originalWidth || outputHeight !== originalHeight)) { + const w = Math.round(outputWidth / 2) * 2; + const h = Math.round(outputHeight / 2) * 2; + args.push("-vf", `scale=${w}:${h}`); + } + + if (reencode && outputFps && originalFps && outputFps !== originalFps) { + args.push("-r", outputFps.toString()); + } + } else { + args.push("-c", "copy"); + } + + return args; + }; + + const exportNative = async () => { + if (!electroview || !nativeFilePath) { + throw new Error("Electrobun RPC or file path not available"); + } + + const args = buildFFmpegArgs(); + const needsReencode = reencode || (outputFormat !== sourceFormat); + + let codecOverrides = [...(NATIVE_CODEC_OVERRIDES[outputFormat] ?? [])]; + if (needsReencode && hwAcc) { + if (["mp4", "mkv", "mov", "flv", "ts"].includes(outputFormat)) { + const h264Encoder = getBestH264Encoder(supportedEncoders); + if (h264Encoder) { + codecOverrides = ["-c:v", h264Encoder]; + } + } else if (outputFormat === "webm") { + const vp9Encoder = getBestVP9Encoder(supportedEncoders); + if (vp9Encoder) { + codecOverrides = ["-c:v", vp9Encoder]; + } + } + } + + const fullArgs = [...args, ...codecOverrides]; + + const result = await electroview.rpc.request.exportVideo({ + inputPath: nativeFilePath, + ffmpegArgs: fullArgs, + outputExt: outputFormat, + clipDuration, + }); + + if (!result.success) { + throw new Error(result.error || "Native FFmpeg export failed"); + } + + setExportSuccess(result.outputPath || "Export complete"); + }; + + const exportVideo = async () => { + setExporting(true); + setExportProgress(0); + setExportError(null); + setExportSuccess(null); + try { + const useNative = isStandalone && nativeFilePath && electroview; + if (useNative) { + await exportNative(); + } else { + const args = buildFFmpegArgs(); + await exportWasm( + sourceFormat, + outputFormat, + videoFile, + videoSrc, + args, + (progress) => setExportProgress(progress) + ); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + setExportError(msg); + console.error("[YAFW] Export failed:", error); + } finally { + setExporting(false); + setExportProgress(0); + } + }; + + return { + videoSrc, + videoFile, + nativeFilePath, + sourceFormat, + position, + setPosition, + start, + setStart, + end, + setEnd, + isPlaying, + setIsPlaying, + videoDuration, + exporting, + exportProgress, + exportError, + exportSuccess, + currentPage, + setCurrentPage, + bitrate, + setBitrate, + originalBitrate, + reencode, + setReencode, + outputFormat, + setOutputFormat, + hwAcc, + setHwAcc, + originalWidth, + originalHeight, + originalFps, + outputWidth, + setOutputWidth, + outputHeight, + setOutputHeight, + lockAspectRatio, + setLockAspectRatio, + outputFps, + setOutputFps, + isConverting, + clipDuration, + useNativeExport, + hasHwAccSupport, + handleFileSelect, + handleNativeBrowse, + handleChangeVideo, + exportVideo, + isStandalone, + }; +};