summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/src/components/editor/media-panel/views/settings.tsx5
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx12
-rw-r--r--apps/web/src/lib/export.ts4
-rw-r--r--apps/web/src/lib/timeline-renderer.ts145
-rw-r--r--apps/web/src/stores/project-store.ts10
-rw-r--r--apps/web/src/types/project.ts4
6 files changed, 148 insertions, 32 deletions
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<Array<{ label: string; value: BlurIntensity }>>(
() => [
{ 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<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,6 +42,8 @@ export async function renderTimelineFrame({
tracks,
mediaFiles,
backgroundColor,
+ backgroundType,
+ blurIntensity,
projectCanvasSize,
}: RenderContext): Promise<void> {
// 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<void>;
updateBackgroundType: (
type: "color" | "blur",
- options?: { backgroundColor?: string; blurIntensity?: number }
+ options?: { backgroundColor?: string; blurIntensity?: BlurIntensity }
) => Promise<void>;
updateProjectFps: (fps: number) => Promise<void>;
updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise<void>;
@@ -399,7 +399,7 @@ export const useProjectStore = create<ProjectStore>((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<ProjectStore>((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;