summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-29 01:28:50 +0200
committerMaze Winther <mazewinther@gmail.com>2026-05-02 03:27:23 +0200
commit2ee0a44735d2b930348e9774909638b2c1909738 (patch)
treee73e60097338ed411739f3b04808dad86f7bb192 /apps/web/src/timeline
parenta9b9cf6bf50f08723896659634f2e6afc17bcd42 (diff)
downloadopencut-2ee0a44735d2b930348e9774909638b2c1909738.tar.gz
refactor: unify element params
Diffstat (limited to 'apps/web/src/timeline')
-rw-r--r--apps/web/src/timeline/__tests__/update-pipeline.test.ts10
-rw-r--r--apps/web/src/timeline/animation-targets.ts217
-rw-r--r--apps/web/src/timeline/audio-separation/index.ts9
-rw-r--r--apps/web/src/timeline/audio-state.ts21
-rw-r--r--apps/web/src/timeline/components/audio-tab.tsx151
-rw-r--r--apps/web/src/timeline/components/audio-volume-line.tsx13
-rw-r--r--apps/web/src/timeline/components/timeline-element.tsx8
-rw-r--r--apps/web/src/timeline/controllers/drag-drop-controller.ts5
-rw-r--r--apps/web/src/timeline/defaults.ts40
-rw-r--r--apps/web/src/timeline/element-utils.ts106
-rw-r--r--apps/web/src/timeline/placement/__tests__/resolve.test.ts53
-rw-r--r--apps/web/src/timeline/types.ts37
-rw-r--r--apps/web/src/timeline/update-pipeline.ts9
13 files changed, 280 insertions, 399 deletions
diff --git a/apps/web/src/timeline/__tests__/update-pipeline.test.ts b/apps/web/src/timeline/__tests__/update-pipeline.test.ts
index e6d63bd6..40a0e645 100644
--- a/apps/web/src/timeline/__tests__/update-pipeline.test.ts
+++ b/apps/web/src/timeline/__tests__/update-pipeline.test.ts
@@ -23,8 +23,14 @@ function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
mediaId: "media-1",
- transform: buildTransform(),
- opacity: 1,
+ params: {
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
+ },
...overrides,
};
}
diff --git a/apps/web/src/timeline/animation-targets.ts b/apps/web/src/timeline/animation-targets.ts
index fe683f92..8cccc78f 100644
--- a/apps/web/src/timeline/animation-targets.ts
+++ b/apps/web/src/timeline/animation-targets.ts
@@ -6,12 +6,6 @@ import type {
NumericSpec,
} from "@/animation/types";
import {
- coerceAnimationParamValue,
- getAnimationParamDefaultInterpolation,
- getAnimationParamNumericRange,
- getAnimationParamValueKind,
-} from "@/animation/animated-params";
-import {
parseEffectParamPath,
} from "@/animation/effect-param-channel";
import {
@@ -19,16 +13,22 @@ import {
} from "@/animation/graphic-param-channel";
import { effectsRegistry, registerDefaultEffects } from "@/effects";
import { getGraphicDefinition } from "@/graphics";
-import type { ParamDefinition } from "@/params";
+import {
+ coerceParamValue,
+ getParamDefaultInterpolation,
+ getParamNumericRange,
+ getParamValueKind,
+ type ParamDefinition,
+ type ParamValues,
+} from "@/params";
+import {
+ getElementParam,
+ readElementParamValue,
+ writeElementParamValue,
+ type ElementParamDefinition,
+} from "@/params/registry";
import type { TimelineElement } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils";
-import { isAnimationPropertyPath } from "@/animation/path";
-import {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
- getElementBaseValueForProperty,
- withElementBaseValueForProperty,
-} from "@/animation";
export interface AnimationPathDescriptor {
kind: AnimationBindingKind;
@@ -47,52 +47,115 @@ function paramNumericRanges({
}: {
param: ParamDefinition;
}): Partial<Record<string, NumericSpec>> | undefined {
- const range = getAnimationParamNumericRange({ param });
+ const range = getParamNumericRange({ param });
return range ? { value: range } : undefined;
}
-function buildGraphicParamDescriptor({
+function buildParamDescriptor({
+ param,
+ baseParams,
+ setParams,
+}: {
+ param: ParamDefinition;
+ baseParams: ParamValues;
+ setParams: (params: ParamValues) => TimelineElement;
+}): AnimationPathDescriptor | null {
+ if (param.keyframable === false) {
+ return null;
+ }
+
+ return {
+ kind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRanges: paramNumericRanges({ param }),
+ coerceValue: ({ value }) => coerceParamValue({ param, value }),
+ getBaseValue: () => baseParams[param.key] ?? param.default,
+ setBaseValue: ({ value }) => {
+ const coercedValue = coerceParamValue({ param, value });
+ if (coercedValue === null) {
+ return setParams(baseParams);
+ }
+
+ return setParams({
+ ...baseParams,
+ [param.key]: coercedValue,
+ });
+ },
+ };
+}
+
+function buildElementParamDescriptor({
element,
paramKey,
}: {
element: TimelineElement;
paramKey: string;
}): AnimationPathDescriptor | null {
- if (element.type !== "graphic") {
+ const param = getElementParam({ element, key: paramKey });
+ if (!param) {
return null;
}
- const definition = getGraphicDefinition({
- definitionId: element.definitionId,
- });
- const param = definition.params.find((candidate) => candidate.key === paramKey);
- if (!param) {
+ return buildTimelineElementParamDescriptor({ element, param });
+}
+
+function buildTimelineElementParamDescriptor({
+ element,
+ param,
+}: {
+ element: TimelineElement;
+ param: ElementParamDefinition;
+}): AnimationPathDescriptor | null {
+ if (param.keyframable === false) {
return null;
}
return {
- kind: getAnimationParamValueKind({ param }),
- defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
+ kind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRanges: paramNumericRanges({ param }),
- coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
- getBaseValue: () => element.params[param.key] ?? param.default,
+ coerceValue: ({ value }) => coerceParamValue({ param, value }),
+ getBaseValue: () => readElementParamValue({ element, param }),
setBaseValue: ({ value }) => {
- const coercedValue = coerceAnimationParamValue({ param, value });
+ const coercedValue = coerceParamValue({ param, value });
if (coercedValue === null) {
return element;
}
- return {
- ...element,
- params: {
- ...element.params,
- [param.key]: coercedValue,
- },
- };
+ return writeElementParamValue({ element, param, value: coercedValue });
},
};
}
+function buildGraphicParamDescriptor({
+ element,
+ paramKey,
+}: {
+ element: TimelineElement;
+ paramKey: string;
+}): AnimationPathDescriptor | null {
+ if (element.type !== "graphic") {
+ return null;
+ }
+
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ const param = definition.params.find((candidate) => candidate.key === paramKey);
+ if (!param) {
+ return null;
+ }
+
+ return buildParamDescriptor({
+ param,
+ baseParams: element.params,
+ setParams: (params) => ({
+ ...element,
+ params,
+ }),
+ });
+}
+
function buildEffectParamDescriptor({
element,
effectId,
@@ -118,35 +181,22 @@ function buildEffectParamDescriptor({
return null;
}
- return {
- kind: getAnimationParamValueKind({ param }),
- defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
- numericRanges: paramNumericRanges({ param }),
- coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
- getBaseValue: () => effect.params[param.key] ?? param.default,
- setBaseValue: ({ value }) => {
- const coercedValue = coerceAnimationParamValue({ param, value });
- if (coercedValue === null) {
- return element;
- }
-
- return {
- ...element,
- effects:
- element.effects?.map((candidate) =>
- candidate.id !== effectId
- ? candidate
- : {
- ...candidate,
- params: {
- ...candidate.params,
- [param.key]: coercedValue,
- },
- },
- ) ?? element.effects,
- };
- },
- };
+ return buildParamDescriptor({
+ param,
+ baseParams: effect.params,
+ setParams: (params) => ({
+ ...element,
+ effects:
+ element.effects?.map((candidate) =>
+ candidate.id !== effectId
+ ? candidate
+ : {
+ ...candidate,
+ params,
+ },
+ ) ?? element.effects,
+ }),
+ });
}
export function resolveAnimationTarget({
@@ -156,41 +206,12 @@ export function resolveAnimationTarget({
element: TimelineElement;
path: AnimationPath;
}): AnimationPathDescriptor | null {
- if (isAnimationPropertyPath(path)) {
- const propertyDefinition = getAnimationPropertyDefinition({
- propertyPath: path,
- });
- if (!propertyDefinition.supportsElement({ element })) {
- return null;
- }
-
- return {
- kind: propertyDefinition.kind,
- defaultInterpolation: propertyDefinition.defaultInterpolation,
- numericRanges: propertyDefinition.numericRanges,
- coerceValue: ({ value }) =>
- coerceAnimationValueForProperty({
- propertyPath: path,
- value,
- }),
- getBaseValue: () =>
- getElementBaseValueForProperty({
- element,
- propertyPath: path,
- }),
- setBaseValue: ({ value }) => {
- const coercedValue = propertyDefinition.coerceValue({ value });
- if (coercedValue === null) {
- return element;
- }
-
- return withElementBaseValueForProperty({
- element,
- propertyPath: path,
- value: coercedValue,
- });
- },
- };
+ const elementParamTarget = buildElementParamDescriptor({
+ element,
+ paramKey: path,
+ });
+ if (elementParamTarget) {
+ return elementParamTarget;
}
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
diff --git a/apps/web/src/timeline/audio-separation/index.ts b/apps/web/src/timeline/audio-separation/index.ts
index beb488de..dd56df5f 100644
--- a/apps/web/src/timeline/audio-separation/index.ts
+++ b/apps/web/src/timeline/audio-separation/index.ts
@@ -87,8 +87,13 @@ export function buildSeparatedAudioElement({
trimStart: sourceElement.trimStart,
trimEnd: sourceElement.trimEnd,
sourceDuration: sourceElement.sourceDuration,
- volume: sourceElement.volume ?? DEFAULTS.element.volume,
- muted: sourceElement.muted ?? false,
+ params: {
+ volume:
+ typeof sourceElement.params.volume === "number"
+ ? sourceElement.params.volume
+ : DEFAULTS.element.volume,
+ muted: sourceElement.params.muted === true,
+ },
retime: sourceElement.retime
? {
rate: sourceElement.retime.rate,
diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts
index 44df5aaf..6c8f3044 100644
--- a/apps/web/src/timeline/audio-state.ts
+++ b/apps/web/src/timeline/audio-state.ts
@@ -21,6 +21,23 @@ export function dBToLinear(db: number): number {
return 10 ** (clampDb(db) / 20);
}
+export function getElementVolume({
+ element,
+}: {
+ element: AudioCapableElement;
+}): number {
+ const value = element.params.volume;
+ return typeof value === "number" ? value : 0;
+}
+
+export function isElementMuted({
+ element,
+}: {
+ element: AudioCapableElement;
+}): boolean {
+ return element.params.muted === true;
+}
+
export function hasAnimatedVolume({
element,
}: {
@@ -43,12 +60,12 @@ export function resolveEffectiveAudioGain({
trackMuted?: boolean;
localTime: number;
}): number {
- if (trackMuted || element.muted === true) {
+ if (trackMuted || isElementMuted({ element })) {
return 0;
}
const resolvedDb = resolveNumberAtTime({
- baseValue: element.volume ?? 0,
+ baseValue: getElementVolume({ element }),
animations: element.animations,
propertyPath: "volume",
localTime: Math.round(localTime * TICKS_PER_SECOND),
diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx
deleted file mode 100644
index 1e47dcc8..00000000
--- a/apps/web/src/timeline/components/audio-tab.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import { Button } from "@/components/ui/button";
-import { NumberField } from "@/components/ui/number-field";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
-import { isSourceAudioSeparated } from "@/timeline/audio-separation";
-import { DEFAULTS } from "@/timeline/defaults";
-import {
- clamp,
- formatNumberForDisplay,
- getFractionDigitsForStep,
- isNearlyEqual,
- snapToStep,
-} from "@/utils/math";
-import type { AudioElement, VideoElement } from "@/timeline";
-import { resolveNumberAtTime } from "@/animation/values";
-import { useEditor } from "@/editor/use-editor";
-import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
-import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { VolumeHighIcon } from "@hugeicons/core-free-icons";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-
-const VOLUME_STEP = 0.1;
-const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
-
-export function AudioTab({
- element,
- trackId,
-}: {
- element: AudioElement | VideoElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedVolume = resolveNumberAtTime({
- baseValue: element.volume ?? DEFAULTS.element.volume,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- });
- const volume = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: formatNumberForDisplay({
- value: resolvedVolume,
- fractionDigits: VOLUME_FRACTION_DIGITS,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) {
- return null;
- }
-
- return clamp({
- value: snapToStep({ value: parsed, step: VOLUME_STEP }),
- min: VOLUME_DB_MIN,
- max: VOLUME_DB_MAX,
- });
- },
- valueAtPlayhead: resolvedVolume,
- step: VOLUME_STEP,
- buildBaseUpdates: ({ value }) => ({
- volume: value,
- }),
- });
- const isDefault =
- volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
- ? isNearlyEqual({
- leftValue: resolvedVolume,
- rightValue: DEFAULTS.element.volume,
- })
- : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
- const isSeparated =
- element.type === "video" && isSourceAudioSeparated({ element });
-
- return (
- <>
- {isSeparated && (
- <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
- <p className="text-sm">Audio has been separated.</p>
- <Button
- className="mt-3"
- size="sm"
- variant="secondary"
- onClick={() =>
- editor.timeline.toggleSourceAudioSeparation({
- trackId,
- elementId: element.id,
- })
- }
- >
- Recover audio
- </Button>
- </div>
- )}
- <Section collapsible sectionKey={`${element.id}:audio`}>
- <SectionHeader>
- <SectionTitle>Audio</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField
- label="Volume"
- beforeLabel={
- <KeyframeToggle
- isActive={volume.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle volume keyframe"
- onToggle={volume.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon={<HugeiconsIcon icon={VolumeHighIcon} />}
- value={volume.displayValue}
- onFocus={volume.onFocus}
- onChange={volume.onChange}
- onBlur={volume.onBlur}
- dragSensitivity="slow"
- scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
- onScrub={volume.scrubTo}
- onScrubEnd={volume.commitScrub}
- onReset={() =>
- volume.commitValue({
- value: DEFAULTS.element.volume,
- })
- }
- isDefault={isDefault}
- suffix="dB"
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- </>
- );
-}
diff --git a/apps/web/src/timeline/components/audio-volume-line.tsx b/apps/web/src/timeline/components/audio-volume-line.tsx
index ac2fd32a..659bc5dc 100644
--- a/apps/web/src/timeline/components/audio-volume-line.tsx
+++ b/apps/web/src/timeline/components/audio-volume-line.tsx
@@ -3,13 +3,12 @@
import { useCallback, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useEditor } from "@/editor/use-editor";
-import { DEFAULTS } from "@/timeline/defaults";
import {
getDbFromLinePos,
getLinePosFromDb,
} from "@/timeline/audio-display";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
-import { hasAnimatedVolume } from "@/timeline/audio-state";
+import { getElementVolume, hasAnimatedVolume } from "@/timeline/audio-state";
import type { AudioElement } from "@/timeline/types";
import {
clamp,
@@ -60,10 +59,8 @@ export function AudioVolumeLine({
const editor = useEditor();
const surfaceRef = useRef<HTMLDivElement>(null);
const activePointerIdRef = useRef<number | null>(null);
- const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume);
- const lastPreviewVolumeRef = useRef(
- element.volume ?? DEFAULTS.element.volume,
- );
+ const startVolumeRef = useRef(getElementVolume({ element }));
+ const lastPreviewVolumeRef = useRef(getElementVolume({ element }));
const hasChangedRef = useRef(false);
const [isDragging, setIsDragging] = useState(false);
const [tooltipClientPos, setTooltipClientPos] = useState<{
@@ -72,7 +69,7 @@ export function AudioVolumeLine({
} | null>(null);
const hasAnimatedEnvelope = hasAnimatedVolume({ element });
- const currentVolume = element.volume ?? DEFAULTS.element.volume;
+ const currentVolume = getElementVolume({ element });
const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`;
const volumeLabel = `${formatNumberForDisplay({
@@ -96,7 +93,7 @@ export function AudioVolumeLine({
{
trackId,
elementId: element.id,
- updates: { volume: nextVolume },
+ updates: { params: { volume: nextVolume } },
},
],
});
diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx
index be8409f3..74f026dc 100644
--- a/apps/web/src/timeline/components/timeline-element.tsx
+++ b/apps/web/src/timeline/components/timeline-element.tsx
@@ -47,7 +47,7 @@ import {
getSourceAudioActionLabel,
isSourceAudioSeparated,
} from "@/timeline/audio-separation";
-import { buildWaveformGainSamples } from "@/timeline/audio-state";
+import { buildWaveformGainSamples, isElementMuted } from "@/timeline/audio-state";
import { getTimelinePixelsPerSecond } from "@/timeline";
import { buildWaveformSourceKey } from "@/media/waveform-summary";
import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
@@ -335,7 +335,7 @@ export function TimelineElement({
}
};
- const isMuted = canElementHaveAudio(element) && element.muted === true;
+ const isMuted = canElementHaveAudio(element) && isElementMuted({ element });
const canToggleCurrentSourceAudio =
selectedElements.length === 1 &&
isCurrentElementSelected &&
@@ -908,7 +908,9 @@ function TextElementContent({
}) {
return (
<div className="flex size-full items-center justify-start pl-2">
- <span className="truncate text-xs text-white">{element.content}</span>
+ <span className="truncate text-xs text-white">
+ {typeof element.params.content === "string" ? element.params.content : ""}
+ </span>
</div>
);
}
diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts
index e0624a74..a419101f 100644
--- a/apps/web/src/timeline/controllers/drag-drop-controller.ts
+++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts
@@ -385,7 +385,10 @@ export class DragDropController {
dragData: Extract<TimelineDragData, { type: "text" }>;
}): void {
const element = buildTextElement({
- raw: { name: dragData.name ?? "", content: dragData.content ?? "" },
+ raw: {
+ name: dragData.name ?? "",
+ params: { content: dragData.content ?? "" },
+ },
startTime: target.xPosition,
});
this.insertAtTarget({ element, target, trackType: "text" });
diff --git a/apps/web/src/timeline/defaults.ts b/apps/web/src/timeline/defaults.ts
index 3b47b4f4..d32fb649 100644
--- a/apps/web/src/timeline/defaults.ts
+++ b/apps/web/src/timeline/defaults.ts
@@ -31,26 +31,36 @@ const defaultTextBackground = {
const defaultTextElement: Omit<TextElement, "id"> = {
type: "text",
name: "Text",
- content: "Default text",
- fontSize: 15,
- fontFamily: "Arial",
- color: "#ffffff",
- background: { ...defaultTextBackground },
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- letterSpacing: defaultTextLetterSpacing,
- lineHeight: defaultTextLineHeight,
duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime: ZERO_MEDIA_TIME,
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
- transform: {
- ...defaultTransform,
- position: { ...defaultTransform.position },
+ params: {
+ content: "Default text",
+ fontSize: 15,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ letterSpacing: defaultTextLetterSpacing,
+ lineHeight: defaultTextLineHeight,
+ "background.enabled": defaultTextBackground.enabled,
+ "background.color": defaultTextBackground.color,
+ "background.cornerRadius": defaultTextBackground.cornerRadius,
+ "background.paddingX": defaultTextBackground.paddingX,
+ "background.paddingY": defaultTextBackground.paddingY,
+ "background.offsetX": defaultTextBackground.offsetX,
+ "background.offsetY": defaultTextBackground.offsetY,
+ "transform.positionX": defaultTransform.position.x,
+ "transform.positionY": defaultTransform.position.y,
+ "transform.scaleX": defaultTransform.scaleX,
+ "transform.scaleY": defaultTransform.scaleY,
+ "transform.rotate": defaultTransform.rotate,
+ opacity: defaultOpacity,
+ blendMode: defaultBlendMode,
},
- opacity: defaultOpacity,
};
const defaultTimelineViewState: TTimelineViewState = {
diff --git a/apps/web/src/timeline/element-utils.ts b/apps/web/src/timeline/element-utils.ts
index cddbf6fd..10c759d9 100644
--- a/apps/web/src/timeline/element-utils.ts
+++ b/apps/web/src/timeline/element-utils.ts
@@ -11,7 +11,6 @@ import {
type CreateStickerElement,
type CreateUploadAudioElement,
type CreateLibraryAudioElement,
- type TextBackground,
type TextElement,
type SceneTracks,
type TimelineElement,
@@ -28,6 +27,10 @@ import type { MediaType } from "@/media/types";
import { buildDefaultEffectInstance } from "@/effects";
import { buildDefaultGraphicInstance } from "@/graphics";
import type { ParamValues } from "@/params";
+import {
+ buildDefaultParamValues,
+ getBuiltInElementParams,
+} from "@/params/registry";
import { capitalizeFirstLetter } from "@/utils/string";
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
@@ -87,20 +90,12 @@ export function requiresMediaId({
);
}
-function buildTextBackground(
- raw: Partial<TextBackground> | undefined,
-): TextBackground {
- const color = raw?.color ?? DEFAULTS.text.element.background.color;
- const enabled = raw?.enabled ?? color !== "transparent";
- return {
- enabled,
- color,
- cornerRadius: raw?.cornerRadius,
- paddingX: raw?.paddingX,
- paddingY: raw?.paddingY,
- offsetX: raw?.offsetX,
- offsetY: raw?.offsetY,
- };
+function buildDefaultElementParams({
+ type,
+}: {
+ type: TimelineElement["type"];
+}): ParamValues {
+ return buildDefaultParamValues(getBuiltInElementParams({ type }));
}
export function buildTextElement({
@@ -115,24 +110,14 @@ export function buildTextElement({
return {
type: "text",
name: t.name ?? DEFAULTS.text.element.name,
- content: t.content ?? DEFAULTS.text.element.content,
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
startTime,
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
- fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
- fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
- color: t.color ?? DEFAULTS.text.element.color,
- background: buildTextBackground(t.background),
- textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
- fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
- fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
- textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
- letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
- lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
- transform: t.transform ?? DEFAULTS.text.element.transform,
- opacity: t.opacity ?? DEFAULTS.text.element.opacity,
- blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
+ params: {
+ ...buildDefaultElementParams({ type: "text" }),
+ ...(t.params ?? {}),
+ },
};
}
@@ -183,12 +168,7 @@ export function buildStickerElement({
startTime,
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
+ params: buildDefaultElementParams({ type: "sticker" }),
};
}
@@ -208,20 +188,36 @@ export function buildGraphicElement({
type: "graphic",
name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
definitionId: instance.definitionId,
- params: { ...instance.params, ...(params ?? {}) } as ParamValues,
+ params: mergeParamValues({
+ base: {
+ ...buildDefaultElementParams({ type: "graphic" }),
+ ...instance.params,
+ },
+ overrides: params,
+ }),
duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime,
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
};
}
+function mergeParamValues({
+ base,
+ overrides,
+}: {
+ base: ParamValues;
+ overrides?: Partial<ParamValues>;
+}): ParamValues {
+ const result: ParamValues = { ...base };
+ for (const [key, value] of Object.entries(overrides ?? {})) {
+ if (value !== undefined) {
+ result[key] = value;
+ }
+ }
+ return result;
+}
+
function buildVideoElement({
mediaId,
name,
@@ -242,16 +238,9 @@ function buildVideoElement({
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration,
- muted: false,
isSourceAudioEnabled: true,
hidden: false,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
- volume: DEFAULTS.element.volume,
+ params: buildDefaultElementParams({ type: "video" }),
};
}
@@ -275,12 +264,7 @@ function buildImageElement({
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
hidden: false,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
+ params: buildDefaultElementParams({ type: "image" }),
};
}
@@ -307,8 +291,7 @@ function buildUploadAudioElement({
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration,
- volume: DEFAULTS.element.volume,
- muted: false,
+ params: buildDefaultElementParams({ type: "audio" }),
};
if (buffer) {
element.buffer = buffer;
@@ -370,8 +353,7 @@ export function buildLibraryAudioElement({
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration,
- volume: DEFAULTS.element.volume,
- muted: false,
+ params: buildDefaultElementParams({ type: "audio" }),
};
if (buffer) {
element.buffer = buffer;
@@ -411,8 +393,8 @@ export function getElementFontFamilies({
const families = new Set<string>();
for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
for (const element of track.elements) {
- if (element.type === "text" && element.fontFamily) {
- families.add(element.fontFamily);
+ if (element.type === "text" && typeof element.params.fontFamily === "string") {
+ families.add(element.params.fontFamily);
}
if ("masks" in element) {
for (const mask of element.masks ?? []) {
diff --git a/apps/web/src/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/timeline/placement/__tests__/resolve.test.ts
index 44f0a1fe..202c9eb8 100644
--- a/apps/web/src/timeline/placement/__tests__/resolve.test.ts
+++ b/apps/web/src/timeline/placement/__tests__/resolve.test.ts
@@ -72,7 +72,7 @@ function buildElement({
duration: mediaTime({ ticks: duration }),
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
- volume: 1,
+ params: { volume: 1, muted: false },
sourceType: "upload",
mediaId: `media-${id}`,
} satisfies AudioElement;
@@ -86,9 +86,14 @@ function buildElement({
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
definitionId: `graphic-${id}`,
- params: {},
- transform: buildTransform(),
- opacity: 1,
+ params: {
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
+ },
} satisfies GraphicElement;
case "text":
return {
@@ -99,20 +104,24 @@ function buildElement({
duration: mediaTime({ ticks: duration }),
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
- content: id,
- fontSize: 32,
- fontFamily: "sans-serif",
- color: "#ffffff",
- background: {
- enabled: false,
- color: "#000000",
+ params: {
+ content: id,
+ fontSize: 32,
+ fontFamily: "sans-serif",
+ color: "#ffffff",
+ "background.enabled": false,
+ "background.color": "#000000",
+ textAlign: "left",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
},
- textAlign: "left",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- transform: buildTransform(),
- opacity: 1,
} satisfies TextElement;
case "video":
return {
@@ -124,8 +133,14 @@ function buildElement({
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
mediaId: `media-${id}`,
- transform: buildTransform(),
- opacity: 1,
+ params: {
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
+ },
} satisfies VideoElement;
}
diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts
index e84a4f4e..c009bc16 100644
--- a/apps/web/src/timeline/types.ts
+++ b/apps/web/src/timeline/types.ts
@@ -2,7 +2,6 @@ import type { ElementAnimations } from "@/animation/types";
import type { Effect } from "@/effects/types";
import type { Mask } from "@/masks/types";
import type { ParamValues } from "@/params";
-import type { BlendMode, Transform } from "@/rendering";
import type { MediaTime } from "@/wasm";
export type ElementRef = {
@@ -87,8 +86,6 @@ export interface RetimeConfig {
interface BaseAudioElement extends BaseTimelineElement {
type: "audio";
- volume: number;
- muted?: boolean;
buffer?: AudioBuffer;
retime?: RetimeConfig;
}
@@ -114,6 +111,7 @@ interface BaseTimelineElement {
trimEnd: MediaTime;
sourceDuration?: MediaTime;
animations?: ElementAnimations;
+ params: ParamValues;
}
export interface VideoElement extends BaseTimelineElement {
@@ -124,9 +122,6 @@ export interface VideoElement extends BaseTimelineElement {
isSourceAudioEnabled?: boolean;
hidden?: boolean;
retime?: RetimeConfig;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
effects?: Effect[];
masks?: Mask[];
}
@@ -135,9 +130,6 @@ export interface ImageElement extends BaseTimelineElement {
type: "image";
mediaId: string;
hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
effects?: Effect[];
masks?: Mask[];
}
@@ -154,21 +146,7 @@ export interface TextBackground {
export interface TextElement extends BaseTimelineElement {
type: "text";
- content: string;
- fontSize: number;
- fontFamily: string;
- color: string;
- background: TextBackground;
- textAlign: "left" | "center" | "right";
- fontWeight: "normal" | "bold";
- fontStyle: "normal" | "italic";
- textDecoration: "none" | "underline" | "line-through";
- letterSpacing?: number;
- lineHeight?: number;
hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
effects?: Effect[];
}
@@ -179,20 +157,13 @@ export interface StickerElement extends BaseTimelineElement {
intrinsicWidth?: number;
intrinsicHeight?: number;
hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
effects?: Effect[];
}
export interface GraphicElement extends BaseTimelineElement {
type: "graphic";
definitionId: string;
- params: ParamValues;
hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
effects?: Effect[];
masks?: Mask[];
}
@@ -200,13 +171,9 @@ export interface GraphicElement extends BaseTimelineElement {
export interface EffectElement extends BaseTimelineElement {
type: "effect";
effectType: string;
- params: ParamValues;
}
-export type ElementUpdatePatch =
- | { transform: Transform }
- | { opacity: number }
- | { volume: number };
+export type ElementUpdatePatch = { params?: Partial<ParamValues> };
export type TimelineElement =
| AudioElement
diff --git a/apps/web/src/timeline/update-pipeline.ts b/apps/web/src/timeline/update-pipeline.ts
index 7ab129e9..590a8f34 100644
--- a/apps/web/src/timeline/update-pipeline.ts
+++ b/apps/web/src/timeline/update-pipeline.ts
@@ -141,7 +141,14 @@ export function applyElementUpdate({
patch: Partial<TimelineElement>;
context: ElementUpdateContext;
}): TimelineElement {
- let nextElement = { ...element, ...patch } as TimelineElement;
+ let nextElement = {
+ ...element,
+ ...patch,
+ params: {
+ ...element.params,
+ ...(patch.params ?? {}),
+ },
+ } as TimelineElement;
const changedFields = new Set(
Object.keys(patch) as ElementUpdateField[],
);