1019 lines
33 KiB
Markdown
1019 lines
33 KiB
Markdown
# 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<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:
|
|
|
|
```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<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:
|
|
|
|
```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.
|