Timeline-based frame-animation and drawing studio with a built-in canvas.
Runs in the browser, on a PC or a tablet — a pen or a mouse draws, a finger navigates.
The accent colour above is user-set — one colour drives the whole UI.
User guide (wiki) — getting started, the camera, part animation and effects, importing video, exporting, working with a lot of cuts · Korean wiki
Korean README: README.ko.md
Drawing
- Dot pen, marker, airbrush (blur / boiling-line modes), eraser, bucket fill, lasso, text, liquify (pushes pixels along with the pen)
- Shape tool: straight line, curve, rectangle and ellipse. Each is stored as an ordinary stroke, so it takes the current brush, erases, and boils with its layer
- Lasso selection: move, resize, rotate (a handle above the selection, or a slider), tilt and bend (sliders, or Ctrl-drag inside the selection); Ctrl+T selects the whole layer
- Move tool moves what is selected — a tapped text, otherwise the active layer; texts never ride along with a layer
- Fill matches the clicked colour, so you can paint over an already-filled area
- Pen draws; finger pans and pinch-zooms (palm rejection)
- Stroke smoothing (resample → Chaikin → Catmull-Rom); in-progress strokes render incrementally on a separate overlay canvas
Motion and effects
- Boiling line — a shimmer applied to strokes you already drew, with amplitude, wavelength and minimum-width settings
- Camera per cut: pan, zoom, tilt, a drawn path, presets, and a handheld shake — the whole frame moves and every layer follows
- Layer effects with a start/end window inside the cut: mosaic (block size, speed, an optional region that moves with the layer) and static — the lines tear, fringe red and cyan and snow, only where there is ink, so it works on a transparent background; the static is available on texts too
- Cut animation (in/out, deform, move, easing) + part animation (lasso a region: move / rotate / scale / path)
- Keyframe tweening — shape morphing via a distance field, with centroid alignment
- Sway that follows a curve you draw, with a lag so the tips trail the root, plus a bend profile using per-slice shear
- Motion presets, text animation, rich text
- Every effect is a pure function of the time — nothing random — so the export repaints exactly what playback showed
Timeline and structure
- Multi-track timeline: drag, resize, snap, loop playback, part grouping
- Playback speed can be kept as the project default, or baked into the film so every cut length, easing and text speed is rescaled to match
- Cuts with layers and nestable folders; rename, collapse, multi-select
- Onion skin
- Numeric fields (speed, coordinates…) accept free input rather than being capped by the slider range
I/O
- Autosave,
.emvsave/open, server save - Automatic server backup every 5 minutes, keeping the newest 12 — runs in the background without blocking the UI
- Video import from a local file or URL: frame extraction, scene-change detection, audio track (with a mute that lifts itself for the export)
- Imports match the source video's own size by default, so a vertical shorts clip fills the canvas instead of being letterboxed; landscape and portrait presets are there too
- WebM/MP4 export recorded on a fixed frame grid (no judder from sampling the paint loop), with a start/end range; PWA, Android packaging
UI
- English, Korean and Japanese, switchable in Settings
- The help dialog opens with a "where is it" list: each effect and the row, icon and panel it lives in
- Dockable panels: drag a panel by its header to the left or right edge to dock it there, or drop it in the middle to pull it out as a floating window. The arrangement is remembered
- Tab hides every panel to leave just the canvas, and restores exactly what was open
- Dragging the playhead scrubs with animation, so you see the motion rather than static artwork sliding past
- Custom theme colour — one colour derives an HSL ramp applied across the whole app (playback bar, panels, buttons included), with neutral saturation adjustable too
- User-definable keyboard shortcuts
- Long jobs report progress in a corner chip instead of a full-screen overlay
npm install
npm run dev # web (:5173, LAN + QR) + API (:8787)
npm run buildOn a tablet, scan the QR printed by npm run dev (same Wi-Fi). If 5173 is taken, Vite moves to 5174, 5175… — check the address in the terminal.
Requirements: Node 18+. Importing video from a URL needs yt-dlp; merged formats such as 1080p additionally need ffmpeg. Everything else works without either.
npm run check # everything below, in order, then a production build
npm test # node --test, no test framework dependency
npm run typecheck # tsc twice: the JavaScript leniently (checkJs), the TypeScript strictly
npm run lint # eslint-plugin-react-hooks
npm run smoke # boots the built app in a headless browser and draws a strokenpm run check is the gate: typecheck, the unit tests, then nine static guards, then the build.
CI runs that one command on every push and pull request - it used to list its steps by hand, and
by October that copy ran four of the ten (see docs/process/).
The code is TypeScript, moved from JavaScript one folder at a time (#268), and every file is
held to strict. The point was never the annotations - the JavaScript was already typed through
JSDoc - but two things JSDoc could not give. Strictness: the JavaScript was checked leniently
(no null checks, implicit any allowed), and strict found missing null guards, a function
returning ids of the wrong type, and settings typed narrower or looser than what the app stores.
And named shapes: a Scene, a CutsAction, a PlaybackDeps is an interface another file can
import, where a JSDoc typedef was local to its file. Importers name a module with its extension -
that is what Node's own type stripping needs in the tests (--experimental-strip-types, no extra
dependency), and the bundler and tsc accept it.
| Guard | What it fails on |
|---|---|
scripts/hook-baseline.mjs |
more React hook dependency warnings than the pinned baseline |
scripts/helper-index.mjs |
a shared export missing from HELPERS.md |
scripts/unreachable.mjs |
an App-level name nothing can reach |
scripts/unused-imports.mjs |
an import nothing in the file uses |
scripts/stroke-writes.mjs |
a write that adds a stroke to a layer without going through commitStroke |
scripts/native-dialogs.mjs |
an alert, confirm or prompt in src/ — a browser that blocks dialogs makes all three fail silently |
scripts/architecture-index.mjs |
a file under src/ that ARCHITECTURE.md does not name |
scripts/fix-needs-test.mjs |
a branch with a fix: commit that changes nothing under test/, unless a No-Test: trailer says why |
scripts/i18n-check.mjs |
a tr() literal with no English entry, or a dictionary row whose key no longer appears in the source |
Measuring tools, none in the gate — they print numbers rather than pass or fail:
npm run bench times the pure hot paths, and node scripts/profile-frame.mjs drives a real
browser against a running dev server and reports frame time, 2d calls per frame attributed to the
canvas they landed on, and image readbacks. The first cannot see the canvas; the second cannot see
raster and compositing. Both say so in their own headers. node scripts/video-dedupe-check.mjs
imports a generated clip whose every frame differs and fails if any are merged (#124), and
node scripts/process-metrics.mjs reads the repository's own records into the process measures
- deployment frequency, lead time, test pass ratio, defect escape rate - that each process cycle is judged against.
Around 1,300 unit tests cover the pure modules under src/core, src/canvas, src/engine and
src/export — geometry, easing, keyframe sampling, the cuts reducer, layer-tree moves, lasso
cut-out, timeline snapping, the time-scale bake, GIF and zip writers. They use Node's built-in
runner because none of it needs a DOM or a framework. The functions that draw - strokes into
pixels, the static, the mosaic, the bucket fill - run on @napi-rs/canvas (a Skia canvas with
the 2D API, no browser) through canvas/canvasFactory.js, and are asserted on real pixels. Frame
extraction needs a real video element and is left to the smoke test. The cost of each effect is
guarded by count, not time: test/canvas/opBudget.test.js fails when a frame of the static or
the mosaic issues more canvas operations than it does today.
npm run bench # measures the pure hot pathsMeasurements of the canvas-side effects, with what was changed and why, live under docs/perf/ — one file per investigation.
Worth knowing before adding memoisation: measured against a 16.7ms frame, the per-render derived
values are not where the time goes. Aggregating parts over 1,000 cuts costs 0.026ms, strokeSig
under a microsecond, flattening 100 layers 0.017ms. Wrapping those in useMemo would add more
dependency-checking than it saves. The cost in this app is canvas repaint and bitmap decoding,
which is why the caching that exists is a per-layer canvas cache, incremental tail rendering, a
rAF throttle, and a WeakMap for the boiling path — not useMemo.
scripts/hook-baseline.mjs fails only when hook dependency warnings grow. The remaining ones are mostly deliberate (per-frame canvas work and heavy caches), so zero isn't the target; the guard catches new stale-closure risk introduced by things like extracting custom hooks. Use UPDATE=1 node scripts/hook-baseline.mjs to move the baseline on purpose.
Passing every static check does not prove a component mounts — a component returning
undefinedis legal in React. After a structural change, open the affected screen and look at it.
An installable APK is built in CI, so you do not need an Android SDK to get one:
- Tagged releases — pushing a
v*tag publishes the APK to Releases. - Any commit — run the Android APK workflow from the Actions tab and download the artifact from that run.
It is the debug variant, signed with the standard debug key, so Android will ask you to allow installs from that source. A release build needs a real keystore, and keystores are kept out of this repository on purpose.
Locally, if you do have the SDK:
npm run android:sync # build web + sync into android/
npm run android:open # open Android Studio -> run, or Build > Generate Signed Bundle / APKnpx cap sync copies dist/ into android/. For live reload on a device, set Capacitor's
server.url temporarily — and do not ship with it.
src/
App.tsx the component: state and wiring (~2,100 lines)
tools/ the drawing tools as a dispatch table, in TypeScript: what each one does on pointer down and move
core/ pure logic, in TypeScript - reducers, timeline geometry, lasso, shapes, persistence, export planning
canvas/ anything that draws on a 2D context, in TypeScript: strokes, text, sway slices, layer compositing
engine/ evaluating one frame, in TypeScript: which cuts are on, what each layer looks like at time t
export/ byte writers for GIF and zip, video recording plumbing, download - TypeScript
hooks/ App state that has been given its own home, in TypeScript: the canvas view, the layer cache, playback,
audio, history, autosave, panels, tool settings, shortcuts, the drag gestures
ui/ panels, in TypeScript (.tsx); ui/dialogs/ one file per dialog
styles/ one stylesheet per area, imported in cascade order by styles/index.css
i18n.ts the English dictionary (~710 entries) and the tr() lookup; i18n.ja.ts the Japanese one
globals.d.ts ambient declarations (EyeDropper, Capacitor, File System Access…)
server/ the API: index.js wires it; projects, backups and youtube are a route module each; paths.js builds every path
scripts/ the check guards above, the hot-path benchmark, font subsetting
test/ unit tests (node --test), mirroring src/ - test/core for src/core and so on - plus smoke/
Nothing under core/, engine/ or export/ touches a canvas or React, and tools/ touches neither React nor a context it owns - where one of them
needs an ImageData, it takes a constructor as an argument. canvas/ draws on a context it is
handed rather than one it owns. That split is what keeps the tests framework-free. ARCHITECTURE.md
is the map to read before editing App.tsx, and HELPERS.md lists every shared
export.
The Korean source text doubles as the translation key, gettext style, so a missing entry shows
Korean rather than an empty label. Japanese is allowed to be incomplete and falls back to English.
The lookup is named tr, not t, because t is already a local variable in dozens of places
here.
The server is for local use only. It is a convenience for reaching your own machine from a tablet on the same Wi-Fi, and it is not written to be exposed to anything wider:
- No authentication of any kind. Anyone who can reach :8787 can read, overwrite and delete every project, and trigger downloads.
- It listens on all interfaces, because the tablet has to reach it. On an untrusted network that means everyone on that network.
- Asset uploads accept up to 1GB of raw body per request, so an open port is also a way to fill your disk.
- Path traversal is handled — project ids are sanitised before touching the filesystem — but that is the only hostile input it defends against.
Run it behind your own firewall on a network you control. Do not port-forward it.
Video import is intended for local, personal use. Respect the source service's terms and copyright.
AGPL-3.0. Use it, change it, share it, for nothing. The condition is the one that keeps that true: if you give out a changed version - as a download or by running it for other people over a network - the source of your version has to be available under this same licence.
That is the point rather than a formality. A permissive licence lets someone take this, close it, and sell it; AGPL does not. It does not forbid charging money, and nothing stops a company using it - but whoever they hand it to gets the source too, and may pass it on for free, so there is nothing to be gained by putting a price on someone else's work.
What you make with it is yours. The licence covers this program, not the videos you draw in it. Music videos, client work, a monetised channel - none of that is affected.
Contributions are welcome and are accepted selectively - the core is not redesigned by pull request, and anything larger than a small fix starts with an issue. See CONTRIBUTING.md. A pull request is offered under AGPL-3.0, the same licence as the project; you keep the copyright in what you wrote.
