diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index cd79efb..50a541d 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -7,7 +7,6 @@ const nextConfig: NextConfig = { }, reactStrictMode: true, productionBrowserSourceMaps: true, - output: "standalone", images: { remotePatterns: [ { diff --git a/apps/web/package.json b/apps/web/package.json index f043828..a05cddb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@hookform/resolvers": "^3.9.1", "@opencut/auth": "workspace:*", "@opencut/db": "workspace:*", + "@radix-ui/react-dialog": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", "@t3-oss/env-core": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8", diff --git a/apps/web/src/app/api/ai/generate-video/route.ts b/apps/web/src/app/api/ai/generate-video/route.ts new file mode 100644 index 0000000..22f16b0 --- /dev/null +++ b/apps/web/src/app/api/ai/generate-video/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { fal } from "@fal-ai/client"; + +// Configure fal with server-side credentials +fal.config({ + credentials: process.env.FAL_KEY, +}); + +export async function POST(request: Request) { + try { + const { prompt, image_url, aspect_ratio, duration, generate_audio, resolution } = await request.json(); + + if (!prompt) { + return NextResponse.json( + { error: "Prompt is required" }, + { status: 400 } + ); + } + + if (!image_url) { + return NextResponse.json( + { error: "Image URL is required" }, + { status: 400 } + ); + } + + // Build input params for Veo 3.1 Fast + const input = { + prompt, + image_url, + aspect_ratio: aspect_ratio || "16:9", + duration: duration || "8s", + generate_audio: generate_audio !== undefined ? generate_audio : true, + resolution: resolution || "720p", + }; + + console.log("Using fal-ai/veo3.1/fast/image-to-video with input:", input); + + // Call fal API directly from server + const result = await fal.subscribe("fal-ai/veo3.1/fast/image-to-video", { + input, + logs: true, + onQueueUpdate: (update) => { + if (update.status === "IN_PROGRESS") { + update.logs.map((log) => log.message).forEach(console.log); + } + }, + }); + + return NextResponse.json(result.data); + } catch (error) { + console.error("Video generation error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Video generation failed" }, + { status: 500 } + ); + } +} + diff --git a/apps/web/src/components/editor/media-panel/views/ai.tsx b/apps/web/src/components/editor/media-panel/views/ai.tsx index ce8a4c5..f68cf83 100644 --- a/apps/web/src/components/editor/media-panel/views/ai.tsx +++ b/apps/web/src/components/editor/media-panel/views/ai.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useAIStore } from "@/stores/ai-store"; -import { Loader2, Sparkles, Clock, Image as ImageIcon, Download, X, ChevronDown, ChevronUp } from "lucide-react"; +import { Loader2, Sparkles, Image as ImageIcon, Download, X, ChevronDown, ChevronUp, Video } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -15,9 +15,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { SegmentedControl } from "@/components/ui/segmented-control"; import { cn } from "@/lib/utils"; import Image from "next/image"; -import type { AspectRatio, OutputFormat } from "@/types/ai"; +import type { AspectRatio, OutputFormat, VideoDuration, VideoResolution, VideoAspectRatio, AIMode } from "@/types/ai"; const ASPECT_RATIOS: { value: AspectRatio; label: string }[] = [ { value: "1:1", label: "Square (1:1)" }, @@ -34,14 +36,30 @@ const OUTPUT_FORMATS: { value: OutputFormat; label: string }[] = [ { value: "webp", label: "WebP" }, ]; +const VIDEO_ASPECT_RATIOS: { value: VideoAspectRatio; label: string }[] = [ + { value: "16:9", label: "Landscape (16:9)" }, + { value: "9:16", label: "Portrait (9:16)" }, +]; + +const VIDEO_DURATIONS: { value: VideoDuration; label: string }[] = [ + { value: "8s", label: "8 seconds" }, +]; + +const VIDEO_RESOLUTIONS: { value: VideoResolution; label: string }[] = [ + { value: "720p", label: "720p" }, + { value: "1080p", label: "1080p" }, +]; + export function AIView() { const { + mode, + setMode, + // Image generation prompt, aspectRatio, outputFormat, isGenerating, currentResult, - generationHistory, error, referenceImageUrls, setPrompt, @@ -49,9 +67,27 @@ export function AIView() { setOutputFormat, generate, addToTimeline, - clearHistory, clearError, clearReferenceImages, + // Video generation + videoPrompt, + videoAspectRatio, + videoDuration, + videoResolution, + generateAudio, + isGeneratingVideo, + currentVideoResult, + videoError, + videoReferenceImageUrl, + setVideoPrompt, + setVideoAspectRatio, + setVideoDuration, + setVideoResolution, + setGenerateAudio, + generateVideo, + addVideoToTimeline, + clearVideoError, + clearVideoReferenceImage, } = useAIStore(); const [addingToTimeline, setAddingToTimeline] = useState(null); @@ -74,10 +110,154 @@ export function AIView() { } }; + const handleGenerateVideo = async () => { + await generateVideo(); + }; + + const handleAddVideoToTimeline = async (videoUrl: string) => { + setAddingToTimeline(videoUrl); + try { + await addVideoToTimeline(videoUrl); + toast("Video added to timeline"); + } catch (error) { + toast.error("Failed to add video to timeline"); + } finally { + setAddingToTimeline(null); + } + }; + return (
+ {/* Mode Selector */} + + {/* Generation Form */}
+ {mode === "image" ? ( + + ) : ( + + )} +
+ + {/* Results */} + +
+ {mode === "image" ? ( + <> + {/* Current Image Result */} + {currentResult && ( +
+

Generated Image

+ +
+ )} + + + ) : ( + <> + {/* Current Video Result */} + {currentVideoResult && ( +
+

Generated Video

+ +
+ )} + + + )} +
+
+
+ ); +} + +// Image Generation Form Component +function ImageGenerationForm({ + prompt, + aspectRatio, + outputFormat, + isGenerating, + error, + referenceImageUrls, + isEditMode, + isReferenceExpanded, + setPrompt, + setAspectRatio, + setOutputFormat, + clearReferenceImages, + setIsReferenceExpanded, + handleGenerate, + clearError, +}: { + prompt: string; + aspectRatio: AspectRatio; + outputFormat: OutputFormat; + isGenerating: boolean; + error: string | null; + referenceImageUrls: string[]; + isEditMode: boolean; + isReferenceExpanded: boolean; + setPrompt: (prompt: string) => void; + setAspectRatio: (ratio: AspectRatio) => void; + setOutputFormat: (format: OutputFormat) => void; + clearReferenceImages: () => void; + setIsReferenceExpanded: (expanded: boolean) => void; + handleGenerate: () => void; + clearError: () => void; +}) { + return ( +
{/* Edit Mode Reference Image - Collapsible */} {isEditMode && (
@@ -220,58 +400,194 @@ export function AIView() {
)} +
+ ); +} + +// Video Generation Form Component +function VideoGenerationForm({ + videoPrompt, + videoAspectRatio, + videoDuration, + videoResolution, + generateAudio, + isGeneratingVideo, + videoError, + videoReferenceImageUrl, + setVideoPrompt, + setVideoAspectRatio, + setVideoDuration, + setVideoResolution, + setGenerateAudio, + clearVideoReferenceImage, + handleGenerateVideo, + clearVideoError, +}: { + videoPrompt: string; + videoAspectRatio: VideoAspectRatio; + videoDuration: VideoDuration; + videoResolution: VideoResolution; + generateAudio: boolean; + isGeneratingVideo: boolean; + videoError: string | null; + videoReferenceImageUrl: string | null; + setVideoPrompt: (prompt: string) => void; + setVideoAspectRatio: (ratio: VideoAspectRatio) => void; + setVideoDuration: (duration: VideoDuration) => void; + setVideoResolution: (resolution: VideoResolution) => void; + setGenerateAudio: (generate: boolean) => void; + clearVideoReferenceImage: () => void; + handleGenerateVideo: () => void; + clearVideoError: () => void; +}) { + return ( +
+ {/* Reference Frame */} + {videoReferenceImageUrl ? ( +
+
+ + +
+
+ Reference frame for video +
+
+ ) : ( +
+
+ )} + +
+ + setVideoPrompt(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleGenerateVideo(); + } + }} + disabled={isGeneratingVideo} + />
- {/* Results */} - -
- {/* Current Result */} - {currentResult && ( -
-

Generated Image

- -
- )} +
+
+ + +
- {/* History */} - {generationHistory.length > 0 && ( -
-
-
- -

Recent

-
- -
-
- {generationHistory.map((item) => ( - - ))} -
-
- )} +
+ +
- +
+ +
+ + +
+ + + + {videoError && ( +
+ {videoError} + +
+ )}
); } @@ -325,4 +641,51 @@ function GeneratedImageCard({
); +} + +function GeneratedVideoCard({ + video, + onAddToTimeline, + isAdding, + compact = false, +}: { + video: { url: string }; + onAddToTimeline: (url: string) => void; + isAdding: boolean; + compact?: boolean; +}) { + return ( +
+
+ {/* biome-ignore lint/a11y/useMediaCaption: Video preview doesn't need captions */} +
+
+ +
+
+ ); } \ No newline at end of file diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 36f6a2b..6fafc1f 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -31,6 +31,7 @@ import { LayoutGuideOverlay } from "./layout-guide-overlay"; import { Label } from "../ui/label"; import { SocialsIcon } from "../icons"; import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store"; +import { VideoPlayer } from "@/components/ui/video-player"; interface ActiveElement { element: TimelineElement; @@ -42,13 +43,14 @@ export function PreviewPanel() { const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); const { mediaFiles } = useMediaStore(); const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); - const { isPlaying, volume, muted } = usePlaybackStore(); + const { isPlaying, volume, muted, previewQuality } = usePlaybackStore(); const { activeProject } = useProjectStore(); const { currentScene } = useSceneStore(); const previewRef = useRef(null); const canvasRef = useRef(null); const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } = useFrameCache(); + const prevQualityRef = useRef(previewQuality); const lastFrameTimeRef = useRef(0); const renderSeqRef = useRef(0); const offscreenCanvasRef = useRef( @@ -293,6 +295,16 @@ export function PreviewPanel() { const activeElements = getActiveElements(); + // Invalidate cache when preview quality changes + useEffect(() => { + if (prevQualityRef.current !== previewQuality) { + invalidateCache(); + lastFrameTimeRef.current = -Infinity; + renderSeqRef.current++; + prevQualityRef.current = previewQuality; + } + }, [previewQuality, invalidateCache]); + // Ensure first frame after mount/seek renders immediately useEffect(() => { const onSeek = () => { @@ -473,14 +485,18 @@ export function PreviewPanel() { const mainCtx = canvas.getContext("2d"); if (!mainCtx) return; - // Set canvas internal resolution to avoid blurry scaling - const displayWidth = Math.max(1, Math.floor(previewDimensions.width)); - const displayHeight = Math.max(1, Math.floor(previewDimensions.height)); + // Set canvas internal resolution with quality scaling for performance + // Lower quality = fewer pixels to render = better performance + const displayWidth = Math.max(1, Math.floor(previewDimensions.width * previewQuality)); + const displayHeight = Math.max(1, Math.floor(previewDimensions.height * previewQuality)); if (canvas.width !== displayWidth || canvas.height !== displayHeight) { canvas.width = displayWidth; canvas.height = displayHeight; } + // Keep projectCanvasSize at full resolution - the renderer scales automatically + // Only the output canvas resolution changes for performance + // Throttle rendering to project FPS during playback only const fps = activeProject?.fps || DEFAULT_FPS; const minDelta = 1 / fps; @@ -685,6 +701,7 @@ export function PreviewPanel() { cacheFrame, preRenderNearbyFrames, isPlaying, + previewQuality, ]); // Get media elements for blur background (video/image only) @@ -703,8 +720,29 @@ export function PreviewPanel() { // Render blur background layer (handled by canvas now) const renderBlurBackground = () => null; - // Render an element (canvas handles visuals now). Audio playback to be implemented via Web Audio. - const renderElement = (_elementData: ActiveElement) => null; + // Render video elements for audio playback (visuals are on canvas) + const renderElement = (elementData: ActiveElement) => { + const { element, mediaItem } = elementData; + + // Only render video elements for audio playback + if (element.type === "media" && mediaItem?.type === "video" && mediaItem.url) { + return ( + + ); + } + + return null; + }; return ( <> @@ -736,6 +774,7 @@ export function PreviewPanel() { top: 0, width: previewDimensions.width, height: previewDimensions.height, + imageRendering: previewQuality < 1.0 ? "auto" : "auto", }} aria-label="Video preview canvas" /> @@ -745,6 +784,11 @@ export function PreviewPanel() { activeElements.map((elementData) => renderElement(elementData)) )} + {previewQuality < 1.0 && ( +
+ {Math.round(previewQuality * 100)}% quality +
+ )} ) : null} diff --git a/apps/web/src/components/editor/preview-quality-control.tsx b/apps/web/src/components/editor/preview-quality-control.tsx new file mode 100644 index 0000000..5e2f06f --- /dev/null +++ b/apps/web/src/components/editor/preview-quality-control.tsx @@ -0,0 +1,44 @@ +import { Button } from "../ui/button"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { Monitor } from "lucide-react"; + +const QUALITY_PRESETS = [ + { label: "25%", value: 0.25, description: "Fastest" }, + { label: "50%", value: 0.5, description: "Fast" }, + { label: "75%", value: 0.75, description: "Balanced" }, + { label: "100%", value: 1.0, description: "Full Quality" }, +]; + +export function PreviewQualityControl() { + const { previewQuality, setPreviewQuality } = usePlaybackStore(); + + return ( +
+
+ +

Preview Quality

+
+

+ Lower quality = better performance. Final export is always full quality. +

+
+ {QUALITY_PRESETS.map((preset) => ( + + ))} +
+
+ Current: {Math.round(previewQuality * 100)}% resolution +
+
+ ); +} + diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 150f951..5d0542d 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -7,6 +7,8 @@ import { AudioProperties } from "./audio-properties"; import { MediaProperties } from "./media-properties"; import { TextProperties } from "./text-properties"; import { SquareSlashIcon } from "lucide-react"; +import { PreviewQualityControl } from "../preview-quality-control"; +import { SpeedControl } from "../speed-control"; export function PropertiesPanel() { const { selectedElements, tracks } = useTimelineStore(); @@ -54,17 +56,28 @@ export function PropertiesPanel() { function EmptyView() { return ( -
- -
-

It’s empty here

-

- Click an element on the timeline to edit its properties -

+ +
+
+ +
+

No selection

+

+ Click an element on the timeline to edit its properties +

+
+
+ +
+ +
+ +
+
-
+ ); } diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index 193e469..f9ae2f4 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -11,6 +11,7 @@ import { Volume2, VolumeX, Sparkles, + Video, } from "lucide-react"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; @@ -164,14 +165,17 @@ export function TimelineElement({ if (element.type !== "media") return; const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); - if (!mediaItem || mediaItem.type !== "image") return; + if (!mediaItem || mediaItem.type !== "image" || !mediaItem.url) return; // Dynamically import AI store to avoid circular dependencies const { useAIStore } = await import("@/stores/ai-store"); - const { setReferenceImages } = useAIStore.getState(); + const { setReferenceImages, setMode } = useAIStore.getState(); // Set the image as reference for editing setReferenceImages([mediaItem.url]); + + // Set mode to image + setMode("image"); // Switch to AI tab setActiveTab("ai"); @@ -182,6 +186,34 @@ export function TimelineElement({ }); }; + const handleAnimateWithAI = async (e: React.MouseEvent) => { + e.stopPropagation(); + + // Check if element is an image + if (element.type !== "media") return; + + const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); + if (!mediaItem || mediaItem.type !== "image" || !mediaItem.url) return; + + // Dynamically import AI store to avoid circular dependencies + const { useAIStore } = await import("@/stores/ai-store"); + const { setVideoReferenceImage, setMode } = useAIStore.getState(); + + // Set the image as reference for video generation + setVideoReferenceImage(mediaItem.url); + + // Set mode to video + setMode("video"); + + // Switch to AI tab + setActiveTab("ai"); + + // Show info toast + toast("Ready to animate", { + description: "Enter a prompt to bring this image to life", + }); + }; + const renderElementContent = () => { if (element.type === "text") { return ( @@ -396,10 +428,16 @@ export function TimelineElement({ Replace clip {mediaItem?.type === "image" && ( - - - Edit with AI - + <> + + + Edit with AI + + + + )} )} diff --git a/apps/web/src/components/ui/segmented-control.tsx b/apps/web/src/components/ui/segmented-control.tsx new file mode 100644 index 0000000..abe19e9 --- /dev/null +++ b/apps/web/src/components/ui/segmented-control.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +interface SegmentedControlOption { + value: T; + label: string; +} + +interface SegmentedControlProps { + options: SegmentedControlOption[]; + value: T; + onChange: (value: T) => void; + className?: string; +} + +export function SegmentedControl({ + options, + value, + onChange, + className, +}: SegmentedControlProps) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + diff --git a/apps/web/src/lib/fal-client.ts b/apps/web/src/lib/fal-client.ts index 011c822..4bfa009 100644 --- a/apps/web/src/lib/fal-client.ts +++ b/apps/web/src/lib/fal-client.ts @@ -1,4 +1,4 @@ -import type { AIGenerationParams, AIGenerationResult } from "@/types/ai"; +import type { AIGenerationParams, AIGenerationResult, VideoGenerationParams, VideoGenerationResult } from "@/types/ai"; /** * Convert blob URL to base64 data URI @@ -69,4 +69,53 @@ export async function generateImage({ error instanceof Error ? error.message : "Failed to generate image. Please try again." ); } +} + +/** + * Generate a video from an image using fal.ai's Veo 3.1 Fast model + * Makes a server-side API call to protect the API key + */ +export async function generateVideo({ + prompt, + image_url, + aspect_ratio, + duration, + generate_audio, + resolution, +}: VideoGenerationParams): Promise { + try { + // Convert blob URL to base64 data URI if needed + let processedImageUrl = image_url; + if (image_url.startsWith('blob:')) { + processedImageUrl = await blobUrlToDataUri(image_url); + } + + const response = await fetch("/api/ai/generate-video", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt, + image_url: processedImageUrl, + aspect_ratio, + duration, + generate_audio, + resolution, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Video generation failed"); + } + + const data = await response.json(); + return data as VideoGenerationResult; + } catch (error) { + console.error("Video generation error:", error); + throw new Error( + error instanceof Error ? error.message : "Failed to generate video. Please try again." + ); + } } \ No newline at end of file diff --git a/apps/web/src/stores/ai-store.ts b/apps/web/src/stores/ai-store.ts index f3d2713..acfc57e 100644 --- a/apps/web/src/stores/ai-store.ts +++ b/apps/web/src/stores/ai-store.ts @@ -5,26 +5,49 @@ import type { AIGenerationResult, GenerationHistoryItem, AspectRatio, - OutputFormat + OutputFormat, + AIMode, + VideoGenerationParams, + VideoGenerationResult, + VideoGenerationHistoryItem, + VideoDuration, + VideoResolution, + VideoAspectRatio } from "@/types/ai"; import type { MediaFile } from "@/types/media"; -import { generateImage } from "@/lib/fal-client"; +import { generateImage, generateVideo } from "@/lib/fal-client"; interface AIStore { - // State + // Mode + mode: AIMode; + + // Image generation state prompt: string; aspectRatio: AspectRatio; outputFormat: OutputFormat; isGenerating: boolean; - generationHistory: GenerationHistoryItem[]; currentResult: AIGenerationResult | null; error: string | null; - // Image editing mode referenceImageUrls: string[]; - // Track current project to clear history on project switch + + // Video generation state + videoPrompt: string; + videoAspectRatio: VideoAspectRatio; + videoDuration: VideoDuration; + videoResolution: VideoResolution; + generateAudio: boolean; + isGeneratingVideo: boolean; + currentVideoResult: VideoGenerationResult | null; + videoError: string | null; + videoReferenceImageUrl: string | null; + + // Track current project to clear session on project switch currentProjectId: string | null; - // Actions + // Mode actions + setMode: (mode: AIMode) => void; + + // Image generation actions setPrompt: (prompt: string) => void; setAspectRatio: (ratio: AspectRatio) => void; setOutputFormat: (format: OutputFormat) => void; @@ -32,28 +55,55 @@ interface AIStore { clearReferenceImages: () => void; generate: () => Promise; addToTimeline: (imageUrl: string) => Promise; - clearHistory: () => void; clearError: () => void; + + // Video generation actions + setVideoPrompt: (prompt: string) => void; + setVideoAspectRatio: (ratio: VideoAspectRatio) => void; + setVideoDuration: (duration: VideoDuration) => void; + setVideoResolution: (resolution: VideoResolution) => void; + setGenerateAudio: (generate: boolean) => void; + setVideoReferenceImage: (url: string | null) => void; + clearVideoReferenceImage: () => void; + generateVideo: () => Promise; + addVideoToTimeline: (videoUrl: string) => Promise; + clearVideoError: () => void; + clearProjectSession: (projectId: string | null) => void; } -const MAX_HISTORY = 20; - export const useAIStore = create()( persist( (set, get) => ({ // Initial state + mode: "image", + + // Image generation state prompt: "", aspectRatio: "1:1", outputFormat: "jpeg", isGenerating: false, - generationHistory: [], currentResult: null, error: null, referenceImageUrls: [], + + // Video generation state + videoPrompt: "", + videoAspectRatio: "16:9", + videoDuration: "8s", + videoResolution: "720p", + generateAudio: true, + isGeneratingVideo: false, + currentVideoResult: null, + videoError: null, + videoReferenceImageUrl: null, + currentProjectId: null, - // Actions + // Mode actions + setMode: (mode) => set({ mode }), + + // Image generation actions setPrompt: (prompt) => set({ prompt }), setAspectRatio: (ratio) => set({ aspectRatio: ratio }), setOutputFormat: (format) => set({ outputFormat: format }), @@ -87,21 +137,17 @@ export const useAIStore = create()( const result = await generateImage(params); - const historyItem: GenerationHistoryItem = { - id: crypto.randomUUID(), - prompt: params.prompt, - params, - result, - timestamp: Date.now(), - }; - - set((state) => ({ - currentResult: result, - generationHistory: [ - historyItem, - ...state.generationHistory, - ].slice(0, MAX_HISTORY), - })); + // Clean up previous result's blob URLs + const { currentResult } = get(); + if (currentResult) { + for (const image of currentResult.images) { + if (image.url && image.url.startsWith('blob:')) { + URL.revokeObjectURL(image.url); + } + } + } + + set({ currentResult: result }); } catch (error) { console.error("AI generation failed:", error); set({ @@ -165,27 +211,200 @@ export const useAIStore = create()( const { currentTime } = usePlaybackStore.getState(); const { addElementAtTime } = useTimelineStore.getState(); addElementAtTime(added, currentTime); + + // Clear current result after successfully adding to timeline + const { currentResult } = get(); + if (currentResult?.images.some(img => img.url === imageUrl)) { + for (const img of currentResult.images) { + if (img.url.startsWith('blob:')) { + URL.revokeObjectURL(img.url); + } + } + set({ currentResult: null }); + } } catch (error) { console.error("Failed to add to timeline:", error); throw error; } }, - clearHistory: () => set({ generationHistory: [] }), clearError: () => set({ error: null }), + // Video generation actions + setVideoPrompt: (videoPrompt) => set({ videoPrompt }), + setVideoAspectRatio: (videoAspectRatio) => set({ videoAspectRatio }), + setVideoDuration: (videoDuration) => set({ videoDuration }), + setVideoResolution: (videoResolution) => set({ videoResolution }), + setGenerateAudio: (generateAudio) => set({ generateAudio }), + setVideoReferenceImage: (url) => set({ videoReferenceImageUrl: url }), + clearVideoReferenceImage: () => set({ videoReferenceImageUrl: null }), + + generateVideo: async () => { + const { videoPrompt, videoAspectRatio, videoDuration, videoResolution, generateAudio, videoReferenceImageUrl } = get(); + + if (!videoPrompt.trim()) { + set({ videoError: "Please enter a prompt" }); + return; + } + + if (!videoReferenceImageUrl) { + set({ videoError: "Please select an image to animate" }); + return; + } + + set({ + isGeneratingVideo: true, + videoError: null, + currentVideoResult: null, + }); + + try { + const params: VideoGenerationParams = { + prompt: videoPrompt.trim(), + image_url: videoReferenceImageUrl, + aspect_ratio: videoAspectRatio, + duration: videoDuration, + generate_audio: generateAudio, + resolution: videoResolution, + }; + + const result = await generateVideo(params); + + // Clean up previous result's blob URL + const { currentVideoResult } = get(); + if (currentVideoResult && currentVideoResult.video.url.startsWith('blob:')) { + URL.revokeObjectURL(currentVideoResult.video.url); + } + + set({ currentVideoResult: result }); + } catch (error) { + console.error("Video generation failed:", error); + set({ + videoError: error instanceof Error ? error.message : "Video generation failed" + }); + } finally { + set({ isGeneratingVideo: false }); + } + }, + + addVideoToTimeline: async (videoUrl: string) => { + try { + const { useProjectStore } = await import("@/stores/project-store"); + const { useMediaStore, generateVideoThumbnail } = await import("@/stores/media-store"); + const { useTimelineStore } = await import("@/stores/timeline-store"); + const { usePlaybackStore } = await import("@/stores/playback-store"); + + const { activeProject } = useProjectStore.getState(); + if (!activeProject) { + throw new Error("No active project"); + } + + // Fetch the video to create file + const response = await fetch(videoUrl); + const blob = await response.blob(); + const file = new File([blob], `ai-video-${Date.now()}.mp4`, { + type: "video/mp4", + }); + + // Create a single blob URL to use consistently + const blobUrl = URL.createObjectURL(file); + + // Generate thumbnail and get video info (same as normal uploads) + const { thumbnailUrl, width, height } = await generateVideoThumbnail(file); + + // Get duration + const video = document.createElement("video"); + video.preload = "metadata"; + + const duration = await new Promise((resolve, reject) => { + video.onloadedmetadata = () => { + const dur = video.duration; + // Clean up properly + video.pause(); + video.removeAttribute('src'); + video.load(); + video.remove(); + resolve(dur); + }; + video.onerror = () => { + video.remove(); + reject(new Error("Failed to load video metadata")); + }; + video.src = blobUrl; + }); + + const mediaItem: Omit = { + name: `AI Video: ${get().videoPrompt.slice(0, 30)}...`, + type: "video", + file, + url: blobUrl, + thumbnailUrl, + width, + height, + duration, + ephemeral: false, + }; + + const { addMediaFile } = useMediaStore.getState(); + await addMediaFile(activeProject.id, mediaItem); + + const added = useMediaStore + .getState() + .mediaFiles.find((m) => m.url === mediaItem.url); + + if (!added) { + throw new Error("Failed to add to media store"); + } + + const { currentTime } = usePlaybackStore.getState(); + const { addElementAtTime } = useTimelineStore.getState(); + addElementAtTime(added, currentTime); + + // Clear current result after successfully adding to timeline + const { currentVideoResult } = get(); + if (currentVideoResult?.video.url === videoUrl) { + if (videoUrl.startsWith('blob:')) { + URL.revokeObjectURL(videoUrl); + } + set({ currentVideoResult: null }); + } + } catch (error) { + console.error("Failed to add video to timeline:", error); + throw error; + } + }, + + clearVideoError: () => set({ videoError: null }), + clearProjectSession: (projectId: string | null) => { - const { currentProjectId } = get(); + const { currentProjectId, currentResult, currentVideoResult } = get(); // If switching to a different project or closing project, clear the session if (currentProjectId !== projectId) { + // Clean up current results + if (currentResult) { + for (const image of currentResult.images) { + if (image.url && image.url.startsWith('blob:')) { + URL.revokeObjectURL(image.url); + } + } + } + + if (currentVideoResult && currentVideoResult.video.url.startsWith('blob:')) { + URL.revokeObjectURL(currentVideoResult.video.url); + } + set({ currentProjectId: projectId, + mode: "image", currentResult: null, - generationHistory: [], prompt: "", error: null, referenceImageUrls: [], + currentVideoResult: null, + videoPrompt: "", + videoError: null, + videoReferenceImageUrl: null, }); } }, @@ -195,6 +414,10 @@ export const useAIStore = create()( partialize: (state) => ({ aspectRatio: state.aspectRatio, outputFormat: state.outputFormat, + videoAspectRatio: state.videoAspectRatio, + videoDuration: state.videoDuration, + videoResolution: state.videoResolution, + generateAudio: state.generateAudio, }), } ) diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts index c2b9cf4..5a7b736 100644 --- a/apps/web/src/stores/playback-store.ts +++ b/apps/web/src/stores/playback-store.ts @@ -6,6 +6,8 @@ import { DEFAULT_FPS, useProjectStore } from "./project-store"; interface PlaybackStore extends PlaybackState, PlaybackControls { setDuration: (duration: number) => void; setCurrentTime: (time: number) => void; + previewQuality: number; // 1.0 = full quality, 0.5 = half resolution (better performance) + setPreviewQuality: (quality: number) => void; } let playbackTimer: number | null = null; @@ -80,6 +82,7 @@ export const usePlaybackStore = create((set, get) => ({ muted: false, previousVolume: 1, speed: 1.0, + previewQuality: 0.75, // Start at 75% for better performance on most machines play: () => { const state = get(); @@ -171,4 +174,7 @@ export const usePlaybackStore = create((set, get) => ({ get().mute(); } }, + + setPreviewQuality: (quality: number) => + set({ previewQuality: Math.max(0.25, Math.min(1.0, quality)) }), })); diff --git a/apps/web/src/types/ai.ts b/apps/web/src/types/ai.ts index 652e76b..5a5d14e 100644 --- a/apps/web/src/types/ai.ts +++ b/apps/web/src/types/ai.ts @@ -1,5 +1,9 @@ export type AspectRatio = "21:9" | "1:1" | "4:3" | "3:2" | "2:3" | "5:4" | "4:5" | "3:4" | "16:9" | "9:16"; export type OutputFormat = "jpeg" | "png" | "webp"; +export type AIMode = "image" | "video"; +export type VideoDuration = "8s"; +export type VideoResolution = "720p" | "1080p"; +export type VideoAspectRatio = "16:9" | "9:16"; export interface AIGenerationParams { prompt: string; @@ -29,4 +33,33 @@ export interface GenerationHistoryItem { params: AIGenerationParams; result: AIGenerationResult; timestamp: number; +} + +// Video generation types +export interface VideoGenerationParams { + prompt: string; + image_url: string; + aspect_ratio: VideoAspectRatio; + duration: VideoDuration; + generate_audio: boolean; + resolution: VideoResolution; +} + +export interface AIGeneratedVideo { + url: string; + content_type?: string; + file_name?: string; + file_size?: number; +} + +export interface VideoGenerationResult { + video: AIGeneratedVideo; +} + +export interface VideoGenerationHistoryItem { + id: string; + prompt: string; + params: VideoGenerationParams; + result: VideoGenerationResult; + timestamp: number; } \ No newline at end of file diff --git a/bun.lock b/bun.lock index 83124a1..d55b0f9 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "@hookform/resolvers": "^3.9.1", "@opencut/auth": "workspace:*", "@opencut/db": "workspace:*", + "@radix-ui/react-dialog": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", "@t3-oss/env-core": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8", @@ -99,6 +100,7 @@ "dependencies": { "@opencut/db": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", + "@upstash/redis": "^1.35.0", "better-auth": "^1.1.1", "zod": "^4.0.5", }, diff --git a/packages/auth/package.json b/packages/auth/package.json index f76a230..c017fee 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -13,6 +13,7 @@ "dependencies": { "@opencut/db": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", + "@upstash/redis": "^1.35.0", "better-auth": "^1.1.1", "zod": "^4.0.5" },