Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 11 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ jobs:
# the six-hour default.
timeout-minutes: 10
steps:
# Full history, not the default single commit: the fix-needs-a-test gate compares the branch
# against origin/main, and with one commit there is no merge base to compare from.
- uses: actions/checkout@v7
with:
fetch-depth: 0

- uses: actions/setup-node@v7
with:
Expand All @@ -41,19 +45,13 @@ jobs:
# added without committing the lockfile fails here rather than on someone else's machine.
- run: npm ci

- name: Type check
run: npm run typecheck

- name: Unit tests
run: npm test

# Fails only when hook dependency warnings grow, i.e. when a change adds new stale-closure
# risk. See scripts/hook-baseline.mjs for why zero is not the target.
- name: Hook baseline
run: node scripts/hook-baseline.mjs

- name: Build
run: npm run build
# The whole gate, as one command - the same one run locally. This used to list its steps by
# hand (type check, tests, hook baseline, build), and as gates were added to `npm run check`
# the copy here was never updated: by 2026-10-14 CI ran four of the ten, and the helper index,
# unreachable names, unused imports, stroke writes, native dialogs and i18n checks bound only
# on a machine where someone remembered to run them. One list cannot drift from itself.
- name: npm run check
run: npm run check

# Separate from `check` on purpose: that job is the required gate and it finishes in half a
# minute, so it must not wait behind a browser. This one is allowed to be the slow one.
Expand Down
71 changes: 70 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Frame-by-frame MV/animation app. Vite + React 19. Capacitor wraps it for Android

## Files

App.tsx is around 2,100 lines; it is mostly wiring now — which hook owns what, and
App.tsx is around 2,200 lines; it is mostly wiring now — which hook owns what, and
which component gets which props. **Put new pure logic in a module, not in App.tsx** — anything
that is a function of its arguments belongs next to its tests. See [#234] for where the rest of
App is going and why.
Expand Down Expand Up @@ -52,6 +52,10 @@ know storage exists at all, because reading it is `useStored`'s decoder.
write UI strings in Korean and add the English to the dictionary. Called `tr`, not `t` — `t` is
a local variable in dozens of places.
- `src/db.ts` — IndexedDB autosave (`saveAutosave`, `loadAutosave`, plus project CRUD).
- `src/i18n.ja.ts` — the Japanese dictionary, keyed by the same Korean text; a missing entry falls
back to English, not to the Korean key.
- `src/globals.d.ts` — browser and vendor globals the type checker does not know (the EyeDropper
among them), declared as possibly absent because the code checks before using them.

**core/** — pure logic, all of it tested, all of it TypeScript (the first folder converted; the
shapes the modules share are in `types.ts`, and the document's own are declared in
Expand Down Expand Up @@ -117,6 +121,44 @@ index signature, so a misspelt one is an error. Ids there are `DocId` - a number
to come: presets that use the room instead of zooming to make some, and a camera that can span
more than one cut.

- `ids.ts` — `nextId`, `randomId`. These were `Date.now()` in nineteen places, and a millisecond
is long enough to make two things with one id.
- `camera.ts` — where the camera is looking at a moment in a cut: a window onto the artwork (a
centre and a zoom), its presets and paths. Applied at playback only.
- `clipping.ts` — `clipGroups`, `canClip`: a layer that only shows where the layer beneath it has
paint, which is how shading goes over flat colour.
- `shapeStroke.ts` — `rectPoints`, `ellipsePoints`: ruler shapes as ordinary point lists, which is
the whole idea - a shape is then just a stroke.
- `pathMotion.ts` — turning a drawn line into a path something can travel along evenly: smoothing
and resampling by length, since a pen piles points up wherever it slowed down.
- `brushSize.ts` / `viewZoom.ts` — the brush width range and the canvas zoom range, each in one
place after being written in two and three places that disagreed.
- `timelineZoom.ts` — timeline pixels to time and back (`timeAtX`, `xAtTime`), and zooming without
the content sliding out from under the cursor.
- `windowDrag.ts` — `dragOnWindow`: a drag run on window listeners, because the pointer leaves the
element it started on constantly. Ends on `pointercancel` too, which every hand-rolled one missed.
- `persist.ts` — `readStored`/`writeStored` and the codecs under `useStored`: the small preferences
kept in localStorage.
- `playbackRate.ts` / `playbackStart.ts` / `timeScale.ts` — the preview speed and that it is
remembered, where play begins when it is pressed, and baking a preview speed into the film's
real timings.
- `mediaReducer.ts` / `mediaEl.ts` — the audio and video tracks as one reducer, and the one thing
done to the elements playing them (`detachMedia`).
- `api.ts` / `probeBackoff.ts` — talking to the local project server (`apiFetch` adds the status
check fetch does not do; `retryDelayMs` honours a 429), and how often to ask whether it is there.
- `projectAssets.ts` — how each piece of a project is stored - as an asset, a blob or a data URL -
and how a stored one comes back.
- `splitProject.ts` — cutting one long project into sequential pieces that can be worked on apart
(#123).
- `textEdit.ts` — a text object's twenty-odd fields in one place (`textFromEdit`, `editFromText`)
instead of three hand-copied ones.
- `videoCuts.ts` — the arithmetic in the middle of a video import: extracted frames into cuts,
their durations and parts.
- `recordBitrate.ts` — the bitrate to ask the recorder for, by codec and canvas size; left unset,
Chrome settles near 2.5 Mbps whatever the size.
- `decodeBudget.ts` — how many decoded frames to hold, which to let go of, and which cached layers
a decode invalidates.

**engine/** — what a frame *is*, with no canvas anywhere near it. TypeScript; `Scene`,
`EvaluatedCut` and `EvaluatedGroup` in `evaluateFrame.ts` are the renderer's whole input, named.

Expand Down Expand Up @@ -155,6 +197,21 @@ frame is made of, and `SceneDeps` what the renderer needs from the app.
- `warpRender.ts` / `shearSlices.ts` / `swayRender.ts` — a selection's rotate/skew/bend outline,
the slice stack that draws a sheared bitmap, and the sway deformation.

- `canvasFactory.ts` — `makeCanvas`/`setCanvasFactory`: where the canvas modules get a canvas, which
is what lets the pixel tests run them in Node on `@napi-rs/canvas`.

**export/** — turning the timeline into a file. No React; what only the app knows (the range, the
audio graph, the playback loop) stays in `hooks/useExport.ts`.

- `recorder.ts` — `pickRecordingType` (MP4 first, asked for by a codec name browsers accept),
`frameSource`, `startRecorder`: recording the canvas, minus the parts that need the app.
- `gif.ts` — a GIF89a encoder, enough for an animation with a transparent background, which no
browser API produces.
- `zip.ts` / `byteWriter.ts` — a store-only ZIP writer (PNGs are already deflated, so compressing
them again buys nothing) and the growable byte buffer under it.
- `download.ts` — `downloadBlob`: handing a finished file to the browser, once rather than three
times.

**styles/** — the stylesheets, one per area (top bar, toolbar, canvas, the cut/layer panel, the
timeline, the colour panel, touch, …), imported in their original cascade order by
`styles/index.css`. The order is load-bearing: some rules override others by position alone.
Expand All @@ -178,6 +235,13 @@ prompt, tool keys, project picker, progress overlay), all over `Modal.tsx`. And
them stops the work underneath; that is the rule they share.
- `NumField.tsx` — **use NumField for any new numeric field.**

- `Logo.tsx` — the app mark: three receding frames - the onion skin - with a play mark on the front.
- `dialogs/` — one file per dialog. `AskModal.tsx` is the app's own confirm and prompt (a browser
told to block dialogs makes `confirm` return silently, so the button looks dead).
`SettingsModal.tsx` / `ToolKeysModal.tsx` are the settings and the two keymap editors;
`VideoImportModal.tsx`, `SceneDetectModal.tsx`, `ExportRangeModal.tsx`, `ProjectPicker.tsx`,
`LinkPromptModal.tsx`, `ProgressOverlay.tsx` and `HelpModal.tsx` do what they are called.

**hooks/** — TypeScript. State that belongs together, lifted out of App so its wiring is somewhere
with a name; each hook's dependency bag is an interface (`PlaybackDeps`, `ExportDeps`, `LayerCacheDeps`, …). Each takes what it cannot own as arguments, and the rule for what it cannot own is the same
every time: `buildData` reads most of App's state and `restore` writes most of it, so a hook that
Expand Down Expand Up @@ -236,6 +300,11 @@ needs a document takes those two functions rather than the document.
call sites keep the shape `window.confirm` gave them. `AskModal` draws it.
- `useDialogs.ts` — which dialog is open, and the rebinding two of them share.

- `useCutListUi.ts` — what is picked in the cut list and how much of it is unfolded, kept in one
hook so opening a document clears all of it at once (#241).
- `useTimelineView.ts` — how far the timeline is zoomed, which part is on screen, and the two
overlays drawn while a drag is happening.

- `server/` — the Express file-backed project DB on :8787, files under `server/data/`. `index.js`
is the wiring (body limit, rate-limit tiers, mount, listen); `projects.js`, `backups.js` and
`youtube.js` are one route family each; `paths.js` builds every path, so `safeId` cannot be
Expand Down
19 changes: 16 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,19 @@ git commit -m "fix: ..."
git push -u origin HEAD
gh pr create --fill # put "Closes #12" in the body
gh pr checks # CI
gh pr merge --squash --delete-branch
gh pr merge --rebase --delete-branch # rebase, so each commit lands on main as written
```

**A `fix:` brings its test.** If a branch has a `fix:` commit, it has to change something under
`test/` - the test that fails without the fix. That is the moment a defect is best understood and
a test is cheapest to write, and it is what stops the same defect coming back: before this rule,
over half of all fixes had landed without one. When nothing can be observed by a test (a colour,
a config value), say so in the commit message and the gate accepts it:

```
fix: the panel border is visible in the light theme

No-Test: colour only; nothing here a test can observe
```

## `main` is protected
Expand All @@ -76,7 +88,7 @@ Turning it back on is the part that gets forgotten, so do both in one sitting.
## Verifying

```bash
npm run check # typecheck → tests → hook-warning baseline → build
npm run check # typecheck → tests → nine static guards → build; CI runs exactly this
```

**Passing this proves less than it looks.** A component that returns `undefined` is legal
Expand Down Expand Up @@ -107,7 +119,8 @@ evidence you will have.
## Where code goes

The folders under `src/` say what a file is allowed to touch. The details are in
[ARCHITECTURE.md](ARCHITECTURE.md) — **read it before editing App.jsx.**
[ARCHITECTURE.md](ARCHITECTURE.md) — **read it before editing App.tsx.** Every file under `src/`
is named in it, and `npm run check` fails when a new one is not.

```
core/ pure logic — no React, no DOM, no canvas. Everything here has tests.
Expand Down
6 changes: 4 additions & 2 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ npm run lint # eslint-plugin-react-hooks
npm run smoke # 빌드된 앱을 헤드리스 브라우저에서 띄워 선 하나 긋기
```

`npm run check`가 관문입니다: 타입 검사, 단위 테스트, 정적 가드 여섯, 빌드. 같은 단계가 모든 푸시와 PR에서 CI로 돕니다.
`npm run check`가 관문입니다: 타입 검사, 단위 테스트, 정적 가드 아홉, 빌드. CI는 모든 푸시와 PR에서 이 명령 하나를 그대로 돌립니다 - 예전에는 단계를 손으로 나열했고, 10월에는 그 사본이 열 개 중 넷만 돌리고 있었습니다([docs/process/](docs/process/2026-10-14.md)).

코드는 TypeScript입니다 — JavaScript에서 폴더 하나씩 옮겼고(#268) 모든 파일이 `strict`로 검사됩니다.
목적은 주석을 타입으로 바꾸는 것이 아니라(JS도 이미 JSDoc으로 타입이 있었음) JSDoc이 줄 수 없던
Expand All @@ -115,9 +115,11 @@ npm run smoke # 빌드된 앱을 헤드리스 브라우저에서 띄워 선
| `scripts/unused-imports.mjs` | 파일 안 아무것도 쓰지 않는 import |
| `scripts/stroke-writes.mjs` | `commitStroke`를 거치지 않고 레이어에 선을 추가하는 쓰기 |
| `scripts/native-dialogs.mjs` | `src/` 안의 `alert`·`confirm`·`prompt` — 브라우저가 대화상자를 막으면 셋 다 조용히 실패함 |
| `scripts/architecture-index.mjs` | `ARCHITECTURE.md`가 이름을 대지 않는 `src/` 아래 파일 |
| `scripts/fix-needs-test.mjs` | `fix:` 커밋이 있는데 `test/` 아래를 하나도 바꾸지 않은 브랜치 (`No-Test:` 트레일러로 이유를 적으면 예외) |
| `scripts/i18n-check.mjs` | 영어 항목 없는 `tr()` 리터럴, 또는 소스에 키가 더는 없는 사전 항목 |

단위 테스트 약 1,150개가 `src/core`, `src/canvas`, `src/engine`, `src/export`의 순수 모듈을
단위 테스트 약 1,300개가 `src/core`, `src/canvas`, `src/engine`, `src/export`의 순수 모듈을
덮습니다 — 기하, 이징, 키프레임 샘플링, 컷 리듀서, 레이어 트리 이동, 올가미 오려내기, 타임라인
스냅, 시간 배율 굳히기, GIF·zip 라이터. DOM도 프레임워크도 필요 없어 Node 내장 러너를 씁니다.
실제로 그리는 함수들 — 선을 픽셀로, 노이즈, 모자이크, 채우기 — 는 `@napi-rs/canvas`(브라우저
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,9 @@ npm run lint # eslint-plugin-react-hooks
npm run smoke # boots the built app in a headless browser and draws a stroke
```

`npm run check` is the gate: typecheck, the unit tests, then six static guards, then the build.
The same steps run in CI on every push and pull request.
`npm 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/](docs/process/2026-10-14.md)).

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
Expand All @@ -119,15 +120,21 @@ dependency), and the bundler and `tsc` accept it.
| `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 |

Two measuring tools, neither in the gate — they print numbers rather than pass or fail:
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.
| `scripts/i18n-check.mjs` | a `tr()` literal with no English entry, or a dictionary row whose key no longer appears in the source |
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](docs/process/) is judged against.

Around 1,150 unit tests cover the pure modules under `src/core`, `src/canvas`, `src/engine` and
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
Expand Down
Loading
Loading