diff options
Diffstat (limited to 'apps/web/src/lib')
| -rw-r--r-- | apps/web/src/lib/canvas-gradients.ts | 219 | ||||
| -rw-r--r-- | apps/web/src/lib/export.ts | 23 | ||||
| -rw-r--r-- | apps/web/src/lib/fetch-github-stars.ts | 29 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/storage-service.ts | 162 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/types.ts | 20 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline-renderer.ts | 208 | ||||
| -rw-r--r-- | apps/web/src/lib/video-cache.ts | 186 |
7 files changed, 722 insertions, 125 deletions
diff --git a/apps/web/src/lib/canvas-gradients.ts b/apps/web/src/lib/canvas-gradients.ts new file mode 100644 index 00000000..7d1cdc05 --- /dev/null +++ b/apps/web/src/lib/canvas-gradients.ts @@ -0,0 +1,219 @@ +function splitBackgroundLayers(input: string): string[] { + const layers: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < input.length; i += 1) { + const ch = input[i] as string; + if (ch === "(") depth += 1; + else if (ch === ")") depth -= 1; + else if (ch === "," && depth === 0) { + layers.push(input.slice(start, i).trim()); + start = i + 1; + } + } + layers.push(input.slice(start).trim()); + return layers; +} + +function parseColorStop(stop: string): { color: string; position?: number } { + const s = stop.trim(); + + // Handle functional colors like rgba(), rgb(), hsla(), hsl() + const colorFunctions = ["rgba(", "rgb(", "hsla(", "hsl("]; + let color = ""; + let remaining = ""; + + for (const fn of colorFunctions) { + if (s.startsWith(fn)) { + let depth = 0; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i] as string; + if (ch === "(") depth += 1; + else if (ch === ")") { + depth -= 1; + if (depth === 0) { + color = s.slice(0, i + 1); + remaining = s.slice(i + 1).trim(); + break; + } + } + } + break; + } + } + + if (!color) { + const parts = s.split(/\s+/); + color = parts[0] as string; + remaining = parts.slice(1).join(" "); + } + + // Convert transparent to transparent white for better blending + if (color === "transparent") { + color = "rgba(255, 255, 255, 0)"; + } + + // Parse position if present + let position: number | undefined; + if (remaining) { + const posMatch = remaining.match(/(\d+(?:\.\d+)?)%/); + if (posMatch) { + position = parseFloat(posMatch[1] as string) / 100; + } + } + + return { color, position }; +} + +function parseLinearGradient(layer: string, width: number, height: number) { + const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")")); + const parts = splitBackgroundLayers(inside); + const dir = (parts.shift() || "").trim(); + + let x0 = 0, + y0 = 0, + x1 = width, + y1 = 0; // default: to right + + if (dir.endsWith("deg")) { + const deg = parseFloat(dir); + const rad = (deg * Math.PI) / 180; + const cx = width / 2; + const cy = height / 2; + const r = Math.hypot(width, height) / 2; + x0 = cx - Math.cos(rad) * r; + y0 = cy - Math.sin(rad) * r; + x1 = cx + Math.cos(rad) * r; + y1 = cy + Math.sin(rad) * r; + } else if (dir.startsWith("to ")) { + const d = dir.slice(3).trim(); + if (d === "right") { + x0 = 0; + y0 = 0; + x1 = width; + y1 = 0; + } else if (d === "left") { + x0 = width; + y0 = 0; + x1 = 0; + y1 = 0; + } else if (d === "bottom") { + x0 = 0; + y0 = 0; + x1 = 0; + y1 = height; + } else if (d === "top") { + x0 = 0; + y0 = height; + x1 = 0; + y1 = 0; + } + } else { + // No direction specified, treat as first color + parts.unshift(dir); + } + + return { x0, y0, x1, y1, colors: parts }; +} + +function parseRadialGradient(layer: string, width: number, height: number) { + const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")")); + const parts = splitBackgroundLayers(inside); + const first = (parts.shift() || "").trim(); + + let cx = width / 2; + let cy = height / 2; + + if (first.startsWith("circle at")) { + const pos = first.replace("circle at", "").trim(); + const coords = pos.split(/\s+/); + + for (let i = 0; i < coords.length; i += 1) { + const coord = coords[i] as string; + if (coord.endsWith("%")) { + const val = parseFloat(coord) / 100; + if (i === 0) cx = val * width; + else if (i === 1) cy = val * height; + } else if (coord === "left") cx = 0; + else if (coord === "right") cx = width; + else if (coord === "top") cy = 0; + else if (coord === "bottom") cy = height; + else if (coord === "center") { + if (i === 0) cx = width / 2; + else if (i === 1) cy = height / 2; + } + } + } else { + parts.unshift(first); + } + + // Use farthest-corner for radius + const r = Math.max( + Math.hypot(cx, cy), + Math.hypot(width - cx, cy), + Math.hypot(cx, height - cy), + Math.hypot(width - cx, height - cy) + ); + + return { cx, cy, r, colors: parts }; +} + +export function drawCssBackground( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + css: string +): void { + const layers = splitBackgroundLayers(css).filter(Boolean); + + // Draw layers from last to first (CSS background order) + for (let i = layers.length - 1; i >= 0; i -= 1) { + const layer = layers[i] as string; + + if (layer.startsWith("linear-gradient(")) { + const { x0, y0, x1, y1, colors } = parseLinearGradient( + layer, + width, + height + ); + const grad = ctx.createLinearGradient(x0, y0, x1, y1); + const colorStops = colors.map((c) => parseColorStop(c as string)); + + // Handle positions properly + for (let j = 0; j < colorStops.length; j += 1) { + const stop = colorStops[j] as { color: string; position?: number }; + const pos = stop.position ?? j / Math.max(1, colorStops.length - 1); + grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color); + } + + ctx.fillStyle = grad; + ctx.fillRect(0, 0, width, height); + } else if (layer.startsWith("radial-gradient(")) { + const { cx, cy, r, colors } = parseRadialGradient(layer, width, height); + const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); + const colorStops = colors.map((c) => parseColorStop(c as string)); + + // Handle positions properly + for (let j = 0; j < colorStops.length; j += 1) { + const stop = colorStops[j] as { color: string; position?: number }; + const pos = stop.position ?? j / Math.max(1, colorStops.length - 1); + grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color); + } + + ctx.fillStyle = grad; + ctx.fillRect(0, 0, width, height); + } else if ( + layer.startsWith("#") || + layer.startsWith("rgb") || + layer.startsWith("hsl") || + layer === "transparent" || + layer === "white" || + layer === "black" + ) { + if (layer !== "transparent") { + ctx.fillStyle = layer; + ctx.fillRect(0, 0, width, height); + } + } + } +} diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts index d2593879..52c58736 100644 --- a/apps/web/src/lib/export.ts +++ b/apps/web/src/lib/export.ts @@ -14,7 +14,7 @@ import { renderTimelineFrame } from "./timeline-renderer"; import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { useProjectStore } from "@/stores/project-store"; -import { DEFAULT_FPS } from "@/stores/project-store"; +import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/stores/project-store"; import { ExportOptions, ExportResult } from "@/types/export"; import { TimelineTrack } from "@/types/timeline"; import { MediaFile } from "@/types/media"; @@ -61,11 +61,12 @@ async function createTimelineAudioBuffer( for (const element of track.elements) { if (element.type !== "media") continue; - const mediaItem = element.type === "media" ? mediaMap.get(element.mediaId) : null; + const mediaElement = element; + const mediaItem = mediaMap.get(mediaElement.mediaId); if (!mediaItem || mediaItem.type !== "audio") continue; const visibleDuration = - element.duration - element.trimStart - element.trimEnd; + mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd; if (visibleDuration <= 0) continue; try { @@ -77,11 +78,11 @@ async function createTimelineAudioBuffer( audioElements.push({ buffer: audioBuffer, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - muted: element.muted || track.muted || false, + startTime: mediaElement.startTime, + duration: mediaElement.duration, + trimStart: mediaElement.trimStart, + trimEnd: mediaElement.trimEnd, + muted: mediaElement.muted || track.muted || false, }); } catch (error) { console.warn(`Failed to decode audio file ${mediaItem.name}:`, error); @@ -170,7 +171,7 @@ export async function exportProject( } const exportFps = fps || activeProject.fps || DEFAULT_FPS; - const canvasSize = activeProject.canvasSize; + const canvasSize = activeProject.canvasSize || DEFAULT_CANVAS_SIZE; const outputFormat = format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); @@ -251,9 +252,11 @@ export async function exportProject( canvasHeight: canvas.height, tracks, mediaFiles, + backgroundType: activeProject.backgroundType, + blurIntensity: activeProject.blurIntensity, backgroundColor: activeProject.backgroundType === "blur" - ? "transparent" + ? undefined : activeProject.backgroundColor || "#000000", projectCanvasSize: canvasSize, }); diff --git a/apps/web/src/lib/fetch-github-stars.ts b/apps/web/src/lib/fetch-github-stars.ts deleted file mode 100644 index 0372f25b..00000000 --- a/apps/web/src/lib/fetch-github-stars.ts +++ /dev/null @@ -1,29 +0,0 @@ -export async function getStars(): Promise<string> { - try { - const res = await fetch( - "https://api.github.com/repos/OpenCut-app/OpenCut", - { - next: { revalidate: 3600 }, - } - ); - - if (!res.ok) { - throw new Error(`GitHub API error: ${res.status} ${res.statusText}`); - } - const data = (await res.json()) as { stargazers_count: number }; - const count = data.stargazers_count; - - if (typeof count !== "number") { - throw new Error("Invalid stargazers_count from GitHub API"); - } - - if (count >= 1_000_000) - return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; - if (count >= 1000) - return (count / 1000).toFixed(1).replace(/\.0$/, "") + "k"; - return count.toString(); - } catch (error) { - console.error("Failed to fetch GitHub stars:", error); - return "1.5k"; - } -} diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 3aadf883..2688f6e8 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -6,6 +6,7 @@ import { MediaFileData, StorageConfig, SerializedProject, + SerializedScene, TimelineData, } from "./types"; import { TimelineTrack } from "@/types/timeline"; @@ -39,7 +40,7 @@ class StorageService { } // Helper to get project-specific media adapters - private getProjectMediaAdapters(projectId: string) { + private getProjectMediaAdapters({ projectId }: { projectId: string }) { const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>( `${this.config.mediaDb}-${projectId}`, "media-metadata", @@ -52,23 +53,43 @@ class StorageService { } // Helper to get project-specific timeline adapter - private getProjectTimelineAdapter(projectId: string) { + private getProjectTimelineAdapter({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) { + const dbName = sceneId + ? `${this.config.timelineDb}-${projectId}-${sceneId}` + : `${this.config.timelineDb}-${projectId}`; + return new IndexedDBAdapter<TimelineData>( - `${this.config.timelineDb}-${projectId}`, + dbName, "timeline", this.config.version ); } // Project operations - async saveProject(project: TProject): Promise<void> { + async saveProject({ project }: { project: TProject }): Promise<void> { // Convert TProject to serializable format + const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({ + id: scene.id, + name: scene.name, + isMain: scene.isMain, + createdAt: scene.createdAt.toISOString(), + updatedAt: scene.updatedAt.toISOString(), + })); + const serializedProject: SerializedProject = { id: project.id, name: project.name, thumbnail: project.thumbnail, createdAt: project.createdAt.toISOString(), updatedAt: project.updatedAt.toISOString(), + scenes: serializedScenes, + currentSceneId: project.currentSceneId, backgroundColor: project.backgroundColor, backgroundType: project.backgroundType, blurIntensity: project.blurIntensity, @@ -81,18 +102,30 @@ class StorageService { await this.projectsAdapter.set(project.id, serializedProject); } - async loadProject(id: string): Promise<TProject | null> { + async loadProject({ id }: { id: string }): Promise<TProject | null> { const serializedProject = await this.projectsAdapter.get(id); if (!serializedProject) return null; + // Now convert serialized scenes back to Scene objects + const scenes = + serializedProject.scenes?.map((scene) => ({ + id: scene.id, + name: scene.name, + isMain: scene.isMain, + createdAt: new Date(scene.createdAt), + updatedAt: new Date(scene.updatedAt), + })) || []; + // Convert back to TProject format - return { + const project = { id: serializedProject.id, name: serializedProject.name, thumbnail: serializedProject.thumbnail, createdAt: new Date(serializedProject.createdAt), updatedAt: new Date(serializedProject.updatedAt), + scenes, + currentSceneId: serializedProject.currentSceneId || "", backgroundColor: serializedProject.backgroundColor, backgroundType: serializedProject.backgroundType, blurIntensity: serializedProject.blurIntensity, @@ -101,6 +134,7 @@ class StorageService { canvasSize: serializedProject.canvasSize, canvasMode: serializedProject.canvasMode, }; + return project; } async loadAllProjects(): Promise<TProject[]> { @@ -108,7 +142,7 @@ class StorageService { const projects: TProject[] = []; for (const id of projectIds) { - const project = await this.loadProject(id); + const project = await this.loadProject({ id }); if (project) { projects.push(project); } @@ -120,14 +154,20 @@ class StorageService { ); } - async deleteProject(id: string): Promise<void> { + async deleteProject({ id }: { id: string }): Promise<void> { await this.projectsAdapter.remove(id); } // Media operations - async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise<void> { + async saveMediaFile({ + projectId, + mediaItem, + }: { + projectId: string; + mediaItem: MediaFile; + }): Promise<void> { const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + this.getProjectMediaAdapters({ projectId }); // Save file to project-specific OPFS await mediaFilesAdapter.set(mediaItem.id, mediaItem.file); @@ -148,12 +188,15 @@ class StorageService { await mediaMetadataAdapter.set(mediaItem.id, metadata); } - async loadMediaFile( - projectId: string, - id: string - ): Promise<MediaFile | null> { + async loadMediaFile({ + projectId, + id, + }: { + projectId: string; + id: string; + }): Promise<MediaFile | null> { const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + this.getProjectMediaAdapters({ projectId }); const [file, metadata] = await Promise.all([ mediaFilesAdapter.get(id), @@ -192,14 +235,20 @@ class StorageService { }; } - async loadAllMediaFiles(projectId: string): Promise<MediaFile[]> { - const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); + async loadAllMediaFiles({ + projectId, + }: { + projectId: string; + }): Promise<MediaFile[]> { + const { mediaMetadataAdapter } = this.getProjectMediaAdapters({ + projectId, + }); const mediaIds = await mediaMetadataAdapter.list(); const mediaItems: MediaFile[] = []; for (const id of mediaIds) { - const item = await this.loadMediaFile(projectId, id); + const item = await this.loadMediaFile({ projectId, id }); if (item) { mediaItems.push(item); } @@ -208,9 +257,15 @@ class StorageService { return mediaItems; } - async deleteMediaFile(projectId: string, id: string): Promise<void> { + async deleteMediaFile({ + projectId, + id, + }: { + projectId: string; + id: string; + }): Promise<void> { const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + this.getProjectMediaAdapters({ projectId }); await Promise.all([ mediaFilesAdapter.remove(id), @@ -218,9 +273,13 @@ class StorageService { ]); } - async deleteProjectMedia(projectId: string): Promise<void> { + async deleteProjectMedia({ + projectId, + }: { + projectId: string; + }): Promise<void> { const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + this.getProjectMediaAdapters({ projectId }); await Promise.all([ mediaMetadataAdapter.clear(), @@ -228,12 +287,20 @@ class StorageService { ]); } - // Timeline operations - now project-specific - async saveTimeline( - projectId: string, - tracks: TimelineTrack[] - ): Promise<void> { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + // Timeline operations - supports both legacy and scene-based storage + async saveTimeline({ + projectId, + tracks, + sceneId, + }: { + projectId: string; + tracks: TimelineTrack[]; + sceneId?: string; + }): Promise<void> { + const timelineAdapter = this.getProjectTimelineAdapter({ + projectId, + sceneId, + }); const timelineData: TimelineData = { tracks, lastModified: new Date().toISOString(), @@ -241,14 +308,27 @@ class StorageService { await timelineAdapter.set("timeline", timelineData); } - async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + async loadTimeline({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }): Promise<TimelineTrack[] | null> { + const timelineAdapter = this.getProjectTimelineAdapter({ + projectId, + sceneId, + }); const timelineData = await timelineAdapter.get("timeline"); return timelineData ? timelineData.tracks : null; } - async deleteProjectTimeline(projectId: string): Promise<void> { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + async deleteProjectTimeline({ + projectId, + }: { + projectId: string; + }): Promise<void> { + const timelineAdapter = this.getProjectTimelineAdapter({ projectId }); await timelineAdapter.remove("timeline"); } @@ -274,12 +354,14 @@ class StorageService { }; } - async getProjectStorageInfo(projectId: string): Promise<{ + async getProjectStorageInfo({ projectId }: { projectId: string }): Promise<{ mediaItems: number; hasTimeline: boolean; }> { - const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const { mediaMetadataAdapter } = this.getProjectMediaAdapters({ + projectId, + }); + const timelineAdapter = this.getProjectTimelineAdapter({ projectId }); const [mediaIds, timelineData] = await Promise.all([ mediaMetadataAdapter.list(), @@ -307,7 +389,11 @@ class StorageService { } } - async saveSoundEffect(soundEffect: SoundEffect): Promise<void> { + async saveSoundEffect({ + soundEffect, + }: { + soundEffect: SoundEffect; + }): Promise<void> { try { const currentData = await this.loadSavedSounds(); @@ -340,7 +426,7 @@ class StorageService { } } - async removeSavedSound(soundId: number): Promise<void> { + async removeSavedSound({ soundId }: { soundId: number }): Promise<void> { try { const currentData = await this.loadSavedSounds(); @@ -356,7 +442,7 @@ class StorageService { } } - async isSoundSaved(soundId: number): Promise<boolean> { + async isSoundSaved({ soundId }: { soundId: number }): Promise<boolean> { try { const currentData = await this.loadSavedSounds(); return currentData.sounds.some((sound) => sound.id === soundId); diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts index 8f593867..e66239cd 100644 --- a/apps/web/src/lib/storage/types.ts +++ b/apps/web/src/lib/storage/types.ts @@ -1,4 +1,4 @@ -import { TProject } from "@/types/project"; +import { TProject, Scene } from "@/types/project"; import { TimelineTrack } from "@/types/timeline"; export interface StorageAdapter<T> { @@ -23,11 +23,23 @@ export interface MediaFileData { // File will be stored separately in OPFS } +// Legacy timeline data, kept for backward compatibility export interface TimelineData { tracks: TimelineTrack[]; lastModified: string; } +export interface SceneTimelineData { + sceneId: string; + tracks: TimelineTrack[]; + lastModified: string; +} + +export type SerializedScene = Omit<Scene, "createdAt" | "updatedAt"> & { + createdAt: string; + updatedAt: string; +}; + export interface StorageConfig { projectsDb: string; mediaDb: string; @@ -37,9 +49,13 @@ export interface StorageConfig { } // Helper type for serialization - converts Date objects to strings -export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & { +export type SerializedProject = Omit< + TProject, + "createdAt" | "updatedAt" | "scenes" +> & { createdAt: string; updatedAt: string; + scenes: SerializedScene[]; bookmarks?: number[]; }; diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 6241a7d9..b2650b7c 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -1,6 +1,8 @@ -import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; import type { TimelineTrack } from "@/types/timeline"; import type { MediaFile } from "@/types/media"; +import type { BlurIntensity } from "@/types/project"; +import { videoCache } from "./video-cache"; +import { drawCssBackground } from "./canvas-gradients"; export interface RenderContext { ctx: CanvasRenderingContext2D; @@ -10,9 +12,29 @@ export interface RenderContext { tracks: TimelineTrack[]; mediaFiles: MediaFile[]; backgroundColor?: string; + backgroundType?: "color" | "blur"; + blurIntensity?: BlurIntensity; projectCanvasSize?: { width: number; height: number }; } +const imageElementCache = new Map<string, HTMLImageElement>(); + +async function getImageElement( + mediaItem: MediaFile +): Promise<HTMLImageElement> { + const cacheKey = mediaItem.id; + const cached = imageElementCache.get(cacheKey); + if (cached) return cached; + const img = new Image(); + await new Promise<void>((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("Image load failed")); + img.src = mediaItem.url || URL.createObjectURL(mediaItem.file); + }); + imageElementCache.set(cacheKey, img); + return img; +} + export async function renderTimelineFrame({ ctx, time, @@ -21,15 +43,26 @@ export async function renderTimelineFrame({ tracks, mediaFiles, backgroundColor, + backgroundType, + blurIntensity, projectCanvasSize, }: RenderContext): Promise<void> { // Background ctx.clearRect(0, 0, canvasWidth, canvasHeight); - if (backgroundColor && backgroundColor !== "transparent") { + if ( + backgroundColor && + backgroundColor !== "transparent" && + !backgroundColor.includes("gradient") + ) { ctx.fillStyle = backgroundColor; ctx.fillRect(0, 0, canvasWidth, canvasHeight); } + // If backgroundColor is a CSS gradient string, draw it + if (backgroundColor && backgroundColor.includes("gradient")) { + drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor); + } + const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1; const scaleY = projectCanvasSize ? canvasHeight / projectCanvasSize.height @@ -44,7 +77,7 @@ export async function renderTimelineFrame({ for (let t = tracks.length - 1; t >= 0; t -= 1) { const track = tracks[t]; for (const element of track.elements) { - if ((element as any).hidden) continue; + if (element.hidden) continue; const elementStart = element.startTime; const elementEnd = element.startTime + @@ -62,34 +95,103 @@ export async function renderTimelineFrame({ } } + // If background is set to blur, draw the active media as a blurred cover layer first + if (backgroundType === "blur") { + const blurPx = Math.max(0, blurIntensity ?? 8); + // Find a suitable media element (video/image) among active elements + const bgCandidate = active.find(({ element, mediaItem }) => { + return ( + element.type === "media" && + mediaItem !== null && + (mediaItem.type === "video" || mediaItem.type === "image") + ); + }); + if (bgCandidate && bgCandidate.mediaItem) { + const { element, mediaItem } = bgCandidate; + try { + if (mediaItem.type === "video") { + const localTime = time - element.startTime + element.trimStart; + const frame = await videoCache.getFrameAt( + mediaItem.id, + mediaItem.file, + Math.max(0, localTime) + ); + if (frame) { + const mediaW = Math.max(1, mediaItem.width || canvasWidth); + const mediaH = Math.max(1, mediaItem.height || canvasHeight); + const coverScale = Math.max( + canvasWidth / mediaW, + canvasHeight / mediaH + ); + const drawW = mediaW * coverScale; + const drawH = mediaH * coverScale; + const drawX = (canvasWidth - drawW) / 2; + const drawY = (canvasHeight - drawH) / 2; + ctx.save(); + ctx.filter = `blur(${blurPx}px)`; + ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH); + ctx.restore(); + } + } else if (mediaItem.type === "image") { + const img = await getImageElement(mediaItem); + const mediaW = Math.max( + 1, + mediaItem.width || img.naturalWidth || canvasWidth + ); + const mediaH = Math.max( + 1, + mediaItem.height || img.naturalHeight || canvasHeight + ); + const coverScale = Math.max( + canvasWidth / mediaW, + canvasHeight / mediaH + ); + const drawW = mediaW * coverScale; + const drawH = mediaH * coverScale; + const drawX = (canvasWidth - drawW) / 2; + const drawY = (canvasHeight - drawH) / 2; + ctx.save(); + ctx.filter = `blur(${blurPx}px)`; + ctx.drawImage(img, drawX, drawY, drawW, drawH); + ctx.restore(); + } + } catch { + // Ignore background blur failures; foreground will still render + } + } + } + for (const { element, mediaItem } of active) { if (element.type === "media" && mediaItem) { if (mediaItem.type === "video") { - const input = new Input({ - source: new BlobSource(mediaItem.file), - formats: ALL_FORMATS, - }); - const track = await input.getPrimaryVideoTrack(); - if (!track) continue; - const decodable = await track.canDecode(); - if (!decodable) continue; - const sink = new VideoSampleSink(track); + try { + const localTime = time - element.startTime + element.trimStart; - const localTime = time - element.startTime + element.trimStart; - const sample = await sink.getSample(localTime); - if (!sample) continue; + const frame = await videoCache.getFrameAt( + mediaItem.id, + mediaItem.file, + localTime + ); + if (!frame) continue; - const mediaW = Math.max(1, mediaItem.width || canvasWidth); - const mediaH = Math.max(1, mediaItem.height || canvasHeight); - const containScale = Math.min( - canvasWidth / mediaW, - canvasHeight / mediaH - ); - const drawW = mediaW * containScale; - const drawH = mediaH * containScale; - const drawX = (canvasWidth - drawW) / 2; - const drawY = (canvasHeight - drawH) / 2; - sample.draw(ctx, drawX, drawY, drawW, drawH); + const mediaW = Math.max(1, mediaItem.width || canvasWidth); + const mediaH = Math.max(1, mediaItem.height || canvasHeight); + const containScale = Math.min( + canvasWidth / mediaW, + canvasHeight / mediaH + ); + const drawW = mediaW * containScale; + const drawH = mediaH * containScale; + const drawX = (canvasWidth - drawW) / 2; + const drawY = (canvasHeight - drawH) / 2; + + ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH); + } catch (error) { + console.warn( + `Failed to render video frame for ${mediaItem.name}:`, + error + ); + } } if (mediaItem.type === "image") { const img = new Image(); @@ -118,33 +220,47 @@ export async function renderTimelineFrame({ } } if (element.type === "text") { - const posX = canvasWidth / 2 + (element as any).x * scaleX; - const posY = canvasHeight / 2 + (element as any).y * scaleY; + const text = element; + const posX = canvasWidth / 2 + text.x * scaleX; + const posY = canvasHeight / 2 + text.y * scaleY; ctx.save(); ctx.translate(posX, posY); - ctx.rotate(((element as any).rotation * Math.PI) / 180); - ctx.globalAlpha = Math.max(0, Math.min(1, (element as any).opacity)); - const px = (element as any).fontSize * scaleX; - const weight = (element as any).fontWeight === "bold" ? "bold " : ""; - const style = (element as any).fontStyle === "italic" ? "italic " : ""; - ctx.font = `${style}${weight}${px}px ${(element as any).fontFamily}`; - ctx.fillStyle = (element as any).color; - ctx.textAlign = (element as any).textAlign; + ctx.rotate((text.rotation * Math.PI) / 180); + ctx.globalAlpha = Math.max(0, Math.min(1, text.opacity)); + const px = text.fontSize * scaleX; + const weight = text.fontWeight === "bold" ? "bold " : ""; + const style = text.fontStyle === "italic" ? "italic " : ""; + ctx.font = `${style}${weight}${px}px ${text.fontFamily}`; + ctx.fillStyle = text.color; + ctx.textAlign = text.textAlign as CanvasTextAlign; ctx.textBaseline = "middle"; - const metrics = ctx.measureText((element as any).content); - const ascent = - (metrics as unknown as { actualBoundingBoxAscent?: number }) - .actualBoundingBoxAscent ?? px * 0.8; - const descent = - (metrics as unknown as { actualBoundingBoxDescent?: number }) - .actualBoundingBoxDescent ?? px * 0.2; + const metrics = ctx.measureText(text.content); + const hasBoxMetrics = + "actualBoundingBoxAscent" in metrics && + "actualBoundingBoxDescent" in metrics; + const ascent = hasBoxMetrics + ? ( + metrics as TextMetrics & { + actualBoundingBoxAscent: number; + actualBoundingBoxDescent: number; + } + ).actualBoundingBoxAscent + : px * 0.8; + const descent = hasBoxMetrics + ? ( + metrics as TextMetrics & { + actualBoundingBoxAscent: number; + actualBoundingBoxDescent: number; + } + ).actualBoundingBoxDescent + : px * 0.2; const textW = metrics.width; const textH = ascent + descent; const padX = 8 * scaleX; const padY = 4 * scaleX; - if ((element as any).backgroundColor) { + if (text.backgroundColor) { ctx.save(); - ctx.fillStyle = (element as any).backgroundColor; + ctx.fillStyle = text.backgroundColor; let bgLeft = -textW / 2; if (ctx.textAlign === "left") bgLeft = 0; if (ctx.textAlign === "right") bgLeft = -textW; @@ -156,7 +272,7 @@ export async function renderTimelineFrame({ ); ctx.restore(); } - ctx.fillText((element as any).content, 0, 0); + ctx.fillText(text.content, 0, 0); ctx.restore(); } } diff --git a/apps/web/src/lib/video-cache.ts b/apps/web/src/lib/video-cache.ts new file mode 100644 index 00000000..aeb62d1c --- /dev/null +++ b/apps/web/src/lib/video-cache.ts @@ -0,0 +1,186 @@ +import {
+ Input,
+ ALL_FORMATS,
+ BlobSource,
+ CanvasSink,
+ WrappedCanvas,
+} from "mediabunny";
+
+interface VideoSinkData {
+ sink: CanvasSink;
+ iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
+ currentFrame: WrappedCanvas | null;
+ lastTime: number;
+}
+export class VideoCache {
+ private sinks = new Map<string, VideoSinkData>();
+ private initPromises = new Map<string, Promise<void>>();
+
+ async getFrameAt(
+ mediaId: string,
+ file: File,
+ time: number
+ ): Promise<WrappedCanvas | null> {
+ await this.ensureSink(mediaId, file);
+
+ const sinkData = this.sinks.get(mediaId);
+ if (!sinkData) return null;
+
+ if (
+ sinkData.currentFrame &&
+ this.isFrameValid(sinkData.currentFrame, time)
+ ) {
+ return sinkData.currentFrame;
+ }
+
+ if (
+ sinkData.iterator &&
+ sinkData.currentFrame &&
+ time >= sinkData.lastTime &&
+ time < sinkData.lastTime + 2.0
+ ) {
+ const frame = await this.iterateToTime(sinkData, time);
+ if (frame) return frame;
+ }
+
+ return await this.seekToTime(sinkData, time);
+ }
+
+ private isFrameValid(frame: WrappedCanvas, time: number): boolean {
+ return time >= frame.timestamp && time < frame.timestamp + frame.duration;
+ }
+ private async iterateToTime(
+ sinkData: VideoSinkData,
+ targetTime: number
+ ): Promise<WrappedCanvas | null> {
+ if (!sinkData.iterator) return null;
+
+ try {
+ while (true) {
+ const { value: frame, done } = await sinkData.iterator.next();
+
+ if (done || !frame) break;
+
+ sinkData.currentFrame = frame;
+ sinkData.lastTime = frame.timestamp;
+
+ if (this.isFrameValid(frame, targetTime)) {
+ return frame;
+ }
+
+ if (frame.timestamp > targetTime + 1.0) break;
+ }
+ } catch (error) {
+ console.warn("Iterator failed, will restart:", error);
+ sinkData.iterator = null;
+ }
+
+ return null;
+ }
+ private async seekToTime(
+ sinkData: VideoSinkData,
+ time: number
+ ): Promise<WrappedCanvas | null> {
+ try {
+ if (sinkData.iterator) {
+ await sinkData.iterator.return();
+ sinkData.iterator = null;
+ }
+
+ sinkData.iterator = sinkData.sink.canvases(time);
+ sinkData.lastTime = time;
+
+ const { value: frame } = await sinkData.iterator.next();
+
+ if (frame) {
+ sinkData.currentFrame = frame;
+ return frame;
+ }
+ } catch (error) {
+ console.warn("Failed to seek video:", error);
+ }
+
+ return null;
+ }
+ private async ensureSink(mediaId: string, file: File): Promise<void> {
+ if (this.sinks.has(mediaId)) return;
+
+ if (this.initPromises.has(mediaId)) {
+ await this.initPromises.get(mediaId);
+ return;
+ }
+
+ const initPromise = this.initializeSink(mediaId, file);
+ this.initPromises.set(mediaId, initPromise);
+
+ try {
+ await initPromise;
+ } finally {
+ this.initPromises.delete(mediaId);
+ }
+ }
+ private async initializeSink(mediaId: string, file: File): Promise<void> {
+ try {
+ const input = new Input({
+ source: new BlobSource(file),
+ formats: ALL_FORMATS,
+ });
+
+ const videoTrack = await input.getPrimaryVideoTrack();
+ if (!videoTrack) {
+ throw new Error("No video track found");
+ }
+
+ const canDecode = await videoTrack.canDecode();
+ if (!canDecode) {
+ throw new Error("Video codec not supported for decoding");
+ }
+
+ const sink = new CanvasSink(videoTrack, {
+ poolSize: 3,
+ fit: "contain",
+ });
+
+ this.sinks.set(mediaId, {
+ sink,
+ iterator: null,
+ currentFrame: null,
+ lastTime: -1,
+ });
+ } catch (error) {
+ console.error(`Failed to initialize video sink for ${mediaId}:`, error);
+ throw error;
+ }
+ }
+
+ clearVideo(mediaId: string): void {
+ const sinkData = this.sinks.get(mediaId);
+ if (sinkData) {
+ if (sinkData.iterator) {
+ sinkData.iterator.return();
+ }
+
+ this.sinks.delete(mediaId);
+ }
+
+ this.initPromises.delete(mediaId);
+ }
+
+ clearAll(): void {
+ for (const [mediaId] of this.sinks) {
+ this.clearVideo(mediaId);
+ }
+ }
+
+ getStats() {
+ return {
+ totalSinks: this.sinks.size,
+ activeSinks: Array.from(this.sinks.values()).filter((s) => s.iterator)
+ .length,
+ cachedFrames: Array.from(this.sinks.values()).filter(
+ (s) => s.currentFrame
+ ).length,
+ };
+ }
+}
+export const videoCache = new VideoCache();
|
