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/animation/interpolation.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts1
-rw-r--r--apps/web/src/lib/effects/definitions/blur.frag.glsl25
-rw-r--r--apps/web/src/lib/effects/definitions/blur.ts50
-rw-r--r--apps/web/src/lib/effects/definitions/index.ts13
-rw-r--r--apps/web/src/lib/effects/effect.vert.glsl7
-rw-r--r--apps/web/src/lib/effects/index.ts34
-rw-r--r--apps/web/src/lib/effects/registry.ts31
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts2
-rw-r--r--apps/web/src/lib/timeline/drop-utils.ts85
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts42
-rw-r--r--apps/web/src/lib/timeline/track-utils.ts38
13 files changed, 306 insertions, 32 deletions
diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts
index a8f60643..d3f68010 100644
--- a/apps/web/src/lib/animation/interpolation.ts
+++ b/apps/web/src/lib/animation/interpolation.ts
@@ -217,6 +217,11 @@ function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TVa
for (let keyframeIndex = 0; keyframeIndex < keyframes.length - 1; keyframeIndex++) {
const leftKeyframe = keyframes[keyframeIndex];
const rightKeyframe = keyframes[keyframeIndex + 1];
+
+ if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) {
+ return rightKeyframe.value;
+ }
+
const isBetweenPair = isWithinTimePair({
time,
leftTime: leftKeyframe.time,
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index e1551471..68fc9841 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -173,6 +173,11 @@ export class InsertElementCommand extends Command {
return false;
}
+ if (element.type === "effect" && !element.effectType) {
+ console.error("Effect element must have effectType");
+ return false;
+ }
+
return true;
}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
index 29d95918..5328419f 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -49,6 +49,7 @@ export class RetimeKeyframeCommand extends Command {
}),
update: (element) => {
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
+ if (!Number.isFinite(boundedTime)) return element;
return {
...element,
animations: retimeElementKeyframe({
diff --git a/apps/web/src/lib/effects/definitions/blur.frag.glsl b/apps/web/src/lib/effects/definitions/blur.frag.glsl
new file mode 100644
index 00000000..2c015725
--- /dev/null
+++ b/apps/web/src/lib/effects/definitions/blur.frag.glsl
@@ -0,0 +1,25 @@
+precision mediump float;
+
+uniform sampler2D u_texture;
+uniform vec2 u_resolution;
+uniform float u_sigma;
+uniform vec2 u_direction;
+
+varying vec2 v_texCoord;
+
+void main() {
+ vec2 texelSize = 1.0 / u_resolution;
+
+ vec4 color = vec4(0.0);
+ float totalWeight = 0.0;
+
+ // step=1 texel — scaling step size instead causes discrete ghosting artifacts
+ for (int i = -30; i <= 30; i++) {
+ float fi = float(i);
+ float weight = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
+ color += texture2D(u_texture, v_texCoord + texelSize * u_direction * fi) * weight;
+ totalWeight += weight;
+ }
+
+ gl_FragColor = color / totalWeight;
+}
diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts
new file mode 100644
index 00000000..3a8f691a
--- /dev/null
+++ b/apps/web/src/lib/effects/definitions/blur.ts
@@ -0,0 +1,50 @@
+import type { EffectDefinition } from "@/types/effects";
+import blurFragmentShader from "./blur.frag.glsl";
+
+export const blurEffectDefinition: EffectDefinition = {
+ type: "blur",
+ name: "Blur",
+ keywords: ["blur", "soft", "defocus"],
+ params: [
+ {
+ key: "intensity",
+ label: "Intensity",
+ type: "number",
+ default: 15,
+ min: 0,
+ max: 100,
+ step: 1,
+ },
+ ],
+ renderer: {
+ type: "webgl",
+ passes: [
+ {
+ fragmentShader: blurFragmentShader,
+ uniforms: ({ effectParams }) => {
+ const intensity =
+ typeof effectParams.intensity === "number"
+ ? effectParams.intensity
+ : Number.parseFloat(String(effectParams.intensity));
+ return {
+ u_sigma: Math.max(intensity / 5, 0.001),
+ u_direction: [1, 0],
+ };
+ },
+ },
+ {
+ fragmentShader: blurFragmentShader,
+ uniforms: ({ effectParams }) => {
+ const intensity =
+ typeof effectParams.intensity === "number"
+ ? effectParams.intensity
+ : Number.parseFloat(String(effectParams.intensity));
+ return {
+ u_sigma: Math.max(intensity / 5, 0.001),
+ u_direction: [0, 1],
+ };
+ },
+ },
+ ],
+ },
+};
diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts
new file mode 100644
index 00000000..1742b1d7
--- /dev/null
+++ b/apps/web/src/lib/effects/definitions/index.ts
@@ -0,0 +1,13 @@
+import { hasEffect, registerEffect } from "../registry";
+import { blurEffectDefinition } from "./blur";
+
+const defaultEffects = [blurEffectDefinition];
+
+export function registerDefaultEffects(): void {
+ for (const definition of defaultEffects) {
+ if (hasEffect({ effectType: definition.type })) {
+ continue;
+ }
+ registerEffect({ definition });
+ }
+}
diff --git a/apps/web/src/lib/effects/effect.vert.glsl b/apps/web/src/lib/effects/effect.vert.glsl
new file mode 100644
index 00000000..5febc835
--- /dev/null
+++ b/apps/web/src/lib/effects/effect.vert.glsl
@@ -0,0 +1,7 @@
+attribute vec2 a_position;
+varying vec2 v_texCoord;
+
+void main() {
+ v_texCoord = a_position * 0.5 + 0.5;
+ gl_Position = vec4(a_position, 0.0, 1.0);
+}
diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts
new file mode 100644
index 00000000..14d06d84
--- /dev/null
+++ b/apps/web/src/lib/effects/index.ts
@@ -0,0 +1,34 @@
+import { generateUUID } from "@/utils/id";
+import { getEffect } from "./registry";
+import type { Effect, EffectParamValues } from "@/types/effects";
+import type { VisualElement } from "@/types/timeline";
+
+export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry";
+export { registerDefaultEffects } from "./definitions";
+
+export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [
+ "video",
+ "image",
+ "text",
+ "sticker",
+];
+
+export function buildDefaultEffectInstance({
+ effectType,
+}: {
+ effectType: string;
+}): Effect {
+ const definition = getEffect({ effectType });
+
+ const params: EffectParamValues = {};
+ for (const paramDef of definition.params) {
+ params[paramDef.key] = paramDef.default;
+ }
+
+ return {
+ id: generateUUID(),
+ type: effectType,
+ params,
+ enabled: true,
+ };
+}
diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts
new file mode 100644
index 00000000..568b8924
--- /dev/null
+++ b/apps/web/src/lib/effects/registry.ts
@@ -0,0 +1,31 @@
+import type { EffectDefinition } from "@/types/effects";
+
+const effectDefinitions = new Map<string, EffectDefinition>();
+
+export function registerEffect({
+ definition,
+}: {
+ definition: EffectDefinition;
+}): void {
+ effectDefinitions.set(definition.type, definition);
+}
+
+export function hasEffect({ effectType }: { effectType: string }): boolean {
+ return effectDefinitions.has(effectType);
+}
+
+export function getEffect({
+ effectType,
+}: {
+ effectType: string;
+}): EffectDefinition {
+ const definition = effectDefinitions.get(effectType);
+ if (!definition) {
+ throw new Error(`Unknown effect type: ${effectType}`);
+ }
+ return definition;
+}
+
+export function getAllEffects(): EffectDefinition[] {
+ return Array.from(effectDefinitions.values());
+}
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
index cc63048f..5b1f301d 100644
--- a/apps/web/src/lib/preview/element-bounds.ts
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -70,7 +70,7 @@ export function getElementBounds({
mediaAsset?: MediaAsset | null;
localTime: number;
}): ElementBounds | null {
- if (element.type === "audio") return null;
+ if (element.type === "audio" || element.type === "effect") return null;
if ("hidden" in element && element.hidden) return null;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts
index 9182892f..b502974d 100644
--- a/apps/web/src/lib/timeline/drop-utils.ts
+++ b/apps/web/src/lib/timeline/drop-utils.ts
@@ -1,9 +1,42 @@
-import type { TimelineTrack, ElementType } from "@/types/timeline";
-import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
+import type {
+ TimelineTrack,
+ ElementType,
+ TimelineElement,
+} from "@/types/timeline";
+import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
import { isMainTrack, enforceMainTrackStart } from "./track-utils";
+function findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+}: {
+ mouseX: number;
+ tracks: TimelineTrack[];
+ trackIndex: number;
+ targetElementTypes: string[];
+ pixelsPerSecond: number;
+ zoomLevel: number;
+}): { elementId: string; trackId: string } | null {
+ const time = mouseX / (pixelsPerSecond * zoomLevel);
+ const track = tracks[trackIndex];
+ if (!track || !("elements" in track)) return null;
+
+ const hit = track.elements.find(
+ (element: TimelineElement) =>
+ targetElementTypes.includes(element.type) &&
+ element.startTime <= time &&
+ time < element.startTime + element.duration,
+ );
+ if (!hit) return null;
+ return { elementId: hit.id, trackId: track.id };
+}
+
function getTrackAtY({
mouseY,
tracks,
@@ -16,7 +49,7 @@ function getTrackAtY({
let cumulativeHeight = 0;
for (let i = 0; i < tracks.length; i++) {
- const trackHeight = TRACK_HEIGHTS[tracks[i].type];
+ const trackHeight = TRACK_CONFIG[tracks[i].type].height;
const trackTop = cumulativeHeight;
const trackBottom = trackTop + trackHeight;
@@ -55,6 +88,7 @@ function isCompatible({
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
+ if (elementType === "effect") return trackType === "effect";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}
@@ -100,6 +134,8 @@ function findInsertIndex({
};
}
+const EMPTY_TARGET_ELEMENT = null;
+
export function computeDropTarget({
elementType,
mouseX,
@@ -113,6 +149,7 @@ export function computeDropTarget({
verticalDragDirection,
startTimeOverride,
excludeElementId,
+ targetElementTypes,
}: ComputeDropTargetParams): DropTarget {
const xPosition =
typeof startTimeOverride === "number"
@@ -130,9 +167,16 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "below",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
- return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: null,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
}
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
@@ -146,6 +190,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "below",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -155,6 +200,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "above",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -163,12 +209,37 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "above",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex];
- const trackHeight = TRACK_HEIGHTS[track.type];
+
+ if (
+ targetElementTypes &&
+ targetElementTypes.length > 0
+ ) {
+ const targetElement = findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+ });
+ if (targetElement) {
+ return {
+ trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition,
+ targetElement,
+ };
+ }
+ }
+
+ const trackHeight = TRACK_CONFIG[track.type].height;
const isInUpperHalf = relativeY < trackHeight / 2;
const isTrackCompatible = isCompatible({
@@ -200,6 +271,7 @@ export function computeDropTarget({
isNewTrack: false,
insertPosition: null,
xPosition: adjustedXPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -220,6 +292,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: position,
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -237,7 +310,7 @@ export function getDropLineY({
let y = 0;
for (let i = 0; i < safeTrackIndex; i++) {
- y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
+ y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP;
}
return y;
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
index 9e191833..8c02c097 100644
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -6,6 +6,7 @@ import {
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import type {
+ CreateEffectElement,
CreateTimelineElement,
CreateVideoElement,
CreateImageElement,
@@ -22,6 +23,8 @@ import type {
UploadAudioElement,
} from "@/types/timeline";
import type { MediaType } from "@/types/assets";
+import { buildDefaultEffectInstance } from "@/lib/effects";
+import { capitalizeFirstLetter } from "@/utils/string";
export function canElementHaveAudio(
element: TimelineElement,
@@ -155,13 +158,13 @@ export function buildTextElement({
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
background: {
- color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
- cornerRadius: t.background?.cornerRadius,
- paddingX: t.background?.paddingX,
- paddingY: t.background?.paddingY,
- offsetX: t.background?.offsetX,
- offsetY: t.background?.offsetY,
- },
+ color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
+ cornerRadius: t.background?.cornerRadius,
+ paddingX: t.background?.paddingX,
+ paddingY: t.background?.paddingY,
+ offsetX: t.background?.offsetX,
+ offsetY: t.background?.offsetY,
+ },
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
@@ -174,6 +177,28 @@ export function buildTextElement({
};
}
+export function buildEffectElement({
+ effectType,
+ startTime,
+ duration,
+}: {
+ effectType: string;
+ startTime: number;
+ duration?: number;
+}): CreateEffectElement {
+ const instance = buildDefaultEffectInstance({ effectType });
+ return {
+ type: "effect",
+ name: capitalizeFirstLetter({ string: instance.type }),
+ effectType,
+ params: instance.params,
+ duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ };
+}
+
export function buildStickerElement({
stickerId,
name,
@@ -218,6 +243,7 @@ export function buildVideoElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
muted: false,
hidden: false,
transform: { ...DEFAULT_TRANSFORM },
@@ -274,6 +300,7 @@ export function buildUploadAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
volume: 1,
muted: false,
};
@@ -336,6 +363,7 @@ export function buildLibraryAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
volume: 1,
muted: false,
};
diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts
index 415d0ace..94ff21ba 100644
--- a/apps/web/src/lib/timeline/track-utils.ts
+++ b/apps/web/src/lib/timeline/track-utils.ts
@@ -6,11 +6,11 @@ import type {
AudioTrack,
StickerTrack,
TextTrack,
+ EffectTrack,
TimelineElement,
} from "@/types/timeline";
import {
- TRACK_COLORS,
- TRACK_HEIGHTS,
+ TRACK_CONFIG,
TRACK_GAP,
} from "@/constants/timeline-constants";
import { generateUUID } from "@/utils/id";
@@ -23,21 +23,20 @@ export function canTracktHaveAudio(
export function canTrackBeHidden(
track: TimelineTrack,
-): track is VideoTrack | TextTrack | StickerTrack {
+): track is VideoTrack | TextTrack | StickerTrack | EffectTrack {
return track.type !== "audio";
}
export function getTrackColor({ type }: { type: TrackType }) {
- return TRACK_COLORS[type];
+ return TRACK_CONFIG[type];
}
export function getTrackClasses({ type }: { type: TrackType }) {
- const colors = TRACK_COLORS[type];
- return `${colors.background}`.trim();
+ return TRACK_CONFIG[type].background.trim();
}
export function getTrackHeight({ type }: { type: TrackType }): number {
- return TRACK_HEIGHTS[type];
+ return TRACK_CONFIG[type].height;
}
export function getCumulativeHeightBefore({
@@ -77,17 +76,7 @@ export function buildEmptyTrack({
type: TrackType;
name?: string;
}): TimelineTrack {
- const trackName =
- name ??
- (type === "video"
- ? "Video track"
- : type === "text"
- ? "Text track"
- : type === "audio"
- ? "Audio track"
- : type === "sticker"
- ? "Sticker track"
- : "Track");
+ const trackName = name ?? TRACK_CONFIG[type].defaultName;
switch (type) {
case "video":
@@ -124,6 +113,14 @@ export function buildEmptyTrack({
elements: [],
muted: false,
};
+ case "effect":
+ return {
+ id,
+ name: trackName,
+ type: "effect",
+ elements: [],
+ hidden: false,
+ };
default:
throw new Error(`Unsupported track type: ${type}`);
}
@@ -140,6 +137,10 @@ export function getDefaultInsertIndexForTrack({
return tracks.length;
}
+ if (trackType === "effect") {
+ return 0;
+ }
+
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (mainTrackIndex >= 0) {
return mainTrackIndex;
@@ -216,6 +217,7 @@ export function canElementGoOnTrack({
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
+ if (elementType === "effect") return trackType === "effect";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}