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) { + +