diff --git a/examples/transitions.json5 b/examples/transitions.json5 new file mode 100644 index 00000000..b0f04710 --- /dev/null +++ b/examples/transitions.json5 @@ -0,0 +1,284 @@ +{ + fast: true, + outPath: "./transitions.mp4", + width: 1024, + height: 576, + defaults: { + duration: 3, + transition: { duration: 1 }, + }, + clips: [ + { + layers: [{ type: "title-background", text: "Transitions" }], + transition: { name: "Bounce" }, + }, + { + layers: [{ type: "title-background", text: "Bounce" }], + transition: { name: "BowTieHorizontal" }, + }, + { + layers: [{ type: "title-background", text: "BowTieHorizontal" }], + transition: { name: "BowTieVertical" }, + }, + { + layers: [{ type: "title-background", text: "BowTieVertical" }], + transition: { name: "ButterflyWaveScrawler" }, + }, + { + layers: [{ type: "title-background", text: "ButterflyWaveScrawler" }], + transition: { name: "CircleCrop" }, + }, + { + layers: [{ type: "title-background", text: "CircleCrop" }], + transition: { name: "ColourDistance" }, + }, + { + layers: [{ type: "title-background", text: "ColourDistance" }], + transition: { name: "CrazyParametricFun" }, + }, + { + layers: [{ type: "title-background", text: "CrazyParametricFun" }], + transition: { name: "CrossZoom" }, + }, + { + layers: [{ type: "title-background", text: "CrossZoom" }], + transition: { name: "Directional" }, + }, + { + layers: [{ type: "title-background", text: "Directional" }], + transition: { name: "DoomScreenTransition" }, + }, + { + layers: [{ type: "title-background", text: "DoomScreenTransition" }], + transition: { name: "Dreamy" }, + }, + { + layers: [{ type: "title-background", text: "Dreamy" }], + transition: { name: "DreamyZoom" }, + }, + { + layers: [{ type: "title-background", text: "DreamyZoom" }], + transition: { name: "GlitchDisplace" }, + }, + { + layers: [{ type: "title-background", text: "GlitchDisplace" }], + transition: { name: "GlitchMemories" }, + }, + { + layers: [{ type: "title-background", text: "GlitchMemories" }], + transition: { name: "GridFlip" }, + }, + { + layers: [{ type: "title-background", text: "GridFlip" }], + transition: { name: "InvertedPageCurl" }, + }, + { + layers: [{ type: "title-background", text: "InvertedPageCurl" }], + transition: { name: "LinearBlur" }, + }, + { + layers: [{ type: "title-background", text: "LinearBlur" }], + transition: { name: "Mosaic" }, + }, + { + layers: [{ type: "title-background", text: "Mosaic" }], + transition: { name: "PolkaDotsCurtain" }, + }, + { + layers: [{ type: "title-background", text: "PolkaDotsCurtain" }], + transition: { name: "Radial" }, + }, + { + layers: [{ type: "title-background", text: "Radial" }], + transition: { name: "SimpleZoom" }, + }, + { + layers: [{ type: "title-background", text: "SimpleZoom" }], + transition: { name: "StereoViewer" }, + }, + { + layers: [{ type: "title-background", text: "StereoViewer" }], + transition: { name: "Swirl" }, + }, + { + layers: [{ type: "title-background", text: "Swirl" }], + transition: { name: "WaterDrop" }, + }, + { + layers: [{ type: "title-background", text: "WaterDrop" }], + transition: { name: "ZoomInCircles" }, + }, + { + layers: [{ type: "title-background", text: "ZoomInCircles" }], + transition: { name: "angular" }, + }, + { + layers: [{ type: "title-background", text: "angular" }], + transition: { name: "burn" }, + }, + { + layers: [{ type: "title-background", text: "burn" }], + transition: { name: "cannabisleaf" }, + }, + { + layers: [{ type: "title-background", text: "cannabisleaf" }], + transition: { name: "circle" }, + }, + { + layers: [{ type: "title-background", text: "circle" }], + transition: { name: "circleopen" }, + }, + { + layers: [{ type: "title-background", text: "circleopen" }], + transition: { name: "colorphase" }, + }, + { + layers: [{ type: "title-background", text: "colorphase" }], + transition: { name: "crosshatch" }, + }, + { + layers: [{ type: "title-background", text: "crosshatch" }], + transition: { name: "crosswarp" }, + }, + { + layers: [{ type: "title-background", text: "crosswarp" }], + transition: { name: "cube" }, + }, + { + layers: [{ type: "title-background", text: "cube" }], + transition: { name: "directionalwarp" }, + }, + { + layers: [{ type: "title-background", text: "directionalwarp" }], + transition: { name: "directionalwipe" }, + }, + { + layers: [{ type: "title-background", text: "directionalwipe" }], + transition: { name: "displacement" }, + }, + { + layers: [{ type: "title-background", text: "displacement" }], + transition: { name: "doorway" }, + }, + { + layers: [{ type: "title-background", text: "doorway" }], + transition: { name: "fade" }, + }, + { + layers: [{ type: "title-background", text: "fade" }], + transition: { name: "fadecolor" }, + }, + { + layers: [{ type: "title-background", text: "fadecolor" }], + transition: { name: "fadegrayscale" }, + }, + { + layers: [{ type: "title-background", text: "fadegrayscale" }], + transition: { name: "flyeye" }, + }, + { + layers: [{ type: "title-background", text: "flyeye" }], + transition: { name: "heart" }, + }, + { + layers: [{ type: "title-background", text: "heart" }], + transition: { name: "hexagonalize" }, + }, + { + layers: [{ type: "title-background", text: "hexagonalize" }], + transition: { name: "kaleidoscope" }, + }, + { + layers: [{ type: "title-background", text: "kaleidoscope" }], + transition: { name: "luma" }, + }, + { + layers: [{ type: "title-background", text: "luma" }], + transition: { name: "luminance_melt" }, + }, + { + layers: [{ type: "title-background", text: "luminance_melt" }], + transition: { name: "morph" }, + }, + { + layers: [{ type: "title-background", text: "morph" }], + transition: { name: "multiply_blend" }, + }, + { + layers: [{ type: "title-background", text: "multiply_blend" }], + transition: { name: "perlin" }, + }, + { + layers: [{ type: "title-background", text: "perlin" }], + transition: { name: "pinwheel" }, + }, + { + layers: [{ type: "title-background", text: "pinwheel" }], + transition: { name: "pixelize" }, + }, + { + layers: [{ type: "title-background", text: "pixelize" }], + transition: { name: "polar_function" }, + }, + { + layers: [{ type: "title-background", text: "polar_function" }], + transition: { name: "randomsquares" }, + }, + { + layers: [{ type: "title-background", text: "randomsquares" }], + transition: { name: "ripple" }, + }, + { + layers: [{ type: "title-background", text: "ripple" }], + transition: { name: "rotate_scale_fade" }, + }, + { + layers: [{ type: "title-background", text: "rotate_scale_fade" }], + transition: { name: "squareswire" }, + }, + { + layers: [{ type: "title-background", text: "squareswire" }], + transition: { name: "squeeze" }, + }, + { + layers: [{ type: "title-background", text: "squeeze" }], + transition: { name: "swap" }, + }, + { + layers: [{ type: "title-background", text: "swap" }], + transition: { name: "undulatingBurnOut" }, + }, + { + layers: [{ type: "title-background", text: "undulatingBurnOut" }], + transition: { name: "wind" }, + }, + { + layers: [{ type: "title-background", text: "wind" }], + transition: { name: "windowblinds" }, + }, + { + layers: [{ type: "title-background", text: "windowblinds" }], + transition: { name: "windowslice" }, + }, + { + layers: [{ type: "title-background", text: "windowslice" }], + transition: { name: "wipeDown" }, + }, + { + layers: [{ type: "title-background", text: "wipeDown" }], + transition: { name: "wipeLeft" }, + }, + { + layers: [{ type: "title-background", text: "wipeLeft" }], + transition: { name: "wipeRight" }, + }, + { + layers: [{ type: "title-background", text: "wipeRight" }], + transition: { name: "wipeUp" }, + }, + { + layers: [{ type: "title-background", text: "wipeUp" }], + transition: { duration: 0 }, + }, + ], +} diff --git a/src/audio.ts b/src/audio.ts index 78f18bc4..4793b4f3 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -3,12 +3,12 @@ import pMap from "p-map"; import { basename, join, resolve } from "path"; import type { Configuration } from "./configuration.js"; import { ffmpeg, getCutFromArgs, readFileStreams } from "./ffmpeg.js"; +import type { TransitionOptions } from "./transition.js"; import type { AudioLayer, AudioNormalizationOptions, AudioTrack, Clip, - Transition, VideoLayer, } from "./types.js"; @@ -178,7 +178,7 @@ export default ({ verbose, tmpDir }: AudioOptions) => { } async function crossFadeConcatClipAudio( - clipAudio: { path: string; transition?: Transition | null }[], + clipAudio: { path: string; transition?: TransitionOptions | null }[], ) { if (clipAudio.length < 2) { return clipAudio[0].path; diff --git a/src/easings.ts b/src/easings.ts new file mode 100644 index 00000000..6c38f0c5 --- /dev/null +++ b/src/easings.ts @@ -0,0 +1,8 @@ +// https://easings.net/ + +export type EasingFunction = (progress: number) => number; + +export const easeOutExpo: EasingFunction = (x: number) => (x === 1 ? 1 : 1 - 2 ** (-10 * x)); +export const easeInOutCubic: EasingFunction = (x: number) => + x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2; +export const linear: EasingFunction = (x: number) => x; diff --git a/src/glTransitions.ts b/src/glTransitions.ts deleted file mode 100644 index 1f22585b..00000000 --- a/src/glTransitions.ts +++ /dev/null @@ -1,108 +0,0 @@ -import GL from "gl"; -import createBuffer from "gl-buffer"; -import createTexture from "gl-texture2d"; -import glTransition from "gl-transition"; -import glTransitions from "gl-transitions"; -import ndarray from "ndarray"; -import { TransitionParams } from "./types.js"; - -const { default: createTransition } = glTransition; - -export type RunTransitionOptions = { - fromFrame: Buffer; - toFrame: Buffer; - progress: number; - transitionName?: string; - transitionParams?: TransitionParams; -}; - -export default ({ - width, - height, - channels, -}: { - width: number; - height: number; - channels: number; -}) => { - const gl = GL(width, height); - - if (!gl) { - throw new Error( - "gl returned null, this probably means that some dependencies are not installed. See README.", - ); - } - - function runTransitionOnFrame({ - fromFrame, - toFrame, - progress, - transitionName, - transitionParams = {}, - }: RunTransitionOptions) { - function convertFrame(buf: Buffer) { - // @see https://github.com/stackgl/gl-texture2d/issues/16 - return ndarray(buf, [width, height, channels], [channels, width * channels, 1]); - } - - const buffer = createBuffer(gl, [-1, -1, -1, 4, 4, -1], gl.ARRAY_BUFFER, gl.STATIC_DRAW); - - let transition; - - try { - const resizeMode = "stretch"; - - const transitionSource = glTransitions.find( - (t) => t.name.toLowerCase() === transitionName?.toLowerCase(), - ); - - transition = createTransition(gl, transitionSource!, { resizeMode }); - - gl.clear(gl.COLOR_BUFFER_BIT); - - // console.time('runTransitionOnFrame internal'); - const fromFrameNdArray = convertFrame(fromFrame); - const textureFrom = createTexture(gl, fromFrameNdArray); - textureFrom.minFilter = gl.LINEAR; - textureFrom.magFilter = gl.LINEAR; - - // console.timeLog('runTransitionOnFrame internal'); - const toFrameNdArray = convertFrame(toFrame); - const textureTo = createTexture(gl, toFrameNdArray); - textureTo.minFilter = gl.LINEAR; - textureTo.magFilter = gl.LINEAR; - - buffer.bind(); - transition.draw( - progress, - textureFrom, - textureTo, - gl.drawingBufferWidth, - gl.drawingBufferHeight, - transitionParams, - ); - - textureFrom.dispose(); - textureTo.dispose(); - - // console.timeLog('runTransitionOnFrame internal'); - - const outArray = Buffer.allocUnsafe(width * height * 4); - gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, outArray); - - // console.timeEnd('runTransitionOnFrame internal'); - - return outArray; - - // require('fs').writeFileSync(`${new Date().getTime()}.raw`, outArray); - // Testing: ffmpeg -f rawvideo -vcodec rawvideo -pix_fmt rgba -s 2166x1650 -i 1586619627191.raw -vf format=yuv420p -vcodec libx264 -y out.mp4 - } finally { - buffer.dispose(); - if (transition) transition.dispose(); - } - } - - return { - runTransitionOnFrame, - }; -}; diff --git a/src/index.ts b/src/index.ts index 32d84681..c28f0830 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,6 @@ import Audio from "./audio.js"; import { Configuration, type ConfigurationOptions } from "./configuration.js"; import { configureFf, ffmpeg, parseFps } from "./ffmpeg.js"; import { createFrameSource } from "./frameSource.js"; -import GlTransitions, { type RunTransitionOptions } from "./glTransitions.js"; import parseConfig, { ProcessedClip } from "./parseConfig.js"; import { createFabricCanvas, rgbaToFabricImage } from "./sources/fabric.js"; import type { RenderSingleFrameConfig } from "./types.js"; @@ -15,6 +14,7 @@ import { assertFileValid, multipleOf2 } from "./util.js"; const channels = 4; +export type * from "./transition.js"; export type * from "./types.js"; /** @@ -73,13 +73,13 @@ async function Editly(input: ConfigurationOptions): Promise { const audioFilePath = !isGif ? await editAudio({ - keepSourceAudio, - arbitraryAudio, - clipsAudioVolume, - clips, - audioNorm, - outputVolume, - }) + keepSourceAudio, + arbitraryAudio, + clipsAudioVolume, + clips, + audioNorm, + outputVolume, + }) : undefined; // Try to detect parameters from first video @@ -185,31 +185,6 @@ async function Editly(input: ConfigurationOptions): Promise { return newAcc; }, 0); - const { runTransitionOnFrame: runGlTransitionOnFrame } = GlTransitions({ - width, - height, - channels, - }); - - function runTransitionOnFrame({ - fromFrame, - toFrame, - progress, - transitionName, - transitionParams, - }: RunTransitionOptions) { - // A dummy transition can be used to have an audio transition without a video transition - // (Note: You will lose a portion from both clips due to overlap) - if (transitionName === "dummy") return progress > 0.5 ? toFrame : fromFrame; - return runGlTransitionOnFrame({ - fromFrame, - toFrame, - progress, - transitionName, - transitionParams, - }); - } - function getOutputArgs() { if (customOutputArgs) { assert(Array.isArray(customOutputArgs), "customOutputArgs must be an array of arguments"); @@ -219,25 +194,25 @@ async function Editly(input: ConfigurationOptions): Promise { // https://superuser.com/questions/556029/how-do-i-convert-a-video-to-gif-using-ffmpeg-with-reasonable-quality const videoOutputArgs = isGif ? [ - "-vf", - `format=rgb24,fps=${fps},scale=${width}:${height}:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`, - "-loop", - "0", - ] + "-vf", + `format=rgb24,fps=${fps},scale=${width}:${height}:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`, + "-loop", + "0", + ] : [ - "-vf", - "format=yuv420p", - "-vcodec", - "libx264", - "-profile:v", - "high", - ...(fast ? ["-preset:v", "ultrafast"] : ["-preset:v", "medium"]), - "-crf", - "18", - - "-movflags", - "faststart", - ]; + "-vf", + "format=yuv420p", + "-vcodec", + "libx264", + "-profile:v", + "high", + ...(fast ? ["-preset:v", "ultrafast"] : ["-preset:v", "medium"]), + "-crf", + "18", + + "-movflags", + "faststart", + ]; const audioOutputArgs = audioFilePath ? ["-acodec", "aac", "-b:a", "128k"] : []; @@ -342,9 +317,9 @@ async function Editly(input: ConfigurationOptions): Promise { const fromClipTime = transitionFromClip.duration * fromClipProgress; const toClipTime = transitionToClip && transitionToClip.duration * toClipProgress; - const currentTransition = transitionFromClip.transition!; - - const transitionNumFrames = Math.round(currentTransition.duration! * fps); + const currentTransition = transitionFromClip.transition; + const transitionNumFrames = Math.round(currentTransition.duration * fps); + const runTransitionOnFrame = currentTransition.create({ width, height, channels }); // Each clip has two transitions, make sure we leave enough room: const transitionNumFramesSafe = Math.floor( @@ -412,16 +387,15 @@ async function Editly(input: ConfigurationOptions): Promise { if (frameSource2Data) { const progress = transitionFrameAt / transitionNumFramesSafe; - const easedProgress = currentTransition.easingFunction(progress); if (logTimes) console.time("runTransitionOnFrame"); + outFrameData = runTransitionOnFrame({ fromFrame: frameSource1Data!, toFrame: frameSource2Data, - progress: easedProgress, - transitionName: currentTransition.name, - transitionParams: currentTransition.params, + progress: progress, }); + if (logTimes) console.timeEnd("runTransitionOnFrame"); } else { console.warn("Got no frame data from transitionToClip!"); @@ -522,7 +496,7 @@ export async function renderSingleFrame(input: RenderSingleFrameConfig): Promise configureFf(config); - console.log({ clipsIn }) + console.log({ clipsIn }); const { clips } = await parseConfig({ clips: clipsIn, arbitraryAudio: [], allowRemoteRequests }); let clipStartTime = 0; diff --git a/src/parseConfig.ts b/src/parseConfig.ts index e47f488a..0eb66c1f 100644 --- a/src/parseConfig.ts +++ b/src/parseConfig.ts @@ -4,7 +4,7 @@ import flatMap from "lodash-es/flatMap.js"; import pMap from "p-map"; import { basename } from "path"; import { readDuration, readVideoFileInfo } from "./ffmpeg.js"; -import { calcTransition, type CalculatedTransition } from "./transitions.js"; +import { Transition } from "./transition.js"; import type { AudioTrack, CanvasLayer, @@ -24,7 +24,7 @@ import { assertFileValid } from "./util.js"; export type ProcessedClip = { layers: Layer[]; duration: number; - transition: CalculatedTransition; + transition: Transition; }; // Cache @@ -129,7 +129,7 @@ export default async function parseConfig({ if (videoLayers.length === 0) assert(duration, `Duration parameter is required for videoless clip ${clipIndex}`); - const transition = calcTransition(userTransition, clipIndex === clips.length - 1); + const transition = new Transition(userTransition, clipIndex === clips.length - 1); let layersOut = flatMap( await pMap( @@ -289,14 +289,9 @@ export default async function parseConfig({ } totalClipDuration += clip.duration - safeTransitionDuration; + clip.transition.duration = safeTransitionDuration; - return { - ...clip, - transition: { - ...clip.transition, - duration: safeTransitionDuration, - }, - }; + return clip; }); // Audio can either come from `audioFilePath`, `audio` or from "detached" audio layers from clips diff --git a/src/sources/news-title.ts b/src/sources/news-title.ts index 34ba96d5..0f0534f3 100644 --- a/src/sources/news-title.ts +++ b/src/sources/news-title.ts @@ -1,6 +1,6 @@ import { FabricText, Rect } from "fabric/node"; import { defineFrameSource } from "../api/index.js"; -import { easeOutExpo } from "../transitions.js"; +import { easeOutExpo } from "../easings.js"; import type { NewsTitleLayer } from "../types.js"; import { defaultFontFamily } from "../util.js"; diff --git a/src/sources/slide-in-text.ts b/src/sources/slide-in-text.ts index 67b7e409..596b0389 100644 --- a/src/sources/slide-in-text.ts +++ b/src/sources/slide-in-text.ts @@ -1,6 +1,6 @@ import * as fabric from "fabric/node"; import { defineFrameSource } from "../api/index.js"; -import { easeInOutCubic } from "../transitions.js"; +import { easeInOutCubic } from "../easings.js"; import type { SlideInTextLayer } from "../types.js"; import { defaultFontFamily, getFrameByKeyFrames, getPositionProps } from "../util.js"; diff --git a/src/sources/subtitle.ts b/src/sources/subtitle.ts index c406a278..a9d3d0a3 100644 --- a/src/sources/subtitle.ts +++ b/src/sources/subtitle.ts @@ -1,6 +1,6 @@ import { Rect, Textbox } from "fabric/node"; import { defineFrameSource } from "../api/index.js"; -import { easeOutExpo } from "../transitions.js"; +import { easeOutExpo } from "../easings.js"; import type { SubtitleLayer } from "../types.js"; import { defaultFontFamily } from "../util.js"; diff --git a/src/transition.ts b/src/transition.ts new file mode 100644 index 00000000..e399a8f9 --- /dev/null +++ b/src/transition.ts @@ -0,0 +1,242 @@ +import assert from "assert"; +import GL from "gl"; +import createBuffer from "gl-buffer"; +import createTexture from "gl-texture2d"; +import glTransition from "gl-transition"; +import glTransitions, { type GlTransition } from "gl-transitions"; +import ndarray from "ndarray"; +import type { EasingFunction } from "./easings.js"; +import * as easings from "./easings.js"; + +const { default: createTransition } = glTransition; + +const TransitionAliases: Record> = { + "directional-left": { name: "directional", easing: "easeOutExpo", params: { direction: [1, 0] } }, + "directional-right": { + name: "directional", + easing: "easeOutExpo", + params: { direction: [-1, 0] }, + }, + "directional-down": { name: "directional", easing: "easeOutExpo", params: { direction: [0, 1] } }, + "directional-up": { name: "directional", easing: "easeOutExpo", params: { direction: [0, -1] } }, +}; + +const AllTransitions = [...glTransitions.map((t) => t.name), ...Object.keys(TransitionAliases)]; + +/** + * @see [Transition types]{@link https://github.com/mifi/editly#transition-types} + */ +export type TransitionType = + | "directional-left" + | "directional-right" + | "directional-up" + | "directional-down" + | "random" + | "dummy" + | string; + +/** + * WARNING: Undocumented feature! + */ +export type GLTextureLike = { + bind: (unit: number) => number; + shape: [number, number]; +}; + +/** + * WARNING: Undocumented feature! + */ +export interface TransitionParams { + /** + * WARNING: Undocumented feature! + */ + [key: string]: number | boolean | GLTextureLike | number[]; +} + +export type RunTransitionOptions = { + fromFrame: Buffer; + toFrame: Buffer; + progress: number; + transitionName?: string; + transitionParams?: TransitionParams; +}; + +/** + * @see [Curve types]{@link https://trac.ffmpeg.org/wiki/AfadeCurves} + */ +export type CurveType = + | "tri" + | "qsin" + | "hsin" + | "esin" + | "log" + | "ipar" + | "qua" + | "cub" + | "squ" + | "cbr" + | "par" + | "exp" + | "iqsin" + | "ihsin" + | "dese" + | "desi" + | "losi" + | "nofade" + | string; + +export type Easing = keyof typeof easings; + +export interface TransitionOptions { + /** + * Transition duration. + * + * @default 0.5 + */ + duration?: number; + + /** + * Transition type. + * + * @default 'random' + * @see [Transition types]{@link https://github.com/mifi/editly#transition-types} + */ + name?: TransitionType; + + /** + * [Fade out curve]{@link https://trac.ffmpeg.org/wiki/AfadeCurves} in audio cross fades. + * + * @default 'tri' + */ + audioOutCurve?: CurveType; + + /** + * [Fade in curve]{@link https://trac.ffmpeg.org/wiki/AfadeCurves} in audio cross fades. + * + * @default 'tri' + */ + audioInCurve?: CurveType; + + /** + * WARNING: Undocumented feature! + */ + easing?: Easing | null; + + /** + * WARNING: Undocumented feature! + */ + params?: TransitionParams; +} + +function getRandomTransition() { + return AllTransitions[Math.floor(Math.random() * AllTransitions.length)]; +} + +export class Transition { + name?: string; + duration: number; + params?: TransitionParams; + easingFunction: EasingFunction; + source?: GlTransition; + + constructor(options?: TransitionOptions | null, isLastClip: boolean = false) { + if (!options || isLastClip) options = { duration: 0 }; + + assert(typeof options === "object", "Transition must be an object"); + assert( + options.duration === 0 || options.name, + "Please specify transition name or set duration to 0", + ); + + if (options.name === "random") options.name = getRandomTransition(); + const aliasedTransition = options.name && TransitionAliases[options.name]; + if (aliasedTransition) Object.assign(options, aliasedTransition); + + this.duration = options.duration ?? 0; + this.name = options.name; + this.params = options.params; + this.easingFunction = + options.easing && easings[options.easing] ? easings[options.easing] : easings.linear; + + // A dummy transition can be used to have an audio transition without a video transition + // (Note: You will lose a portion from both clips due to overlap) + if (this.name && this.name !== "dummy") { + this.source = glTransitions.find( + ({ name }) => name.toLowerCase() === this.name?.toLowerCase(), + ); + assert(this.source, `Transition not found: ${this.name}`); + } + } + + create({ width, height, channels }: { width: number; height: number; channels: number }) { + const gl = GL(width, height); + const resizeMode = "stretch"; + + if (!gl) { + throw new Error( + "gl returned null, this probably means that some dependencies are not installed. See README.", + ); + } + + function convertFrame(buf: Buffer) { + // @see https://github.com/stackgl/gl-texture2d/issues/16 + return ndarray(buf, [width, height, channels], [channels, width * channels, 1]); + } + + return ({ fromFrame, toFrame, progress }: RunTransitionOptions) => { + if (!this.source) { + // No transition found, just switch frames half way through the transition. + return this.easingFunction(progress) > 0.5 ? toFrame : fromFrame; + } + + const buffer = createBuffer(gl, [-1, -1, -1, 4, 4, -1], gl.ARRAY_BUFFER, gl.STATIC_DRAW); + let transition; + + try { + transition = createTransition(gl, this.source, { resizeMode }); + + gl.clear(gl.COLOR_BUFFER_BIT); + + // console.time('runTransitionOnFrame internal'); + const fromFrameNdArray = convertFrame(fromFrame); + const textureFrom = createTexture(gl, fromFrameNdArray); + textureFrom.minFilter = gl.LINEAR; + textureFrom.magFilter = gl.LINEAR; + + // console.timeLog('runTransitionOnFrame internal'); + const toFrameNdArray = convertFrame(toFrame); + const textureTo = createTexture(gl, toFrameNdArray); + textureTo.minFilter = gl.LINEAR; + textureTo.magFilter = gl.LINEAR; + + buffer.bind(); + transition.draw( + this.easingFunction(progress), + textureFrom, + textureTo, + gl.drawingBufferWidth, + gl.drawingBufferHeight, + this.params, + ); + + textureFrom.dispose(); + textureTo.dispose(); + + // console.timeLog('runTransitionOnFrame internal'); + + const outArray = Buffer.allocUnsafe(width * height * 4); + gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, outArray); + + // console.timeEnd('runTransitionOnFrame internal'); + + return outArray; + + // require('fs').writeFileSync(`${new Date().getTime()}.raw`, outArray); + // Testing: ffmpeg -f rawvideo -vcodec rawvideo -pix_fmt rgba -s 2166x1650 -i 1586619627191.raw -vf format=yuv420p -vcodec libx264 -y out.mp4 + } finally { + buffer.dispose(); + if (transition) transition.dispose(); + } + }; + } +} diff --git a/src/transitions.ts b/src/transitions.ts deleted file mode 100644 index 213970f9..00000000 --- a/src/transitions.ts +++ /dev/null @@ -1,89 +0,0 @@ -import assert from "assert"; -import type { Transition } from "./types.js"; - -export type EasingFunction = (progress: number) => number; - -export type CalculatedTransition = Transition & { - duration: number; - easingFunction: EasingFunction; -}; - -const randomTransitionsSet = [ - "fade", - "fadegrayscale", - "directionalwarp", - "crosswarp", - "dreamyzoom", - "burn", - "crosszoom", - "simplezoom", - "linearblur", - "directional-left", - "directional-right", - "directional-up", - "directional-down", -]; - -function getRandomTransition() { - return randomTransitionsSet[Math.floor(Math.random() * randomTransitionsSet.length)]; -} - -// https://easings.net/ - -export function easeOutExpo(x: number) { - return x === 1 ? 1 : 1 - 2 ** (-10 * x); -} - -export function easeInOutCubic(x: number) { - return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2; -} - -export function linear(x: number) { - return x; -} - -function getTransitionEasingFunction( - easing: string | null | undefined, - transitionName?: string, -): EasingFunction { - if (easing !== null) { - // FIXME[TS]: `easing` always appears to be null or undefined, so this never gets called - if (easing) return { easeOutExpo }[easing] || linear; - if (transitionName === "directional") return easeOutExpo; - } - return linear; -} - -const TransitionAliases: Record> = { - "directional-left": { name: "directional", params: { direction: [1, 0] } }, - "directional-right": { name: "directional", params: { direction: [-1, 0] } }, - "directional-down": { name: "directional", params: { direction: [0, 1] } }, - "directional-up": { name: "directional", params: { direction: [0, -1] } }, -}; - -export function calcTransition( - transition: Transition | null | undefined, - isLastClip: boolean, -): CalculatedTransition { - if (!transition || isLastClip) return { duration: 0, easingFunction: linear }; - - assert( - !transition.duration || transition.name, - "Please specify transition name or set duration to 0", - ); - - if (transition.name === "random" && transition.duration) { - transition = { ...transition, name: getRandomTransition() }; - } - - const aliasedTransition = transition.name ? TransitionAliases[transition.name] : undefined; - if (aliasedTransition) { - transition = { ...transition, ...aliasedTransition }; - } - - return { - ...transition, - duration: transition.duration || 0, - easingFunction: getTransitionEasingFunction(transition.easing, transition.name), - }; -} diff --git a/src/types.ts b/src/types.ts index 4d390b0c..51e3cc2b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ import type { Canvas } from "canvas"; import type * as Fabric from "fabric/node"; import { ConfigurationOptions } from "./configuration.js"; +import { TransitionOptions } from "./transition.js"; /** Little utility */ export type OptionalPromise = Promise | T; @@ -67,101 +68,6 @@ export type Position = | "bottom-right" | PositionObject; -/** - * @see [Curve types]{@link https://trac.ffmpeg.org/wiki/AfadeCurves} - */ -export type CurveType = - | "tri" - | "qsin" - | "hsin" - | "esin" - | "log" - | "ipar" - | "qua" - | "cub" - | "squ" - | "cbr" - | "par" - | "exp" - | "iqsin" - | "ihsin" - | "dese" - | "desi" - | "losi" - | "nofade" - | string; - -/** - * @see [Transition types]{@link https://github.com/mifi/editly#transition-types} - */ -export type TransitionType = - | "directional-left" - | "directional-right" - | "directional-up" - | "directional-down" - | "random" - | "dummy" - | string; - -/** - * WARNING: Undocumented feature! - */ -export type GLTextureLike = { - bind: (unit: number) => number; - shape: [number, number]; -}; - -/** - * WARNING: Undocumented feature! - */ -export interface TransitionParams { - /** - * WARNING: Undocumented feature! - */ - [key: string]: number | boolean | GLTextureLike | number[]; -} - -export interface Transition { - /** - * Transition duration. - * - * @default 0.5 - */ - duration?: number; - - /** - * Transition type. - * - * @default 'random' - * @see [Transition types]{@link https://github.com/mifi/editly#transition-types} - */ - name?: TransitionType; - - /** - * [Fade out curve]{@link https://trac.ffmpeg.org/wiki/AfadeCurves} in audio cross fades. - * - * @default 'tri' - */ - audioOutCurve?: CurveType; - - /** - * [Fade in curve]{@link https://trac.ffmpeg.org/wiki/AfadeCurves} in audio cross fades. - * - * @default 'tri' - */ - audioInCurve?: CurveType; - - /** - * WARNING: Undocumented feature! - */ - easing?: string | null; - - /** - * WARNING: Undocumented feature! - */ - params?: TransitionParams; -} - /** * @see [Arbitrary audio tracks]{@link https://github.com/mifi/editly#arbitrary-audio-tracks} */ @@ -797,7 +703,7 @@ export interface Clip { * Defaults to `defaults.transition`. * Set to `null` to disable transitions. */ - transition?: Transition | null; + transition?: TransitionOptions | null; } export interface DefaultLayerOptions { @@ -843,7 +749,7 @@ export interface DefaultOptions { * An object describing the default transition. * Set to `null` to disable transitions. */ - transition?: Transition | null; + transition?: TransitionOptions | null; } /** diff --git a/src/types/gl-transitions.d.ts b/src/types/gl-transitions.d.ts index dea80f33..817809c4 100644 --- a/src/types/gl-transitions.d.ts +++ b/src/types/gl-transitions.d.ts @@ -1,5 +1,5 @@ declare module "gl-transitions" { - type GlTransition = { + export type GlTransition = { name: string; author: string; license: string; diff --git a/test/transition.test.ts b/test/transition.test.ts new file mode 100644 index 00000000..8c1bf1f4 --- /dev/null +++ b/test/transition.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; +import { Transition } from "../src/transition.js"; + +describe("constructor", () => { + test("null", () => { + const transition = new Transition(null); + expect(transition.duration).toBe(0); + }); + + test("random transition", () => { + const transition = new Transition({ name: "random", duration: 1 }); + expect(transition.name).toBeDefined(); + expect(transition.name).not.toBe("random"); + }); + + test("directional-left", () => { + const transition = new Transition({ name: "directional-left" }); + expect(transition.name).toBe("directional"); + expect(transition.params).toEqual({ direction: [1, 0] }); + }); + + test("raises error with unknown transition", () => { + expect(() => new Transition({ name: "unknown", duration: 1 })).toThrow( + "Transition not found: unknown", + ); + }); +}); + +describe("easingFunction", () => { + test("linear", () => { + const transition = new Transition({ name: "fade", easing: "linear" }); + expect(transition.easingFunction(0.5)).toBe(0.5); + }); + + test("easeOutExpo", () => { + const transition = new Transition({ name: "fade", easing: "easeOutExpo" }); + expect(transition.easingFunction(0.2)).toBe(0.75); + }); + + test("easeInOutCubic", () => { + const transition = new Transition({ name: "fade", easing: "easeInOutCubic" }); + expect(transition.easingFunction(0.2)).toBeCloseTo(0.032, 3); + expect(transition.easingFunction(0.5)).toBe(0.5); + expect(transition.easingFunction(0.8)).toBeCloseTo(0.968, 3); + }); +});