mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: improve types, fix background blur, some other stuff
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "../../properties-panel/property-item";
|
} from "../../properties-panel/property-item";
|
||||||
import { FPS_PRESETS } from "@/constants/timeline-constants";
|
import { FPS_PRESETS } from "@/constants/timeline-constants";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
|
import type { BlurIntensity } from "@/types/project";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -215,7 +216,7 @@ BackgroundPreviews.displayName = "BackgroundPreviews";
|
|||||||
function BackgroundView() {
|
function BackgroundView() {
|
||||||
const { activeProject, updateBackgroundType } = useProjectStore();
|
const { activeProject, updateBackgroundType } = useProjectStore();
|
||||||
|
|
||||||
const blurLevels = useMemo(
|
const blurLevels = useMemo<Array<{ label: string; value: BlurIntensity }>>(
|
||||||
() => [
|
() => [
|
||||||
{ label: "Light", value: 4 },
|
{ label: "Light", value: 4 },
|
||||||
{ label: "Medium", value: 8 },
|
{ label: "Medium", value: 8 },
|
||||||
@@ -225,7 +226,7 @@ function BackgroundView() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleBlurSelect = useCallback(
|
const handleBlurSelect = useCallback(
|
||||||
async (blurIntensity: number) => {
|
async (blurIntensity: BlurIntensity) => {
|
||||||
await updateBackgroundType("blur", { blurIntensity });
|
await updateBackgroundType("blur", { blurIntensity });
|
||||||
},
|
},
|
||||||
[updateBackgroundType]
|
[updateBackgroundType]
|
||||||
|
|||||||
@@ -521,9 +521,11 @@ export function PreviewPanel() {
|
|||||||
canvasHeight: displayHeight,
|
canvasHeight: displayHeight,
|
||||||
tracks,
|
tracks,
|
||||||
mediaFiles,
|
mediaFiles,
|
||||||
|
backgroundType: activeProject?.backgroundType,
|
||||||
|
blurIntensity: activeProject?.blurIntensity,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
activeProject?.backgroundType === "blur"
|
activeProject?.backgroundType === "blur"
|
||||||
? "transparent"
|
? undefined
|
||||||
: activeProject?.backgroundColor || "#000000",
|
: activeProject?.backgroundColor || "#000000",
|
||||||
projectCanvasSize: canvasSize,
|
projectCanvasSize: canvasSize,
|
||||||
});
|
});
|
||||||
@@ -553,9 +555,11 @@ export function PreviewPanel() {
|
|||||||
canvasHeight: displayHeight,
|
canvasHeight: displayHeight,
|
||||||
tracks,
|
tracks,
|
||||||
mediaFiles,
|
mediaFiles,
|
||||||
|
backgroundType: activeProject?.backgroundType,
|
||||||
|
blurIntensity: activeProject?.blurIntensity,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
activeProject?.backgroundType === "blur"
|
activeProject?.backgroundType === "blur"
|
||||||
? "transparent"
|
? undefined
|
||||||
: activeProject?.backgroundColor || "#000000",
|
: activeProject?.backgroundColor || "#000000",
|
||||||
projectCanvasSize: canvasSize,
|
projectCanvasSize: canvasSize,
|
||||||
});
|
});
|
||||||
@@ -624,9 +628,11 @@ export function PreviewPanel() {
|
|||||||
canvasHeight: displayHeight,
|
canvasHeight: displayHeight,
|
||||||
tracks,
|
tracks,
|
||||||
mediaFiles,
|
mediaFiles,
|
||||||
|
backgroundType: activeProject?.backgroundType,
|
||||||
|
blurIntensity: activeProject?.blurIntensity,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
activeProject?.backgroundType === "blur"
|
activeProject?.backgroundType === "blur"
|
||||||
? "transparent"
|
? undefined
|
||||||
: activeProject?.backgroundColor || "#000000",
|
: activeProject?.backgroundColor || "#000000",
|
||||||
projectCanvasSize: canvasSize,
|
projectCanvasSize: canvasSize,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -252,9 +252,11 @@ export async function exportProject(
|
|||||||
canvasHeight: canvas.height,
|
canvasHeight: canvas.height,
|
||||||
tracks,
|
tracks,
|
||||||
mediaFiles,
|
mediaFiles,
|
||||||
|
backgroundType: activeProject.backgroundType,
|
||||||
|
blurIntensity: activeProject.blurIntensity,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
activeProject.backgroundType === "blur"
|
activeProject.backgroundType === "blur"
|
||||||
? "transparent"
|
? undefined
|
||||||
: activeProject.backgroundColor || "#000000",
|
: activeProject.backgroundColor || "#000000",
|
||||||
projectCanvasSize: canvasSize,
|
projectCanvasSize: canvasSize,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { TimelineTrack } from "@/types/timeline";
|
import type { TimelineTrack } from "@/types/timeline";
|
||||||
import type { MediaFile } from "@/types/media";
|
import type { MediaFile } from "@/types/media";
|
||||||
|
import type { BlurIntensity } from "@/types/project";
|
||||||
import { videoCache } from "./video-cache";
|
import { videoCache } from "./video-cache";
|
||||||
|
|
||||||
export interface RenderContext {
|
export interface RenderContext {
|
||||||
@@ -10,9 +11,29 @@ export interface RenderContext {
|
|||||||
tracks: TimelineTrack[];
|
tracks: TimelineTrack[];
|
||||||
mediaFiles: MediaFile[];
|
mediaFiles: MediaFile[];
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
|
backgroundType?: "color" | "blur";
|
||||||
|
blurIntensity?: BlurIntensity;
|
||||||
projectCanvasSize?: { width: number; height: number };
|
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({
|
export async function renderTimelineFrame({
|
||||||
ctx,
|
ctx,
|
||||||
time,
|
time,
|
||||||
@@ -21,6 +42,8 @@ export async function renderTimelineFrame({
|
|||||||
tracks,
|
tracks,
|
||||||
mediaFiles,
|
mediaFiles,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
backgroundType,
|
||||||
|
blurIntensity,
|
||||||
projectCanvasSize,
|
projectCanvasSize,
|
||||||
}: RenderContext): Promise<void> {
|
}: RenderContext): Promise<void> {
|
||||||
// Background
|
// Background
|
||||||
@@ -44,7 +67,7 @@ export async function renderTimelineFrame({
|
|||||||
for (let t = tracks.length - 1; t >= 0; t -= 1) {
|
for (let t = tracks.length - 1; t >= 0; t -= 1) {
|
||||||
const track = tracks[t];
|
const track = tracks[t];
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
if ((element as any).hidden) continue;
|
if (element.hidden) continue;
|
||||||
const elementStart = element.startTime;
|
const elementStart = element.startTime;
|
||||||
const elementEnd =
|
const elementEnd =
|
||||||
element.startTime +
|
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) {
|
for (const { element, mediaItem } of active) {
|
||||||
if (element.type === "media" && mediaItem) {
|
if (element.type === "media" && mediaItem) {
|
||||||
if (mediaItem.type === "video") {
|
if (mediaItem.type === "video") {
|
||||||
@@ -121,33 +210,47 @@ export async function renderTimelineFrame({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (element.type === "text") {
|
if (element.type === "text") {
|
||||||
const posX = canvasWidth / 2 + (element as any).x * scaleX;
|
const text = element;
|
||||||
const posY = canvasHeight / 2 + (element as any).y * scaleY;
|
const posX = canvasWidth / 2 + text.x * scaleX;
|
||||||
|
const posY = canvasHeight / 2 + text.y * scaleY;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(posX, posY);
|
ctx.translate(posX, posY);
|
||||||
ctx.rotate(((element as any).rotation * Math.PI) / 180);
|
ctx.rotate((text.rotation * Math.PI) / 180);
|
||||||
ctx.globalAlpha = Math.max(0, Math.min(1, (element as any).opacity));
|
ctx.globalAlpha = Math.max(0, Math.min(1, text.opacity));
|
||||||
const px = (element as any).fontSize * scaleX;
|
const px = text.fontSize * scaleX;
|
||||||
const weight = (element as any).fontWeight === "bold" ? "bold " : "";
|
const weight = text.fontWeight === "bold" ? "bold " : "";
|
||||||
const style = (element as any).fontStyle === "italic" ? "italic " : "";
|
const style = text.fontStyle === "italic" ? "italic " : "";
|
||||||
ctx.font = `${style}${weight}${px}px ${(element as any).fontFamily}`;
|
ctx.font = `${style}${weight}${px}px ${text.fontFamily}`;
|
||||||
ctx.fillStyle = (element as any).color;
|
ctx.fillStyle = text.color;
|
||||||
ctx.textAlign = (element as any).textAlign;
|
ctx.textAlign = text.textAlign as CanvasTextAlign;
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
const metrics = ctx.measureText((element as any).content);
|
const metrics = ctx.measureText(text.content);
|
||||||
const ascent =
|
const hasBoxMetrics =
|
||||||
(metrics as unknown as { actualBoundingBoxAscent?: number })
|
"actualBoundingBoxAscent" in metrics &&
|
||||||
.actualBoundingBoxAscent ?? px * 0.8;
|
"actualBoundingBoxDescent" in metrics;
|
||||||
const descent =
|
const ascent = hasBoxMetrics
|
||||||
(metrics as unknown as { actualBoundingBoxDescent?: number })
|
? (
|
||||||
.actualBoundingBoxDescent ?? px * 0.2;
|
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 textW = metrics.width;
|
||||||
const textH = ascent + descent;
|
const textH = ascent + descent;
|
||||||
const padX = 8 * scaleX;
|
const padX = 8 * scaleX;
|
||||||
const padY = 4 * scaleX;
|
const padY = 4 * scaleX;
|
||||||
if ((element as any).backgroundColor) {
|
if (text.backgroundColor) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.fillStyle = (element as any).backgroundColor;
|
ctx.fillStyle = text.backgroundColor;
|
||||||
let bgLeft = -textW / 2;
|
let bgLeft = -textW / 2;
|
||||||
if (ctx.textAlign === "left") bgLeft = 0;
|
if (ctx.textAlign === "left") bgLeft = 0;
|
||||||
if (ctx.textAlign === "right") bgLeft = -textW;
|
if (ctx.textAlign === "right") bgLeft = -textW;
|
||||||
@@ -159,7 +262,7 @@ export async function renderTimelineFrame({
|
|||||||
);
|
);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
ctx.fillText((element as any).content, 0, 0);
|
ctx.fillText(text.content, 0, 0);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TProject } from "@/types/project";
|
import { TProject, BlurIntensity } from "@/types/project";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { storageService } from "@/lib/storage/storage-service";
|
import { storageService } from "@/lib/storage/storage-service";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -44,7 +44,7 @@ interface ProjectStore {
|
|||||||
updateProjectBackground: (backgroundColor: string) => Promise<void>;
|
updateProjectBackground: (backgroundColor: string) => Promise<void>;
|
||||||
updateBackgroundType: (
|
updateBackgroundType: (
|
||||||
type: "color" | "blur",
|
type: "color" | "blur",
|
||||||
options?: { backgroundColor?: string; blurIntensity?: number }
|
options?: { backgroundColor?: string; blurIntensity?: BlurIntensity }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
updateProjectFps: (fps: number) => Promise<void>;
|
updateProjectFps: (fps: number) => Promise<void>;
|
||||||
updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise<void>;
|
updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise<void>;
|
||||||
@@ -399,7 +399,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|||||||
|
|
||||||
updateBackgroundType: async (
|
updateBackgroundType: async (
|
||||||
type: "color" | "blur",
|
type: "color" | "blur",
|
||||||
options?: { backgroundColor?: string; blurIntensity?: number }
|
options?: { backgroundColor?: string; blurIntensity?: BlurIntensity }
|
||||||
) => {
|
) => {
|
||||||
const { activeProject } = get();
|
const { activeProject } = get();
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
@@ -410,7 +410,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|||||||
...(options?.backgroundColor && {
|
...(options?.backgroundColor && {
|
||||||
backgroundColor: options.backgroundColor,
|
backgroundColor: options.backgroundColor,
|
||||||
}),
|
}),
|
||||||
...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
|
...(options?.blurIntensity !== undefined && {
|
||||||
|
blurIntensity: options.blurIntensity,
|
||||||
|
}),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { CanvasSize } from "./editor";
|
import { CanvasSize } from "./editor";
|
||||||
|
|
||||||
|
export type BlurIntensity = 4 | 8 | 18;
|
||||||
|
|
||||||
export interface TProject {
|
export interface TProject {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -9,7 +11,7 @@ export interface TProject {
|
|||||||
mediaItems?: string[];
|
mediaItems?: string[];
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
backgroundType?: "color" | "blur";
|
backgroundType?: "color" | "blur";
|
||||||
blurIntensity?: number; // in pixels (4, 8, 18)
|
blurIntensity?: BlurIntensity;
|
||||||
fps?: number;
|
fps?: number;
|
||||||
bookmarks?: number[];
|
bookmarks?: number[];
|
||||||
canvasSize: CanvasSize;
|
canvasSize: CanvasSize;
|
||||||
|
|||||||
Reference in New Issue
Block a user