# Camera Area / Camera Track Plan Date: 2026-06-07 ## Core Rule The camera is a stage-area framing tool, not the editor viewport. The normal editor stage must stay unchanged. `CanvasStage.vue` should keep using `editor.canvasZoom`, `editor.canvasViewX`, and `editor.canvasViewY` only for the workbench view. Moving or resizing the camera must never pan, zoom, crop, or transform the editable stage. The camera affects only final output surfaces: - fullscreen/final preview - exported media - exported runtime/example HTML playback In the normal editor view, the camera is only an overlay: a selectable, manipulatable rectangle drawn in stage coordinates. ## Terminology - **Stage:** the document canvas coordinate system, currently `scene.canvas.width` by `scene.canvas.height`. - **Editor viewport:** the user's zoomed/panned workbench view in `CanvasStage.vue`. - **Camera frame:** an animatable stage-space rectangle that defines the final visible area. - **Final preview:** fullscreen/runtime preview through `FullscreenPreview.vue` and `src/lib/exportPlayer.ts`. This does not mean the existing `stagePreview` button-mode toggle in `CanvasStage.vue`. ## Current Architecture ### Scene And Object Model Primary types live in `src/types/scene.ts`. - `Project` is the saved top-level container. - Project-wide shared fields include `canvas`, `duration`, `fps`, `inTime`, `outTime`, `assets`, and the clip library. - Each `ScenePage` owns page-specific content: `layers`, `clipInstances`, `clipTracks`, `scriptTracks`, `scriptFrames`, `markers`, `groups`, `trackOrder`, `backgroundVideo`, `audioTracks`, and `audioClips`. - `useSceneStore().scene` returns a merged `Scene` view combining project shared fields with the active page. Many render/export paths consume this merged `Scene`. Stage objects are mostly `Layer` records that point at an `Asset` by `assetId`. Layer order is controlled by `trackOrder`, with `groups` expanding into child track ids. Clip placements use `ClipInstance` records on `ClipTrack`s; a `ClipInstance` has static per-instance `x`, `y`, `scaleX`, `scaleY`, and `rotation`. ### Object Transforms Layer transforms are stored in `Layer.base` plus keyframes: - `Layer.base: AnimatableProps` - `Layer.keyframes: Keyframe[]` - optional `Layer.positionKeyframes` - optional `Layer.rotationKeyframes` - `Layer.separated` switches position/rotation to those separated tracks `AnimatableProps` currently includes transform fields (`x`, `y`, `scaleX`, `scaleY`, `rotation`, `opacity`) and visual fields such as fill, stroke, blur, shadow, polygon points, kaleido pivot, and so on. ### Timeline And Keyframes `src/components/Timeline.vue` builds visible timeline rows from: - layer tracks - clip tracks - groups - script tracks - audio tracks Layer rows can be split into main, position, and rotation subtracks. Keyframe selection is held by `src/composables/useKfSelection.ts` and is currently keyed as `layerId:kind:keyframeId`, so camera keyframes need either an expanded selection key model or a small camera-specific selection model. ### Playback And Interpolation Playback sampling is centralized for layer content in `src/lib/interpolate.ts`: - `sampleLayer(layer, time)` - `sampleKeyframeSet(...)` - easing: `linear`, `easeIn`, `easeOut`, `easeInOut` - `hold` keyframes - `kind: 'end'` visibility markers - separated position/rotation support - wiggle applied after keyframe sampling The camera should reuse the same easing and hold semantics, but not become a `Layer`. It is not content and should not be affected by masks, groups, visibility tails, asset rendering, or layer z-order. ### Editor Stage Rendering `src/components/CanvasStage.vue` uses Konva/vue-konva for the editable stage. Important stage pieces: - `viewConfig` applies editor zoom/pan. - `clipGroupConfig` applies the user's stage object crop toggle. - `stackItems` samples layers/clips at `store.playhead` and renders editable Konva nodes. - selection and manipulation route through `selectedLayerId`, `selectedLayerIds`, `selectedClipInstanceId`, and the Konva transformer. - `drawPreview()` paints a separate mask-preview canvas using `drawSceneFrame()`. The camera overlay belongs in `CanvasStage.vue` inside the zoomed/panned `viewConfig` group, but outside content rendering. It should not alter `viewConfig`, `clipGroupConfig`, `stackItems`, or object transforms. ### Preview And Export Rendering There are multiple render paths: | Surface | Current code | Notes | | --- | --- | --- | | Editable stage | `CanvasStage.vue` Konva tree | Editor-only, interactive. Must not render through camera. | | Mask preview canvas | `CanvasStage.vue` `drawPreview()` -> `drawSceneFrame()` | Editor aid. Should not become camera preview unless explicitly changed later. | | Fullscreen preview | `FullscreenPreview.vue` -> `mountPlayer()` -> `src/lib/exportPlayer.ts` | This is final preview and should render through camera. | | Still/GIF/APNG | `stillImageExport.ts`, `gifExport.ts`, `apngExport.ts`, `frameExport.ts` -> `drawSceneFrame()` | TypeScript canvas path. | | WebM | `videoExport.ts` -> realtime canvas -> `drawSceneFrame()` | TypeScript canvas path plus `MediaRecorder`. | | MP4 server | `server/server.js` -> `server/render.html` -> Puppeteer screenshot | Separate vanilla JS renderer. | | MP4 Electron | `electron/renderWorker.cts` -> `server/render.html` -> `capturePage()` | Same render page as server. | | Runtime/example HTML export | `src/lib/exportPlayer.ts` generated runtime | Separate generated JS renderer. | Camera work must be mirrored across the TypeScript canvas renderer, generated runtime renderer, and `server/render.html`, otherwise preview/export parity will diverge. ## Data Model Proposal Use one optional camera per `ScenePage` in v1. This matches scene-specific framing and avoids making a global project camera that every page must share. Add camera types in `src/types/scene.ts`: ```ts export interface CameraProps { /** Top-left stage coordinate of the visible rectangle. */ x: number y: number width: number height: number } export interface CameraKeyframe { id: string time: number props: Partial easing: Easing hold?: boolean } export interface CameraTrack { id: string name?: string enabled: boolean base: CameraProps keyframes: CameraKeyframe[] /** Editor overlay flags only. They do not affect preview/export. */ overlayVisible?: boolean locked?: boolean } ``` Add optional fields: ```ts export interface Scene { camera?: CameraTrack } export interface ScenePage { camera?: CameraTrack } ``` The store's merged `scene()` getter should include `camera: page.camera`. `createBlankScenePage()` should omit `camera` by default. A camera is created only when the user enables/adds it. ### Why Not A Normal Layer? Do not store the camera as a `Layer`. A normal layer would: - require a fake `Asset` - participate in z-order and grouping - be affected by masks/editor-hidden/export-hidden behavior - collide with layer selection, deletion, duplication, and asset logic - risk being exported as visible scene content The camera should instead be a special page-level track with keyframe semantics similar to a layer, but rendering semantics completely separate from scene content. ### Defaults And Backward Compatibility Missing camera means disabled camera. Effective default when no camera exists: ```ts { enabled: false, base: { x: 0, y: 0, width: scene.canvas.width, height: scene.canvas.height, }, keyframes: [], } ``` When the user first enables the camera, create a `CameraTrack` with `enabled: true` and a single keyframe at the current playhead or at `0`. Prefer `0` for predictable exports unless the user explicitly adds a keyframe from the timeline. Old project files remain unchanged: - `project.camera` and `page.camera` can be missing. - `parseProject()` does not need a hard migration for missing camera data. - `normalizeProject()` should validate existing camera data when present and drop/repair invalid values. - `serializeProject()` can keep `FILE_VERSION = 2` unless the team wants a schema-version marker for camera. The feature is optional and additive. Invalid camera fallback: - missing width/height or non-finite values: treat camera as disabled for that render - width/height less than `1`: clamp to `1` - missing base: synthesize stage-bounds base - malformed keyframe props: ignore only those malformed props ## Sampling And Framing Helpers Add `src/lib/camera.ts` as the shared TypeScript source of truth: ```ts export function defaultCameraProps(scene: Pick): CameraProps export function isCameraEnabled(scene: Scene): boolean export function sampleCamera(scene: Scene, time: number): CameraProps export function cameraAspect(scene: Scene, time?: number): number export function renderSceneFrameForOutput( ctx: CanvasRenderingContext2D, scene: Scene, time: number, images: Record, options?: { width: number height: number useCamera?: boolean cropStage?: boolean }, ): void ``` `sampleCamera()` should mirror `sampleLayer()` semantics for numeric props: - sort camera keyframes by time - merge keyframe props over `base` - apply easing from the later keyframe - honor `hold` - clamp width/height to positive values Do not apply wiggle, layer active ranges, `end` keyframes, masks, or separated tracks to the camera. ## Rendering Strategy ### Axis-Aligned Camera Only In V1 Do not support camera rotation in v1. Rotation is possible later by applying an inverse camera transform, but it touches every render path and has tricky edge cases: - output bounds for rotated rectangles - shadows and blurs clipped by rotated camera edges - MP4 render page parity - pointer hit-testing in runtime preview - UI handles and resize math V1 should support `x`, `y`, `width`, and `height` only. Add a future migration slot for `rotation`, but do not render or expose it yet. ### Camera Transform For camera-enabled output, render the stage through an inverse camera transform onto the output canvas: ```ts ctx.clearRect(0, 0, outputWidth, outputHeight) ctx.save() ctx.scale(outputWidth / camera.width, outputHeight / camera.height) ctx.translate(-camera.x, -camera.y) drawSceneFrame(ctx, scene, time, images, { crop: true }) ctx.restore() ``` This keeps object coordinates unchanged. The camera changes only how stage coordinates map into final pixels. This approach is preferred over rendering a full-stage bitmap and cropping it because it preserves vector/text/shadow fidelity when the export output is larger than the stage or when the camera zooms into a small area. ### Aspect Ratio Policy V1 should lock the camera aspect ratio to the final output aspect. Default final output aspect is the current stage aspect: ```ts scene.canvas.width / scene.canvas.height ``` When camera is enabled: - the camera frame starts at the stage bounds - resize handles preserve aspect ratio - width/height keyframes should preserve the same aspect when created through the UI - export target height is derived from the camera/output aspect If a loaded file contains a mismatched camera aspect, rendering should still be safe. The fallback policy should be: - map the sampled camera rectangle to the fixed output frame - show a non-blocking warning in camera properties - do not mutate the file automatically This fallback may stretch content. The UI should prevent creating such frames in v1. ### Output Size No-camera behavior remains exactly as today: - still/GIF/APNG target size defaults to `scene.canvas` - WebM/MP4 output defaults to `scene.canvas` - fullscreen/example HTML aspect uses `scene.canvas` Camera-enabled v1: - final output still defaults to `scene.canvas.width` by `scene.canvas.height` - the camera rect is mapped into that output frame - existing projects without enabled camera export identically Future improvement: extend MP4/WebM/still/GIF/APNG target-size controls so all media formats can explicitly export through the camera at custom dimensions. ### Empty Areas And Bounds Allow camera rectangles outside the stage. Expected output: - pixels inside the stage render normally - pixels outside the stage are transparent for alpha-capable paths - MP4/JPG and other opaque paths flatten empty pixels using the current opaque export behavior, effectively white in the MP4 render windows today Do not clamp camera `x`/`y` to the stage. Animators may intentionally pan past the canvas edge. ### Shadows, Masks, Effects, Clips, Groups The camera should be applied after normal scene compositing. Implications: - masks keep their existing behavior inside the stage render - groups and track order remain unchanged - shadows and blurs may be clipped by the camera edge, just as they are currently clipped by the stage edge - clip instances remain in stage coordinates and are only framed by the camera - nested button/SVG assets render normally before camera framing Do not make the camera a mask layer. It is a final output transform. ### Render Path Changes TypeScript canvas paths: - `src/lib/render.ts`: keep `drawSceneFrame()` as the full-stage renderer. - `src/lib/camera.ts`: add the output-camera wrapper. - `src/lib/frameExport.ts`: call the wrapper when camera is enabled. - `src/lib/stillImageExport.ts`: same wrapper for snapshots. - `src/lib/videoExport.ts`: create the recording canvas at output size and draw through the wrapper each animation frame. Fullscreen/runtime paths: - `src/components/FullscreenPreview.vue`: compute aspect ratio from the camera output aspect when camera is enabled. - `src/lib/exportPlayer.ts`: mirror camera sampling and camera transform in the generated runtime. - `generateExampleHtml()` should use camera aspect for the `.stage` aspect-ratio when the exported payload has an enabled camera. - Runtime button hit-testing must use the post-camera draw matrix. The existing `buttonInstancesInFrame` matrix capture should work if the camera transform is applied before drawing layers, but this needs an explicit test. MP4 paths: - `server/render.html`: mirror the camera sampling/transform. - `server/server.js`: keep no-camera capture exactly as today. With camera enabled, capture the render page's full output canvas after the render page has mapped the camera into it. - `electron/renderWorker.cts`: same as server, because it loads `server/render.html`. - `shared/renderValidation.cjs`: include camera keyframes in keyframe count limits and validate camera width/height if camera is enabled. ## Editor Interaction Design ### Modes V1 needs three practical modes: 1. **Normal stage editing** - full stage visible - all objects editable as today - camera frame appears as an overlay when enabled and overlay is visible - editor zoom/pan/crop controls unchanged 2. **Camera frame editing** - selecting the camera frame selects the camera, not objects behind it - moving/resizing the frame upserts camera keyframes at the playhead - object positions and object keyframes are untouched 3. **Preview through camera** - fullscreen/final preview renders only the camera result - normal stage stays unchanged after exiting preview Do not make the main editor stage become a camera viewport in v1. ### Store State Add small editor selection state in `src/stores/scene.ts`: ```ts selectedCamera: boolean ``` or a slightly more future-proof: ```ts selectedSpecialTrack: 'camera' | null ``` Update `useDeselectAll()` to clear it. Selecting a layer or clip instance should clear camera selection, and selecting the camera should clear layer and clip selection. ### Canvas Overlay Add camera overlay rendering to `CanvasStage.vue`: - draw the frame after stage content and canvas border - place it inside the zoomed `viewConfig` group so it follows the same stage coordinates as objects - do not place it inside the cropped content group, so the frame/handles remain visible even when it extends outside the stage - use stroke-only hit testing with a generous `hitStrokeWidth` - keep the frame interior non-listening so objects behind the camera remain easy to select - show resize handles when selected and unlocked - use screen-stable stroke width/handle size via `1 / zoom` Suggested visual treatment: - cyan/blue frame stroke - dashed outside-frame guide or subtle corner ticks - small label only on hover/selection if needed - no filled overlay by default ### Timeline Add a special camera row above normal content tracks in `Timeline.vue`. V1 behavior: - one row named `Camera` - only appears after camera is enabled, or appears as a disabled/add row in the timeline header - keyframes show as camera-specific diamonds - double-click lane adds a camera keyframe - drag keyframes with the existing snap system - context menu supports easing, hold, duplicate, delete - lock/hide controls apply only to editing/overlay visibility, not render output Do not include the camera in `trackOrder`. It is not stack content and should not be groupable, draggable into groups, masked, hidden as content, duplicated as a layer, or converted into a clip. ### Properties Panel Add a `selectedCamera` branch in `PropertiesPanel.vue`: - enabled toggle - overlay visible toggle - locked toggle - X/Y - Width/Height - aspect lock indicator - keyframe status at playhead - remove camera keyframe at playhead Keep camera properties separate from layer properties. Do not show fill, stroke, mask, shadow, effects, or delete-layer actions for the camera. ## UX Behavior When camera is enabled: - the editor still shows the full stage - the camera frame is visible unless `overlayVisible === false` - dragging the camera frame feels like moving a crop/framing rectangle - objects behind the camera do not move - object selection remains predictable because only the frame stroke/handles intercept pointer events Selection priority: - camera handles win when directly hit - camera frame stroke wins when directly hit - clicks inside the camera frame pass through to objects - empty-stage clicks clear camera/layer/clip/keyframe selection Lock behavior: - locked camera cannot be dragged, resized, or keyframed from the stage - locked camera can still affect preview/export if enabled - locked camera overlay may remain visible as a guide Hide behavior: - hiding the overlay hides only the editor guide - hidden overlay does not disable the camera - disabled camera does not affect preview/export ## Export Behavior Exports should evaluate camera state at the same timestamp used to evaluate the scene frame. For each output frame: 1. Determine render time using the existing playback range and fps logic. 2. Sample scene objects normally. 3. Sample camera at the same time. 4. If camera is enabled and valid, draw scene content through the camera transform. 5. If camera is missing/disabled, run the current render path exactly as today. Old exports remain unchanged because missing/disabled camera bypasses the new wrapper. ### Format Notes - **PNG/JPG still:** snapshot current playhead through camera if enabled. - **GIF/APNG:** use camera for every frame in `renderFrames()`. - **WebM:** realtime `MediaRecorder` canvas should be the final output frame, with every animation frame drawn through camera. - **MP4 server/Electron:** `server/render.html` must render through camera so both Puppeteer screenshot and Electron `capturePage()` capture the same pixels. - **Runtime/example HTML:** generated playback should use camera. Raw `.ruw`, project JS, and runtime JS exports preserve camera data rather than baking pixels. ### Target Size And MP4 Current MP4 export accepts fps but not target width/height from the export dialog. Do not add unrelated MP4 size controls in the first camera patch unless needed. With v1 camera enabled, MP4 output can remain `scene.canvas.width` by `scene.canvas.height`, with the sampled camera rect mapped into that frame. ## Backward Compatibility And Project Safety Required guarantees: - old project files load without migration prompts - missing `camera` means disabled camera - no-camera render output is byte/pixel-equivalent where practical - editor viewport state remains editor-only - camera movement never mutates `Layer`, `ClipInstance`, or asset coordinates - hidden/locked camera editor flags do not accidentally strip render output Implementation safeguards: - normalize camera data in `normalizeProject()` and `normalizeScenePage()` - never synthesize a saved camera unless the user enables it - keep default camera stage-bounds and disabled - include camera in `duplicateScene()` with fresh camera/keyframe ids - include camera in undo/redo snapshots naturally through `Project` - update `.ruw` packing indirectly by storing camera in project JSON ## Implementation Phases ### Phase 1: Camera Helpers And Data Shape Files likely touched: - `src/types/scene.ts` - `src/stores/scene.ts` - `src/lib/camera.ts` - `src/devtools/tests.ts` Changes: - add `CameraProps`, `CameraKeyframe`, `CameraTrack` - add optional `camera` to `Scene` and `ScenePage` - include camera in the store's merged `scene()` getter - add `createDefaultCameraTrack(scene, enabled?)` - add `sampleCamera()`, `defaultCameraProps()`, validation helpers - normalize existing camera payloads defensively Risks: - accidentally saving default disabled cameras into every old project - duplicating scenes without fresh camera keyframe ids Acceptance: - old project with no camera loads and serializes without behavior changes - enabling camera creates a valid full-stage frame - `sampleCamera()` interpolates x/y/width/height with easing and hold ### Phase 2: Output Render Wrapper Proof Of Concept Files likely touched: - `src/lib/camera.ts` - `src/lib/frameExport.ts` - `src/lib/stillImageExport.ts` - `src/devtools/tests.ts` Changes: - implement `renderSceneFrameForOutput()` - keep `drawSceneFrame()` as the full-stage primitive - switch still and frame exports to the wrapper - no-camera path should preserve existing transform behavior Risks: - clear/clip behavior with camera outside stage - mask offscreen buffers with transformed contexts Acceptance: - no-camera render tests pass unchanged - camera at full stage matches no-camera pixels - camera cropping a quadrant maps that quadrant into the output frame - camera outside stage leaves expected empty pixels ### Phase 3: Store Actions And Selection Files likely touched: - `src/stores/scene.ts` - `src/composables/useDeselectAll.ts` - `src/devtools/tests.ts` Changes: - add selected-camera state - add actions: - `enableCamera()` - `disableCamera()` - `selectCamera()` - `setCameraOverlayVisible()` - `setCameraLocked()` - `upsertCameraKeyframe(time, props)` - `removeCameraKeyframeAt(time)` - `removeCameraKeyframe(id)` - `setCameraKeyframeTime(id, time)` - `setCameraKeyframeEasing(id, easing)` - `setCameraKeyframeHold(id, hold)` Risks: - overlapping with layer selection and keyframe selection - undo snapshots missing editor-only selection clears Acceptance: - selecting camera clears layer and clip selection - selecting layer/clip clears camera selection - locked camera cannot be edited by camera actions meant for UI gestures - camera keyframes can be added, moved, removed, and sampled ### Phase 4: Editor Stage Camera Overlay Files likely touched: - `src/components/CanvasStage.vue` - `src/i18n/messages.ts` - `src/devtools/tests.ts` Changes: - render camera frame overlay - add frame drag to update x/y at playhead - add resize handles preserving aspect ratio - add live camera pose state during drag - avoid interior hit interception - hide overlay when `overlayVisible === false` Risks: - camera frame stealing clicks from objects behind it - handles resizing the editor viewport by mistake - overlay clipped by the stage content crop group Acceptance: - moving camera does not change editor zoom/pan - moving camera does not mutate object positions - clicks inside frame still select objects - frame stroke/handles select and edit camera - overlay follows editor zoom/pan visually as a stage-space guide ### Phase 5: Camera Timeline Track Files likely touched: - `src/components/Timeline.vue` - `src/composables/useKfSelection.ts` or a new camera kf selection helper - `src/i18n/messages.ts` - `src/devtools/tests.ts` Changes: - add special Camera row above content tracks - add camera keyframe dots and segments - support add/move/delete/duplicate/easing/hold - include camera keyframe times in timeline snapping - do not include camera in `trackOrder`, groups, layer menus, or clip conversion Risks: - overloading existing `layerId` keyframe selection with a fake camera id - accidentally making camera draggable/groupable as a content track Acceptance: - camera keyframes are distinguishable from object keyframes - camera kf drag snaps like object kfs - camera row lock blocks edits - disabling camera leaves row/keyframes intact but render output bypasses camera ### Phase 6: Camera Properties Panel Files likely touched: - `src/components/PropertiesPanel.vue` - `src/i18n/messages.ts` - `src/devtools/tests.ts` Changes: - add selected-camera branch - expose enabled, overlay visible, locked, x/y/width/height - show keyframe-at-playhead status - remove camera keyframe at playhead - warn if loaded camera aspect does not match output aspect Risks: - camera selected but properties panel still showing stale layer properties - writing width/height without aspect lock Acceptance: - selected camera shows camera properties only - edits create/update camera keyframes at playhead - locked camera shows read-only state - no layer delete/fill/mask controls appear for camera ### Phase 7: Fullscreen Preview And Runtime Export Files likely touched: - `src/components/FullscreenPreview.vue` - `src/lib/exportPlayer.ts` - `src/devtools/tests.ts` Changes: - compute fullscreen aspect from camera output aspect when enabled - mirror camera sampling in generated runtime - apply camera transform before drawing scene content - make example HTML aspect-ratio use camera aspect when enabled - ensure button hit-testing still works through the camera transform Risks: - fullscreen preview diverging from media exports - runtime button hit areas offset when camera is not at `0,0` - scene switches need per-page camera state Acceptance: - fullscreen preview shows cropped/framed camera output - normal editor stage remains unchanged before/after preview - generated example HTML matches fullscreen preview framing - runtime button clicks work when camera is panned/zoomed ### Phase 8: WebM And MP4 Integration Files likely touched: - `src/lib/videoExport.ts` - `server/render.html` - `server/server.js` - `electron/renderWorker.cts` - `shared/renderValidation.cjs` - `server/render.e2e.test.cjs` - `scripts/verify-*.cjs` Changes: - WebM draws through the TypeScript camera wrapper - `server/render.html` mirrors camera sampling and transform - MP4 server and Electron continue to capture full output frame - validation counts camera keyframes and rejects invalid enabled camera sizes Risks: - MP4 render page drifting from TypeScript renderer - Electron hidden-window dimensions and camera output dimensions diverging - odd output dimensions still require ffmpeg pad behavior Acceptance: - camera-enabled MP4 server export matches TypeScript still-frame sample within tolerance - camera-enabled Electron MP4 export matches server path within tolerance - no-camera MP4 still passes existing render harnesses - camera keyframes are evaluated per frame, not once at export start ### Phase 9: Polish, Documentation, And Cleanup Files likely touched: - `src/i18n/messages.ts` - `src/devtools/tests.ts` - `dev_docs/*` as needed Changes: - final copy/tooltips - help text for camera behavior - remove temporary flags/debug code - document v1 limitations: one camera, no rotation, aspect locked Risks: - unclear UX around overlay hide vs camera disable Acceptance: - user can discover how to enable, edit, preview, and export through camera - no visible in-app text claims the editor stage itself is camera view - tests cover old-project no-camera behavior and camera-enabled behavior ## Concrete Testing Plan ### Unit / Helper Tests Add in `src/devtools/tests.ts` or a future pure test file: - `defaultCameraProps()` equals stage bounds. - missing camera means disabled. - enabled full-stage camera renders same pixels as no camera. - `sampleCamera()` clamps invalid width/height. - `sampleCamera()` interpolates x/y/width/height linearly. - `sampleCamera()` honors easeIn/easeOut/easeInOut. - `sampleCamera()` honors hold keyframes. - camera keyframes do not affect `sampleLayer()`. ### Project Safety Tests - `parseProject()` loads v1/v2 files without camera. - `serializeProject()` round-trips a project with camera. - `projectFromLegacyScene()` leaves camera missing/disabled. - `duplicateScene()` gives cloned camera/keyframes fresh ids. - `loadProject()` normalizes malformed camera data without crashing. - undo/redo snapshots restore camera track changes. ### Editor UI Tests - enabling camera shows overlay frame. - hiding overlay hides only the frame, not camera render output. - selecting camera clears layer/clip selection. - selecting layer clears camera selection. - camera frame interior does not block object selection behind it. - camera frame stroke selects camera. - dragging camera frame upserts camera x/y at playhead. - resizing camera preserves aspect ratio. - locked camera cannot be dragged/resized. - normal `canvasZoom`/`canvasViewX`/`canvasViewY` are unchanged after camera movement. ### Timeline Tests - camera row appears when enabled. - camera row is not part of `trackOrder`. - camera row cannot be grouped, duplicated as a layer, or converted to a clip. - double-click camera lane adds a camera keyframe. - camera keyframe drag snaps to playhead/object keyframes. - camera keyframe context menu applies easing/hold/delete/duplicate. - disabling camera keeps keyframes but bypasses render framing. ### Render And Export Tests - no-camera still/GIF/APNG/WebM use old full-stage output. - full-stage camera output equals no-camera output. - panned camera crops expected pixels. - zoomed camera scales expected pixels into full output. - camera outside stage leaves transparent/empty pixels. - camera keyframes change crop over time in animated exports. - fullscreen preview matches still export at the same time. - generated example HTML uses camera aspect/framing. - WebM recording canvas dimensions stay fixed while camera moves. - MP4 server render samples camera per frame. - Electron MP4 render samples camera per frame. - camera keyframes are included in render validation limits. ### Edge Cases - camera width/height near `1`. - camera exactly stage bounds. - camera partially outside all four stage edges. - object shadow crossing camera edge. - masks inside camera. - top-level masks and grouped masks. - clip instances inside and outside camera. - button hit-testing through camera in runtime preview. - background video scenes, preserving current limitations/behavior. - odd output dimensions still encode through MP4 pad filter. ## Risks And Mitigations - **Preview/export diverge from editor behavior:** keep editor stage unchanged and add explicit fullscreen/export tests. The editor is a guide; output surfaces own camera rendering. - **Render paths diverge from each other:** implement camera once in `src/lib/camera.ts`, then mirror the smallest possible helper in `exportPlayer.ts` and `server/render.html`. Add parity tests. - **Camera frame interferes with object selection:** make only stroke/handles listening; keep the interior pass-through. - **Hit-testing ambiguity:** camera selection has priority only on direct frame hits; timeline/properties provide non-spatial selection fallback. - **Aspect mismatch:** lock aspect in v1; tolerate malformed loaded data without crashing. - **Shadows/effects clipped unexpectedly:** document that camera is a final frame edge, like the current stage edge. Do not expand camera bounds for effects in v1. - **MP4 render page has separate logic:** update `server/render.html` in the same phase as TypeScript render wrapper and add server/Electron capture tests. - **Schema breakage:** optional per-page `camera`; missing camera is disabled; no eager migration required. - **Over-refactoring:** do not replace `Layer`, `trackOrder`, or Konva stage architecture. Add a special camera path only where final framing is needed. ## Open Questions 1. Should camera be per scene page only, or should there later be an option to share one project-wide camera across all pages? 2. Should MP4/WebM get target-size controls now, or should v1 keep current scene-canvas output dimensions and only change framing? 3. Should the UI expose arbitrary camera aspect ratios later, and if so should export dimensions follow camera aspect or preserve an independent output aspect? 4. Should camera overlay visibility be saved per project, or should it behave like an editor preference only? 5. Should rotation become v2, and should it rotate the camera frame itself or only add a rendered output transform? ## Recommended V1 Scope Ship the smallest robust version: - one optional camera per scene page - x/y/width/height only - aspect locked to output/stage aspect - no rotation - normal editor stage unchanged - camera overlay in editor - camera row in timeline - camera properties branch - fullscreen preview and all media/runtime exports render through camera - old projects and no-camera exports unchanged This keeps the feature local and preserves the core rule: the camera frames the final output, not the editor viewport.