Clip, cut and resize a video without uploading it. Drop an MP4, M4V or MOV and it is opened right here, in the tab, by your own browser — with a player and a timeline. Trim it, cut it, stack clips, choose the size and shape it comes out at, and save it back. It will compress a video without uploading it too, which is the phrase this app was founded on; it is simply no longer the whole of what it is for. No upload, no account, no size cap, no watermark, no queue — and no server ever sees a frame of it.
Part of the UNI·SIM Universal Apps — free,
open source, no account required. Served at opensource.unisim.co.uk/video.
your file ──▶ mp4read ──▶ VideoDecoder ──▶ canvas ──▶ VideoEncoder ──▶ mp4mux ──▶ your file
(on disk) (our code) (the browser) (scale) (the browser) (our code) (smaller)
│
the audio track: decodeAudioData ─▶ OfflineAudioContext ─▶ AudioEncoder
Every box on that line runs inside the tab. There is no server in the picture, because there is no picture of a server — nothing is fetched to make it work, so there is nothing to intercept and nothing to trust us about. Open the network tab and watch.
The codecs are the browser's own, through WebCodecs. What isn't in the
browser is the container: WebCodecs deals in frames, so nothing in the platform
will hand you an EncodedVideoChunk out of an MP4 or turn chunks back into one.
That part is ours, and it lives in
@unisim/media — shared with
Universal Converter, which is where
it was written.
No ffmpeg.wasm. No GPL. No 30 MB download. No CDN. No COOP/COEP. Those are
decisions, not oversights — see Don't at the bottom.
These are on the page in the app itself, not just here.
| Read MKV, WebM, AVI or WMV | ❌ No. MKV and WebM need a Matroska/EBML reader (a real piece of work, and the top of the backlog). AVI and WMV are refused permanently — the browser cannot decode MPEG-4 ASP or WMV3, so a reader would buy a different error message, not a working conversion. |
| Read fragmented MP4 | ❌ Not yet. Common from screen recorders and some phone apps, so the refusal fires more often than its rarity suggests. It is detected on drop and named, not parsed half-way. |
| Write WebM / VP9 | ❌ Not yet — and this is the highest-value gap, because it is also what would make Firefox a supported browser. WebCodecs already has the VP9 encoder; only the container is missing. |
| Edit | ✅ Trim, cut at the playhead, delete, slide clips along and between tracks, stack them (two clips slid over each other add a track rather than overwriting), intro/outro cards from an image or a video, and crossfade / fade-to-black between clips. A clip carries its own audio, so a cut splits picture and sound at the same instant by construction — see timeline.ts, the contract this editor and the renderer share. |
| Reframe | ✅ The output frame is chosen, not inherited: match the source (the default), 1920×1080, 1080×1920, 1080×1080, or a size you type. A source of a different shape is centred and the rest filled black — contain, never cover, so nothing is ever cropped away. Both edges are forced even (H.264 codes in 16×16 macroblocks and the renderer refuses an odd one up front). The preview canvas is the output frame and letterboxes through the same fitInside() maths, so what you see while editing is what comes out. An upright frame is capped at 540 px tall in the viewer (src/lib/layout.ts) instead of drawing ~1280 px and pushing the timeline off the screen — and the timeline narrows with it, because the needle is placed as a fraction of that width. |
| Crop, zoom-to-fill, per-clip position | ❌ No. Reframing is letterbox/pillarbox only. A fill mode would silently throw away picture that is visible in the preview; black bars are visible and fixable, a missing head is neither. |
| Filters, text, speed ramps, detached audio | ❌ No colour work, no titles or watermarks, no speed changes, and no separating a clip's sound from its picture. Transitions are crossfade and fade to black only. |
| Record | ❌ That is Universal Recorder. Adjacent products should not grow into each other. |
| Fall back to a server for big files | ⛔ Never. One "we'll process the big ones on our server" button would make every other sentence on the page false, and it would be discovered in five seconds by anyone with devtools open. If a hosted path ever exists it is a separate, explicitly-labelled product. |
| Run in Firefox | ❌ Firefox has no WebCodecs H.264 encoder. It is probed on arrival and said plainly, rather than failing after a long wait. Tested on Chrome and Edge. Safari 16.4+ ships WebCodecs and ought to work, but it has never been run there — an untested browser is not a supported one, so it is not claimed as one. |
The finished MP4 is assembled in memory before you save it, so there is a real limit — roughly a gigabyte or so of output on a desktop, less on a phone.
The counter-intuitive part, and the thing the UI exists to explain: a 2 GB source compressed to 300 MB is fine, while a 400 MB source re-encoded up to 5 GB is not. What binds is the file you are producing, not the file you dropped in.
So the app refuses before, rather than crashing after:
- Probe on drop. The header is read off disk —
File.slice()on a few dozen bytes per box — so a 4 GB file is understood in milliseconds without being loaded. - Predict the output, because that is what binds, and show the number.
- Arm or refuse the button, with the estimate written on the button itself.
- A refusal names the fix. Not "file too large" but "this would produce about 2.4 GB, which this browser can't hold in one piece — 1080p at Balanced produces about 600 MB; that fits", with a button that applies it.
- Show trouble at 20%, not at 100%. Progress is frames done / frames total plus a live byte counter against the prediction, and a remaining time measured from this device's real encoding speed.
This matters because the failure mode is tab death: an out-of-memory kill
fires no onerror, rejects no promise, and gives you nothing to catch. There is
no recovery path, so the pre-flight refusal is the only defence there is.
cd D:/Github/UNISIM/Universal_Apps/Universal_Video # macOS: ~/Github/UNISIM/...
npm install
./scripts/preview.sh # or .\scripts\preview.ps1 on WindowsPort 5199, reserved for Video in Docs_UNI_SIM/dev-preview.md. The scripts
pass --strictPort, so a clash fails loudly rather than serving Video on another
app's port.
⚠️ npm installneeds@unisim/media@0.1.0on npm, and it is not published yet. The package lives atbackoffice/universal-platform/packages/media. Until it is released, install it from a local pack:cd D:/Github/UNISIM/backoffice/universal-platform/packages/media npm run build && npm pack cd D:/Github/UNISIM/Universal_Apps/Universal_Video npm install --no-save ../../backoffice/universal-platform/packages/media/unisim-media-0.1.0.tgz
package-lock.jsontherefore does not yet contain an entry for it. Run a plainnpm installonce the package is on npm and commit the updated lockfile; until then a cleannpm ci— including a Cloudflare Pages build — will fail.
| Command | Does |
|---|---|
npm run dev |
Vite dev server |
npm run build |
tsc -b then a production bundle into dist/ |
npm run lint |
ESLint (flat config, typescript-eslint) |
npm test |
Vitest — the pure logic (progress projection, overrun detection) |
npm run test:e2e |
Playwright — a real Chromium, a real H.264 MP4, a real conversion, read back by the browser's own demuxer. No internet needed. |
npm run test:e2e is the one that matters. A compressor that compiles proves
nothing. The container and pre-flight logic have their own suite — run
npm test in backoffice/universal-platform/packages/media.
e2e/fixtures/clip-480x270.mp4 is 2 seconds of 480×270 H.264 with an AAC track,
generated by Chromium's own encoders and muxed by @unisim/media. There is no
ffmpeg on the machines this is built on, and that turned out to be a feature:
making the fixture exercises the writer before anything else runs.
| Shell | Vite + React + TypeScript, PWA, Tailwind v4 |
| Chrome | @unisim/sdk — UniversalAppsNavBar, suite switcher, usage telemetry |
| State | zustand (src/stores/videoStore.ts) — one file at a time, on purpose |
| Pipeline | @unisim/media — mp4read, mp4mux, video, probe, plan |
| Theme | Light by default, always, until the user picks otherwise (src/stores/themeStore.ts) |
Bundle: ~511 KB raw / ~148 KB gzip of JavaScript, of which @unisim/media is
about 24 KB raw / 9 KB gzip. For comparison, the ffmpeg.wasm core this app
does not ship is 30.7 MiB (9.7 MiB gzipped).
- Don't add
ffmpeg.wasm. The only published@ffmpeg/coreisGPL-2.0-or-later— it bundles libx264 — and there is no LGPL build to take. Beyond the licence, its.wasmis 30.7 MiB and Cloudflare Pages enforces a 25 MiB per-file limit, so it cannot even be self-hosted here without new infrastructure. And it is unnecessary: the browser already has an H.264 encoder. - Don't load an engine from a CDN. The core would never see your file, so the
claim survives technically. But the claim we sell is a trust claim, and
it is falsifiable by anyone who opens the network tab: a 10 MB request to
unpkg.comwhile their private footage sits in the drop zone reads as "something went somewhere", and no amount of explaining wins that argument. If a user with devtools open would have to trust our explanation, the design is wrong. - Don't set COOP/COEP.
require-corpblocks cross-origin<img>loads made without CORS — including the SDK navbar's org branding logos from Supabase Storage. The symptom is a paying customer's logo silently vanishing, reported three weeks later as "the site looks broken". Nothing here wantsSharedArrayBufferanyway. - Don't write
as unknown as ProductCode.SuiteProductIdends in| (string & {}), so a wrong product code type-checks — and that cast is what let Universal Converter and Universal USB lose every usage event from launch.'video'is a real member of the union and of the Postgres enum, soproduct: 'video'needs no cast. If the type ever fights you, the type is telling you the enum is missing a value.
The long-form argument for all four is §10 of Docs_UNI_SIM/next-products.md.
MIT © 2026 James Markey. See LICENSE.