From 2ee0a44735d2b930348e9774909638b2c1909738 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 29 Apr 2026 01:28:50 +0200 Subject: refactor: unify element params --- .../src/timeline/__tests__/update-pipeline.test.ts | 10 +- apps/web/src/timeline/animation-targets.ts | 217 +++++++++++---------- apps/web/src/timeline/audio-separation/index.ts | 9 +- apps/web/src/timeline/audio-state.ts | 21 +- apps/web/src/timeline/components/audio-tab.tsx | 151 -------------- .../src/timeline/components/audio-volume-line.tsx | 13 +- .../src/timeline/components/timeline-element.tsx | 8 +- .../timeline/controllers/drag-drop-controller.ts | 5 +- apps/web/src/timeline/defaults.ts | 40 ++-- apps/web/src/timeline/element-utils.ts | 106 +++++----- .../timeline/placement/__tests__/resolve.test.ts | 53 +++-- apps/web/src/timeline/types.ts | 37 +--- apps/web/src/timeline/update-pipeline.ts | 9 +- 13 files changed, 280 insertions(+), 399 deletions(-) delete mode 100644 apps/web/src/timeline/components/audio-tab.tsx (limited to 'apps/web/src/timeline') 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 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 @@ -5,12 +5,6 @@ import type { AnimationValue, NumericSpec, } from "@/animation/types"; -import { - coerceAnimationParamValue, - getAnimationParamDefaultInterpolation, - getAnimationParamNumericRange, - getAnimationParamValueKind, -} from "@/animation/animated-params"; import { parseEffectParamPath, } from "@/animation/effect-param-channel"; @@ -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> | 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 && ( -
-

Audio has been separated.

- -
- )} -
- - Audio - - - - - } - > - } - 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" - /> - - - -
- - ); -} 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(null); const activePointerIdRef = useRef(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 (
- {element.content} + + {typeof element.params.content === "string" ? element.params.content : ""} +
); } 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; }): 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 = { 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 | 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 { + 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(); 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 }; 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; 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[], ); -- cgit v1.3.1