From 3eb75b8a0454877e2eb3a159d1719ab0a86077e0 Mon Sep 17 00:00:00 2001 From: Rambilas Sah Date: Tue, 19 May 2026 21:41:37 +0530 Subject: [PATCH 1/5] fix: ensure native beforeunload warning triggers correctly during video export --- src/hooks/useVideoEditor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index a2283128..7ddbd9e8 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -348,13 +348,13 @@ export function useVideoEditor() { useEffect(() => { const shouldWarn = status === "exporting" || - status === "loading-engine" || - status === "done"; + status === "loading-engine"; if (!shouldWarn) return; const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); + e.returnValue = ""; }; window.addEventListener("beforeunload", handler); From a8ffc076558c8ba5303fe05a00f7fdf2487694cc Mon Sep 17 00:00:00 2001 From: Rambilas Sah Date: Tue, 19 May 2026 22:18:35 +0530 Subject: [PATCH 2/5] feat: add global drag and drop video upload zone --- src/components/VideoEditor.tsx | 35 +++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index d43f5d18..c30d9f4b 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -62,6 +62,7 @@ export default function VideoEditor() { recommendedPreset, } = useVideoEditor(); const [copied, setCopied] = useState(false); + const [isDraggingGlobally, setIsDraggingGlobally] = useState(false); const downloadRef = useRef(null); useEffect(() => { @@ -88,7 +89,39 @@ export default function VideoEditor() { }, [videoSrc]); return ( -
+
{ + if (e.dataTransfer?.types.includes("Files")) { + e.preventDefault(); + setIsDraggingGlobally(true); + } + }} + > + {isDraggingGlobally && ( +
e.preventDefault()} + onDragLeave={(e) => { + e.preventDefault(); + setIsDraggingGlobally(false); + }} + onDrop={(e) => { + e.preventDefault(); + setIsDraggingGlobally(false); + const droppedFile = e.dataTransfer.files?.[0]; + if (droppedFile) { + handleFileSelect(droppedFile); + } + }} + > +
+ +

Drop video here to begin

+
+
+ )} From b22510bc9ecd33069d842bff20a22319a58d0f98 Mon Sep 17 00:00:00 2001 From: Rambilas Sah Date: Wed, 20 May 2026 17:59:25 +0530 Subject: [PATCH 3/5] feat: add global spacebar playback hotkey with input exclusion logic --- src/components/VideoPreview.tsx | 38 +++++++++++---------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 603cd6c7..544b406a 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -79,6 +79,8 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.tagName === "BUTTON" || target.isContentEditable) ) { return; @@ -87,12 +89,22 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { if (e.code === "KeyT") { e.preventDefault(); void handleGrabFrame(); + } else if (e.code === "Space") { + const video = videoRef.current; + if (video) { + e.preventDefault(); // Prevent default page scroll + if (video.paused) { + video.play().catch(() => {}); + } else { + video.pause(); + } + } } }; window.addEventListener("keydown", handleShortcut); return () => window.removeEventListener("keydown", handleShortcut); - }, [handleGrabFrame]); + }, [handleGrabFrame, videoRef]); useEffect(() => { if (!file) return; @@ -196,35 +208,11 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { if (!file) return null; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.code === "Space") { - const target = e.target as HTMLElement; - if ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable - ) { - return; - } - - const video = videoRef.current; - if (video) { - e.preventDefault(); // Prevent default page scroll - if (video.paused) { - video.play().catch(() => {}); - } else { - video.pause(); - } - } - } - }; - return (
{frameNotice && ( From 529b6efd715e2124859d2c24c914ebb98057bd5a Mon Sep 17 00:00:00 2001 From: Rambilas Sah Date: Wed, 20 May 2026 18:25:51 +0530 Subject: [PATCH 4/5] conflit resolved --- src/components/VideoPreview.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 544b406a..02f9eb42 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -157,6 +157,17 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { }; }, [file, videoRef]); + // sync mute state to video element + useEffect(() => { + if (!videoRef.current) return; + videoRef.current.muted = !activeRecipe.keepAudio; + }, [activeRecipe.keepAudio, videoRef]); + + useEffect(() => { + if (!videoRef.current) return; + videoRef.current.playbackRate = activeRecipe.speed; + }, [activeRecipe.speed, videoRef]); + /** * Compute the overlay geometry for the selected preset + framing mode. * The preview container always uses a 16:9 aspect-video box. @@ -242,7 +253,10 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { className={cn("w-full h-full object-contain transition-opacity duration-300", isLoading ? "opacity-0" : "opacity-100")} onLoadedData={() => setIsLoading(false)} playsInline - /> + muted={!activeRecipe.keepAudio} + > + + {/* Letterbox / Crop overlay */} {overlay && ( From ac241071eae5133f0455add047a3c62a37e480f9 Mon Sep 17 00:00:00 2001 From: Rambilas Sah Date: Wed, 20 May 2026 23:23:38 +0530 Subject: [PATCH 5/5] web pipeline studio UI --- pipeline.yaml | 58 ++++ scripts/pipeline-cli.ts | 468 +++++++++++++++++++++++++ src/components/PipelineStudio.tsx | 389 +++++++++++++++++++++ src/lib/pipeline.ts | 553 ++++++++++++++++++++++++++++++ src/lib/tests/pipeline.test.ts | 86 +++++ 5 files changed, 1554 insertions(+) create mode 100644 pipeline.yaml create mode 100644 scripts/pipeline-cli.ts create mode 100644 src/components/PipelineStudio.tsx create mode 100644 src/lib/pipeline.ts create mode 100644 src/lib/tests/pipeline.test.ts diff --git a/pipeline.yaml b/pipeline.yaml new file mode 100644 index 00000000..642c01db --- /dev/null +++ b/pipeline.yaml @@ -0,0 +1,58 @@ +# Reframe CLI Pipeline Configuration File +# ------------------------------------------------------------ +# This file defines the sequence of operations applied to your video. +# You can customize these steps or comment/uncomment them as needed. + +name: "Standard Video Preprocessing" +pipeline: + # 1. Trim the video (start and end times in seconds) + - step: trim + start: 0 + end: 10 + + # 2. Resize the video to standard resolution + # Options for fit: "contain" (pads black bars) or "crop" (fills screen) + - step: resize + width: 720 + height: 1280 + fit: contain + + # 3. Transcode and convert format + # Options: "mp4", "webm", "mkv" + - step: convert + format: mp4 + +# ============================================================ +# ALTERNATIVE EXAMPLE: ML Dataset Frame Extraction +# To use this instead, uncomment the block below and comment out the block above. +# +# name: "Chroma Key Frame Extraction Pipeline" +# pipeline: +# # Trim the clip to the target segment +# - step: trim +# start: 1 +# end: 5 +# +# # Extract individual frames at a specified frame rate (FPS) +# # Note: When 'extract_frames' is present, the pipeline outputs +# # a folder of images instead of a single output video. +# - step: extract_frames +# fps: 2 +# format: png +# +# # Remove chroma-key background color (e.g. green screen) from extracted frames +# - step: remove_background +# color: green +# similarity: 0.15 +# blend: 0.05 +# +# # Crop/resize frames for machine learning input +# - step: resize +# width: 512 +# height: 512 +# fit: crop +# +# # Convert and save frames to target format +# - step: convert +# format: png +# ============================================================ diff --git a/scripts/pipeline-cli.ts b/scripts/pipeline-cli.ts new file mode 100644 index 00000000..d4c54322 --- /dev/null +++ b/scripts/pipeline-cli.ts @@ -0,0 +1,468 @@ +#!/usr/bin/env ts-node +/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable @typescript-eslint/no-var-requires */ + +const { spawnSync, execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +// --- ANSI Escape Codes for Colors --- +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const GREEN = "\x1b[32m"; +const BLUE = "\x1b[34m"; +const YELLOW = "\x1b[33m"; +const RED = "\x1b[31m"; +const GRAY = "\x1b[90m"; + +// --- Types --- +interface PipelineStep { + step: string; + [key: string]: any; +} + +interface PipelineConfig { + name?: string; + pipeline: PipelineStep[]; +} + +// --- CLI Argument Parsing --- +function printHelp() { + console.log(` +${BOLD}${GREEN}Reframe CLI Pipeline Runner${RESET} +====================================== +Automate media preprocessing pipelines via YAML/JSON workflow files. + +${BOLD}Usage:${RESET} + npx ts-node scripts/pipeline-cli.ts run [options] + +${BOLD}Options:${RESET} + --out Output directory for processed files (default: ./output) + --help, -h Show this help message + +${BOLD}Example:${RESET} + npx ts-node scripts/pipeline-cli.ts run pipeline.yaml input.mp4 --out ./processed_dataset +`); +} + +// Check for system ffmpeg dependency with helpful install guides +function checkSystemFFmpeg() { + try { + execSync("ffmpeg -version", { stdio: "ignore" }); + } catch { + console.error(`\n${RED}${BOLD}❌ Error: 'ffmpeg' binary is not found in your system's PATH.${RESET}`); + console.error(`\n${YELLOW}${BOLD}How to fix this:${RESET}`); + console.error(`Reframe CLI runs pipelines locally using your system's native FFmpeg binary.`); + console.error(`Please install FFmpeg using one of the commands below depending on your OS:\n`); + + console.error(`${BOLD}Windows (PowerShell/CMD):${RESET}`); + console.error(` winget install Gyan.FFmpeg`); + console.error(` ${GRAY}(Then close and reopen your terminal session)${RESET}\n`); + + console.error(`${BOLD}macOS (Homebrew):${RESET}`); + console.error(` brew install ffmpeg\n`); + + console.error(`${BOLD}Linux (Debian/Ubuntu):${RESET}`); + console.error(` sudo apt update && sudo apt install -y ffmpeg\n`); + + console.error(`${GRAY}Note: The Reframe Web application runs FFmpeg.wasm in-browser automatically without needing any system installation!${RESET}\n`); + process.exit(1); + } +} + +// --- Simple YAML/JSON Parser --- +function parsePipelineConfig(filePath: string): PipelineConfig { + if (!fs.existsSync(filePath)) { + console.error(`${RED}Error: Config file not found: ${filePath}${RESET}`); + process.exit(1); + } + + const content = fs.readFileSync(filePath, "utf-8").trim(); + + if (filePath.endsWith(".json") || content.startsWith("{")) { + try { + return JSON.parse(content) as PipelineConfig; + } catch (e) { + console.error(`${RED}Error: Failed to parse JSON: ${(e as Error).message}${RESET}`); + process.exit(1); + } + } + + // Parse YAML simply + try { + const lines = content.split("\n"); + const result: PipelineConfig = { pipeline: [] }; + let currentStep: PipelineStep | null = null; + + for (let line of lines) { + const commentIdx = line.indexOf("#"); + if (commentIdx !== -1) { + line = line.substring(0, commentIdx); + } + line = line.trimEnd(); + if (!line.trim()) continue; + + const trimmed = line.trim(); + const indent = line.length - line.trimStart().length; + + if (trimmed.startsWith("-")) { + if (currentStep) { + result.pipeline.push(currentStep); + } + currentStep = { step: "" }; + const rest = trimmed.substring(1).trim(); + if (rest) { + const colonIdx = rest.indexOf(":"); + if (colonIdx !== -1) { + const k = rest.substring(0, colonIdx).trim(); + const v = parseYamlValue(rest.substring(colonIdx + 1).trim()); + if (k === "step") { + currentStep.step = String(v); + } else { + currentStep[k] = v; + } + } else { + currentStep.step = rest; + } + } + } else { + const colonIdx = trimmed.indexOf(":"); + if (colonIdx !== -1) { + const k = trimmed.substring(0, colonIdx).trim(); + const v = parseYamlValue(trimmed.substring(colonIdx + 1).trim()); + if (currentStep && indent > 0) { + if (k === "step") { + currentStep.step = String(v); + } else { + currentStep[k] = v; + } + } else { + if (k === "name") { + result.name = String(v); + } + } + } + } + } + + if (currentStep) { + result.pipeline.push(currentStep); + } + + if (!result.pipeline || result.pipeline.length === 0) { + throw new Error("No pipeline steps found under 'pipeline:' key."); + } + + return result; + } catch (e) { + console.error(`${RED}Error parsing YAML config: ${(e as Error).message}${RESET}`); + process.exit(1); + } +} + +function parseYamlValue(v: string): any { + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + return v.slice(1, -1); + } + if (v === "true") return true; + if (v === "false") return false; + if (!isNaN(v as any) && v !== "") return Number(v); + return v; +} + +function parseColorToHex(colorStr: string): string { + const c = colorStr.toLowerCase().trim(); + if (c === "green") return "0x00FF00"; + if (c === "blue") return "0x0000FF"; + if (c === "red") return "0xFF0000"; + if (c === "black") return "0x000000"; + if (c === "white") return "0xFFFFFF"; + + const hex = c.startsWith("#") ? c.slice(1) : c; + if (hex.length === 6) { + return `0x${hex}`; + } + return "0x00FF00"; +} + +// --- Main Execution --- +function main() { + const args = process.argv.slice(2); + + if (args.length === 0 || args.includes("--help") || args.includes("-h")) { + printHelp(); + return; + } + + const command = args[0]; + if (command !== "run") { + console.error(`${RED}Error: Unknown command "${command}". Use "run".${RESET}`); + printHelp(); + process.exit(1); + } + + const configFile = args[1]; + const inputFile = args[2]; + + if (!configFile || !inputFile) { + console.error(`${RED}Error: Missing required arguments config-file or input-file.${RESET}`); + printHelp(); + process.exit(1); + } + + let outDir = "./output"; + const outIndex = args.indexOf("--out"); + if (outIndex !== -1 && args[outIndex + 1]) { + outDir = args[outIndex + 1]; + } + + checkSystemFFmpeg(); + + console.log(`${BOLD}${GREEN}🚀 Starting Reframe CLI pipeline...${RESET}`); + console.log(`${GRAY}Config:${RESET} ${configFile}`); + console.log(`${GRAY}Input:${RESET} ${inputFile}`); + console.log(`${GRAY}Output:${RESET} ${outDir}\n`); + + const config = parsePipelineConfig(configFile); + console.log(`${BOLD}📂 Loaded pipeline${config.name ? `: "${config.name}"` : ""} with ${config.pipeline.length} steps.${RESET}`); + + // Create output directory + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + + const extractStepIndex = config.pipeline.findIndex((s: PipelineStep) => s.step === "extract_frames"); + const hasFrameExtraction = extractStepIndex !== -1; + + if (!hasFrameExtraction) { + // --- Scenario A: Video pipeline --- + console.log(`\n${BLUE}${BOLD}[STAGE 1/1] Running single-pass video transcode...${RESET}`); + + const vfFilters: string[] = []; + let format = "mp4"; + let trimStart = 0; + let trimEnd: number | null = null; + let hasTrim = false; + + for (const step of config.pipeline) { + if (step.step === "trim") { + trimStart = Number(step.start ?? 0); + trimEnd = step.end !== undefined ? Number(step.end) : null; + hasTrim = true; + } else if (step.step === "rotate") { + const angle = Number(step.angle ?? 90); + if (angle === 90) vfFilters.push("transpose=1"); + else if (angle === 180) vfFilters.push("transpose=1,transpose=1"); + else if (angle === 270) vfFilters.push("transpose=2"); + } else if (step.step === "resize") { + const w = Math.round(Number(step.width ?? 512) / 2) * 2; + const h = Math.round(Number(step.height ?? 512) / 2) * 2; + const fit = step.fit ?? "contain"; + if (fit === "contain") { + vfFilters.push(`scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2:color=black`); + } else { + vfFilters.push(`scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`); + } + } else if (step.step === "remove_background") { + const col = String(step.color ?? "green"); + const sim = Number(step.similarity ?? 0.15); + const bld = Number(step.blend ?? 0.05); + const hex = parseColorToHex(col); + vfFilters.push(`chromakey=color=${hex}:similarity=${sim}:blend=${bld}`); + } else if (step.step === "convert") { + format = String(step.format ?? "mp4").toLowerCase(); + } + } + + const ffmpegArgs: string[] = []; + + if (hasTrim) { + ffmpegArgs.push("-ss", String(trimStart)); + } + ffmpegArgs.push("-i", inputFile); + if (hasTrim && trimEnd !== null) { + ffmpegArgs.push("-to", String(trimEnd)); + } + + if (vfFilters.length > 0) { + ffmpegArgs.push("-vf", vfFilters.join(",")); + } + + const outExt = format === "webm" ? "webm" : format === "mkv" ? "mkv" : "mp4"; + const outPath = path.join(outDir, `reframe_output.${outExt}`); + + if (outExt === "webm") { + ffmpegArgs.push("-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30", "-c:a", "libopus"); + } else if (outExt === "mkv") { + ffmpegArgs.push("-c:v", "libx264", "-crf", "23", "-c:a", "aac"); + } else { + ffmpegArgs.push("-c:v", "libx264", "-crf", "23", "-preset", "medium", "-c:a", "aac", "-pix_fmt", "yuv420p"); + } + + ffmpegArgs.push("-y", outPath); + + console.log(`${GRAY}Executing command: ffmpeg ${ffmpegArgs.join(" ")}${RESET}`); + + const result = spawnSync("ffmpeg", ffmpegArgs, { stdio: "inherit" }); + if (result.status !== 0) { + console.error(`\n${RED}${BOLD}❌ Transcoding failed with exit code ${result.status}${RESET}`); + process.exit(1); + } + + console.log(`\n${GREEN}${BOLD}✅ Pipeline finished successfully!${RESET}`); + console.log(`Saved output video to: ${outPath}\n`); + + } else { + // --- Scenario B: Frame extraction & batch frame processing --- + const preSteps = config.pipeline.slice(0, extractStepIndex); + const extractStep = config.pipeline[extractStepIndex]; + const postSteps = config.pipeline.slice(extractStepIndex + 1); + + const tempDir = path.join(outDir, ".reframe_temp"); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + console.log(`\n${BLUE}${BOLD}[STAGE 1/3] Preprocessing video & frame extraction...${RESET}`); + + let currentInputVideo = inputFile; + const preVf: string[] = []; + let hasTrim = false; + let trimStart = 0; + let trimEnd: number | null = null; + + for (const step of preSteps) { + if (step.step === "trim") { + trimStart = Number(step.start ?? 0); + trimEnd = step.end !== undefined ? Number(step.end) : null; + hasTrim = true; + } else if (step.step === "rotate") { + const angle = Number(step.angle ?? 90); + if (angle === 90) preVf.push("transpose=1"); + else if (angle === 180) preVf.push("transpose=1,transpose=1"); + else if (angle === 270) preVf.push("transpose=2"); + } + } + + if (hasTrim || preVf.length > 0) { + const intermediateVideo = path.join(tempDir, "preprocessed.mp4"); + const intermediateArgs = ["-y"]; + if (hasTrim) intermediateArgs.push("-ss", String(trimStart)); + intermediateArgs.push("-i", currentInputVideo); + if (hasTrim && trimEnd !== null) intermediateArgs.push("-to", String(trimEnd)); + if (preVf.length > 0) intermediateArgs.push("-vf", preVf.join(",")); + intermediateArgs.push("-c:a", "copy", intermediateVideo); + + console.log(`${GRAY}Running pre-extraction filter: ffmpeg ${intermediateArgs.join(" ")}${RESET}`); + const interResult = spawnSync("ffmpeg", intermediateArgs, { stdio: "ignore" }); + if (interResult.status === 0) { + currentInputVideo = intermediateVideo; + } + } + + const fps = Number(extractStep.fps ?? 1); + const imgFormat = String(extractStep.format ?? "png").toLowerCase(); + + // Extract frames command + const extractPattern = path.join(tempDir, "frame_%03d." + imgFormat); + const extractArgs = [ + "-y", + "-i", currentInputVideo, + "-vf", `fps=${fps}`, + "-vsync", "vfr", + extractPattern + ]; + + console.log(`${GRAY}Running extraction: ffmpeg ${extractArgs.join(" ")}${RESET}`); + const extractResult = spawnSync("ffmpeg", extractArgs, { stdio: "inherit" }); + if (extractResult.status !== 0) { + console.error(`\n${RED}❌ Frame extraction failed.${RESET}`); + cleanupTemp(tempDir); + process.exit(1); + } + + const extractedFiles = fs.readdirSync(tempDir) + .filter((f: string) => f.startsWith("frame_") && f.endsWith("." + imgFormat)) + .sort(); + + console.log(`\n${GREEN}📁 Extracted ${extractedFiles.length} frames successfully.${RESET}`); + + console.log(`\n${BLUE}${BOLD}[STAGE 2/3] Batch processing frame images...${RESET}`); + + let resizeConfig = null; + let removeBgConfig = null; + let finalFormat = imgFormat; + + for (const step of postSteps) { + if (step.step === "resize") { + resizeConfig = { + width: Number(step.width ?? 512), + height: Number(step.height ?? 512), + fit: step.fit ?? "contain", + }; + } else if (step.step === "remove_background") { + removeBgConfig = { + color: String(step.color ?? "green"), + similarity: Number(step.similarity ?? 0.15), + blend: Number(step.blend ?? 0.05), + }; + } else if (step.step === "convert") { + finalFormat = String(step.format ?? "png").toLowerCase(); + } + } + + let processedCount = 0; + + for (const file of extractedFiles) { + const srcPath = path.join(tempDir, file); + const outputFilename = file.replace("." + imgFormat, "." + finalFormat); + const dstPath = path.join(outDir, outputFilename); + + const postVf = []; + + if (removeBgConfig) { + const hex = parseColorToHex(removeBgConfig.color); + postVf.push(`chromakey=color=${hex}:similarity=${removeBgConfig.similarity}:blend=${removeBgConfig.blend}`); + } + + if (resizeConfig) { + const w = Math.round((resizeConfig as any).width / 2) * 2; + const h = Math.round((resizeConfig as any).height / 2) * 2; + if ((resizeConfig as any).fit === "contain") { + postVf.push(`scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2:color=black`); + } else { + postVf.push(`scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`); + } + } + + const postArgs = ["-y", "-i", srcPath]; + if (postVf.length > 0) { + postArgs.push("-vf", postVf.join(",")); + } + postArgs.push(dstPath); + + const singleResult = spawnSync("ffmpeg", postArgs, { stdio: "ignore" }); + if (singleResult.status === 0) { + processedCount++; + process.stdout.write(`\r${GRAY}Processed ${processedCount}/${extractedFiles.length} frames...${RESET}`); + } + } + + console.log(`\n\n${BLUE}${BOLD}[STAGE 3/3] Cleaning up temporary files...${RESET}`); + cleanupTemp(tempDir); + + console.log(`\n${GREEN}${BOLD}✅ Pipeline finished successfully!${RESET}`); + console.log(`Saved ${processedCount} processed frames to: ${outDir}\n`); + } +} + +function cleanupTemp(dir: string) { + if (fs.existsSync(dir)) { + fs.readdirSync(dir).forEach((file: string) => { + fs.unlinkSync(path.join(dir, file)); + }); + fs.rmdirSync(dir); + } +} + +main(); diff --git a/src/components/PipelineStudio.tsx b/src/components/PipelineStudio.tsx new file mode 100644 index 00000000..b4c797a6 --- /dev/null +++ b/src/components/PipelineStudio.tsx @@ -0,0 +1,389 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { loadFFmpeg } from "@/lib/ffmpeg"; +import { runPipeline, PipelineResult } from "@/lib/pipeline"; +import { cn } from "@/lib/utils"; +import { + Code, + Terminal as TerminalIcon, + Play, + Square, + Download, + AlertCircle, + CheckCircle2, + FolderArchive, + RefreshCw, + Copy, + ChevronDown +} from "lucide-react"; + +interface Props { + file: File | null; +} + +const PRESETS = [ + { + id: "dataset-prep", + name: "🤖 AI Dataset Prep (Transparent WebP Frames)", + description: "Extract frames, remove green screen backgrounds, scale to 512x512, and export as highly optimized WebP images.", + code: `name: "AI Dataset Preprocessing" +pipeline: + - step: extract_frames + fps: 2 + format: webp + - step: remove_background + color: green + similarity: 0.18 + - step: resize + width: 512 + height: 512 + - step: convert + format: webp +`, + }, + { + id: "green-screen-video", + name: "đŸŸĸ Green Screen Removal (WebM Alpha Video)", + description: "Trim the first 10 seconds of a video, remove green background, and convert to WebM with alpha transparency.", + code: `name: "Chroma Key transparent Video" +pipeline: + - step: trim + start: 0 + end: 10 + - step: remove_background + color: green + similarity: 0.15 + blend: 0.05 + - step: convert + format: webm +`, + }, + { + id: "frame-extractor", + name: "📸 Batch Frame Extractor (High-Res JPEG)", + description: "Simply extract high-resolution JPEG frames at 5 frames per second across the video timeline.", + code: `name: "Batch Frame Extractor" +pipeline: + - step: extract_frames + fps: 5 + format: jpeg +`, + }, + { + id: "square-mp4", + name: "âšī¸ Social Media Square Cover (Crop & Convert)", + description: "Resize/crop the video to 600x600 square framing, convert to MP4 format with standard settings.", + code: `name: "Square MP4 Transcoder" +pipeline: + - step: resize + width: 600 + height: 600 + fit: cover + - step: convert + format: mp4 +`, + }, +]; + +export default function PipelineStudio({ file }: Props) { + const [configText, setConfigText] = useState(PRESETS[0].code); + const [selectedPresetId, setSelectedPresetId] = useState(PRESETS[0].id); + const [status, setStatus] = useState<"idle" | "loading-engine" | "running" | "done" | "error">("idle"); + const [progress, setProgress] = useState(0); + const [logs, setLogs] = useState([]); + const [result, setResult] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [copied, setCopied] = useState(false); + + const consoleEndRef = useRef(null); + const abortControllerRef = useRef(null); + + // Auto-scroll terminal logs + useEffect(() => { + if (consoleEndRef.current) { + consoleEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [logs]); + + const addLog = (msg: string) => { + setLogs((prev) => [...prev, msg]); + }; + + const handlePresetSelect = (id: string) => { + const preset = PRESETS.find((p) => p.id === id); + if (preset) { + setConfigText(preset.code); + setSelectedPresetId(id); + } + }; + + const startPipeline = async () => { + if (!file) return; + + setStatus("loading-engine"); + setProgress(0); + setLogs([]); + setResult(null); + setErrorMsg(null); + + abortControllerRef.current = new AbortController(); + + addLog(`[${new Date().toLocaleTimeString()}] đŸŽŦ Loading client-side FFmpeg WebAssembly engine...`); + try { + const ffmpeg = await loadFFmpeg(abortControllerRef.current.signal); + + setStatus("running"); + const pipelineResult = await runPipeline( + ffmpeg, + file, + configText, + (msg) => addLog(msg), + (pct) => setProgress(pct) + ); + + setResult(pipelineResult); + setStatus("done"); + } catch (e) { + if (status === "loading-engine" || status === "running") { + const errorText = e instanceof Error ? e.message : String(e); + addLog(`[ERROR] ${errorText}`); + setErrorMsg(errorText); + setStatus("error"); + } + } + }; + + const stopPipeline = () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + addLog(`[${new Date().toLocaleTimeString()}] 🛑 Execution aborted by user.`); + setStatus("idle"); + setProgress(0); + }; + + const copyLogs = () => { + navigator.clipboard.writeText(logs.join("\n")).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + return ( +
+ {/* Overview Card */} +
+
+ Pipeline Studio +
+

AUTOMATED PRESETS STUDIO

+

+ Create, edit, and execute multi-step media preprocessing workflows locally. Specify options in standard YAML or JSON and watch the steps process sequentially in the secure client-side sandbox. +

+
+ +
+ {/* Left Column: Script / Config Editor */} +
+
+ {/* Header controls */} +
+
+ + Workflow YAML +
+
+ + +
+
+ + {/* Selected preset description */} +
+ {PRESETS.find((p) => p.id === selectedPresetId)?.description} +
+ + {/* Code area with line numbers */} +
+
+ {Array.from({ length: configText.split("\n").length }).map((_, i) => ( + {i + 1} + ))} +
+