From 5adb24d1157a1a1593b44ccd641ceb9e3b8a0668 Mon Sep 17 00:00:00 2001 From: surjeetkumar800 Date: Mon, 25 May 2026 14:33:52 +0530 Subject: [PATCH] feat: add design session timeline and snapshot history --- src/components/AICopilotTimeline.tsx | 713 ++++++++++++++++++ src/components/VideoEditor.tsx | 570 ++++++++++---- .../__tests__/sessionHistory.test.ts | 104 +++ src/lib/sessionHistory.ts | 602 +++++++++++++++ 4 files changed, 1829 insertions(+), 160 deletions(-) create mode 100644 src/components/AICopilotTimeline.tsx create mode 100644 src/components/__tests__/sessionHistory.test.ts create mode 100644 src/lib/sessionHistory.ts diff --git a/src/components/AICopilotTimeline.tsx b/src/components/AICopilotTimeline.tsx new file mode 100644 index 00000000..58bae91e --- /dev/null +++ b/src/components/AICopilotTimeline.tsx @@ -0,0 +1,713 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { + Sparkles, History, Diff, BarChart4, Play, Pause, + SkipBack, SkipForward, Bookmark, Check, Plus, AlertCircle, HelpCircle, RefreshCw +} from "lucide-react"; +import { EditRecipe } from "@/lib/types"; +import { SessionState, compileAIPrompt, diffRecipes, RecipeDiff } from "@/lib/sessionHistory"; +import { cn } from "@/lib/utils"; + +interface AICopilotTimelineProps { + recipe: EditRecipe; + onUpdateRecipe: ( + newRecipe: EditRecipe, + description: string, + actionType: SessionState["actionType"], + category: SessionState["category"], + promptText?: string + ) => void; + history: SessionState[]; + currentStateIndex: number; + onNavigateHistory: (index: number) => void; + onToggleMilestone: (stateId: string, name?: string) => void; +} + +export default function AICopilotTimeline({ + recipe, + onUpdateRecipe, + history, + currentStateIndex, + onNavigateHistory, + onToggleMilestone, +}: AICopilotTimelineProps) { + const [activeTab, setActiveTab] = useState<"copilot" | "timeline" | "diff" | "analytics">("copilot"); + + // AI Copilot state + const [prompt, setPrompt] = useState(""); + const [isCompiling, setIsCompiling] = useState(false); + const [aiLogs, setAiLogs] = useState([]); + const [lastExecutedPrompt, setLastExecutedPrompt] = useState(""); + + // Replay playback states + const [isPlayingReplay, setIsPlayingReplay] = useState(false); + const replayIntervalRef = useRef(null); + + // Diff comparison states + const [diffBaseIndex, setDiffBaseIndex] = useState(0); + const [diffTargetIndex, setDiffTargetIndex] = useState(history.length - 1); + + // Milestone input state + const [bookmarkingStateId, setBookmarkingStateId] = useState(null); + const [milestoneNameInput, setMilestoneNameInput] = useState(""); + + // Sync diff comparison target on history changes + useEffect(() => { + if (history.length > 0) { + setDiffTargetIndex(history.length - 1); + } + }, [history.length]); + + // Clean up replay interval + useEffect(() => { + return () => { + if (replayIntervalRef.current) clearInterval(replayIntervalRef.current); + }; + }, []); + + // Handle step-by-step replay playback + useEffect(() => { + if (isPlayingReplay) { + replayIntervalRef.current = setInterval(() => { + if (currentStateIndex < history.length - 1) { + onNavigateHistory(currentStateIndex + 1); + } else { + setIsPlayingReplay(false); + } + }, 1500); // 1.5s per step + } else { + if (replayIntervalRef.current) { + clearInterval(replayIntervalRef.current); + replayIntervalRef.current = null; + } + } + + return () => { + if (replayIntervalRef.current) clearInterval(replayIntervalRef.current); + }; + }, [isPlayingReplay, currentStateIndex, history.length, onNavigateHistory]); + + const handleSendPrompt = (e?: React.FormEvent) => { + if (e) e.preventDefault(); + if (!prompt.trim() || isCompiling) return; + + setIsCompiling(true); + setAiLogs(["AI thinking... analyzing prompt"]); + + // Simulate AI thinking and styling generations + setTimeout(() => { + try { + const result = compileAIPrompt(prompt, recipe); + onUpdateRecipe( + result.recipe, + result.description, + "ai_prompt", + result.category, + prompt.trim() + ); + setAiLogs(result.logs); + setLastExecutedPrompt(prompt.trim()); + setPrompt(""); + } catch (err) { + setAiLogs(["Error executing prompt. Please try again."]); + } finally { + setIsCompiling(false); + } + }, 900); + }; + + const applyQuickPrompt = (text: string) => { + setPrompt(text); + // Auto-submit quick prompt + setIsCompiling(true); + setAiLogs(["AI applying macro preset..."]); + setTimeout(() => { + try { + const result = compileAIPrompt(text, recipe); + onUpdateRecipe( + result.recipe, + result.description, + "ai_prompt", + result.category, + text + ); + setAiLogs(result.logs); + setLastExecutedPrompt(text); + setPrompt(""); + } catch (err) { + setAiLogs(["Error executing prompt."]); + } finally { + setIsCompiling(false); + } + }, 700); + }; + + const handleToggleMilestoneClick = (state: SessionState) => { + if (state.isMilestone) { + // Remove milestone + onToggleMilestone(state.id); + } else { + // Open milestone name prompt + setBookmarkingStateId(state.id); + setMilestoneNameInput(state.milestoneName || `Version ${history.indexOf(state) + 1}`); + } + }; + + const submitMilestone = () => { + if (bookmarkingStateId) { + onToggleMilestone(bookmarkingStateId, milestoneNameInput.trim() || undefined); + setBookmarkingStateId(null); + setMilestoneNameInput(""); + } + }; + + const prevState = history[currentStateIndex - 1]; + const currState = history[currentStateIndex]; + const currentDiffs = history.length > 1 && currentStateIndex > 0 && prevState && currState + ? diffRecipes(prevState.recipe, currState.recipe) + : []; + + const baseCompareState = history[diffBaseIndex]; + const targetCompareState = history[diffTargetIndex]; + const baseCompareDiffs = history.length > 0 && diffBaseIndex < history.length && diffTargetIndex < history.length && baseCompareState && targetCompareState + ? diffRecipes(baseCompareState.recipe, targetCompareState.recipe) + : []; + + // Compute analytics metrics + const categoryCounts = history.reduce( + (acc, cur) => { + acc[cur.category] = (acc[cur.category] || 0) + 1; + return acc; + }, + {} as Record + ); + + const totalEdits = history.length; + const aiEditsCount = history.filter((h) => h.actionType === "ai_prompt").length; + const manualEditsCount = history.filter((h) => h.actionType === "manual").length; + const milestoneCount = history.filter((h) => h.isMilestone).length; + + const quickPrompts = [ + { text: "🎬 optimize for TikTok Reel (9:16)", label: "Reel Mode" }, + { text: "✨ cinematic look and high contrast", label: "Cinematic Grade" }, + { text: "🏎️ double speed timelapse", label: "Timelapse" }, + { text: "🔇 mute audio", label: "Mute" }, + { text: "📝 add text overlay 'Awesome Day!'", label: "Add Text" }, + { text: "🎨 vlog preset and high saturation", label: "Warm Vlog" }, + ]; + + return ( +
+ {/* Header & Tabs */} +
+
+ + + AI Design Copilot & Replay + +
+ +
+ + + + +
+
+ + {/* Tab Contents */} +
+ {/* Tab 1: AI Prompt Copilot */} + {activeTab === "copilot" && ( +
+
+ +
+ setPrompt(e.target.value)} + disabled={isCompiling} + placeholder="e.g. 'crop to 9:16 and boost saturation and add title Vlog'" + className="w-full px-4 py-3 bg-transparent border-none text-sm text-[var(--text)] focus:ring-0 focus:outline-none placeholder-[var(--muted)] placeholder-opacity-70" + /> + +
+
+ + {/* Quick Prompts Chips */} +
+ + Quick Actions + +
+ {quickPrompts.map((qp, idx) => ( + + ))} +
+
+ + {/* Simulated AI Log Panel */} + {(aiLogs.length > 0 || lastExecutedPrompt) && ( +
+ {lastExecutedPrompt && ( +
+ Prompt: + "{lastExecutedPrompt}" +
+ )} +
+ AI Operations Log: + {aiLogs.map((log, index) => ( +
+ + {log} +
+ ))} +
+
+ )} + + {/* Instructions Help Alert */} +
+ +

+ Reframe's Client-Side Copilot accepts combinations of layout preset changes (TikTok/Landscape/Square), adjustments (contrast, brightness, saturation), rotations (90/180/270 degrees), audio tracks (mute/normalize), speeds, and overlays. Use "and" to chain edits. +

+
+
+ )} + + {/* Tab 2: Evolution Timeline & Scrubbing */} + {activeTab === "timeline" && ( +
+ {/* Replay Controller Bar */} +
+ + State: {currentStateIndex + 1} / {history.length} + + + {/* Controls */} +
+ + + +
+ + + {isPlayingReplay ? "Playing..." : "Scrub mode"} + +
+ + {/* Scrubbing Slider */} + {history.length > 1 && ( +
+ + onNavigateHistory(Number(e.target.value))} + className="w-full accent-film-600 cursor-pointer h-1.5 rounded-lg bg-[var(--border)]" + aria-label="Timeline scrubbing control" + /> +
+ )} + + {/* Milestone Booking Input Modal-Overlay */} + {bookmarkingStateId !== null && ( +
+
Bookmark State Milestone
+
+ setMilestoneNameInput(e.target.value)} + placeholder="Milestone name, e.g. Cinematic Version" + className="flex-1 px-3 py-1.5 text-xs rounded-lg bg-transparent" + /> + + +
+
+ )} + + {/* Timeline Vertical Stack */} +
+ {/* Vertical line indicator */} +
+ + {history.map((state, idx) => { + const isActive = idx === currentStateIndex; + const dateStr = new Date(state.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + return ( +
+ {/* Bullet marker */} +
+ {state.isMilestone && ( +
+ )} +
+ + {/* Timeline Node Info */} +
+
+ {state.description} + + {state.category} + + {state.isMilestone && ( + + + {state.milestoneName} + + )} +
+ {state.promptText && ( +

Prompt: "{state.promptText}"

+ )} + {dateStr} • Iteration {idx + 1} +
+ + {/* Node Actions */} +
+ + + {idx !== currentStateIndex && ( + + )} +
+
+ ); + })} +
+
+ )} + + {/* Tab 3: Visual Diff & Parameter Comparisons */} + {activeTab === "diff" && ( +
+ {/* Compare Selectors */} +
+
+ + +
+ +
+ + +
+
+ + {/* Differences Table */} +
+
+
Setting
+
Base (A)
+
Compare (B)
+
+ +
+ {baseCompareDiffs.length === 0 ? ( +
+ No parameter differences detected. Both configurations are identical. +
+ ) : ( + baseCompareDiffs.map((d, index) => ( +
+
{d.label}
+
{d.fromVal}
+
+ + {d.toVal} + +
+
+ )) + )} +
+
+
+ )} + + {/* Tab 4: Workflow Analytics & Success Trends */} + {activeTab === "analytics" && ( +
+ {/* Overview statistics cards */} +
+
+ Total Edits + {totalEdits} +
+
+ AI Prompts + {aiEditsCount} +
+
+ Milestones + {milestoneCount} +
+
+ + {/* Category distribution bars */} +
+ + Edit Distribution by Feature Category + + +
+ {(["Layout", "Color", "Text", "Audio", "Speed", "Macro", "Manual"] as const).map((cat) => { + const count = categoryCounts[cat] || 0; + const pct = totalEdits > 0 ? (count / totalEdits) * 100 : 0; + if (count === 0) return null; + + return ( +
+
+ {cat} + {count} ({pct.toFixed(0)}%) +
+
+
+
+
+ ); + })} +
+
+ + {/* Prompt Effectiveness Meter */} +
+
+ + AI Copilot Effectiveness Rating + +

+ Based on AI prompt usage, timeline bookmark milestones, and layout retention. +

+
+ +
+ {totalEdits > 0 ? Math.round(((aiEditsCount + milestoneCount) / totalEdits) * 100) : 0}% +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 1e4e9f0d..eb5b7f0d 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useMemo } from "react"; import { useVideoEditor } from "@/hooks/useVideoEditor"; -import { TextOverlay } from "@/lib/types"; +import { TextOverlay, EditRecipe } from "@/lib/types"; import FileUpload from "./FileUpload"; import VideoPreview from "./VideoPreview"; import ThumbnailStrip from "./ThumbnailStrip"; @@ -26,6 +26,15 @@ import { } from "lucide-react"; import OnboardingTour from "./OnboardingTour"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; +import AICopilotTimeline from "./AICopilotTimeline"; +import { + SessionState, + saveSessionHistory, + loadSessionHistory, + clearSessionHistory, + diffRecipes +} from "@/lib/sessionHistory"; +import { Sparkles } from "lucide-react"; interface SectionProps { icon: React.ReactNode; @@ -225,6 +234,172 @@ export default function VideoEditor() { const [copied, setCopied] = useState(false); const [shareCopied, setShareCopied] = useState(false); + + const [controlMode, setControlMode] = useState<"manual" | "ai">("manual"); + const [history, setHistory] = useState([]); + const [currentStateIndex, setCurrentStateIndex] = useState(0); + + const isAITriggeredRef = useRef(false); + const isInitialLoadRef = useRef(true); + + const fileKey = useMemo(() => (file ? `${file.name}_${file.size}` : ""), [file]); + + // Load/initialize history on file selection or change + useEffect(() => { + if (!file) { + setHistory([]); + setCurrentStateIndex(0); + isInitialLoadRef.current = true; + return; + } + + const savedHistory = loadSessionHistory(fileKey); + const lastSavedState = savedHistory[savedHistory.length - 1]; + if (savedHistory.length > 0 && lastSavedState) { + setHistory(savedHistory); + setCurrentStateIndex(savedHistory.length - 1); + isAITriggeredRef.current = true; + updateRecipe(lastSavedState.recipe); + isInitialLoadRef.current = false; + } else { + const initialRecipe = JSON.parse(JSON.stringify(recipe)); + const initialState: SessionState = { + id: "state_initial", + timestamp: Date.now(), + recipe: initialRecipe, + actionType: "initial", + description: "Original Video Settings", + category: "Initial", + }; + const newHistory = [initialState]; + setHistory(newHistory); + setCurrentStateIndex(0); + saveSessionHistory(fileKey, newHistory); + isInitialLoadRef.current = false; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileKey]); + + // Debounced recording of manual adjustments + useEffect(() => { + if (!file) return; + + // Check if recipe actually changed from current state + const currentState = history[currentStateIndex]; + if (currentState && JSON.stringify(currentState.recipe) === JSON.stringify(recipe)) { + return; + } + + if (isInitialLoadRef.current) { + isInitialLoadRef.current = false; + return; + } + + if (isAITriggeredRef.current) { + isAITriggeredRef.current = false; + return; + } + + const timer = setTimeout(() => { + const lastState = history[history.length - 1]; + let desc = "Manual Adjustments"; + if (lastState) { + const diffs = diffRecipes(lastState.recipe, recipe); + if (diffs.length > 0) { + desc = `Manual: ${diffs.map((d) => d.label).join(", ")}`; + } else { + return; // no actual recipe change + } + } + + const newState: SessionState = { + id: `state_${Date.now()}`, + timestamp: Date.now(), + recipe: JSON.parse(JSON.stringify(recipe)), + actionType: "manual", + description: desc, + category: "Manual", + }; + + const newHistory = [...history.slice(0, currentStateIndex + 1), newState]; + setHistory(newHistory); + setCurrentStateIndex(newHistory.length - 1); + saveSessionHistory(fileKey, newHistory); + }, 1200); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recipe, file]); + + const handleNavigateHistory = (index: number) => { + const targetState = history[index]; + if (index >= 0 && index < history.length && targetState) { + setCurrentStateIndex(index); + isAITriggeredRef.current = true; + updateRecipe(targetState.recipe); + } + }; + + const handleAIUpdateRecipe = ( + newRecipe: EditRecipe, + description: string, + actionType: SessionState["actionType"], + category: SessionState["category"], + promptText?: string + ) => { + isAITriggeredRef.current = true; + updateRecipe(newRecipe); + + const newState: SessionState = { + id: `state_${Date.now()}`, + timestamp: Date.now(), + recipe: JSON.parse(JSON.stringify(newRecipe)), + actionType, + description, + category, + promptText, + }; + + const newHistory = [...history.slice(0, currentStateIndex + 1), newState]; + setHistory(newHistory); + setCurrentStateIndex(newHistory.length - 1); + saveSessionHistory(fileKey, newHistory); + }; + + const handleToggleMilestone = (stateId: string, name?: string) => { + const newHistory = history.map((state) => { + if (state.id === stateId) { + return { + ...state, + isMilestone: !state.isMilestone, + milestoneName: name, + }; + } + return state; + }); + setHistory(newHistory); + saveSessionHistory(fileKey, newHistory); + }; + + const handleReset = () => { + reset(); + if (fileKey) { + clearSessionHistory(fileKey); + } + setHistory([]); + setCurrentStateIndex(0); + isInitialLoadRef.current = true; + }; + + const handleResetSettings = () => { + resetSettings(); + if (fileKey) { + clearSessionHistory(fileKey); + } + setHistory([]); + setCurrentStateIndex(0); + isInitialLoadRef.current = true; + }; const [selectedTextId, setSelectedTextId] = useState(null); const [openSections, setOpenSections] = useState({ resize: true, @@ -254,7 +429,7 @@ export default function VideoEditor() { const encoded = btoa(JSON.stringify(recipe)); const url = new URL(window.location.href); url.searchParams.set("settings", encoded); - history.replaceState(null, "", url.toString()); + window.history.replaceState(null, "", url.toString()); navigator.clipboard.writeText(url.toString()).then(() => { setShareCopied(true); setTimeout(() => setShareCopied(false), 2000); @@ -415,170 +590,212 @@ export default function VideoEditor() { "grid grid-cols-1 gap-4", isProcessing && "pointer-events-none opacity-50" )}> -
- } - title="Trim" - isOpen={openSections.trim} - onToggle={() => toggleSection("trim")} - delay={50} - > - - - - } - title="Rotation" - isOpen={openSections.rotation} - onToggle={() => toggleSection("rotation")} - delay={100} + {/* Control Mode Switcher */} +
+ +
-
- } - title="Audio & Speed" - isOpen={openSections.audio} - onToggle={() => toggleSection("audio")} - delay={150} - > - - -
} - title="Adjustments" - delay={175} - > -
- {/* Brightness */} -
-
- - -
- updateRecipe({ brightness: Number(e.target.value) })} - aria-label="Adjust brightness" - className="w-full accent-film-600" + + {controlMode === "manual" ? ( + <> +
+ } + title="Trim" + isOpen={openSections.trim} + onToggle={() => toggleSection("trim")} + delay={50} + > + -
- {/* Contrast */} -
-
- - -
- updateRecipe({ contrast: Number(e.target.value) })} - aria-label="Adjust contrast" - className="w-full accent-film-600" + + + } + title="Rotation" + isOpen={openSections.rotation} + onToggle={() => toggleSection("rotation")} + delay={100} + > + + + + } + title="Text Overlay" + isOpen={openSections.text} + onToggle={() => toggleSection("text")} + delay={110} + > + -
- {/* Saturation */} -
-
- - + +
+
+ } + title="Audio & Speed" + isOpen={openSections.audio} + onToggle={() => toggleSection("audio")} + delay={150} + > + + +
} + title="Adjustments" + delay={175} + > +
+ {/* Brightness */} +
+
+ + +
+ updateRecipe({ brightness: Number(e.target.value) })} + aria-label="Adjust brightness" + className="w-full accent-film-600" + /> +
+ {/* Contrast */} +
+
+ + +
+ updateRecipe({ contrast: Number(e.target.value) })} + aria-label="Adjust contrast" + className="w-full accent-film-600" + /> +
+ {/* Saturation */} +
+
+ + +
+ updateRecipe({ saturation: Number(e.target.value) })} + aria-label="Adjust saturation" + className="w-full accent-film-600" + /> +
- updateRecipe({ saturation: Number(e.target.value) })} - aria-label="Adjust saturation" - className="w-full accent-film-600" +
+
} title="Output format" delay={190}> + +
+ } + title="Export" + isOpen={openSections.export} + onToggle={() => toggleSection("export")} + delay={200} + > + + +
} title="Image overlay" delay={120}> + -
+
- -
} title="Output format" delay={190}> - -
- } - title="Export" - isOpen={openSections.export} - onToggle={() => toggleSection("export")} - delay={200} - > - - -
} title="Image overlay" delay={120}> - -
-
+ + ) : ( + + )}
)} @@ -607,6 +824,39 @@ export default function VideoEditor() { > {copied ? "Copied!" : "Copy error"} + {history.length > 1 && ( + + )} {!error.includes("Validation Failed") && (
)}
@@ -673,7 +923,7 @@ export default function VideoEditor() {