diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index df1ec54f..6cdd1b65 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -16,6 +16,7 @@ import { } from "../../properties-panel/property-item"; import { FPS_PRESETS } from "@/constants/timeline-constants"; import { useProjectStore } from "@/stores/project-store"; +import type { BlurIntensity } from "@/types/project"; import { useEditorStore } from "@/stores/editor-store"; import { useAspectRatio } from "@/hooks/use-aspect-ratio"; import Image from "next/image"; @@ -215,7 +216,7 @@ BackgroundPreviews.displayName = "BackgroundPreviews"; function BackgroundView() { const { activeProject, updateBackgroundType } = useProjectStore(); - const blurLevels = useMemo( + const blurLevels = useMemo>( () => [ { label: "Light", value: 4 }, { label: "Medium", value: 8 }, @@ -225,7 +226,7 @@ function BackgroundView() { ); const handleBlurSelect = useCallback( - async (blurIntensity: number) => { + async (blurIntensity: BlurIntensity) => { await updateBackgroundType("blur", { blurIntensity }); }, [updateBackgroundType] diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index c53cdafb..b7cc9578 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -521,9 +521,11 @@ export function PreviewPanel() { canvasHeight: displayHeight, tracks, mediaFiles, + backgroundType: activeProject?.backgroundType, + blurIntensity: activeProject?.blurIntensity, backgroundColor: activeProject?.backgroundType === "blur" - ? "transparent" + ? undefined : activeProject?.backgroundColor || "#000000", projectCanvasSize: canvasSize, }); @@ -553,9 +555,11 @@ export function PreviewPanel() { canvasHeight: displayHeight, tracks, mediaFiles, + backgroundType: activeProject?.backgroundType, + blurIntensity: activeProject?.blurIntensity, backgroundColor: activeProject?.backgroundType === "blur" - ? "transparent" + ? undefined : activeProject?.backgroundColor || "#000000", projectCanvasSize: canvasSize, }); @@ -624,9 +628,11 @@ export function PreviewPanel() { canvasHeight: displayHeight, 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/export.ts b/apps/web/src/lib/export.ts index 8b1ab83e..52c58736 100644 --- a/apps/web/src/lib/export.ts +++ b/apps/web/src/lib/export.ts @@ -252,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/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 08785369..ddc361b5 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -1,5 +1,6 @@ import type { TimelineTrack } from "@/types/timeline"; import type { MediaFile } from "@/types/media"; +import type { BlurIntensity } from "@/types/project"; import { videoCache } from "./video-cache"; export interface RenderContext { @@ -10,9 +11,29 @@ export interface RenderContext { tracks: TimelineTrack[]; mediaFiles: MediaFile[]; backgroundColor?: string; + backgroundType?: "color" | "blur"; + blurIntensity?: BlurIntensity; projectCanvasSize?: { width: number; height: number }; } +const imageElementCache = new Map(); + +async function getImageElement( + mediaItem: MediaFile +): Promise { + const cacheKey = mediaItem.id; + const cached = imageElementCache.get(cacheKey); + if (cached) return cached; + const img = new Image(); + await new Promise((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,6 +42,8 @@ export async function renderTimelineFrame({ tracks, mediaFiles, backgroundColor, + backgroundType, + blurIntensity, projectCanvasSize, }: RenderContext): Promise { // Background @@ -44,7 +67,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,6 +85,72 @@ 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") { @@ -121,33 +210,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; @@ -159,7 +262,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/stores/project-store.ts b/apps/web/src/stores/project-store.ts index b924ea22..ed5eda73 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -1,4 +1,4 @@ -import { TProject } from "@/types/project"; +import { TProject, BlurIntensity } from "@/types/project"; import { create } from "zustand"; import { storageService } from "@/lib/storage/storage-service"; import { toast } from "sonner"; @@ -44,7 +44,7 @@ interface ProjectStore { updateProjectBackground: (backgroundColor: string) => Promise; updateBackgroundType: ( type: "color" | "blur", - options?: { backgroundColor?: string; blurIntensity?: number } + options?: { backgroundColor?: string; blurIntensity?: BlurIntensity } ) => Promise; updateProjectFps: (fps: number) => Promise; updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise; @@ -399,7 +399,7 @@ export const useProjectStore = create((set, get) => ({ updateBackgroundType: async ( type: "color" | "blur", - options?: { backgroundColor?: string; blurIntensity?: number } + options?: { backgroundColor?: string; blurIntensity?: BlurIntensity } ) => { const { activeProject } = get(); if (!activeProject) return; @@ -410,7 +410,9 @@ export const useProjectStore = create((set, get) => ({ ...(options?.backgroundColor && { backgroundColor: options.backgroundColor, }), - ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }), + ...(options?.blurIntensity !== undefined && { + blurIntensity: options.blurIntensity, + }), updatedAt: new Date(), }; diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index bf510942..66de858a 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -1,5 +1,7 @@ import { CanvasSize } from "./editor"; +export type BlurIntensity = 4 | 8 | 18; + export interface TProject { id: string; name: string; @@ -9,7 +11,7 @@ export interface TProject { mediaItems?: string[]; backgroundColor?: string; backgroundType?: "color" | "blur"; - blurIntensity?: number; // in pixels (4, 8, 18) + blurIntensity?: BlurIntensity; fps?: number; bookmarks?: number[]; canvasSize: CanvasSize;