From 23a66c6fdf740302ca7c9a7bb3fabb1a0a76745d Mon Sep 17 00:00:00 2001
From: th
Date: Sun, 7 Jun 2026 23:12:46 +0200
Subject: [PATCH] Add output camera framing
---
dev_docs/10_kamera.md | 1018 ++++++++++++++++++++++++++
index.html | 2 +-
server/render.html | 59 ++
shared/renderValidation.cjs | 33 +
shared/renderValidation.test.cjs | 31 +
src/components/CanvasStage.vue | 78 ++
src/components/FullscreenPreview.vue | 9 +-
src/components/PropertiesPanel.vue | 128 +++-
src/components/Timeline.vue | 107 +++
src/composables/useDeselectAll.ts | 1 +
src/devtools/tests.ts | 26 +-
src/i18n/messages.ts | 12 +
src/lib/camera.ts | 192 +++++
src/lib/exportPlayer.ts | 90 ++-
src/lib/frameExport.ts | 16 +-
src/lib/stillImageExport.ts | 21 +-
src/lib/videoExport.ts | 13 +-
src/stores/scene.ts | 132 +++-
src/types/scene.ts | 30 +
19 files changed, 1955 insertions(+), 43 deletions(-)
create mode 100644 dev_docs/10_kamera.md
create mode 100644 src/lib/camera.ts
diff --git a/dev_docs/10_kamera.md b/dev_docs/10_kamera.md
new file mode 100644
index 0000000..6cc33cb
--- /dev/null
+++ b/dev_docs/10_kamera.md
@@ -0,0 +1,1018 @@
+# 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.
diff --git a/index.html b/index.html
index 6f96641..2f050c3 100644
--- a/index.html
+++ b/index.html
@@ -9,7 +9,7 @@
RUWIO
diff --git a/server/render.html b/server/render.html
index cf88f2e..4b89fc1 100644
--- a/server/render.html
+++ b/server/render.html
@@ -102,6 +102,56 @@
}
return merge(layer.base);
}
+ function defaultCameraProps(scene){
+ return {
+ x:0,
+ y:0,
+ width:Math.max(1, Number(scene.canvas && scene.canvas.width) || 1280),
+ height:Math.max(1, Number(scene.canvas && scene.canvas.height) || 720)
+ };
+ }
+ function mergeCameraProps(base, props){
+ props=props||{};
+ function finite(v, fallback){ return typeof v==='number' && isFinite(v) ? v : fallback; }
+ return {
+ x:finite(props.x, base.x),
+ y:finite(props.y, base.y),
+ width:Math.max(1, finite(props.width, base.width)),
+ height:Math.max(1, finite(props.height, base.height))
+ };
+ }
+ function lerpCameraProps(a,b,t){
+ return {
+ x:lerp(a.x,b.x,t),
+ y:lerp(a.y,b.y,t),
+ width:Math.max(1, lerp(a.width,b.width,t)),
+ height:Math.max(1, lerp(a.height,b.height,t))
+ };
+ }
+ function sampleCamera(scene,time){
+ var camera=scene.camera;
+ var base=defaultCameraProps(scene);
+ if(!camera) return base;
+ base=mergeCameraProps(base, camera.base);
+ var kfs=(camera.keyframes||[]).slice().sort(function(a,b){return a.time-b.time;});
+ if(!kfs.length) return base;
+ if(time<=kfs[0].time) return mergeCameraProps(base,kfs[0].props);
+ var last=kfs[kfs.length-1];
+ if(time>=last.time) return mergeCameraProps(base,last.props);
+ for(var i=0;i=a.time && time<=b.time){
+ if(a.hold){
+ if(time>=b.time) return mergeCameraProps(base,b.props);
+ return mergeCameraProps(base,a.props);
+ }
+ var raw=(time-a.time)/Math.max(1e-9,b.time-a.time);
+ var tt=easing(b.easing,raw);
+ return lerpCameraProps(mergeCameraProps(base,a.props), mergeCameraProps(base,b.props), tt);
+ }
+ }
+ return base;
+ }
function loadImages(assets){
var imgs={};
var list=assets.filter(function(a){return a.kind==='image';});
@@ -267,8 +317,17 @@
function drawFrame(time){
if(!scene) return;
var lw=scene.canvas.width, lh=scene.canvas.height;
+ var useCamera=!!(scene.camera && scene.camera.enabled);
+ var cam=useCamera?sampleCamera(scene,time):null;
+ ctx.setTransform(1,0,0,1,0,0);
+ ctx.clearRect(0,0,canvas.width,canvas.height);
ctx.save();
ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip();
+ if(useCamera){
+ ctx.scale(lw/cam.width, lh/cam.height);
+ ctx.translate(-cam.x,-cam.y);
+ ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip();
+ }
if(!scene.backgroundVideo){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
diff --git a/shared/renderValidation.cjs b/shared/renderValidation.cjs
index 337a855..8f991b1 100644
--- a/shared/renderValidation.cjs
+++ b/shared/renderValidation.cjs
@@ -71,6 +71,39 @@ function validateRenderRequest(scene, options = {}, limits = DEFAULT_LIMITS) {
}
}
}
+ if (scene.camera != null) {
+ const camera = scene.camera
+ if (typeof camera !== 'object') return { ok: false, error: 'camera must be an object' }
+ if (camera.enabled) {
+ const cw = Number(camera.base?.width)
+ const ch = Number(camera.base?.height)
+ if (!Number.isFinite(cw) || cw <= 0 || !Number.isFinite(ch) || ch <= 0) {
+ return { ok: false, error: 'enabled camera must have positive width and height' }
+ }
+ }
+ if (camera.keyframes != null) {
+ if (!Array.isArray(camera.keyframes)) {
+ return { ok: false, error: 'camera keyframes must be an array' }
+ }
+ keyframes += camera.keyframes.length
+ if (keyframes > limits.maxKeyframes) {
+ return { ok: false, error: `scene has more than ${limits.maxKeyframes} keyframes` }
+ }
+ for (const kf of camera.keyframes) {
+ if (!Number.isFinite(Number(kf?.time))) {
+ return { ok: false, error: 'keyframe times must be finite numbers' }
+ }
+ const w = kf?.props?.width
+ const h = kf?.props?.height
+ if (w != null && (!Number.isFinite(Number(w)) || Number(w) <= 0)) {
+ return { ok: false, error: 'camera keyframe width must be positive' }
+ }
+ if (h != null && (!Number.isFinite(Number(h)) || Number(h) <= 0)) {
+ return { ok: false, error: 'camera keyframe height must be positive' }
+ }
+ }
+ }
+ }
const duration = Number(scene.duration)
if (!Number.isFinite(duration) || duration <= 0 || duration > limits.maxDuration) {
return { ok: false, error: `duration must be between 0 and ${limits.maxDuration} seconds` }
diff --git a/shared/renderValidation.test.cjs b/shared/renderValidation.test.cjs
index bbc4f92..b84f21f 100644
--- a/shared/renderValidation.test.cjs
+++ b/shared/renderValidation.test.cjs
@@ -56,6 +56,37 @@ test('rejects invalid crf and preset', () => {
assert.equal(validateRenderRequest(scene(), { preset: 'turbo' }).ok, false)
})
+test('validates enabled camera data', () => {
+ const valid = validateRenderRequest(scene({
+ camera: {
+ enabled: true,
+ base: { x: 0, y: 0, width: 320, height: 180 },
+ keyframes: [{ time: 0.5, props: { x: 10 }, easing: 'linear' }],
+ },
+ }))
+ assert.equal(valid.ok, true)
+
+ const invalidSize = validateRenderRequest(scene({
+ camera: {
+ enabled: true,
+ base: { x: 0, y: 0, width: 0, height: 180 },
+ keyframes: [],
+ },
+ }))
+ assert.equal(invalidSize.ok, false)
+ assert.match(invalidSize.error, /camera/)
+
+ const invalidKf = validateRenderRequest(scene({
+ camera: {
+ enabled: true,
+ base: { x: 0, y: 0, width: 320, height: 180 },
+ keyframes: [{ time: Number.NaN, props: {}, easing: 'linear' }],
+ },
+ }))
+ assert.equal(invalidKf.ok, false)
+ assert.match(invalidKf.error, /keyframe/)
+})
+
test('computes playback range from in/out or last keyframe', () => {
assert.deepEqual(playbackRange(scene({ inTime: 0.25, outTime: 1.5 })), { start: 0.25, end: 1.5 })
assert.deepEqual(playbackRange(scene()), { start: 0, end: 1 })
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index 06522c9..6c1e38c 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -30,12 +30,14 @@ import {
shouldRotateClipCopies,
} from '@/lib/kaleido'
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
+import { sampleCamera } from '@/lib/camera'
import type {
Action,
AnimatableProps,
Asset,
ButtonAsset,
ButtonState,
+ CameraProps,
ClipInstance,
HitShape,
Keyframe,
@@ -762,6 +764,67 @@ const clipGroupConfig = computed(() => {
}
})
+const cameraVisibleInEditor = computed(() => {
+ if (store.activeClipId || store.activeButtonAssetId || store.activeSvgAssetId) return false
+ const camera = store.scene.camera
+ return !!camera && camera.overlayVisible !== false
+})
+
+const cameraFrame = computed(() => {
+ if (!cameraVisibleInEditor.value) return null
+ return sampleCamera(store.scene, store.playhead)
+})
+
+const cameraFrameConfig = computed(() => {
+ const frame = cameraFrame.value
+ const camera = store.scene.camera
+ if (!frame || !camera) return null
+ const selected = store.selectedCamera
+ return {
+ id: 'camera-frame',
+ name: 'camera-frame',
+ x: frame.x,
+ y: frame.y,
+ width: frame.width,
+ height: frame.height,
+ fillEnabled: false,
+ stroke: selected ? '#38bdf8' : '#0ea5e9',
+ strokeWidth: (selected ? 2.5 : 1.5) / zoom.value,
+ strokeScaleEnabled: false,
+ dash: [10 / zoom.value, 6 / zoom.value],
+ hitStrokeWidth: Math.max(10 / zoom.value, 8),
+ listening: true,
+ draggable: selected && !camera.locked,
+ }
+})
+
+function selectCameraFromStage(e?: Konva.KonvaEventObject) {
+ if (e) e.cancelBubble = true
+ store.selectCamera(true)
+}
+
+function onCameraDragMove(e: Konva.KonvaEventObject) {
+ const camera = store.scene.camera
+ if (!camera || camera.locked) return
+ const node = e.target
+ store.upsertCameraKeyframe(store.playhead, {
+ x: Math.round(node.x()),
+ y: Math.round(node.y()),
+ })
+}
+
+function onCameraDragEnd(e: Konva.KonvaEventObject) {
+ onCameraDragMove(e)
+}
+
+function toggleCameraEnabled() {
+ if (store.scene.camera?.enabled) {
+ store.disableCamera()
+ return
+ }
+ store.enableCamera()
+}
+
/** Horizontal + vertical centre-crosshair line configs. Null when
* the toggle is off. Lines span the effective canvas so the centre
* is exactly where layer keyframes default to (canvas.width/2,
@@ -4723,6 +4786,13 @@ function onResizeHandlePointerUp(e: PointerEvent) {
+
+