mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: Clip effects, asset sorting, and timeline improvements
Major features and improvements: * **Clip Effects**: * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder). * Implemented dynamic parameter fields for effects. * Added support for keyframing effect parameters. * **Assets Panel**: * Added sorting options: Name, Type, Duration, and File Size. * Persisted view preferences (grid/list mode, sort order) to local storage. * Refactored media item rendering and drag interactions. * **Timeline & Interaction**: * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element. * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps). * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline. * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key. * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled. * **Text Elements**: * Refactored text background storage to use an explicit `enabled` flag. * Added `V8toV9` storage migration to update existing projects. * **Architecture**: * Moved export state management to `ProjectManager` for better lifecycle handling. * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { createOffscreenCanvas } from "../canvas-utils";
|
||||
import { BaseNode } from "./base-node";
|
||||
import type { TextElement } from "@/types/timeline";
|
||||
import {
|
||||
DEFAULT_TEXT_ELEMENT,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/constants/text-constants";
|
||||
import {
|
||||
getMetricAscent,
|
||||
@@ -17,6 +20,10 @@ import {
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
|
||||
import { getEffect } from "@/lib/effects";
|
||||
import { webglEffectRenderer } from "../webgl-effect-renderer";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
function scaleFontSize({
|
||||
fontSize,
|
||||
@@ -32,6 +39,9 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
|
||||
return `"${fontFamily.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
|
||||
const STRIKETHROUGH_VERTICAL_RATIO = 0.35;
|
||||
|
||||
function drawTextDecoration({
|
||||
ctx,
|
||||
textDecoration,
|
||||
@@ -51,7 +61,7 @@ function drawTextDecoration({
|
||||
}): void {
|
||||
if (textDecoration === "none" || !textDecoration) return;
|
||||
|
||||
const thickness = Math.max(1, scaledFontSize * 0.07);
|
||||
const thickness = Math.max(1, scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO);
|
||||
const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
|
||||
const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize });
|
||||
|
||||
@@ -65,7 +75,7 @@ function drawTextDecoration({
|
||||
}
|
||||
|
||||
if (textDecoration === "line-through") {
|
||||
const strikeY = lineY - (ascent - descent) * 0.35;
|
||||
const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO;
|
||||
ctx.fillRect(xStart, strikeY, lineWidth, thickness);
|
||||
}
|
||||
}
|
||||
@@ -89,8 +99,6 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.context.save();
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: time,
|
||||
elementStartTime: this.params.startTime,
|
||||
@@ -106,15 +114,10 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
||||
animations: this.params.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const x = transform.position.x + this.params.canvasCenter.x;
|
||||
const y = transform.position.y + this.params.canvasCenter.y;
|
||||
|
||||
renderer.context.translate(x, y);
|
||||
renderer.context.scale(transform.scale, transform.scale);
|
||||
if (transform.rotate) {
|
||||
renderer.context.rotate((transform.rotate * Math.PI) / 180);
|
||||
}
|
||||
|
||||
const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = this.params.fontStyle === "italic" ? "italic" : "normal";
|
||||
const scaledFontSize = scaleFontSize({
|
||||
@@ -122,83 +125,147 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
||||
canvasHeight: this.params.canvasHeight,
|
||||
});
|
||||
const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily });
|
||||
renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
|
||||
renderer.context.textAlign = this.params.textAlign;
|
||||
renderer.context.fillStyle = this.params.color;
|
||||
|
||||
const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
|
||||
const letterSpacing = this.params.letterSpacing ?? 0;
|
||||
const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT;
|
||||
if ("letterSpacing" in renderer.context) {
|
||||
(
|
||||
renderer.context as CanvasRenderingContext2D & { letterSpacing: string }
|
||||
).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
|
||||
const lines = this.params.content.split("\n");
|
||||
const lineHeightPx = scaledFontSize * lineHeight;
|
||||
const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
|
||||
const baseline = this.params.textBaseline ?? "middle";
|
||||
|
||||
renderer.context.textBaseline = baseline;
|
||||
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
|
||||
const lineCount = lines.length;
|
||||
|
||||
const block = measureTextBlock({
|
||||
lineMetrics,
|
||||
lineHeightPx,
|
||||
fallbackFontSize: scaledFontSize,
|
||||
});
|
||||
|
||||
const prevAlpha = renderer.context.globalAlpha;
|
||||
renderer.context.globalCompositeOperation = (
|
||||
const blendMode = (
|
||||
this.params.blendMode && this.params.blendMode !== "normal"
|
||||
? this.params.blendMode
|
||||
: "source-over"
|
||||
) as GlobalCompositeOperation;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
|
||||
if (
|
||||
this.params.background.color &&
|
||||
this.params.background.color !== "transparent" &&
|
||||
lineCount > 0
|
||||
) {
|
||||
const { color, cornerRadius = 0 } = this.params.background;
|
||||
const backgroundRect = getTextBackgroundRect({
|
||||
textAlign: this.params.textAlign,
|
||||
block,
|
||||
background: this.params.background,
|
||||
fontSizeRatio,
|
||||
});
|
||||
if (backgroundRect) {
|
||||
renderer.context.fillStyle = color;
|
||||
renderer.context.beginPath();
|
||||
renderer.context.roundRect(
|
||||
backgroundRect.left,
|
||||
backgroundRect.top,
|
||||
backgroundRect.width,
|
||||
backgroundRect.height,
|
||||
cornerRadius,
|
||||
);
|
||||
renderer.context.fill();
|
||||
renderer.context.fillStyle = this.params.color;
|
||||
renderer.context.save();
|
||||
renderer.context.font = fontString;
|
||||
renderer.context.textBaseline = baseline;
|
||||
if ("letterSpacing" in renderer.context) {
|
||||
(renderer.context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
|
||||
renderer.context.restore();
|
||||
|
||||
const lineCount = lines.length;
|
||||
const block = measureTextBlock({ lineMetrics, lineHeightPx, fallbackFontSize: scaledFontSize });
|
||||
|
||||
const drawContent = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
|
||||
ctx.font = fontString;
|
||||
ctx.textAlign = this.params.textAlign;
|
||||
ctx.textBaseline = baseline;
|
||||
ctx.fillStyle = this.params.color;
|
||||
if ("letterSpacing" in ctx) {
|
||||
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
|
||||
if (
|
||||
this.params.background.enabled &&
|
||||
this.params.background.color &&
|
||||
this.params.background.color !== "transparent" &&
|
||||
lineCount > 0
|
||||
) {
|
||||
const { color, cornerRadius = 0 } = this.params.background;
|
||||
const backgroundRect = getTextBackgroundRect({
|
||||
textAlign: this.params.textAlign,
|
||||
block,
|
||||
background: this.params.background,
|
||||
fontSizeRatio,
|
||||
});
|
||||
if (backgroundRect) {
|
||||
const p = clamp({ value: cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100;
|
||||
const radius = Math.min(backgroundRect.width, backgroundRect.height) / 2 * p;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = this.params.color;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const lineY = i * lineHeightPx - block.visualCenterOffset;
|
||||
ctx.fillText(lines[i], 0, lineY);
|
||||
drawTextDecoration({
|
||||
ctx,
|
||||
textDecoration: this.params.textDecoration ?? "none",
|
||||
lineWidth: lineMetrics[i].width,
|
||||
lineY,
|
||||
metrics: lineMetrics[i],
|
||||
scaledFontSize,
|
||||
textAlign: this.params.textAlign,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const applyTransform = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
|
||||
ctx.translate(x, y);
|
||||
ctx.scale(transform.scale, transform.scale);
|
||||
if (transform.rotate) {
|
||||
ctx.rotate((transform.rotate * Math.PI) / 180);
|
||||
}
|
||||
};
|
||||
|
||||
const enabledEffects = this.params.effects?.filter((effect) => effect.enabled) ?? [];
|
||||
|
||||
if (enabledEffects.length === 0) {
|
||||
renderer.context.save();
|
||||
applyTransform(renderer.context);
|
||||
renderer.context.globalCompositeOperation = blendMode;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
drawContent(renderer.context);
|
||||
renderer.context.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const y = i * lineHeightPx - block.visualCenterOffset;
|
||||
renderer.context.fillText(lines[i], 0, y);
|
||||
drawTextDecoration({
|
||||
ctx: renderer.context,
|
||||
textDecoration: this.params.textDecoration ?? "none",
|
||||
lineWidth: lineMetrics[i].width,
|
||||
lineY: y,
|
||||
metrics: lineMetrics[i],
|
||||
scaledFontSize,
|
||||
textAlign: this.params.textAlign,
|
||||
// Effects path: render text to a same-size offscreen canvas so the blur
|
||||
// can spread into the surrounding transparent area without hard clipping.
|
||||
const offscreen = createOffscreenCanvas({ width: renderer.width, height: renderer.height });
|
||||
const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
|
||||
|
||||
if (!offscreenCtx) {
|
||||
renderer.context.save();
|
||||
applyTransform(renderer.context);
|
||||
renderer.context.globalCompositeOperation = blendMode;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
drawContent(renderer.context);
|
||||
renderer.context.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
offscreenCtx.save();
|
||||
applyTransform(offscreenCtx);
|
||||
drawContent(offscreenCtx);
|
||||
offscreenCtx.restore();
|
||||
|
||||
let currentSource: CanvasImageSource = offscreen;
|
||||
for (const effect of enabledEffects) {
|
||||
const resolvedParams = resolveEffectParamsAtTime({
|
||||
effect,
|
||||
animations: this.params.animations,
|
||||
localTime,
|
||||
});
|
||||
const definition = getEffect({ effectType: effect.type });
|
||||
const passes = definition.renderer.passes.map((pass) => ({
|
||||
fragmentShader: pass.fragmentShader,
|
||||
uniforms: pass.uniforms({
|
||||
effectParams: resolvedParams,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
}),
|
||||
}));
|
||||
currentSource = webglEffectRenderer.applyEffect({
|
||||
source: currentSource,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
passes,
|
||||
});
|
||||
}
|
||||
|
||||
renderer.context.globalAlpha = prevAlpha;
|
||||
renderer.context.save();
|
||||
renderer.context.globalCompositeOperation = blendMode;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
renderer.context.drawImage(currentSource, 0, 0);
|
||||
renderer.context.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import { getEffect } from "@/lib/effects";
|
||||
import { webglEffectRenderer } from "../webgl-effect-renderer";
|
||||
@@ -127,11 +128,16 @@ export abstract class VisualNode<
|
||||
let currentResult: CanvasImageSource = elementCanvas;
|
||||
|
||||
for (const effect of enabledEffects) {
|
||||
const resolvedParams = resolveEffectParamsAtTime({
|
||||
effect,
|
||||
animations: this.params.animations,
|
||||
localTime: animationLocalTime,
|
||||
});
|
||||
const definition = getEffect({ effectType: effect.type });
|
||||
const passes = definition.renderer.passes.map((pass) => ({
|
||||
fragmentShader: pass.fragmentShader,
|
||||
uniforms: pass.uniforms({
|
||||
effectParams: effect.params,
|
||||
effectParams: resolvedParams,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user