mfx
Version:
In-browser video editing toolkit, with effects accelerated by WebGL
141 lines (117 loc) • 3.78 kB
text/typescript
import { type UniformProducer } from "./effects/shaders";
import { ExtendedVideoFrame } from "./frame";
import { MFXTransformStream } from "./stream";
import { easing as tsEasing } from "ts-easing";
import parseTime from "parse-duration";
export class FrameRateAdjuster extends MFXTransformStream<
ExtendedVideoFrame,
ExtendedVideoFrame
> {
get identifier() {
return "FrameRateAdjuster";
}
constructor(fps: number) {
// Limit to 500fps to prevent bugs
const maxDuration = (1 / Math.min(fps, 500)) * 1e6;
let borrowedDuration = 0;
let skippedDuration = 0;
super({
transform: (frame, controller) => {
const duration = frame.duration;
const idealFrameCount = Math.floor(duration / maxDuration);
const minFrameCount = Math.floor(
(duration + borrowedDuration) / maxDuration,
);
// Simple path, no need for a fill
if (idealFrameCount === 1 || minFrameCount === 1) {
controller.enqueue(frame);
borrowedDuration = 0;
return;
}
if (idealFrameCount < 1) {
// Skip this frame and store the duration
borrowedDuration += frame.duration;
return;
}
// Calculate the durations ensuring no fractional durations
const baseDuration = Math.floor(duration / idealFrameCount);
const remainingDuration = duration % idealFrameCount;
const timestamp = frame.timestamp;
let accumulatedDuration = 0;
for (let i = 0; i < idealFrameCount; i++) {
const frameDuration =
i === idealFrameCount - 1
? baseDuration + remainingDuration
: baseDuration;
const clone = ExtendedVideoFrame.revise(frame, frame.clone() as any, {
timestamp: timestamp + accumulatedDuration,
duration: baseDuration + borrowedDuration,
});
// Reset borrowedDuration as it is consumed in the new frame's duration
borrowedDuration = 0;
accumulatedDuration += frameDuration;
controller.enqueue(clone);
}
frame.close();
},
});
}
}
/**
*
* @group Advanced
* @example animate("0s 100, 0.5s 200", "elastic");
*/
export const animate = (
value: string,
easing: string | ((number) => number) = (v) => v,
) => {
const steps = value.split(",");
const parsedSteps = steps.map((step) => {
const [time, value] = step.trim().split(" ");
return {
time: parseTime(time),
value: JSON.parse(value.trim()),
};
});
return keyframes(
parsedSteps,
typeof easing === "function" ? easing : tsEasing[easing],
);
};
/**
* @group Advanced
*/
export const keyframes = <T>(
defs: {
time: number;
easing?: (number) => number;
value: T;
}[],
easing: (number) => number = (v) => v,
): UniformProducer<T> => {
return async (frame) => {
const ts = frame.timestamp / 1000;
const idx = defs.findIndex((_, i) => {
const nextTime = defs[i + 1]?.time || Infinity;
return ts < nextTime;
});
const windowStart = defs[idx];
const windowEnd = defs[idx + 1] || windowStart;
// Fractional / relative value
if (typeof windowStart.value === "number") {
const pos = ts - windowStart.time;
const duration = windowEnd.time - windowStart.time;
const ease = windowStart.easing || easing || ((v) => v);
if (duration <= 0) {
return windowEnd.value;
}
const delta = ease(pos / duration);
const diff = (windowEnd.value as number) - (windowStart.value as number);
const value = ((windowStart.value as number) + diff * delta) as T;
return value;
}
// Absolute value
return windowStart.value;
};
};