From cb2dd98a86e290becd5bc47778a50b6380502e4d Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 11 Aug 2026 17:20:16 +1000 Subject: [PATCH 1/4] feat: edit opacity keyframes in the media toolbar --- src/core/animations/keyframe-builder.ts | 30 +- src/core/animations/opacity-keyframes.ts | 163 ++++++++++ src/core/edit-session.ts | 1 + src/core/ui/media-toolbar.ts | 361 ++++++++++++++++++++- src/core/ui/merge-field-label-manager.ts | 11 +- src/core/ui/primitives/MergeFieldLabel.ts | 11 +- src/core/ui/primitives/SliderControl.ts | 18 +- src/core/ui/selection-handles.ts | 25 +- src/core/ui/svg-toolbar.ts | 50 ++- src/core/ui/text-to-image-toolbar.ts | 49 ++- src/core/ui/text-to-speech-toolbar.ts | 35 ++- src/main.ts | 7 +- src/styles/ui/media-toolbar.css | 79 ++++- src/templates/opacity-keyframes.json | 79 +++++ tests/cross-bundle-merge-fields.test.ts | 8 + tests/edit-commands.test.ts | 60 ++++ tests/keyframe-builder.test.ts | 67 ++++ tests/media-toolbar.test.ts | 362 ++++++++++++++++++++++ tests/merge-field-label-manager.test.ts | 45 +++ tests/opacity-keyframes.test.ts | 110 +++++++ tests/svg-toolbar.test.ts | 29 ++ tests/text-to-image-toolbar.test.ts | 27 ++ tests/text-to-speech-toolbar.test.ts | 76 +++++ tests/toolbar-delete-button.test.ts | 6 +- 24 files changed, 1639 insertions(+), 70 deletions(-) create mode 100644 src/core/animations/opacity-keyframes.ts create mode 100644 src/templates/opacity-keyframes.json create mode 100644 tests/opacity-keyframes.test.ts create mode 100644 tests/text-to-speech-toolbar.test.ts diff --git a/src/core/animations/keyframe-builder.ts b/src/core/animations/keyframe-builder.ts index 7d59225c..b478cdfd 100644 --- a/src/core/animations/keyframe-builder.ts +++ b/src/core/animations/keyframe-builder.ts @@ -2,6 +2,8 @@ import { type Keyframe, type NumericKeyframe } from "@schemas"; import { CurveInterpolator } from "./curve-interpolator"; +const TIME_EPSILON = 1e-6; + export class KeyframeBuilder { private readonly property: NumericKeyframe[]; private readonly length: number; @@ -113,6 +115,7 @@ export class KeyframeBuilder { const normalizedKeyframes = this.createNormalizedKeyframes(value); + this.normaliseAdjacentBoundaries(normalizedKeyframes); this.validateKeyframes(normalizedKeyframes); return this.insertFillerKeyframes(normalizedKeyframes, length, initialValue); @@ -132,18 +135,25 @@ export class KeyframeBuilder { })); } + private normaliseAdjacentBoundaries(keyframes: NumericKeyframe[]): void { + for (let i = 0; i < keyframes.length - 1; i += 1) { + const current = keyframes[i]; + const next = keyframes[i + 1]; + const boundaryDelta = current.start + current.length - next.start; + const canonicalLength = next.start - current.start; + + if (Math.abs(boundaryDelta) <= TIME_EPSILON && canonicalLength > 0) { + current.length = canonicalLength; + } + } + } + private validateKeyframes(keyframes: NumericKeyframe[]): void { for (let i = 0; i < keyframes.length; i += 1) { const current = keyframes[i]; const next = keyframes[i + 1]; - if (!next) { - if (current.start + current.length > this.length) { - throw new Error("Last keyframe exceeds the maximum duration."); - } - - break; - } + if (!next) break; if (current.start + current.length > next.start) { throw new Error("Overlapping keyframes detected."); @@ -158,7 +168,7 @@ export class KeyframeBuilder { const current = keyframes[i]; const next = keyframes[i + 1]; - const shouldFillStart = i === 0 && current.start !== 0; + const shouldFillStart = i === 0 && current.start > 0; if (shouldFillStart) { const fillerKeyframe: NumericKeyframe = { start: 0, length: current.start, from: initialValue, to: current.from }; updatedKeyframes.push(fillerKeyframe); @@ -167,7 +177,7 @@ export class KeyframeBuilder { updatedKeyframes.push(current); if (!next) { - const shouldFillEnd = current.start + current.length < length; + const shouldFillEnd = length - (current.start + current.length) > 0; if (shouldFillEnd) { const currentStart = current.start + current.length; const fillerKeyframe: NumericKeyframe = { start: currentStart, length: length - currentStart, from: current.to, to: current.to }; @@ -178,7 +188,7 @@ export class KeyframeBuilder { break; } - const shouldFillMiddle = current.start + current.length !== next.start; + const shouldFillMiddle = next.start - (current.start + current.length) > 0; if (shouldFillMiddle) { const fillerStart = current.start + current.length; const fillerLength = next.start - fillerStart; diff --git a/src/core/animations/opacity-keyframes.ts b/src/core/animations/opacity-keyframes.ts new file mode 100644 index 00000000..0a4c54e0 --- /dev/null +++ b/src/core/animations/opacity-keyframes.ts @@ -0,0 +1,163 @@ +import type { Clip, Tween } from "@schemas"; + +import { KeyframeBuilder } from "./keyframe-builder"; + +const TIME_EPSILON = 1e-6; + +export type OpacityPoint = { + time: number; + value: number; +}; + +function isOpacityValue(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1; +} + +function segmentEnd(tween: Tween): number { + return (tween.start as number) + (tween.length as number); +} + +/** Decode only the linear Tween shape authored by Studio. Other shapes stay read-only. */ +export function decodeOpacityPoints(value: Tween[], clipLength: number): OpacityPoint[] | null { + if (value.length === 0 || !Number.isFinite(clipLength) || clipLength < 0) return null; + + for (let index = 0; index < value.length; index += 1) { + const tween = value[index]; + const interpolation = tween.interpolation ?? "linear"; + if ( + !isOpacityValue(tween.from) || + !isOpacityValue(tween.to) || + typeof tween.start !== "number" || + !Number.isFinite(tween.start) || + tween.start < 0 || + typeof tween.length !== "number" || + !Number.isFinite(tween.length) || + tween.length <= TIME_EPSILON || + (interpolation !== "linear" && interpolation !== "constant") || + tween.easing !== undefined + ) { + return null; + } + + if (index === 0 && Math.abs(tween.start) > TIME_EPSILON) return null; + + const next = value[index + 1]; + if (next) { + if (typeof next.start !== "number" || Math.abs(segmentEnd(tween) - next.start) > TIME_EPSILON || tween.to !== next.from) return null; + } + } + + const firstLinear = value.findIndex(tween => (tween.interpolation ?? "linear") === "linear"); + if (firstLinear === -1) return null; + const lastLinear = value.findLastIndex(tween => (tween.interpolation ?? "linear") === "linear"); + + for (let index = 0; index < value.length; index += 1) { + const tween = value[index]; + const interpolation = tween.interpolation ?? "linear"; + if (interpolation === "constant") { + if ((index !== 0 && index !== value.length - 1) || tween.from !== tween.to) return null; + } else if (index < firstLinear || index > lastLinear) { + return null; + } + } + + const linearTweens = value.slice(firstLinear, lastLinear + 1); + if (linearTweens.some(tween => (tween.interpolation ?? "linear") !== "linear")) return null; + const first = linearTweens[0]; + const points: OpacityPoint[] = [{ time: first.start as number, value: first.from as number }]; + for (const tween of linearTweens) { + points.push({ time: segmentEnd(tween), value: tween.to as number }); + } + return points; +} + +export function encodeOpacityPoints(points: readonly OpacityPoint[], clipLength: number): Tween[] | null { + if (points.length < 2 || !Number.isFinite(clipLength) || clipLength < 0) return null; + + for (let index = 0; index < points.length; index += 1) { + const point = points[index]; + if (!Number.isFinite(point.time) || point.time < 0 || !isOpacityValue(point.value)) return null; + if (index > 0 && point.time - points[index - 1].time <= TIME_EPSILON) return null; + } + + const tweens: Tween[] = []; + const first = points[0]; + if (first.time > TIME_EPSILON) { + tweens.push({ from: first.value, to: first.value, start: 0, length: first.time, interpolation: "constant" }); + } + + for (let index = 0; index < points.length - 1; index += 1) { + const from = points[index]; + const to = points[index + 1]; + tweens.push({ from: from.value, to: to.value, start: from.time, length: to.time - from.time, interpolation: "linear" }); + } + + const last = points[points.length - 1]; + const end = Math.max(clipLength, last.time); + if (end - last.time > TIME_EPSILON) { + tweens.push({ from: last.value, to: last.value, start: last.time, length: end - last.time, interpolation: "constant" }); + } + + return tweens; +} + +export function evaluateOpacity(value: Clip["opacity"], localTime: number, clipLength: number): number | null { + try { + const evaluated = new KeyframeBuilder(value ?? 1, clipLength, 1).getValue(Math.max(0, Math.min(localTime, clipLength))); + return Number.isFinite(evaluated) ? Math.max(0, Math.min(1, evaluated)) : null; + } catch { + return null; + } +} + +export function snapOpacityTime(localTime: number, clipLength: number, fps: number): number { + const clamped = Math.max(0, Math.min(localTime, clipLength)); + if (clamped <= TIME_EPSILON) return 0; + if (clipLength - clamped <= TIME_EPSILON) return clipLength; + if (!Number.isFinite(fps) || fps <= 0) return clamped; + return Math.max(0, Math.min(clipLength, Math.round(clamped * fps) / fps)); +} + +export function findOpacityPoint( + points: readonly OpacityPoint[], + localTime: number, + fps: number, + direction: -1 | 0 | 1 = 0 +): OpacityPoint | undefined { + const tolerance = Number.isFinite(fps) && fps > 0 ? 0.5 / fps + TIME_EPSILON : TIME_EPSILON; + if (direction !== 0) { + const current = findOpacityPoint(points, localTime, fps); + const referenceTime = current?.time ?? localTime; + if (direction < 0) return points.findLast(point => point.time < referenceTime - TIME_EPSILON); + return points.find(point => point.time > referenceTime + TIME_EPSILON); + } + + let closest: OpacityPoint | undefined; + let closestDistance = Number.POSITIVE_INFINITY; + for (const point of points) { + const distance = Math.abs(point.time - localTime); + if (distance <= tolerance && distance < closestDistance) { + closest = point; + closestDistance = distance; + } + } + return closest; +} + +export function upsertOpacityPoint( + points: readonly OpacityPoint[], + localTime: number, + value: number, + clipLength: number, + fps: number +): OpacityPoint[] { + const time = snapOpacityTime(localTime, clipLength, fps); + const existing = findOpacityPoint(points, time, fps); + const next = existing ? points.map(point => (point === existing ? { time: point.time, value } : point)) : [...points, { time, value }]; + return next.toSorted((a, b) => a.time - b.time); +} + +export function removeOpacityPoint(points: readonly OpacityPoint[], localTime: number, fps: number): OpacityPoint[] { + const existing = findOpacityPoint(points, localTime, fps); + return existing ? points.filter(point => point !== existing) : [...points]; +} diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 6e7bb4e4..cccb4160 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -2081,6 +2081,7 @@ export class Edit { const resolvedClip = this.getResolvedClip(trackIndex, clipIndex); if (!resolvedClip) return; + if (Array.isArray(resolvedClip.offset?.x) || Array.isArray(resolvedClip.offset?.y)) return; const initialConfig = structuredClone(resolvedClip); diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index 5180eaa5..aed99e67 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -1,3 +1,13 @@ +import { + decodeOpacityPoints, + encodeOpacityPoints, + evaluateOpacity, + findOpacityPoint, + removeOpacityPoint, + snapOpacityTime, + type OpacityPoint, + upsertOpacityPoint +} from "@core/animations/opacity-keyframes"; import type { Edit } from "@core/edit-session"; import { EditEvent } from "@core/events/edit-events"; import { validateAssetUrl } from "@core/shared/utils"; @@ -42,7 +52,10 @@ const ICONS = { fadeIn: ``, fadeOut: ``, fadeInOut: ``, - fadeNone: `` + fadeNone: ``, + keyframePrevious: ``, + keyframe: ``, + keyframeNext: `` }; type MediaAssetType = "video" | "image" | "audio" | "text-to-image" | "image-to-video" | "text-to-speech"; @@ -63,6 +76,7 @@ const SPEED_PRESETS = [0.25, 0.5, 1, 1.5, 2, 4]; const SPEED_MIN = 0.1; const SPEED_MAX = 10; +const KEYFRAME_TIME_EPSILON = 1e-6; /** Slider midpoint: log-scaled so 1× sits centred and 0.5–2× gets half the travel */ const SPEED_SLIDER_HALF = 300; @@ -109,6 +123,9 @@ export class MediaToolbar extends BaseToolbar { // ─── Button Elements ───────────────────────────────────────────────────────── private fitBtn: HTMLButtonElement | null = null; private opacityBtn: HTMLButtonElement | null = null; + private opacityPreviousKeyframeBtn: HTMLButtonElement | null = null; + private opacityKeyframeBtn: HTMLButtonElement | null = null; + private opacityNextKeyframeBtn: HTMLButtonElement | null = null; private scaleBtn: HTMLButtonElement | null = null; private volumeBtn: HTMLButtonElement | null = null; private transitionBtn: HTMLButtonElement | null = null; @@ -149,6 +166,10 @@ export class MediaToolbar extends BaseToolbar { // ─── State ─────────────────────────────────────────────────────────────────── private dragManager = new DragStateManager(); + private pendingOpacityTimes = new Map(); + private opacityDragTime: number | null = null; + private playbackPauseListener: (() => void) | null = null; + private editChangedListener: ((event: { source: string }) => void) | null = null; private audioFadeEffect: "" | "fadeIn" | "fadeOut" | "fadeInFadeOut" = ""; private isDynamicSource: boolean = false; private dynamicFieldName: string = ""; @@ -411,7 +432,7 @@ export class MediaToolbar extends BaseToolbar { // Re-sync merge field labels when fields are added/removed globally this.unsubMergeFieldChanged = this.edit.getInternalEvents().on(EditEvent.MergeFieldChanged, () => { if (this.container?.style.display !== "none" && this.mergeFieldManager?.hasLabels) { - this.mergeFieldManager.sync(); + this.syncState(); } }); } @@ -442,6 +463,29 @@ export class MediaToolbar extends BaseToolbar { this.opacitySlider.onChange(value => this.handleOpacityChange(value)); this.opacitySlider.onDragEnd(() => this.endSliderDrag("opacity")); this.opacitySlider.mount(opacityMount as HTMLElement); + + const section = opacityMount.querySelector(".ss-toolbar-popup-section"); + const label = section?.querySelector(".ss-toolbar-popup-label"); + if (section && label) { + const header = document.createElement("div"); + header.className = "ss-media-toolbar-keyframe-header"; + label.before(header); + header.appendChild(label); + header.insertAdjacentHTML( + "beforeend", + `
+ + + +
` + ); + this.opacityPreviousKeyframeBtn = header.querySelector("[data-opacity-keyframe-previous]"); + this.opacityKeyframeBtn = header.querySelector("[data-opacity-keyframe]"); + this.opacityNextKeyframeBtn = header.querySelector("[data-opacity-keyframe-next]"); + } + + opacityMount.querySelector('input[type="range"]')?.setAttribute("aria-label", "Opacity"); + opacityMount.querySelector('input[type="text"]')?.setAttribute("aria-label", "Opacity percentage"); } // Mount scale slider (two-phase: live preview during drag, single undo on release) @@ -557,6 +601,10 @@ export class MediaToolbar extends BaseToolbar { { signal } ); + this.opacityPreviousKeyframeBtn?.addEventListener("click", () => this.navigateOpacityKeyframe(-1), { signal }); + this.opacityKeyframeBtn?.addEventListener("click", () => this.toggleOpacityKeyframe(), { signal }); + this.opacityNextKeyframeBtn?.addEventListener("click", () => this.navigateOpacityKeyframe(1), { signal }); + // Speed slider: readout-only during drag, single commit on release. this.speedSlider?.addEventListener( "input", @@ -672,6 +720,20 @@ export class MediaToolbar extends BaseToolbar { { signal } ); }); + + if (!this.playbackPauseListener) { + this.playbackPauseListener = () => { + if (this.selectedTrackIdx >= 0 && !this.dragManager.isDragging("opacity")) this.syncState(); + }; + this.edit.events.on(EditEvent.PlaybackPause, this.playbackPauseListener); + } + if (!this.editChangedListener) { + this.editChangedListener = event => { + if (event.source.startsWith("loadEdit:")) this.pendingOpacityTimes.clear(); + if (this.selectedTrackIdx >= 0 && !this.dragManager.isDragging("opacity")) this.syncState(); + }; + this.edit.events.on(EditEvent.EditChanged, this.editChangedListener); + } } private togglePopupByName(popup: "fit" | "opacity" | "scale" | "volume" | "transition" | "effect" | "advanced" | "audio-fade" | "speed"): void { @@ -731,10 +793,6 @@ export class MediaToolbar extends BaseToolbar { // Fit this.currentFit = (clip.fit as FitValue) || "crop"; - // Opacity (convert from 0-1 to 0-100) - const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; - this.opacitySlider?.setValue(Math.round(opacity * 100)); - // Scale (convert from 0-1 to percentage) const scale = typeof clip.scale === "number" ? clip.scale : 1; this.scaleSlider?.setValue(Math.round(scale * 100)); @@ -767,7 +825,6 @@ export class MediaToolbar extends BaseToolbar { // Update displays this.updateFitDisplay(); - this.updateOpacityDisplay(); this.updateScaleDisplay(); this.updateVolumeDisplay(); this.updateSpeedDisplay(); @@ -810,6 +867,130 @@ export class MediaToolbar extends BaseToolbar { if (this.showMergeFields && this.mergeFieldManager?.hasLabels) { this.mergeFieldManager.sync(); } + + if (clip) { + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + const shotstackEdit = this.getShotstackEdit(); + const scaleKeyframed = Array.isArray(clip.scale); + this.scaleSlider?.setEnabled(!scaleKeyframed && !shotstackEdit?.getMergeFieldForProperty(clipId ?? "", "scale")); + if (this.scaleBtn) { + this.scaleBtn.disabled = scaleKeyframed; + this.scaleBtn.title = scaleKeyframed ? "Keyframed scale cannot be edited with this control" : ""; + } + const { volume } = clip.asset as { volume?: unknown }; + const volumeKeyframed = Array.isArray(volume); + const volumeEnabled = !volumeKeyframed && !shotstackEdit?.getMergeFieldForProperty(clipId ?? "", "asset.volume"); + if (this.volumeSlider) this.volumeSlider.disabled = !volumeEnabled; + if (this.volumeDisplayInput) this.volumeDisplayInput.disabled = !volumeEnabled; + if (this.volumeBtn) { + this.volumeBtn.disabled = volumeKeyframed; + this.volumeBtn.title = volumeKeyframed ? "Keyframed volume cannot be edited with this control" : ""; + } + this.syncOpacityState(clip); + } + } + + private getOpacityTime(clip: ResolvedClip): number | null { + const localTime = this.edit.playbackTime - clip.start; + if (localTime < -KEYFRAME_TIME_EPSILON || localTime > clip.length + KEYFRAME_TIME_EPSILON) return null; + return snapOpacityTime(Math.max(0, Math.min(localTime, clip.length)), clip.length, this.edit.getOutputFps()); + } + + private getOpacityPoints(clip: ResolvedClip, clipId: string): OpacityPoint[] | null { + const documentOpacity = this.edit.getDocumentClip(this.selectedTrackIdx, this.selectedClipIdx)?.opacity; + const opacity = Array.isArray(documentOpacity) ? documentOpacity : clip.opacity; + if (Array.isArray(opacity)) return decodeOpacityPoints(opacity, clip.length); + const pendingTime = this.pendingOpacityTimes.get(clipId); + if (pendingTime === undefined) return []; + return [{ time: pendingTime, value: typeof clip.opacity === "number" ? clip.opacity : 1 }]; + } + + private clipHasVisualKeyframes(clip: ResolvedClip): boolean { + return [ + clip.opacity, + clip.scale, + clip.offset?.x, + clip.offset?.y, + clip.transform?.rotate?.angle, + clip.transform?.skew?.x, + clip.transform?.skew?.y + ].some(Array.isArray); + } + + private syncOpacityState(clip: ResolvedClip): void { + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const shotstackEdit = this.getShotstackEdit(); + const isBound = Boolean(shotstackEdit?.getMergeFieldForProperty(clipId, "opacity")); + if (Array.isArray(clip.opacity) || isBound) this.pendingOpacityTimes.delete(clipId); + const points = this.getOpacityPoints(clip, clipId); + const editable = points !== null; + const visiblePoints = (points ?? []).filter(point => point.time <= clip.length + KEYFRAME_TIME_EPSILON); + const localTime = this.getOpacityTime(clip); + const fps = this.edit.getOutputFps(); + const currentPoint = localTime === null ? undefined : findOpacityPoint(visiblePoints, localTime, fps); + const evaluatedTime = Math.max(0, Math.min(this.edit.playbackTime - clip.start, clip.length)); + const opacity = evaluateOpacity(clip.opacity, evaluatedTime, clip.length) ?? 1; + this.opacitySlider?.setValue(opacity * 100); + this.updateOpacityDisplay(); + + const hasEffect = Boolean(clip.effect); + const hasTransition = Boolean(clip.transition?.in || clip.transition?.out); + const hasPreset = hasEffect || hasTransition; + const animated = Array.isArray(clip.opacity) || (points?.length ?? 0) > 0; + let disabledReason = ""; + if (!editable) disabledReason = "This opacity animation can be previewed but not edited"; + else if (isBound) disabledReason = "Remove the merge field before keyframing opacity"; + else if (hasPreset) disabledReason = "Remove the clip effect or transition before keyframing opacity"; + else if (localTime === null) disabledReason = "Move the playhead over the clip to edit opacity keyframes"; + else if ( + currentPoint && + points?.length === 2 && + points.some(point => point !== currentPoint && point.time > clip.length + KEYFRAME_TIME_EPSILON) + ) { + disabledReason = "Extend the clip before removing this keyframe"; + } + + this.opacitySlider?.setEnabled(!isBound && editable && (!animated || (!hasPreset && localTime !== null))); + + if (this.opacityKeyframeBtn) { + let state = "static"; + if (animated) state = "animated"; + if (currentPoint) state = "keyframe"; + this.opacityKeyframeBtn.dataset["state"] = state; + this.opacityKeyframeBtn.disabled = Boolean(disabledReason); + let ariaPressed = "false"; + if (animated) ariaPressed = "mixed"; + if (currentPoint) ariaPressed = "true"; + this.opacityKeyframeBtn.setAttribute("aria-pressed", ariaPressed); + let ariaLabel = "Add opacity keyframe"; + if (animated) ariaLabel = "Add opacity keyframe at playhead; opacity is animated"; + if (currentPoint) ariaLabel = "Remove opacity keyframe"; + this.opacityKeyframeBtn.ariaLabel = ariaLabel; + if (disabledReason) this.opacityKeyframeBtn.ariaLabel = disabledReason; + this.opacityKeyframeBtn.title = disabledReason || this.opacityKeyframeBtn.ariaLabel; + } + + const navigationTime = this.edit.playbackTime - clip.start; + const previous = findOpacityPoint(visiblePoints, navigationTime, fps, -1); + const next = findOpacityPoint(visiblePoints, navigationTime, fps, 1); + if (this.opacityPreviousKeyframeBtn) this.opacityPreviousKeyframeBtn.disabled = !previous; + if (this.opacityNextKeyframeBtn) this.opacityNextKeyframeBtn.disabled = !next; + + const hasVisualKeyframes = this.clipHasVisualKeyframes(clip) || this.pendingOpacityTimes.has(clipId); + if (this.effectBtn) { + this.effectBtn.disabled = hasVisualKeyframes && !hasEffect; + this.effectBtn.title = this.effectBtn.disabled ? "Effects are unavailable for clips with keyframed visual properties" : ""; + if (this.effectBtn.disabled) this.effectBtn.ariaLabel = this.effectBtn.title; + else this.effectBtn.removeAttribute("aria-label"); + } + if (this.transitionBtn) { + this.transitionBtn.disabled = hasVisualKeyframes && !hasTransition; + this.transitionBtn.title = this.transitionBtn.disabled ? "Transitions are unavailable for clips with keyframed visual properties" : ""; + if (this.transitionBtn.disabled) this.transitionBtn.ariaLabel = this.transitionBtn.title; + else this.transitionBtn.removeAttribute("aria-label"); + } } // ─── Two-Phase Drag Helpers ────────────────────────────────────────────────── @@ -830,13 +1011,21 @@ export class MediaToolbar extends BaseToolbar { private captureClipState(): { clipId: string; initialState: ResolvedClip } | null { const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); - return clip && clipId ? { clipId, initialState: structuredClone(clip) } : null; + if (!clip || !clipId) return null; + const documentClip = this.edit.getDocumentClip(this.selectedTrackIdx, this.selectedClipIdx); + const initialState = documentClip ? ({ ...structuredClone(documentClip), id: clip.id } as ResolvedClip) : structuredClone(clip); + return { clipId, initialState }; } /** * Start a drag session for a slider control. */ private startSliderDrag(controlId: string): void { + if (controlId === "opacity") { + if (this.edit.isPlaying) this.edit.pause(); + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + this.opacityDragTime = clip ? this.getOpacityTime(clip) : null; + } const state = this.captureClipState(); if (state) { this.dragManager.start(controlId, state.clipId, state.initialState); @@ -848,12 +1037,18 @@ export class MediaToolbar extends BaseToolbar { */ private endSliderDrag(controlId: string): void { const session = this.dragManager.end(controlId); - if (!session) return; + if (!session) { + if (controlId === "opacity") this.opacityDragTime = null; + return; + } const finalClip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); if (finalClip) { - this.edit.commitClipUpdate(session.clipId, session.initialState, structuredClone(finalClip)); + const documentClip = this.edit.getDocumentClip(this.selectedTrackIdx, this.selectedClipIdx); + const finalState = documentClip ? ({ ...structuredClone(documentClip), id: finalClip.id } as ResolvedClip) : structuredClone(finalClip); + this.edit.commitClipUpdate(session.clipId, session.initialState, finalState); } + if (controlId === "opacity") this.opacityDragTime = null; } // ─── Value Change Handlers ─────────────────────────────────────────────────── @@ -868,11 +1063,37 @@ export class MediaToolbar extends BaseToolbar { private handleOpacityChange(value: number): void { this.updateOpacityDisplay(); + if (this.edit.isPlaying) this.edit.pause(); + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); - if (!clipId) return; + if (!clip || !clipId) return; + + const points = this.getOpacityPoints(clip, clipId); + const isBound = Boolean(this.getShotstackEdit()?.getMergeFieldForProperty(clipId, "opacity")); + if (points === null || isBound) { + this.syncOpacityState(clip); + return; + } + + const nextValue = value / 100; + let opacity: ResolvedClip["opacity"] = nextValue; + if (points.length > 0) { + const localTime = this.opacityDragTime ?? this.getOpacityTime(clip); + if (localTime === null || clip.effect || clip.transition?.in || clip.transition?.out) { + this.syncOpacityState(clip); + return; + } - const updates = { opacity: value / 100 }; + const updatedPoints = upsertOpacityPoint(points, localTime, nextValue, clip.length, this.edit.getOutputFps()); + const encoded = encodeOpacityPoints(updatedPoints, clip.length); + if (encoded) { + opacity = encoded; + this.pendingOpacityTimes.delete(clipId); + } + } + + const updates = { opacity }; if (this.dragManager.isDragging("opacity")) { this.edit.updateClipInDocument(clipId, updates); @@ -882,11 +1103,90 @@ export class MediaToolbar extends BaseToolbar { } } + private toggleOpacityKeyframe(): void { + if (this.edit.isPlaying) this.edit.pause(); + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || !clipId) return; + + const points = this.getOpacityPoints(clip, clipId); + const localTime = this.getOpacityTime(clip); + if ( + points === null || + localTime === null || + this.getShotstackEdit()?.getMergeFieldForProperty(clipId, "opacity") || + clip.effect || + clip.transition?.in || + clip.transition?.out + ) { + this.syncOpacityState(clip); + return; + } + + const fps = this.edit.getOutputFps(); + const currentPoint = findOpacityPoint(points, localTime, fps); + if (currentPoint) { + const remaining = removeOpacityPoint(points, localTime, fps); + if (remaining.length === 0) { + this.pendingOpacityTimes.delete(clipId); + this.syncOpacityState(clip); + return; + } + if (remaining.length === 1) { + if (remaining[0].time > clip.length + KEYFRAME_TIME_EPSILON) { + this.syncOpacityState(clip); + return; + } + this.pendingOpacityTimes.delete(clipId); + this.applyClipUpdate({ opacity: remaining[0].value }); + return; + } + + const encoded = encodeOpacityPoints(remaining, clip.length); + if (encoded) this.applyClipUpdate({ opacity: encoded }); + return; + } + + const value = evaluateOpacity(clip.opacity, localTime, clip.length); + if (value === null) return; + if (points.length === 0) { + this.pendingOpacityTimes.set(clipId, localTime); + this.syncOpacityState(clip); + return; + } + + const encoded = encodeOpacityPoints(upsertOpacityPoint(points, localTime, value, clip.length, fps), clip.length); + if (encoded) { + this.pendingOpacityTimes.delete(clipId); + this.applyClipUpdate({ opacity: encoded }); + } + } + + private navigateOpacityKeyframe(direction: -1 | 1): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || !clipId) return; + const points = this.getOpacityPoints(clip, clipId); + if (!points) return; + const visiblePoints = points.filter(point => point.time <= clip.length + KEYFRAME_TIME_EPSILON); + const point = findOpacityPoint(visiblePoints, this.edit.playbackTime - clip.start, this.edit.getOutputFps(), direction); + if (point) this.edit.seek(clip.start + point.time); + } + private handleScaleChange(value: number): void { this.updateScaleDisplay(); + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (Array.isArray(clip?.scale)) { + this.syncState(); + return; + } const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); if (!clipId) return; + if (this.getShotstackEdit()?.getMergeFieldForProperty(clipId, "scale")) { + this.syncState(); + return; + } const updates = { scale: value / 100 }; @@ -909,8 +1209,16 @@ export class MediaToolbar extends BaseToolbar { const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); if (!clipId) return; + if (this.getShotstackEdit()?.getMergeFieldForProperty(clipId, "asset.volume")) { + this.syncState(); + return; + } const asset = clip.asset as Record; + if (Array.isArray(asset["volume"])) { + this.syncState(); + return; + } const updates = { asset: { ...asset, volume: value / 100 } as typeof clip.asset }; if (this.dragManager.isDragging("volume")) { @@ -1025,6 +1333,16 @@ export class MediaToolbar extends BaseToolbar { private applyTransitionUpdate(): void { const transition = this.transitionPanel?.getClipValue(); + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (clip && clipId && (this.clipHasVisualKeyframes(clip) || this.pendingOpacityTimes.has(clipId))) { + const changesIn = Boolean(transition?.in && transition.in !== clip.transition?.in); + const changesOut = Boolean(transition?.out && transition.out !== clip.transition?.out); + if (changesIn || changesOut) { + this.transitionPanel?.setFromClip(clip.transition); + return; + } + } this.applyClipUpdate({ transition }); } @@ -1032,6 +1350,12 @@ export class MediaToolbar extends BaseToolbar { private applyEffect(): void { const effectValue = this.effectPanel?.getClipValue(); + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (effectValue && clip && clipId && (this.clipHasVisualKeyframes(clip) || this.pendingOpacityTimes.has(clipId))) { + this.effectPanel?.setFromClip(clip.effect); + return; + } this.applyClipUpdate({ effect: effectValue }); } @@ -1280,6 +1604,16 @@ export class MediaToolbar extends BaseToolbar { // Clear any in-progress drag sessions this.dragManager.clear(); + if (this.playbackPauseListener) { + this.edit.events.off(EditEvent.PlaybackPause, this.playbackPauseListener); + this.playbackPauseListener = null; + } + if (this.editChangedListener) { + this.edit.events.off(EditEvent.EditChanged, this.editChangedListener); + this.editChangedListener = null; + } + this.pendingOpacityTimes.clear(); + this.opacityDragTime = null; // Dispose composite components this.transitionPanel?.dispose(); @@ -1302,6 +1636,9 @@ export class MediaToolbar extends BaseToolbar { this.fitBtn = null; this.opacityBtn = null; + this.opacityPreviousKeyframeBtn = null; + this.opacityKeyframeBtn = null; + this.opacityNextKeyframeBtn = null; this.scaleBtn = null; this.volumeBtn = null; this.transitionBtn = null; diff --git a/src/core/ui/merge-field-label-manager.ts b/src/core/ui/merge-field-label-manager.ts index 0f573b22..b82207ad 100644 --- a/src/core/ui/merge-field-label-manager.ts +++ b/src/core/ui/merge-field-label-manager.ts @@ -5,6 +5,8 @@ import { ShotstackEdit } from "@core/shotstack-edit"; import { MergeFieldLabel } from "./primitives"; +const ARRAY_VALUE_DISABLED_REASON = "Remove keyframes before using a merge field"; + /** * Interface that any toolbar must satisfy to host merge field labels. * All properties are available on BaseToolbar (protected/public). @@ -87,6 +89,9 @@ export class MergeFieldLabelManager { for (const label of this.labels) { const propertyPath = label.getPropertyPath(); + const resolvedClip = this.host.edit.getResolvedClipById(clipId); + const currentValue = resolvedClip ? getNestedValue(resolvedClip, propertyPath) : null; + label.setEnabled(!Array.isArray(currentValue), ARRAY_VALUE_DISABLED_REASON); // Compute which fields are type-compatible with this property const compatibleNames = this.getCompatibleFieldNames(allFields, propertyPath, clipId); @@ -133,6 +138,9 @@ export class MergeFieldLabelManager { const clipId = this.getSelectedClipId(); if (!clipId) return; + const resolvedClip = this.host.edit.getResolvedClipById(clipId); + const currentValue = resolvedClip ? getNestedValue(resolvedClip, propertyPath) : null; + if (Array.isArray(currentValue)) return; const existingField = shotstackEdit.mergeFields.get(nameOrPrefix); @@ -149,9 +157,6 @@ export class MergeFieldLabelManager { value = existingField.defaultValue; } else { fieldName = shotstackEdit.mergeFields.generateUniqueName(nameOrPrefix); - - const resolvedClip = this.host.edit.getResolvedClipById(clipId); - const currentValue = resolvedClip ? getNestedValue(resolvedClip, propertyPath) : null; value = currentValue != null ? String(currentValue) : (this.propertyDefaults[propertyPath] ?? "0"); } diff --git a/src/core/ui/primitives/MergeFieldLabel.ts b/src/core/ui/primitives/MergeFieldLabel.ts index 86d4d845..e6d808ff 100644 --- a/src/core/ui/primitives/MergeFieldLabel.ts +++ b/src/core/ui/primitives/MergeFieldLabel.ts @@ -149,10 +149,19 @@ export class MergeFieldLabel extends UIComponent { return this.bound; } + /** Enable or disable merge-field binding for this property. */ + setEnabled(enabled: boolean, disabledReason = "Merge field unavailable"): void { + if (!this.iconBtn) return; + + this.iconBtn.disabled = !enabled; + this.iconBtn.title = enabled ? "Merge field" : disabledReason; + if (!enabled) this.hideDropdown(); + } + // ─── Private ─────────────────────────────────────────────────────────── private toggleDropdown(): void { - if (!this.dropdown) return; + if (!this.dropdown || this.iconBtn?.disabled) return; const isHidden = this.dropdown.style.display === "none"; if (isHidden) { this.showDropdown(); diff --git a/src/core/ui/primitives/SliderControl.ts b/src/core/ui/primitives/SliderControl.ts index d471dac9..8032ef92 100644 --- a/src/core/ui/primitives/SliderControl.ts +++ b/src/core/ui/primitives/SliderControl.ts @@ -14,6 +14,8 @@ export class SliderControl extends UIComponent { private formatValue: (value: number) => string; private dragStartCallbacks: ChangeCallback[] = []; private dragEndCallbacks: ChangeCallback[] = []; + private inputValueOnFocus: string | null = null; + private skipNextBlurCommit = false; constructor(private sliderConfig: SliderConfig) { super({ className: sliderConfig.className ?? "ss-toolbar-popup-section" }); @@ -63,14 +65,22 @@ export class SliderControl extends UIComponent { }); // Value input: commit on blur or Enter, revert on Escape - this.events.on(this.valueInput, "blur", () => this.commitInputValue()); + this.events.on(this.valueInput, "blur", () => { + const changed = this.inputValueOnFocus === null || this.valueInput?.value !== this.inputValueOnFocus; + this.inputValueOnFocus = null; + if (this.skipNextBlurCommit) { + this.skipNextBlurCommit = false; + return; + } + if (changed) this.commitInputValue(); + }); this.events.on(this.valueInput, "keydown", (e: KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); - this.commitInputValue(); this.valueInput?.blur(); } else if (e.key === "Escape") { e.preventDefault(); + this.skipNextBlurCommit = true; this.revertInputValue(); this.valueInput?.blur(); } @@ -78,6 +88,8 @@ export class SliderControl extends UIComponent { // Select all text on focus for easy replacement this.events.on(this.valueInput, "focus", () => { + this.inputValueOnFocus = this.valueInput?.value ?? null; + this.skipNextBlurCommit = false; this.valueInput?.select(); }); } @@ -176,5 +188,7 @@ export class SliderControl extends UIComponent { super.dispose(); this.dragStartCallbacks = []; this.dragEndCallbacks = []; + this.inputValueOnFocus = null; + this.skipNextBlurCommit = false; } } diff --git a/src/core/ui/selection-handles.ts b/src/core/ui/selection-handles.ts index a004098e..9e2ed934 100644 --- a/src/core/ui/selection-handles.ts +++ b/src/core/ui/selection-handles.ts @@ -580,7 +580,7 @@ export class SelectionHandles implements CanvasOverlayRegistration { // ─── Drag Operations ───────────────────────────────────────────────────────── private startDrag(event: pixi.FederatedPointerEvent): void { - if (!this.selectedPlayer) return; + if (!this.selectedPlayer || this.hasKeyframedOffset()) return; this.isDragging = true; const viewportContainer = this.edit.getViewportContainer(); @@ -594,7 +594,7 @@ export class SelectionHandles implements CanvasOverlayRegistration { } private handleDrag(event: pixi.FederatedPointerEvent): void { - if (!this.selectedPlayer || !this.selectedClipId) return; + if (!this.selectedPlayer || !this.selectedClipId || this.hasKeyframedOffset()) return; const viewportContainer = this.edit.getViewportContainer(); const timelinePoint = event.getLocalPosition(viewportContainer); @@ -663,7 +663,7 @@ export class SelectionHandles implements CanvasOverlayRegistration { } private startCornerResize(event: pixi.FederatedPointerEvent, corner: ScaleDirection): void { - if (!this.selectedPlayer) return; + if (!this.selectedPlayer || this.hasKeyframedOffset()) return; this.scaleDirection = corner; const timelinePoint = event.getLocalPosition(this.edit.getViewportContainer()); @@ -673,7 +673,7 @@ export class SelectionHandles implements CanvasOverlayRegistration { } private handleCornerResize(event: pixi.FederatedPointerEvent): void { - if (!this.selectedPlayer || !this.selectedClipId || !this.scaleDirection || !this.originalDimensions) return; + if (!this.selectedPlayer || !this.selectedClipId || !this.scaleDirection || !this.originalDimensions || this.hasKeyframedOffset()) return; const timelinePoint = event.getLocalPosition(this.edit.getViewportContainer()); const delta = { @@ -704,7 +704,7 @@ export class SelectionHandles implements CanvasOverlayRegistration { } private startEdgeResize(event: pixi.FederatedPointerEvent, edge: EdgeDirection): void { - if (!this.selectedPlayer) return; + if (!this.selectedPlayer || this.hasKeyframedOffset()) return; this.edgeDragDirection = edge; const timelinePoint = event.getLocalPosition(this.edit.getViewportContainer()); @@ -714,7 +714,7 @@ export class SelectionHandles implements CanvasOverlayRegistration { } private handleEdgeResize(event: pixi.FederatedPointerEvent): void { - if (!this.selectedPlayer || !this.selectedClipId || !this.edgeDragDirection || !this.originalDimensions) return; + if (!this.selectedPlayer || !this.selectedClipId || !this.edgeDragDirection || !this.originalDimensions || this.hasKeyframedOffset()) return; const timelinePoint = event.getLocalPosition(this.edit.getViewportContainer()); const delta = { @@ -745,7 +745,7 @@ export class SelectionHandles implements CanvasOverlayRegistration { } private startRotation(event: pixi.FederatedPointerEvent, _corner: CornerName): void { - if (!this.selectedPlayer) return; + if (!this.selectedPlayer || this.hasKeyframedRotation()) return; this.isRotating = true; @@ -755,7 +755,7 @@ export class SelectionHandles implements CanvasOverlayRegistration { } private handleRotation(event: pixi.FederatedPointerEvent): void { - if (!this.selectedPlayer || !this.selectedClipId || this.rotationStart === null) return; + if (!this.selectedPlayer || !this.selectedClipId || this.rotationStart === null || this.hasKeyframedRotation()) return; const center = this.getContentCenter(); const currentAngle = Math.atan2(event.globalY - center.y, event.globalX - center.x); @@ -832,6 +832,15 @@ export class SelectionHandles implements CanvasOverlayRegistration { // ─── Helpers ───────────────────────────────────────────────────────────────── + private hasKeyframedOffset(): boolean { + const offset = this.selectedPlayer?.clipConfiguration.offset; + return Array.isArray(offset?.x) || Array.isArray(offset?.y); + } + + private hasKeyframedRotation(): boolean { + return Array.isArray(this.selectedPlayer?.clipConfiguration.transform?.rotate?.angle); + } + private captureOriginalDimensions(): void { if (!this.selectedPlayer || !this.selectedClipId) return; diff --git a/src/core/ui/svg-toolbar.ts b/src/core/ui/svg-toolbar.ts index 89290b04..40d65cf7 100644 --- a/src/core/ui/svg-toolbar.ts +++ b/src/core/ui/svg-toolbar.ts @@ -17,6 +17,8 @@ const ICONS = { type PopupName = "opacity" | "scale" | "transition" | "effect"; +const KEYFRAMED_VALUE_DISABLED_REASON = "Keyframed values cannot be edited with this control"; + /** * Toolbar for editing SVG clip properties. * @@ -375,13 +377,23 @@ export class SvgToolbar extends BaseToolbar { } // Clip-level controls - const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; - this.opacitySlider?.setValue(Math.round(opacity * 100)); - this.updateOpacityDisplay(); + const opacityKeyframed = Array.isArray(clip.opacity); + this.opacitySlider?.setEnabled(!opacityKeyframed); + this.setNumericControlEnabled("opacity", !opacityKeyframed); + if (!opacityKeyframed) { + const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; + this.opacitySlider?.setValue(Math.round(opacity * 100)); + this.updateOpacityDisplay(); + } - const scale = typeof clip.scale === "number" ? clip.scale : 1; - this.scaleSlider?.setValue(Math.round(scale * 100)); - this.updateScaleDisplay(); + const scaleKeyframed = Array.isArray(clip.scale); + this.scaleSlider?.setEnabled(!scaleKeyframed); + this.setNumericControlEnabled("scale", !scaleKeyframed); + if (!scaleKeyframed) { + const scale = typeof clip.scale === "number" ? clip.scale : 1; + this.scaleSlider?.setValue(Math.round(scale * 100)); + this.updateScaleDisplay(); + } this.transitionPanel?.setFromClip(clip.transition); this.effectPanel?.setFromClip(clip.effect); @@ -408,9 +420,14 @@ export class SvgToolbar extends BaseToolbar { /** Start a drag session for any control (asset or clip-level). */ private startAssetDrag(controlId: string): void { const state = this.captureClipState(); - if (state) { - this.dragManager.start(controlId, state.clipId, state.initialState); + if ( + !state || + (controlId === "opacity" && Array.isArray(state.initialState.opacity)) || + (controlId === "scale" && Array.isArray(state.initialState.scale)) + ) { + return; } + this.dragManager.start(controlId, state.clipId, state.initialState); } /** End a drag session and commit a single undo entry. */ @@ -427,6 +444,11 @@ export class SvgToolbar extends BaseToolbar { // ─── Value Change Handlers ─────────────────────────────────────────────────── private handleOpacityChange(value: number): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || Array.isArray(clip.opacity)) { + if (clip) this.syncState(); + return; + } this.updateOpacityDisplay(); const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); @@ -443,6 +465,11 @@ export class SvgToolbar extends BaseToolbar { } private handleScaleChange(value: number): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || Array.isArray(clip.scale)) { + if (clip) this.syncState(); + return; + } this.updateScaleDisplay(); const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); @@ -482,6 +509,13 @@ export class SvgToolbar extends BaseToolbar { if (el) el.textContent = `${Math.round(value)}%`; } + private setNumericControlEnabled(name: "opacity" | "scale", enabled: boolean): void { + const button = this.buttons.get(name); + if (!button) return; + button.disabled = !enabled; + button.title = enabled ? "" : KEYFRAMED_VALUE_DISABLED_REASON; + } + // ─── Update Helpers ─────────────────────────────────────────────────────────── private applyClipUpdate(updates: Record): void { diff --git a/src/core/ui/text-to-image-toolbar.ts b/src/core/ui/text-to-image-toolbar.ts index 9b78a8b9..c881adc8 100644 --- a/src/core/ui/text-to-image-toolbar.ts +++ b/src/core/ui/text-to-image-toolbar.ts @@ -39,6 +39,7 @@ const ICONS = { }; const PROMPT_DEBOUNCE_MS = 150; +const KEYFRAMED_VALUE_DISABLED_REASON = "Keyframed values cannot be edited with this control"; export class TextToImageToolbar extends BaseToolbar { // ─── Current Values ────────────────────────────────────────────────────────── @@ -398,14 +399,24 @@ export class TextToImageToolbar extends BaseToolbar { this.updateFitActiveState(); // Opacity - const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; - this.opacitySlider?.setValue(Math.round(opacity * 100)); - this.updateOpacityDisplay(); + const opacityKeyframed = Array.isArray(clip.opacity); + this.opacitySlider?.setEnabled(!opacityKeyframed); + this.setNumericControlEnabled("opacity", !opacityKeyframed); + if (!opacityKeyframed) { + const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; + this.opacitySlider?.setValue(Math.round(opacity * 100)); + this.updateOpacityDisplay(); + } // Scale - const scale = typeof clip.scale === "number" ? clip.scale : 1; - this.scaleSlider?.setValue(Math.round(scale * 100)); - this.updateScaleDisplay(); + const scaleKeyframed = Array.isArray(clip.scale); + this.scaleSlider?.setEnabled(!scaleKeyframed); + this.setNumericControlEnabled("scale", !scaleKeyframed); + if (!scaleKeyframed) { + const scale = typeof clip.scale === "number" ? clip.scale : 1; + this.scaleSlider?.setValue(Math.round(scale * 100)); + this.updateScaleDisplay(); + } // Transition this.transitionPanel?.setFromClip(clip.transition); @@ -528,9 +539,14 @@ export class TextToImageToolbar extends BaseToolbar { */ private startSliderDrag(controlId: string): void { const state = this.captureClipState(); - if (state) { - this.dragManager.start(controlId, state.clipId, state.initialState); + if ( + !state || + (controlId === "opacity" && Array.isArray(state.initialState.opacity)) || + (controlId === "scale" && Array.isArray(state.initialState.scale)) + ) { + return; } + this.dragManager.start(controlId, state.clipId, state.initialState); } /** @@ -557,6 +573,11 @@ export class TextToImageToolbar extends BaseToolbar { } private handleOpacityChange(value: number): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || Array.isArray(clip.opacity)) { + if (clip) this.syncState(); + return; + } this.updateOpacityDisplay(); const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); @@ -573,6 +594,11 @@ export class TextToImageToolbar extends BaseToolbar { } private handleScaleChange(value: number): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || Array.isArray(clip.scale)) { + if (clip) this.syncState(); + return; + } this.updateScaleDisplay(); const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); @@ -630,6 +656,13 @@ export class TextToImageToolbar extends BaseToolbar { if (scaleValue) scaleValue.textContent = text; } + private setNumericControlEnabled(name: "opacity" | "scale", enabled: boolean): void { + const button = this.btn(name); + if (!button) return; + button.disabled = !enabled; + button.title = enabled ? "" : KEYFRAMED_VALUE_DISABLED_REASON; + } + // ─── Update Helpers ─────────────────────────────────────────────────────────── private updateAssetProperty(updates: Partial): void { diff --git a/src/core/ui/text-to-speech-toolbar.ts b/src/core/ui/text-to-speech-toolbar.ts index 64bd54b4..57043283 100644 --- a/src/core/ui/text-to-speech-toolbar.ts +++ b/src/core/ui/text-to-speech-toolbar.ts @@ -121,6 +121,7 @@ const ICONS = { }; const TEXT_DEBOUNCE_MS = 300; +const KEYFRAMED_VALUE_DISABLED_REASON = "Keyframed values cannot be edited with this control"; // ─── Toolbar ──────────────────────────────────────────────────────────────── @@ -431,9 +432,13 @@ export class TextToSpeechToolbar extends BaseToolbar { this.updateTextPreview(asset.text ?? ""); // Volume - const volume = typeof asset.volume === "number" ? asset.volume : 1; - this.currentVolume = Math.round(volume * 100); - this.updateVolumeDisplay(); + const volumeKeyframed = Array.isArray(asset.volume); + this.setVolumeEnabled(!volumeKeyframed); + if (!volumeKeyframed) { + const volume = typeof asset.volume === "number" ? asset.volume : 1; + this.currentVolume = Math.round(volume * 100); + this.updateVolumeDisplay(); + } // Audio fade this.audioFadeEffect = (asset.effect as "" | "fadeIn" | "fadeOut" | "fadeInFadeOut") || ""; @@ -479,12 +484,15 @@ export class TextToSpeechToolbar extends BaseToolbar { // ─── Volume Handlers ───────────────────────────────────────────────────────── private handleVolumeChange(value: number): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || clip.asset.type !== "text-to-speech" || Array.isArray(clip.asset.volume)) { + if (clip?.asset.type === "text-to-speech") this.syncState(); + return; + } + this.currentVolume = value; this.updateVolumeDisplay(); - const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); - if (!clip) return; - const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); if (!clipId) return; @@ -520,6 +528,15 @@ export class TextToSpeechToolbar extends BaseToolbar { } } + private setVolumeEnabled(enabled: boolean): void { + if (this.volumeBtn) { + this.volumeBtn.disabled = !enabled; + this.volumeBtn.title = enabled ? "" : KEYFRAMED_VALUE_DISABLED_REASON; + } + if (this.volumeSlider) this.volumeSlider.disabled = !enabled; + if (this.volumeDisplayInput) this.volumeDisplayInput.disabled = !enabled; + } + // ─── Audio Fade ────────────────────────────────────────────────────────────── private handleAudioFadeSelect(effect: "" | "fadeIn" | "fadeOut" | "fadeInFadeOut"): void { @@ -560,9 +577,9 @@ export class TextToSpeechToolbar extends BaseToolbar { private startSliderDrag(controlId: string): void { const state = this.captureClipState(); - if (state) { - this.dragManager.start(controlId, state.clipId, state.initialState); - } + if (!state) return; + if (controlId === "volume" && state.initialState.asset.type === "text-to-speech" && Array.isArray(state.initialState.asset.volume)) return; + this.dragManager.start(controlId, state.clipId, state.initialState); } private endSliderDrag(controlId: string): void { diff --git a/src/main.ts b/src/main.ts index 6a8d4e8f..b047c4a7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,11 @@ import { type Edit as EditSchema } from "@schemas"; import { Timeline } from "@timeline/index"; -import template from "./templates/prompt-assets.json"; +import template from "./templates/opacity-keyframes.json"; import { Edit, Canvas, Controls, UIController } from "./index"; -/** - * Simple example implementing the README quick start guide. - * Run with `npm run dev` to see it in action. - */ +/** Opacity keyframe development demo. Run with `npm run dev`. */ async function main() { try { // 1. Create core components diff --git a/src/styles/ui/media-toolbar.css b/src/styles/ui/media-toolbar.css index 0cdcdca9..15e4cc5d 100644 --- a/src/styles/ui/media-toolbar.css +++ b/src/styles/ui/media-toolbar.css @@ -72,7 +72,7 @@ white-space: nowrap; } -.ss-media-toolbar-btn:hover { +.ss-media-toolbar-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.95); } @@ -82,6 +82,12 @@ color: #fff; } +.ss-media-toolbar-btn:disabled { + background: rgba(255, 255, 255, 0.03); + color: rgba(255, 255, 255, 0.3); + cursor: not-allowed; +} + .ss-media-toolbar-btn svg { width: 16px; height: 16px; @@ -206,6 +212,77 @@ margin-bottom: 12px; } +.ss-media-toolbar-keyframe-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.ss-media-toolbar-keyframe-header .ss-toolbar-popup-label { + margin-bottom: 0; +} + +.ss-media-toolbar-keyframe-controls { + display: flex; + align-items: center; + gap: 2px; +} + +.ss-media-toolbar-keyframe-btn { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + flex: 0 0 24px; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: rgba(255, 255, 255, 0.55); + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} + +.ss-media-toolbar-keyframe-btn svg { + width: 14px; + height: 14px; +} + +.ss-media-toolbar-keyframe-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.95); +} + +.ss-media-toolbar-keyframe-btn:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 1px; +} + +.ss-media-toolbar-keyframe-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.ss-media-toolbar-keyframe-btn[data-state="static"] { + color: rgba(255, 255, 255, 0.55); +} + +.ss-media-toolbar-keyframe-btn[data-state="animated"], +.ss-media-toolbar-keyframe-btn[data-state="keyframe"] { + color: #3b82f6; +} + +.ss-media-toolbar-keyframe-btn[data-state="keyframe"] svg path { + fill: currentColor; +} + .ss-media-toolbar-slider-row { display: flex; align-items: center; diff --git a/src/templates/opacity-keyframes.json b/src/templates/opacity-keyframes.json new file mode 100644 index 00000000..1af61af0 --- /dev/null +++ b/src/templates/opacity-keyframes.json @@ -0,0 +1,79 @@ +{ + "timeline": { + "background": "#111827", + "tracks": [ + { + "clips": [ + { + "asset": { + "type": "rich-text", + "text": "1 — INSPECT EXISTING KEYS\nSelect woods1.jpg in the timeline, open Opacity, then use ‹ ◇ ›\nOutline = animated • filled = key at playhead • arrows navigate", + "font": { "family": "Work Sans", "size": 34, "weight": 600, "color": "#ffffff", "opacity": 1 }, + "style": { "lineHeight": 1.25 }, + "align": { "horizontal": "center", "vertical": "middle" } + }, + "start": 0, + "length": 5, + "width": 1100, + "height": 180, + "offset": { "x": 0, "y": -0.36 } + }, + { + "asset": { + "type": "rich-text", + "text": "2 — CREATE YOUR OWN\nSelect source.png, open Opacity, click ◇, move the playhead, then change opacity\nAdd a second keyframe to create the opacity animation", + "font": { "family": "Work Sans", "size": 34, "weight": 600, "color": "#ffffff", "opacity": 1 }, + "style": { "lineHeight": 1.25 }, + "align": { "horizontal": "center", "vertical": "middle" } + }, + "start": 5, + "length": 5, + "width": 1100, + "height": 180, + "offset": { "x": 0, "y": -0.36 } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "image", + "src": "https://shotstack-assets.s3.amazonaws.com/images/woods1.jpg" + }, + "start": 0, + "length": 5, + "width": 900, + "height": 430, + "fit": "cover", + "offset": { "x": 0, "y": 0.13 }, + "opacity": [ + { "from": 0.2, "to": 0.2, "start": 0, "length": 1, "interpolation": "constant" }, + { "from": 0.2, "to": 1, "start": 1, "length": 1.5, "interpolation": "linear" }, + { "from": 1, "to": 0.2, "start": 2.5, "length": 1.5, "interpolation": "linear" }, + { "from": 0.2, "to": 0.2, "start": 4, "length": 1, "interpolation": "constant" } + ] + }, + { + "asset": { + "type": "image", + "src": "https://templates.shotstack.io/bold-business-promotion-event-template/ef7eddca-7336-4bd8-a952-fb28a67a1de3/source.png" + }, + "start": 5, + "length": 5, + "width": 900, + "height": 430, + "fit": "cover", + "offset": { "x": 0, "y": 0.13 }, + "opacity": 1 + } + ] + } + ] + }, + "output": { + "format": "mp4", + "fps": 25, + "size": { "width": 1280, "height": 720 } + } +} diff --git a/tests/cross-bundle-merge-fields.test.ts b/tests/cross-bundle-merge-fields.test.ts index 4fc8c19d..cda154e7 100644 --- a/tests/cross-bundle-merge-fields.test.ts +++ b/tests/cross-bundle-merge-fields.test.ts @@ -85,12 +85,16 @@ function createCrossBundleEdit() { getClipId: jest.fn().mockReturnValue("clip-1"), getResolvedClip: jest.fn().mockReturnValue(createImageClip()), getResolvedClipById: jest.fn().mockReturnValue(createImageClip()), + getDocumentClip: jest.fn().mockReturnValue(createImageClip()), updateClip: jest.fn(), updateClipInDocument: jest.fn(), resolveClip: jest.fn(), commitClipUpdate: jest.fn(), deleteClip: jest.fn(), canDeleteClip: jest.fn(() => true), + playbackTime: 0, + getOutputFps: jest.fn(() => 30), + seek: jest.fn(), events: { on: jest.fn(), off: jest.fn() }, getInternalEvents: jest.fn(() => internalEvents), getMergeFieldForProperty: jest.fn(() => null), @@ -119,12 +123,16 @@ function createPlainEdit() { return { getClipId: jest.fn().mockReturnValue("clip-1"), getResolvedClip: jest.fn().mockReturnValue(createImageClip()), + getDocumentClip: jest.fn().mockReturnValue(createImageClip()), updateClip: jest.fn(), updateClipInDocument: jest.fn(), resolveClip: jest.fn(), commitClipUpdate: jest.fn(), deleteClip: jest.fn(), canDeleteClip: jest.fn(() => true), + playbackTime: 0, + getOutputFps: jest.fn(() => 30), + seek: jest.fn(), events: { on: jest.fn(), off: jest.fn() }, getInternalEvents: jest.fn(() => internalEvents), size: { width: 1920, height: 1080 } diff --git a/tests/edit-commands.test.ts b/tests/edit-commands.test.ts index e2977443..912f5192 100644 --- a/tests/edit-commands.test.ts +++ b/tests/edit-commands.test.ts @@ -1832,4 +1832,64 @@ describe("SelectionHandles fall-through dismiss", () => { fireFallThroughClick(unrelatedContainer); expect(backgroundEmits()).toHaveLength(0); }); + + it("does not start move or resize gestures that would flatten keyframed offsets", () => { + const player = edit.getPlayerClip(0, 0)!; + player.clipConfiguration.offset = { + x: [{ from: 0, to: 1, start: 0, length: 1, interpolation: "linear" }], + y: 0 + }; + const event = { getLocalPosition: jest.fn() }; + const guardedHandles = handles as unknown as { + startDrag: (value: unknown) => void; + startCornerResize: (value: unknown, corner: "topLeft") => void; + startEdgeResize: (value: unknown, edge: "left") => void; + isDragging: boolean; + scaleDirection: string | null; + edgeDragDirection: string | null; + }; + + guardedHandles.startDrag(event); + guardedHandles.startCornerResize(event, "topLeft"); + guardedHandles.startEdgeResize(event, "left"); + + expect(event.getLocalPosition).not.toHaveBeenCalled(); + expect(guardedHandles.isDragging).toBe(false); + expect(guardedHandles.scaleDirection).toBeNull(); + expect(guardedHandles.edgeDragDirection).toBeNull(); + }); + + it("does not start rotation when its angle is keyframed", () => { + const player = edit.getPlayerClip(0, 0)!; + player.clipConfiguration.transform = { + rotate: { angle: [{ from: 0, to: 90, start: 0, length: 1, interpolation: "linear" }] } + }; + const guardedHandles = handles as unknown as { + startRotation: (event: unknown, corner: "topLeft") => void; + isRotating: boolean; + }; + + guardedHandles.startRotation({ globalX: 0, globalY: 0 }, "topLeft"); + + expect(guardedHandles.isRotating).toBe(false); + }); +}); + +describe("Keyboard movement keyframe safety", () => { + it("does not replace keyframed offsets with a scalar nudge", () => { + const calculateMoveOffset = jest.fn(); + const setUpdatedClip = jest.fn(); + const fakeEdit = { + getSelectedClipInfo: () => ({ player: { calculateMoveOffset }, trackIndex: 0, clipIndex: 0 }), + getResolvedClip: () => ({ + offset: { x: [{ from: 0, to: 1, start: 0, length: 1, interpolation: "linear" }], y: 0 } + }), + setUpdatedClip + } as unknown as Edit; + + Edit.prototype.moveSelectedClip.call(fakeEdit, 1, 0); + + expect(calculateMoveOffset).not.toHaveBeenCalled(); + expect(setUpdatedClip).not.toHaveBeenCalled(); + }); }); diff --git a/tests/keyframe-builder.test.ts b/tests/keyframe-builder.test.ts index e2176a4c..54d81bc4 100644 --- a/tests/keyframe-builder.test.ts +++ b/tests/keyframe-builder.test.ts @@ -135,4 +135,71 @@ describe("KeyframeBuilder", () => { expect(builder.getValue(3)).toBeCloseTo(0.3); }); }); + + describe("timeline boundary safety", () => { + it("preserves and evaluates a keyframe that extends beyond a shortened clip", () => { + const builder = new KeyframeBuilder([{ start: 0, length: 10, from: 0, to: 1, interpolation: "linear" }], 5); + + expect(builder.getValue(5)).toBe(0.5); + }); + + it("tolerates floating-point noise between adjacent frame-derived segments", () => { + const boundary = 2 / 29.97; + const builder = new KeyframeBuilder( + [ + { start: 0, length: boundary + 1e-10, from: 0, to: 0.5, interpolation: "linear" }, + { start: boundary, length: 1, from: 0.5, to: 1, interpolation: "linear" } + ], + 2 + ); + + expect(builder.getValue(boundary)).toBeCloseTo(0.5); + }); + + it("normalises tolerated overlaps so seek order cannot change the value", () => { + const overlap = 5e-7; + const overlapTime = 1 + overlap / 2; + const builder = new KeyframeBuilder( + [ + { start: 0, length: 1 + overlap, from: 0, to: 0, interpolation: "linear" }, + { start: 1, length: 1, from: 1, to: 1, interpolation: "linear" } + ], + 2 + ); + + builder.getValue(0.5); + const valueAfterEarlierSeek = builder.getValue(overlapTime); + builder.getValue(1.5); + const valueAfterLaterSeek = builder.getValue(overlapTime); + + expect(valueAfterEarlierSeek).toBe(1); + expect(valueAfterLaterSeek).toBe(valueAfterEarlierSeek); + }); + + it("fills a tiny positive gap instead of falling back to the initial value", () => { + const builder = new KeyframeBuilder( + [ + { start: 0, length: 1, from: 0, to: 0.2, interpolation: "linear" }, + { start: 1 + 1e-10, length: 1, from: 0.4, to: 0.6, interpolation: "linear" } + ], + 3, + 1 + ); + + expect(builder.getValue(1 + 5e-11)).toBeLessThan(0.5); + }); + + it("still rejects material overlaps", () => { + expect( + () => + new KeyframeBuilder( + [ + { start: 0, length: 1.01, from: 0, to: 0.5, interpolation: "linear" }, + { start: 1, length: 1, from: 0.5, to: 1, interpolation: "linear" } + ], + 2 + ) + ).toThrow("Overlapping keyframes detected."); + }); + }); }); diff --git a/tests/media-toolbar.test.ts b/tests/media-toolbar.test.ts index d7c703ac..fb190609 100644 --- a/tests/media-toolbar.test.ts +++ b/tests/media-toolbar.test.ts @@ -63,6 +63,11 @@ function createMockEditSession() { commitClipUpdate: jest.fn(), deleteClip: jest.fn(), canDeleteClip: jest.fn(() => true), + playbackTime: 0, + isPlaying: false, + pause: jest.fn(), + getOutputFps: jest.fn(() => 30), + seek: jest.fn(), events: { on: jest.fn(), off: jest.fn() }, size: { width: 1920, height: 1080 } }; @@ -97,12 +102,18 @@ function createMergeFieldMockEditSession() { getClipId: jest.fn().mockReturnValue("clip-1"), getResolvedClip: jest.fn(), getResolvedClipById: jest.fn(), + getDocumentClip: jest.fn(), updateClip: jest.fn(), updateClipInDocument: jest.fn(), resolveClip: jest.fn(), commitClipUpdate: jest.fn(), deleteClip: jest.fn(), canDeleteClip: jest.fn(() => true), + playbackTime: 0, + isPlaying: false, + pause: jest.fn(), + getOutputFps: jest.fn(() => 30), + seek: jest.fn(), events: { on: jest.fn(), off: jest.fn() }, getInternalEvents: jest.fn(() => internalEvents), getMergeFieldForProperty: jest.fn((): string | null => null), @@ -210,6 +221,319 @@ describe("MediaToolbar", () => { }); }); + describe("opacity keyframes", () => { + const opacityTweens = [ + { from: 0.2, to: 0.2, start: 0, length: 1, interpolation: "constant" as const }, + { from: 0.2, to: 0.8, start: 1, length: 2, interpolation: "linear" as const }, + { from: 0.8, to: 0.8, start: 3, length: 2, interpolation: "constant" as const } + ]; + + it("exposes accessible three-state controls and keeps the first key session-only", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 1; + mockEdit.getResolvedClip.mockReturnValue(createImageClip()); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + + const keyframe = parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement; + expect(keyframe.dataset["state"]).toBe("static"); + expect(keyframe.getAttribute("aria-label")).toBe("Add opacity keyframe"); + keyframe.click(); + + expect(mockEdit.updateClip).not.toHaveBeenCalled(); + expect(keyframe.dataset["state"]).toBe("keyframe"); + expect(keyframe.getAttribute("aria-pressed")).toBe("true"); + toolbar.dispose(); + }); + + it("serialises the existing Tween array shape when a second key is added", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 1; + mockEdit.getResolvedClip.mockReturnValue(createImageClip()); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + const keyframe = parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement; + keyframe.click(); + mockEdit.playbackTime = 3; + keyframe.click(); + + expect(mockEdit.updateClip).toHaveBeenCalledWith(0, 0, { + opacity: [ + { from: 1, to: 1, start: 0, length: 1, interpolation: "constant" }, + { from: 1, to: 1, start: 1, length: 2, interpolation: "linear" }, + { from: 1, to: 1, start: 3, length: 2, interpolation: "constant" } + ] + }); + toolbar.dispose(); + }); + + it("clears a pending first key when the edit is reloaded", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 1; + mockEdit.getResolvedClip.mockReturnValue(createImageClip()); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + const keyframe = parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement; + keyframe.click(); + expect(keyframe.dataset["state"]).toBe("keyframe"); + + const editChangedListener = mockEdit.events.on.mock.calls.find(([event]) => event === "edit:changed")?.[1]; + editChangedListener?.({ source: "loadEdit:granular", timestamp: Date.now() }); + + expect(keyframe.dataset["state"]).toBe("static"); + toolbar.dispose(); + }); + + it("auto-keys an animated slider drag and commits one undo entry", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 2; + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: opacityTweens })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + const range = parent.querySelector("[data-opacity-slider-mount] input[type='range']") as HTMLInputElement; + expect(range.value).toBe("50"); + + range.dispatchEvent(new Event("pointerdown", { bubbles: true })); + range.value = "40"; + range.dispatchEvent(new Event("input", { bubbles: true })); + range.dispatchEvent(new Event("change", { bubbles: true })); + + const update = mockEdit.updateClipInDocument.mock.calls[0][1]; + expect(update.opacity).toEqual(expect.arrayContaining([{ from: 0.2, to: 0.4, start: 1, length: 1, interpolation: "linear" }])); + expect(mockEdit.commitClipUpdate).toHaveBeenCalledTimes(1); + toolbar.dispose(); + }); + + it("keeps document timing intent in opacity drag history", () => { + const mockEdit = createMockEditSession(); + const clip = createImageClip({ start: sec(2), length: sec(5) }); + const documentClip = { ...clip, start: "auto", length: "end" }; + mockEdit.playbackTime = 3; + mockEdit.getResolvedClip.mockReturnValue(clip); + mockEdit.getDocumentClip.mockImplementation(() => documentClip); + mockEdit.updateClipInDocument.mockImplementation((_clipId, updates) => Object.assign(documentClip, updates)); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + const range = parent.querySelector("[data-opacity-slider-mount] input[type='range']") as HTMLInputElement; + range.dispatchEvent(new Event("pointerdown", { bubbles: true })); + range.value = "50"; + range.dispatchEvent(new Event("input", { bubbles: true })); + range.dispatchEvent(new Event("change", { bubbles: true })); + + const [, initialState, finalState] = mockEdit.commitClipUpdate.mock.calls[0]; + expect(initialState).toEqual(expect.objectContaining({ start: "auto", length: "end", opacity: 1 })); + expect(finalState).toEqual(expect.objectContaining({ start: "auto", length: "end", opacity: 0.5 })); + toolbar.dispose(); + }); + + it("keeps unsupported Tween arrays read-only while showing their evaluated value", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 2.5; + const opacity = [{ from: 0, to: 1, start: 0, length: 5, interpolation: "bezier" as const, easing: "ease" as const }]; + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + const range = parent.querySelector("[data-opacity-slider-mount] input[type='range']") as HTMLInputElement; + const keyframe = parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement; + expect(range.disabled).toBe(true); + expect(keyframe.disabled).toBe(true); + expect(Number(range.value)).toBeGreaterThan(50); + + range.value = "25"; + range.dispatchEvent(new Event("input", { bubbles: true })); + expect(mockEdit.updateClip).not.toHaveBeenCalled(); + expect(mockEdit.updateClipInDocument).not.toHaveBeenCalled(); + expect(opacity).toEqual([{ from: 0, to: 1, start: 0, length: 5, interpolation: "bezier", easing: "ease" }]); + toolbar.dispose(); + }); + + it("keeps Tween arrays with nested merge values read-only", () => { + const mockEdit = createMockEditSession(); + const opacity = [{ from: 0.2, to: 0.8, start: 0, length: 5, interpolation: "linear" as const }]; + const clip = createImageClip({ opacity }); + mockEdit.getResolvedClip.mockReturnValue(clip); + mockEdit.getDocumentClip.mockReturnValue({ + ...clip, + opacity: [{ from: "{{ ALPHA }}", to: 0.8, start: 0, length: 5, interpolation: "linear" }] + }); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + + expect((parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement).disabled).toBe(true); + expect((parent.querySelector("[data-opacity-slider-mount] input[type='range']") as HTMLInputElement).disabled).toBe(true); + toolbar.dispose(); + }); + + it("does not write when the opacity input is blurred unchanged or cancelled", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 2; + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: opacityTweens })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + const input = parent.querySelector("[data-opacity-slider-mount] input[type='text']") as HTMLInputElement; + input.focus(); + input.blur(); + input.focus(); + input.value = "25%"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + + expect(mockEdit.updateClip).not.toHaveBeenCalled(); + expect(mockEdit.updateClipInDocument).not.toHaveBeenCalled(); + toolbar.dispose(); + }); + + it("pauses playback before editing an opacity animation", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 2; + mockEdit.isPlaying = true; + mockEdit.pause.mockImplementation(() => { + mockEdit.isPlaying = false; + }); + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: opacityTweens })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + (parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement).click(); + + expect(mockEdit.pause).toHaveBeenCalledTimes(1); + toolbar.dispose(); + }); + + it("preserves a trimmed-out key when removing its only visible partner", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 1; + const opacity = [ + { from: 0.25, to: 0.25, start: 0, length: 1, interpolation: "constant" as const }, + { from: 0.25, to: 0.75, start: 1, length: 7, interpolation: "linear" as const } + ]; + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + const keyframe = parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement; + expect(keyframe.disabled).toBe(true); + expect(keyframe.title).toContain("Extend the clip"); + keyframe.click(); + + expect(mockEdit.updateClip).not.toHaveBeenCalled(); + expect(opacity).toHaveLength(2); + toolbar.dispose(); + }); + + it("collapses a two-key animation to a static value", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 1; + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: opacityTweens })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + (parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement).click(); + + expect(mockEdit.updateClip).toHaveBeenCalledWith(0, 0, { opacity: 0.8 }); + toolbar.dispose(); + }); + + it("navigates to adjacent opacity keys", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 2; + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: opacityTweens })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + (parent.querySelector("[data-opacity-keyframe-previous]") as HTMLButtonElement).click(); + (parent.querySelector("[data-opacity-keyframe-next]") as HTMLButtonElement).click(); + + expect(mockEdit.seek).toHaveBeenNthCalledWith(1, 1); + expect(mockEdit.seek).toHaveBeenNthCalledWith(2, 3); + toolbar.dispose(); + }); + + it("announces the animated-between-keys state", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 2; + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: opacityTweens })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + const keyframe = parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement; + expect(keyframe.getAttribute("aria-pressed")).toBe("mixed"); + expect(keyframe.getAttribute("aria-label")).toContain("opacity is animated"); + toolbar.dispose(); + }); + + it("blocks effects and transitions while opacity is animated", () => { + const mockEdit = createMockEditSession(); + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: opacityTweens })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + + expect((parent.querySelector('[data-action="effect"]') as HTMLButtonElement).disabled).toBe(true); + expect((parent.querySelector('[data-action="transition"]') as HTMLButtonElement).disabled).toBe(true); + toolbar.dispose(); + }); + + it("blocks keyframe activation while a preset is present", () => { + const mockEdit = createMockEditSession(); + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ effect: "zoomIn" })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + const keyframe = parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement; + expect(keyframe.disabled).toBe(true); + expect(keyframe.title).toContain("effect or transition"); + toolbar.dispose(); + }); + + it("blocks keyframe activation while opacity is merge-field bound", () => { + const { mockEdit } = createMergeFieldMockEditSession(); + const clip = createImageClip(); + mockEdit.getResolvedClip.mockReturnValue(clip); + mockEdit.getResolvedClipById.mockReturnValue(clip); + mockEdit.getMergeFieldForProperty.mockReturnValue("OPACITY"); + + const toolbar = new MediaToolbar(mockEdit as unknown as Edit, { mergeFields: true }); + const parent = document.createElement("div"); + document.body.appendChild(parent); + toolbar.mount(parent); + toolbar.show(0, 0); + + expect((parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement).disabled).toBe(true); + toolbar.dispose(); + }); + + it("cancels a pending first key when opacity becomes merge-field bound", () => { + const { mockEdit, internalEvents } = createMergeFieldMockEditSession(); + const clip = createImageClip(); + mockEdit.getResolvedClip.mockReturnValue(clip); + mockEdit.getResolvedClipById.mockReturnValue(clip); + + const toolbar = new MediaToolbar(mockEdit as unknown as Edit, { mergeFields: true }); + const parent = document.createElement("div"); + document.body.appendChild(parent); + toolbar.mount(parent); + toolbar.show(0, 0); + const keyframe = parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement; + keyframe.click(); + expect(keyframe.dataset["state"]).toBe("keyframe"); + + mockEdit.getMergeFieldForProperty.mockReturnValue("OPACITY"); + internalEvents.emit("mergefield:changed"); + + expect(keyframe.dataset["state"]).toBe("static"); + expect(keyframe.disabled).toBe(true); + toolbar.dispose(); + }); + }); + describe("two-phase opacity slider drag", () => { it("uses live preview (updateClipInDocument) during opacity drag", () => { const mockEdit = createMockEditSession(); @@ -277,6 +601,24 @@ describe("MediaToolbar", () => { }); describe("two-phase scale slider drag", () => { + it("keeps imported scale Tween arrays read-only", () => { + const mockEdit = createMockEditSession(); + const scale = [{ from: 1, to: 2, start: 0, length: 5, interpolation: "linear" as const }]; + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ scale })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + const range = parent.querySelector("[data-scale-slider-mount] input[type='range']") as HTMLInputElement; + expect(range.disabled).toBe(true); + range.value = "150"; + range.dispatchEvent(new Event("input", { bubbles: true })); + + expect(mockEdit.updateClip).not.toHaveBeenCalled(); + expect(mockEdit.updateClipInDocument).not.toHaveBeenCalled(); + expect(scale).toHaveLength(1); + toolbar.dispose(); + }); + it("uses live preview during scale drag", () => { const mockEdit = createMockEditSession(); const clip = createImageClip(); @@ -308,6 +650,26 @@ describe("MediaToolbar", () => { }); describe("two-phase volume slider drag", () => { + it("keeps imported volume Tween arrays read-only", () => { + const mockEdit = createMockEditSession(); + const clip = createVideoClip(); + const volume = [{ from: 0, to: 1, start: 0, length: 10, interpolation: "linear" as const }]; + (clip.asset as unknown as { volume: typeof volume }).volume = volume; + mockEdit.getResolvedClip.mockReturnValue(clip); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + const range = parent.querySelector("[data-volume-slider]") as HTMLInputElement; + expect(range.disabled).toBe(true); + range.value = "50"; + range.dispatchEvent(new Event("input", { bubbles: true })); + + expect(mockEdit.updateClip).not.toHaveBeenCalled(); + expect(mockEdit.updateClipInDocument).not.toHaveBeenCalled(); + expect(volume).toHaveLength(1); + toolbar.dispose(); + }); + it("uses live preview during volume drag", () => { const mockEdit = createMockEditSession(); const clip = createVideoClip(); diff --git a/tests/merge-field-label-manager.test.ts b/tests/merge-field-label-manager.test.ts index 7ab7e2df..0bb32e25 100644 --- a/tests/merge-field-label-manager.test.ts +++ b/tests/merge-field-label-manager.test.ts @@ -247,6 +247,26 @@ describe("MergeFieldLabelManager", () => { expect(container.querySelector(".ss-merge-label--bound")).toBeNull(); }); + it("disables merge-field binding for array-valued properties", () => { + const container = buildContainer({ path: "opacity", prefix: "OPACITY", text: "Opacity" }); + document.body.appendChild(container); + + const host = createMockHost(container); + const edit = getEdit(host); + edit.getResolvedClipById.mockReturnValue({ + opacity: [{ from: 0, to: 1, start: 0, length: 1 }], + asset: { type: "image", src: "test.jpg" } + }); + + const manager = new MergeFieldLabelManager(host); + manager.init(); + manager.sync(); + + const iconBtn = container.querySelector(".ss-merge-label__icon") as HTMLButtonElement; + expect(iconBtn.disabled).toBe(true); + expect(iconBtn.title).toBe("Remove keyframes before using a merge field"); + }); + // ─── wireBindCallback ───────────────────────────────────────────────── it("bind callback creates new field via applyMergeField", async () => { @@ -346,6 +366,31 @@ describe("MergeFieldLabelManager", () => { expect(edit.applyMergeField).not.toHaveBeenCalled(); }); + it("bind callback rejects an array-valued property if the UI state is stale", async () => { + const container = buildContainer({ path: "opacity", prefix: "OPACITY", text: "Opacity" }); + document.body.appendChild(container); + + const host = createMockHost(container); + const edit = getEdit(host); + edit.getResolvedClipById.mockReturnValue({ + opacity: [{ from: 0, to: 1, start: 0, length: 1 }], + asset: { type: "image", src: "test.jpg" } + }); + + const manager = new MergeFieldLabelManager(host); + manager.init(); + + const iconBtn = container.querySelector(".ss-merge-label__icon") as HTMLButtonElement; + iconBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const createBtn = container.querySelector(".ss-merge-label__create") as HTMLButtonElement; + createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await Promise.resolve(); + + expect(edit.applyMergeField).not.toHaveBeenCalled(); + }); + // ─── wireClearCallback ──────────────────────────────────────────────── it("clear callback calls removeMergeField with restore value", async () => { diff --git a/tests/opacity-keyframes.test.ts b/tests/opacity-keyframes.test.ts new file mode 100644 index 00000000..bc62cf7f --- /dev/null +++ b/tests/opacity-keyframes.test.ts @@ -0,0 +1,110 @@ +import { + decodeOpacityPoints, + encodeOpacityPoints, + evaluateOpacity, + findOpacityPoint, + removeOpacityPoint, + snapOpacityTime, + upsertOpacityPoint +} from "../src/core/animations/opacity-keyframes"; +import type { Tween } from "../src/core/schemas"; + +const UNSUPPORTED_TWEENS: Tween[][] = [ + [{ from: 0, to: 1, start: 0, length: 1, interpolation: "bezier", easing: "ease" }], + [{ from: 0, to: 1, start: 0, length: 1, interpolation: "linear", easing: "ease" }], + [ + { from: 0, to: 0.5, start: 0, length: 1, interpolation: "linear" }, + { from: 0.5, to: 1, start: 2, length: 1, interpolation: "linear" } + ], + [ + { from: 0, to: 0.5, start: 0, length: 2, interpolation: "linear" }, + { from: 0.5, to: 1, start: 1, length: 1, interpolation: "linear" } + ], + [ + { from: 0, to: 0.5, start: 0, length: 1, interpolation: "linear" }, + { from: 0.6, to: 1, start: 1, length: 1, interpolation: "linear" } + ], + [{ from: "{{ FROM }}", to: 1, start: 0, length: 1, interpolation: "linear" }] +]; + +describe("opacity keyframe editing", () => { + it("round-trips Studio points through boundary holds", () => { + const points = [ + { time: 1, value: 0.25 }, + { time: 2.5, value: 0.75 } + ]; + const tweens = encodeOpacityPoints(points, 4); + + expect(tweens).toEqual([ + { from: 0.25, to: 0.25, start: 0, length: 1, interpolation: "constant" }, + { from: 0.25, to: 0.75, start: 1, length: 1.5, interpolation: "linear" }, + { from: 0.75, to: 0.75, start: 2.5, length: 1.5, interpolation: "constant" } + ]); + expect(decodeOpacityPoints(tweens!, 4)).toEqual(points); + }); + + it("keeps equal-valued linear points while hiding constant padding", () => { + const points = [ + { time: 1, value: 0.5 }, + { time: 2, value: 0.5 } + ]; + + expect(decodeOpacityPoints(encodeOpacityPoints(points, 3)!, 3)).toEqual(points); + }); + + it.each(UNSUPPORTED_TWEENS.map(tweens => [tweens]))("leaves unsupported Tween arrays read-only", tweens => { + const original = structuredClone(tweens); + + const clipLength = Math.max(...tweens.map(tween => Number(tween.start) + Number(tween.length))); + expect(decodeOpacityPoints(tweens, clipLength)).toBeNull(); + expect(tweens).toEqual(original); + }); + + it("retains points beyond a shortened clip", () => { + const points = [ + { time: 1, value: 0.25 }, + { time: 8, value: 0.75 } + ]; + + expect(decodeOpacityPoints(encodeOpacityPoints(points, 5)!, 5)).toEqual(points); + }); + + it("keeps a Studio Tween chain editable after extending the clip", () => { + const points = [ + { time: 1, value: 0.25 }, + { time: 3, value: 0.75 } + ]; + const tweens = encodeOpacityPoints(points, 5)!; + + expect(decodeOpacityPoints(tweens, 8)).toEqual(points); + }); + + it("snaps, replaces, removes and navigates with frame tolerance", () => { + const fps = 30; + const first = upsertOpacityPoint([], 1.01, 0.25, 5, fps); + const replaced = upsertOpacityPoint(first, 1.015, 0.5, 5, fps); + const withNext = upsertOpacityPoint(replaced, 2.01, 0.75, 5, fps); + + expect(first).toEqual([{ time: 1, value: 0.25 }]); + expect(replaced).toEqual([{ time: 1, value: 0.5 }]); + expect(snapOpacityTime(5, 5, fps)).toBe(5); + expect(findOpacityPoint(withNext, 1, fps, 1)?.time).toBe(2); + expect(findOpacityPoint(withNext, 2, fps, -1)?.time).toBe(1); + expect(removeOpacityPoint(withNext, 2.01, fps)).toEqual([{ time: 1, value: 0.5 }]); + }); + + it("navigates adjacent imported points after the output frame rate changes", () => { + const points = [ + { time: 1, value: 0.25 }, + { time: 1 + 1 / 60, value: 0.75 } + ]; + + expect(findOpacityPoint(points, 1, 30, 1)).toBe(points[1]); + expect(findOpacityPoint(points, 1 + 1 / 60, 30, -1)).toBe(points[0]); + }); + + it("uses the existing evaluator for supported and advanced opacity", () => { + expect(evaluateOpacity([{ from: 0, to: 1, start: 0, length: 2, interpolation: "linear" }], 1, 2)).toBe(0.5); + expect(evaluateOpacity([{ from: 0, to: 1, start: 0, length: 2, interpolation: "bezier", easing: "ease" }], 1, 2)).toBeCloseTo(0.8024); + }); +}); diff --git a/tests/svg-toolbar.test.ts b/tests/svg-toolbar.test.ts index 0a0821de..fcd57fcf 100644 --- a/tests/svg-toolbar.test.ts +++ b/tests/svg-toolbar.test.ts @@ -474,6 +474,35 @@ describe("SvgToolbar - Critical Bug Fixes", () => { // ───────────────────────────────────────────────────────────────────────────── describe("SvgToolbar - Data Flow Integrity", () => { + it("keeps keyframed opacity and scale read-only", () => { + const mockEdit = createMockEditSession(); + const svgClip = createSvgClip(''); + const tweens = [{ from: 0, to: 1, start: 0, length: 1, interpolation: "linear" as const }]; + svgClip.opacity = structuredClone(tweens); + svgClip.scale = structuredClone(tweens); + mockEdit.getResolvedClip.mockReturnValue(svgClip); + + const { toolbar, parent } = createToolbar(mockEdit); + // @ts-expect-error - accessing protected method for testing + toolbar.syncState(); + + (["opacity", "scale"] as const).forEach(property => { + const button = parent.querySelector(`[data-action="${property}"]`)!; + const range = parent.querySelector(`[data-popup="${property}"] input[type="range"]`)!; + const value = parent.querySelector(`[data-popup="${property}"] input[type="text"]`)!; + expect(button.disabled).toBe(true); + expect(button.title).toBe("Keyframed values cannot be edited with this control"); + expect(range.disabled).toBe(true); + expect(value.disabled).toBe(true); + + range.value = "50"; + range.dispatchEvent(new Event("input", { bubbles: true })); + }); + + expect(mockEdit.updateClip).not.toHaveBeenCalled(); + expect(mockEdit.updateClipInDocument).not.toHaveBeenCalled(); + }); + it("reads from edit session as single source of truth", () => { const mockEdit = createMockEditSession(); const svgClip = createSvgClip(''); diff --git a/tests/text-to-image-toolbar.test.ts b/tests/text-to-image-toolbar.test.ts index c63ac679..71e879dd 100644 --- a/tests/text-to-image-toolbar.test.ts +++ b/tests/text-to-image-toolbar.test.ts @@ -396,6 +396,33 @@ describe("TextToImageToolbar", () => { }); describe("two-phase opacity slider drag", () => { + it("keeps keyframed opacity and scale read-only", () => { + const mockEdit = createMockEditSession(); + const tweens = [{ from: 0, to: 1, start: 0, length: 1, interpolation: "linear" as const }]; + const clip = createTtiClip({ opacity: structuredClone(tweens), scale: structuredClone(tweens) }); + mockEdit.getResolvedClip.mockReturnValue(clip); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + + (["opacity", "scale"] as const).forEach(property => { + const button = parent.querySelector(`[data-action="${property}"]`)!; + const range = parent.querySelector(`[data-popup="${property}"] input[type="range"]`)!; + const value = parent.querySelector(`[data-popup="${property}"] input[type="text"]`)!; + expect(button.disabled).toBe(true); + expect(button.title).toBe("Keyframed values cannot be edited with this control"); + expect(range.disabled).toBe(true); + expect(value.disabled).toBe(true); + + range.value = "50"; + range.dispatchEvent(new Event("input", { bubbles: true })); + }); + + expect(mockEdit.updateClip).not.toHaveBeenCalled(); + expect(mockEdit.updateClipInDocument).not.toHaveBeenCalled(); + toolbar.dispose(); + }); + it("uses live preview during opacity drag", () => { const mockEdit = createMockEditSession(); const clip = createTtiClip(); diff --git a/tests/text-to-speech-toolbar.test.ts b/tests/text-to-speech-toolbar.test.ts new file mode 100644 index 00000000..f37dae07 --- /dev/null +++ b/tests/text-to-speech-toolbar.test.ts @@ -0,0 +1,76 @@ +/** + * @jest-environment jsdom + */ +/* eslint-disable import/first -- toolbar imports must follow the Pixi and edit-session mocks in this jsdom test. */ + +import type { Edit } from "@core/edit-session"; +import type { ResolvedClip } from "@schemas"; + +if (typeof structuredClone === "undefined") { + global.structuredClone = (value: unknown) => JSON.parse(JSON.stringify(value)); +} +if (typeof CSS === "undefined") { + Object.defineProperty(global, "CSS", { value: { escape: (value: string) => value } }); +} + +jest.mock("pixi.js", () => ({})); +jest.mock("../src/components/canvas/players/player", () => ({ Player: class MockPlayer {}, PlayerType: {} })); +jest.mock("../src/core/edit-session", () => ({})); +jest.mock("@styles/inject", () => ({ injectShotstackStyles: jest.fn() })); + +import { TextToSpeechToolbar } from "@core/ui/text-to-speech-toolbar"; + +function createMockEdit(clip: ResolvedClip) { + return { + getClipId: jest.fn(() => "clip-tts-1"), + getResolvedClip: jest.fn(() => clip), + updateClip: jest.fn(), + updateClipInDocument: jest.fn(), + resolveClip: jest.fn(), + commitClipUpdate: jest.fn(), + deleteClip: jest.fn(), + canDeleteClip: jest.fn(() => true), + events: { on: jest.fn(), off: jest.fn() } + }; +} + +describe("TextToSpeechToolbar", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("keeps keyframed volume read-only", () => { + const clip = { + id: "clip-tts-1", + asset: { + type: "text-to-speech", + text: "Hello", + voice: "Matthew", + volume: [{ from: 0, to: 1, start: 0, length: 1, interpolation: "linear" }] + }, + start: 0, + length: 5 + } as unknown as ResolvedClip; + const edit = createMockEdit(clip); + const toolbar = new TextToSpeechToolbar(edit as unknown as Edit); + const parent = document.createElement("div"); + document.body.appendChild(parent); + toolbar.mount(parent); + toolbar.show(0, 0); + + const button = parent.querySelector('[data-action="volume"]')!; + const range = parent.querySelector("[data-volume-slider]")!; + const value = parent.querySelector("[data-volume-display]")!; + expect(button.disabled).toBe(true); + expect(button.title).toBe("Keyframed values cannot be edited with this control"); + expect(range.disabled).toBe(true); + expect(value.disabled).toBe(true); + + range.value = "50"; + range.dispatchEvent(new Event("input", { bubbles: true })); + + expect(edit.updateClip).not.toHaveBeenCalled(); + expect(edit.updateClipInDocument).not.toHaveBeenCalled(); + toolbar.dispose(); + }); +}); diff --git a/tests/toolbar-delete-button.test.ts b/tests/toolbar-delete-button.test.ts index 29284b66..5d800e1a 100644 --- a/tests/toolbar-delete-button.test.ts +++ b/tests/toolbar-delete-button.test.ts @@ -194,9 +194,9 @@ describe("Toolbar delete button", () => { // Simulating a re-mount path: call mount() again on the same toolbar. toolbar.mount(document.body); - // We expect exactly 3 subscriptions across the lifetime — one per event. - const totalCalls = mockEdit.events.on.mock.calls.length; - expect(totalCalls).toBe(3); + [EditEvent.ClipAdded, EditEvent.ClipDeleted, EditEvent.ClipRestored].forEach(event => { + expect(mockEdit.events.on.mock.calls.filter(([name]) => name === event)).toHaveLength(1); + }); }); }); From f35a8b03cc3646890dab9489c6edf5c5137821e8 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 11 Aug 2026 21:26:39 +1000 Subject: [PATCH 2/4] fix: correct toolbar undo state and keyframed control behaviour --- src/components/canvas/players/player.ts | 15 +-- src/core/animations/keyframe-builder.ts | 8 +- src/core/animations/opacity-keyframes.ts | 15 +-- src/core/shared/clip-utils.ts | 18 +++ src/core/ui/base-toolbar.ts | 15 +++ src/core/ui/media-toolbar.ts | 151 +++++++++++------------ src/core/ui/merge-field-label-manager.ts | 2 +- src/core/ui/rich-text-toolbar.ts | 14 +-- src/core/ui/svg-toolbar.ts | 34 ++--- src/core/ui/text-to-image-toolbar.ts | 36 ++---- src/core/ui/text-to-speech-toolbar.ts | 28 ++--- src/main.ts | 7 +- tests/media-toolbar.test.ts | 39 ++++++ tests/svg-toolbar.test.ts | 44 +++++++ tests/text-to-image-toolbar.test.ts | 1 + tests/toolbar-delete-button.test.ts | 5 +- 16 files changed, 243 insertions(+), 189 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index b6a0e048..37e100a8 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -6,6 +6,7 @@ import { WipeFilter } from "@animations/wipe-filter"; import { type Edit } from "@core/edit-session"; import { InternalEvent } from "@core/events/edit-events"; import { calculateContainerScale, calculateFitScale, calculateSpriteTransform, type FitMode } from "@core/layout/fit-system"; +import { hasKeyframedVisualProperty } from "@core/shared/clip-utils"; import { type AliasReference, type ResolvedTiming, @@ -155,7 +156,7 @@ export abstract class Player extends Entity { this.skewYKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewY, length, "additive"); // If user has custom keyframes, add them and skip effect/transition layers - if (this.clipHasKeyframes()) { + if (hasKeyframedVisualProperty(this.clipConfiguration)) { if (Array.isArray(config.scale)) { this.scaleKeyframeBuilder.addLayer(config.scale); } @@ -602,18 +603,6 @@ export abstract class Player extends Entity { this.edit.getInternalEvents().emit(InternalEvent.CanvasClipClicked, { player: this }); } - private clipHasKeyframes(): boolean { - return [ - this.clipConfiguration.scale, - this.clipConfiguration.opacity, - this.clipConfiguration.offset?.x, - this.clipConfiguration.offset?.y, - this.clipConfiguration.transform?.rotate?.angle, - this.clipConfiguration.transform?.skew?.x, - this.clipConfiguration.transform?.skew?.y - ].some(property => property && typeof property !== "number"); - } - protected applyFixedDimensions(): void { const clipWidth = this.clipConfiguration.width; const clipHeight = this.clipConfiguration.height; diff --git a/src/core/animations/keyframe-builder.ts b/src/core/animations/keyframe-builder.ts index b478cdfd..ece0e132 100644 --- a/src/core/animations/keyframe-builder.ts +++ b/src/core/animations/keyframe-builder.ts @@ -2,7 +2,13 @@ import { type Keyframe, type NumericKeyframe } from "@schemas"; import { CurveInterpolator } from "./curve-interpolator"; -const TIME_EPSILON = 1e-6; +/** + * Tolerance for comparing frame-derived times, which are stored as seconds and so + * are not exactly representable (least of all at 23.976 / 29.97 / 59.94). Far above + * real accumulated drift (~1e-13) and far below one frame at the highest supported + * rate (~1.7e-2 at 60fps). + */ +export const TIME_EPSILON = 1e-6; export class KeyframeBuilder { private readonly property: NumericKeyframe[]; diff --git a/src/core/animations/opacity-keyframes.ts b/src/core/animations/opacity-keyframes.ts index 0a4c54e0..53e1f834 100644 --- a/src/core/animations/opacity-keyframes.ts +++ b/src/core/animations/opacity-keyframes.ts @@ -1,8 +1,6 @@ import type { Clip, Tween } from "@schemas"; -import { KeyframeBuilder } from "./keyframe-builder"; - -const TIME_EPSILON = 1e-6; +import { KeyframeBuilder, TIME_EPSILON } from "./keyframe-builder"; export type OpacityPoint = { time: number; @@ -51,18 +49,15 @@ export function decodeOpacityPoints(value: Tween[], clipLength: number): Opacity if (firstLinear === -1) return null; const lastLinear = value.findLastIndex(tween => (tween.interpolation ?? "linear") === "linear"); + // Constant segments only pad the head and tail, so everything between the first + // and last linear segment is linear and the slice below needs no further checks. for (let index = 0; index < value.length; index += 1) { const tween = value[index]; - const interpolation = tween.interpolation ?? "linear"; - if (interpolation === "constant") { - if ((index !== 0 && index !== value.length - 1) || tween.from !== tween.to) return null; - } else if (index < firstLinear || index > lastLinear) { - return null; - } + const isPad = index === 0 || index === value.length - 1; + if ((tween.interpolation ?? "linear") === "constant" && (!isPad || tween.from !== tween.to)) return null; } const linearTweens = value.slice(firstLinear, lastLinear + 1); - if (linearTweens.some(tween => (tween.interpolation ?? "linear") !== "linear")) return null; const first = linearTweens[0]; const points: OpacityPoint[] = [{ time: first.start as number, value: first.from as number }]; for (const tween of linearTweens) { diff --git a/src/core/shared/clip-utils.ts b/src/core/shared/clip-utils.ts index c0232510..8b197114 100644 --- a/src/core/shared/clip-utils.ts +++ b/src/core/shared/clip-utils.ts @@ -10,3 +10,21 @@ export function stripInternalProperties(clip: Clip): Clip { const { id, ...publicClip } = clip as Clip & { id?: string }; return publicClip; } + +/** + * True when any visual property holds something other than a plain number — + * keyframes, or a merge field placeholder that never resolved. + * Such clips render without effect and transition layers, so the renderer and + * the toolbars must agree on the property list; keep this the only copy. + */ +export function hasKeyframedVisualProperty(clip: Clip): boolean { + return [ + clip.opacity, + clip.scale, + clip.offset?.x, + clip.offset?.y, + clip.transform?.rotate?.angle, + clip.transform?.skew?.x, + clip.transform?.skew?.y + ].some(property => property && typeof property !== "number"); +} diff --git a/src/core/ui/base-toolbar.ts b/src/core/ui/base-toolbar.ts index b60298d4..529b0a25 100644 --- a/src/core/ui/base-toolbar.ts +++ b/src/core/ui/base-toolbar.ts @@ -1,5 +1,6 @@ import type { Edit } from "@core/edit-session"; import { EditEvent } from "@core/events/edit-events"; +import type { ResolvedClip } from "@schemas"; import { makeToolbarDraggable, type ToolbarDragHandle } from "./toolbar-drag"; @@ -289,6 +290,20 @@ export abstract class BaseToolbar { btn?.classList.toggle("active", active); } + /** + * Snapshot the selected clip for a history entry. + * Undo replays the snapshot into the document, so it holds document values — + * resolved ones would overwrite "auto"/"end" timing and merge field placeholders. + */ + protected captureClipState(): { clipId: string; clip: ResolvedClip } | null { + const resolved = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!resolved || !clipId) return null; + const documentClip = this.edit.getDocumentClip(this.selectedTrackIdx, this.selectedClipIdx); + const clip = documentClip ? ({ ...structuredClone(documentClip), id: resolved.id } as ResolvedClip) : structuredClone(resolved); + return { clipId, clip }; + } + /** * Sync UI state with current clip configuration. * Subclasses must implement. diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index aed99e67..09cc1bb1 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -1,3 +1,4 @@ +import { TIME_EPSILON } from "@core/animations/keyframe-builder"; import { decodeOpacityPoints, encodeOpacityPoints, @@ -10,6 +11,7 @@ import { } from "@core/animations/opacity-keyframes"; import type { Edit } from "@core/edit-session"; import { EditEvent } from "@core/events/edit-events"; +import { hasKeyframedVisualProperty } from "@core/shared/clip-utils"; import { validateAssetUrl } from "@core/shared/utils"; import { ShotstackEdit } from "@core/shotstack-edit"; import type { ResolvedClip } from "@schemas"; @@ -76,7 +78,11 @@ const SPEED_PRESETS = [0.25, 0.5, 1, 1.5, 2, 4]; const SPEED_MIN = 0.1; const SPEED_MAX = 10; -const KEYFRAME_TIME_EPSILON = 1e-6; +const KEYFRAME_BUTTON_STATES = { + static: { pressed: "false", label: "Add opacity keyframe" }, + animated: { pressed: "mixed", label: "Add opacity keyframe at playhead; opacity is animated" }, + keyframe: { pressed: "true", label: "Remove opacity keyframe" } +} as const; /** Slider midpoint: log-scaled so 1× sits centred and 0.5–2× gets half the travel */ const SPEED_SLIDER_HALF = 300; @@ -721,21 +727,27 @@ export class MediaToolbar extends BaseToolbar { ); }); + // Edit.seek() pauses, so this also covers playhead scrubbing — keep the visibility + // check so a hidden toolbar does not resync on every pointermove. if (!this.playbackPauseListener) { - this.playbackPauseListener = () => { - if (this.selectedTrackIdx >= 0 && !this.dragManager.isDragging("opacity")) this.syncState(); - }; + this.playbackPauseListener = () => this.syncIfVisible(); this.edit.events.on(EditEvent.PlaybackPause, this.playbackPauseListener); } if (!this.editChangedListener) { this.editChangedListener = event => { - if (event.source.startsWith("loadEdit:")) this.pendingOpacityTimes.clear(); - if (this.selectedTrackIdx >= 0 && !this.dragManager.isDragging("opacity")) this.syncState(); + if (event.source.startsWith("load")) this.pendingOpacityTimes.clear(); + this.syncIfVisible(); }; this.edit.events.on(EditEvent.EditChanged, this.editChangedListener); } } + private syncIfVisible(): void { + if (this.container?.style.display === "none") return; + if (this.selectedTrackIdx < 0 || this.dragManager.isDragging("opacity")) return; + this.syncState(); + } + private togglePopupByName(popup: "fit" | "opacity" | "scale" | "volume" | "transition" | "effect" | "advanced" | "audio-fade" | "speed"): void { const popupMap = { fit: { popup: this.fitPopup, btn: this.fitBtn }, @@ -892,7 +904,7 @@ export class MediaToolbar extends BaseToolbar { private getOpacityTime(clip: ResolvedClip): number | null { const localTime = this.edit.playbackTime - clip.start; - if (localTime < -KEYFRAME_TIME_EPSILON || localTime > clip.length + KEYFRAME_TIME_EPSILON) return null; + if (localTime < -TIME_EPSILON || localTime > clip.length + TIME_EPSILON) return null; return snapOpacityTime(Math.max(0, Math.min(localTime, clip.length)), clip.length, this.edit.getOutputFps()); } @@ -905,20 +917,20 @@ export class MediaToolbar extends BaseToolbar { return [{ time: pendingTime, value: typeof clip.opacity === "number" ? clip.opacity : 1 }]; } - private clipHasVisualKeyframes(clip: ResolvedClip): boolean { - return [ - clip.opacity, - clip.scale, - clip.offset?.x, - clip.offset?.y, - clip.transform?.rotate?.angle, - clip.transform?.skew?.x, - clip.transform?.skew?.y - ].some(Array.isArray); + /** Keys past a trimmed clip end stay in the document but are not addressable from the toolbar. */ + private visibleOpacityPoints(points: readonly OpacityPoint[], clip: ResolvedClip): OpacityPoint[] { + return points.filter(point => point.time <= clip.length + TIME_EPSILON); } private syncOpacityState(clip: ResolvedClip): void { const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + const localTime = this.getOpacityTime(clip); + + // Read back at the same frame-snapped time keys are written to, so the + // value the user just set is the value redisplayed. + const evaluatedTime = localTime ?? Math.max(0, Math.min(this.edit.playbackTime - clip.start, clip.length)); + this.opacitySlider?.setValue((evaluateOpacity(clip.opacity, evaluatedTime, clip.length) ?? 1) * 100); + this.updateOpacityDisplay(); if (!clipId) return; const shotstackEdit = this.getShotstackEdit(); @@ -926,14 +938,9 @@ export class MediaToolbar extends BaseToolbar { if (Array.isArray(clip.opacity) || isBound) this.pendingOpacityTimes.delete(clipId); const points = this.getOpacityPoints(clip, clipId); const editable = points !== null; - const visiblePoints = (points ?? []).filter(point => point.time <= clip.length + KEYFRAME_TIME_EPSILON); - const localTime = this.getOpacityTime(clip); + const visiblePoints = this.visibleOpacityPoints(points ?? [], clip); const fps = this.edit.getOutputFps(); const currentPoint = localTime === null ? undefined : findOpacityPoint(visiblePoints, localTime, fps); - const evaluatedTime = Math.max(0, Math.min(this.edit.playbackTime - clip.start, clip.length)); - const opacity = evaluateOpacity(clip.opacity, evaluatedTime, clip.length) ?? 1; - this.opacitySlider?.setValue(opacity * 100); - this.updateOpacityDisplay(); const hasEffect = Boolean(clip.effect); const hasTransition = Boolean(clip.transition?.in || clip.transition?.out); @@ -944,53 +951,44 @@ export class MediaToolbar extends BaseToolbar { else if (isBound) disabledReason = "Remove the merge field before keyframing opacity"; else if (hasPreset) disabledReason = "Remove the clip effect or transition before keyframing opacity"; else if (localTime === null) disabledReason = "Move the playhead over the clip to edit opacity keyframes"; - else if ( - currentPoint && - points?.length === 2 && - points.some(point => point !== currentPoint && point.time > clip.length + KEYFRAME_TIME_EPSILON) - ) { + else if (currentPoint && points?.length === 2 && points.some(point => point !== currentPoint && point.time > clip.length + TIME_EPSILON)) { disabledReason = "Extend the clip before removing this keyframe"; } this.opacitySlider?.setEnabled(!isBound && editable && (!animated || (!hasPreset && localTime !== null))); if (this.opacityKeyframeBtn) { - let state = "static"; + let state: keyof typeof KEYFRAME_BUTTON_STATES = "static"; if (animated) state = "animated"; if (currentPoint) state = "keyframe"; + const { pressed, label } = KEYFRAME_BUTTON_STATES[state]; this.opacityKeyframeBtn.dataset["state"] = state; this.opacityKeyframeBtn.disabled = Boolean(disabledReason); - let ariaPressed = "false"; - if (animated) ariaPressed = "mixed"; - if (currentPoint) ariaPressed = "true"; - this.opacityKeyframeBtn.setAttribute("aria-pressed", ariaPressed); - let ariaLabel = "Add opacity keyframe"; - if (animated) ariaLabel = "Add opacity keyframe at playhead; opacity is animated"; - if (currentPoint) ariaLabel = "Remove opacity keyframe"; - this.opacityKeyframeBtn.ariaLabel = ariaLabel; - if (disabledReason) this.opacityKeyframeBtn.ariaLabel = disabledReason; - this.opacityKeyframeBtn.title = disabledReason || this.opacityKeyframeBtn.ariaLabel; + this.opacityKeyframeBtn.setAttribute("aria-pressed", pressed); + this.opacityKeyframeBtn.setAttribute("aria-label", disabledReason || label); + this.opacityKeyframeBtn.title = disabledReason || label; } const navigationTime = this.edit.playbackTime - clip.start; - const previous = findOpacityPoint(visiblePoints, navigationTime, fps, -1); - const next = findOpacityPoint(visiblePoints, navigationTime, fps, 1); - if (this.opacityPreviousKeyframeBtn) this.opacityPreviousKeyframeBtn.disabled = !previous; - if (this.opacityNextKeyframeBtn) this.opacityNextKeyframeBtn.disabled = !next; - - const hasVisualKeyframes = this.clipHasVisualKeyframes(clip) || this.pendingOpacityTimes.has(clipId); - if (this.effectBtn) { - this.effectBtn.disabled = hasVisualKeyframes && !hasEffect; - this.effectBtn.title = this.effectBtn.disabled ? "Effects are unavailable for clips with keyframed visual properties" : ""; - if (this.effectBtn.disabled) this.effectBtn.ariaLabel = this.effectBtn.title; - else this.effectBtn.removeAttribute("aria-label"); - } - if (this.transitionBtn) { - this.transitionBtn.disabled = hasVisualKeyframes && !hasTransition; - this.transitionBtn.title = this.transitionBtn.disabled ? "Transitions are unavailable for clips with keyframed visual properties" : ""; - if (this.transitionBtn.disabled) this.transitionBtn.ariaLabel = this.transitionBtn.title; - else this.transitionBtn.removeAttribute("aria-label"); - } + if (this.opacityPreviousKeyframeBtn) this.opacityPreviousKeyframeBtn.disabled = !findOpacityPoint(visiblePoints, navigationTime, fps, -1); + if (this.opacityNextKeyframeBtn) this.opacityNextKeyframeBtn.disabled = !findOpacityPoint(visiblePoints, navigationTime, fps, 1); + + // Only committed keyframes gate presets. An armed-but-unwritten point has no + // document presence, so gating on it would disable controls with nothing to undo. + const hasVisualKeyframes = hasKeyframedVisualProperty(clip); + this.setPresetButtonEnabled(this.effectBtn, !(hasVisualKeyframes && !hasEffect), "Effects can't be combined with keyframes in the editor"); + this.setPresetButtonEnabled( + this.transitionBtn, + !(hasVisualKeyframes && !hasTransition), + "Transitions can't be combined with keyframes in the editor" + ); + } + + private setPresetButtonEnabled(button: HTMLButtonElement | null, enabled: boolean, disabledReason: string): void { + if (!button) return; + Object.assign(button, { disabled: !enabled, title: enabled ? "" : disabledReason }); + if (enabled) button.removeAttribute("aria-label"); + else button.setAttribute("aria-label", disabledReason); } // ─── Two-Phase Drag Helpers ────────────────────────────────────────────────── @@ -1008,28 +1006,21 @@ export class MediaToolbar extends BaseToolbar { /** * Capture and deep-clone the current clip state for drag rollback. */ - private captureClipState(): { clipId: string; initialState: ResolvedClip } | null { - const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); - const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); - if (!clip || !clipId) return null; - const documentClip = this.edit.getDocumentClip(this.selectedTrackIdx, this.selectedClipIdx); - const initialState = documentClip ? ({ ...structuredClone(documentClip), id: clip.id } as ResolvedClip) : structuredClone(clip); - return { clipId, initialState }; - } - /** * Start a drag session for a slider control. */ private startSliderDrag(controlId: string): void { + const state = this.captureClipState(); + if (state) { + this.dragManager.start(controlId, state.clipId, state.clip); + } + // Pause after the session opens: pause() emits PlaybackPause synchronously, + // and its listener must see the drag in progress to leave the slider alone. if (controlId === "opacity") { if (this.edit.isPlaying) this.edit.pause(); const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); this.opacityDragTime = clip ? this.getOpacityTime(clip) : null; } - const state = this.captureClipState(); - if (state) { - this.dragManager.start(controlId, state.clipId, state.initialState); - } } /** @@ -1042,11 +1033,9 @@ export class MediaToolbar extends BaseToolbar { return; } - const finalClip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); - if (finalClip) { - const documentClip = this.edit.getDocumentClip(this.selectedTrackIdx, this.selectedClipIdx); - const finalState = documentClip ? ({ ...structuredClone(documentClip), id: finalClip.id } as ResolvedClip) : structuredClone(finalClip); - this.edit.commitClipUpdate(session.clipId, session.initialState, finalState); + const final = this.captureClipState(); + if (final) { + this.edit.commitClipUpdate(session.clipId, session.initialState, final.clip); } if (controlId === "opacity") this.opacityDragTime = null; } @@ -1133,7 +1122,7 @@ export class MediaToolbar extends BaseToolbar { return; } if (remaining.length === 1) { - if (remaining[0].time > clip.length + KEYFRAME_TIME_EPSILON) { + if (remaining[0].time > clip.length + TIME_EPSILON) { this.syncOpacityState(clip); return; } @@ -1168,7 +1157,7 @@ export class MediaToolbar extends BaseToolbar { if (!clip || !clipId) return; const points = this.getOpacityPoints(clip, clipId); if (!points) return; - const visiblePoints = points.filter(point => point.time <= clip.length + KEYFRAME_TIME_EPSILON); + const visiblePoints = this.visibleOpacityPoints(points, clip); const point = findOpacityPoint(visiblePoints, this.edit.playbackTime - clip.start, this.edit.getOutputFps(), direction); if (point) this.edit.seek(clip.start + point.time); } @@ -1334,8 +1323,7 @@ export class MediaToolbar extends BaseToolbar { private applyTransitionUpdate(): void { const transition = this.transitionPanel?.getClipValue(); const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); - const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); - if (clip && clipId && (this.clipHasVisualKeyframes(clip) || this.pendingOpacityTimes.has(clipId))) { + if (clip && hasKeyframedVisualProperty(clip)) { const changesIn = Boolean(transition?.in && transition.in !== clip.transition?.in); const changesOut = Boolean(transition?.out && transition.out !== clip.transition?.out); if (changesIn || changesOut) { @@ -1351,8 +1339,7 @@ export class MediaToolbar extends BaseToolbar { private applyEffect(): void { const effectValue = this.effectPanel?.getClipValue(); const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); - const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); - if (effectValue && clip && clipId && (this.clipHasVisualKeyframes(clip) || this.pendingOpacityTimes.has(clipId))) { + if (effectValue && clip && hasKeyframedVisualProperty(clip)) { this.effectPanel?.setFromClip(clip.effect); return; } @@ -1594,6 +1581,8 @@ export class MediaToolbar extends BaseToolbar { override show(trackIndex: number, clipIndex: number): void { const clip = this.edit.getResolvedClip(trackIndex, clipIndex); this.assetType = (clip?.asset?.type ?? "image") as MediaAssetType; + // An armed keyframe is scoped to the current selection; it never reaches the document. + this.pendingOpacityTimes.clear(); super.show(trackIndex, clipIndex); } diff --git a/src/core/ui/merge-field-label-manager.ts b/src/core/ui/merge-field-label-manager.ts index b82207ad..51533104 100644 --- a/src/core/ui/merge-field-label-manager.ts +++ b/src/core/ui/merge-field-label-manager.ts @@ -86,10 +86,10 @@ export class MergeFieldLabelManager { const clipId = this.getSelectedClipId(); if (!clipId) return; + const resolvedClip = this.host.edit.getResolvedClipById(clipId); for (const label of this.labels) { const propertyPath = label.getPropertyPath(); - const resolvedClip = this.host.edit.getResolvedClipById(clipId); const currentValue = resolvedClip ? getNestedValue(resolvedClip, propertyPath) : null; label.setEnabled(!Array.isArray(currentValue), ARRAY_VALUE_DISABLED_REASON); diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index 65772748..2b9a492e 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -354,7 +354,7 @@ export class RichTextToolbar extends BaseToolbar { this.spacingPanel.onDragStart(() => { const state = this.captureClipState(); if (state) { - this.dragManager.start("spacing-panel", state.clipId, state.initialState); + this.dragManager.start("spacing-panel", state.clipId, state.clip); } }); @@ -464,7 +464,7 @@ export class RichTextToolbar extends BaseToolbar { this.animationDurationSlider?.addEventListener("pointerdown", () => { const state = this.captureClipState(); if (state) { - this.dragManager.start("animation-duration", state.clipId, state.initialState); + this.dragManager.start("animation-duration", state.clipId, state.clip); } }); @@ -521,7 +521,7 @@ export class RichTextToolbar extends BaseToolbar { this.stylePanel.onDragStart(() => { const state = this.captureClipState(); if (state) { - this.dragManager.start("style-panel", state.clipId, state.initialState); + this.dragManager.start("style-panel", state.clipId, state.clip); } }); @@ -692,7 +692,7 @@ export class RichTextToolbar extends BaseToolbar { this.backgroundColorPicker.onDragStart(controlId => { const state = this.captureClipState(); if (state) { - this.dragManager.start(controlId, state.clipId, state.initialState); + this.dragManager.start(controlId, state.clipId, state.clip); } }); @@ -1402,12 +1402,6 @@ export class RichTextToolbar extends BaseToolbar { * * @returns Object with clipId and cloned initial state, or null if no clip selected */ - protected captureClipState(): { clipId: string; initialState: ResolvedClip } | null { - const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); - const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); - return clip && clipId ? { clipId, initialState: structuredClone(clip) } : null; - } - private selectFont(font: FontInfo): void { // Add font URL to timeline.fonts via document layer (persists properly) const document = this.edit.getDocument(); diff --git a/src/core/ui/svg-toolbar.ts b/src/core/ui/svg-toolbar.ts index 40d65cf7..229a0d90 100644 --- a/src/core/ui/svg-toolbar.ts +++ b/src/core/ui/svg-toolbar.ts @@ -380,20 +380,14 @@ export class SvgToolbar extends BaseToolbar { const opacityKeyframed = Array.isArray(clip.opacity); this.opacitySlider?.setEnabled(!opacityKeyframed); this.setNumericControlEnabled("opacity", !opacityKeyframed); - if (!opacityKeyframed) { - const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; - this.opacitySlider?.setValue(Math.round(opacity * 100)); - this.updateOpacityDisplay(); - } + this.opacitySlider?.setValue(Math.round((typeof clip.opacity === "number" ? clip.opacity : 1) * 100)); + this.updateOpacityDisplay(); const scaleKeyframed = Array.isArray(clip.scale); this.scaleSlider?.setEnabled(!scaleKeyframed); this.setNumericControlEnabled("scale", !scaleKeyframed); - if (!scaleKeyframed) { - const scale = typeof clip.scale === "number" ? clip.scale : 1; - this.scaleSlider?.setValue(Math.round(scale * 100)); - this.updateScaleDisplay(); - } + this.scaleSlider?.setValue(Math.round((typeof clip.scale === "number" ? clip.scale : 1) * 100)); + this.updateScaleDisplay(); this.transitionPanel?.setFromClip(clip.transition); this.effectPanel?.setFromClip(clip.effect); @@ -411,23 +405,13 @@ export class SvgToolbar extends BaseToolbar { // Text-input commits (blur / Enter) skip the drag path and go straight // through applyClipUpdate(). - private captureClipState(): { clipId: string; initialState: ResolvedClip } | null { - const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); - const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); - return clip && clipId ? { clipId, initialState: structuredClone(clip) } : null; - } - /** Start a drag session for any control (asset or clip-level). */ private startAssetDrag(controlId: string): void { const state = this.captureClipState(); - if ( - !state || - (controlId === "opacity" && Array.isArray(state.initialState.opacity)) || - (controlId === "scale" && Array.isArray(state.initialState.scale)) - ) { + if (!state || (controlId === "opacity" && Array.isArray(state.clip.opacity)) || (controlId === "scale" && Array.isArray(state.clip.scale))) { return; } - this.dragManager.start(controlId, state.clipId, state.initialState); + this.dragManager.start(controlId, state.clipId, state.clip); } /** End a drag session and commit a single undo entry. */ @@ -435,9 +419,9 @@ export class SvgToolbar extends BaseToolbar { const session = this.dragManager.end(controlId); if (!session) return; - const finalClip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); - if (finalClip) { - this.edit.commitClipUpdate(session.clipId, session.initialState, structuredClone(finalClip)); + const final = this.captureClipState(); + if (final) { + this.edit.commitClipUpdate(session.clipId, session.initialState, final.clip); } } diff --git a/src/core/ui/text-to-image-toolbar.ts b/src/core/ui/text-to-image-toolbar.ts index c881adc8..a09d56fe 100644 --- a/src/core/ui/text-to-image-toolbar.ts +++ b/src/core/ui/text-to-image-toolbar.ts @@ -1,6 +1,6 @@ import { truncatePrompt } from "@core/shared/ai-asset-utils"; import { ShotstackEdit } from "@core/shotstack-edit"; -import type { ResolvedClip, TextToImageAsset } from "@schemas"; +import type { TextToImageAsset } from "@schemas"; import { injectShotstackStyles } from "@styles/inject"; import { BaseToolbar } from "./base-toolbar"; @@ -402,21 +402,15 @@ export class TextToImageToolbar extends BaseToolbar { const opacityKeyframed = Array.isArray(clip.opacity); this.opacitySlider?.setEnabled(!opacityKeyframed); this.setNumericControlEnabled("opacity", !opacityKeyframed); - if (!opacityKeyframed) { - const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; - this.opacitySlider?.setValue(Math.round(opacity * 100)); - this.updateOpacityDisplay(); - } + this.opacitySlider?.setValue(Math.round((typeof clip.opacity === "number" ? clip.opacity : 1) * 100)); + this.updateOpacityDisplay(); // Scale const scaleKeyframed = Array.isArray(clip.scale); this.scaleSlider?.setEnabled(!scaleKeyframed); this.setNumericControlEnabled("scale", !scaleKeyframed); - if (!scaleKeyframed) { - const scale = typeof clip.scale === "number" ? clip.scale : 1; - this.scaleSlider?.setValue(Math.round(scale * 100)); - this.updateScaleDisplay(); - } + this.scaleSlider?.setValue(Math.round((typeof clip.scale === "number" ? clip.scale : 1) * 100)); + this.updateScaleDisplay(); // Transition this.transitionPanel?.setFromClip(clip.transition); @@ -528,25 +522,15 @@ export class TextToImageToolbar extends BaseToolbar { /** * Capture and deep-clone the current clip state for drag rollback. */ - private captureClipState(): { clipId: string; initialState: ResolvedClip } | null { - const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); - const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); - return clip && clipId ? { clipId, initialState: structuredClone(clip) } : null; - } - /** * Start a drag session for a slider control. */ private startSliderDrag(controlId: string): void { const state = this.captureClipState(); - if ( - !state || - (controlId === "opacity" && Array.isArray(state.initialState.opacity)) || - (controlId === "scale" && Array.isArray(state.initialState.scale)) - ) { + if (!state || (controlId === "opacity" && Array.isArray(state.clip.opacity)) || (controlId === "scale" && Array.isArray(state.clip.scale))) { return; } - this.dragManager.start(controlId, state.clipId, state.initialState); + this.dragManager.start(controlId, state.clipId, state.clip); } /** @@ -556,9 +540,9 @@ export class TextToImageToolbar extends BaseToolbar { const session = this.dragManager.end(controlId); if (!session) return; - const finalClip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); - if (finalClip) { - this.edit.commitClipUpdate(session.clipId, session.initialState, structuredClone(finalClip)); + const final = this.captureClipState(); + if (final) { + this.edit.commitClipUpdate(session.clipId, session.initialState, final.clip); } } diff --git a/src/core/ui/text-to-speech-toolbar.ts b/src/core/ui/text-to-speech-toolbar.ts index 57043283..cad06fc4 100644 --- a/src/core/ui/text-to-speech-toolbar.ts +++ b/src/core/ui/text-to-speech-toolbar.ts @@ -1,4 +1,4 @@ -import type { ResolvedClip, TextToSpeechAsset } from "@schemas"; +import type { TextToSpeechAsset } from "@schemas"; import { injectShotstackStyles } from "@styles/inject"; import { BaseToolbar } from "./base-toolbar"; @@ -432,13 +432,9 @@ export class TextToSpeechToolbar extends BaseToolbar { this.updateTextPreview(asset.text ?? ""); // Volume - const volumeKeyframed = Array.isArray(asset.volume); - this.setVolumeEnabled(!volumeKeyframed); - if (!volumeKeyframed) { - const volume = typeof asset.volume === "number" ? asset.volume : 1; - this.currentVolume = Math.round(volume * 100); - this.updateVolumeDisplay(); - } + this.setVolumeEnabled(!Array.isArray(asset.volume)); + this.currentVolume = Math.round((typeof asset.volume === "number" ? asset.volume : 1) * 100); + this.updateVolumeDisplay(); // Audio fade this.audioFadeEffect = (asset.effect as "" | "fadeIn" | "fadeOut" | "fadeInFadeOut") || ""; @@ -569,26 +565,20 @@ export class TextToSpeechToolbar extends BaseToolbar { // ─── Two-Phase Drag ────────────────────────────────────────────────────────── - private captureClipState(): { clipId: string; initialState: ResolvedClip } | null { - const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); - const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); - return clip && clipId ? { clipId, initialState: structuredClone(clip) } : null; - } - private startSliderDrag(controlId: string): void { const state = this.captureClipState(); if (!state) return; - if (controlId === "volume" && state.initialState.asset.type === "text-to-speech" && Array.isArray(state.initialState.asset.volume)) return; - this.dragManager.start(controlId, state.clipId, state.initialState); + if (controlId === "volume" && state.clip.asset.type === "text-to-speech" && Array.isArray(state.clip.asset.volume)) return; + this.dragManager.start(controlId, state.clipId, state.clip); } private endSliderDrag(controlId: string): void { const session = this.dragManager.end(controlId); if (!session) return; - const finalClip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); - if (finalClip) { - this.edit.commitClipUpdate(session.clipId, session.initialState, structuredClone(finalClip)); + const final = this.captureClipState(); + if (final) { + this.edit.commitClipUpdate(session.clipId, session.initialState, final.clip); } } diff --git a/src/main.ts b/src/main.ts index b047c4a7..6a8d4e8f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,14 @@ import { type Edit as EditSchema } from "@schemas"; import { Timeline } from "@timeline/index"; -import template from "./templates/opacity-keyframes.json"; +import template from "./templates/prompt-assets.json"; import { Edit, Canvas, Controls, UIController } from "./index"; -/** Opacity keyframe development demo. Run with `npm run dev`. */ +/** + * Simple example implementing the README quick start guide. + * Run with `npm run dev` to see it in action. + */ async function main() { try { // 1. Create core components diff --git a/tests/media-toolbar.test.ts b/tests/media-toolbar.test.ts index fb190609..4a4ce819 100644 --- a/tests/media-toolbar.test.ts +++ b/tests/media-toolbar.test.ts @@ -469,6 +469,45 @@ describe("MediaToolbar", () => { toolbar.dispose(); }); + it("leaves effects and transitions available after arming the first key", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 1; + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: 0.5 })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + (parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement).click(); + + // Arming writes nothing to the document, so there is nothing to undo and + // nothing that should gate the preset controls. + expect(mockEdit.updateClip).not.toHaveBeenCalled(); + expect((parent.querySelector('[data-action="effect"]') as HTMLButtonElement).disabled).toBe(false); + expect((parent.querySelector('[data-action="transition"]') as HTMLButtonElement).disabled).toBe(false); + toolbar.dispose(); + }); + + it("discards an armed key when the selection or the edit changes", () => { + const mockEdit = createMockEditSession(); + mockEdit.playbackTime = 1; + mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: 0.5 })); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + const keyframe = parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement; + keyframe.click(); + expect(keyframe.dataset["state"]).toBe("keyframe"); + + toolbar.show(0, 0); + expect(keyframe.dataset["state"]).toBe("static"); + + keyframe.click(); + expect(keyframe.dataset["state"]).toBe("keyframe"); + const editChanged = mockEdit.events.on.mock.calls.find(([name]) => name === "edit:changed")?.[1]; + editChanged({ source: "loadEdit" }); + expect(keyframe.dataset["state"]).toBe("static"); + toolbar.dispose(); + }); + it("blocks effects and transitions while opacity is animated", () => { const mockEdit = createMockEditSession(); mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: opacityTweens })); diff --git a/tests/svg-toolbar.test.ts b/tests/svg-toolbar.test.ts index fcd57fcf..64a1d06b 100644 --- a/tests/svg-toolbar.test.ts +++ b/tests/svg-toolbar.test.ts @@ -42,6 +42,7 @@ function createMockEditSession() { return { getClipId: jest.fn().mockReturnValue("clip-123"), getResolvedClip: jest.fn(), + getDocumentClip: jest.fn(), updateClip: jest.fn(), updateClipInDocument: jest.fn(), resolveClip: jest.fn(), @@ -503,6 +504,49 @@ describe("SvgToolbar - Data Flow Integrity", () => { expect(mockEdit.updateClipInDocument).not.toHaveBeenCalled(); }); + it("shows the selected clip's own value when opacity is keyframed", () => { + const mockEdit = createMockEditSession(); + const svgClip = createSvgClip(''); + svgClip.opacity = 0.2; + mockEdit.getResolvedClip.mockReturnValue(svgClip); + + const { toolbar, parent } = createToolbar(mockEdit); + // @ts-expect-error - accessing protected method for testing + toolbar.syncState(); + const value = parent.querySelector(`[data-popup="opacity"] input[type="text"]`)!; + expect(value.value).toBe("20%"); + + const keyframed = createSvgClip(''); + keyframed.opacity = [{ from: 0.8, to: 0.1, start: 0, length: 1, interpolation: "linear" as const }]; + mockEdit.getResolvedClip.mockReturnValue(keyframed); + // @ts-expect-error - accessing protected method for testing + toolbar.syncState(); + + // Must not still read 20% — that described the clip that is no longer selected. + expect(value.value).toBe("100%"); + }); + + it("keeps document timing intent in slider undo history", () => { + const mockEdit = createMockEditSession(); + const svgClip = createSvgClip(''); + mockEdit.getResolvedClip.mockReturnValue(svgClip); + mockEdit.getDocumentClip.mockReturnValue({ ...svgClip, start: "auto", length: "end" }); + + const { toolbar, parent } = createToolbar(mockEdit); + // @ts-expect-error - accessing protected method for testing + toolbar.syncState(); + + const range = parent.querySelector(`[data-popup="opacity"] input[type="range"]`)!; + range.dispatchEvent(new Event("pointerdown", { bubbles: true })); + range.value = "50"; + range.dispatchEvent(new Event("input", { bubbles: true })); + range.dispatchEvent(new Event("change", { bubbles: true })); + + const [, initialState, finalState] = mockEdit.commitClipUpdate.mock.calls[0]; + expect(initialState).toEqual(expect.objectContaining({ id: "clip-123", start: "auto", length: "end" })); + expect(finalState).toEqual(expect.objectContaining({ id: "clip-123", start: "auto", length: "end" })); + }); + it("reads from edit session as single source of truth", () => { const mockEdit = createMockEditSession(); const svgClip = createSvgClip(''); diff --git a/tests/text-to-image-toolbar.test.ts b/tests/text-to-image-toolbar.test.ts index 71e879dd..f95cd424 100644 --- a/tests/text-to-image-toolbar.test.ts +++ b/tests/text-to-image-toolbar.test.ts @@ -61,6 +61,7 @@ function createMockEditSession() { return { getClipId: jest.fn().mockReturnValue("clip-tti-1"), getResolvedClip: jest.fn(), + getDocumentClip: jest.fn(), getDocument: jest.fn().mockReturnValue(mockDocument), updateClip: jest.fn(), updateClipInDocument: jest.fn(), diff --git a/tests/toolbar-delete-button.test.ts b/tests/toolbar-delete-button.test.ts index 5d800e1a..d869fdc8 100644 --- a/tests/toolbar-delete-button.test.ts +++ b/tests/toolbar-delete-button.test.ts @@ -194,9 +194,12 @@ describe("Toolbar delete button", () => { // Simulating a re-mount path: call mount() again on the same toolbar. toolbar.mount(document.body); - [EditEvent.ClipAdded, EditEvent.ClipDeleted, EditEvent.ClipRestored].forEach(event => { + [EditEvent.ClipAdded, EditEvent.ClipDeleted, EditEvent.ClipRestored, EditEvent.PlaybackPause, EditEvent.EditChanged].forEach(event => { expect(mockEdit.events.on.mock.calls.filter(([name]) => name === event)).toHaveLength(1); }); + + // Any listener beyond those five would survive a re-mount unremoved. + expect(mockEdit.events.on.mock.calls).toHaveLength(5); }); }); From 1a72d2133f17dbe7030fb31d7e137271efa088fb Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 11 Aug 2026 21:49:01 +1000 Subject: [PATCH 3/4] docs: correct keyframe helper comment and drop orphaned toolbar doc block --- src/core/shared/clip-utils.ts | 6 ++++-- src/core/ui/rich-text-toolbar.ts | 6 ------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/core/shared/clip-utils.ts b/src/core/shared/clip-utils.ts index 8b197114..0359a6d3 100644 --- a/src/core/shared/clip-utils.ts +++ b/src/core/shared/clip-utils.ts @@ -14,8 +14,10 @@ export function stripInternalProperties(clip: Clip): Clip { /** * True when any visual property holds something other than a plain number — * keyframes, or a merge field placeholder that never resolved. - * Such clips render without effect and transition layers, so the renderer and - * the toolbars must agree on the property list; keep this the only copy. + * + * Studio previews such clips without effect and transition layers, so the player + * and the toolbars must agree on the property list; keep this the only copy. + * Preview-only: rendered output composes presets over keyframes instead. */ export function hasKeyframedVisualProperty(clip: Clip): boolean { return [ diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index 2b9a492e..c30224ba 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -1396,12 +1396,6 @@ export class RichTextToolbar extends BaseToolbar { this.edit.resolveClip(clipId); } - /** - * Capture current clip state for two-phase drag pattern (Phase 1). - * Creates a deep clone of the clip's current state to enable command rollback on drag end. - * - * @returns Object with clipId and cloned initial state, or null if no clip selected - */ private selectFont(font: FontInfo): void { // Add font URL to timeline.fonts via document layer (persists properly) const document = this.edit.getDocument(); From 7b6332cc6275de8fca8e8abd4886e6f2eae803c3 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 11 Aug 2026 22:01:17 +1000 Subject: [PATCH 4/4] fix: treat unresolved merge fields as non-editable in canvas gesture guards --- src/core/shared/clip-utils.ts | 14 +++++++++++--- src/core/ui/selection-handles.ts | 6 ++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/core/shared/clip-utils.ts b/src/core/shared/clip-utils.ts index 0359a6d3..bc4e4013 100644 --- a/src/core/shared/clip-utils.ts +++ b/src/core/shared/clip-utils.ts @@ -12,8 +12,16 @@ export function stripInternalProperties(clip: Clip): Clip { } /** - * True when any visual property holds something other than a plain number — - * keyframes, or a merge field placeholder that never resolved. + * True when a numeric clip property holds something else — keyframes, or a merge + * field placeholder that never resolved. Writing a scalar over either destroys it, + * so every control that writes scalars must agree on this test. + */ +export function isKeyframedValue(value: unknown): boolean { + return Boolean(value) && typeof value !== "number"; +} + +/** + * True when any visual property is keyframed or bound. * * Studio previews such clips without effect and transition layers, so the player * and the toolbars must agree on the property list; keep this the only copy. @@ -28,5 +36,5 @@ export function hasKeyframedVisualProperty(clip: Clip): boolean { clip.transform?.rotate?.angle, clip.transform?.skew?.x, clip.transform?.skew?.y - ].some(property => property && typeof property !== "number"); + ].some(isKeyframedValue); } diff --git a/src/core/ui/selection-handles.ts b/src/core/ui/selection-handles.ts index 9e2ed934..677acf76 100644 --- a/src/core/ui/selection-handles.ts +++ b/src/core/ui/selection-handles.ts @@ -26,6 +26,7 @@ import { snapRotation, visualToLogical } from "@core/interaction/snap-system"; +import { isKeyframedValue } from "@core/shared/clip-utils"; import { updateSvgViewBox, isSimpleRectSvg } from "@core/shared/svg-utils"; import { Pointer } from "@inputs/pointer"; import type { Size, Vector } from "@layouts/geometry"; @@ -832,13 +833,14 @@ export class SelectionHandles implements CanvasOverlayRegistration { // ─── Helpers ───────────────────────────────────────────────────────────────── + /** Canvas gestures write absolute scalars, which would overwrite an animated or bound value. */ private hasKeyframedOffset(): boolean { const offset = this.selectedPlayer?.clipConfiguration.offset; - return Array.isArray(offset?.x) || Array.isArray(offset?.y); + return isKeyframedValue(offset?.x) || isKeyframedValue(offset?.y); } private hasKeyframedRotation(): boolean { - return Array.isArray(this.selectedPlayer?.clipConfiguration.transform?.rotate?.angle); + return isKeyframedValue(this.selectedPlayer?.clipConfiguration.transform?.rotate?.angle); } private captureOriginalDimensions(): void {