diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..d6565ce --- /dev/null +++ b/.cursorrules @@ -0,0 +1,75 @@ +# Cursor Rules for Kimu Video Editor + +project: + name: "Kimu Video Editor" + language: "TypeScript/React (React Router) + Python FastAPI" + package_manager: pnpm + +conventions: + - Prefer central Zod schemas in app/schemas/**; do not define inline schemas in components or routes. + - Always validate external boundaries: AI responses, API inputs/outputs, localStorage, drag-and-drop payloads. + - Keep UI components presentational; move parsing/validation to hooks or route loaders/actions when possible. + - Use named exports and barrel files under app/schemas for discoverability. + +imports: + zod: + source: "zod" + identifier: "z" + schemas: + source: "~/schemas" + +editor: + formatting: + - Match existing indentation and code style. + - Avoid reformatting unrelated code during edits. + typescript: + - Prefer explicit types for exported APIs; avoid any. + - Use narrow schemas and safeParse for user/LLM data. + +testing: + - Add schema unit tests when adding complex schemas. + - Validate response shapes in API route tests. + +commit_messages: + - Use scope tags: feat(schemas), refactor(chat), fix(api), chore(tooling). + +typescript_guidelines: + - Always enable strict type checking + - Export interfaces and types from dedicated type files + - Leverage a common module for shared types and utilities when possible + - Use proper type imports from @types packages when available + - Follow framework conventions for typing (e.g., Remix loaders/actions, React types) + - DO NOT use the any type + - Prefer importing types from packages before declaring your own + - Avoid type casting; prefer precise types and narrowing + - Prefer inferring types from Zod schemas using z.infer instead of manual type definitions + - For Zod schemas, prefer .nullish().transform((val) => val ?? undefined) over .optional() for null handling; do not combine .nullish() with .default() + +project_structure: + overview: + - Frontend (Remix/React) lives under app/ + - Backend (FastAPI) lives under backend/ + - Centralized Zod schemas live under app/schemas/** with barrel exports in app/schemas/index.ts + - Database/sql migrations under migrations/ + - Shared UI primitives under app/components/ui/** + - Timeline/editor components under app/components/timeline/** + - Chat/AI components under app/components/chat/** + - Hooks under app/hooks/** + - Utilities under app/utils/** and app/lib/** + +code_organization_rules: + - Keep feature-specific code within its respective directory (timeline, chat, media, etc.) + - Place all Zod schemas under app/schemas/** (components/, apis/, domain files) and import from there (no inline schemas in components/routes) + - Maintain consistent file naming: + - index.ts for barrel exports + - types.ts or types/index.ts for type definitions when schema inference is not applicable + - Remix routes: + - Validate params in loaders/actions with Zod + - Validate request bodies and response payloads (APIs under app/routes/api.*) + - Components: + - Keep presentational; parse/validate data in hooks or route loaders + - Import schemas from app/schemas/components/** + - APIs: + - Import request/response schemas from app/schemas/apis/** + - Validate inputs (safeParse) and outputs (parse) at boundaries + - Prefer z.infer to derive TS types from Zod diff --git a/.env.example b/.env.example index 033692d..6dacc92 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ +NODE_ENV= # production | development VITE_SUPABASE_URL= VITE_SUPABASE_ANON_KEY= DATABASE_URL= GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= \ No newline at end of file +GOOGLE_CLIENT_SECRET= +PROD_DOMAIN= # trykimu.com \ No newline at end of file diff --git a/Dockerfile.frontend b/Dockerfile.frontend index ca8e4c8..98a325f 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -23,7 +23,7 @@ COPY . . RUN pnpm run build # Expose port -EXPOSE 3000 +EXPOSE 5173 # Start the application CMD ["pnpm", "run", "start"] \ No newline at end of file diff --git a/README.md b/README.md index 93dfab6..949b308 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,168 @@ - - -

Kimu

-

Copilot for Video Editing.

-
- -> [!NOTE] -> The application is under active development. This is an early MVP. Please join the [Discord server](https://discord.gg/GSknuxubZK) if you're going to run it. -

- React Video Editor Screenshot + image +

-

An open-source alternative to Capcut, Canva, and RVE.

+

A friendly AI powered open-source alternative to Capcut, Canva.
+ Discord   Twitter   Website

## ✨Features -- 🎬Non Linear Video Editing -- 🔀Multi-track Support -- 👀Live Preview -- 📤Export Video + + + + + + + + + + + +
+ +

Advanced Multi‑Track Editing

+

Edit across unlimited tracks with precise control, snapping, and effortless layer management.

+
+
+ image +

Real‑Time Preview

+

See every change instantly with low‑latency playback—no waiting, no rendering.

+
+
+ +

Fast Export

+

Render high‑quality videos quickly and export exactly where you need them.

+
+
+

Vibe AI Assistant

+

Describe your idea and let Kimu generate edits, timing, and layouts automatically.

+
+
+ +

Smart Media Library

+

Organize by type, tags, and sentiment—search and filter your assets in seconds.

+
+
+ +

Cloud‑Synced Projects

+

Keep timelines and assets in sync across devices so you can pick up right where you left off.

+
+
+

transitions, offline datastore, OAuth based security, change control +
and much more...

+ + +## 💻 Development + + 🐳 Docker Recommended + +**Quick Start:** + +```bash +docker compose -f docker-compose.yml \ + -f docker-compose.dev.yml up -d +``` + +**Ports:** + + + +- Frontend: `5173` +- Backend : `8000` +- FastAPI : `3000` + + +
+ + 🛠️ Local Development + +For local development without Docker: -## 🐋Deployment +```bash +# Install dependencies +pnpm install +# Start services +pnpm run dev # Frontend (port 5173) +pnpm dlx tsx app/videorender/videorender.ts # Backend (port 8000) +uv run backend/main.py # FastAPI (port 3000) + +# Note: You'll need GEMINI_API_KEY for AI features ``` -git clone https://github.com/robinroy03/videoeditor.git -cd videoeditor -docker compose up + +`Requirements` + + + +- Node.js 20+ +- Python 3.9+ +- PostgreSQL +- pnpm + + + + +## 🚀 Production + +**Quick Start:** + +```bash +docker compose up -d ``` -## 🧑‍💻Development +**With Custom Domain:** +```bash +PROD_DOMAIN=yourdomain.com docker compose up -d ``` -pnpm i -pnpm run dev (frontend) -pnpm dlx tsx app/videorender/videorender.ts (backend) -uv run backend/main.py -flip `isProduction` to `false` in `/app/utils/api.ts` -You will also require a GEMINI_API_KEY if you want to use AI. +or alternatively edit `docker-compose.yml` + +**Ports:** + +- HTTP: `80` +- HTTPS: `443` + +## ⚙️ Environment Configuration + +Create a `.env` file for custom settings: + +```env +# Domain Configuration +PROD_DOMAIN=yourdomain.com + +# Database +DATABASE_URL=postgresql://user:pass@localhost:5432/videoeditor + +# Authentication (Google OAuth) +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret + +# AI Features (Optional -> /backend) +GEMINI_API_KEY=your_gemini_api_key + +# Supabase (Optional) +VITE_SUPABASE_URL=your_supabase_url +VITE_SUPABASE_ANON_KEY=your_supabase_key ``` -## 📃TODO +**Environment Variables Explained:** + +- `PROD_DOMAIN`: Your production domain (host only, e.g., `yourdomain.com`) +- `DATABASE_URL`: PostgreSQL connection string +- `GOOGLE_CLIENT_ID/SECRET`: Google OAuth credentials for authentication +- `GEMINI_API_KEY`: Required for AI-powered video editing features +- `VITE_SUPABASE_*`: Optional Supabase integration for additional features -We have a lot of work! For starters, we plan to integrate all Remotion APIs. I'll add a proper roadmap soon. Join the [Discord Server](https://discord.com/invite/GSknuxubZK) for updates and support. +
## ❤️Contribution -We would love your contributions! ❤️ Check the [contribution guide](CONTRIBUTING.md). + We would love your contributions! ❤️ Check the [contribution guide](CONTRIBUTING.md). ## 📜License -This project is licensed under a dual-license. Refer to [LICENSE](LICENSE.md) for details. The [Remotion license](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md) also applies to the relevant parts of the project. + This project is licensed under a dual-license. Refer to [LICENSE](LICENSE.md) for details. The [Remotion license](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md) also applies to the relevant parts of the project. diff --git a/app/app.css b/app/app.css index ca09f38..b358301 100644 --- a/app/app.css +++ b/app/app.css @@ -4,8 +4,9 @@ @custom-variant dark (&:is(.dark *)); @theme { - --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-sans: + "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; } html, @@ -202,6 +203,27 @@ body { max-width: 100%; } + /* Ultra-thin scrollbar specifically for chat tabs strip */ + .chat-tabs-scroll::-webkit-scrollbar { + height: 0px; /* hide horizontal bar */ + } + .chat-tabs-scroll::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 9999px; + } + .chat-tabs-scroll::-webkit-scrollbar-track { + background: transparent; + } + + /* Hide scrollbar utility (cross-browser) */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + /* Prevent horizontal overflow in chat areas */ .chat-container * { max-width: 100%; @@ -332,11 +354,14 @@ body { @keyframes glow-pulse { 0%, 100% { - box-shadow: 0 0 20px rgba(37, 99, 235, 0.1), + box-shadow: + 0 0 20px rgba(37, 99, 235, 0.1), 0 0 40px rgba(37, 99, 235, 0.05); } 50% { - box-shadow: 0 0 30px rgba(37, 99, 235, 0.2), 0 0 60px rgba(37, 99, 235, 0.1); + box-shadow: + 0 0 30px rgba(37, 99, 235, 0.2), + 0 0 60px rgba(37, 99, 235, 0.1); } } @@ -489,7 +514,16 @@ body { } @keyframes indeterminate-slide { - 0% { left: -40%; width: 40%; } - 50% { left: 20%; width: 60%; } - 100% { left: 100%; width: 40%; } -} \ No newline at end of file + 0% { + left: -40%; + width: 40%; + } + 50% { + left: 20%; + width: 60%; + } + 100% { + left: 100%; + width: 40%; + } +} diff --git a/app/components/chat/ChatBox.tsx b/app/components/chat/ChatBox.tsx index 55cf82c..6f46d19 100644 --- a/app/components/chat/ChatBox.tsx +++ b/app/components/chat/ChatBox.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useEffect } from "react"; +import { parseDuration } from "@alwatr/parse-duration"; import { Send, Bot, @@ -11,16 +12,49 @@ import { ChevronLeft, ChevronRight, RotateCcw, + History, + Trash2, + Pencil, + Eraser, + CornerUpLeft, } from "lucide-react"; import { Button } from "~/components/ui/button"; import { - type MediaBinItem, - type TimelineState, - type ScrubberState, -} from "../timeline/types"; + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/components/ui/alert-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { Separator } from "~/components/ui/separator"; +import { type MediaBinItem, type TimelineState, type ScrubberState } from "../timeline/types"; import { cn } from "~/lib/utils"; import axios from "axios"; import { apiUrl } from "~/utils/api"; +import { + AiResponseSchema, + MoveScrubberArgsSchema, + ResizeScrubberArgsSchema, + AddScrubberByNameArgsSchema, + AddMediaByIdArgsSchema, + DeleteScrubbersInTrackArgsSchema, + UpdateTextContentArgsSchema, + UpdateTextStyleArgsSchema, + MoveScrubbersByOffsetArgsSchema, + ChatTabsStorageSchema, +} from "~/schemas/components/chat"; // llm tools import { @@ -28,6 +62,11 @@ import { llmMoveScrubber, llmAddScrubberByName, llmDeleteScrubbersInTrack, + llmResizeScrubber, + llmUpdateTextContent, + llmUpdateTextStyle, + llmMoveScrubbersByOffset, + llmSetResolution, } from "~/utils/llm-handler"; interface Message { @@ -35,16 +74,13 @@ interface Message { content: string; isUser: boolean; timestamp: Date; + snapshot?: TimelineState | null; } interface ChatBoxProps { className?: string; mediaBinItems: MediaBinItem[]; - handleDropOnTrack: ( - item: MediaBinItem, - trackId: string, - dropLeftPx: number - ) => void; + handleDropOnTrack: (item: MediaBinItem, trackId: string, dropLeftPx: number) => string; isMinimized?: boolean; onToggleMinimize?: () => void; messages: Message[]; @@ -52,6 +88,9 @@ interface ChatBoxProps { timelineState: TimelineState; handleUpdateScrubber: (updatedScrubber: ScrubberState) => void; handleDeleteScrubber?: (scrubberId: string) => void; + pixelsPerSecond: number; + handleAddTrack?: () => void; + restoreTimeline?: (state: TimelineState) => void; } export function ChatBox({ @@ -65,6 +104,9 @@ export function ChatBox({ timelineState, handleUpdateScrubber, handleDeleteScrubber, + pixelsPerSecond, + handleAddTrack, + restoreTimeline, }: ChatBoxProps) { const [inputValue, setInputValue] = useState(""); const [isTyping, setIsTyping] = useState(false); @@ -76,11 +118,195 @@ export function ChatBox({ const [textareaHeight, setTextareaHeight] = useState(36); // Starting height for proper size const [sendWithMedia, setSendWithMedia] = useState(false); // Track send mode const [mentionedItems, setMentionedItems] = useState([]); // Store actual mentioned items + const [contextMenu, setContextMenu] = useState<{ + open: boolean; + x: number; + y: number; + index: number; + message?: Message | null; + }>({ open: false, x: 0, y: 0, index: -1, message: null }); + const [showConfirmRestore, setShowConfirmRestore] = useState(false); + const [confirmRestoreIndex, setConfirmRestoreIndex] = useState(null); + const [showConfirmDelete, setShowConfirmDelete] = useState(false); + const [confirmDeleteIndex, setConfirmDeleteIndex] = useState(null); + const [showEdit, setShowEdit] = useState(false); + const [editIndex, setEditIndex] = useState(null); + const [editValue, setEditValue] = useState(""); + const [tabsMenu, setTabsMenu] = useState<{ open: boolean; x: number; y: number; tabId: string | null }>({ + open: false, + x: 0, + y: 0, + tabId: null, + }); + const headerRef = useRef(null); + const [historyWidthPx, setHistoryWidthPx] = useState(null); + const [historyQuery, setHistoryQuery] = useState(""); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [editingTabId, setEditingTabId] = useState(null); + const [editingTabName, setEditingTabName] = useState(""); + const [historyEditingId, setHistoryEditingId] = useState(null); + const [historyEditingName, setHistoryEditingName] = useState(""); + const tabsContainerRef = useRef(null); const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); + const scrollToTabId = (id: string) => { + const container = tabsContainerRef.current; + if (!container) return; + const el = container.querySelector(`[data-tab-id="${id}"]`); + if (!el) return; + const targetLeft = el.offsetLeft - container.clientWidth / 2 + el.clientWidth / 2; + container.scrollTo({ left: Math.max(0, targetLeft), behavior: "smooth" }); + }; const inputRef = useRef(null); const mentionsRef = useRef(null); const sendOptionsRef = useRef(null); + const latestTimelineRef = useRef(timelineState); + const [pendingResizeRequests, setPendingResizeRequests] = useState< + { id: string; durationSeconds: number; pixelsPerSecond: number; trackNumber: number }[] + >([]); + const getProjectIdFromPath = () => { + try { + const m = window.location.pathname.match(/\/project\/([^/]+)/); + return m ? m[1] : "default"; + } catch { + return "default"; + } + }; + const PROJECT_ID = getProjectIdFromPath(); + const STORAGE_KEY = `kimu.chat.tabs.v2.${PROJECT_ID}`; + const ACTIVE_TAB_KEY = `kimu.chat.activeTab.v2.${PROJECT_ID}`; + + const getRecencyGroup = (ts: number) => { + const now = Date.now(); + const diff = now - ts; + const oneHour = 60 * 60 * 1000; + const oneDay = 24 * oneHour; + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + const startOfYesterday = new Date(startOfToday.getTime() - oneDay); + const startOfWeek = new Date(); + startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); + startOfWeek.setHours(0, 0, 0, 0); + + if (diff <= oneHour) return "Last hour"; + if (ts >= startOfToday.getTime()) return "Today"; + if (ts >= startOfYesterday.getTime()) return "Yesterday"; + if (ts >= startOfWeek.getTime()) return "This week"; + return "Older"; + }; + + type ChatTab = { + id: string; + name: string; + messages: Message[]; + timelineSnapshot: TimelineState | null; + createdAt: number; + }; + + const loadTabs = (): ChatTab[] => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.map((t: any) => ({ + id: String(t.id ?? Date.now().toString()), + name: String(t.name ?? "Chat"), + messages: Array.isArray(t.messages) + ? t.messages.map((m: any) => ({ + id: String(m.id ?? Date.now().toString()), + content: String(m.content ?? ""), + isUser: Boolean(m.isUser), + timestamp: m && m.timestamp ? new Date(m.timestamp) : new Date(), + })) + : [], + timelineSnapshot: t.timelineSnapshot ?? null, + createdAt: Number(t.createdAt ?? Date.now()), + })); + } + } catch {} + return []; + }; + + const [tabs, setTabs] = useState(() => { + const existing = loadTabs(); + if (existing.length) return existing; + return [{ id: Date.now().toString(), name: "Chat 1", messages: [], timelineSnapshot: null, createdAt: Date.now() }]; + }); + const [activeTabId, setActiveTabId] = useState(() => { + try { + const stored = localStorage.getItem(ACTIVE_TAB_KEY); + if (stored) return stored; + } catch {} + return tabs[0]?.id || ""; + }); + const activeTab = tabs.find((t) => t.id === activeTabId) || tabs[0]; + + useEffect(() => { + latestTimelineRef.current = timelineState; + }, [timelineState]); + + // Process queued resize requests once the timeline reflects new scrubbers + useEffect(() => { + if (!pendingResizeRequests.length) return; + const tl = latestTimelineRef.current; + const remaining: typeof pendingResizeRequests = []; + for (const req of pendingResizeRequests) { + const trackIndex = Math.max(0, req.trackNumber - 1); + const track = tl.tracks?.[trackIndex]; + const target = track?.scrubbers.find((s) => s.id === req.id); + if (target) { + llmResizeScrubber(target.id, req.durationSeconds, req.pixelsPerSecond, tl, handleUpdateScrubber); + } else { + remaining.push(req); + } + } + if (remaining.length !== pendingResizeRequests.length) { + setPendingResizeRequests(remaining); + } + }, [timelineState]); + + useEffect(() => { + if (activeTabId) { + scrollToTabId(activeTabId); + } + }, [activeTabId]); + + const persistTabs = (next: ChatTab[]) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + localStorage.setItem(ACTIVE_TAB_KEY, activeTabId); + } catch {} + }; + + useEffect(() => { + persistTabs(tabs); + }, [tabs]); + + useEffect(() => { + const updateWidth = () => { + if (headerRef.current) { + const w = headerRef.current.offsetWidth; + setHistoryWidthPx(w); + } + }; + updateWidth(); + window.addEventListener("resize", updateWidth); + return () => window.removeEventListener("resize", updateWidth); + }, []); + + // Ensure activeTabId is valid after tabs change + useEffect(() => { + if (!tabs.find((t) => t.id === activeTabId)) { + setActiveTabId(tabs[0]?.id || ""); + } + }, [tabs, activeTabId]); + + // keep ChatBox external messages prop in sync with active tab + useEffect(() => { + if (!activeTab) return; + onMessagesChange(activeTab.messages); + }, [activeTabId]); // Auto-scroll to bottom when new messages are added useEffect(() => { @@ -92,25 +318,19 @@ export function ChatBox({ // Click outside handler for send options useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if ( - sendOptionsRef.current && - !sendOptionsRef.current.contains(event.target as Node) - ) { + if (sendOptionsRef.current && !sendOptionsRef.current.contains(event.target as Node)) { setShowSendOptions(false); } }; if (showSendOptions) { document.addEventListener("mousedown", handleClickOutside); - return () => - document.removeEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); } }, [showSendOptions]); // Filter media bin items based on mention query - const filteredMentions = mediaBinItems.filter((item) => - item.name.toLowerCase().includes(mentionQuery.toLowerCase()) - ); + const filteredMentions = mediaBinItems.filter((item) => item.name.toLowerCase().includes(mentionQuery.toLowerCase())); // Handle input changes and @ mention detection const handleInputChange = (e: React.ChangeEvent) => { @@ -129,15 +349,9 @@ export function ChatBox({ // Clean up mentioned items that are no longer in the text const mentionPattern = /@(\w+(?:\s+\w+)*)/g; - const currentMentions = Array.from(value.matchAll(mentionPattern)).map( - (match) => match[1] - ); + const currentMentions = Array.from(value.matchAll(mentionPattern)).map((match) => match[1]); setMentionedItems((prev) => - prev.filter((item) => - currentMentions.some( - (mention) => mention.toLowerCase() === item.name.toLowerCase() - ) - ) + prev.filter((item) => currentMentions.some((mention) => mention.toLowerCase() === item.name.toLowerCase())), ); // Check for @ mentions @@ -147,9 +361,7 @@ export function ChatBox({ if (lastAtIndex !== -1) { const afterAt = beforeCursor.slice(lastAtIndex + 1); // Only show mentions if @ is at start or after whitespace, and no spaces after @ - const isValidMention = - (lastAtIndex === 0 || /\s/.test(beforeCursor[lastAtIndex - 1])) && - !afterAt.includes(" "); + const isValidMention = (lastAtIndex === 0 || /\s/.test(beforeCursor[lastAtIndex - 1])) && !afterAt.includes(" "); if (isValidMention) { setMentionQuery(afterAt); @@ -169,8 +381,7 @@ export function ChatBox({ const afterCursor = inputValue.slice(cursorPosition); const lastAtIndex = beforeCursor.lastIndexOf("@"); - const newValue = - beforeCursor.slice(0, lastAtIndex) + `@${item.name} ` + afterCursor; + const newValue = beforeCursor.slice(0, lastAtIndex) + `@${item.name} ` + afterCursor; setInputValue(newValue); setShowMentions(false); @@ -204,125 +415,356 @@ export function ChatBox({ // Add all media items to the items to send itemsToSend = [ ...mentionedItems, - ...mediaBinItems.filter( - (item) => - !mentionedItems.find((mentioned) => mentioned.id === item.id) - ), + ...mediaBinItems.filter((item) => !mentionedItems.find((mentioned) => mentioned.id === item.id)), ]; } + const captureSnapshot = (): TimelineState => JSON.parse(JSON.stringify(latestTimelineRef.current)); + const userMessage: Message = { id: Date.now().toString(), content: messageContent, isUser: true, timestamp: new Date(), + snapshot: captureSnapshot(), }; + const nextTabs = tabs.map((t) => + t.id === activeTab.id + ? { + ...t, + // intelligent one-time auto-rename if this is the first message + name: + (t.messages?.length || 0) === 0 + ? messageContent.length > 24 + ? messageContent.slice(0, 24) + "…" + : messageContent + : t.name, + messages: [...(t.messages || []), userMessage], + } + : t, + ); + setTabs(nextTabs); + onMessagesChange( + (nextTabs.find((tt) => tt.id === activeTab.id)?.messages || []).map((m) => ({ + id: m.id, + content: m.content, + isUser: m.isUser, + timestamp: m.timestamp, + })), + ); - onMessagesChange([...messages, userMessage]); - setInputValue(""); - setMentionedItems([]); // Clear mentioned items after sending - setIsTyping(true); - - // Reset textarea height - if (inputRef.current) { - inputRef.current.style.height = "36px"; // Back to normal height - setTextareaHeight(36); - } + // Build assistant context + const chatHistoryPayload = (nextTabs.find((tt) => tt.id === activeTab.id)?.messages || []).map((m) => ({ + role: m.isUser ? "user" : "assistant", + content: m.content, + timestamp: m.timestamp, + })); try { + setIsTyping(true); // Use the stored mentioned items to get their IDs const mentionedScrubberIds = itemsToSend.map((item) => item.id); - // Build short chat history to give context to the backend - const history = messages.slice(-10).map((m) => ({ - role: m.isUser ? "user" : "assistant", - content: m.content, - timestamp: m.timestamp, - })); - - // Make API call to the backend const response = await axios.post(apiUrl("/ai", true), { message: messageContent, mentioned_scrubber_ids: mentionedScrubberIds, timeline_state: timelineState, mediabin_items: mediaBinItems, - chat_history: history, + chat_history: chatHistoryPayload, }); - const functionCallResponse = response.data; + let functionCallResponse: any; + try { + // Be resilient to provider response shapes; avoid hard Zod failure on client + if (response && typeof response.data === "object" && response.data !== null) { + const data = response.data as any; + if (data.function_call || data.assistant_message) { + functionCallResponse = data; + } else { + functionCallResponse = { assistant_message: "I received an invalid response format from AI." } as any; + } + } else { + functionCallResponse = { assistant_message: "I received an invalid response format from AI." } as any; + } + } catch { + functionCallResponse = { assistant_message: "I received an invalid response format from AI." } as any; + } let aiResponseContent = ""; - // Handle the function call based on function_name + // Handle the function call (universal v2: {function_name, arguments}) if (functionCallResponse.function_call) { const { function_call } = functionCallResponse; + const fn = function_call.function_name; + const args = function_call.arguments || {}; + + const toNumber = (val: unknown): number | undefined => { + if (typeof val === "number") return Number.isFinite(val) ? val : undefined; + if (typeof val === "string") { + const n = parseFloat(val); + return Number.isFinite(n) ? n : undefined; + } + return undefined; + }; + + const toSeconds = (val: unknown): number | undefined => { + if (typeof val === "number") return Number.isFinite(val) ? val : undefined; + if (typeof val !== "string") return undefined; + const raw = val.trim().toLowerCase(); + // Try @alwatr/parse-duration (returns ms) + try { + const ms = (parseDuration as unknown as (v: unknown) => number)(raw); + if (typeof ms === "number" && Number.isFinite(ms)) return ms / 1000; + } catch {} + // Try hh:mm:ss / mm:ss + const colon = raw.match(/^\s*(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?\s*$/); + if (colon) { + const h = colon[3] ? parseFloat(colon[1]) : 0; + const m = colon[3] ? parseFloat(colon[2]) : parseFloat(colon[1]); + const s = colon[3] ? parseFloat(colon[2 + 1]) : parseFloat(colon[2]); + const total = (h || 0) * 3600 + (m || 0) * 60 + (s || 0); + return Number.isFinite(total) ? total : undefined; + } + // Fallback numeric seconds + const n = parseFloat(raw); + return Number.isFinite(n) ? n : undefined; + }; + + const getArg = (obj: Record | undefined, keys: string[]): T | undefined => { + if (!obj) return undefined; + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const value = obj[key]; + if (value !== undefined && value !== null) return value as T; + } + } + return undefined; + }; try { - if (function_call.function_name === "LLMAddScrubberToTimeline") { + if (fn === "LLMAddScrubberToTimeline") { // Find the media item by ID - const mediaItem = mediaBinItems.find( - (item) => item.id === function_call.scrubber_id - ); + const mediaItem = mediaBinItems.find((item) => item.id === (args.scrubber_id as string)); if (!mediaItem) { - aiResponseContent = `❌ Error: Media item with ID "${function_call.scrubber_id}" not found in the media bin.`; + aiResponseContent = `❌ Error: Media item with ID "${args.scrubber_id}" not found in the media bin.`; } else { // Execute the function llmAddScrubberToTimeline( - function_call.scrubber_id, + args.scrubber_id as string, mediaBinItems, - function_call.track_id, - function_call.drop_left_px, - handleDropOnTrack + args.track_id as string, + args.drop_left_px as number, + handleDropOnTrack, ); - aiResponseContent = `✅ Successfully added "${mediaItem.name}" to ${function_call.track_id} at position ${function_call.drop_left_px}px.`; + aiResponseContent = `✅ Successfully added "${mediaItem.name}" to ${args.track_id} at position ${args.drop_left_px}px.`; } - } else if (function_call.function_name === "LLMMoveScrubber") { - // Execute move scrubber operation + } else if (fn === "LLMMoveScrubber" || fn === "MoveScrubber") { + const parsed = MoveScrubberArgsSchema.safeParse(args); + if (!parsed.success) throw new Error("Invalid arguments for MoveScrubber"); + const a = parsed.data; + const posSec = (a.new_position_seconds ?? a.position_seconds ?? a.start_seconds ?? 0) as number; + const destTrack = Number(a.new_track_number ?? a.track_number ?? 1); llmMoveScrubber( - function_call.scrubber_id, - function_call.new_position_seconds, - function_call.new_track_number, - function_call.pixels_per_second, + a.scrubber_id, + posSec, + destTrack, + (a.pixels_per_second as number | undefined) ?? pixelsPerSecond, timelineState, - handleUpdateScrubber + handleUpdateScrubber, ); // Try to locate the scrubber name for a nicer message - const allScrubbers = timelineState.tracks.flatMap( - (t) => t.scrubbers - ); - const moved = allScrubbers.find( - (s) => s.id === function_call.scrubber_id - ); - const movedName = moved ? moved.name : function_call.scrubber_id; - aiResponseContent = `✅ Moved "${movedName}" to track ${function_call.new_track_number} at ${function_call.new_position_seconds}s.`; - } else if (function_call.function_name === "LLMAddScrubberByName") { - // Add media by name with defaults - llmAddScrubberByName( - function_call.scrubber_name, + const allScrubbers = timelineState.tracks.flatMap((t) => t.scrubbers); + const moved = allScrubbers.find((s) => s.id === (args.scrubber_id as string)); + const movedName = moved ? moved.name : (args.scrubber_id as string); + aiResponseContent = `✅ Moved "${movedName}" to track ${args.new_track_number} at ${args.new_position_seconds}s.`; + } else if (fn === "LLMAddScrubberByName" || fn === "AddMediaByName") { + const parsed = AddScrubberByNameArgsSchema.safeParse(args); + if (!parsed.success) throw new Error("Invalid arguments for AddScrubberByName"); + const a = parsed.data; + const name = a.scrubber_name; + const pps = (a.pixels_per_second as number | undefined) ?? pixelsPerSecond; + const startSeconds = (a.start_seconds ?? a.position_seconds ?? 0) as number; + const trackNumber = Number(a.track_number ?? 1); + const startPx = startSeconds * pps; + + const newId = llmAddScrubberByName( + name, mediaBinItems, - function_call.track_number, - function_call.position_seconds, - function_call.pixels_per_second ?? 100, - handleDropOnTrack - ); + trackNumber, + startSeconds, + pps, + handleDropOnTrack, + ) as unknown as string; + + // Optional duration or end time handling (resize after drop) + const endSec = a.end_seconds as number | undefined; + const durationSeconds = + (a.duration_seconds as number | undefined) ?? + (endSec !== undefined ? Math.max(0, endSec - startSeconds) : undefined); + if (durationSeconds && durationSeconds > 0) { + if (newId) { + setPendingResizeRequests((prev) => [ + ...prev, + { id: newId as string, durationSeconds, pixelsPerSecond: pps, trackNumber }, + ]); + } + } + + aiResponseContent = `✅ Added "${name}" to track ${trackNumber} at ${startSeconds}s.`; + } else if (fn === "AddMediaById") { + const parsed = AddMediaByIdArgsSchema.safeParse(args); + if (!parsed.success) throw new Error("Invalid arguments for AddMediaById"); + const a = parsed.data; + const scrubberId = a.scrubber_id; + const pps = (a.pixels_per_second as number | undefined) ?? pixelsPerSecond; + const startSeconds = (a.start_seconds as number | undefined) ?? 0; + const trackNumber = Number(a.track_number ?? 1); + const startPx = startSeconds * pps; + + const mediaItem = mediaBinItems.find((i) => i.id === scrubberId); + if (!mediaItem) { + aiResponseContent = `❌ Error: Media item with ID "${scrubberId}" not found in the media bin.`; + } else { + const trackId = `track-${trackNumber}`; + const newId = handleDropOnTrack(mediaItem, trackId, startPx); - aiResponseContent = `✅ Added "${function_call.scrubber_name}" to track ${function_call.track_number} at ${function_call.position_seconds}s.`; - } else if ( - function_call.function_name === "LLMDeleteScrubbersInTrack" - ) { + const endSec2 = a.end_seconds as number | undefined; + const durationSeconds = + (a.duration_seconds as number | undefined) ?? + (endSec2 !== undefined ? Math.max(0, endSec2 - startSeconds) : undefined); + if (durationSeconds && durationSeconds > 0) { + if (newId) { + setPendingResizeRequests((prev) => [ + ...prev, + { id: newId, durationSeconds, pixelsPerSecond: pps, trackNumber }, + ]); + } + } + + aiResponseContent = `✅ Added media to track ${trackNumber} at ${startSeconds}s.`; + } + } else if (fn === "LLMDeleteScrubbersInTrack" || fn === "DeleteScrubbersInTrack") { if (!handleDeleteScrubber) { throw new Error("Delete handler is not available"); } - llmDeleteScrubbersInTrack( - function_call.track_number, + const parsed = DeleteScrubbersInTrackArgsSchema.safeParse(args); + const trackNum = parsed.success ? Number(parsed.data.track_number ?? 1) : 1; + llmDeleteScrubbersInTrack(trackNum, timelineState, handleDeleteScrubber); + aiResponseContent = `✅ Removed all scrubbers in track ${trackNum}.`; + } else if (fn === "LLMResizeScrubber" || fn === "ResizeScrubber") { + const parsed = ResizeScrubberArgsSchema.safeParse(args); + if (!parsed.success) throw new Error("Invalid arguments for ResizeScrubber"); + const a = parsed.data as any; + const startSecForDiff = (a.start_seconds ?? a.position_seconds) as number | undefined; + const candidateDur = (a.new_duration_seconds ?? + a.duration_seconds ?? + a.seconds ?? + a.duration ?? + a.newDurationSeconds ?? + a.durationInSeconds) as number | undefined; + const endSecVal = a.end_seconds as number | undefined; + const dur = + candidateDur ?? + (startSecForDiff !== undefined && endSecVal !== undefined + ? Math.max(0, endSecVal - startSecForDiff) + : undefined); + const ppsVal = (a.pixels_per_second as number | undefined) ?? pixelsPerSecond; + const trackNum = (a.track_number as number | undefined) ?? (a.new_track_number as number | undefined); + let targetId = typeof a.scrubber_id === "string" ? (a.scrubber_id as string) : undefined; + if (!targetId && trackNum !== undefined) { + const trackIndex = Math.max(0, Math.floor(trackNum) - 1); + const track = timelineState.tracks?.[trackIndex]; + if (track && track.scrubbers.length > 0) { + const nameRaw = a.scrubber_name as string | undefined; + const nameSub = typeof nameRaw === "string" ? nameRaw.toLowerCase() : undefined; + if (nameSub) { + const found = track.scrubbers.find((s) => s.name.toLowerCase().includes(nameSub)); + if (found) targetId = found.id; + } + if (!targetId) { + // fallback to rightmost scrubber + targetId = track.scrubbers.reduce( + (best, s) => (s.left > best.left ? s : best), + track.scrubbers[0], + ).id; + } + } + } + if (dur && dur > 0 && targetId) { + llmResizeScrubber(targetId, dur, ppsVal, timelineState, handleUpdateScrubber); + aiResponseContent = `✅ Resized scrubber to ${dur}s.`; + } else if (!targetId) { + aiResponseContent = `❌ Unable to resize: could not identify target scrubber.`; + } else { + aiResponseContent = `❌ Unable to resize: invalid duration.`; + } + } else if (fn === "LLMUpdateTextContent" || fn === "UpdateTextContent") { + const parsed = UpdateTextContentArgsSchema.safeParse(args); + if (!parsed.success) throw new Error("Invalid arguments for UpdateTextContent"); + llmUpdateTextContent( + parsed.data.scrubber_id, + parsed.data.new_text_content, + timelineState, + handleUpdateScrubber, + ); + aiResponseContent = `✅ Updated text content.`; + } else if (fn === "LLMUpdateTextStyle" || fn === "UpdateTextStyle") { + const parsed = UpdateTextStyleArgsSchema.safeParse(args); + if (!parsed.success) throw new Error("Invalid arguments for UpdateTextStyle"); + const { scrubber_id, ...style } = parsed.data as any; + llmUpdateTextStyle(scrubber_id, style, timelineState, handleUpdateScrubber); + aiResponseContent = `✅ Updated text style.`; + } else if (fn === "LLMMoveScrubbersByOffset" || fn === "MoveScrubbersByOffset") { + const parsed = MoveScrubbersByOffsetArgsSchema.safeParse(args); + if (!parsed.success) throw new Error("Invalid arguments for MoveScrubbersByOffset"); + llmMoveScrubbersByOffset( + parsed.data.scrubber_ids, + parsed.data.offset_seconds as number, + (parsed.data.pixels_per_second as number | undefined) ?? pixelsPerSecond, timelineState, - handleDeleteScrubber + handleUpdateScrubber, ); - aiResponseContent = `✅ Removed all scrubbers in track ${function_call.track_number}.`; + aiResponseContent = `✅ Moved ${parsed.data.scrubber_ids.length} scrubber(s) by ${parsed.data.offset_seconds}s.`; + } else if (fn === "CreateTrack") { + if (handleAddTrack) { + handleAddTrack(); + aiResponseContent = "✅ Created 1 new track."; + } else { + aiResponseContent = "❌ Cannot create track: handler unavailable."; + } + } else if (fn === "CreateTracks") { + const count = toNumber((args as any).count) ?? 1; + if (handleAddTrack) { + const n = Math.max(1, Math.floor(count)); + for (let i = 0; i < n; i++) handleAddTrack(); + aiResponseContent = `✅ Created ${n} track(s).`; + } else { + aiResponseContent = "❌ Cannot create tracks: handler unavailable."; + } + } else if (fn === "PlaceAllAssetsParallel") { + // Place each media bin item on a separate (new if needed) track at the same start time + const startSec = toSeconds((args as any).start_seconds) ?? 0; + const pps = toNumber((args as any).pixels_per_second) ?? pixelsPerSecond; + const startPx = startSec * pps; + const requiredTracks = mediaBinItems.length; + // Ensure enough tracks + const shortage = Math.max(0, requiredTracks - timelineState.tracks.length); + if (shortage > 0 && handleAddTrack) { + for (let i = 0; i < shortage; i++) handleAddTrack(); + } + mediaBinItems.forEach((item, index) => { + const trackId = timelineState.tracks[index]?.id || `track-${index + 1}`; + handleDropOnTrack(item, trackId, startPx); + }); + aiResponseContent = `✅ Placed ${mediaBinItems.length} asset(s) in parallel across tracks at ${startSec}s.`; + } else if (fn === "LLMSetResolution" || fn === "SetResolution") { + // This requires handlers from parent; ChatBox doesn't own them, so we ignore here or bubble up later. + // Leaving placeholder for future wiring if exposed via props. + aiResponseContent = `ℹ️ Resolution change acknowledged.`; } else { - aiResponseContent = `❌ Unknown function: ${function_call.function_name}`; + aiResponseContent = `❌ Unknown function: ${fn}`; } } catch (error) { aiResponseContent = `❌ Error executing function: ${ @@ -341,8 +783,14 @@ export function ChatBox({ content: aiResponseContent, isUser: false, timestamp: new Date(), + snapshot: captureSnapshot(), }; - + const updated = tabs.map((t) => + t.id === activeTab.id + ? { ...t, messages: [...t.messages, userMessage, aiMessage], timelineSnapshot: latestTimelineRef.current } + : t, + ); + setTabs(updated); onMessagesChange([...messages, userMessage, aiMessage]); } catch (error) { console.error("Error calling AI API:", error); @@ -352,8 +800,15 @@ export function ChatBox({ content: `❌ Sorry, I encountered an error while processing your request. Please try again.`, isUser: false, timestamp: new Date(), + snapshot: captureSnapshot(), }; + const updated = tabs.map((t) => + t.id === activeTab.id + ? { ...t, messages: [...t.messages, userMessage, errorMessage], timelineSnapshot: latestTimelineRef.current } + : t, + ); + setTabs(updated); onMessagesChange([...messages, userMessage, errorMessage]); } finally { setIsTyping(false); @@ -364,16 +819,12 @@ export function ChatBox({ if (showMentions && filteredMentions.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); - setSelectedMentionIndex((prev) => - prev < filteredMentions.length - 1 ? prev + 1 : 0 - ); + setSelectedMentionIndex((prev) => (prev < filteredMentions.length - 1 ? prev + 1 : 0)); return; } if (e.key === "ArrowUp") { e.preventDefault(); - setSelectedMentionIndex((prev) => - prev > 0 ? prev - 1 : filteredMentions.length - 1 - ); + setSelectedMentionIndex((prev) => (prev > 0 ? prev - 1 : filteredMentions.length - 1)); return; } if (e.key === "Enter") { @@ -400,47 +851,292 @@ export function ChatBox({ } }; - const formatTime = (date: Date) => { - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + // helpers to update current tab messages consistently + const setActiveTabMessages = (newMessages: Message[]) => { + const updatedTabs = tabs.map((t) => + t.id === activeTab.id ? { ...t, messages: newMessages, timelineSnapshot: latestTimelineRef.current } : t, + ); + setTabs(updatedTabs); + onMessagesChange(newMessages); + }; + + const truncateAtIndexPreserveReply = (index: number) => { + const base = activeTab?.messages || messages; + if (index < 0 || index >= base.length) return; + const keepUntil = base[index + 1] && !base[index + 1].isUser ? index + 1 : index; + setActiveTabMessages(base.slice(0, keepUntil + 1)); + }; + + const restoreAtIndex = (index: number) => { + const base = activeTab?.messages || messages; + if (index < 0 || index >= base.length) return; + const msg = base[index]; + const snap = msg?.snapshot || null; + if (snap && restoreTimeline) restoreTimeline(snap); + truncateAtIndexPreserveReply(index); + }; + + const startInlineEditAt = (index: number) => { + const base = activeTab?.messages || messages; + const msg = base[index]; + if (!msg) return; + // auto-restore to saved snapshot and truncate + restoreSnapshot?.(); + truncateAtIndexPreserveReply(index); + setInputValue(msg.content); + setTimeout(() => inputRef.current?.focus(), 0); + }; + + const formatTime = (dateLike: unknown) => { + try { + const d = dateLike instanceof Date ? dateLike : new Date(dateLike as any); + if (!(d instanceof Date) || isNaN(d.getTime())) return ""; + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } catch { + return ""; + } + }; + + // Tab actions + const createTab = () => { + const t: ChatTab = { + id: Date.now().toString(), + name: `Chat ${tabs.length + 1}`, + messages: [], + timelineSnapshot: null, + createdAt: Date.now(), + }; + const next = [...tabs, t]; + setTabs(next); + setActiveTabId(t.id); + }; + const renameTab = (id: string) => { + const name = prompt("Rename chat", tabs.find((x) => x.id === id)?.name || "Chat"); + if (!name) return; + setTabs(tabs.map((t) => (t.id === id ? { ...t, name } : t))); + }; + const deleteTab = (id: string) => { + const next = tabs.filter((t) => t.id !== id); + setTabs( + next.length + ? next + : [{ id: Date.now().toString(), name: "Chat 1", messages: [], timelineSnapshot: null, createdAt: Date.now() }], + ); + if (activeTabId === id) setActiveTabId((next[0] || { id: "" }).id); + }; + const saveSnapshot = () => { + setTabs(tabs.map((t) => (t.id === activeTab.id ? { ...t, timelineSnapshot: latestTimelineRef.current } : t))); + }; + const restoreSnapshot = () => { + const snap = activeTab.timelineSnapshot; + if (!snap || !restoreTimeline) return; + restoreTimeline(snap); + }; + + // Send to new chat helper + const [sendToTabId, setSendToTabId] = useState(null); + const sendMessageToNewChat = (includeAllMedia = false) => { + const newTab: ChatTab = { + id: Date.now().toString(), + name: `Chat ${tabs.length + 1}`, + messages: [], + timelineSnapshot: null, + createdAt: Date.now(), + }; + const next = [...tabs, newTab]; + setTabs(next); + setActiveTabId(newTab.id); + setSendToTabId(newTab.id); + // Slight delay to allow state to settle before sending + setTimeout(() => handleSendMessage(includeAllMedia), 0); }; return ( -
+
{/* Chat Header */} -
-
- - Ask Kimu -
- -
- - {onToggleMinimize && ( +
+ {/* Row 1: brand + actions */} +
+
+ + Ask Kimu +
+
+ - )} + {onToggleMinimize && ( + + )} +
+
+ {/* Row 2: tabs strip (single-line, horizontally scrollable) */} +
+
+ {tabs.map((t) => ( + + ))} +
+ {/* History panel centered (no blur overlay) */} + {isHistoryOpen && ( + <> + {/* slight dark/blur overlay only over the chat panel */} +
setIsHistoryOpen(false)} + /> +
+
+ setHistoryQuery(e.target.value)} + /> +
+
+ {(() => { + const filtered = tabs + .map((t) => ({ + ...t, + lastActivity: (t.messages?.[t.messages.length - 1]?.timestamp as any)?.getTime?.() || t.createdAt, + })) + .filter((t) => t.name.toLowerCase().includes(historyQuery.toLowerCase())) + .sort((a, b) => b.lastActivity - a.lastActivity); + + const groups: Record = {} as any; + filtered.forEach((t) => { + const g = getRecencyGroup(t.lastActivity); + if (!groups[g]) groups[g] = [] as any; + groups[g].push(t); + }); + + const order = ["Last hour", "Today", "Yesterday", "This week", "Older"]; + return order + .filter((g) => groups[g] && groups[g].length) + .map((g) => ( +
+
{g}
+ {groups[g].map((t) => ( +
{ + setActiveTabId(t.id); + setIsHistoryOpen(false); + scrollToTabId(t.id); + }}> + + {historyEditingId === t.id ? ( + setHistoryEditingName(e.target.value)} + onClick={(e) => e.stopPropagation()} + onBlur={() => { + const name = historyEditingName.trim(); + if (name) setTabs(tabs.map((x) => (x.id === t.id ? { ...x, name } : x))); + setHistoryEditingId(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") (e.currentTarget as HTMLInputElement).blur(); + if (e.key === "Escape") setHistoryEditingId(null); + }} + /> + ) : ( + t.name + )} + +
+ + +
+
+ ))} +
+ )); + })()} +
+
+ + )} + {/* Content Area */}
{messages.length === 0 ? ( @@ -451,8 +1147,8 @@ export function ChatBox({

Ask Kimu

- Kimu is your AI assistant for video editing. Ask questions, get - help with timeline operations, or request specific edits. + Kimu is your AI assistant for video editing. Ask questions, get help with timeline operations, or request + specific edits.

@@ -460,19 +1156,13 @@ export function ChatBox({ to chat with media
- - Enter - + Enter to send
- - Shift - + Shift + - - Enter - + Enter for new line
@@ -482,47 +1172,52 @@ export function ChatBox({
+ style={{ maxHeight: "calc(100vh - 200px)" }}>
- {messages.map((message) => ( + {(activeTab?.messages || messages).map((message, idx) => (
-
-
- {!message.isUser && ( - - )} -
-

- {message.content} -

- - {formatTime(message.timestamp)} - + className={`flex ${message.isUser ? "justify-end" : "justify-start"}`} + onContextMenu={(e) => { + e.preventDefault(); + setContextMenu({ open: true, x: e.clientX, y: e.clientY, index: idx, message }); + }}> + {message.isUser ? ( +
+
+
+

{message.content}

+
+ {formatTime(message.timestamp)} + +
+
- {message.isUser && ( - - )}
-
+ ) : ( +
+
+
+

{message.content}

+
+ {formatTime(message.timestamp)} +
+
+
+
+ )}
))} {/* Typing Indicator */} {isTyping && (
-
+
@@ -546,6 +1241,31 @@ export function ChatBox({ {/* Invisible element to scroll to */}
+ {/* Simple custom context menu */} + {contextMenu.open && ( +
setContextMenu({ ...contextMenu, open: false })}> +
{ + setContextMenu({ ...contextMenu, open: false }); + startInlineEditAt(contextMenu.index); + }}> + Edit here (inline) +
+
{ + setContextMenu({ ...contextMenu, open: false }); + setConfirmRestoreIndex(contextMenu.index); + setShowConfirmRestore(true); + }}> + Restore to this point +
+
+ )}
)} @@ -557,18 +1277,14 @@ export function ChatBox({ {showMentions && filteredMentions.length > 0 && (
+ className="absolute bottom-full left-4 right-4 mb-2 bg-background border border-border/50 rounded-lg shadow-lg max-h-40 overflow-y-auto z-50"> {filteredMentions.map((item, index) => (
insertMention(item)} - > + onClick={() => insertMention(item)}>
{item.mediaType === "video" ? ( @@ -579,9 +1295,7 @@ export function ChatBox({ )}
{item.name} - - {item.mediaType} - + {item.mediaType}
))}
@@ -591,8 +1305,7 @@ export function ChatBox({ {showSendOptions && (
+ className="absolute bottom-full right-4 mb-2 bg-background border border-border/50 rounded-md shadow-lg z-50 min-w-48">
+ }}> Send - - Enter - + Enter
+ }}> Send with all Media
{ // Clear current messages and send to new chat - onMessagesChange([]); setShowSendOptions(false); - handleSendMessage(false); - }} - > + sendMessageToNewChat(false); + }}> Send to New Chat
@@ -643,7 +1350,7 @@ export function ChatBox({ placeholder="Ask Kimu..." className={cn( "w-full min-h-8 max-h-20 resize-none text-xs bg-transparent border-0 px-3 pt-2.5 pb-1 placeholder:text-muted-foreground/60 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50", - "transition-all duration-200 leading-relaxed" + "transition-all duration-200 leading-relaxed", )} disabled={isTyping} rows={1} @@ -659,12 +1366,8 @@ export function ChatBox({ className="h-6 w-6 p-0 text-muted-foreground/70 hover:text-foreground hover:bg-muted/50" onClick={() => { if (inputRef.current) { - const cursorPos = - inputRef.current.selectionStart || inputValue.length; - const newValue = - inputValue.slice(0, cursorPos) + - "@" + - inputValue.slice(cursorPos); + const cursorPos = inputRef.current.selectionStart || inputValue.length; + const newValue = inputValue.slice(0, cursorPos) + "@" + inputValue.slice(cursorPos); setInputValue(newValue); const newCursorPos = cursorPos + 1; setCursorPosition(newCursorPos); @@ -676,14 +1379,10 @@ export function ChatBox({ setTimeout(() => { inputRef.current?.focus(); - inputRef.current?.setSelectionRange( - newCursorPos, - newCursorPos - ); + inputRef.current?.setSelectionRange(newCursorPos, newCursorPos); }, 0); } - }} - > + }}> @@ -694,8 +1393,7 @@ export function ChatBox({ disabled={!inputValue.trim() || isTyping} size="sm" className="h-6 px-2 bg-transparent hover:bg-primary/10 text-primary hover:text-primary text-xs" - variant="ghost" - > + variant="ghost">
+ {/* Modals for restore/delete/edit */} + + + + Restore to this point? + + The timeline will be restored to the snapshot saved for this chat. Messages after this point can be + deleted optionally. + + + + Cancel + { + setShowConfirmRestore(false); + if (confirmRestoreIndex !== null) restoreAtIndex(confirmRestoreIndex); + }}> + Restore + + + + + + {/* Tabs context menu: Rename / Clear / Delete */} + {tabsMenu.open && ( +
setTabsMenu({ ...tabsMenu, open: false })}> +
{ + setTabsMenu({ ...tabsMenu, open: false }); + if (!tabsMenu.tabId) return; + const t = tabs.find((x) => x.id === tabsMenu.tabId); + if (!t) return; + setEditingTabId(t.id); + setEditingTabName(t.name); + }}> + Rename chat +
+
{ + setTabsMenu({ ...tabsMenu, open: false }); + if (!tabsMenu.tabId) return; + if (tabsMenu.tabId === activeTab.id) { + setActiveTabMessages([]); + } else { + setTabs(tabs.map((t) => (t.id === tabsMenu.tabId ? { ...t, messages: [] } : t))); + } + }}> + Clear chat +
+
{ + setTabsMenu({ ...tabsMenu, open: false }); + if (!tabsMenu.tabId) return; + deleteTab(tabsMenu.tabId); + }}> + Delete chat +
+
+ )}
); } diff --git a/app/components/editor/LeftPanel.tsx b/app/components/editor/LeftPanel.tsx index 5e9002c..8cbac86 100644 --- a/app/components/editor/LeftPanel.tsx +++ b/app/components/editor/LeftPanel.tsx @@ -14,7 +14,7 @@ interface LeftPanelProps { fontFamily: string, color: string, textAlign: "left" | "center" | "right", - fontWeight: "normal" | "bold" + fontWeight: "normal" | "bold", ) => void; contextMenu: { x: number; @@ -25,6 +25,13 @@ interface LeftPanelProps { handleDeleteFromContext: () => void; handleSplitAudioFromContext: () => void; handleCloseContextMenu: () => void; + // When true, renders the horizontal tab headers (default). Set false to hide headers + showTabs?: boolean; + // Persisted MediaBin view state + arrangeMode?: "default" | "group"; + sortBy?: "default" | "name_asc" | "name_desc"; + onArrangeModeChange?: (mode: "default" | "group") => void; + onSortByChange?: (sort: "default" | "name_asc" | "name_desc") => void; } export default function LeftPanel({ @@ -37,6 +44,11 @@ export default function LeftPanel({ handleDeleteFromContext, handleSplitAudioFromContext, handleCloseContextMenu, + showTabs = true, + arrangeMode, + sortBy, + onArrangeModeChange, + onSortByChange, }: LeftPanelProps) { const location = useLocation(); @@ -54,40 +66,39 @@ export default function LeftPanel({
{/* Tab Headers */} -
- - - - - - - - - - - - - - - - - -
+ {showTabs && ( +
+ + + + + + + + + + + + + + + + + +
+ )} {/* Tab Content */} -
+
diff --git a/app/components/media/TextEditor.tsx b/app/components/media/TextEditor.tsx index 835706d..1e31167 100644 --- a/app/components/media/TextEditor.tsx +++ b/app/components/media/TextEditor.tsx @@ -54,15 +54,15 @@ export default function TextEditor() { return (
-
+
- +
Text Properties
- + {/* Text Content */}
diff --git a/app/components/media/Transitions.tsx b/app/components/media/Transitions.tsx index fc5e6b6..dfb60c6 100644 --- a/app/components/media/Transitions.tsx +++ b/app/components/media/Transitions.tsx @@ -1,180 +1,181 @@ import React, { useState } from "react"; import { Card, CardContent } from "~/components/ui/card"; import { FPS } from "../timeline/types"; +import { TransitionDragPayloadSchema } from "~/schemas"; import { generateUUID } from "~/utils/uuid"; // Data router loader (no data needed, ensures route is compatible with data router) export function loader() { - return null; + return null; } type TransitionType = { - type: "fade" | "wipe" | "clockWipe" | "slide" | "flip" | "iris"; - name: string; - description: string; + type: "fade" | "wipe" | "clockWipe" | "slide" | "flip" | "iris"; + name: string; + description: string; }; const transitionTypes: TransitionType[] = [ - { - type: "fade", - name: "fade()", - description: "Animate the opacity of the scenes", - }, - { - type: "slide", - name: "slide()", - description: "Slide in and push out the previous scene", - }, - { - type: "wipe", - name: "wipe()", - description: "Slide over the previous scene", - }, - { - type: "flip", - name: "flip()", - description: "Rotate the previous scene", - }, - { - type: "clockWipe", - name: "clockWipe()", - description: "Reveal the new scene in a circular movement", - }, - { - type: "iris", - name: "iris()", - description: "Reveal the scene through a circular mask from center", - }, + { + type: "fade", + name: "fade()", + description: "Animate the opacity of the scenes", + }, + { + type: "slide", + name: "slide()", + description: "Slide in and push out the previous scene", + }, + { + type: "wipe", + name: "wipe()", + description: "Slide over the previous scene", + }, + { + type: "flip", + name: "flip()", + description: "Rotate the previous scene", + }, + { + type: "clockWipe", + name: "clockWipe()", + description: "Reveal the new scene in a circular movement", + }, + { + type: "iris", + name: "iris()", + description: "Reveal the scene through a circular mask from center", + }, ]; -const TransitionThumbnail = ({ transition, isSelected, onClick }: { - transition: TransitionType; - isSelected: boolean; - onClick: () => void; +const TransitionThumbnail = ({ + transition, + isSelected, + onClick, +}: { + transition: TransitionType; + isSelected: boolean; + onClick: () => void; }) => { - const handleDragStart = (e: React.DragEvent) => { - const transitionData = { - id: generateUUID(), - type: "transition", - presentation: transition.type, - timing: "linear", - durationInFrames: 1 * FPS, - leftScrubberId: null, - rightScrubberId: null, - }; - e.dataTransfer.setData("application/json", JSON.stringify(transitionData)); - e.dataTransfer.effectAllowed = "copy"; + const handleDragStart = (e: React.DragEvent) => { + const transitionData = { + id: generateUUID(), + type: "transition", + presentation: transition.type, + timing: "linear", + durationInFrames: 1 * FPS, + leftScrubberId: null, + rightScrubberId: null, }; + // Validate payload via centralized schema + const payload = TransitionDragPayloadSchema.parse(transitionData); + e.dataTransfer.setData("application/json", JSON.stringify(payload)); + e.dataTransfer.effectAllowed = "copy"; + }; - const renderTransitionEffect = () => { - const baseClasses = "absolute rounded-sm"; - - switch (transition.type) { - case "fade": - return ( - <> -
-
- - ); - case "slide": - return ( - <> -
-
- - ); - case "wipe": - return ( - <> -
-
- - ); - case "flip": - return ( - <> -
-
- - ); - case "clockWipe": - return ( - <> -
-
- - ); - case "iris": - return ( - <> -
-
- - ); - } - }; + const renderTransitionEffect = () => { + const baseClasses = "absolute rounded-sm"; + + switch (transition.type) { + case "fade": + return ( + <> +
+
+ + ); + case "slide": + return ( + <> +
+
+ + ); + case "wipe": + return ( + <> +
+
+ + ); + case "flip": + return ( + <> +
+
+ + ); + case "clockWipe": + return ( + <> +
+
+ + ); + case "iris": + return ( + <> +
+
+ + ); + } + }; - return ( - - -
- {/* Thumbnail */} -
- {renderTransitionEffect()} -
+ return ( + + +
+ {/* Thumbnail */} +
{renderTransitionEffect()}
- {/* Title and description */} -
-
- - {transition.name} - -
-

- {transition.description} -

-
-
-
-
- ); + {/* Title and description */} +
+
+ {transition.name} +
+

{transition.description}

+
+
+
+
+ ); }; export default function Transitions() { - const [selectedTransition, setSelectedTransition] = useState(null); + const [selectedTransition, setSelectedTransition] = useState(null); - return ( -
-
- {/* Transitions Grid */} -
- {transitionTypes.map((transition) => ( - setSelectedTransition(transition.type)} - /> - ))} -
-
+ return ( +
+
+ {/* Transitions Grid */} +
+ {transitionTypes.map((transition) => ( + setSelectedTransition(transition.type)} + /> + ))}
- ); +
+
+ ); } diff --git a/app/components/timeline/MediaBin.tsx b/app/components/timeline/MediaBin.tsx index 454fe96..bbaf085 100644 --- a/app/components/timeline/MediaBin.tsx +++ b/app/components/timeline/MediaBin.tsx @@ -44,7 +44,7 @@ interface MediaBinProps { fontFamily: string, color: string, textAlign: "left" | "center" | "right", - fontWeight: "normal" | "bold" + fontWeight: "normal" | "bold", ) => void; contextMenu: { x: number; @@ -55,38 +55,33 @@ interface MediaBinProps { handleDeleteFromContext: () => Promise; handleSplitAudioFromContext: () => Promise; handleCloseContextMenu: () => void; + // Persisted UI state from parent so grouping/sorting survives tab switches + arrangeModeExternal?: "default" | "group"; + sortByExternal?: "default" | "name_asc" | "name_desc"; + onArrangeModeChange?: (mode: "default" | "group") => void; + onSortByChange?: (sort: "default" | "name_asc" | "name_desc") => void; } // Memoized component for video thumbnails to prevent flickering -const VideoThumbnail = memo( - ({ - mediaUrl, - width, - height, - }: { - mediaUrl: string; - width: number; - height: number; - }) => { - const VideoComponent = useMemo(() => { - return () =>