diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ed3ea16 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +.git +.gitignore +target +*.md +!package.json +apps/ui/.output +apps/ui/dist +apps/docs/dist +apps/docs/.output +packages/render-engine/dist +packages/render-engine/wasm +.turbo +.vscode +.env* +scripts +signatures diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a4b83b2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# Stage 1: Base with Node.js and pnpm +FROM node:20-bookworm-slim AS base +RUN corepack enable && corepack prepare pnpm@10.19.0 --activate + +# Stage 2: Install Rust toolchain and wasm-pack for WASM build +FROM base AS rust +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + build-essential \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --target wasm32-unknown-unknown \ + && . "/root/.cargo/env" \ + && cargo install wasm-pack +ENV PATH="/root/.cargo/bin:${PATH}" + +# Stage 3: Install dependencies and build WASM + app +FROM rust AS build +WORKDIR /app + +# Copy workspace config files first for better layer caching +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY apps/ui/package.json apps/ui/ +COPY packages/render-engine/package.json packages/render-engine/ +# Create a stub for docs so pnpm workspace resolution doesn't fail +COPY apps/docs/package.json apps/docs/ + +RUN pnpm install --frozen-lockfile + +# Copy Rust crates and build WASM +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ +RUN pnpm build:wasm + +# Copy source for the UI app and render-engine package (not docs source) +COPY packages/render-engine/ packages/render-engine/ +COPY apps/ui/ apps/ui/ + +# Build only the UI app and its dependencies (skip docs) +RUN pnpm --filter @tooscut/ui... build + +# Stage 4: Production image — only Node.js + built output +FROM node:20-bookworm-slim AS production +WORKDIR /app + +COPY --from=build /app/apps/ui/.output .output + +ENV NODE_ENV=production +ENV HOST=:: +ENV PORT=3000 +EXPOSE 3000 + +CMD ["node", ".output/server/index.mjs"] diff --git a/apps/docs/source.generated.ts b/apps/docs/source.generated.ts index ac939de..a5e2baa 100644 --- a/apps/docs/source.generated.ts +++ b/apps/docs/source.generated.ts @@ -1,41 +1,28 @@ /// -import { fromConfig } from "fumadocs-mdx/runtime/vite"; - -import type * as Config from "./source.config"; +import { fromConfig } from 'fumadocs-mdx/runtime/vite'; +import type * as Config from './source.config'; export const create = fromConfig(); -export const blog = create.doc( - "blog", - "./content/blog", - import.meta.glob(["./**/*.{mdx,md}"], { - base: "./content/blog", - query: { - collection: "blog", - }, - }), -); +export const blog = create.doc("blog", "./content/blog", import.meta.glob(["./**/*.{mdx,md}"], { + "base": "./content/blog", + "query": { + "collection": "blog" + } +})); export const docs = { - doc: create.doc( - "docs", - "./content/docs", - import.meta.glob(["./**/*.{mdx,md}"], { - base: "./content/docs", - query: { - collection: "docs", - }, - }), - ), - meta: create.meta( - "docs", - "./content/docs", - import.meta.glob(["./**/*.{json,yaml}"], { - import: "default", - base: "./content/docs", - query: { - collection: "docs", - }, - }), - ), -}; + doc: create.doc("docs", "./content/docs", import.meta.glob(["./**/*.{mdx,md}"], { + "base": "./content/docs", + "query": { + "collection": "docs" + } + })), + meta: create.meta("docs", "./content/docs", import.meta.glob(["./**/*.{json,yaml}"], { + "import": "default", + "base": "./content/docs", + "query": { + "collection": "docs" + } + })) +}; \ No newline at end of file diff --git a/apps/ui/package.json b/apps/ui/package.json index 36b69b8..c2f637b 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -28,12 +28,11 @@ "clsx": "^2.1.1", "color": "^5.0.3", "comlink": "^4.4.2", - "dexie": "^4.3.0", - "dexie-react-hooks": "^4.2.0", "konva": "^10.2.0", "lucide-react": "^0.561.0", "mediabunny": "^1.40.1", "nitro": "npm:nitro-nightly@latest", + "postgres": "^3.4.7", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/apps/ui/src/components/editor/asset-panel.tsx b/apps/ui/src/components/editor/asset-panel.tsx index 01c12b8..9d99545 100644 --- a/apps/ui/src/components/editor/asset-panel.tsx +++ b/apps/ui/src/components/editor/asset-panel.tsx @@ -1,277 +1,15 @@ -import { X, Plus, Music, Video, Image, FolderOpen, Type, Shapes, Sparkles } from "lucide-react"; -import { useRef, useCallback, useState, type DragEvent } from "react"; +import { FolderOpen, Type, Shapes, Sparkles } from "lucide-react"; +import { useState } from "react"; import { cn } from "@/lib/utils"; -import { useVideoEditorStore } from "../../state/video-editor-store"; -import { - useAssetStore, - importFiles, - importFilesWithPicker, - handleNativeFileDrop, - addAssetsToStores, - formatFileSize, - formatDuration, - type MediaAsset, -} from "../timeline/use-asset-store"; import { Button } from "../ui/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; +import { TooltipProvider } from "../ui/tooltip"; import { ShapePanel } from "./shape-panel"; +import { TamsAssetsContent } from "./tams-assets-content"; import { TextPanel } from "./text-panel"; import { TransitionPanel } from "./transition-panel"; -function AssetCard({ asset }: { asset: MediaAsset }) { - const removeAssetFromUI = useAssetStore((s) => s.removeAsset); - const removeAssetFromEditor = useVideoEditorStore((s) => s.removeAsset); - const dragImageRef = useRef(null); - const removeAsset = useCallback( - (id: string) => { - removeAssetFromUI(id); - removeAssetFromEditor(id); - }, - [removeAssetFromUI, removeAssetFromEditor], - ); - - const handleDragStart = (e: React.DragEvent) => { - e.dataTransfer.setData("application/x-asset-id", asset.id); - // Also set asset type so we can check track compatibility during dragover - e.dataTransfer.setData(`application/x-asset-type-${asset.type}`, ""); - // Encode duration in MIME type so dragOver can read it (data values are inaccessible during dragOver) - e.dataTransfer.setData(`application/x-asset-duration-${asset.duration}`, ""); - e.dataTransfer.effectAllowed = "copy"; - - // Set a thumbnail drag ghost - if (asset.thumbnailUrl) { - if (!dragImageRef.current) { - const img = document.createElement("img"); - img.src = asset.thumbnailUrl; - dragImageRef.current = img; - } - e.dataTransfer.setDragImage(dragImageRef.current, 60, 34); - } - }; - - return ( -
- {/* Thumbnail */} -
- {asset.thumbnailUrl ? ( - {asset.name} - ) : asset.type === "audio" ? ( - - ) : ( -
- - {/* Info */} -
-
- {asset.name} -
-
- {formatFileSize(asset.size)} - {asset.width && asset.height && ` • ${asset.width}×${asset.height}`} -
-
- - {/* Delete button (on hover) */} - - - - - -

Remove asset

-
-
-
- ); -} - -function ImportButton({ - accept, - onImport, - label, - icon: Icon, -}: { - accept: string; - onImport: (assets: MediaAsset[]) => void; - label?: string; - icon?: React.ComponentType<{ className?: string }>; -}) { - const inputRef = useRef(null); - const hasFilePicker = "showOpenFilePicker" in window; - - const handleClick = async () => { - if (hasFilePicker) { - const assets = await importFilesWithPicker(accept); - if (assets.length > 0) { - onImport(assets); - } - } else { - inputRef.current?.click(); - } - }; - - const handleChange = async (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - const assets = await importFiles(e.target.files); - onImport(assets); - e.target.value = ""; - } - }; - - const IconComponent = Icon || Plus; - - return ( - <> - {!hasFilePicker && ( - void handleChange(e)} - /> - )} - - - ); -} - -function AssetsContent() { - const assets = useAssetStore((s) => s.assets); - const isLoading = useAssetStore((s) => s.isLoading); - - const handleImportedAssets = useCallback((imported: MediaAsset[]) => { - addAssetsToStores(imported); - }, []); - - const videoAssets = assets.filter((a) => a.type === "video"); - const audioAssets = assets.filter((a) => a.type === "audio"); - const imageAssets = assets.filter((a) => a.type === "image"); - - return ( - - - - All ({assets.length}) - - - Video ({videoAssets.length}) - - - Audio ({audioAssets.length}) - - - Image ({imageAssets.length}) - - - -
- - - {isLoading && ( -
Loading...
- )} -
- {assets.map((asset) => ( - - ))} -
- {assets.length === 0 && !isLoading && ( -
- No assets imported yet -
- )} -
- - - -
- {videoAssets.map((asset) => ( - - ))} -
- {videoAssets.length === 0 && ( -
No video files
- )} -
- - - -
- {audioAssets.map((asset) => ( - - ))} -
- {audioAssets.length === 0 && ( -
No audio files
- )} -
- - - -
- {imageAssets.map((asset) => ( - - ))} -
- {imageAssets.length === 0 && ( -
No image files
- )} -
-
-
- ); -} - const PANEL_TABS = [ { id: "assets", label: "Assets", icon: FolderOpen }, { id: "text", label: "Text", icon: Type }, @@ -283,74 +21,10 @@ type PanelTab = (typeof PANEL_TABS)[number]["id"]; export function AssetPanel() { const [activeTab, setActiveTab] = useState("assets"); - const [isDragOver, setIsDragOver] = useState(false); - const dragCounterRef = useRef(0); - const setLoading = useAssetStore((s) => s.setLoading); - - const handleDropFiles = useCallback( - async (files: FileList, handles?: FileSystemFileHandle[]) => { - setLoading(true); - try { - const imported = await importFiles(files, handles); - if (imported.length > 0) addAssetsToStores(imported); - } finally { - setLoading(false); - } - }, - [setLoading], - ); - - const handleDragEnter = useCallback((e: DragEvent) => { - e.preventDefault(); - // Only show indicator for file drops, not internal asset drags - if (e.dataTransfer.types.includes("Files")) { - dragCounterRef.current++; - setIsDragOver(true); - } - }, []); - - const handleDragLeave = useCallback((e: DragEvent) => { - e.preventDefault(); - dragCounterRef.current--; - if (dragCounterRef.current <= 0) { - dragCounterRef.current = 0; - setIsDragOver(false); - } - }, []); - - const handleDragOver = useCallback((e: DragEvent) => { - e.preventDefault(); - if (e.dataTransfer.types.includes("Files")) { - e.dataTransfer.dropEffect = "copy"; - } - }, []); - - const handleDrop = useCallback( - (e: DragEvent) => { - e.preventDefault(); - dragCounterRef.current = 0; - setIsDragOver(false); - if (e.dataTransfer.types.includes("Files")) { - handleNativeFileDrop( - e.nativeEvent, - (files, handles) => void handleDropFiles(files, handles), - ); - setActiveTab("assets"); - } - }, - [handleDropFiles], - ); return ( -
- {/* Top-level tab bar */} +
{PANEL_TABS.map((tab) => { const Icon = tab.icon; @@ -373,9 +47,8 @@ export function AssetPanel() { })}
- {/* Tab content */}
- {activeTab === "assets" && } + {activeTab === "assets" && } {activeTab === "text" && (
@@ -391,13 +64,6 @@ export function AssetPanel() {
)} - - {/* Drag-over overlay */} - {isDragOver && ( -
-
Drop files to import
-
- )}
diff --git a/apps/ui/src/components/editor/color-grading/lut-properties.tsx b/apps/ui/src/components/editor/color-grading/lut-properties.tsx index ea67dd3..91e4904 100644 --- a/apps/ui/src/components/editor/color-grading/lut-properties.tsx +++ b/apps/ui/src/components/editor/color-grading/lut-properties.tsx @@ -7,10 +7,9 @@ import type { LutReference, LutInterpolation } from "@tooscut/render-engine"; -import { Upload, X } from "lucide-react"; +import { X } from "lucide-react"; import { useCallback, useMemo } from "react"; -import { importLutWithPicker } from "../../../lib/lut-manager"; import { useVideoEditorStore } from "../../../state/video-editor-store"; import { Button } from "../../ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select"; @@ -35,17 +34,6 @@ export function LutProperties({ lut, onChange }: LutPropertiesProps) { [lutAssets, lut.lut_id], ); - const handleLoadFile = useCallback(() => { - async function loadLut() { - const result = await importLutWithPicker(); - if (result) { - onChange({ lut_id: result.id }); - } - } - - void loadLut(); - }, [onChange]); - const handleSelectExisting = useCallback( (value: string | null) => { if (value) onChange({ lut_id: value }); @@ -95,10 +83,6 @@ export function LutProperties({ lut, onChange }: LutPropertiesProps) {
) : (
- {lutAssets.length > 0 && ( )} + {lutAssets.length === 0 && ( +

No LUT assets available from media store.

+ )}
)} diff --git a/apps/ui/src/components/editor/preview-panel.tsx b/apps/ui/src/components/editor/preview-panel.tsx index 28c8a8e..00c5fb5 100644 --- a/apps/ui/src/components/editor/preview-panel.tsx +++ b/apps/ui/src/components/editor/preview-panel.tsx @@ -31,6 +31,7 @@ interface ImageEntry { element: HTMLImageElement; assetId: string; objectUrl: string; + ownsObjectUrl: boolean; isReady: boolean; } @@ -207,14 +208,19 @@ export function PreviewPanel() { const existing = imageElementsRef.current.get(asset.id); if (existing) return existing; + if (!asset.url && !asset.file) { + return null; + } + const img = document.createElement("img"); - const objectUrl = URL.createObjectURL(asset.file); + const objectUrl = asset.file ? URL.createObjectURL(asset.file) : asset.url; img.src = objectUrl; const entry: ImageEntry = { element: img, assetId: asset.id, objectUrl, + ownsObjectUrl: !!asset.file, isReady: false, }; @@ -241,7 +247,9 @@ export function PreviewPanel() { const currentAssetIds = new Set(assets.map((a) => a.id)); for (const [assetId, entry] of imageElements) { if (!currentAssetIds.has(assetId)) { - URL.revokeObjectURL(entry.objectUrl); + if (entry.ownsObjectUrl) { + URL.revokeObjectURL(entry.objectUrl); + } imageElements.delete(assetId); uploadedTextures.delete(assetId); } @@ -792,7 +800,9 @@ export function PreviewPanel() { const loaderManager = loaderManagerRef.current; return () => { for (const [, entry] of imageElements) { - URL.revokeObjectURL(entry.objectUrl); + if (entry.ownsObjectUrl) { + URL.revokeObjectURL(entry.objectUrl); + } } imageElements.clear(); loaderManager.disposeAll(); diff --git a/apps/ui/src/components/editor/project-settings-dialog.tsx b/apps/ui/src/components/editor/project-settings-dialog.tsx index c9137b8..07da395 100644 --- a/apps/ui/src/components/editor/project-settings-dialog.tsx +++ b/apps/ui/src/components/editor/project-settings-dialog.tsx @@ -2,7 +2,7 @@ import { FRAME_RATE_PRESETS, type FrameRate } from "@tooscut/render-engine"; import { Monitor, Smartphone, Square, RectangleHorizontal } from "lucide-react"; import { useState, useCallback, useEffect } from "react"; -import { db } from "../../state/db"; +import { getProject, type ProjectRow, upsertProject } from "../../lib/project-api"; import { useVideoEditorStore } from "../../state/video-editor-store"; import { Button } from "../ui/button"; import { @@ -151,9 +151,11 @@ export function ProjectSettingsDialog({ setHeight(settings.height); setFps(settings.fps); setPreset(findPresetIndex(settings.width, settings.height)); - // Load project name from DB - void db.projects.get(projectId).then((project) => { - if (project) setName(project.name); + void getProject({ data: projectId }).then((project) => { + const row = project as ProjectRow | null; + if (row) { + setName(row.name); + } }); } }, [open, settings, projectId]); @@ -181,9 +183,10 @@ export function ProjectSettingsDialog({ const handleSave = useCallback(() => { setSettings({ width, height, fps }); - // Save project name to DB const trimmed = name.trim() || "Untitled Project"; - void db.projects.update(projectId, { name: trimmed }); + void upsertProject({ + data: { id: projectId, name: trimmed, settings: { width, height, fps } }, + }); onOpenChange(false); }, [width, height, fps, name, projectId, setSettings, onOpenChange]); diff --git a/apps/ui/src/components/editor/tams-assets-content.tsx b/apps/ui/src/components/editor/tams-assets-content.tsx new file mode 100644 index 0000000..14a1d25 --- /dev/null +++ b/apps/ui/src/components/editor/tams-assets-content.tsx @@ -0,0 +1,277 @@ +import { useCallback, useEffect, useState } from "react"; +import { Loader2, Film, Music, Image, AlertCircle, Settings } from "lucide-react"; + +import { TamsClient, TamsClientError, type TamsFlow, type TamsSource } from "../../lib/tams-client"; +import { useVideoEditorStore } from "../../state/video-editor-store"; +import { useTamsSettingsStore } from "../../state/tams-settings-store"; +import { useAssetStore } from "../timeline/use-asset-store"; +import { Button } from "../ui/button"; +import { TamsSettingsDialog } from "./tams-settings-dialog"; + +interface TamsAssetItem { + flow: TamsFlow; + source?: TamsSource; +} + +function TamsFlowCard({ + item, + onAdd, +}: { + item: TamsAssetItem; + onAdd: (flow: TamsFlow) => void; +}) { + const { flow } = item; + + const formatLabel = flow.label || flow.id.slice(0, 8); + const isVideo = flow.format?.includes("video"); + const isAudio = flow.format?.includes("audio"); + const isImage = flow.format?.includes("image"); + + const Icon = isVideo ? Film : isAudio ? Music : isImage ? Image : Film; + + return ( +
+ {/* Thumbnail placeholder */} +
+ + + {/* Format badge */} + {flow.codec && ( +
+ {flow.codec} +
+ )} + + {/* Type badge */} +
+ {isVideo ? "video" : isAudio ? "audio" : isImage ? "image" : "data"} +
+
+ + {/* Info */} +
+
+ {formatLabel} +
+
+ {flow.frame_width && flow.frame_height && `${flow.frame_width}×${flow.frame_height}`} + {item.source?.label && ` • ${item.source.label}`} +
+
+ +
+ +
+
+ ); +} + +export function TamsAssetsContent() { + const tamsConfig = useTamsSettingsStore((s) => s.tamsConfig); + const isConnected = useTamsSettingsStore((s) => s.isConnected); + const setConnected = useTamsSettingsStore((s) => s.setConnected); + const setConnectionError = useTamsSettingsStore((s) => s.setConnectionError); + + const [flows, setFlows] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + + const handleAddFlow = useCallback( + async (flow: TamsFlow) => { + if (!tamsConfig) return; + + try { + const client = new TamsClient(tamsConfig); + const segments = await client.listFlowSegments(flow.id, { limit: 1 }); + const url = segments[0] ? client.getSegmentDownloadUrl(segments[0]) : null; + if (!url) return; + + const type: "video" | "audio" | "image" = flow.format?.includes("video") + ? "video" + : flow.format?.includes("audio") + ? "audio" + : "image"; + + const editorStore = useVideoEditorStore.getState(); + const existingEditorAsset = editorStore.assets.find((asset) => asset.id === flow.id); + if (existingEditorAsset) { + editorStore.updateAssetUrl(flow.id, url); + } else { + editorStore.addAssets([ + { + id: flow.id, + type, + name: flow.label ?? flow.id, + url, + duration: 0, + width: flow.frame_width, + height: flow.frame_height, + }, + ]); + } + + useAssetStore.getState().addAssets([ + { + id: flow.id, + type, + name: flow.label ?? flow.id, + url, + duration: 0, + size: 0, + file: undefined, + width: flow.frame_width, + height: flow.frame_height, + }, + ]); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to add flow"; + setError(message); + } + }, + [tamsConfig], + ); + + const fetchAssets = useCallback(async () => { + if (!tamsConfig) return; + + setIsLoading(true); + setError(null); + + try { + const client = new TamsClient(tamsConfig); + + // Verify connection first + await client.getServiceInfo(); + setConnected(true); + setConnectionError(null); + + // Fetch flows (video, audio, image) + const allFlows = await client.listFlows({ limit: 50 }); + + // Fetch sources for labels + const sourceIds = [...new Set(allFlows.map((f) => f.source_id))]; + const sources: Map = new Map(); + + await Promise.all( + sourceIds.slice(0, 20).map(async (id) => { + try { + const source = await client.getSource(id); + sources.set(id, source); + } catch { + // Source may not exist or be inaccessible + } + }), + ); + + const items: TamsAssetItem[] = allFlows.map((flow) => ({ + flow, + source: sources.get(flow.source_id), + })); + + setFlows(items); + } catch (err) { + const message = + err instanceof TamsClientError + ? `Error ${err.status}: ${err.message}` + : err instanceof Error + ? err.message + : "Failed to load TAMS assets"; + setError(message); + setConnected(false); + setConnectionError(message); + } finally { + setIsLoading(false); + } + }, [tamsConfig, setConnected, setConnectionError]); + + useEffect(() => { + if (tamsConfig) { + void fetchAssets(); + } + }, [tamsConfig, fetchAssets]); + + // Not configured + if (!tamsConfig) { + return ( +
+ +
+

TAMS not configured

+

+ Set up a connection to browse media from a Time-addressable Media Store. +

+
+ + +
+ ); + } + + return ( +
+ {/* Header with refresh + settings */} +
+ + +
+ + {/* Connection status */} + {isConnected && !error && ( +
+
+ Connected +
+ )} + + {/* Error */} + {error && ( +
{error}
+ )} + + {/* Loading */} + {isLoading && ( +
+ + Loading flows... +
+ )} + + {/* Flow grid */} + {!isLoading && flows.length > 0 && ( +
+ {flows.map((item) => ( + void handleAddFlow(flow)} /> + ))} +
+ )} + + {/* Empty state */} + {!isLoading && !error && flows.length === 0 && ( +
+ No flows found in TAMS +
+ )} + + +
+ ); +} diff --git a/apps/ui/src/components/editor/tams-settings-dialog.tsx b/apps/ui/src/components/editor/tams-settings-dialog.tsx new file mode 100644 index 0000000..791f38b --- /dev/null +++ b/apps/ui/src/components/editor/tams-settings-dialog.tsx @@ -0,0 +1,247 @@ +import { useState, useCallback, useEffect } from "react"; +import { Loader2, CheckCircle2, XCircle } from "lucide-react"; + +import { TamsClient, TamsClientError, type TamsConfig } from "../../lib/tams-client"; +import { useTamsSettingsStore } from "../../state/tams-settings-store"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; + +interface TamsSettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function TamsSettingsDialog({ open, onOpenChange }: TamsSettingsDialogProps) { + const tamsConfig = useTamsSettingsStore((s) => s.tamsConfig); + const setTamsConfig = useTamsSettingsStore((s) => s.setTamsConfig); + const setConnected = useTamsSettingsStore((s) => s.setConnected); + const setConnectionError = useTamsSettingsStore((s) => s.setConnectionError); + + const [endpoint, setEndpoint] = useState(""); + const [authType, setAuthType] = useState("bearer"); + const [token, setToken] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + + useEffect(() => { + if (open && tamsConfig) { + setEndpoint(tamsConfig.endpoint); + setAuthType(tamsConfig.authType); + setToken(tamsConfig.token ?? ""); + setUsername(tamsConfig.username ?? ""); + setPassword(tamsConfig.password ?? ""); + setTestResult(null); + } else if (open) { + setEndpoint(""); + setAuthType("bearer"); + setToken(""); + setUsername(""); + setPassword(""); + setTestResult(null); + } + }, [open, tamsConfig]); + + const buildConfig = useCallback((): TamsConfig => { + return { + endpoint: endpoint.trim(), + authType, + token: token.trim() || undefined, + username: username.trim() || undefined, + password: password.trim() || undefined, + }; + }, [endpoint, authType, token, username, password]); + + const handleTestConnection = useCallback(async () => { + setIsTesting(true); + setTestResult(null); + + try { + const config = buildConfig(); + const client = new TamsClient(config); + const info = await client.getServiceInfo(); + setTestResult({ + success: true, + message: `Connected: ${info.name ?? "TAMS"} (API v${info.api_version})`, + }); + } catch (error) { + const message = + error instanceof TamsClientError + ? `Error ${error.status}: ${error.message}` + : error instanceof Error + ? error.message + : "Connection failed"; + setTestResult({ success: false, message }); + } finally { + setIsTesting(false); + } + }, [buildConfig]); + + const handleSave = useCallback(() => { + const config = buildConfig(); + if (!config.endpoint) { + setTestResult({ success: false, message: "Endpoint URL is required" }); + return; + } + setTamsConfig(config); + if (testResult?.success) { + setConnected(true); + setConnectionError(null); + } + onOpenChange(false); + }, [buildConfig, testResult, setTamsConfig, setConnected, setConnectionError, onOpenChange]); + + const handleDisconnect = useCallback(() => { + setTamsConfig(null); + setConnected(false); + setConnectionError(null); + setEndpoint(""); + setToken(""); + setUsername(""); + setPassword(""); + setTestResult(null); + }, [setTamsConfig, setConnected, setConnectionError]); + + return ( + + + + TAMS Connection + + Configure the Time-addressable Media Store endpoint and credentials. + + + + + {/* Endpoint URL */} +
+ + ) => setEndpoint(e.target.value)} + /> +
+ + {/* Auth Type */} +
+ + +
+ + {/* Credential fields based on auth type */} + {authType === "bearer" && ( +
+ + ) => setToken(e.target.value)} + /> +
+ )} + + {authType === "url_token" && ( +
+ + ) => setToken(e.target.value)} + /> +
+ )} + + {authType === "basic" && ( + <> +
+ + ) => setUsername(e.target.value)} + /> +
+
+ + ) => setPassword(e.target.value)} + /> +
+ + )} + + {/* Test result */} + {testResult && ( +
+ {testResult.success ? ( + + ) : ( + + )} + {testResult.message} +
+ )} +
+ + + {tamsConfig && ( + + )} +
+ + + + +
+ ); +} diff --git a/apps/ui/src/components/editor/toolbar.tsx b/apps/ui/src/components/editor/toolbar.tsx index d6be9ba..90d0743 100644 --- a/apps/ui/src/components/editor/toolbar.tsx +++ b/apps/ui/src/components/editor/toolbar.tsx @@ -1,6 +1,5 @@ import { useNavigate } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router"; -import { useLiveQuery } from "dexie-react-hooks"; import { Undo2, Redo2, @@ -13,10 +12,10 @@ import { } from "lucide-react"; import { useState, useCallback, useRef, useEffect } from "react"; +import type { ProjectRow } from "../../lib/project-api"; import { Route } from "../../routes/editor/$projectId"; -import { db } from "../../state/db"; +import { upsertProject } from "../../lib/project-api"; import { useVideoEditorStore, useTemporalStore } from "../../state/video-editor-store"; -import { importFilesWithPicker, addAssetsToStores } from "../timeline/use-asset-store"; import { Button } from "../ui/button"; import { Menubar, @@ -33,6 +32,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/ import { ExportDialog } from "./export-dialog"; import { openKeyboardShortcuts } from "./keyboard-shortcuts-modal"; import { ProjectSettingsDialog } from "./project-settings-dialog"; +import { TamsSettingsDialog } from "./tams-settings-dialog"; interface ToolbarProps { /** Open the settings dialog on mount (for new projects) */ @@ -44,6 +44,7 @@ export function Toolbar({ showSettingsOnMount }: ToolbarProps) { const exportDialogOpen = useVideoEditorStore((s) => s.exportDialogOpen); const setExportDialogOpen = useVideoEditorStore((s) => s.setExportDialogOpen); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [tamsSettingsOpen, setTamsSettingsOpen] = useState(false); const navigate = useNavigate(); @@ -178,18 +179,6 @@ export function Toolbar({ showSettingsOnMount }: ToolbarProps) { ⌘S Save As... - - { - void (async () => { - const assets = await importFilesWithPicker(); - if (assets.length > 0) addAssetsToStores(assets); - })(); - }} - > - Import Media - ⌘I - Export ⌘E @@ -198,6 +187,9 @@ export function Toolbar({ showSettingsOnMount }: ToolbarProps) { setSettingsDialogOpen(true)}> Project Settings + setTamsSettingsOpen(true)}> + TAMS Connection + @@ -390,30 +382,36 @@ export function Toolbar({ showSettingsOnMount }: ToolbarProps) { onOpenChange={handleSettingsDialogChange} projectId={projectId} /> +
); } function ToolbarProjectName() { - const { projectId } = Route.useParams(); - const project = useLiveQuery(() => db.projects.get(projectId), [projectId]); + const project = Route.useLoaderData() as ProjectRow; const [editing, setEditing] = useState(false); - const [value, setValue] = useState(""); + const [displayName, setDisplayName] = useState(project.name); + const [value, setValue] = useState(project.name); const inputRef = useRef(null); + useEffect(() => { + setDisplayName(project.name); + setValue(project.name); + }, [project.name]); + useEffect(() => { if (editing) { inputRef.current?.select(); } }, [editing]); - if (!project) return null; - const commit = () => { const trimmed = value.trim(); - if (trimmed && trimmed !== project.name) { - void db.projects.update(project.id, { name: trimmed }); + if (trimmed && trimmed !== displayName) { + void upsertProject({ data: { id: project.id, name: trimmed } }); + setDisplayName(trimmed); + setValue(trimmed); } setEditing(false); }; @@ -428,7 +426,10 @@ function ToolbarProjectName() { onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") commit(); - if (e.key === "Escape") setEditing(false); + if (e.key === "Escape") { + setValue(displayName); + setEditing(false); + } }} /> ); @@ -439,11 +440,11 @@ function ToolbarProjectName() { type="button" className="max-w-48 cursor-text truncate text-xs text-muted-foreground transition-colors hover:text-foreground" onClick={() => { - setValue(project.name); + setValue(displayName); setEditing(true); }} > - {project.name} + {displayName} ); } diff --git a/apps/ui/src/components/timeline/canvas-timeline.tsx b/apps/ui/src/components/timeline/canvas-timeline.tsx index 8118bc2..64b4105 100644 --- a/apps/ui/src/components/timeline/canvas-timeline.tsx +++ b/apps/ui/src/components/timeline/canvas-timeline.tsx @@ -39,10 +39,6 @@ import { TimelineStage } from "./timeline-stage"; import { computeSplitLayout, yToSectionTrackIndex } from "./track-layout"; import { useAssetStore, - importFiles, - importFilesWithPicker, - handleNativeFileDrop, - addAssetsToStores, } from "./use-asset-store"; /** @@ -223,16 +219,6 @@ export function CanvasTimeline() { return; } - // Cmd/Ctrl+I: Import media - if ((e.metaKey || e.ctrlKey) && e.key === "i") { - e.preventDefault(); - void (async () => { - const imported = await importFilesWithPicker(); - if (imported.length > 0) addAssetsToStores(imported); - })(); - return; - } - // Cmd/Ctrl+E: Open export dialog if ((e.metaKey || e.ctrlKey) && e.key === "e") { e.preventDefault(); @@ -1232,87 +1218,7 @@ export function CanvasTimeline() { return; } - // Handle file drop from OS (Finder / Explorer) - if (e.dataTransfer!.files.length > 0) { - handleNativeFileDrop(e, (files, handles) => { - void (async () => { - const imported = await importFiles(files, handles); - - let asset: (typeof imported)[number] | undefined; - if (imported.length > 0) { - addAssetsToStores(imported); - asset = imported[0]; - } else { - // File was already imported (dedup) — find existing asset by name+size - const file = files[0]; - asset = useAssetStore - .getState() - .assets.find((a) => a.name === file.name && a.size === file.size); - } - if (!asset) return; - const rect = el.getBoundingClientRect(); - - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const d = dropHandlerDepsRef.current; - const dropStartTime = d.clips.length === 0 ? 0 : d.xToFrame(x); - const rawIdx = d.yToTrackIndex(y); - - const isAudio = asset.type === "audio"; - const requiredTrackType = isAudio ? "audio" : "video"; - const trackIndex = isAudio ? rawIdx : d.findNearestVideoTrack(rawIdx); - if (trackIndex === null) return; - if (trackIndex < 0 || trackIndex >= d.allTracks.length) return; - - const track = d.allTracks[trackIndex]; - if (track.type !== requiredTrackType) return; - - const clipType = - asset.type === "audio" ? "audio" : asset.type === "image" ? "image" : "video"; - - let transform: { scale_x: number; scale_y: number } | undefined; - if ((asset.type === "video" || asset.type === "image") && asset.width && asset.height) { - const scaleX = d.settings.width / asset.width; - const scaleY = d.settings.height / asset.height; - const scale = Math.min(scaleX, scaleY); - transform = { scale_x: scale, scale_y: scale }; - } - - const fileDurationFrames = secondsToFrames(asset.duration, d.settings.fps); - - const newClipId = d.addClipToTrack({ - type: clipType, - trackId: track.fullId, - startTime: dropStartTime, - duration: fileDurationFrames, - name: asset.name, - assetId: asset.id, - speed: 1, - assetDuration: clipType === "image" ? undefined : fileDurationFrames, - transform, - }); - - if (asset.type === "video" && track.pairedTrackId) { - const audioTrack = d.allTracks.find((t) => t.fullId === track.pairedTrackId); - if (audioTrack) { - const audioClipId = d.addClipToTrack({ - type: "audio", - trackId: audioTrack.fullId, - startTime: dropStartTime, - duration: fileDurationFrames, - name: `${asset.name} (Audio)`, - assetId: asset.id, - speed: 1, - assetDuration: fileDurationFrames, - }); - d.linkClipPair(newClipId, audioClipId); - } - } - - d.setSelectedClipIds([newClipId]); - })(); - }); - } + // Native file drops are not supported in EKS-backed mode. }; el.addEventListener("drop", handleDrop); diff --git a/apps/ui/src/components/timeline/use-asset-store.ts b/apps/ui/src/components/timeline/use-asset-store.ts index ed3f30c..33cea23 100644 --- a/apps/ui/src/components/timeline/use-asset-store.ts +++ b/apps/ui/src/components/timeline/use-asset-store.ts @@ -1,10 +1,6 @@ import { secondsToFrames } from "@tooscut/render-engine"; -/** - * Asset store for managing imported media files. - */ import { create } from "zustand"; -import { db } from "../../state/db"; import { useVideoEditorStore, type MediaAsset as StoreMediaAsset, @@ -14,18 +10,16 @@ export interface MediaAsset { id: string; type: "video" | "audio" | "image" | "lut"; name: string; - /** Object URL for playback/preview */ + /** URL used for playback/preview */ url: string; - /** Duration in seconds (0 for images) */ + /** Duration in seconds (0 for unknown/non-temporal sources) */ duration: number; - /** File size in bytes */ + /** File size in bytes (0 when unavailable) */ size: number; - /** Original file reference */ - file: File; - /** Video/image dimensions */ + /** Optional file reference for in-memory/local sources */ + file?: File; width?: number; height?: number; - /** Thumbnail data URL (for video/image) */ thumbnailUrl?: string; } @@ -49,7 +43,12 @@ export const useAssetStore = create((set) => ({ addAsset: (asset) => set((state) => ({ assets: [...state.assets, asset] })), - addAssets: (assets) => set((state) => ({ assets: [...state.assets, ...assets] })), + addAssets: (assets) => + set((state) => { + const existing = new Set(state.assets.map((a) => a.id)); + const deduped = assets.filter((asset) => !existing.has(asset.id)); + return { assets: [...state.assets, ...deduped] }; + }), removeAsset: (id) => set((state) => { @@ -78,488 +77,21 @@ export const useAssetStore = create((set) => ({ setError: (error) => set({ error }), })); -/** - * Get file type from MIME type. - */ -function getAssetType(mimeType: string): "video" | "audio" | "image" | null { - if (mimeType.startsWith("video/")) return "video"; - if (mimeType.startsWith("audio/")) return "audio"; - if (mimeType.startsWith("image/")) return "image"; - return null; -} - -/** - * Generate a unique ID. - */ -function generateId(): string { - return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; -} - -/** - * Get video duration and dimensions. - */ -async function getVideoMetadata( - file: File, -): Promise<{ duration: number; width: number; height: number }> { - return new Promise((resolve, reject) => { - const video = document.createElement("video"); - video.preload = "metadata"; - - video.onloadedmetadata = () => { - resolve({ - duration: video.duration, - width: video.videoWidth, - height: video.videoHeight, - }); - URL.revokeObjectURL(video.src); - }; - - video.onerror = () => { - URL.revokeObjectURL(video.src); - reject(new Error("Failed to load video metadata")); - }; - - video.src = URL.createObjectURL(file); - }); -} - -/** - * Get audio duration. - */ -async function getAudioDuration(file: File): Promise { - return new Promise((resolve, reject) => { - const audio = document.createElement("audio"); - audio.preload = "metadata"; - - audio.onloadedmetadata = () => { - resolve(audio.duration); - URL.revokeObjectURL(audio.src); - }; - - audio.onerror = () => { - URL.revokeObjectURL(audio.src); - reject(new Error("Failed to load audio metadata")); - }; - - audio.src = URL.createObjectURL(file); - }); -} - -/** - * Get image dimensions. - */ -async function getImageDimensions(file: File): Promise<{ width: number; height: number }> { - return new Promise((resolve, reject) => { - const img = new Image(); - - img.onload = () => { - resolve({ - width: img.naturalWidth, - height: img.naturalHeight, - }); - URL.revokeObjectURL(img.src); - }; - - img.onerror = () => { - URL.revokeObjectURL(img.src); - reject(new Error("Failed to load image")); - }; - - img.src = URL.createObjectURL(file); - }); -} - -/** - * Generate a thumbnail for video or image. - */ -async function generateThumbnail(file: File, type: "video" | "image"): Promise { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("Failed to get canvas context"); - - const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 2; - const thumbnailSize = Math.round(200 * dpr); - - if (type === "image") { - const img = new Image(); - const url = URL.createObjectURL(file); - - return new Promise((resolve, reject) => { - img.onload = () => { - const scale = Math.min(thumbnailSize / img.width, thumbnailSize / img.height); - canvas.width = img.width * scale; - canvas.height = img.height * scale; - ctx.drawImage(img, 0, 0, canvas.width, canvas.height); - URL.revokeObjectURL(url); - resolve(canvas.toDataURL("image/png")); - }; - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error("Failed to load image for thumbnail")); - }; - img.src = url; - }); - } - - // Video thumbnail — use createImageBitmap for high-quality capture - const video = document.createElement("video"); - video.preload = "auto"; - video.muted = true; - video.playsInline = true; - const url = URL.createObjectURL(file); - - return new Promise((resolve, reject) => { - video.onloadedmetadata = () => { - video.currentTime = Math.min(1, video.duration / 2); - }; - - video.onseeked = async () => { - try { - // Wait for the frame to be fully decoded - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { - await new Promise((r) => { - video.addEventListener("canplay", () => r(), { once: true }); - }); - } - // Capture the frame respecting display rotation. - // drawImage(video) always applies the video's rotation metadata, - // unlike createImageBitmap which may return raw unrotated frames. - const displayW = video.videoWidth; - const displayH = video.videoHeight; - const scale = Math.min(thumbnailSize / displayW, thumbnailSize / displayH); - canvas.width = Math.round(displayW * scale); - canvas.height = Math.round(displayH * scale); - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = "high"; - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - URL.revokeObjectURL(url); - resolve(canvas.toDataURL("image/png")); - } catch (err) { - URL.revokeObjectURL(url); - reject(err instanceof Error ? err : new Error("Failed to generate thumbnail")); - } - }; - - video.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error("Failed to load video for thumbnail")); - }; - - video.src = url; - }); -} - -/** - * Import files and create MediaAsset objects. - * Optionally accepts FileSystemFileHandles for persistence across sessions. - */ -export async function importFiles( - files: FileList | File[], - fileHandles?: FileSystemFileHandle[], -): Promise { - const assets: MediaAsset[] = []; - const fileArray = Array.from(files); - - // Build a set of existing assets for dedup (match by name + size + type) - const existingAssets = useAssetStore.getState().assets; - const existingKeys = new Set(existingAssets.map((a) => `${a.name}|${a.size}|${a.type}`)); - - for (let i = 0; i < fileArray.length; i++) { - const file = fileArray[i]; - const type = getAssetType(file.type); - if (!type) { - console.warn(`Unsupported file type: ${file.type}`); - continue; - } - - // Skip duplicates (same name, size, and type already imported) - const dedupeKey = `${file.name}|${file.size}|${type}`; - if (existingKeys.has(dedupeKey)) { - continue; - } - existingKeys.add(dedupeKey); - - try { - const id = generateId(); - const url = URL.createObjectURL(file); - - let duration = 0; - let width: number | undefined; - let height: number | undefined; - let thumbnailUrl: string | undefined; - - if (type === "video") { - const meta = await getVideoMetadata(file); - duration = meta.duration; - width = meta.width; - height = meta.height; - thumbnailUrl = await generateThumbnail(file, "video"); - } else if (type === "audio") { - duration = await getAudioDuration(file); - } else if (type === "image") { - const dims = await getImageDimensions(file); - width = dims.width; - height = dims.height; - duration = 10; // Default duration for images (can be freely extended) - thumbnailUrl = await generateThumbnail(file, "image"); - } - - // Store file handle in IndexedDB for persistence - const handle = fileHandles?.[i]; - if (handle) { - await db.fileHandles.put({ - id, - handle, - fileName: file.name, - mimeType: file.type, - size: file.size, - storedAt: Date.now(), - }); - } - - assets.push({ - id, - type, - name: file.name, - url, - duration, - size: file.size, - file, - width, - height, - thumbnailUrl, - }); - } catch (error) { - console.error(`Failed to import ${file.name}:`, error); - } - } - - return assets; -} - -/** Map accept strings like "video/*" to file extensions for showOpenFilePicker */ -const ACCEPT_MAP: Record = { - "video/*": [".mp4", ".webm", ".mov", ".avi", ".mkv"], - "audio/*": [".mp3", ".wav", ".ogg", ".aac", ".flac"], - "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"], -}; - -/** - * Import files using the File System Access API (showOpenFilePicker). - * This provides FileSystemFileHandles that persist across sessions. - * @param accept - Comma-separated accept string (e.g. "video/*,audio/*,image/*") - */ -export async function importFilesWithPicker( - accept = "video/*,audio/*,image/*", -): Promise { - const picker = ( - window as unknown as { - showOpenFilePicker?: (options: Record) => Promise; - } - ).showOpenFilePicker; - - if (!picker) { - // Fall back to regular file picker (no handles → assets won't persist) - return new Promise((resolve) => { - const input = document.createElement("input"); - input.type = "file"; - input.multiple = true; - input.accept = accept; - input.onchange = async () => { - if (input.files && input.files.length > 0) { - resolve(await importFiles(input.files)); - } else { - resolve([]); - } - }; - input.click(); - }); - } - - // Build accept entries from the accept string - const acceptEntries: Record = {}; - for (const part of accept.split(",")) { - const key = part.trim(); - if (ACCEPT_MAP[key]) { - acceptEntries[key] = ACCEPT_MAP[key]; - } - } - - try { - const handles = await picker({ - multiple: true, - types: [ - { - description: "Media files", - accept: acceptEntries, - }, - ], - }); - - const files = await Promise.all(handles.map((h: FileSystemFileHandle) => h.getFile())); - return importFiles(files, handles); - } catch (err) { - // User cancelled picker - if (err instanceof DOMException && err.name === "AbortError") { - return []; - } - throw err; - } -} - -interface HydratedAsset extends StoreMediaAsset { - file: File; - size: number; -} - -/** - * Hydrate assets from stored file handles. - * Only restores assets where permission is already "granted". - * Assets needing permission (state="prompt") are returned in `pendingIds` — - * these require a user gesture to call requestPermission(). - */ -export async function hydrateAssets(assets: StoreMediaAsset[]): Promise<{ - hydrated: HydratedAsset[]; - pendingIds: string[]; - failedIds: string[]; -}> { - const hydrated: HydratedAsset[] = []; - const pendingIds: string[] = []; - const failedIds: string[] = []; - - for (const asset of assets) { - // Assets that already have URLs (e.g. remote) don't need file handle hydration - if (asset.url !== "") continue; - // LUT assets are hydrated separately via lut-manager - if (asset.type === "lut") continue; - - try { - const stored = await db.fileHandles.get(asset.id); - if (!stored) { - failedIds.push(asset.id); - continue; - } - - const handle = stored.handle as FileSystemFileHandle & { - queryPermission: (opts: { mode: string }) => Promise; - }; - const permission: string = await handle.queryPermission({ mode: "read" }); - - if (permission === "granted") { - const file = await stored.handle.getFile(); - const url = URL.createObjectURL(file); - hydrated.push({ ...asset, url, file, size: file.size }); - } else if (permission === "prompt") { - // Needs user gesture to request — can't do it automatically - pendingIds.push(asset.id); - } else { - failedIds.push(asset.id); - } - } catch (err) { - console.error(`[hydrate] asset ${asset.id}: error`, err); - failedIds.push(asset.id); - } - } - - return { hydrated, pendingIds, failedIds }; -} - -/** - * Request file permission for pending assets. MUST be called from a user gesture (click). - * For each asset ID, looks up the stored handle, requests permission, and restores the file. - */ -export async function requestPermissionAndHydrate( - assetIds: string[], - allAssets: StoreMediaAsset[], -): Promise { - const hydrated: HydratedAsset[] = []; - - for (const assetId of assetIds) { - const asset = allAssets.find((a) => a.id === assetId); - if (!asset) continue; - - try { - const stored = await db.fileHandles.get(assetId); - if (!stored) continue; - - const handle = stored.handle as FileSystemFileHandle & { - requestPermission: (opts: { mode: string }) => Promise; - }; - const result: string = await handle.requestPermission({ mode: "read" }); - - if (result === "granted") { - const file = await stored.handle.getFile(); - const url = URL.createObjectURL(file); - hydrated.push({ ...asset, url, file, size: file.size }); - } - } catch (err) { - console.error(`[permission] asset ${assetId}: error`, err); - } - } - - return hydrated; -} - -/** - * Format file size for display. - */ -/** - * Handle a native file drop event, extracting FileSystemFileHandles when available. - */ -export function handleNativeFileDrop( - e: DragEvent, - onDrop: (files: FileList, handles?: FileSystemFileHandle[]) => void, -) { - if (!e.dataTransfer || e.dataTransfer.files.length === 0) return; - - const files = e.dataTransfer.files; - const items = e.dataTransfer.items; - - if (items.length > 0 && "getAsFileSystemHandle" in DataTransferItem.prototype) { - const handlePromises = Array.from(items) - .filter((item) => item.kind === "file") - .map((item) => - ( - item as unknown as { - getAsFileSystemHandle(): Promise; - } - ).getAsFileSystemHandle(), - ); - - void Promise.all(handlePromises) - .then((results) => { - const handles = results.filter( - (h): h is FileSystemFileHandle => h != null && h.kind === "file", - ); - onDrop(files, handles.length > 0 ? handles : undefined); - }) - .catch(() => { - onDrop(files); - }); - } else { - onDrop(files); - } -} - -/** - * Sync imported assets to both stores: - * - useAssetStore (UI: thumbnails, File objects, drag-to-timeline) - * - useVideoEditorStore (persistence: auto-saved to IndexedDB) - */ export function addAssetsToStores(imported: MediaAsset[]) { useAssetStore.getState().addAssets(imported); const projectFps = useVideoEditorStore.getState().settings.fps; + const editorAssets: StoreMediaAsset[] = imported.map((a) => ({ id: a.id, type: a.type, name: a.name, url: a.url, - // Convert source duration (seconds) to project frames duration: a.type === "image" ? 0 : secondsToFrames(a.duration, projectFps), width: a.width, height: a.height, thumbnailUrl: a.thumbnailUrl, })); + useVideoEditorStore.getState().addAssets(editorAssets); } @@ -570,9 +102,6 @@ export function formatFileSize(bytes: number): string { return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } -/** - * Format duration for display. - */ export function formatDuration(seconds: number): string { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); diff --git a/apps/ui/src/hooks/use-audio-engine.ts b/apps/ui/src/hooks/use-audio-engine.ts index 51d7076..12fd306 100644 --- a/apps/ui/src/hooks/use-audio-engine.ts +++ b/apps/ui/src/hooks/use-audio-engine.ts @@ -88,12 +88,24 @@ export function useAudioEngine() { for (const asset of audioAssets) { if (uploadedSourcesRef.current.has(asset.id)) continue; + if (!asset.file && !asset.url) continue; uploadedSourcesRef.current.add(asset.id); - engine.registerAudioSource(asset.id, asset.file).catch((err) => { - console.error(`[useAudioEngine] Failed to register audio for ${asset.id}:`, err); - uploadedSourcesRef.current.delete(asset.id); - }); + const sourcePromise = asset.file + ? Promise.resolve(asset.file as File | Blob) + : fetch(asset.url).then(async (response) => { + if (!response.ok) { + throw new Error(`Failed to fetch audio source: ${response.status}`); + } + return response.blob(); + }); + + sourcePromise + .then((source) => engine.registerAudioSource(asset.id, source)) + .catch((err) => { + console.error(`[useAudioEngine] Failed to register audio for ${asset.id}:`, err); + uploadedSourcesRef.current.delete(asset.id); + }); } }, [assets, isWasmReady]); diff --git a/apps/ui/src/hooks/use-auto-save.ts b/apps/ui/src/hooks/use-auto-save.ts index 36667f7..0731dfd 100644 --- a/apps/ui/src/hooks/use-auto-save.ts +++ b/apps/ui/src/hooks/use-auto-save.ts @@ -2,7 +2,7 @@ import { EvaluatorManager } from "@tooscut/render-engine"; import { useEffect, useRef } from "react"; import { buildLayersForTime } from "../lib/layer-builder"; -import { db } from "../state/db"; +import { upsertProject } from "../lib/project-api"; import { useVideoEditorStore } from "../state/video-editor-store"; import { getSharedCompositor } from "../workers/compositor-api"; @@ -10,17 +10,21 @@ function saveProject(projectId: string) { const { clips, tracks, crossTransitions, assets, settings } = useVideoEditorStore.getState(); const assetsToSave = assets.map((a) => ({ ...a, - url: "", // blob URLs aren't persistable; restored via file handle hydration + // TAMS URLs are ephemeral; store only stable asset IDs and metadata. + url: "", })); - return db.projects.update(projectId, { - content: { - tracks, - clips, - crossTransitions, - assets: assetsToSave, + + return upsertProject({ + data: { + id: projectId, + settings: settings as unknown as Record, + content: { + tracks, + clips, + crossTransitions, + assets: assetsToSave, + }, }, - settings, - updatedAt: Date.now(), }); } @@ -68,7 +72,7 @@ async function generateThumbnail(projectId: string): Promise { const blob = new Blob([arrayBuffer], { type: "image/jpeg" }); const dataUrl = await blobToDataUrl(blob); - await db.projects.update(projectId, { thumbnailDataUrl: dataUrl }); + await upsertProject({ data: { id: projectId, thumbnail: dataUrl } }); } catch (err) { // Thumbnail generation is best-effort — don't break auto-save console.warn("[useAutoSave] Thumbnail generation failed:", err); diff --git a/apps/ui/src/lib/auth.ts b/apps/ui/src/lib/auth.ts new file mode 100644 index 0000000..ecb2be9 --- /dev/null +++ b/apps/ui/src/lib/auth.ts @@ -0,0 +1,39 @@ +import { getRequest } from "@tanstack/react-start/server"; + +export interface CognitoUser { + sub: string; + name: string; + email: string; +} + +export function getAuthenticatedUser(): CognitoUser { + const request = getRequest(); + + const sub = request.headers.get("x-amzn-oidc-identity"); + if (!sub) { + throw new Error("Unauthenticated: missing x-amzn-oidc-identity"); + } + + const dataHeader = request.headers.get("x-amzn-oidc-data"); + let name = ""; + let email = ""; + + if (dataHeader) { + try { + const parts = dataHeader.split("."); + const payloadB64 = parts[1]; + if (payloadB64) { + const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8")) as { + name?: string; + email?: string; + }; + name = payload.name ?? ""; + email = payload.email ?? ""; + } + } catch { + // Non-fatal: authenticated sub is still available. + } + } + + return { sub, name, email }; +} diff --git a/apps/ui/src/lib/db-client.ts b/apps/ui/src/lib/db-client.ts new file mode 100644 index 0000000..b051f6f --- /dev/null +++ b/apps/ui/src/lib/db-client.ts @@ -0,0 +1,29 @@ +import postgres from "postgres"; + +function createClients() { + if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL is not set"); + } + if (!process.env.DATABASE_RO_URL) { + throw new Error("DATABASE_RO_URL is not set"); + } + + const write = postgres(process.env.DATABASE_URL, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + }); + + const read = postgres(process.env.DATABASE_RO_URL, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + }); + + return { write, read }; +} + +const clients = createClients(); + +export const db = clients.write; +export const dbRead = clients.read; diff --git a/apps/ui/src/lib/lut-manager.ts b/apps/ui/src/lib/lut-manager.ts index 2cf649e..ffdf044 100644 --- a/apps/ui/src/lib/lut-manager.ts +++ b/apps/ui/src/lib/lut-manager.ts @@ -1,167 +1,21 @@ /** - * LUT asset management — import, persist, hydrate, and upload to GPU. - * - * LUT files (.cube) are stored as assets in the editor store with their - * FileSystemFileHandle persisted in IndexedDB. On page load, LUT assets - * are re-parsed and uploaded to the compositor GPU. + * LUT asset management. + * LUT data is fetched from asset URLs and uploaded to the compositor. */ import type { MediaAsset } from "../state/video-editor-store"; -import { db } from "../state/db"; -import { useVideoEditorStore } from "../state/video-editor-store"; import { getSharedCompositor } from "../workers/compositor-api"; import { parseCubeFile, type CubeLut } from "./cube-parser"; -/** - * Import a .cube LUT file from a FileSystemFileHandle. - * - * - Parses the .cube file - * - Stores the FileSystemFileHandle in IndexedDB - * - Adds the LUT as an asset in the editor store - * - Uploads the LUT data to the GPU compositor - * - * Returns the asset ID. - */ -async function importLutFromHandle( - handle: FileSystemFileHandle, -): Promise<{ id: string; name: string } | null> { - try { - const file = await handle.getFile(); - const text = await file.text(); - const parsed = parseCubeFile(text); - const lutName = parsed.title || file.name.replace(/\.cube$/i, ""); - const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; - - // Persist file handle - await db.fileHandles.put({ - id, - handle, - fileName: file.name, - mimeType: "application/x-cube", - size: file.size, - storedAt: Date.now(), - }); - - // Add to editor store as asset - const store = useVideoEditorStore.getState(); - store.addAssets([ - { - id, - type: "lut", - name: lutName, - url: "", - duration: 0, - lutSize: parsed.size, - }, - ]); - - // Upload to GPU - await uploadLutToGpu(id, parsed); - - return { id, name: lutName }; - } catch (err) { - console.error("Failed to import LUT:", err); - return null; - } -} - -/** - * Import a .cube LUT file via the File System Access API picker. - * Falls back to if the API is unavailable. - */ -export async function importLutWithPicker(): Promise<{ - id: string; - name: string; -} | null> { - if ("showOpenFilePicker" in window) { - try { - const [handle] = await (window as any).showOpenFilePicker({ - types: [ - { - description: "3D LUT Files", - accept: { "application/x-cube": [".cube"] }, - }, - ], - multiple: false, - }); - return importLutFromHandle(handle); - } catch { - // User cancelled - return null; - } - } - - // Fallback: — no persistence across refreshes - return new Promise((resolve) => { - const input = document.createElement("input"); - input.type = "file"; - input.accept = ".cube"; - input.onchange = async () => { - const file = input.files?.[0]; - if (!file) { - resolve(null); - return; - } - try { - const text = await file.text(); - const parsed = parseCubeFile(text); - const lutName = parsed.title || file.name.replace(/\.cube$/i, ""); - const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; - - useVideoEditorStore.getState().addAssets([ - { - id, - type: "lut", - name: lutName, - url: "", - duration: 0, - lutSize: parsed.size, - }, - ]); - - await uploadLutToGpu(id, parsed); - resolve({ id, name: lutName }); - } catch (err) { - console.error("Failed to import LUT:", err); - resolve(null); - } - }; - input.click(); - }); -} - -/** - * Hydrate a LUT asset on page load — re-parse the file and upload to GPU. - * - * Called during project hydration for each LUT asset. - */ export async function hydrateLutAsset(asset: MediaAsset): Promise { - if (asset.type !== "lut") return false; - - const stored = await db.fileHandles.get(asset.id); - if (!stored) { - console.warn(`[lut-manager] No stored handle for LUT asset ${asset.id}`); - return false; - } + if (asset.type !== "lut" || !asset.url) return false; try { - // Check/request permission - const permission = await (stored.handle as any).queryPermission({ - mode: "read", - }); - if (permission !== "granted") { - const requested = await (stored.handle as any).requestPermission({ - mode: "read", - }); - if (requested !== "granted") { - console.warn(`[lut-manager] Permission denied for LUT ${asset.id}`); - return false; - } - } + const response = await fetch(asset.url); + if (!response.ok) return false; - const file = await stored.handle.getFile(); - const text = await file.text(); + const text = await response.text(); const parsed = parseCubeFile(text); await uploadLutToGpu(asset.id, parsed); return true; @@ -171,9 +25,6 @@ export async function hydrateLutAsset(asset: MediaAsset): Promise { } } -/** - * Upload parsed LUT data to the GPU compositor. - */ async function uploadLutToGpu(lutId: string, parsed: CubeLut): Promise { const compositor = getSharedCompositor(); if (compositor) { diff --git a/apps/ui/src/lib/project-api.ts b/apps/ui/src/lib/project-api.ts new file mode 100644 index 0000000..e174fd6 --- /dev/null +++ b/apps/ui/src/lib/project-api.ts @@ -0,0 +1,129 @@ +import { createServerFn } from "@tanstack/react-start"; + +import { getAuthenticatedUser } from "./auth"; +import { db, dbRead } from "./db-client"; + +export interface ProjectRow { + id: string; + name: string; + settings: Record; + content: Record; + thumbnail: string | null; + archived: boolean; + created_at: string; + updated_at: string; +} + +export const listActiveProjects = createServerFn({ method: "GET" }).handler(async () => { + const { sub } = getAuthenticatedUser(); + + const rows = await dbRead` + SELECT id, name, settings, thumbnail, archived, created_at, updated_at + FROM projects + WHERE user_id = ${sub} AND archived = FALSE + ORDER BY updated_at DESC + `; + + return [...rows]; +}); + +export const listArchivedProjects = createServerFn({ method: "GET" }).handler(async () => { + const { sub } = getAuthenticatedUser(); + + const rows = await dbRead` + SELECT id, name, settings, thumbnail, archived, created_at, updated_at + FROM projects + WHERE user_id = ${sub} AND archived = TRUE + ORDER BY updated_at DESC + `; + + return [...rows]; +}); + +export const getMe = createServerFn({ method: "GET" }).handler(async () => { + return getAuthenticatedUser(); +}); + +export const getProject = createServerFn({ method: "GET" }) + .inputValidator((id: string) => id) + .handler(async ({ data: id }) => { + const { sub } = getAuthenticatedUser(); + const rows = await dbRead` + SELECT * + FROM projects + WHERE id = ${id} AND user_id = ${sub} + LIMIT 1 + `; + + return rows[0] ?? null; + }); + +interface CreateProjectInput { + id: string; + name: string; + settings: Record; + content: Record; +} + +export const createProject = createServerFn({ method: "POST" }) + .inputValidator((p: CreateProjectInput) => p) + .handler(async ({ data }) => { + const { sub } = getAuthenticatedUser(); + + await db` + INSERT INTO projects (id, user_id, name, settings, content) + VALUES (${data.id}, ${sub}, ${data.name}, ${data.settings as never}, ${data.content as never}) + `; + + return { id: data.id }; + }); + +interface UpsertProjectInput { + id: string; + name?: string; + settings?: Record; + content?: Record; + thumbnail?: string; +} + +export const upsertProject = createServerFn({ method: "POST" }) + .inputValidator((p: UpsertProjectInput) => p) + .handler(async ({ data }) => { + const { sub } = getAuthenticatedUser(); + + await db` + UPDATE projects + SET + name = COALESCE(${data.name ?? null}, name), + settings = COALESCE(${(data.settings as never) ?? null}, settings), + content = COALESCE(${(data.content as never) ?? null}, content), + thumbnail = COALESCE(${data.thumbnail ?? null}, thumbnail), + updated_at = now() + WHERE id = ${data.id} AND user_id = ${sub} + `; + }); + +interface ArchiveInput { + id: string; + archived: boolean; +} + +export const setProjectArchived = createServerFn({ method: "POST" }) + .inputValidator((p: ArchiveInput) => p) + .handler(async ({ data }) => { + const { sub } = getAuthenticatedUser(); + + await db` + UPDATE projects + SET archived = ${data.archived}, updated_at = now() + WHERE id = ${data.id} AND user_id = ${sub} + `; + }); + +export const deleteProject = createServerFn({ method: "POST" }) + .inputValidator((id: string) => id) + .handler(async ({ data: id }) => { + const { sub } = getAuthenticatedUser(); + + await db`DELETE FROM projects WHERE id = ${id} AND user_id = ${sub}`; + }); diff --git a/apps/ui/src/lib/tams-client.ts b/apps/ui/src/lib/tams-client.ts new file mode 100644 index 0000000..7f4b230 --- /dev/null +++ b/apps/ui/src/lib/tams-client.ts @@ -0,0 +1,193 @@ +/** + * TAMS (Time-addressable Media Store) API client. + * Based on the TAMS OpenAPI spec v8.0. + */ + +export interface TamsConfig { + endpoint: string; + authType: "bearer" | "basic" | "url_token"; + /** Bearer token or URL token */ + token?: string; + /** Basic auth username */ + username?: string; + /** Basic auth password */ + password?: string; +} + +export interface TamsSource { + id: string; + format: string; + label?: string; + description?: string; + tags?: Record; +} + +export interface TamsFlow { + id: string; + source_id: string; + format?: string; + codec?: string; + label?: string; + description?: string; + container?: string; + frame_width?: number; + frame_height?: number; + tags?: Record; +} + +export interface TamsFlowSegment { + object_id: string; + timerange: string; + ts_offset?: string; + key_frame_count?: number; + get_urls?: TamsGetUrl[]; +} + +export interface TamsGetUrl { + url: string; + label?: string; + content_type?: string; +} + +export interface TamsServiceInfo { + name?: string; + description?: string; + type: string; + api_version: string; + service_version?: string; + min_object_timeout: string; +} + +export class TamsClientError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + this.name = "TamsClientError"; + } +} + +export class TamsClient { + private config: TamsConfig; + + constructor(config: TamsConfig) { + this.config = config; + } + + private get baseUrl(): string { + return this.config.endpoint.replace(/\/$/, ""); + } + + private getHeaders(): HeadersInit { + const headers: HeadersInit = { + Accept: "application/json", + }; + + switch (this.config.authType) { + case "bearer": + if (this.config.token) { + headers["Authorization"] = `Bearer ${this.config.token}`; + } + break; + case "basic": + if (this.config.username && this.config.password) { + const encoded = btoa(`${this.config.username}:${this.config.password}`); + headers["Authorization"] = `Basic ${encoded}`; + } + break; + // url_token is handled via query param + } + + return headers; + } + + private buildUrl(path: string, params?: Record): string { + const url = new URL(`${this.baseUrl}${path}`); + if (this.config.authType === "url_token" && this.config.token) { + url.searchParams.set("access_token", this.config.token); + } + if (params) { + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + } + return url.toString(); + } + + private async request(path: string, params?: Record): Promise { + const url = this.buildUrl(path, params); + const response = await fetch(url, { + headers: this.getHeaders(), + credentials: "same-origin", + }); + + if (!response.ok) { + throw new TamsClientError(response.status, `TAMS API error: ${response.status} ${response.statusText}`); + } + + return response.json() as Promise; + } + + /** Test connection and retrieve service info */ + async getServiceInfo(): Promise { + return this.request("/service"); + } + + /** List all sources */ + async listSources(params?: { label?: string; format?: string; limit?: number }): Promise { + const queryParams: Record = {}; + if (params?.label) queryParams["label"] = params.label; + if (params?.format) queryParams["format"] = params.format; + if (params?.limit) queryParams["limit"] = String(params.limit); + return this.request("/sources", queryParams); + } + + /** Get a specific source */ + async getSource(sourceId: string): Promise { + return this.request(`/sources/${encodeURIComponent(sourceId)}`); + } + + /** List all flows */ + async listFlows(params?: { + source_id?: string; + format?: string; + codec?: string; + label?: string; + limit?: number; + }): Promise { + const queryParams: Record = {}; + if (params?.source_id) queryParams["source_id"] = params.source_id; + if (params?.format) queryParams["format"] = params.format; + if (params?.codec) queryParams["codec"] = params.codec; + if (params?.label) queryParams["label"] = params.label; + if (params?.limit) queryParams["limit"] = String(params.limit); + return this.request("/flows", queryParams); + } + + /** Get a specific flow */ + async getFlow(flowId: string): Promise { + return this.request(`/flows/${encodeURIComponent(flowId)}`); + } + + /** List segments for a flow */ + async listFlowSegments( + flowId: string, + params?: { timerange?: string; limit?: number; reverse_order?: boolean }, + ): Promise { + const queryParams: Record = {}; + if (params?.timerange) queryParams["timerange"] = params.timerange; + if (params?.limit) queryParams["limit"] = String(params.limit); + if (params?.reverse_order) queryParams["reverse_order"] = "true"; + return this.request( + `/flows/${encodeURIComponent(flowId)}/segments`, + queryParams, + ); + } + + /** Get download URL for a flow segment's media object */ + getSegmentDownloadUrl(segment: TamsFlowSegment): string | null { + if (!segment.get_urls || segment.get_urls.length === 0) return null; + return segment.get_urls[0].url; + } +} diff --git a/apps/ui/src/routes/editor/$projectId.tsx b/apps/ui/src/routes/editor/$projectId.tsx index bd4aa27..6209fa9 100644 --- a/apps/ui/src/routes/editor/$projectId.tsx +++ b/apps/ui/src/routes/editor/$projectId.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { framesToSeconds } from "@tooscut/render-engine"; import { useEffect, useState } from "react"; @@ -14,35 +14,39 @@ import { PropertiesPanel } from "../../components/editor/properties-panel"; import { TimelinePanel } from "../../components/editor/timeline-panel"; import { Toolbar } from "../../components/editor/toolbar"; import { VideoEditorLayout } from "../../components/editor/video-editor-layout"; -import { - useAssetStore, - hydrateAssets, - requestPermissionAndHydrate, -} from "../../components/timeline/use-asset-store"; +import { useAssetStore } from "../../components/timeline/use-asset-store"; import { Button } from "../../components/ui/button"; import { useAudioEngine } from "../../hooks/use-audio-engine"; import { useAutoSave } from "../../hooks/use-auto-save"; import { hydrateLutAsset } from "../../lib/lut-manager"; -import { db } from "../../state/db"; +import { getProject, type ProjectRow } from "../../lib/project-api"; +import { TamsClient } from "../../lib/tams-client"; +import { useTamsSettingsStore } from "../../state/tams-settings-store"; import { useVideoEditorStore } from "../../state/video-editor-store"; export const Route = createFileRoute("/editor/$projectId")({ component: EditorPage, ssr: false, pendingComponent: EditorSkeleton, + loader: async ({ params }) => { + const project = (await getProject({ data: params.projectId })) as ProjectRow | null; + if (!project) { + throw redirect({ to: "/projects" }); + } + return project; + }, validateSearch: (search: Record) => ({ new: search.new === true || search.new === "true", }), }); function EditorPage() { + const project = Route.useLoaderData(); const { projectId } = Route.useParams(); const { new: isNewProject } = Route.useSearch(); const navigate = useNavigate(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [pendingPermissionIds, setPendingPermissionIds] = useState([]); - const [savedAssets, setSavedAssets] = useState([]); // Initialize audio engine for playback useAudioEngine(); @@ -56,65 +60,62 @@ function EditorPage() { async function loadProject() { try { - const project = await db.projects.get(projectId); - if (cancelled) return; - - if (!project) { - setError("Project not found"); - setLoading(false); - return; - } - // Hydrate the Zustand store with project data + const content = project.content as { + tracks: any[]; + clips: any[]; + crossTransitions?: any[]; + assets: MediaAsset[]; + }; useVideoEditorStore.getState().loadProject({ - tracks: project.content.tracks, - clips: project.content.clips, - crossTransitions: project.content.crossTransitions, - assets: project.content.assets, - settings: project.settings, + tracks: content.tracks as any, + clips: content.clips as any, + crossTransitions: content.crossTransitions as any, + assets: content.assets, + settings: project.settings as any, }); // Clear undo history so the empty initial state isn't in the stack useVideoEditorStore.temporal.getState().clear(); - // Hydrate file handles: restore blob URLs from stored FileSystemFileHandles - if (project.content.assets.length > 0) { - const { hydrated, pendingIds } = await hydrateAssets(project.content.assets); - if (cancelled) return; + const tamsConfig = useTamsSettingsStore.getState().tamsConfig; + const store = useVideoEditorStore.getState(); - // Update the main editor store with restored blob URLs - const store = useVideoEditorStore.getState(); - for (const asset of hydrated) { - store.updateAssetUrl(asset.id, asset.url); - } - - // Populate the UI asset store (with file objects for preview/thumbnails) - // Hydrated assets have duration in frames (from editor store) — convert back to seconds - if (hydrated.length > 0) { - const fps = project.settings.fps; - useAssetStore.getState().addAssets( - hydrated.map((a) => ({ - ...a, - duration: framesToSeconds(a.duration, fps), - })), - ); + if (tamsConfig) { + const client = new TamsClient(tamsConfig); + for (const asset of store.assets) { + if (cancelled) return; + if (asset.url !== "") continue; + + try { + const segments = await client.listFlowSegments(asset.id, { limit: 1 }); + const url = segments[0] ? client.getSegmentDownloadUrl(segments[0]) : null; + if (url) { + store.updateAssetUrl(asset.id, url); + } + } catch { + // Non-fatal: flow may no longer exist in TAMS. + } } + } - // If some assets need user permission, show prompt - if (pendingIds.length > 0) { - setPendingPermissionIds(pendingIds); - setSavedAssets(project.content.assets); - } + useAssetStore.getState().addAssets( + store.assets.map((asset) => ({ + ...asset, + duration: framesToSeconds(asset.duration, store.settings.fps), + size: 0, + })), + ); - // Hydrate LUT assets (parse .cube files and upload to GPU) - const lutAssets = project.content.assets.filter((a: MediaAsset) => a.type === "lut"); - for (const lutAsset of lutAssets) { - if (cancelled) return; - await hydrateLutAsset(lutAsset); - } + const lutAssets = store.assets.filter((asset) => asset.type === "lut"); + for (const lutAsset of lutAssets) { + if (cancelled) return; + await hydrateLutAsset(lutAsset); } - setLoading(false); + if (!cancelled) { + setLoading(false); + } } catch (err) { if (!cancelled) { console.error("Failed to load project:", err); @@ -133,29 +134,7 @@ function EditorPage() { useVideoEditorStore.temporal.getState().clear(); useAssetStore.getState().clearAssets(); }; - }, [projectId]); - - const handleGrantPermission = async () => { - const hydrated = await requestPermissionAndHydrate(pendingPermissionIds, savedAssets); - - // Update both stores with the newly-granted assets - const store = useVideoEditorStore.getState(); - for (const asset of hydrated) { - store.updateAssetUrl(asset.id, asset.url); - } - if (hydrated.length > 0) { - const fps = useVideoEditorStore.getState().settings.fps; - useAssetStore.getState().addAssets( - hydrated.map((a) => ({ - ...a, - duration: framesToSeconds(a.duration, fps), - })), - ); - } - - setPendingPermissionIds([]); - setSavedAssets([]); - }; + }, [project, projectId]); return ( <> @@ -171,22 +150,6 @@ function EditorPage() { {/* Keyboard shortcuts modal (press ? to open) */} - {/* Permission prompt — must be triggered by user gesture */} - {pendingPermissionIds.length > 0 && ( -
-
-

- {pendingPermissionIds.length} file{pendingPermissionIds.length > 1 ? "s" : ""} need - access permission -

-

- Your browser requires you to re-grant access to local files after a reload. -

- -
-
- )} - {/* Overlay loading/error state so the canvas stays mounted (transferControlToOffscreen is one-shot) */} {(loading || error) && (
diff --git a/apps/ui/src/routes/index.tsx b/apps/ui/src/routes/index.tsx index d02d8e9..a7bbd56 100644 --- a/apps/ui/src/routes/index.tsx +++ b/apps/ui/src/routes/index.tsx @@ -1,4 +1,6 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; +import { getRequest } from "@tanstack/react-start/server"; import { FaqsSection } from "../components/faqs-section"; import { FeatureSection } from "../components/feature-section"; @@ -6,7 +8,20 @@ import { Footer } from "../components/footer"; import { Header } from "../components/header"; import { HeroSection } from "../components/hero"; -export const Route = createFileRoute("/")({ component: LandingPage }); +const checkAuth = createServerFn({ method: "GET" }).handler(async () => { + const request = getRequest(); + return !!request.headers.get("x-amzn-oidc-identity"); +}); + +export const Route = createFileRoute("/")({ + component: LandingPage, + loader: async () => { + const isAuthenticated = await checkAuth(); + if (isAuthenticated) { + throw redirect({ to: "/projects" }); + } + }, +}); function LandingPage() { return ( diff --git a/apps/ui/src/routes/projects.tsx b/apps/ui/src/routes/projects.tsx index 818e761..7d82896 100644 --- a/apps/ui/src/routes/projects.tsx +++ b/apps/ui/src/routes/projects.tsx @@ -1,9 +1,17 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { Link } from "@tanstack/react-router"; +import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router"; import { addTrackPair, type EditableTrack } from "@tooscut/render-engine"; -import { useLiveQuery } from "dexie-react-hooks"; -import { Plus, Trash2, Film, Clock, Monitor, TriangleAlert, Smartphone } from "lucide-react"; -import { useState, useRef, useEffect } from "react"; +import { + Archive, + Clock, + Film, + Monitor, + Plus, + RotateCcw, + Smartphone, + Trash2, + TriangleAlert, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { LogoIcon } from "../components/logo"; import { Button } from "../components/ui/button"; @@ -23,16 +31,39 @@ import { EmptyMedia, EmptyTitle, } from "../components/ui/empty"; -import { db, type LocalProject } from "../state/db"; +import { + createProject, + deleteProject, + getMe, + listActiveProjects, + listArchivedProjects, + setProjectArchived, + upsertProject, + type ProjectRow, +} from "../lib/project-api"; -export const Route = createFileRoute("/projects")({ component: ProjectChooser }); +export const Route = createFileRoute("/projects")({ + component: ProjectChooser, + loader: async () => { + const [me, active, archived] = (await Promise.all([ + getMe(), + listActiveProjects(), + listArchivedProjects(), + ])) as [ + { sub: string; name: string; email: string }, + ProjectRow[], + ProjectRow[], + ]; + return { me, active, archived }; + }, +}); function generateId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } -function formatDate(timestamp: number): string { - const date = new Date(timestamp); +function formatDate(value: string): string { + const date = new Date(value); const now = new Date(); const diff = now.getTime() - date.getTime(); @@ -47,19 +78,19 @@ function formatDate(timestamp: number): string { function isChromiumBrowser(): boolean { if (typeof navigator === "undefined") return true; const ua = navigator.userAgent; - // Chrome/Chromium-based browsers have "Chrome/" in UA but not "Edg/" false positive — Edge is also Chromium - // Key non-Chromium browsers: Firefox (Gecko), Safari (without Chrome token) const hasChrome = /Chrome\//.test(ua); const isFirefox = /Firefox\//.test(ua); - // Safari has "Safari/" but NOT "Chrome/" in the UA const isSafariOnly = /Safari\//.test(ua) && !hasChrome; return hasChrome || (!isFirefox && !isSafariOnly); } function ProjectChooser() { + const { me, active, archived } = Route.useLoaderData(); + const router = useRouter(); const navigate = useNavigate(); - const projects = useLiveQuery(() => db.projects.orderBy("updatedAt").reverse().toArray()); - const [deleteTarget, setDeleteTarget] = useState(null); + + const [tab, setTab] = useState<"active" | "archived">("active"); + const [deleteTarget, setDeleteTarget] = useState(null); const [showBrowserWarning, setShowBrowserWarning] = useState(() => !isChromiumBrowser()); const [showMobileWarning, setShowMobileWarning] = useState( () => @@ -76,68 +107,75 @@ function ProjectChooser() { const audioTrackId2 = generateId(); const { tracks } = addTrackPair(tracks1, videoTrackId2, audioTrackId2); - const project: LocalProject = { - id, - name: "Untitled Project", - settings: { width: 1920, height: 1080, fps: { numerator: 30, denominator: 1 } }, - content: { - tracks, - clips: [], - assets: [], + await createProject({ + data: { + id, + name: "Untitled Project", + settings: { width: 1920, height: 1080, fps: { numerator: 30, denominator: 1 } }, + content: { + tracks, + clips: [], + crossTransitions: [], + assets: [], + }, }, - thumbnailDataUrl: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }; + }); - await db.projects.add(project); void navigate({ to: "/editor/$projectId", params: { projectId: id }, - search: { new: true } as any, // eslint-disable-line @typescript-eslint/no-unsafe-assignment + search: { new: true } as never, }); }; + const handleArchive = async (id: string) => { + await setProjectArchived({ data: { id, archived: true } }); + await router.invalidate(); + setTab("active"); + }; + + const handleUnarchive = async (id: string) => { + await setProjectArchived({ data: { id, archived: false } }); + await router.invalidate(); + setTab("archived"); + }; + const handleConfirmDelete = async () => { if (!deleteTarget) return; - const assetIds = deleteTarget.content.assets.map((a) => a.id); - await db.projects.delete(deleteTarget.id); - if (assetIds.length > 0) { - await db.fileHandles.bulkDelete(assetIds); - } + await deleteProject({ data: deleteTarget.id }); setDeleteTarget(null); + await router.invalidate(); }; const handleOpenProject = (projectId: string) => { void navigate({ to: "/editor/$projectId", params: { projectId }, - search: { new: false } as any, // eslint-disable-line @typescript-eslint/no-unsafe-assignment + search: { new: false } as never, }); }; - if (projects === undefined) { - return ( -
-
-
- ); - } + const projects = tab === "active" ? active : archived; return (
- + +
+ {me.name ? {me.name} : null} - )} +
@@ -169,7 +207,7 @@ function ProjectChooser() {

Browser not fully supported

This editor relies on WebGPU for rendering, which currently works best in Chrome or - other Chromium-based browsers. You may experience issues in your current browser. + other Chromium-based browsers.

+ +
{projects.length === 0 ? (
- + {tab === "active" ? : } - No projects yet + {tab === "active" ? "No active projects" : "No archived projects"} - Create your first project to start editing video. + {tab === "active" + ? "Create your first project to start editing video." + : "Archived projects will appear here."} - - - + {tab === "active" ? ( + + + + ) : null}
) : ( @@ -217,7 +275,10 @@ function ProjectChooser() { ))} @@ -249,12 +310,18 @@ function ProjectChooser() { function ProjectCard({ project, + isArchived, onOpen, + onArchive, + onUnarchive, onDelete, }: { - project: LocalProject; + project: ProjectRow; + isArchived: boolean; onOpen: (id: string) => void; - onDelete: (project: LocalProject) => void; + onArchive: (id: string) => void; + onUnarchive: (id: string) => void; + onDelete: (project: ProjectRow) => void; }) { return (
- {project.thumbnailDataUrl ? ( + {project.thumbnail ? ( {project.name} @@ -273,17 +340,47 @@ function ProjectCard({
)} - + +
+ {isArchived ? ( + + ) : ( + + )} + {isArchived ? ( + + ) : null} +
@@ -291,23 +388,48 @@ function ProjectCard({
- {formatDate(project.updatedAt)} - - - - {project.settings.width}x{project.settings.height} + {formatDate(project.updated_at)} +
); } -function ProjectName({ project }: { project: LocalProject }) { +function ProjectResolution({ settings }: { settings: unknown }) { + const width = + typeof settings === "object" && settings !== null && "width" in settings + ? Number((settings as { width?: unknown }).width) + : null; + const height = + typeof settings === "object" && settings !== null && "height" in settings + ? Number((settings as { height?: unknown }).height) + : null; + + if (!width || !height) { + return null; + } + + return ( + + + {width}x{height} + + ); +} + +function ProjectName({ project }: { project: ProjectRow }) { const [editing, setEditing] = useState(false); + const [displayName, setDisplayName] = useState(project.name); const [value, setValue] = useState(project.name); const inputRef = useRef(null); + useEffect(() => { + setDisplayName(project.name); + setValue(project.name); + }, [project.name]); + useEffect(() => { if (editing) { inputRef.current?.select(); @@ -316,10 +438,12 @@ function ProjectName({ project }: { project: LocalProject }) { const commit = () => { const trimmed = value.trim(); - if (trimmed && trimmed !== project.name) { - void db.projects.update(project.id, { name: trimmed }); + if (trimmed && trimmed !== displayName) { + void upsertProject({ data: { id: project.id, name: trimmed } }); + setDisplayName(trimmed); + setValue(trimmed); } else { - setValue(project.name); + setValue(displayName); } setEditing(false); }; @@ -335,7 +459,7 @@ function ProjectName({ project }: { project: LocalProject }) { onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") { - setValue(project.name); + setValue(displayName); setEditing(false); } }} @@ -349,11 +473,11 @@ function ProjectName({ project }: { project: LocalProject }) { className="truncate text-sm font-medium text-foreground" onDoubleClick={(e) => { e.stopPropagation(); - setValue(project.name); + setValue(displayName); setEditing(true); }} > - {project.name} + {displayName} ); } diff --git a/apps/ui/src/state/db.ts b/apps/ui/src/state/db.ts deleted file mode 100644 index d9c1baf..0000000 --- a/apps/ui/src/state/db.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { EditableTrack, CrossTransitionRef } from "@tooscut/render-engine"; - -import Dexie, { type Table } from "dexie"; - -import type { EditorClip, MediaAsset, ProjectSettings } from "./video-editor-store"; - -export interface LocalProject { - id: string; - name: string; - settings: ProjectSettings; - content: { - tracks: EditableTrack[]; - clips: EditorClip[]; - crossTransitions?: CrossTransitionRef[]; - assets: MediaAsset[]; - }; - thumbnailDataUrl: string | null; - createdAt: number; - updatedAt: number; -} - -interface StoredFileHandle { - id: string; - handle: FileSystemFileHandle; - fileName: string; - mimeType: string; - size: number; - storedAt: number; -} - -class EditorDatabase extends Dexie { - projects!: Table; - fileHandles!: Table; - - constructor() { - super("tooscut-editor"); - this.version(1).stores({ - projects: "id, updatedAt, name", - fileHandles: "id", - }); - - // V2: Migrate fps from number to FrameRate { numerator, denominator } - this.version(2) - .stores({ - projects: "id, updatedAt, name", - fileHandles: "id", - }) - .upgrade((tx) => { - return tx - .table("projects") - .toCollection() - .modify((project: LocalProject) => { - if (typeof project.settings?.fps === "number") { - project.settings.fps = { - numerator: project.settings.fps, - denominator: 1, - }; - } - }); - }); - - // V3: Convert all time-based values (seconds) to frame-based values (integer frames) - this.version(3) - .stores({ - projects: "id, updatedAt, name", - fileHandles: "id", - }) - .upgrade((tx) => { - return tx - .table("projects") - .toCollection() - .modify((project: LocalProject) => { - const fps = project.settings?.fps; - if (!fps?.numerator) return; - - const fpsFloat = fps.numerator / fps.denominator; - - // Convert clip time fields from seconds to frames - for (const clip of project.content?.clips ?? []) { - if (typeof clip.startTime === "number") { - clip.startTime = Math.round(clip.startTime * fpsFloat); - } - if (typeof clip.duration === "number" && clip.duration < 1000) { - // Heuristic: if duration < 1000, it's likely still in seconds - // (a 1000-frame clip at 30fps is ~33s, unlikely for seconds-based) - clip.duration = Math.max(1, Math.round(clip.duration * fpsFloat)); - } - if (typeof clip.inPoint === "number") { - clip.inPoint = Math.round(clip.inPoint * fpsFloat); - } - if (typeof clip.assetDuration === "number" && clip.assetDuration < 100000) { - clip.assetDuration = Math.round(clip.assetDuration * fpsFloat); - } - } - - // Convert cross-transition time fields - for (const ct of project.content?.crossTransitions ?? []) { - if (typeof ct.duration === "number" && ct.duration < 1000) { - ct.duration = Math.max(1, Math.round(ct.duration * fpsFloat)); - } - if (typeof ct.boundary === "number") { - ct.boundary = Math.round(ct.boundary * fpsFloat); - } - } - - // Convert asset durations - for (const asset of project.content?.assets ?? []) { - if (typeof asset.duration === "number" && asset.duration < 100000) { - asset.duration = Math.round(asset.duration * fpsFloat); - } - } - }); - }); - } -} - -export const db = new EditorDatabase(); diff --git a/apps/ui/src/state/tams-settings-store.ts b/apps/ui/src/state/tams-settings-store.ts new file mode 100644 index 0000000..28f441d --- /dev/null +++ b/apps/ui/src/state/tams-settings-store.ts @@ -0,0 +1,41 @@ +/** + * TAMS configuration store. + * Persisted to localStorage, separate from project state (not undo-tracked). + */ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +import type { TamsConfig } from "../lib/tams-client"; + +interface TamsSettingsState { + /** TAMS connection configuration */ + tamsConfig: TamsConfig | null; + /** Whether the connection has been verified */ + isConnected: boolean; + /** Last connection error */ + connectionError: string | null; + + setTamsConfig: (config: TamsConfig | null) => void; + setConnected: (connected: boolean) => void; + setConnectionError: (error: string | null) => void; +} + +export const useTamsSettingsStore = create()( + persist( + (set) => ({ + tamsConfig: null, + isConnected: false, + connectionError: null, + + setTamsConfig: (config) => set({ tamsConfig: config, isConnected: false, connectionError: null }), + setConnected: (connected) => set({ isConnected: connected }), + setConnectionError: (error) => set({ connectionError: error, isConnected: false }), + }), + { + name: "tooscut-tams-settings", + partialize: (state) => ({ + tamsConfig: state.tamsConfig, + }), + }, + ), +); diff --git a/docker-testing/Makefile b/docker-testing/Makefile new file mode 100644 index 0000000..f705fc1 --- /dev/null +++ b/docker-testing/Makefile @@ -0,0 +1,20 @@ +SHELL := /bin/bash + +COMPOSE := docker compose -f compose.yaml +MEDIA_VIDEO := media/sample-video.mp4 +MEDIA_AUDIO := media/sample-audio.wav + +.PHONY: setup serve destroy + +setup: + chmod +x media/generate.sh + ./media/generate.sh + $(COMPOSE) pull + $(COMPOSE) build + +serve: + $(COMPOSE) up -d --build + +destroy: + $(COMPOSE) down --volumes --remove-orphans --rmi local + rm -f $(MEDIA_VIDEO) $(MEDIA_AUDIO) diff --git a/docker-testing/compose.yaml b/docker-testing/compose.yaml new file mode 100644 index 0000000..2d6cbda --- /dev/null +++ b/docker-testing/compose.yaml @@ -0,0 +1,97 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: tooscut + POSTGRES_USER: tooscut + POSTGRES_PASSWORD: tooscut + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tooscut -d tooscut"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 5s + + app: + build: + context: .. + dockerfile: Dockerfile + environment: + DATABASE_URL: postgresql://tooscut:tooscut@postgres:5432/tooscut + DATABASE_RO_URL: postgresql://tooscut:tooscut@postgres:5432/tooscut + NODE_ENV: production + PORT: "3000" + HOST: "::" + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + alb-proxy: + image: nginx:alpine + depends_on: + app: + condition: service_healthy + ports: + - "8080:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://localhost/ >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 5s + + tams-mock: + image: stoplight/prism:5 + command: ["mock", "/app/tams.yaml", "--host", "0.0.0.0", "--port", "4010", "--multiprocess", "false"] + ports: + - "4010:4010" + volumes: + - ./tams-mock/tams.yaml:/app/tams.yaml:ro + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:4010/service').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + + tams-media: + image: nginx:alpine + ports: + - "4011:80" + volumes: + - ./media:/media:ro + - ./media/nginx.conf:/etc/nginx/conf.d/default.conf:ro + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://localhost/ >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 5s + +volumes: + pgdata: diff --git a/docker-testing/media/README.md b/docker-testing/media/README.md new file mode 100644 index 0000000..58c4584 --- /dev/null +++ b/docker-testing/media/README.md @@ -0,0 +1,15 @@ +# Sample Media Files + +Sample media files are not committed to git. Generate them locally before running manual QA. + +## Generate files + +Requirements: ffmpeg installed locally. + +Run: + +./generate.sh + +Generated files: +- sample-video.mp4 +- sample-audio.wav diff --git a/docker-testing/media/generate.sh b/docker-testing/media/generate.sh new file mode 100755 index 0000000..39b1a07 --- /dev/null +++ b/docker-testing/media/generate.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +ffmpeg -y -f lavfi -i testsrc2=duration=10:size=1920x1080:rate=25 \ + -c:v libx264 -preset ultrafast -pix_fmt yuv420p sample-video.mp4 + +ffmpeg -y -f lavfi -i sine=frequency=440:duration=10:sample_rate=48000 \ + -c:a pcm_s16le sample-audio.wav + +echo "Sample media generated." diff --git a/docker-testing/media/nginx.conf b/docker-testing/media/nginx.conf new file mode 100644 index 0000000..5621edd --- /dev/null +++ b/docker-testing/media/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name _; + root /media; + + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; + add_header Access-Control-Allow-Headers "*" always; + + if ($request_method = OPTIONS) { + return 204; + } + + location / { + autoindex on; + try_files $uri =404; + } +} diff --git a/docker-testing/media/sample-audio.wav b/docker-testing/media/sample-audio.wav new file mode 100644 index 0000000..325dc01 Binary files /dev/null and b/docker-testing/media/sample-audio.wav differ diff --git a/docker-testing/media/sample-video.mp4 b/docker-testing/media/sample-video.mp4 new file mode 100644 index 0000000..1925358 Binary files /dev/null and b/docker-testing/media/sample-video.mp4 differ diff --git a/docker-testing/nginx/default.conf b/docker-testing/nginx/default.conf new file mode 100644 index 0000000..ff1d739 --- /dev/null +++ b/docker-testing/nginx/default.conf @@ -0,0 +1,27 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name _; + + location / { + proxy_pass http://app:3000; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_set_header x-amzn-oidc-identity "test-user-sub-00000001"; + proxy_set_header x-amzn-oidc-data "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0ZXN0LXVzZXItc3ViLTAwMDAwMDAxIiwibmFtZSI6IlRlc3RpIEVkaXRvaWphIiwiZW1haWwiOiJ0ZXN0aS5lZGl0b2lqYUBkb21haW4udGxkIn0."; + + proxy_buffering off; + } +} diff --git a/docker-testing/postgres/init.sql b/docker-testing/postgres/init.sql new file mode 100644 index 0000000..0e5fb93 --- /dev/null +++ b/docker-testing/postgres/init.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT 'Untitled Project', + settings JSONB NOT NULL DEFAULT '{}', + content JSONB NOT NULL DEFAULT '{}', + thumbnail TEXT, + archived BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_projects_user_active + ON projects (user_id, updated_at DESC) + WHERE archived = FALSE; + +CREATE INDEX IF NOT EXISTS idx_projects_user_archived + ON projects (user_id, updated_at DESC) + WHERE archived = TRUE; + +INSERT INTO projects (id, user_id, name, settings, content) VALUES ( + 'test-project-001', + 'test-user-sub-00000001', + 'Sample Project', + '{"resolution":{"width":1920,"height":1080},"frameRate":25}', + '{}' +) ON CONFLICT (id) DO NOTHING; diff --git a/docker-testing/tams-mock/seed/flows.json b/docker-testing/tams-mock/seed/flows.json new file mode 100644 index 0000000..c6403aa --- /dev/null +++ b/docker-testing/tams-mock/seed/flows.json @@ -0,0 +1,28 @@ +[ + { + "id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", + "source_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "format": "urn:x-nmos:format:video", + "label": "Camera A - H.264 1080p", + "codec": "h264", + "frame_width": 1920, + "frame_height": 1080 + }, + { + "id": "d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f8a", + "source_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "format": "urn:x-nmos:format:video", + "label": "Camera A - ProRes 1080p", + "codec": "prores", + "frame_width": 1920, + "frame_height": 1080 + }, + { + "id": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b", + "source_id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", + "format": "urn:x-nmos:format:audio", + "label": "Mic 1 - PCM 48kHz", + "codec": "pcm_s16le", + "sample_rate": 48000 + } +] diff --git a/docker-testing/tams-mock/seed/segments.json b/docker-testing/tams-mock/seed/segments.json new file mode 100644 index 0000000..66dba2e --- /dev/null +++ b/docker-testing/tams-mock/seed/segments.json @@ -0,0 +1,32 @@ +{ + "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f": [ + { + "object_id": "f6a7b8c9-d0e1-4f2a-3b4c-5d6e7f8a9b0c", + "flow_id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", + "timerange": { + "start": "0:0", + "end": "10:0" + }, + "get_urls": [ + { + "url": "http://localhost:4011/sample-video.mp4" + } + ] + } + ], + "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b": [ + { + "object_id": "a7b8c9d0-e1f2-4a3b-4c5d-6e7f8a9b0c1d", + "flow_id": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b", + "timerange": { + "start": "0:0", + "end": "10:0" + }, + "get_urls": [ + { + "url": "http://localhost:4011/sample-audio.wav" + } + ] + } + ] +} diff --git a/docker-testing/tams-mock/seed/sources.json b/docker-testing/tams-mock/seed/sources.json new file mode 100644 index 0000000..ade3e82 --- /dev/null +++ b/docker-testing/tams-mock/seed/sources.json @@ -0,0 +1,18 @@ +[ + { + "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "format": "urn:x-nmos:format:video", + "label": "Camera A", + "tags": { + "location": ["studio-1"] + } + }, + { + "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", + "format": "urn:x-nmos:format:audio", + "label": "Mic 1", + "tags": { + "location": ["studio-1"] + } + } +] diff --git a/docker-testing/tams-mock/tams.yaml b/docker-testing/tams-mock/tams.yaml new file mode 100644 index 0000000..5e9274f --- /dev/null +++ b/docker-testing/tams-mock/tams.yaml @@ -0,0 +1,175 @@ +openapi: 3.1.0 +info: + title: Testing TAMS Mock + version: 8.0.0 +servers: + - url: http://localhost:4010 + +paths: + /service: + get: + operationId: getService + responses: + '200': + description: Service information + content: + application/json: + schema: + type: object + examples: + default: + value: + name: Testing TAMS + type: urn:x-tams:service:testing + api_version: "8.0" + min_object_timeout: "300:0" + + /sources: + get: + operationId: listSources + responses: + '200': + description: List sources + content: + application/json: + schema: + type: array + items: + type: object + examples: + default: + value: + - id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d + format: urn:x-nmos:format:video + label: Camera A + tags: + location: [studio-1] + - id: b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e + format: urn:x-nmos:format:audio + label: Mic 1 + tags: + location: [studio-1] + + /sources/{sourceId}: + get: + operationId: getSource + parameters: + - name: sourceId + in: path + required: true + schema: + type: string + responses: + '200': + description: Source details + content: + application/json: + schema: + type: object + examples: + default: + value: + id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d + format: urn:x-nmos:format:video + label: Camera A + tags: + location: [studio-1] + + /flows: + get: + operationId: listFlows + responses: + '200': + description: List flows + content: + application/json: + schema: + type: array + items: + type: object + examples: + default: + value: + - id: c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f + source_id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d + format: urn:x-nmos:format:video + label: Camera A - H.264 1080p + codec: h264 + frame_width: 1920 + frame_height: 1080 + - id: d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f8a + source_id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d + format: urn:x-nmos:format:video + label: Camera A - ProRes 1080p + codec: prores + frame_width: 1920 + frame_height: 1080 + - id: e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b + source_id: b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e + format: urn:x-nmos:format:audio + label: Mic 1 - PCM 48kHz + codec: pcm_s16le + sample_rate: 48000 + + /flows/{flowId}: + get: + operationId: getFlow + parameters: + - name: flowId + in: path + required: true + schema: + type: string + responses: + '200': + description: Flow details + content: + application/json: + schema: + type: object + examples: + default: + value: + id: c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f + source_id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d + format: urn:x-nmos:format:video + label: Camera A - H.264 1080p + codec: h264 + frame_width: 1920 + frame_height: 1080 + + /flows/{flowId}/segments: + get: + operationId: listFlowSegments + parameters: + - name: flowId + in: path + required: true + schema: + type: string + responses: + '200': + description: Flow segments + content: + application/json: + schema: + type: array + items: + type: object + examples: + default: + value: + - object_id: f6a7b8c9-d0e1-4f2a-3b4c-5d6e7f8a9b0c + flow_id: c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f + timerange: + start: "0:0" + end: "10:0" + get_urls: + - url: http://localhost:4011/sample-video.mp4 + - object_id: a7b8c9d0-e1f2-4a3b-4c5d-6e7f8a9b0c1d + flow_id: e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b + timerange: + start: "0:0" + end: "10:0" + get_urls: + - url: http://localhost:4011/sample-audio.wav