diff options
Diffstat (limited to 'apps/web/src/fps')
| -rw-r--r-- | apps/web/src/fps/__tests__/fps.test.ts | 65 | ||||
| -rw-r--r-- | apps/web/src/fps/defaults.ts | 3 | ||||
| -rw-r--r-- | apps/web/src/fps/presets.ts | 7 | ||||
| -rw-r--r-- | apps/web/src/fps/utils.ts | 95 |
4 files changed, 170 insertions, 0 deletions
diff --git a/apps/web/src/fps/__tests__/fps.test.ts b/apps/web/src/fps/__tests__/fps.test.ts new file mode 100644 index 00000000..e025a188 --- /dev/null +++ b/apps/web/src/fps/__tests__/fps.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { + getHighestImportedVideoFps, + getRaisedProjectFpsForImportedMedia, +} from "@/fps/utils"; + +describe("getHighestImportedVideoFps", () => { + test("returns the highest valid video fps", () => { + expect( + getHighestImportedVideoFps({ + mediaAssets: [ + { type: "audio" }, + { type: "video", fps: 30 }, + { type: "image", fps: 120 }, + { type: "video", fps: 60 }, + ], + }), + ).toBe(60); + }); + + test("ignores missing and invalid fps values", () => { + expect( + getHighestImportedVideoFps({ + mediaAssets: [ + { type: "video" }, + { type: "video", fps: 0 }, + { type: "video", fps: -10 }, + { type: "audio", fps: 120 }, + ], + }), + ).toBeNull(); + }); +}); + +describe("getRaisedProjectFpsForImportedMedia", () => { + test("raises the project fps to match a higher-fps import", () => { + expect( + getRaisedProjectFpsForImportedMedia({ + currentFps: { numerator: 30, denominator: 1 }, + importedAssets: [{ type: "video", fps: 60 }], + }), + ).toEqual({ numerator: 60, denominator: 1 }); + }); + + test("does not lower the project fps for lower-fps imports", () => { + expect( + getRaisedProjectFpsForImportedMedia({ + currentFps: { numerator: 60, denominator: 1 }, + importedAssets: [{ type: "video", fps: 10 }], + }), + ).toBeNull(); + }); + + test("ignores non-video imports", () => { + expect( + getRaisedProjectFpsForImportedMedia({ + currentFps: { numerator: 30, denominator: 1 }, + importedAssets: [ + { type: "image", fps: 60 }, + { type: "audio", fps: 120 }, + ], + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/fps/defaults.ts b/apps/web/src/fps/defaults.ts new file mode 100644 index 00000000..66bf0b1c --- /dev/null +++ b/apps/web/src/fps/defaults.ts @@ -0,0 +1,3 @@ +import type { FrameRate } from "opencut-wasm"; + +export const DEFAULT_FPS: FrameRate = { numerator: 30, denominator: 1 }; diff --git a/apps/web/src/fps/presets.ts b/apps/web/src/fps/presets.ts new file mode 100644 index 00000000..b09920d4 --- /dev/null +++ b/apps/web/src/fps/presets.ts @@ -0,0 +1,7 @@ +export const FPS_PRESETS = [ + { value: "24", label: "24 fps" }, + { value: "25", label: "25 fps" }, + { value: "30", label: "30 fps" }, + { value: "60", label: "60 fps" }, + { value: "120", label: "120 fps" }, +] as const; diff --git a/apps/web/src/fps/utils.ts b/apps/web/src/fps/utils.ts new file mode 100644 index 00000000..e30e263a --- /dev/null +++ b/apps/web/src/fps/utils.ts @@ -0,0 +1,95 @@ +import type { FrameRate } from "opencut-wasm"; +import type { MediaAsset } from "@/media/types"; + +type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">; + +const STANDARD_FRAME_RATES: Array<{ value: number; rate: FrameRate }> = [ + { value: 24_000 / 1_001, rate: { numerator: 24_000, denominator: 1_001 } }, + { value: 24, rate: { numerator: 24, denominator: 1 } }, + { value: 25, rate: { numerator: 25, denominator: 1 } }, + { value: 30_000 / 1_001, rate: { numerator: 30_000, denominator: 1_001 } }, + { value: 30, rate: { numerator: 30, denominator: 1 } }, + { value: 48, rate: { numerator: 48, denominator: 1 } }, + { value: 50, rate: { numerator: 50, denominator: 1 } }, + { value: 60_000 / 1_001, rate: { numerator: 60_000, denominator: 1_001 } }, + { value: 60, rate: { numerator: 60, denominator: 1 } }, + { value: 120, rate: { numerator: 120, denominator: 1 } }, +]; + +const STANDARD_FRAME_RATE_TOLERANCE = 0.01; + +export function frameRateToFloat(rate: FrameRate): number { + return rate.numerator / rate.denominator; +} + +export function frameRatesEqual(a: FrameRate, b: FrameRate): boolean { + return a.numerator === b.numerator && a.denominator === b.denominator; +} + +export function floatToFrameRate(fps: number): FrameRate { + const standard = STANDARD_FRAME_RATES.find( + (candidate) => Math.abs(fps - candidate.value) <= STANDARD_FRAME_RATE_TOLERANCE, + ); + if (standard) return standard.rate; + + if (Number.isInteger(fps)) { + return { numerator: fps, denominator: 1 }; + } + + const ARBITRARY_DENOMINATOR = 1_000_000; + const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR); + const divisor = gcd(scaledNumerator, ARBITRARY_DENOMINATOR); + return { + numerator: scaledNumerator / divisor, + denominator: ARBITRARY_DENOMINATOR / divisor, + }; +} + +function gcd(left: number, right: number): number { + let a = Math.abs(left); + let b = Math.abs(right); + while (b !== 0) { + const remainder = a % b; + a = b; + b = remainder; + } + return a || 1; +} + +export function getHighestImportedVideoFps({ + mediaAssets, +}: { + mediaAssets: MediaAssetFpsInput[]; +}): number | null { + let highestFps: number | null = null; + + for (const asset of mediaAssets) { + const fps = asset.fps ?? Number.NaN; + if (asset.type !== "video") continue; + if (!Number.isFinite(fps) || fps <= 0) continue; + + highestFps = highestFps === null ? fps : Math.max(highestFps, fps); + } + + return highestFps; +} + +export function getRaisedProjectFpsForImportedMedia({ + currentFps, + importedAssets, +}: { + currentFps: FrameRate; + importedAssets: MediaAssetFpsInput[]; +}): FrameRate | null { + const highestImportedVideoFps = getHighestImportedVideoFps({ + mediaAssets: importedAssets, + }); + + const currentFpsFloat = frameRateToFloat(currentFps); + + if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFpsFloat) { + return null; + } + + return floatToFrameRate(highestImportedVideoFps); +} |
