Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 2 additions & 13 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 26 additions & 10 deletions src/core/animations/keyframe-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { type Keyframe, type NumericKeyframe } from "@schemas";

import { CurveInterpolator } from "./curve-interpolator";

/**
* 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[];
private readonly length: number;
Expand Down Expand Up @@ -113,6 +121,7 @@ export class KeyframeBuilder {

const normalizedKeyframes = this.createNormalizedKeyframes(value);

this.normaliseAdjacentBoundaries(normalizedKeyframes);
this.validateKeyframes(normalizedKeyframes);

return this.insertFillerKeyframes(normalizedKeyframes, length, initialValue);
Expand All @@ -132,18 +141,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.");
Expand All @@ -158,7 +174,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);
Expand All @@ -167,7 +183,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 };
Expand All @@ -178,7 +194,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;
Expand Down
158 changes: 158 additions & 0 deletions src/core/animations/opacity-keyframes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type { Clip, Tween } from "@schemas";

import { KeyframeBuilder, TIME_EPSILON } from "./keyframe-builder";

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");

// 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 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);
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];
}
1 change: 1 addition & 0 deletions src/core/edit-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
28 changes: 28 additions & 0 deletions src/core/shared/clip-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,31 @@ export function stripInternalProperties(clip: Clip): Clip {
const { id, ...publicClip } = clip as Clip & { id?: string };
return publicClip;
}

/**
* 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.
* Preview-only: rendered output composes presets over keyframes instead.
*/
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(isKeyframedValue);
}
15 changes: 15 additions & 0 deletions src/core/ui/base-toolbar.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading