diff --git a/.dockerignore b/.dockerignore index 919559f6..2f13b9d7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,22 @@ node_modules -build .react-router +build +dist out +coverage *.log -.env .git .gitignore +.env +.env.* +!.env.example README.md docker-compose.yml -Dockerfile* \ No newline at end of file +docker-compose.dev.yml +Dockerfile* +.github +.vscode +.idea +*.bak +*.tmp +*.temp diff --git a/.env.example b/.env.example index e0258e67..0e58d0c8 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,22 @@ +# Local/Docker Postgres: postgresql://videoeditor:videoeditor@localhost:5432/videoeditor +# Supabase Session Pooler: postgresql://postgres.REF:password@aws-0-REGION.pooler.supabase.com:5432/postgres DATABASE_URL= -VITE_GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= -JWT_SECRET= # for the jwt secret, you can use `openssl rand -hex 32` to generate a random string +DATABASE_SSL= # Set to "true" when using Supabase or any remote DB that requires SSL. Omit for local/Docker Postgres. + +REDIS_URL= # redis://localhost:6379/0 (required for render queue) + +BETTER_AUTH_SECRET= # openssl rand -hex 32 +BETTER_AUTH_URL= # https://trykimu.com (production) or http://localhost:5173 (dev) +GOOGLE_CLIENT_ID= # Google OAuth client ID +GOOGLE_CLIENT_SECRET= # Google OAuth client secret GEMINI_API_KEY= + +# NODE_ENV=production # Set to "production" to disable uvicorn hot-reload + +# Cloudflare R2 object storage +R2_ACCOUNT_ID= # From Cloudflare dashboard → R2 → Overview +R2_ACCESS_KEY_ID= # R2 API token access key +R2_SECRET_ACCESS_KEY= # R2 API token secret key +R2_ASSETS_BUCKET= +R2_RENDERS_BUCKET= + diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c5479a6b..8513f133 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,7 +8,7 @@ on: jobs: frontend: - name: Frontend ESLint and TS + name: Frontend ESLint, TS, Format, Build runs-on: ubuntu-latest steps: - name: Checkout @@ -30,16 +30,19 @@ jobs: run: pnpm install --frozen-lockfile - name: Generate router types & TS typecheck (tsc) - run: pnpm run typecheck --noEmit + run: pnpm run typecheck - - name: ESLint - run: pnpm eslint . --ext .ts,.tsx --fix + - name: ESLint (read-only) + run: pnpm eslint . --max-warnings 0 - - name: Prettier format (auto-fix) - run: pnpm format + - name: Prettier format check + run: pnpm format:check + + - name: Build (production SSR bundle) + run: pnpm run build backend: - name: Backend Ruff and mypy + name: Backend Ruff, format, mypy runs-on: ubuntu-latest defaults: run: @@ -54,14 +57,14 @@ jobs: enable-cache: true python-version: "3.12" - - name: Install deps (with dev extras) - run: uv sync --extra dev --dev + - name: Install deps (with dev group) + run: uv sync --dev - name: Ruff check run: uv run python -m ruff check . - - name: Ruff format (auto-fix) - run: uv run python -m ruff format . + - name: Ruff format check + run: uv run python -m ruff format --check . - name: mypy run: uv run mypy . diff --git a/Dockerfile.backend b/Dockerfile.backend index 39f2d9fe..48178a82 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -1,41 +1,41 @@ -# Backend Dockerfile +# Node "renderer" service — runs Remotion render jobs via a small Express server. +# Single stage: Chromium runtime deps and the Node app share the same image. FROM node:22-bookworm-slim + WORKDIR /app -# Install Chrome dependencies -RUN apt-get update -RUN apt install -y \ - libnss3 \ - libdbus-1-3 \ - libatk1.0-0 \ - libgbm-dev \ - libasound2 \ - libxrandr2 \ - libxkbcommon-dev \ - libxfixes3 \ - libxcomposite1 \ - libxdamage1 \ - libatk-bridge2.0-0 \ - libpango-1.0-0 \ - libcairo2 \ - libcups2 - -# Install pnpm -RUN npm install -g pnpm - -# Copy package files and install dependencies +# Remotion / Chromium runtime dependencies. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnss3 \ + libdbus-1-3 \ + libatk1.0-0 \ + libgbm-dev \ + libasound2 \ + libxrandr2 \ + libxkbcommon-dev \ + libxfixes3 \ + libxcomposite1 \ + libxdamage1 \ + libatk-bridge2.0-0 \ + libpango-1.0-0 \ + libcairo2 \ + libcups2 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +RUN corepack enable + COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile -# Copy source code COPY . . +RUN mkdir -p /app/out && chown -R node:node /app -# Create output directory for rendered videos -RUN mkdir -p /app/out +USER node -# Expose port EXPOSE 8000 -# Start the backend -CMD ["pnpm", "dlx", "tsx", "app/videorender/videorender.ts"] \ No newline at end of file +# Renderer is deployed to a separate host; use `pnpm dlx tsx` so the binary is fetched on that +# system rather than baked into the image (keeps tsx out of node_modules at install time). +CMD ["pnpm", "dlx", "tsx", "app/videorender/videorender.ts"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend index ca8e4c83..2f679631 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -1,29 +1,38 @@ -# Frontend Dockerfile +# Multi-stage frontend build. Builder installs full deps and produces the React Router SSR bundle; +# runtime ships only production deps + the built artifact. + +# ─── Build stage ───────────────────────────────────────────────────────────── +FROM node:20.18-bookworm-slim AS builder -FROM node:20-bookworm-slim WORKDIR /app -# Install pnpm -RUN npm install -g pnpm +# Install pnpm directly; Node 20's bundled corepack can fail signature verification in Docker. +RUN npm install -g pnpm@9.15.9 -# Copy package files and install dependencies COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile -# Build-time args for Vite env injection -ARG VITE_SUPABASE_URL -ARG VITE_SUPABASE_ANON_KEY - -# Expose VITE_* to the build step (and keep at runtime for client hydration if needed) -ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL \ - VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY - -# Copy source code and build COPY . . RUN pnpm run build -# Expose port +# Drop dev deps so the next stage can copy a lean node_modules. +RUN pnpm prune --prod + +# ─── Runtime stage ─────────────────────────────────────────────────────────── +FROM node:20.18-bookworm-slim AS runtime + +WORKDIR /app + +RUN npm install -g pnpm@9.15.9 + +COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/build ./build +COPY --from=builder /app/public ./public + +# Run as the unprivileged "node" user shipped in the base image. +USER node + EXPOSE 3000 -# Start the application -CMD ["pnpm", "run", "start"] \ No newline at end of file +CMD ["pnpm", "run", "start"] diff --git a/README.md b/README.md index 5a3a0719..a18589c4 100644 --- a/README.md +++ b/README.md @@ -56,35 +56,124 @@
and much more...

-## 🐋Deployment +## 💻 Development + 🛠️ Local Development + +Only postgres runs in Docker. All three services run directly on your machine — Vite handles proxying so no nginx is needed. + +```bash +# Install dependencies +pnpm i +cd backend && uv sync && cd .. + +# 1. Start postgres +docker compose -f docker-compose.dev.yml up -d + +# 2. Start FastAPI (terminal 1) +cd backend && uv run uvicorn main:app --reload --port 3000 + +# 3. Start renderer (terminal 2) +pnpm dlx tsx app/videorender/videorender.ts + +# 4. Start frontend (terminal 3) +pnpm dev ``` -git clone https://github.com/robinroy03/videoeditor.git -cd videoeditor -docker compose up + +Open **`http://localhost:5173`**. The Vite dev server proxies requests transparently: + + + +- `/backend/*` → FastAPI at `:3000` +- `/renderer/*` → Renderer at `:8000` +- `/*` → React Router SSR (Vite) + + + +`Requirements` + + + +- Node.js 20+ +- Python 3.12+ +- pnpm +- Docker (for postgres only) + + + +## 🚀 Production + +Everything runs in Docker behind nginx. One command: + +```bash +docker compose up -d ``` -## 🧑‍💻Development +**With Custom Domain:** +```bash +PROD_DOMAIN=yourdomain.com docker compose up -d ``` -docker compose -f docker-compose.dev.yml up -migrate the db (docker exec -i videoeditor-postgres-dev psql -U videoeditor -d videoeditor -f /dev/stdin < migrations/000_init.sql) -pnpm run dev (frontend) -pnpm dlx tsx app/videorender/videorender.ts (backend) -cd backend -uv run main.py - -localhost:8080 for the server + +nginx routes: + + + +- `/backend/*` → FastAPI +- `/renderer/*` → Renderer (video rendering) +- `/*` → Frontend (React Router SSR) + + + +**Ports:** + +- HTTP: `80` (redirects to HTTPS) +- HTTPS: `443` + +## ⚙️ Environment Configuration + +Copy `.env.example` to `.env` and fill in your values: + +```env +# Local/Docker Postgres (dev or self-hosted prod): +DATABASE_URL=postgresql://videoeditor:videoeditor@localhost:5432/videoeditor + +# — OR — Supabase Session Pooler (cloud): +DATABASE_URL=postgresql://postgres.REF:password@aws-0-REGION.pooler.supabase.com:5432/postgres +DATABASE_SSL=true # required for Supabase; omit for local/Docker Postgres + +# BetterAuth +BETTER_AUTH_SECRET= # generate with: openssl rand -hex 32 +BETTER_AUTH_URL=https://yourdomain.com # http://localhost:5173 for dev + +# Google OAuth +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret + +# AI Features (optional) +GEMINI_API_KEY=your_gemini_api_key ``` +**Environment Variables Explained:** + +- `DATABASE_URL`: PostgreSQL connection string. Use the local Docker URL for dev/self-hosted prod, or the Supabase Session Pooler URL for cloud. +- `DATABASE_SSL`: Set to `"true"` when connecting to Supabase or any remote DB that requires SSL. Leave unset for local/Docker Postgres. +- `BETTER_AUTH_SECRET`: Random secret used to sign sessions — generate with `openssl rand -hex 32`. +- `BETTER_AUTH_URL`: The public URL of the app. Used by BetterAuth for OAuth callbacks. +- `GOOGLE_CLIENT_ID/SECRET`: Google OAuth credentials — register at [console.cloud.google.com](https://console.cloud.google.com). +- `GEMINI_API_KEY`: Required for AI-powered video editing features. + +
+ ## 📃TODO -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. + 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/components/chat/ChatBox.tsx b/app/components/chat/ChatBox.tsx index 85476600..7cc540a0 100644 --- a/app/components/chat/ChatBox.tsx +++ b/app/components/chat/ChatBox.tsx @@ -11,11 +11,54 @@ import { ChevronLeft, ChevronRight, RotateCcw, + History, + Trash2, + Pencil, + Eraser, + CornerUpLeft, } from "lucide-react"; import { Button } from "~/components/ui/button"; +import { + 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 { z } from "zod"; +import { + AiResponseSchema, + MoveScrubberArgsSchema, + ResizeScrubberArgsSchema, + AddScrubberByNameArgsSchema, + AddMediaByIdArgsSchema, + DeleteScrubbersInTrackArgsSchema, + UpdateTextContentArgsSchema, + UpdateTextStyleArgsSchema, + MoveScrubbersByOffsetArgsSchema, + DeleteScrubberArgsSchema, + SetVolumeArgsSchema, + SetPlaybackSpeedArgsSchema, + SplitScrubberArgsSchema, + CreateTrackArgsSchema, + ChatTabsStorageSchema, +} from "~/schemas/components/chat"; // llm tools import { @@ -23,6 +66,10 @@ import { llmMoveScrubber, llmAddScrubberByName, llmDeleteScrubbersInTrack, + llmResizeScrubber, + llmUpdateTextContent, + llmUpdateTextStyle, + llmMoveScrubbersByOffset, } from "~/utils/llm-handler"; interface Message { @@ -30,12 +77,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[]; @@ -43,6 +91,10 @@ interface ChatBoxProps { timelineState: TimelineState; handleUpdateScrubber: (updatedScrubber: ScrubberState) => void; handleDeleteScrubber?: (scrubberId: string) => void; + handleSplitScrubberAtRuler?: (rulerPositionPx: number, scrubberId: string | null) => number; + pixelsPerSecond: number; + handleAddTrack?: () => void; + restoreTimeline?: (state: TimelineState) => void; } export function ChatBox({ @@ -56,6 +108,10 @@ export function ChatBox({ timelineState, handleUpdateScrubber, handleDeleteScrubber, + handleSplitScrubberAtRuler, + pixelsPerSecond, + handleAddTrack, + restoreTimeline, }: ChatBoxProps) { const [inputValue, setInputValue] = useState(""); const [isTyping, setIsTyping] = useState(false); @@ -67,11 +123,230 @@ 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[] => { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = ChatTabsStorageSchema.safeParse(JSON.parse(raw)); + if (!parsed.success) return []; + return parsed.data.map((t) => ({ + id: t.id, + name: t.name, + messages: t.messages.map((m) => ({ + id: m.id, + content: m.content, + isUser: m.isUser, + timestamp: m.timestamp, + })), + timelineSnapshot: (t.timelineSnapshot as TimelineState | null) ?? null, + createdAt: t.createdAt, + })); + } catch { + return []; + } + }; + + // Tabs init must NOT touch localStorage or Date.now() during render — those values diverge + // between server SSR (no window, no real time) and the first client render, causing hydration + // mismatches. Start with a deterministic empty list and hydrate inside useEffect below. + const [tabs, setTabs] = useState([]); + const [activeTabId, setActiveTabId] = useState(""); + const [isTabsHydrated, setIsTabsHydrated] = useState(false); + + useEffect(() => { + const existing = loadTabs(); + let storedActive = ""; + try { + storedActive = window.localStorage.getItem(ACTIVE_TAB_KEY) ?? ""; + } catch { + // ignore + } + if (existing.length) { + setTabs(existing); + setActiveTabId(storedActive && existing.some((t) => t.id === storedActive) ? storedActive : existing[0].id); + } else { + const fresh: ChatTab = { + id: `${Date.now()}`, + name: "Chat 1", + messages: [], + timelineSnapshot: null, + createdAt: Date.now(), + }; + setTabs([fresh]); + setActiveTabId(fresh.id); + } + setIsTabsHydrated(true); + // One-shot hydration: read localStorage exactly once on mount. ACTIVE_TAB_KEY and loadTabs + // are stable closures over PROJECT_ID and don't need to be re-run on change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + 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); + } + // pendingResizeRequests is read+written here; including it would loop. handleUpdateScrubber is + // a stable parent callback. Only re-run when the timeline updates (the trigger we actually care + // about — new scrubbers appear after timeline state propagates). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timelineState]); + + useEffect(() => { + if (activeTabId) { + scrollToTabId(activeTabId); + } + }, [activeTabId]); + + const persistTabs = (next: ChatTab[]) => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + window.localStorage.setItem(ACTIVE_TAB_KEY, activeTabId); + } catch { + // ignore quota/privacy errors + } + }; + + useEffect(() => { + // Don't write before the hydration effect has populated tabs — otherwise the empty initial + // state would overwrite real saved tabs in localStorage. + if (!isTabsHydrated) return; + persistTabs(tabs); + // persistTabs is a closure that reads activeTabId; we deliberately re-run on tabs OR + // activeTabId so the persisted active-tab pointer stays in sync. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tabs, activeTabId, isTabsHydrated]); + + 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); + }, [activeTab, onMessagesChange]); // Auto-scroll to bottom when new messages are added useEffect(() => { @@ -184,106 +459,409 @@ export function ChatBox({ ]; } + const captureSnapshot = (): TimelineState => structuredClone(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("/ai", { + const response = await axios.post("/backend/ai", { message: messageContent, mentioned_scrubber_ids: mentionedScrubberIds, timeline_state: timelineState, mediabin_items: mediaBinItems, - chat_history: history, + chat_history: chatHistoryPayload, }); - const functionCallResponse = response.data; + // Be resilient to provider response shapes; avoid hard Zod failure on client + const fallbackResponse = { + assistant_message: "I received an invalid response format from AI.", + } satisfies z.infer; + const aiParsed = AiResponseSchema.safeParse(response?.data); + if (!aiParsed.success && import.meta.env?.DEV) { + console.warn("AI response failed schema validation:", aiParsed.error.format(), response?.data); + } + const hasContent = aiParsed.success && (aiParsed.data.function_call || aiParsed.data.assistant_message); + const functionCallResponse: z.infer = + aiParsed.success && hasContent ? aiParsed.data : fallbackResponse; let aiResponseContent = ""; - // Handle the function call based on function_name + // Backend's function_call is flat: { function_name, ...arg fields }. Read fn name, treat the + // rest of the object as the args bag — per-tool zod schemas tolerate the extra function_name. if (functionCallResponse.function_call) { const { function_call } = functionCallResponse; + const fn = function_call.function_name; + const args: Record = function_call; + + 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 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, + 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, ); // 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 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, + 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); + + 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 "${function_call.scrubber_name}" to track ${function_call.track_number} at ${function_call.position_seconds}s.`; - } else if (function_call.function_name === "LLMDeleteScrubbersInTrack") { + 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, timelineState, handleDeleteScrubber); - aiResponseContent = `✅ Removed all scrubbers in track ${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; + const startSecForDiff = a.start_seconds ?? a.position_seconds; + const candidateDur = + a.new_duration_seconds ?? + a.duration_seconds ?? + a.seconds ?? + a.duration ?? + a.newDurationSeconds ?? + a.durationInSeconds; + const endSecVal = a.end_seconds; + const dur = + candidateDur ?? + (startSecForDiff !== undefined && endSecVal !== undefined + ? Math.max(0, endSecVal - startSecForDiff) + : undefined); + const ppsVal = a.pixels_per_second ?? pixelsPerSecond; + const trackNum = a.track_number ?? a.new_track_number; + let targetId = typeof a.scrubber_id === "string" ? a.scrubber_id : 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; + 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; + 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, + handleUpdateScrubber, + ); + 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.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.start_seconds) ?? 0; + const pps = toNumber(args.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 === "LLMDeleteScrubber") { + if (!handleDeleteScrubber) throw new Error("Delete handler unavailable"); + const parsed = DeleteScrubberArgsSchema.safeParse(args); + if (!parsed.success) throw new Error("Invalid arguments for LLMDeleteScrubber"); + const { scrubber_id, scrubber_name } = parsed.data; + const allScrubbers = timelineState.tracks.flatMap((t) => t.scrubbers); + let target = scrubber_id ? allScrubbers.find((s) => s.id === scrubber_id) : undefined; + if (!target && scrubber_name) { + target = allScrubbers.find((s) => s.name.toLowerCase().includes(scrubber_name.toLowerCase())); + } + if (!target) throw new Error(`Clip not found: ${scrubber_id ?? scrubber_name}`); + handleDeleteScrubber(target.id); + aiResponseContent = `✅ Deleted "${target.name}".`; + + } else if (fn === "LLMSetVolume") { + const parsed = SetVolumeArgsSchema.safeParse(args); + if (!parsed.success) throw new Error("Invalid arguments for LLMSetVolume"); + const { scrubber_id, scrubber_name, volume, muted } = parsed.data; + const allScrubbers = timelineState.tracks.flatMap((t) => t.scrubbers); + let target = scrubber_id ? allScrubbers.find((s) => s.id === scrubber_id) : undefined; + if (!target && scrubber_name) { + target = allScrubbers.find((s) => s.name.toLowerCase().includes(scrubber_name.toLowerCase())); + } + if (!target) throw new Error(`Clip not found: ${scrubber_id ?? scrubber_name}`); + handleUpdateScrubber({ ...target, volume: volume as number, muted: muted ?? false }); + aiResponseContent = muted ? `✅ Muted "${target.name}".` : `✅ Set "${target.name}" volume to ${Math.round((volume as number) * 100)}%.`; + + } else if (fn === "LLMSetPlaybackSpeed") { + const parsed = SetPlaybackSpeedArgsSchema.safeParse(args); + if (!parsed.success) throw new Error("Invalid arguments for LLMSetPlaybackSpeed"); + const { scrubber_id, scrubber_name, playback_rate } = parsed.data; + const allScrubbers = timelineState.tracks.flatMap((t) => t.scrubbers); + let target = scrubber_id ? allScrubbers.find((s) => s.id === scrubber_id) : undefined; + if (!target && scrubber_name) { + target = allScrubbers.find((s) => s.name.toLowerCase().includes(scrubber_name.toLowerCase())); + } + if (!target) throw new Error(`Clip not found: ${scrubber_id ?? scrubber_name}`); + handleUpdateScrubber({ ...target, playbackRate: playback_rate as number }); + aiResponseContent = `✅ Set "${target.name}" speed to ${playback_rate}×.`; + + } else if (fn === "LLMSplitScrubber") { + if (!handleSplitScrubberAtRuler) throw new Error("Split handler unavailable"); + const parsed = SplitScrubberArgsSchema.safeParse(args); + if (!parsed.success) throw new Error("Invalid arguments for LLMSplitScrubber"); + const { scrubber_id, scrubber_name, time_seconds } = parsed.data; + const allScrubbers = timelineState.tracks.flatMap((t) => t.scrubbers); + let target = scrubber_id ? allScrubbers.find((s) => s.id === scrubber_id) : undefined; + if (!target && scrubber_name) { + target = allScrubbers.find((s) => s.name.toLowerCase().includes(scrubber_name.toLowerCase())); + } + if (!target) throw new Error(`Clip not found: ${scrubber_id ?? scrubber_name}`); + const rulerPx = (time_seconds as number) * pixelsPerSecond; + const count = handleSplitScrubberAtRuler(rulerPx, target.id); + aiResponseContent = count > 0 + ? `✅ Split "${target.name}" at ${time_seconds}s.` + : `❌ Could not split "${target.name}" — make sure ${time_seconds}s is within the clip.`; + + } else if (fn === "LLMCreateTrack") { + const parsed = CreateTrackArgsSchema.safeParse(args); + const n = parsed.success ? (parsed.data.count ?? 1) : 1; + if (handleAddTrack) { + for (let i = 0; i < Math.max(1, n); i++) handleAddTrack(); + aiResponseContent = `✅ Created ${n} track(s).`; + } else { + aiResponseContent = "❌ Cannot create track: handler unavailable."; + } + + } else if (fn === "LLMSetResolution" || fn === "SetResolution") { + aiResponseContent = `ℹ️ Resolution change is not yet supported via chat.`; } else { - aiResponseContent = `❌ Unknown function: ${function_call.function_name}`; + aiResponseContent = `❌ Unknown function: ${fn}`; } } catch (error) { aiResponseContent = `❌ Error executing function: ${ @@ -302,8 +880,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); @@ -313,8 +897,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); @@ -357,41 +948,298 @@ 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 + : typeof dateLike === "string" || typeof dateLike === "number" + ? new Date(dateLike) + : null; + if (!d || 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) => { + const last = t.messages?.[t.messages.length - 1]?.timestamp; + const lastMs = last instanceof Date ? last.getTime() : t.createdAt; + return { ...t, lastActivity: lastMs }; + }) + .filter((t) => t.name.toLowerCase().includes(historyQuery.toLowerCase())) + .sort((a, b) => b.lastActivity - a.lastActivity); + + const groups: Record = {}; + filtered.forEach((t) => { + const g = getRecencyGroup(t.lastActivity); + if (!groups[g]) groups[g] = []; + 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 ? ( @@ -429,28 +1277,50 @@ export function ChatBox({ className="flex-1 overflow-y-auto p-3 scroll-smooth" style={{ maxHeight: "calc(100vh - 200px)" }}>
- {messages.map((message) => ( -
-
-
- {!message.isUser && } -
-

{message.content}

- {formatTime(message.timestamp)} + {(activeTab?.messages || messages).map((message, idx) => ( +
{ + 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 && (
-
+
@@ -474,6 +1344,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 +
+
+ )}
)} @@ -538,9 +1433,8 @@ export function ChatBox({ className="px-3 py-2 text-xs cursor-pointer hover:bg-muted rounded flex items-center justify-between" onClick={() => { // Clear current messages and send to new chat - onMessagesChange([]); setShowSendOptions(false); - handleSendMessage(false); + sendMessageToNewChat(false); }}> Send to New Chat
@@ -617,6 +1511,71 @@ export function ChatBox({
+ {/* 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/AspectRatioIcon.tsx b/app/components/editor/AspectRatioIcon.tsx new file mode 100644 index 00000000..c54d087a --- /dev/null +++ b/app/components/editor/AspectRatioIcon.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import type { AspectRatioPresetId } from "~/lib/aspect-ratios"; +import { cn } from "~/lib/utils"; + +const ICON_CLASS = "stroke-current fill-none stroke-[1.75]"; + +export function AspectRatioIcon({ + id, + className, + active, +}: { + id: AspectRatioPresetId | "custom"; + className?: string; + active?: boolean; +}) { + const stroke = active ? "stroke-primary" : "stroke-muted-foreground"; + const box = cn(ICON_CLASS, stroke, className); + + if (id === "16:9" || id === "21:9" || id === "4:3") { + const w = id === "21:9" ? 18 : 16; + return ( + + + + ); + } + if (id === "9:16" || id === "4:5") { + const h = id === "4:5" ? 14 : 16; + return ( + + + + ); + } + if (id === "1:1") { + return ( + + + + ); + } + return ( + + + + + ); +} diff --git a/app/components/editor/AspectRatioSelect.tsx b/app/components/editor/AspectRatioSelect.tsx new file mode 100644 index 00000000..0d522f8c --- /dev/null +++ b/app/components/editor/AspectRatioSelect.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { + ASPECT_RATIO_PRESETS, + findAspectPreset, + type AspectRatioPresetId, +} from "~/lib/aspect-ratios"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { AspectRatioIcon } from "./AspectRatioIcon"; +import { cn } from "~/lib/utils"; + +interface AspectRatioSelectProps { + width: number; + height: number; + disabled?: boolean; + onSelectPreset: (presetId: AspectRatioPresetId) => void; +} + +export function AspectRatioSelect({ width, height, disabled, onSelectPreset }: AspectRatioSelectProps) { + const activeId = findAspectPreset(width, height); + + return ( + + ); +} diff --git a/app/components/editor/ExportHistory.tsx b/app/components/editor/ExportHistory.tsx new file mode 100644 index 00000000..2eec2a4f --- /dev/null +++ b/app/components/editor/ExportHistory.tsx @@ -0,0 +1,220 @@ +import React, { useMemo, useState } from "react"; +import { Download, Play, Loader2, Film, ArrowDownAZ, Trash2 } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { type ExportHistoryItem } from "~/hooks/useExportHistory"; + +export type ExportHistorySort = "newest" | "oldest" | "name-asc" | "name-desc"; + +function formatRenderDate(iso: string): string { + try { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(iso)); + } catch { + return iso; + } +} + +function sortHistoryItems(items: ExportHistoryItem[], sort: ExportHistorySort): ExportHistoryItem[] { + const copy = [...items]; + switch (sort) { + case "oldest": + return copy.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + case "name-asc": + return copy.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: "base" })); + case "name-desc": + return copy.sort((a, b) => b.fileName.localeCompare(a.fileName, undefined, { sensitivity: "base" })); + case "newest": + default: + return copy.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } +} + +interface ExportHistoryProps { + items: ExportHistoryItem[]; + loading: boolean; + deletingId: string | null; + onDelete: (renderId: string) => Promise; +} + +export function ExportHistory({ items, loading, deletingId, onDelete }: ExportHistoryProps) { + const [preview, setPreview] = useState(null); + const [sort, setSort] = useState("newest"); + + const sortedItems = useMemo(() => sortHistoryItems(items, sort), [items, sort]); + + const handleDelete = async (item: ExportHistoryItem) => { + const confirmed = window.confirm( + `Delete "${item.fileName}"? This removes the file from storage and cannot be undone.`, + ); + if (!confirmed) return; + const ok = await onDelete(item.id); + if (ok && preview?.id === item.id) { + setPreview(null); + } + }; + + return ( + <> +
+
+
+ Export history +
+ {!loading && items.length > 1 && ( + + )} +
+ + {loading && ( +
+ + Loading… +
+ )} + + {!loading && items.length === 0 && ( +

+ No exports yet. Your completed renders will appear here. +

+ )} + + {!loading && sortedItems.length > 0 && ( +
    + {sortedItems.map((item) => { + const isDeleting = deletingId === item.id; + return ( +
  • + + +
    + + {item.fileName} + + + {formatRenderDate(item.createdAt)} + + + {item.width}×{item.height} · {item.codec.toUpperCase()} + +
    + +
    + + +
    +
  • + ); + })} +
+ )} +
+ + {preview && ( +
+
setPreview(null)} + /> +
+
+
+
+

{preview.fileName}

+

+ {formatRenderDate(preview.createdAt)} +

+
+ +
+
+
+
+ )} + + ); +} diff --git a/app/components/editor/ExportPanel.tsx b/app/components/editor/ExportPanel.tsx new file mode 100644 index 00000000..8cc76326 --- /dev/null +++ b/app/components/editor/ExportPanel.tsx @@ -0,0 +1,471 @@ +import React, { useState, useCallback, useEffect, useMemo } from "react"; +import { Download, Loader2, CheckCircle2, AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { Label } from "~/components/ui/label"; +import { Input } from "~/components/ui/input"; +import { Switch } from "~/components/ui/switch"; +import { Progress } from "~/components/ui/progress"; +import { Separator } from "~/components/ui/separator"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { toast } from "sonner"; +import { type TimelineDataItem, type TimelineState } from "~/components/timeline/types"; +import { type RenderOptions } from "~/hooks/useRenderer"; +import { useExportHistory } from "~/hooks/useExportHistory"; +import { ExportHistory } from "./ExportHistory"; +import { + capExportDimensions, + clampExportCrf, + defaultExportFileName, + formatResolutionLabel, + sanitizeExportFileName, + X264_PRESETS, + type ExportResolutionPreset, + type X264Preset, +} from "~/lib/render-settings"; + +interface ExportPanelProps { + projectId?: string; + projectName?: string; + isRendering: boolean; + renderProgress: number; + timeline: TimelineState; + timelineData: TimelineDataItem[]; + getTimelineData: () => TimelineDataItem[]; + getPixelsPerSecond: () => number; + handleRenderVideo: ( + getTimelineData: () => TimelineDataItem[], + timeline: TimelineState, + compositionWidth: number | null, + compositionHeight: number | null, + getPixelsPerSecond: () => number, + options?: RenderOptions, + ) => void; +} + +type QualityPreset = "web" | "balanced" | "high"; +const QUALITY_CRF: Record = { web: 32, balanced: 28, high: 23 }; +const QUALITY_LABELS: Record = { + web: "Web", + balanced: "Balanced", + high: "High", +}; +const QUALITY_DESC: Record = { + web: "Smaller file, good for sharing", + balanced: "Recommended default", + high: "Sharper detail, larger file", +}; + +const RESOLUTION_PRESETS: { id: ExportResolutionPreset; label: string }[] = [ + { id: "1080p", label: "1080p (recommended)" }, + { id: "720p", label: "720p" }, + { id: "source", label: "Source (max 1080p)" }, + { id: "4k", label: "4K" }, +]; + +const X264_PRESET_LABELS: Record = { + ultrafast: "Ultrafast — fastest, largest", + superfast: "Superfast", + veryfast: "Very fast (recommended)", + faster: "Faster", + fast: "Fast", + medium: "Medium — slower, smaller", +}; + +function sourceDimensions(timelineData: TimelineDataItem[]): { w: number; h: number } { + let w = 1920; + let h = 1080; + for (const item of timelineData) { + for (const s of item.scrubbers) { + if (s.media_width && s.media_width > w) w = s.media_width; + if (s.media_height && s.media_height > h) h = s.media_height; + } + } + return { w, h }; +} + +export function ExportPanel({ + projectId, + projectName = "", + isRendering, + renderProgress, + timeline, + timelineData, + getTimelineData, + getPixelsPerSecond, + handleRenderVideo, +}: ExportPanelProps) { + const [codec, setCodec] = useState<"h264" | "h265" | "vp9">("h264"); + const [quality, setQuality] = useState("balanced"); + const [resolutionPreset, setResolutionPreset] = useState("1080p"); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [advancedCrf, setAdvancedCrf] = useState(23); + const [jpegQuality, setJpegQuality] = useState(80); + const [x264Preset, setX264Preset] = useState("veryfast"); + const [exportAudio, setExportAudio] = useState(true); + const [fileNameBase, setFileNameBase] = useState(""); + const [justFinished, setJustFinished] = useState(false); + + const { + items: historyItems, + loading: historyLoading, + deletingId: historyDeletingId, + refetch: refetchHistory, + deleteExport, + } = useExportHistory(projectId); + + const outputExt = codec === "vp9" ? ".webm" : ".mp4"; + + useEffect(() => { + setFileNameBase(defaultExportFileName(projectName, outputExt).replace(/\.(mp4|webm)$/i, "")); + }, [projectName, outputExt]); + + const source = useMemo(() => sourceDimensions(timelineData), [timelineData]); + const outputSize = useMemo( + () => capExportDimensions(source.w, source.h, resolutionPreset), + [source.w, source.h, resolutionPreset], + ); + + const fullFileName = useMemo( + () => sanitizeExportFileName(fileNameBase || "export", outputExt), + [fileNameBase, outputExt], + ); + + const wasRendering = React.useRef(false); + useEffect(() => { + if (wasRendering.current && !isRendering && renderProgress === 100) { + setJustFinished(true); + const t = setTimeout(() => setJustFinished(false), 3000); + return () => clearTimeout(t); + } + wasRendering.current = isRendering; + }, [isRendering, renderProgress]); + + const isEmpty = timeline.tracks.length === 0 || timeline.tracks.every((t) => t.scrubbers.length === 0); + + const buildOptions = useCallback((): RenderOptions | null => { + if (!projectId) return null; + const base = { + projectId, + onComplete: () => void refetchHistory(), + }; + if (advancedOpen) { + return { + ...base, + codec, + crf: clampExportCrf(advancedCrf, true), + resolutionPreset, + outputFileName: fullFileName, + advancedMode: true, + jpegQuality, + x264Preset: codec === "h264" ? x264Preset : undefined, + muted: !exportAudio, + }; + } + return { + ...base, + codec, + crf: QUALITY_CRF[quality], + resolutionPreset, + outputFileName: fullFileName, + advancedMode: false, + muted: !exportAudio, + }; + }, [ + projectId, + refetchHistory, + advancedOpen, + codec, + advancedCrf, + quality, + resolutionPreset, + fullFileName, + jpegQuality, + x264Preset, + exportAudio, + ]); + + const handleExport = useCallback(() => { + const opts = buildOptions(); + if (!opts) { + toast.error("Save the project before exporting"); + return; + } + setJustFinished(false); + handleRenderVideo(getTimelineData, timeline, source.w, source.h, getPixelsPerSecond, opts); + }, [handleRenderVideo, getTimelineData, timeline, source.w, source.h, getPixelsPerSecond, buildOptions]); + + const disabled = isRendering || isEmpty; + const show4kWarning = resolutionPreset === "4k"; + const effectiveCrf = advancedOpen ? clampExportCrf(advancedCrf, true) : QUALITY_CRF[quality]; + + return ( +
+
+ Export +
+ +
+ {isRendering && ( + <> +
+
+
+ + Rendering +
+ {renderProgress}% +
+ +

+ {renderProgress < 10 + ? "Preparing composition…" + : renderProgress < 90 + ? "Encoding frames…" + : "Uploading and finalising…"} +

+
+ + + )} + + {justFinished && !isRendering && ( + <> +
+ + Export complete — download started +
+ + + )} + +
+
+ File name +
+
+ setFileNameBase(e.target.value)} + onBlur={() => setFileNameBase((v) => v.trim() || "export")} + className="h-7 text-xs flex-1" + placeholder="my-video" + /> + {outputExt} +
+

+ Saves as {fullFileName} +

+
+ + + +
+
+ Resolution +
+ +

+ {formatResolutionLabel(source.w, source.h)} →{" "} + + {formatResolutionLabel(outputSize.width, outputSize.height)} + +

+ {show4kWarning && ( +

+ + 4K needs more server RAM. Use 1080p if export fails. +

+ )} +
+ + + +
+
Format
+ +
+ + + +
+ + +
+ + + +
+ + + {!advancedOpen ? ( +
+ {(Object.keys(QUALITY_LABELS) as QualityPreset[]).map((preset) => ( + + ))} +
+ ) : ( +
+
+
+ + {effectiveCrf} +
+ setAdvancedCrf(Number(e.target.value))} + className="w-full h-1.5 accent-primary" + /> +

Lower = better quality, larger file. Minimum 16.

+
+ + {codec === "h264" && ( +
+ + +
+ )} + +
+
+ + {jpegQuality} +
+ setJpegQuality(Number(e.target.value))} + className="w-full h-1.5 accent-primary" + /> +

Intermediate frames before H.264 encode.

+
+
+ )} +
+ + + + +
+ +
+ + {isEmpty && !isRendering && ( +

Add clips to the timeline first

+ )} +
+
+ ); +} diff --git a/app/components/editor/InspectorPanel.tsx b/app/components/editor/InspectorPanel.tsx new file mode 100644 index 00000000..ab3686a6 --- /dev/null +++ b/app/components/editor/InspectorPanel.tsx @@ -0,0 +1,195 @@ +import React from "react"; +import { Volume2, VolumeX, MousePointerClick, Film, Image, Music, Type, Layers } from "lucide-react"; +import { Separator } from "~/components/ui/separator"; +import { type ScrubberState } from "~/components/timeline/types"; + +interface InspectorPanelProps { + selectedScrubberIds: string[]; + getAllScrubbers: () => ScrubberState[]; + pixelsPerSecond: number; + onUpdate: (s: ScrubberState) => void; +} + +const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2, 4]; + +const MEDIA_TYPE_META: Record = { + video: { label: "Video", icon: Film, color: "text-blue-400" }, + audio: { label: "Audio", icon: Music, color: "text-purple-400" }, + image: { label: "Image", icon: Image, color: "text-green-400" }, + text: { label: "Text", icon: Type, color: "text-yellow-400" }, + groupped_scrubber: { label: "Group", icon: Layers, color: "text-orange-400" }, +}; + +function fmt(seconds: number) { + if (seconds >= 60) { + const m = Math.floor(seconds / 60); + const s = (seconds % 60).toFixed(1); + return `${m}m ${s}s`; + } + return `${seconds.toFixed(2)}s`; +} + +export function InspectorPanel({ selectedScrubberIds, getAllScrubbers, pixelsPerSecond, onUpdate }: InspectorPanelProps) { + const scrubber = selectedScrubberIds.length === 1 + ? getAllScrubbers().find((s) => s.id === selectedScrubberIds[0]) ?? null + : null; + + const meta = scrubber ? (MEDIA_TYPE_META[scrubber.mediaType] ?? MEDIA_TYPE_META.video) : null; + const Icon = meta?.icon; + + const startSec = scrubber ? scrubber.left / pixelsPerSecond : 0; + const durationSec = scrubber ? scrubber.width / pixelsPerSecond : 0; + const endSec = startSec + durationSec; + + return ( +
+ {/* Header */} +
+ Inspector +
+ + {!scrubber ? ( +
+
+ +
+

+ Select a clip on the timeline to inspect and edit its properties +

+
+ ) : ( +
+ {/* Identity */} +
+ {Icon && ( +
+ +
+ )} +
+
+ {scrubber.name} +
+
+ {meta?.label ?? scrubber.mediaType} +
+
+
+ + + + {/* Timing */} +
+
Timing
+
+ {[ + { label: "Start", val: fmt(startSec) }, + { label: "End", val: fmt(endSec) }, + { label: "Duration", val: fmt(durationSec) }, + ].map(({ label, val }) => ( +
+
{val}
+
{label}
+
+ ))} +
+
+ + {(scrubber.mediaType === "video" || scrubber.mediaType === "audio") && ( + <> + + + {/* Volume */} +
+
+
Volume
+ +
+
+ onUpdate({ ...scrubber, volume: parseFloat(e.target.value), muted: false })} + className="w-full h-1.5 accent-primary cursor-pointer" + /> +
+ 0% + + {scrubber.muted ? "Muted" : `${Math.round((scrubber.volume ?? 1) * 100)}%`} + + 100% +
+
+
+ + + + {/* Speed */} +
+
Playback Speed
+
+ {SPEED_OPTIONS.map((rate) => { + const active = (scrubber.playbackRate ?? 1) === rate; + return ( + + ); + })} +
+
+ + )} + + {scrubber.mediaType !== "audio" && ( + <> + + + {/* Player Position */} +
+
Position & Size
+
+ {( + [ + { label: "X", key: "left_player" as const }, + { label: "Y", key: "top_player" as const }, + { label: "W", key: "width_player" as const }, + { label: "H", key: "height_player" as const }, + ] as { label: string; key: keyof ScrubberState }[] + ).map(({ label, key }) => ( +
+ {label} + { + const val = parseInt(e.target.value, 10); + if (!isNaN(val)) onUpdate({ ...scrubber, [key]: val }); + }} + className="flex-1 w-0 bg-transparent text-[11px] font-mono text-foreground focus:outline-none" + /> +
+ ))} +
+
+ + )} +
+ )} +
+ ); +} diff --git a/app/components/editor/LeftPanel.tsx b/app/components/editor/LeftPanel.tsx index bc387708..475e2fd0 100644 --- a/app/components/editor/LeftPanel.tsx +++ b/app/components/editor/LeftPanel.tsx @@ -1,10 +1,13 @@ import React from "react"; -import { Link, Outlet, useLocation } from "react-router"; -import { FileImage, Type, BetweenVerticalEnd } from "lucide-react"; import { type MediaBinItem } from "~/components/timeline/types"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import MediaBin from "~/components/timeline/MediaBin"; +import TextEditor from "~/components/media/TextEditor"; +import Transitions from "~/components/media/Transitions"; + +export type LeftPanelSection = "media-bin" | "text-editor" | "transitions"; interface LeftPanelProps { + section: LeftPanelSection; mediaBinItems: MediaBinItem[]; isMediaLoading?: boolean; onAddMedia: (file: File) => void; @@ -25,9 +28,15 @@ interface LeftPanelProps { handleDeleteFromContext: () => void; handleSplitAudioFromContext: () => void; handleCloseContextMenu: () => void; + arrangeMode?: "default" | "group"; + sortBy?: "default" | "name_asc" | "name_desc"; + onArrangeModeChange?: (mode: "default" | "group") => void; + onSortByChange?: (sort: "default" | "name_asc" | "name_desc") => void; + onAfterAddText?: () => void; } export default function LeftPanel({ + section, mediaBinItems, isMediaLoading, onAddMedia, @@ -37,71 +46,35 @@ export default function LeftPanel({ handleDeleteFromContext, handleSplitAudioFromContext, handleCloseContextMenu, + arrangeMode, + sortBy, + onArrangeModeChange, + onSortByChange, + onAfterAddText, }: LeftPanelProps) { - const location = useLocation(); - - // Determine active tab based on current route - const getActiveTab = () => { - if (location.pathname.includes("/media-bin")) return "media-bin"; - if (location.pathname.includes("/text-editor")) return "text-editor"; - if (location.pathname.includes("/transitions")) return "transitions"; - return "media-bin"; // default - }; - - console.log("mediabinitems", mediaBinItems); - const activeTab = getActiveTab(); - return ( -
- - {/* Tab Headers */} -
- - - - - - - - - - - - - - - - - -
- - {/* Tab Content */} -
- +
+ {section === "media-bin" && ( + -
- + )} + {section === "text-editor" && } + {section === "transitions" && } +
); } diff --git a/app/components/editor/PreviewFullscreenControls.tsx b/app/components/editor/PreviewFullscreenControls.tsx new file mode 100644 index 00000000..10b08dfd --- /dev/null +++ b/app/components/editor/PreviewFullscreenControls.tsx @@ -0,0 +1,79 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; + +function formatClock(seconds: number): string { + const s = Math.max(0, seconds); + const mins = Math.floor(s / 60); + const secs = Math.floor(s % 60); + return `${mins}:${String(secs).padStart(2, "0")}`; +} + +interface PreviewFullscreenControlsProps { + isFullscreen: boolean; + currentTimeSec: number; + durationSec: number; + onSeek: (timeSec: number) => void; +} + +/** Progress + time only; play / fullscreen use the center control bar. */ +export function PreviewFullscreenControls({ + isFullscreen, + currentTimeSec, + durationSec, + onSeek, +}: PreviewFullscreenControlsProps) { + const [isDragging, setIsDragging] = useState(false); + const barRef = useRef(null); + + const progress = durationSec > 0 ? Math.min(1, currentTimeSec / durationSec) : 0; + + const seekFromClientX = useCallback( + (clientX: number) => { + const bar = barRef.current; + if (!bar || durationSec <= 0) return; + const rect = bar.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + onSeek(ratio * durationSec); + }, + [durationSec, onSeek], + ); + + useEffect(() => { + if (!isDragging) return; + const onMove = (e: MouseEvent) => seekFromClientX(e.clientX); + const onUp = () => setIsDragging(false); + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + return () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + }, [isDragging, seekFromClientX]); + + if (!isFullscreen) return null; + + return ( +
+
+
{ + setIsDragging(true); + seekFromClientX(e.clientX); + }}> +
+
+
+

+ {formatClock(currentTimeSec)} / {formatClock(durationSec)} +

+
+
+ ); +} diff --git a/app/components/media/TextEditor.tsx b/app/components/media/TextEditor.tsx index 835706d3..025d43e1 100644 --- a/app/components/media/TextEditor.tsx +++ b/app/components/media/TextEditor.tsx @@ -1,10 +1,9 @@ import React, { useState } from "react"; -import { useOutletContext, useNavigate } from "react-router"; +import { useOutletContext } from "react-router"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Badge } from "~/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Separator } from "~/components/ui/separator"; import { AlignLeft, AlignCenter, AlignRight, Bold, ChevronDown, Type, Plus } from "lucide-react"; import { @@ -14,7 +13,7 @@ import { DropdownMenuItem, } from "~/components/ui/dropdown-menu"; -interface TextEditorProps { +export interface TextEditorProps { onAddText: ( textContent: string, fontSize: number, @@ -23,11 +22,12 @@ interface TextEditorProps { textAlign: "left" | "center" | "right", fontWeight: "normal" | "bold", ) => void; + onAfterAdd?: () => void; } -export default function TextEditor() { - const { onAddText } = useOutletContext(); - const navigate = useNavigate(); +export default function TextEditor(props: TextEditorProps) { + const outletCtx = useOutletContext(); + const { onAddText, onAfterAdd } = { ...outletCtx, ...props }; const [textContent, setTextContent] = useState("Hello World"); const [fontSize, setFontSize] = useState(48); @@ -48,166 +48,167 @@ export default function TextEditor() { const handleAddText = () => { if (textContent.trim()) { onAddText(textContent, fontSize, fontFamily, color, textAlign, fontWeight); - navigate("../media-bin"); + onAfterAdd?.(); } }; return ( -
-
- - -
- - Text Properties -
-
- - {/* Text Content */} -
- -