summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/canvas-gradients.ts219
-rw-r--r--apps/web/src/lib/export.ts23
-rw-r--r--apps/web/src/lib/fetch-github-stars.ts29
-rw-r--r--apps/web/src/lib/storage/storage-service.ts162
-rw-r--r--apps/web/src/lib/storage/types.ts20
-rw-r--r--apps/web/src/lib/timeline-renderer.ts208
-rw-r--r--apps/web/src/lib/video-cache.ts186
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();