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/actions/definitions.ts7
-rw-r--r--apps/web/src/lib/animation/effect-param-channel.ts117
-rw-r--r--apps/web/src/lib/animation/keyframes.ts32
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/add-effect.ts74
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/index.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts65
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts73
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts67
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts86
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts1
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/index.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts68
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts84
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts39
-rw-r--r--apps/web/src/lib/export.ts20
-rw-r--r--apps/web/src/lib/text/layout.ts22
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts37
17 files changed, 755 insertions, 44 deletions
diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts
index 5ec056c4..82319216 100644
--- a/apps/web/src/lib/actions/definitions.ts
+++ b/apps/web/src/lib/actions/definitions.ts
@@ -114,6 +114,11 @@ export const ACTIONS = {
category: "selection",
defaultShortcuts: ["ctrl+a"],
},
+ "deselect-all": {
+ description: "Deselect all elements",
+ category: "selection",
+ defaultShortcuts: ["escape"],
+ },
"duplicate-selected": {
description: "Duplicate selected element",
category: "selection",
@@ -145,7 +150,7 @@ export const ACTIONS = {
export type TAction = keyof typeof ACTIONS;
-export function getActionDefinition(action: TAction): TActionDefinition {
+export function getActionDefinition({ action }: { action: TAction }): TActionDefinition {
return ACTIONS[action];
}
diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts
new file mode 100644
index 00000000..07efe4d8
--- /dev/null
+++ b/apps/web/src/lib/animation/effect-param-channel.ts
@@ -0,0 +1,117 @@
+import type { Effect, EffectParamValues } from "@/types/effects";
+import type {
+ ElementAnimations,
+ NumberAnimationChannel,
+} from "@/types/animation";
+import {
+ getChannel,
+ removeKeyframe,
+ setChannel,
+ upsertKeyframe,
+} from "./keyframes";
+import { getChannelValueAtTime } from "./interpolation";
+
+const EFFECT_PARAM_PATH_PREFIX = "effects.";
+const EFFECT_PARAM_PATH_SUFFIX = ".params.";
+
+function buildEffectParamPath({
+ effectId,
+ paramKey,
+}: {
+ effectId: string;
+ paramKey: string;
+}): string {
+ return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
+}
+
+export function resolveEffectParamsAtTime({
+ effect,
+ animations,
+ localTime,
+}: {
+ effect: Effect;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): EffectParamValues {
+ const resolved: EffectParamValues = {};
+
+ for (const [paramKey, staticValue] of Object.entries(effect.params)) {
+ const path = buildEffectParamPath({ effectId: effect.id, paramKey });
+ const channel = getChannel({ animations, propertyPath: path });
+ if (channel && channel.keyframes.length > 0) {
+ resolved[paramKey] = getChannelValueAtTime({
+ channel,
+ time: localTime,
+ fallbackValue: staticValue,
+ }) as number | string | boolean;
+ } else {
+ resolved[paramKey] = staticValue;
+ }
+ }
+
+ return resolved;
+}
+
+const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = {
+ valueKind: "number",
+ keyframes: [],
+};
+
+export function upsertEffectParamKeyframe({
+ animations,
+ effectId,
+ paramKey,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ effectId: string;
+ paramKey: string;
+ time: number;
+ value: number;
+ interpolation?: "linear" | "hold";
+ keyframeId?: string;
+}): ElementAnimations | undefined {
+ const path = buildEffectParamPath({ effectId, paramKey });
+ const channel = getChannel({ animations, propertyPath: path });
+ const targetChannel =
+ channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL;
+ const updatedChannel = upsertKeyframe({
+ channel: targetChannel,
+ time,
+ value,
+ interpolation: interpolation ?? "linear",
+ keyframeId,
+ });
+
+ return (
+ setChannel({
+ animations,
+ propertyPath: path,
+ channel: updatedChannel,
+ }) ?? { channels: {} }
+ );
+}
+
+export function removeEffectParamKeyframe({
+ animations,
+ effectId,
+ paramKey,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ effectId: string;
+ paramKey: string;
+ keyframeId: string;
+}): ElementAnimations | undefined {
+ const path = buildEffectParamPath({ effectId, paramKey });
+ const channel = getChannel({ animations, propertyPath: path });
+ const updatedChannel = removeKeyframe({ channel, keyframeId });
+ return setChannel({
+ animations,
+ propertyPath: path,
+ channel: updatedChannel,
+ });
+}
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
index 56f156a4..3e3613f2 100644
--- a/apps/web/src/lib/animation/keyframes.ts
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -127,7 +127,9 @@ function buildKeyframe({
}
if (typeof value !== "string" && typeof value !== "boolean") {
- throw new Error("Discrete channel keyframes require boolean or string values");
+ throw new Error(
+ "Discrete channel keyframes require boolean or string values",
+ );
}
return {
@@ -145,14 +147,23 @@ function createEmptyChannel({
}): AnimationChannel {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
if (propertyDefinition.valueKind === "number") {
- return { valueKind: "number", keyframes: [] } satisfies NumberAnimationChannel;
+ return {
+ valueKind: "number",
+ keyframes: [],
+ } satisfies NumberAnimationChannel;
}
if (propertyDefinition.valueKind === "color") {
- return { valueKind: "color", keyframes: [] } satisfies ColorAnimationChannel;
+ return {
+ valueKind: "color",
+ keyframes: [],
+ } satisfies ColorAnimationChannel;
}
- return { valueKind: "discrete", keyframes: [] } satisfies DiscreteAnimationChannel;
+ return {
+ valueKind: "discrete",
+ keyframes: [],
+ } satisfies DiscreteAnimationChannel;
}
export function upsertKeyframe({
@@ -432,7 +443,10 @@ export function splitAnimationsAtTime({
const hasBoundaryOnRight = rightKeyframes.some((keyframe) =>
isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }),
);
- if (shouldIncludeSplitBoundary && (!hasBoundaryOnLeft || !hasBoundaryOnRight)) {
+ if (
+ shouldIncludeSplitBoundary &&
+ (!hasBoundaryOnLeft || !hasBoundaryOnRight)
+ ) {
const boundaryValue = getChannelValueAtTime({
channel: normalizedChannel,
time: splitTime,
@@ -442,7 +456,9 @@ export function splitAnimationsAtTime({
? (propertyPath as AnimationPropertyPath)
: null;
const boundaryInterpolation = knownPropertyPath
- ? getDefaultInterpolationForProperty({ propertyPath: knownPropertyPath })
+ ? getDefaultInterpolationForProperty({
+ propertyPath: knownPropertyPath,
+ })
: normalizedChannel.valueKind === "discrete"
? "hold"
: "linear";
@@ -523,7 +539,9 @@ export function upsertElementKeyframe({
return animations;
}
- const defaultInterpolation = getDefaultInterpolationForProperty({ propertyPath });
+ const defaultInterpolation = getDefaultInterpolationForProperty({
+ propertyPath,
+ });
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
const channel = getChannel({ animations, propertyPath });
const targetChannel =
diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
new file mode 100644
index 00000000..5299749d
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
@@ -0,0 +1,74 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import { buildDefaultEffectInstance } from "@/lib/effects";
+
+function addEffectToElement({
+ element,
+ effectType,
+}: {
+ element: VisualElement;
+ effectType: string;
+}): VisualElement {
+ const instance = buildDefaultEffectInstance({ effectType });
+ const currentEffects = element.effects ?? [];
+ return { ...element, effects: [...currentEffects, instance] };
+}
+
+export class AddClipEffectCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private effectId: string | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectType: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectType,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectType: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectType = effectType;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ const updated = addEffectToElement({
+ element: element as VisualElement,
+ effectType: this.effectType,
+ });
+ const effects = updated.effects ?? [];
+ this.effectId = effects[effects.length - 1]?.id ?? null;
+ return updated;
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getEffectId(): string | null {
+ return this.effectId;
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/index.ts b/apps/web/src/lib/commands/timeline/element/effects/index.ts
new file mode 100644
index 00000000..133ce661
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/effects/index.ts
@@ -0,0 +1,5 @@
+export { AddClipEffectCommand } from "./add-effect";
+export { RemoveClipEffectCommand } from "./remove-effect";
+export { ToggleClipEffectCommand } from "./toggle-effect";
+export { UpdateClipEffectParamsCommand } from "./update-effect-params";
+export { ReorderClipEffectsCommand } from "./reorder-effect";
diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
new file mode 100644
index 00000000..b475474f
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
@@ -0,0 +1,65 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, VisualElement } from "@/types/timeline";
+
+function removeEffectFromElement({
+ element,
+ effectId,
+}: {
+ element: VisualElement;
+ effectId: string;
+}): VisualElement {
+ const currentEffects = element.effects ?? [];
+ const filtered = currentEffects.filter((effect) => effect.id !== effectId);
+ return { ...element, effects: filtered };
+}
+
+export class RemoveClipEffectCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return removeEffectFromElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
new file mode 100644
index 00000000..81c66503
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
@@ -0,0 +1,73 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, VisualElement } from "@/types/timeline";
+
+function reorderEffectsOnElement({
+ element,
+ fromIndex,
+ toIndex,
+}: {
+ element: VisualElement;
+ fromIndex: number;
+ toIndex: number;
+}): VisualElement {
+ const effects = [...(element.effects ?? [])];
+ const [moved] = effects.splice(fromIndex, 1);
+ effects.splice(toIndex, 0, moved);
+ return { ...element, effects };
+}
+
+export class ReorderClipEffectsCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly fromIndex: number;
+ private readonly toIndex: number;
+
+ constructor({
+ trackId,
+ elementId,
+ fromIndex,
+ toIndex,
+ }: {
+ trackId: string;
+ elementId: string;
+ fromIndex: number;
+ toIndex: number;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.fromIndex = fromIndex;
+ this.toIndex = toIndex;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return reorderEffectsOnElement({
+ element: element as VisualElement,
+ fromIndex: this.fromIndex,
+ toIndex: this.toIndex,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
new file mode 100644
index 00000000..70bf955d
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
@@ -0,0 +1,67 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, VisualElement } from "@/types/timeline";
+
+export function toggleEffectOnElement({
+ element,
+ effectId,
+}: {
+ element: VisualElement;
+ effectId: string;
+}): VisualElement {
+ const currentEffects = element.effects ?? [];
+ const updated = currentEffects.map((effect) =>
+ effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect,
+ );
+ return { ...element, effects: updated };
+}
+
+export class ToggleClipEffectCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return toggleEffectOnElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
new file mode 100644
index 00000000..d9bf0aad
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
@@ -0,0 +1,86 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { EffectParamValues } from "@/types/effects";
+import type { TimelineTrack, VisualElement } from "@/types/timeline";
+
+function updateEffectParamsOnElement({
+ element,
+ effectId,
+ params,
+}: {
+ element: VisualElement;
+ effectId: string;
+ params: Partial<EffectParamValues>;
+}): VisualElement {
+ const currentEffects = element.effects ?? [];
+ const updated = currentEffects.map((effect) => {
+ if (effect.id !== effectId) {
+ return effect;
+ }
+
+ const nextParams = { ...effect.params };
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined) {
+ nextParams[key] = value;
+ }
+ }
+
+ return { ...effect, params: nextParams };
+ });
+ return { ...element, effects: updated };
+}
+
+export class UpdateClipEffectParamsCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+ private readonly params: Partial<EffectParamValues>;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ params,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ params: Partial<EffectParamValues>;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ this.params = params;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return updateEffectParamsOnElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ params: this.params,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts
index 69d94c24..413d1adf 100644
--- a/apps/web/src/lib/commands/timeline/element/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/index.ts
@@ -10,3 +10,4 @@ export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
export * from "./keyframes";
+export * from "./effects";
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
index f221e33a..c03b14e9 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
@@ -1,3 +1,5 @@
+export * from "./remove-effect-param-keyframe";
export * from "./remove-keyframe";
export * from "./retime-keyframe";
+export * from "./upsert-effect-param-keyframe";
export * from "./upsert-keyframe";
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
new file mode 100644
index 00000000..d4922532
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
@@ -0,0 +1,68 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
+import { updateElementInTracks } from "@/lib/timeline";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import type { TimelineTrack } from "@/types/timeline";
+
+export class RemoveEffectParamKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+ private readonly paramKey: string;
+ private readonly keyframeId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ paramKey,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ paramKey: string;
+ keyframeId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ this.paramKey = paramKey;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ const animations = removeEffectParamKeyframe({
+ animations: element.animations,
+ effectId: this.effectId,
+ paramKey: this.paramKey,
+ keyframeId: this.keyframeId,
+ });
+ return { ...element, animations };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
new file mode 100644
index 00000000..f5236279
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
@@ -0,0 +1,84 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
+import { updateElementInTracks } from "@/lib/timeline";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import type { TimelineTrack } from "@/types/timeline";
+
+export class UpsertEffectParamKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+ private readonly paramKey: string;
+ private readonly time: number;
+ private readonly value: number;
+ private readonly interpolation: "linear" | "hold" | undefined;
+ private readonly keyframeId: string | undefined;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ paramKey,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ paramKey: string;
+ time: number;
+ value: number;
+ interpolation?: "linear" | "hold";
+ keyframeId?: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ this.paramKey = paramKey;
+ this.time = time;
+ this.value = value;
+ this.interpolation = interpolation;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ const boundedTime = Math.max(0, Math.min(this.time, element.duration));
+ const animations = upsertEffectParamKeyframe({
+ animations: element.animations,
+ effectId: this.effectId,
+ paramKey: this.paramKey,
+ time: boundedTime,
+ value: this.value,
+ interpolation: this.interpolation,
+ keyframeId: this.keyframeId,
+ });
+ return { ...element, animations };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts
index 6aadcf16..51e18c21 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -11,6 +11,7 @@ import {
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
+import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -19,6 +20,7 @@ export class MoveElementCommand extends Command {
private readonly elementId: string;
private readonly newStartTime: number;
private readonly createTrack: { type: TrackType; index: number } | undefined;
+ private readonly rippleEnabled: boolean;
constructor({
sourceTrackId,
@@ -26,12 +28,14 @@ export class MoveElementCommand extends Command {
elementId,
newStartTime,
createTrack,
+ rippleEnabled = false,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
+ rippleEnabled?: boolean;
}) {
super();
this.sourceTrackId = sourceTrackId;
@@ -39,6 +43,7 @@ export class MoveElementCommand extends Command {
this.elementId = elementId;
this.newStartTime = newStartTime;
this.createTrack = createTrack;
+ this.rippleEnabled = rippleEnabled;
}
execute(): void {
@@ -53,8 +58,7 @@ export class MoveElementCommand extends Command {
);
if (!sourceTrack || !element) {
- console.error("Source track or element not found");
- return;
+ throw new Error("Source track or element not found");
}
let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
@@ -69,8 +73,7 @@ export class MoveElementCommand extends Command {
targetTrack = newTrack;
}
if (!targetTrack) {
- console.error("Target track not found");
- return;
+ throw new Error("Target track not found");
}
const validation = validateElementTrackCompatibility({
@@ -79,8 +82,7 @@ export class MoveElementCommand extends Command {
});
if (!validation.isValid) {
- console.error(validation.errorMessage);
- return;
+ throw new Error(validation.errorMessage);
}
const adjustedStartTime = enforceMainTrackStart({
@@ -105,27 +107,32 @@ export class MoveElementCommand extends Command {
elements: track.elements.map((trackElement) =>
trackElement.id === this.elementId ? movedElement : trackElement,
),
- };
+ } as typeof track;
}
if (track.id === this.sourceTrackId) {
- return {
- ...track,
- elements: track.elements.filter(
- (trackElement) => trackElement.id !== this.elementId,
- ),
- };
+ const remainingElements = track.elements.filter(
+ (trackElement) => trackElement.id !== this.elementId,
+ );
+ const shiftedElements = this.rippleEnabled
+ ? rippleShiftElements({
+ elements: remainingElements,
+ afterTime: element.startTime,
+ shiftAmount: element.duration,
+ })
+ : remainingElements;
+ return { ...track, elements: shiftedElements } as typeof track;
}
if (track.id === this.targetTrackId) {
return {
...track,
elements: [...track.elements, movedElement],
- };
+ } as typeof track;
}
- return track;
- });
+ return track;
+ });
if (!isSameTrack) {
const sourceTrackAfterMove = updatedTracks.find(
diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts
index 67621e16..ae68144b 100644
--- a/apps/web/src/lib/export.ts
+++ b/apps/web/src/lib/export.ts
@@ -16,3 +16,23 @@ export function getExportFileExtension({
}): string {
return `.${format}`;
}
+
+export function downloadBuffer({
+ buffer,
+ filename,
+ mimeType,
+}: {
+ buffer: ArrayBuffer;
+ filename: string;
+ mimeType: string;
+}): void {
+ const blob = new Blob([buffer], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const downloadLink = document.createElement("a");
+ downloadLink.href = url;
+ downloadLink.download = filename;
+ document.body.appendChild(downloadLink);
+ downloadLink.click();
+ document.body.removeChild(downloadLink);
+ URL.revokeObjectURL(url);
+}
diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts
index 8c38e9aa..3eadcec2 100644
--- a/apps/web/src/lib/text/layout.ts
+++ b/apps/web/src/lib/text/layout.ts
@@ -1,5 +1,5 @@
import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants";
-import type { TextElement } from "@/types/timeline";
+import type { TextBackground, TextElement } from "@/types/timeline";
type TextRect = {
left: number;
@@ -74,8 +74,12 @@ function getTextRect({
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
}): TextRect {
- const left =
- textAlign === "left" ? 0 : textAlign === "right" ? -block.maxWidth : -block.maxWidth / 2;
+ const textAlignToLeft: Record<typeof textAlign, number> = {
+ left: 0,
+ right: -block.maxWidth,
+ center: -block.maxWidth / 2,
+ };
+ const left = textAlignToLeft[textAlign];
return {
left,
@@ -88,9 +92,13 @@ function getTextRect({
function isTextBackgroundVisible({
background,
}: {
- background: TextElement["background"];
+ background: TextBackground;
}): boolean {
- return Boolean(background.color) && background.color !== "transparent";
+ return (
+ background.enabled &&
+ Boolean(background.color) &&
+ background.color !== "transparent"
+ );
}
export function getTextBackgroundRect({
@@ -101,7 +109,7 @@ export function getTextBackgroundRect({
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
- background: TextElement["background"];
+ background: TextBackground;
fontSizeRatio?: number;
}): TextRect | null {
if (!isTextBackgroundVisible({ background })) {
@@ -132,7 +140,7 @@ export function getTextVisualRect({
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
- background: TextElement["background"];
+ background: TextBackground;
fontSizeRatio?: number;
}): TextRect {
const textRect = getTextRect({ textAlign, block });
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
index 8c02c097..11564376 100644
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -13,6 +13,7 @@ import type {
CreateStickerElement,
CreateUploadAudioElement,
CreateLibraryAudioElement,
+ TextBackground,
TextElement,
TimelineElement,
TimelineTrack,
@@ -46,7 +47,7 @@ export function isVisualElement(
export function canElementBeHidden(
element: TimelineElement,
): element is VisualElement {
- return element.type !== "audio";
+ return isVisualElement(element);
}
export function hasMediaId(
@@ -127,13 +128,30 @@ export function wouldElementOverlap({
endTime: number;
excludeElementId?: string;
}): boolean {
- return elements.some((el) => {
- if (excludeElementId && el.id === excludeElementId) return false;
- const elEnd = el.startTime + el.duration;
- return startTime < elEnd && endTime > el.startTime;
+ return elements.some((element) => {
+ if (excludeElementId && element.id === excludeElementId) return false;
+ const elementEnd = element.startTime + element.duration;
+ return startTime < elementEnd && endTime > element.startTime;
});
}
+function buildTextBackground(
+ raw: Partial<TextBackground> | undefined,
+): TextBackground {
+ const color = raw?.color ?? DEFAULT_TEXT_ELEMENT.background.color;
+ const enabled =
+ typeof raw?.enabled === "boolean" ? raw.enabled : color !== "transparent";
+ return {
+ enabled,
+ color,
+ cornerRadius: raw?.cornerRadius,
+ paddingX: raw?.paddingX,
+ paddingY: raw?.paddingY,
+ offsetX: raw?.offsetX,
+ offsetY: raw?.offsetY,
+ };
+}
+
export function buildTextElement({
raw,
startTime,
@@ -157,14 +175,7 @@ export function buildTextElement({
: DEFAULT_TEXT_ELEMENT.fontSize,
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,
- },
+ background: buildTextBackground(t.background),
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,