diff --git a/.changeset/tall-loops-bathe.md b/.changeset/tall-loops-bathe.md new file mode 100644 index 00000000..573b5e06 --- /dev/null +++ b/.changeset/tall-loops-bathe.md @@ -0,0 +1,5 @@ +--- +"@imgproxy/imgproxy-js-core": minor +--- + +Add support for [video_thumbnail_animation property](https://docs.imgproxy.net/usage/processing#video-thumbnail-animation) diff --git a/src/options/index.ts b/src/options/index.ts index deed9caf..c8720feb 100644 --- a/src/options/index.ts +++ b/src/options/index.ts @@ -58,6 +58,7 @@ export * as style from "./style"; export * as trim from "./trim"; export * as unsharpMasking from "./unsharpMasking"; export * as videoThumbnailSecond from "../optionsShared/videoThumbnailSecond"; +export * as videoThumbnailAnimation from "./videoThumbnailAnimation"; export * as watermark from "./watermark"; export * as watermarkShadow from "./watermarkShadow"; export * as watermarkSize from "./watermarkSize"; diff --git a/src/options/videoThumbnailAnimation.ts b/src/options/videoThumbnailAnimation.ts new file mode 100644 index 00000000..d3dff070 --- /dev/null +++ b/src/options/videoThumbnailAnimation.ts @@ -0,0 +1,55 @@ +import { VideoThumbnailAnimationOptionsPartial } from "../types/videoThumbnailAnimation"; +import { guardIsNotNum, guardIsUndef, normalizeBoolean } from "../utils"; + +function getOpts(options: VideoThumbnailAnimationOptionsPartial) { + return options.video_thumbnail_animation || options.vta; +} + +function test(options: VideoThumbnailAnimationOptionsPartial) { + return Boolean(getOpts(options)); +} + +function build(options: VideoThumbnailAnimationOptionsPartial) { + const vta = getOpts(options); + guardIsUndef(vta, "video_thumbnail_animation"); + + guardIsNotNum(vta.step, "video_thumbnail_animation.step"); + guardIsNotNum(vta.delay, "video_thumbnail_animation.delay"); + guardIsNotNum(vta.frames, "video_thumbnail_animation.frames"); + + if (vta.frame_width !== undefined) { + guardIsNotNum(vta.frame_width, "video_thumbnail_animation.frame_width"); + } + + if (vta.frame_height !== undefined) { + guardIsNotNum(vta.frame_height, "video_thumbnail_animation.frame_height"); + } + + const parts = []; + + // Add boolean flags with proper normalization + const extend_frame = + vta.extend_frame !== undefined + ? normalizeBoolean(vta.extend_frame) + : undefined; + const trim = vta.trim !== undefined ? normalizeBoolean(vta.trim) : undefined; + const fill = vta.fill !== undefined ? normalizeBoolean(vta.fill) : undefined; + + parts.push(extend_frame, trim, fill); + + // Add focus coordinates if fill is true and coordinates are defined + if (fill === "t") { + parts.push(vta.focus_x, vta.focus_y); + } + + // Remove trailing undefined values + while (parts.length > 0 && parts[parts.length - 1] === undefined) { + parts.pop(); + } + + const optionsPart = parts.length > 0 ? `:${parts.join(":")}` : ""; + + return `vta:${vta.step}:${vta.delay}:${vta.frames}:${vta.frame_width}:${vta.frame_height}${optionsPart}`; +} + +export { test, build }; diff --git a/src/types/index.ts b/src/types/index.ts index 1b12b5f4..415f9f68 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -57,6 +57,7 @@ import type { StripMetadataOptionsPartial } from "./stripMetadata"; import type { StyleOptionsPartial } from "./style"; import type { TrimOptionsPartial } from "./trim"; import type { UnsharpMaskingOptionsPartial } from "./unsharpMasking"; +import type { VideoThumbnailAnimationOptionsPartial } from "./videoThumbnailAnimation"; import type { VideoThumbnailSecondOptionsPartial } from "../typesShared/videoThumbnailSecond"; import type { WatermarkOptionsPartial } from "./watermark"; import type { WatermarkRotateOptionsPartial } from "./watermarkRotate"; @@ -128,6 +129,7 @@ export type Options = AdjustOptionsPartial & StyleOptionsPartial & TrimOptionsPartial & UnsharpMaskingOptionsPartial & + VideoThumbnailAnimationOptionsPartial & VideoThumbnailSecondOptionsPartial & WatermarkOptionsPartial & WatermarkShadowOptionsPartial & diff --git a/src/types/videoThumbnailAnimation.ts b/src/types/videoThumbnailAnimation.ts new file mode 100644 index 00000000..1229028d --- /dev/null +++ b/src/types/videoThumbnailAnimation.ts @@ -0,0 +1,63 @@ +/** + * When step is not 0, imgproxy will generate an animated image using the source video frames. + */ +interface VideoThumbnailAnimation { + /** + * step: the step of timestamp (in seconds) between video frames that should be used for the animation generation: + * When step value is positive, imgproxy will use it as an absolute value + * When step value is negative, imgproxy will calculate the actual step as video_duration / frames + */ + step: number; + + /** + * the delay between animation frames in milliseconds + */ + delay: number; + + /** + * the number of animation frames + */ + frames: number; + + /** + * the width and height of animation frames. imgproxy will resize each used frame to fit the provided size + */ + frame_width: number; + frame_height: number; + + /** + * when set to 1, t or true, imgproxy will extend each animation frame to the requested size using a black background + */ + extend_frame?: boolean | 1 | string; + + /** + * when set to 1, t or true, imgproxy will trim the unused frames from the animation + */ + trim?: boolean | 1 | string; + + /** + * when set to 1, t or true, imgproxy will use the fill resizing type for the animation frames + */ + fill?: boolean | 1 | string; + + /** + * floating point numbers between 0 and 1 that define the coordinates of the center of the resulting animation frame + * (as in the fp gravity type). Treat 0 and 1 as right/left for x and top/bottom for y. + * Applicable only when fill is set. Default: 0.5:0.5 + */ + focus_x?: number; + focus_y?: number; +} +/** + * *Video thumbnail animation options*. **PRO feature** + * + * Allows generating an animated image using the source video frames. + * + * @see https://docs.imgproxy.net/usage/processing#video-thumbnail-animation + */ +interface VideoThumbnailAnimationOptionsPartial { + video_thumbnail_animation?: VideoThumbnailAnimation; + vta?: VideoThumbnailAnimation; +} + +export { VideoThumbnailAnimation, VideoThumbnailAnimationOptionsPartial }; diff --git a/tests/optionsBasic/videoThumbnailAnimation.test.ts b/tests/optionsBasic/videoThumbnailAnimation.test.ts new file mode 100644 index 00000000..bfd5e1ad --- /dev/null +++ b/tests/optionsBasic/videoThumbnailAnimation.test.ts @@ -0,0 +1,184 @@ +import { assertType, describe, expect, expectTypeOf, it } from "vitest"; +import { test, build } from "../../src/options/videoThumbnailAnimation"; +import { VideoThumbnailAnimation } from "../../src/types/videoThumbnailAnimation"; +import { Options } from "../../src/types"; + +describe("videoThumbnailAnimation", () => { + describe("test", () => { + it("should return true if video_thumbnail_animation option is defined", () => { + const vta: VideoThumbnailAnimation = { + step: 1, + delay: 100, + frames: 10, + frame_width: 320, + frame_height: 240, + }; + expect(test({ video_thumbnail_animation: vta })).toEqual(true); + }); + + it("should return true if vta option is defined", () => { + const vta: VideoThumbnailAnimation = { + step: 2, + delay: 200, + frames: 5, + frame_width: 320, + frame_height: 240, + }; + expect(test({ vta })).toEqual(true); + }); + + it("should return false if video_thumbnail_animation option is undefined", () => { + expect(test({})).toEqual(false); + }); + }); + + describe("build", () => { + it("should throw an error if video_thumbnail_animation option is undefined", () => { + expect(() => build({})).toThrow( + "video_thumbnail_animation option is undefined" + ); + }); + + it("should build basic vta option with required parameters", () => { + const vta: VideoThumbnailAnimation = { + step: 1.5, + delay: 100, + frames: 10, + frame_width: 320, + frame_height: 240, + }; + expect(build({ video_thumbnail_animation: vta })).toEqual( + "vta:1.5:100:10:320:240" + ); + }); + + it("should build vta option with extend_frame as boolean", () => { + const vta: VideoThumbnailAnimation = { + step: 1, + delay: 100, + frames: 10, + frame_width: 320, + frame_height: 240, + extend_frame: true, + }; + expect(build({ vta })).toEqual("vta:1:100:10:320:240:t"); + }); + + it("should build vta option with extend_frame as number", () => { + const vta: VideoThumbnailAnimation = { + step: 1, + delay: 100, + frames: 10, + frame_width: 320, + frame_height: 240, + extend_frame: 1, + }; + expect(build({ vta })).toEqual("vta:1:100:10:320:240:t"); + }); + + it("should build vta option with extend_frame as string", () => { + const vta: VideoThumbnailAnimation = { + step: 1, + delay: 100, + frames: 10, + frame_width: 320, + frame_height: 240, + extend_frame: "t", + }; + expect(build({ vta })).toEqual("vta:1:100:10:320:240:t"); + }); + + it("should build vta option with trim flag", () => { + const vta: VideoThumbnailAnimation = { + step: 1, + delay: 100, + frames: 10, + frame_width: 320, + frame_height: 240, + trim: true, + }; + expect(build({ vta })).toEqual("vta:1:100:10:320:240::t"); + }); + + it("should build vta option with fill flag", () => { + const vta: VideoThumbnailAnimation = { + step: 1, + delay: 100, + frames: 10, + frame_width: 320, + frame_height: 240, + fill: true, + }; + expect(build({ vta })).toEqual("vta:1:100:10:320:240:::t"); + }); + + it("should build vta option with focus coordinates", () => { + const vta: VideoThumbnailAnimation = { + step: 1, + delay: 100, + frames: 10, + frame_width: 320, + frame_height: 240, + fill: true, + focus_x: 0.3, + focus_y: 0.7, + }; + expect(build({ vta })).toEqual("vta:1:100:10:320:240:::t:0.3:0.7"); + }); + + it("should build vta option with all parameters", () => { + const vta: VideoThumbnailAnimation = { + step: -1, + delay: 200, + frames: 15, + frame_width: 640, + frame_height: 480, + extend_frame: true, + trim: true, + fill: true, + focus_x: 0.5, + focus_y: 0.5, + }; + expect(build({ video_thumbnail_animation: vta })).toEqual( + "vta:-1:200:15:640:480:t:t:t:0.5:0.5" + ); + }); + }); +}); + +describe("Check `video_thumbnail_animation` type declarations", () => { + it("video_thumbnail_animation option should have correct type", () => { + expectTypeOf(build).parameter(0).toEqualTypeOf<{ + video_thumbnail_animation?: VideoThumbnailAnimation; + vta?: VideoThumbnailAnimation; + }>(); + expectTypeOf(build).returns.toEqualTypeOf(); + }); + + it("check TS type declaration", () => { + assertType({ + video_thumbnail_animation: { + step: 1, + delay: 100, + frames: 10, + frame_width: 320, + frame_height: 240, + }, + }); + + assertType({ + vta: { + step: 1, + delay: 100, + frames: 10, + frame_width: 320, + frame_height: 240, + extend_frame: true, + trim: true, + fill: true, + focus_x: 0.5, + focus_y: 0.5, + }, + }); + }); +});