33 KiB
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.widthbyscene.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.vueandsrc/lib/exportPlayer.ts. This does not mean the existingstagePreviewbutton-mode toggle inCanvasStage.vue.
Current Architecture
Scene And Object Model
Primary types live in src/types/scene.ts.
Projectis the saved top-level container.- Project-wide shared fields include
canvas,duration,fps,inTime,outTime,assets, and the clip library. - Each
ScenePageowns page-specific content:layers,clipInstances,clipTracks,scriptTracks,scriptFrames,markers,groups,trackOrder,backgroundVideo,audioTracks, andaudioClips. useSceneStore().scenereturns a mergedSceneview combining project shared fields with the active page. Many render/export paths consume this mergedScene.
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 ClipTracks; 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: AnimatablePropsLayer.keyframes: Keyframe[]- optional
Layer.positionKeyframes - optional
Layer.rotationKeyframes Layer.separatedswitches 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 holdkeyframeskind: '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:
viewConfigapplies editor zoom/pan.clipGroupConfigapplies the user's stage object crop toggle.stackItemssamples layers/clips atstore.playheadand renders editable Konva nodes.- selection and manipulation route through
selectedLayerId,selectedLayerIds,selectedClipInstanceId, and the Konva transformer. drawPreview()paints a separate mask-preview canvas usingdrawSceneFrame().
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:
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<CameraProps>
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:
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:
{
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.cameraandpage.cameracan 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 keepFILE_VERSION = 2unless 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 to1 - 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:
export function defaultCameraProps(scene: Pick<Scene, 'canvas'>): 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<string, HTMLImageElement>,
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:
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:
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.widthbyscene.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: keepdrawSceneFrame()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.stageaspect-ratio when the exported payload has an enabled camera.- Runtime button hit-testing must use the post-camera draw matrix. The existing
buttonInstancesInFramematrix 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 loadsserver/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:
-
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
-
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
-
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:
selectedCamera: boolean
or a slightly more future-proof:
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
viewConfiggroup 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:
- Determine render time using the existing playback range and fps logic.
- Sample scene objects normally.
- Sample camera at the same time.
- If camera is enabled and valid, draw scene content through the camera transform.
- 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
MediaRecordercanvas should be the final output frame, with every animation frame drawn through camera. - MP4 server/Electron:
server/render.htmlmust render through camera so both Puppeteer screenshot and ElectroncapturePage()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
camerameans 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()andnormalizeScenePage() - 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
.ruwpacking indirectly by storing camera in project JSON
Implementation Phases
Phase 1: Camera Helpers And Data Shape
Files likely touched:
src/types/scene.tssrc/stores/scene.tssrc/lib/camera.tssrc/devtools/tests.ts
Changes:
- add
CameraProps,CameraKeyframe,CameraTrack - add optional
cameratoSceneandScenePage - 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.tssrc/lib/frameExport.tssrc/lib/stillImageExport.tssrc/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.tssrc/composables/useDeselectAll.tssrc/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.vuesrc/i18n/messages.tssrc/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.vuesrc/composables/useKfSelection.tsor a new camera kf selection helpersrc/i18n/messages.tssrc/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
layerIdkeyframe 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.vuesrc/i18n/messages.tssrc/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.vuesrc/lib/exportPlayer.tssrc/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.tsserver/render.htmlserver/server.jselectron/renderWorker.ctsshared/renderValidation.cjsserver/render.e2e.test.cjsscripts/verify-*.cjs
Changes:
- WebM draws through the TypeScript camera wrapper
server/render.htmlmirrors 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.tssrc/devtools/tests.tsdev_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/canvasViewYare 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 inexportPlayer.tsandserver/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.htmlin 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
- Should camera be per scene page only, or should there later be an option to share one project-wide camera across all pages?
- Should MP4/WebM get target-size controls now, or should v1 keep current scene-canvas output dimensions and only change framing?
- Should the UI expose arbitrary camera aspect ratios later, and if so should export dimensions follow camera aspect or preserve an independent output aspect?
- Should camera overlay visibility be saved per project, or should it behave like an editor preference only?
- 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.