|
| 1 | +--- |
| 2 | +name: pitfalls |
| 3 | +description: CineScreen's sharp edges and invariants — metadata invalidation, zoom-section rules, coordinate spaces, PTS rebasing, AVFoundation callback traps, SwiftUI gesture state, autosave suppression. Read before modifying capture, editor, or export code. |
| 4 | +--- |
| 5 | + |
| 6 | +# Common pitfalls & invariants |
| 7 | + |
| 8 | +## Editor state |
| 9 | + |
| 10 | +- **`EditorViewModel.metadata.didSet` is the single invalidation choke |
| 11 | + point**: it clears `cachedSnapshot` + `cachedZoomSections` and schedules |
| 12 | + the debounced autosave. Mutate metadata *through the property* (value-type |
| 13 | + write-back does this automatically, e.g. `metadata?.zoom.sections = …`). |
| 14 | + Never cache derived render state anywhere else. |
| 15 | +- **`suppressAutosave` must wrap any code that loads state INTO the VM** |
| 16 | + (loadMetadata, derived defaults) — otherwise merely opening a recording |
| 17 | + rewrites its sidecar. |
| 18 | +- **Trim lives twice** (vm.trimStartMs/EndMs ↔ metadata.trim) and is synced |
| 19 | + by the trim didSets. Don't add a third copy. |
| 20 | +- **Zoom sections invariant: sorted by startTime, non-overlapping, ≥100ms.** |
| 21 | + `updateZoomSection` enforces it by clamping to neighbours (which is also |
| 22 | + why no mid-drag re-sort is needed — a drag can't cross a neighbour). |
| 23 | + `RenderSnapshot.init` sorts defensively; the pan table is binary-searched |
| 24 | + by time and breaks on non-monotonic input. |
| 25 | +- **Drag gesture baselines must be `@GestureState`, not `@State`** — SwiftUI |
| 26 | + never calls `onEnded` for a *cancelled* gesture, and stale `@State` wedged |
| 27 | + scrubbing/drags before. Same for pinch: `MagnifyGesture.magnification` is |
| 28 | + cumulative from gesture start; scale a gesture-start baseline or it |
| 29 | + compounds exponentially. |
| 30 | + |
| 31 | +## Coordinate spaces (four of them — never mix) |
| 32 | + |
| 33 | +1. **CGEvent global points**: top-left origin of the primary display. This is |
| 34 | + what the mouse tap yields. (NSEvent.mouseLocation is bottom-left Cocoa — |
| 35 | + convert once using the *primary* screen, not NSScreen.main.) |
| 36 | +2. **Recorded-file pixels**: top-left; metadata keyframes/clicks live here. |
| 37 | + Mapping: `(global − CaptureInfo.contentRectPoints.origin) × px/pt`. |
| 38 | +3. **UV [0,1]²** in shaders (video space), then **NDC** with y-up; canvas |
| 39 | + passes multiply `aspectScale` then `canvas.contentScale` — every overlay |
| 40 | + pass (video, cursor, clicks) must apply both or it drifts under padding. |
| 41 | +4. **Webcam layout norms** are relative to the *padded content rect*: |
| 42 | + on-screen px = norm × contentScale × viewSize. Invert exactly. |
| 43 | + |
| 44 | +## Timing |
| 45 | + |
| 46 | +- All capture PTS are **rebased so the file starts at 0** (first screen |
| 47 | + frame is the base; mic + system audio rebase against it and drop |
| 48 | + negative-PTS samples). |
| 49 | +- **Webcam**: screenT = webcamT + `metadata.webcamOffsetMs` (camera warm-up). |
| 50 | + Editor seeks and export reads must apply the mapping; playback defers the |
| 51 | + webcam start inside the warm-up gap. |
| 52 | +- Recorded duration comes from the **last video frame's rebased PTS**, not |
| 53 | + wall clock (wall clock includes ~0.3–1s of startup latency). |
| 54 | + |
| 55 | +## AVFoundation traps |
| 56 | + |
| 57 | +- `requestMediaDataWhenReady` blocks are **re-invoked after failures** (a |
| 58 | + failed writer forces `isReadyForMoreMediaData=true` so you can poll the |
| 59 | + error). Any continuation resumed from such a block needs a resume-once |
| 60 | + guard + `markAsFinished()` — see ExportPipeline's `finish(_:)` pattern. |
| 61 | + Double-resume = runtime trap. |
| 62 | +- With multiple writer inputs, the writer **interleaves**: a stalled/failed |
| 63 | + input blocks the others forever. Mark the failed input finished so |
| 64 | + siblings' callbacks fire and can exit (shared `ExportSessionState`). |
| 65 | +- `alwaysCopiesSampleData = true` on reader outputs feeding |
| 66 | + CVMetalTextureCache is load-bearing: cached textures pin decoder pool |
| 67 | + buffers; without copies the decoder stalls (~2s in, export "freezes"). |
| 68 | +- Export writes to a hidden temp file and promotes atomically on success — |
| 69 | + never write directly to the user's chosen path. |
| 70 | + |
| 71 | +## Capture |
| 72 | + |
| 73 | +- `ScreenCaptureService.stop()` tolerates an already-dead stream — stream |
| 74 | + death mid-recording routes through `onRuntimeFailure` → RecordingSession |
| 75 | + salvages via the normal stop path. |
| 76 | +- The stop hotkey is **⌥⎋ exactly** (caps-lock tolerated). Never rebind to |
| 77 | + unmodified ESC (it silently ended recordings from the recorded app) and |
| 78 | + never match ⌘⌥⎋ (Force Quit). |
| 79 | +- After granting Screen Recording, macOS requires an **app relaunch** before |
| 80 | + `CGPreflightScreenCaptureAccess()` returns true — a permission that "won't |
| 81 | + turn green" in-process is expected, not a bug. |
| 82 | +- `_CGSCurrentCursorSeed` is private SPI (cursor-shape detection). It can |
| 83 | + vanish in an OS update; if cursor-shape code crashes at launch, look here. |
| 84 | + |
| 85 | +## Process & release |
| 86 | + |
| 87 | +- Each improvement = its own commit, pushed (user preference). |
| 88 | +- `NSSupportsAutomaticTermination` must stay **false** — a recorder with all |
| 89 | + regular windows hidden must not be reclaimable mid-capture. |
| 90 | +- CFBundleVersion = `git rev-list --count HEAD` (Sparkle monotonicity). |
| 91 | + History rewrites that reduce the commit count below the last release's |
| 92 | + would break auto-update — check before squashing/rebasing main. |
| 93 | +- The release tag must equal project.yml's MARKETING_VERSION (CI preflight |
| 94 | + enforces it). |
0 commit comments