# FPS Changer — open-source video engine

The video engine is self-hosted. It does not send video files to a server or load code from a third-party CDN at runtime. It uses FFmpeg to decode a local file, duplicate or drop frames, and encode a new H.264/AAC MP4. Re-encoding can change quality and file size. It does not perform AI interpolation.

## Exact packages and licenses

| Component | Version | License | Local license text | Published package |
| --- | --- | --- | --- | --- |
| `@ffmpeg/ffmpeg` browser wrapper | 0.12.15 | MIT | [MIT license](./FFMPEG-WASM-LICENSE.txt) | [Official npm archive](https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz) |
| `@ffmpeg/core` single-thread core | 0.12.10 | GPL-2.0-or-later | [GNU GPL version 2](./FFMPEG-CORE-GPLv2.txt) | [Official npm archive](https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz) |

The core includes FFmpeg and GPL-licensed components such as x264 and x265. The core is **not** covered by the wrapper's MIT license. Upstream third-party libraries retain their own copyright and license notices.

The wrapper's ESM JavaScript and the core JavaScript are unmodified copies from those archives. The core WebAssembly is unmodified and gzip-compressed for transport. The browser decompresses it locally. `converter.js` is FPS Changer's integration code, separate from the upstream files.

## Corresponding source and build instructions

The upstream source release containing wrapper 0.12.15 and core 0.12.10 is `v12.15`, commit `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`:

- [Complete ffmpeg.wasm source archive](https://github.com/ffmpegwasm/ffmpeg.wasm/archive/71aa99d37c02a7b4c435275ca9ef50e612f6efa1.tar.gz)
- [Wrapper source](https://github.com/ffmpegwasm/ffmpeg.wasm/tree/71aa99d37c02a7b4c435275ca9ef50e612f6efa1/packages/ffmpeg)
- [Core package version and license](https://github.com/ffmpegwasm/ffmpeg.wasm/blob/71aa99d37c02a7b4c435275ca9ef50e612f6efa1/packages/core/package.json)
- [Core build Dockerfile with dependency source URLs and versions](https://github.com/ffmpegwasm/ffmpeg.wasm/blob/71aa99d37c02a7b4c435275ca9ef50e612f6efa1/Dockerfile)
- [Build scripts](https://github.com/ffmpegwasm/ffmpeg.wasm/tree/71aa99d37c02a7b4c435275ca9ef50e612f6efa1/build), [Makefile](https://github.com/ffmpegwasm/ffmpeg.wasm/blob/71aa99d37c02a7b4c435275ca9ef50e612f6efa1/Makefile), and [FFmpeg WebAssembly bindings and patches](https://github.com/ffmpegwasm/ffmpeg.wasm/tree/71aa99d37c02a7b4c435275ca9ef50e612f6efa1/src)

The upstream build uses Emscripten 3.1.40 and FFmpeg n5.1.4. Its dependency sources are listed below exactly as selected by the Dockerfile. Some upstream entries use branch names rather than immutable commit hashes; the npm archives themselves are pinned and their hashes are recorded in `manifest.json`.

| Dependency | Upstream source selected by the build (includes its own license notices) |
| --- | --- |
| FFmpeg | [n5.1.4](https://github.com/FFmpeg/FFmpeg/tree/n5.1.4) |
| x264 | [ffmpegwasm fork, 4-cores](https://github.com/ffmpegwasm/x264/tree/4-cores) |
| x265 | [ffmpegwasm fork, 3.4](https://github.com/ffmpegwasm/x265/tree/3.4) |
| libvpx | [v1.13.1](https://github.com/ffmpegwasm/libvpx/tree/v1.13.1) |
| LAME | [ffmpegwasm fork, master](https://github.com/ffmpegwasm/lame/tree/master) |
| Ogg | [v1.3.4](https://github.com/ffmpegwasm/Ogg/tree/v1.3.4) |
| Theora | [v1.1.1](https://github.com/ffmpegwasm/theora/tree/v1.1.1) |
| Opus | [v1.3.1](https://github.com/ffmpegwasm/opus/tree/v1.3.1) |
| Vorbis | [v1.3.3](https://github.com/ffmpegwasm/vorbis/tree/v1.3.3) |
| zlib | [v1.2.11](https://github.com/ffmpegwasm/zlib/tree/v1.2.11) |
| WebP | [v1.3.2](https://github.com/ffmpegwasm/libwebp/tree/v1.3.2) |
| FreeType | [VER-2-10-4](https://github.com/ffmpegwasm/freetype2/tree/VER-2-10-4) |
| FriBidi | [v1.0.9](https://github.com/fribidi/fribidi/tree/v1.0.9) |
| HarfBuzz | [5.2.0](https://github.com/harfbuzz/harfbuzz/tree/5.2.0) |
| libass | [0.15.0](https://github.com/libass/libass/tree/0.15.0) |
| zimg | [release-3.0.5](https://github.com/sekrit-twc/zimg/tree/release-3.0.5) |

## Integration contract

Import `{ analyzeVideo, convertVideo, cancelConversion, LIMITS }` from `./converter.js`.

- `analyzeVideo(file, { onStatus, onProgress })` resolves with duration in seconds, display width/height after rotation, numeric FPS, an FPS label, codec, audio presence, optional VFR hint, and warnings.
- `convertVideo(file, { fps, onStatus, onProgress })` resolves with `{ blob, filename, metadata, warnings }`. Supported FPS strings are `24000/1001`, `24`, `25`, `30000/1001`, `30`, `50`, `60000/1001`, and `60`.
- `onStatus` receives English text. `onProgress` receives a monotonically increasing value between 0 and 1 for the current operation.
- `cancelConversion()` cancels loading, probing, or conversion. A cancelled promise rejects with `name: 'AbortError'` and `code: 'CANCELLED'`. Other errors expose user-readable messages and stable codes. A new operation can start after cancellation.
- Input limits: 52,428,800 bytes (50 MiB, presented in the UI as 50 MB), 60 seconds, a maximum long edge of 1920 and short edge of 1080. Portrait is supported. Odd pixel dimensions and non-right-angle rotation metadata are rejected to avoid silently resizing the image.
- MP4/MOV, WebM/MKV, and AVI container signatures are checked; ffprobe then checks actual video streams. Codec support depends on the bundled FFmpeg build.
- The first video and first audio track are retained. Subtitles and other tracks are omitted with warnings. AAC is re-encoded; silent input stays silent.
- Output validation checks H.264 video, AAC audio when present, orientation, dimensions, duration, target frame rate, actual video packet count, and presentation timestamp spacing. This is not a perceptual audio/video synchronization test.
- Each operation uses its own worker. Success, errors and cancellation terminate it and release its filesystem and WebAssembly heap. UI-created download and preview object URLs remain the UI's responsibility.
- Required browser APIs: WebAssembly, module workers, Fetch, Blob streams and DecompressionStream. All assets use the same origin. No SharedArrayBuffer or cross-origin isolation headers are needed for this single-thread build.
- The compressed core is 10,184,930 bytes and below a 25 MiB static-asset limit. Serve JS as JavaScript. Serve the `.gz` file as gzip bytes; automatic HTTP gzip decompression is also detected and supported.

The original demo is a four-second, 640 × 360, 30 FPS animation with a quiet synthetic tone, generated for FPS Changer. It is provided as `samples/sample.mp4` (50,604 bytes).
