();
+ 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 +438,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 +469,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 +607,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 +726,26 @@ export class MediaToolbar extends BaseToolbar {
{ signal }
);
});
+
+ // 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 = () => this.syncIfVisible();
+ this.edit.events.on(EditEvent.PlaybackPause, this.playbackPauseListener);
+ }
+ if (!this.editChangedListener) {
+ this.editChangedListener = event => {
+ 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 {
@@ -731,10 +805,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 +837,6 @@ export class MediaToolbar extends BaseToolbar {
// Update displays
this.updateFitDisplay();
- this.updateOpacityDisplay();
this.updateScaleDisplay();
this.updateVolumeDisplay();
this.updateSpeedDisplay();
@@ -810,6 +879,116 @@ 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 < -TIME_EPSILON || localTime > clip.length + 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 }];
+ }
+
+ /** 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();
+ 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 = this.visibleOpacityPoints(points ?? [], clip);
+ const fps = this.edit.getOutputFps();
+ const currentPoint = localTime === null ? undefined : findOpacityPoint(visiblePoints, localTime, fps);
+
+ 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 + TIME_EPSILON)) {
+ disabledReason = "Extend the clip before removing this keyframe";
+ }
+
+ this.opacitySlider?.setEnabled(!isBound && editable && (!animated || (!hasPreset && localTime !== null)));
+
+ if (this.opacityKeyframeBtn) {
+ 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);
+ 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;
+ 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 ──────────────────────────────────────────────────
@@ -827,19 +1006,20 @@ 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);
- 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) {
- this.dragManager.start(controlId, state.clipId, state.initialState);
+ 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;
}
}
@@ -848,12 +1028,16 @@ 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 final = this.captureClipState();
+ if (final) {
+ this.edit.commitClipUpdate(session.clipId, session.initialState, final.clip);
}
+ if (controlId === "opacity") this.opacityDragTime = null;
}
// ─── Value Change Handlers ───────────────────────────────────────────────────
@@ -868,11 +1052,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 updates = { opacity: value / 100 };
+ 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 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 +1092,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 + 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 = 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);
+ }
+
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 +1198,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 +1322,15 @@ export class MediaToolbar extends BaseToolbar {
private applyTransitionUpdate(): void {
const transition = this.transitionPanel?.getClipValue();
+ const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx);
+ 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) {
+ this.transitionPanel?.setFromClip(clip.transition);
+ return;
+ }
+ }
this.applyClipUpdate({ transition });
}
@@ -1032,6 +1338,11 @@ export class MediaToolbar extends BaseToolbar {
private applyEffect(): void {
const effectValue = this.effectPanel?.getClipValue();
+ const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx);
+ if (effectValue && clip && hasKeyframedVisualProperty(clip)) {
+ this.effectPanel?.setFromClip(clip.effect);
+ return;
+ }
this.applyClipUpdate({ effect: effectValue });
}
@@ -1270,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);
}
@@ -1280,6 +1593,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 +1625,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..51533104 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).
@@ -84,9 +86,12 @@ 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 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/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts
index 65772748..c30224ba 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);
}
});
@@ -1396,18 +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
- */
- 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/selection-handles.ts b/src/core/ui/selection-handles.ts
index a004098e..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";
@@ -580,7 +581,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 +595,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 +664,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 +674,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 +705,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 +715,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 +746,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 +756,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 +833,16 @@ 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 isKeyframedValue(offset?.x) || isKeyframedValue(offset?.y);
+ }
+
+ private hasKeyframedRotation(): boolean {
+ return isKeyframedValue(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..229a0d90 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,12 +377,16 @@ export class SvgToolbar extends BaseToolbar {
}
// Clip-level controls
- const opacity = typeof clip.opacity === "number" ? clip.opacity : 1;
- this.opacitySlider?.setValue(Math.round(opacity * 100));
+ const opacityKeyframed = Array.isArray(clip.opacity);
+ this.opacitySlider?.setEnabled(!opacityKeyframed);
+ this.setNumericControlEnabled("opacity", !opacityKeyframed);
+ this.opacitySlider?.setValue(Math.round((typeof clip.opacity === "number" ? clip.opacity : 1) * 100));
this.updateOpacityDisplay();
- const scale = typeof clip.scale === "number" ? clip.scale : 1;
- this.scaleSlider?.setValue(Math.round(scale * 100));
+ const scaleKeyframed = Array.isArray(clip.scale);
+ this.scaleSlider?.setEnabled(!scaleKeyframed);
+ this.setNumericControlEnabled("scale", !scaleKeyframed);
+ this.scaleSlider?.setValue(Math.round((typeof clip.scale === "number" ? clip.scale : 1) * 100));
this.updateScaleDisplay();
this.transitionPanel?.setFromClip(clip.transition);
@@ -399,18 +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) {
- this.dragManager.start(controlId, state.clipId, state.initialState);
+ if (!state || (controlId === "opacity" && Array.isArray(state.clip.opacity)) || (controlId === "scale" && Array.isArray(state.clip.scale))) {
+ return;
}
+ this.dragManager.start(controlId, state.clipId, state.clip);
}
/** End a drag session and commit a single undo entry. */
@@ -418,15 +419,20 @@ 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);
}
}
// ─── 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 +449,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 +493,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..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";
@@ -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,13 +399,17 @@ export class TextToImageToolbar extends BaseToolbar {
this.updateFitActiveState();
// Opacity
- const opacity = typeof clip.opacity === "number" ? clip.opacity : 1;
- this.opacitySlider?.setValue(Math.round(opacity * 100));
+ const opacityKeyframed = Array.isArray(clip.opacity);
+ this.opacitySlider?.setEnabled(!opacityKeyframed);
+ this.setNumericControlEnabled("opacity", !opacityKeyframed);
+ this.opacitySlider?.setValue(Math.round((typeof clip.opacity === "number" ? clip.opacity : 1) * 100));
this.updateOpacityDisplay();
// Scale
- const scale = typeof clip.scale === "number" ? clip.scale : 1;
- this.scaleSlider?.setValue(Math.round(scale * 100));
+ const scaleKeyframed = Array.isArray(clip.scale);
+ this.scaleSlider?.setEnabled(!scaleKeyframed);
+ this.setNumericControlEnabled("scale", !scaleKeyframed);
+ this.scaleSlider?.setValue(Math.round((typeof clip.scale === "number" ? clip.scale : 1) * 100));
this.updateScaleDisplay();
// Transition
@@ -517,20 +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) {
- this.dragManager.start(controlId, state.clipId, state.initialState);
+ if (!state || (controlId === "opacity" && Array.isArray(state.clip.opacity)) || (controlId === "scale" && Array.isArray(state.clip.scale))) {
+ return;
}
+ this.dragManager.start(controlId, state.clipId, state.clip);
}
/**
@@ -540,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);
}
}
@@ -557,6 +557,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 +578,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 +640,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..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";
@@ -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,8 +432,8 @@ 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.setVolumeEnabled(!Array.isArray(asset.volume));
+ this.currentVolume = Math.round((typeof asset.volume === "number" ? asset.volume : 1) * 100);
this.updateVolumeDisplay();
// Audio fade
@@ -479,12 +480,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 +524,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 {
@@ -552,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) {
- this.dragManager.start(controlId, state.clipId, state.initialState);
- }
+ if (!state) return;
+ 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/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..4a4ce819 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,358 @@ 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("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 }));
+
+ 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 +640,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 +689,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..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(),
@@ -474,6 +475,78 @@ 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("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 c63ac679..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(),
@@ -396,6 +397,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..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);
- // 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, 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);
});
});