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/app/globals.css b/src/app/globals.css index 52b2929d..def5ff97 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -75,4 +75,18 @@ body { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; +} + +/* ── High Contrast Mode Accessibility Fixes ── */ +/* When outline buttons with `hover:bg-[var(--border)]` are hovered in High Contrast Mode, + their background becomes white. We force all text, icons, and child elements to black + to maintain readable contrast (21:1 ratio). */ +[data-theme="high-contrast"] [class*="hover:bg-[var(--border)]"]:hover { + background-color: #FFFFFF !important; + color: #000000 !important; +} + +[data-theme="high-contrast"] [class*="hover:bg-[var(--border)]"]:hover * { + color: #000000 !important; + stroke: #000000 !important; } \ No newline at end of file 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} + ))} +
+