Compare commits

...
37 Commits
Author SHA1 Message Date
th 7093e1f6d2 Update fullscreen preview and camera output 2026-06-08 18:15:27 +02:00
th 9b314ad817 Unify switch controls 2026-06-08 10:57:00 +02:00
th b9a4b53109 Add camera resize and keyframe controls 2026-06-08 00:33:57 +02:00
th 3a93d6ea5d Add camera frontend tests 2026-06-07 23:58:05 +02:00
th 23a66c6fdf Add output camera framing 2026-06-07 23:12:46 +02:00
th 616ced17ce Enable frontend tests in Electron 2026-06-07 15:58:18 +02:00
th b18e66c28d Add export environment plan 2026-06-07 13:24:34 +02:00
th f469ab7444 Clarify export runtime copy 2026-06-07 12:10:35 +02:00
th 8d296c963e Update development doc references 2026-06-07 11:10:05 +02:00
th 00ee563311 Add Electron E2E harness launcher 2026-06-07 11:09:39 +02:00
th fa8c52b239 Split renderer bundles by feature 2026-06-07 11:08:41 +02:00
th 7bf18636d6 Fix desktop packaging artifact build 2026-06-07 11:08:15 +02:00
th ba58ebb48d Bump version to 1.3.1 2026-06-07 11:06:50 +02:00
th 196aa2e8ba rename docs 2026-06-07 10:46:34 +02:00
thandClaude Opus 4.8 518d43855b Add desktop/server MP4 export pipeline, hardening, and render tests (v1.3.0)
Desktop MP4 export renders frames from a hidden BrowserWindow via
capturePage, pipes PNGs to a bundled ffmpeg (libx264/yuv420p/+faststart),
and writes the file through an atomic temp-file swap. It shares scene
validation (shared/renderValidation.cjs) and the render.html renderer with
the standalone Puppeteer server, which is hardened in parallel.

Render worker & main (electron/):
- abortable render loop with an inactivity watchdog and cancel plumbing
- atomic temp-file + rename so a cancel/failure never truncates a
  pre-existing target (#7); stdin EPIPE / early-exit / backpressure-hang
  handled (#8)
- HiDPI frames normalized to canvas size (#10); odd dimensions padded to
  even (#13, also server); backgroundThrottling disabled (#12)
- reject canvases larger than the display (#11); single-render guard;
  isolated render session that blocks network requests (#5)

Tests:
- Electron harnesses (scripts/verify-*.cjs) for capture, export, cancel,
  timeout, assets, background, capture-fidelity, resolution, odd-dims,
  aggregated by verify-desktop-render.cjs
- server smoke + e2e and shared validation unit tests
- headless hook (src/devtools/headless.ts) to run the in-app suite under
  browser/electron hosts; pixel-diff util + capture fixtures (test/)
- dev_docs plans: integration, fixes (#7-#13), and the e2e/capture strategy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:41:25 +02:00
th 46f9f32aef Add security review and remediation plan 2026-06-05 20:07:19 +02:00
th b536e8cf73 Bump version to 1.2.15 2026-06-05 20:07:05 +02:00
th fae55e3f13 Harden render server and import boundaries 2026-06-05 20:05:32 +02:00
th 5cc373cf6b bump 2026-06-05 17:43:42 +02:00
th 32cc3ecec3 Polish Electron desktop shell 2026-06-05 17:34:50 +02:00
th 70cf131c47 Make kaleido pivot keyframable 2026-06-05 14:31:31 +02:00
th c646f66e8f Add Electron desktop shell 2026-06-05 14:11:44 +02:00
thandClaude Opus 4.7 42c5f68182 1.2.13: README + Effects merge + draggable kaleido pivot for layers
README
------
Initial README.md at repo root with the existing src/assets/logo.png
header, the editor screenshot from src/assets/ruwio_screenshot.png,
a feature-highlights list, quickstart, stack, project layout, and
the in-app test runner shortcut (Ctrl+Shift+Alt+T).

Effects panel merge
-------------------
Wiggle and Kaleidoscope used to live in two separate collapsible
sections. Now they share one "Effects" section with a thin border
between the two sub-blocks (`.effect-block + .effect-block`),
keeping their independent toggles + controls. Same change for the
ClipInstance variant. Section preference key flows through one
`effects` slot instead of two.

Layer kaleido pivot
-------------------
Until now only ClipInstance kaleido had a separate pivot;
Layer kaleido was locked to the layer's own (x, y). Layers now
get optional `kaleidoPivotX/Y` too, mirroring the ClipInstance
contract.

- types/scene.ts: optional pivot fields on Layer.
- lib/kaleido.ts: layerKaleidoPivot + layerArmTransform helpers
  computing per-arm world (x, y, rotation) around the override.
- lib/render.ts: drawLayers + drawMaskShape route through
  layerArmTransform.
- lib/exportPlayer.ts: byte-for-byte JS mirror layerArmTf inside
  the engine source. RUNTIME_VERSION 32 → 33.
- components/CanvasStage.vue: withK now takes the layer +
  computes rotated x/y/rotation per arm; honours the
  liveKaleidoPivot ref so a drag updates all copies in real
  time. Generic kaleidoPivotHandle handles both clip-instance
  and layer entities (KaleidoPivotEntity discriminated union);
  manual window-level pointermove drives the drag because
  Konva's `draggable: true` didn't fire dragmove for shapes
  inside the clipFunc'd canvas group here.
- stores/scene.ts: setLayerKaleidoPivot(id, patch | null) with
  null-clear semantics.
- components/PropertiesPanel.vue: two pivot X/Y inputs in a
  `.row` inside the layer kaleido sub-block, defaulting to the
  sampled position. All kaleido inputs (count, offset, pivot)
  switched from @change to @input so the stage repaints live
  while the user types or spins the value.

Test fixes
----------
- "PropertiesPanel kaleido offset writes back through the mode"
  dispatched a synthetic 'change' event; the input now binds to
  @input. Updated to dispatch 'input'.
- "PropertiesPanel: Wiggle section toggles the wiggle field" —
  section open-state persists across tests via the panel's
  localStorage pref. Test now resets the key before mount and
  only clicks the header when the body isn't already shown,
  then restores the prior value.
- "PropertiesPanel: layer kaleido pivot inputs render + write
  back live" used a global value-50 filter that also matched the
  Transform section's X/Y inputs at the sampled position (50, 50).
  Scoped the search to the Effects section's body + its `.row`.

Tests added (6 new under "layer kaleido pivot — separate orbit centre"):
- layerArmTransform math: defaults vs explicit override.
- store roundtrip + null-clear of setLayerKaleidoPivot.
- drawSceneFrame integration: 4 cardinal pixel checks for a
  20×20 red rect at (160, 100), pivot (100, 100), 4 arms —
  sampled at the rotated body centres.
- PropertiesPanel: two pivot inputs render at the sampled
  position; @input writes pivotX while pivotY stays default.
- CanvasStage smoke: pivot handle paths render when a kaleido-
  on layer is selected (plus toggle-off cleanup).
- Engine source carries layerArmTf and bumps RUNTIME_VERSION
  to '33'. Earlier '32' assertions in the kaleido + wiggle
  suites also bumped to '33'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:26:56 +02:00
th b88d174bcb cleanup 2026-04-26 21:39:11 +02:00
th 367dd68e49 readme 2026-04-26 21:36:20 +02:00
th e47a982492 readme 2026-04-26 21:33:40 +02:00
th ceaf1db434 readme 2026-04-26 21:25:12 +02:00
thandClaude Opus 4.7 184842b2e0 1.2.12: eyedropper — sample colours from anywhere on the page
Tiny progressive-enhancement feature using the browser's native
EyeDropper API (Chromium 95+). Adds a small pipette button next
to every colour swatch in the properties panel; clicking it opens
the OS picker and applies the chosen sRGBHex to the bound colour
property.

- src/composables/useEyeDropper.ts: returns { supported, pick }.
  `supported` re-detects on every call (not a singleton) so a late-
  loaded polyfill or a test-time monkey-patch is observed without
  juggling Vite's module cache. `pick()` resolves to the chosen
  hex string, returns null on cancel / abort / missing API.
- PropertiesPanel: PhEyedropper button on each of the three colour
  rows (fill, stroke, shadow). v-if="eyeDropperSupported" hides
  the button entirely on Safari + Firefox — clean PE. Fill and
  stroke buttons disable when their "Transparent" toggle is on.
  Picked colour writes through the same fillColor / strokeColor /
  shadowColorValue computeds, so the keyframe path / asset writer
  is identical to a color-input drag.
- properties.eyedropper i18n string.

Tests (7 new under "eyedropper — color sampling from anywhere on the page"):
- supported reflects window.EyeDropper presence at call time.
- pick() resolves with the sRGBHex from a stubbed EyeDropper.
- pick() returns null on AbortError.
- pick() returns null when the API is missing.
- PropertiesPanel renders eyedropper buttons when supported.
- PropertiesPanel hides them when EyeDropper is undefined.
- Clicking the fill eyedropper applies the picked colour through
  the layer's keyframe / asset writer.

`claude.txt` backlog complete (wiggle / motion-paths / eyedropper
all shipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:21:14 +02:00
thandClaude Opus 4.7 a2fdd1684a 1.2.11: motion-path overlay (toggle in stage view-options) + type-check cleanup
Motion-path overlay
-------------------
A toggleable always-on visualization of the selected layer's
position-keyframe trajectory. Pairs with the existing kf-selection-
driven path that only appears when a keyframe is actively selected.

- src/composables/useShowMotionPaths.ts: shared ref + localStorage
  ('animatr:showMotionPaths'), default off.
- CanvasStage.vue: new motionPathOverlay computed builds dashed
  segments + a dot per keyframe for store.selectedLayerId, but only
  when the toggle is on AND no kf is explicitly selected (the kf
  view wins — it's richer with drag handles). Color: lilac
  (#a47bff) so it doesn't collide with the blue kf-selection path.
- Click a dot → seek the playhead to that keyframe's time. Cheap
  navigation aid for layers with many keyframes.
- Toggle button placed in the stage's existing zoom/view-controls
  toolbar (alongside crop / mask preview / centre crosshair / stage
  preview), not the app topbar — that's where the canvas-scoped
  view modifiers already live.

Tests (4 new under "motion-path overlay — toggle + selected-layer rendering"):
- showMotionPaths persists to localStorage.
- Toggle on with two position kfs renders the overlay; toggling
  off returns to a clean canvas.
- kf-selection path takes precedence over the toggle (mount smoke
  with both paths active simultaneously).
- The motion-paths button in .zoom-controls flips the ref.

Type-check cleanup
------------------
The 7 pre-existing TS errors that piled up over the last few patch
bumps. None affected runtime, but vue-tsc was complaining:

- src/lib/clipBounds.ts: assetFootprint read width/height directly
  off ButtonAsset, which actually has canvas.width/canvas.height.
  Split the branch and added the same handling for SvgAsset.
- src/lib/render.ts: instancesByTrack was typed via
  `typeof scene.clipInstances` (which is optional) → values were
  ClipInstance[] | undefined. Wrapped in NonNullable.
- src/lib/ruwFile.ts: BackgroundVideo has no `name` field. Synthesised
  one from page.id so the extracted file gets a readable filename
  (`scene-bg-${page.id}`).
- src/devtools/tests.ts (2 sites): polygon test fixtures missed the
  required `fill` field — added `fill: '#0f0'`.
- src/devtools/tests.ts (1 site): TS 5 widens
  `Uint8Array<ArrayBufferLike>` past BlobPart's union; cast to
  unknown as BlobPart at the new File call.

`npm run type-check` is now clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:08:49 +02:00
thandClaude Opus 4.7 9e25cf155a 1.2.10: wiggle — deterministic procedural shake modifier per layer
Per-layer modifier that adds noise to the sampled position and
rotation each frame. Pairs thematically with kaleido: both stack
on top of the keyframe sample, both per-layer, both deterministic
so editor and exported runtime render identically.

Math (src/lib/wiggle.ts):
- hash32 + layerSeeds(layerId): three uncorrelated 32-bit seeds per
  layer, derived from the id so the same layer always wobbles the
  same way.
- wiggleNoise(seed, t): sum-of-three-sines in [-1, 1]. Smooth (C∞),
  cheap, no noise-lib dependency. Phase constants are irrational
  ratios picked so the streams don't collapse into a low-period
  pattern.
- applyWiggle(props, layer, time): adds amp * noise(seed, t*freq)
  to x / y / rotation. Defaults: 8px position, 2° rotation, 2Hz.

Wire-in:
- src/lib/interpolate.ts sampleLayer: post-processes the keyframe
  sample with applyWiggle. Every renderer goes through sampleLayer
  (canvas2d, Konva, button-state, clip-internal), so the modifier
  lights up everywhere from a single hook.
- src/lib/exportPlayer.ts engine: byte-for-byte JS mirror of
  hash32 / layerSeeds / wiggleNoise / applyWiggle, plus the same
  post-process branch in the engine's sample(). RUNTIME_VERSION
  31 → 32.

Data + store:
- Layer.wiggle?: { amplitude?, rotation?, frequency? }. All sub-
  fields optional with the documented defaults.
- store.setLayerWiggle(id, patch | null): patch object updates
  individual fields with magnitude clamping (≥ 0); null deletes
  the whole wiggle field.

UI (PropertiesPanel + i18n):
- New "Wiggle" section between Mask and Kaleidoscope. Toggle
  (PhWaveSine icon) seeds the pleasant defaults; three RangeSliders
  for position amplitude (0-64 px), rotation amplitude (0-45°),
  and frequency (0-10 Hz). Hidden for button-asset layers
  (consistent with Kaleido's hit-area constraint).

Tests (8 new under "wiggle — procedural shake modifier"):
- wiggleNoise determinism + [-1, 1] bounds across 4 seeds × ~100
  time points.
- layerSeeds stability + the three seeds are pairwise distinct;
  different ids yield different triples.
- applyWiggle on a layer without `wiggle` is a no-op (returns the
  input reference unchanged).
- sampleLayer with wiggle: drift visible across a 1s window and
  always within the configured amplitude bands.
- sampleLayer with wiggle is deterministic — same time, same drift.
- setLayerWiggle roundtrip + magnitude clamp + null-delete.
- PropertiesPanel Wiggle toggle writes the wiggle field with the
  documented defaults.
- Exported runtime carries wiggleNoise / applyWiggle / wiggleSeeds
  and bumps RUNTIME_VERSION to '32'.

Plan for the next two backlog items (motion-path overlay + canvas
eyedropper) is in claude.txt for later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:24:23 +02:00
thandClaude Opus 4.7 7d81a50e74 1.2.9: global seconds/frames toggle + sane number-input widths/rounding
The Timeline ruler's existing s/fr toggle (store.editor.timelineDisplayMode)
now drives every time display and time input across the editor. Mixing
"frames here, seconds there" was inconsistent — pick the unit once, see
it everywhere.

New composable (src/composables/useTimeFormat.ts):
- formatTime(seconds, opts) → "1.42s" or "42 fr" (short variant drops
  trailing zeros for ruler-style ticks).
- timeToInputValue / inputValueToSeconds — paired conversions used at
  every <input type="number"> bound to a time. Storage stays in seconds
  (renderer + .ruw files unchanged); conversion happens at the UI edge.
- inputStep ('0.01' / '1') and unitLabel ('seconds' / 'frames') for
  consistent input attributes / labels.

Surfaces switched over:
- App.vue topbar playhead + duration.
- PropertiesPanel keyframe-here label (`@ 1.50s` → `@ 45 fr`).
- FullscreenPreview time label.
- AssetSidebar clip + audio asset durations.
- Timeline audio clip + clip trim tooltips (route through formatKfTime,
  which already honoured the toggle).
- DocumentDrawer scene duration input — step + min flip with the mode,
  label gets a (seconds)/(frames) suffix.
- ScriptFrameDialog time input — same flip.
- ActionEditor jumpTo time input — same flip. (jumpToFrame stays as
  frames since "frame" is its semantic, not a unit choice.)
- Kaleido offset on ClipInstance — now follows the global toggle too,
  was hardcoded frames before. The frames-by-default UX comes from
  the user defaulting to 'frames' on the ruler; switching to seconds
  shows the same offset in seconds without losing precision.

Number-input audit (cleaning up "ewig viele Nachkommastellen"):
- ScriptFrameDialog time: rounded display to ≤3 decimals at the edge.
- ActionEditor jumpTo time: same.
- PropertiesPanel kaleido pivot X/Y (ClipInstance): rounded to int.
- RangeSlider strokeWidth format: snapped to 0.5 increments.

Number-input widths (capping the panel sprawl):
- PropertiesPanel inputs: max-width 96px + tabular-nums; .row inputs
  (side-by-side pivot X/Y, transform X/Y) keep flex-share.
- ActionEditor .params input[type='number']: explicit 96px.
- ScriptFrameDialog .row input[type='number']: explicit 96px.
- DocumentDrawer + ExportDialog: already had explicit widths.

Tests (6 new under "time format — global seconds/frames toggle"):
- useTimeFormat composable roundtrips through both modes at fps=30.
- PropertiesPanel keyframe label flips on mode change.
- DocumentDrawer duration input flips step + value (2s ↔ 60fr).
- ActionEditor jumpTo flips step + value (1s ↔ 24fr at fps=24).
- Kaleido offset display switches between modes for the same stored
  value (0.5s ↔ 15fr).
- Kaleido offset write-back: typing "30" in frames mode at fps=30
  stores exactly 1.0s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:01:54 +02:00
thandClaude Opus 4.7 80e1510b53 1.2.8: kaleidoscope — radial repeat for layers + clips with delayed-start arms
New per-entity property that renders content N times around a pivot.
For clip instances each radial copy can also be staggered in time, so
arm i starts playing the clip i*offset frames later than the source.

Data model (src/types/scene.ts):
- Layer: kaleido?, kaleidoCount?
- ClipInstance: kaleido?, kaleidoCount?, kaleidoOffset?, kaleidoRotateCopies?,
  kaleidoPivotX?, kaleidoPivotY?
All fields optional; legacy projects unchanged.

Math (src/lib/kaleido.ts):
- kaleidoCountFor / kaleidoAngleDegrees, clipKaleidoPivot, clipArmTranslate.
- clipArmLocalTime: returns the per-arm clip-local time. For arm i with
  offset dt, the arm shows frame (offsetIntoBlock - i*dt) — visible only
  when that falls inside [0, trimmedDuration]. Returns null otherwise so
  the caller can hide pre-start and post-end arms.

Render parity:
- src/lib/render.ts: extracted renderClipInstance helper used by both the
  top-level scene loop and drawTrackGroup. drawLayers + drawMaskShape now
  loop the kaleido count and add the per-copy angle to the layer rotation
  (Konva-style "rotate around (x,y)" comes for free).
- src/lib/exportPlayer.ts: mirror in JS (kalCount, kalAngle, clipKaleidoPivot,
  clipArmLocalT, clipArmTrans, drawClipInstance). drawLayer + drawMaskShape
  wrapped the same way. RUNTIME_VERSION 28 → 31 (3 fixes during landing).
- src/components/CanvasStage.vue: stackItems now emits one bundle per
  kaleido arm with arm-specific items sampled at the arm's localT, so the
  Konva live preview shows the staggered start in the editor — not just
  in the fullscreen preview / export. Source arm (armIndex 0) keeps the
  selection / drag / transform handlers; non-source arms are listening:
  false. New clipInstanceKaleidoGroupConfig handles the per-arm transform
  with rotateCopies on/off semantics. withK helper augments per-layer
  configs with the kaleido rotation.

UI (src/components/PropertiesPanel.vue + i18n):
- New "Kaleidoscope" collapsible section. Layer mode: toggle + repeat.
  Clip-instance mode (selected via canvas, panel banner explains the
  partial editability): toggle, repeat, start offset (frames — converted
  to seconds via scene.fps before storage), pivot X/Y, rotate-copies
  toggle. Hidden for button-asset layers (hit-area routing breaks if
  multiplied).

Store (src/stores/scene.ts):
- setLayerKaleido / setLayerKaleidoCount with [2, 64] integer clamp,
  setClipInstanceKaleido with clear-semantics for offset=0 and
  rotateCopies=true (deletes the field so JSON stays clean).

Tests (8 new under "kaleidoscope — store + render + engine"):
- Roundtrip + clamp for both Layer and ClipInstance setters.
- drawSceneFrame: 4 cardinal-pixel kaleido smoke for static layer.
- Button asset bypasses kaleido (hit-area routing intact).
- ClipInstance roundtrip + clear-semantics for offset=0.
- clipArmLocalTime: arm 0 honours bounds (returns null when source
  itself is past), delayed arms outlive the source's window when
  their shifted time is still in [0, dur].
- drawSceneFrame integration: 4-arm clip with 0.5s offset; sample at
  t=0 / t=0.5 / t=1.5 verifies staircase visibility (1 → 2 → 3 arms).
- Engine source carries kalCount, kalAngle, drawClipInstance,
  clipKaleidoPivot, clipArmLocalT; RUNTIME_VERSION '31'.
- layerActiveAt regression — kaleido fields don't break visibility gate.

Plus the prior export-runtime test updated to read drawClipInstance's
inst.{x,y,scaleX,scaleY,rotation} variables instead of the old inline
inst2.* names that the kaleido refactor removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:18:16 +02:00
thandClaude Opus 4.7 27d6a47b72 1.2.7: 6 more bundled fonts + custom-font upload + layerActiveAt parity
Built-in additions: Montserrat, Nunito, Figtree, Gabarito (sans),
Archivo Black (heavy display), Press Start 2P (pixel/retro).

Custom font upload (lib/fonts.ts):
- uploadCustomFont(file) reads .woff2/.woff/.ttf/.otf via FileReader,
  registers a FontFace early so a corrupt file fails before storage,
  then persists {id, label, dataUrl} to localStorage under
  'animatr:customFonts'. Quota errors surface a friendly message.
- removeCustomFont(id) drops the FontFace + storage entry. If the
  asset still pointed at the deleted font, the picker resets it to
  System Sans on the calling side.
- AVAILABLE_FONTS becomes a ComputedRef combining BUILTIN_FONTS with
  the reactive customFontsState. fontFamilyMapSnapshot() rebuilds the
  {id → CSS family} map per export so user-uploaded fonts make it
  into the exported runtime.

FontPicker UI:
- Two sections divided by a thin rule — built-ins on top, "Your fonts"
  below — plus an "Upload font…" entry at the bottom that opens a
  hidden file input. Custom rows show a trash button on hover.
- Auto-selects a freshly uploaded font.

Engine source becomes a function (engineSource()) instead of a const,
so each export materializes the latest FONT_FAMILY map. ENGINE_TEMPLATE
holds the static skeleton with a __FONT_FAMILY_MAP__ placeholder.

Example HTML hint:
- Built-in fonts list their .woff2 path; custom fonts get a
  "locally uploaded; @font-face below has the file inlined as a data
  URL" line. The fontFaceCss snippet inlines data URLs for custom
  entries so it's self-contained.

Bug fixes (player layerActiveAt):
- Mirrors interpolate.ts isLayerActiveAt now. The previous
  `if(total<=1) return true` short-circuit made layers with a single
  future keyframe visible at t=0; the lastHold→Infinity heuristic
  ignored end keyframes entirely. Both regressions affected the
  fullscreen preview and standalone exports. Replaced with a strict
  scan: min = earliest non-end kf, max = earliest 'end' kf or +Inf.
  Static layers (no normal kfs) stay always-visible.

Tests (5 new under "text font picker — model + render + export"):
- 6 new built-in ids in FONT_FAMILIES map.
- uploadCustomFont registers + persists + appears in AVAILABLE_FONTS;
  removeCustomFont reverses it. Stubs FontFace and document.fonts.add
  for the test environment via a stubFontFaceForTest() helper.
- uploadCustomFont rejects unsupported extensions.
- Exported runtime FONT_FAMILIES includes uploaded custom font ids.
- FontPicker popover shows the Upload font… entry.
- New "exported runtime layerActiveAt mirrors editor isLayerActiveAt"
  extracts the function from engineSource() and exercises five
  scenarios (single future kf, two normal kfs no end, end kf cuts
  off, static layer always-visible, end kf wins over separated tracks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:40:26 +02:00
thandClaude Opus 4.7 8c4f744afc 1.2.6: dedicated FontPicker component with in-line type previews
Replaces the bare <select> with a custom combobox that fits the
properties panel's visual language:

- Trigger looks like the other inputs (var(--bg)/var(--border)/
  var(--radius-sm)) and shows the selected font's name rendered in
  that font itself, with a rotating caret.
- Options live in a teleported popover anchored beneath the trigger.
  Each option renders its own label in its own typeface so the user
  sees what they're picking before applying it. Active row uses
  var(--accent-soft) to match the rest of the editor.
- Click-outside closes via window pointerdown; Escape closes and
  returns focus to the trigger.
- Storage shape simplified: textFontModel uses '' for system-sans
  instead of the literal 'system-sans' id, so the model field round-
  trips through JSON without a placeholder string ever leaking.

Updated PropertiesPanel test to walk the new picker (click trigger,
read options from .font-popover, click an option) instead of
dispatching a select 'change' event. Picker label text doubles as
the test selector — System Sans / Inter / Permanent Marker confirm
the option list is wired up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:27:19 +02:00
thandClaude Opus 4.7 4c2b7c7c35 1.2.5: text font picker + 7 bundled Google fonts + default text fill black
- New src/lib/fonts.ts: curated set (Inter, Roboto, Lora, Playfair
  Display, Bebas Neue, Source Code Pro, Permanent Marker) shipped as
  local .woff2 in public/fonts/. loadFonts() registers them via
  FontFace at app start; fontsReady ref re-triggers Konva text configs
  once fonts resolve so the first paint isn't stuck on the fallback.
- TextAsset gets optional fontFamily (id; undefined = system sans).
  PropertiesPanel adds a font dropdown next to font size.
- All five render sites read the family: CanvasStage textConfig +
  clipTextConfig (Konva), drawLayer + drawMaskShape (canvas2d),
  exportPlayer engine (uses an embedded FONT_FAMILIES map and fontFam
  helper; runtime version 27 → 28).
- Example HTML export now lists used bundled fonts and prints the
  @font-face CSS the user has to paste alongside the .woff2 files —
  fonts themselves are NOT bundled into the export to keep it small.
- Default text fill changed from yellow #f5d76e to black #000000 in
  both the main sidebar and the SVG-scope add-text path.
- 8 new tests under "text font picker — model + render + export".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:32:42 +02:00
thandClaude Opus 4.7 b32b8a6a3d 1.2.4: mask cascade strict bottom-up + clip-internal masks
Audited masking against the four-scope spec (groups, main timeline,
clips, SVG-internal). Rule 2 was already correct; rules 1, 3, 4 needed
fixes:

- drawMaskedGroup now walks children bottom-up and applies each mask
  to the offscreen buffer immediately, so a mask only affects layers
  below it in the stack — layers above the mask render unmasked on top.
- drawTrackGroup rewritten with an Item discriminated union collected
  bottom-up, then a single z-order loop renders/applies, replacing the
  old "collect all masks → apply at end" pattern.
- Both clip-rendering sites (drawSceneFrame top-level clip path and
  drawTrackGroup's clip-child path) switched from drawLayers to
  drawMaskedGroup — previously a mask layer inside a clip was rendered
  as a regular shape instead of masking.
- 5 new tests under "mask layers — rendering" cover the four-rule
  spec (group below-only, group negative below-only, clip-scoped mask,
  SVG-asset mask scoped to interior, clip-internal layers above mask
  unmasked).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:05:40 +02:00
th 127812d3bf 1.2.3: track-name shift/ctrl select, polygon level-1/level-2, cross-scope polygon ops
- Track-name click semantics in the Timeline:
  - plain → single-select that layer
  - shift → range-select every parent layer between anchor and click
    (locked + locked-group children are filtered out)
  - ctrl/cmd → toggle the clicked layer in/out of the selection
  Anchor moves to the just-clicked id like a file manager.
- Polygon / freeform selection now lands at "level 1" — the regular
  size + rotation transformer attaches like any other shape. A
  double-click promotes to level 2 (vertex handles + edge-insert
  dbl-click + tangent handles); selecting another layer drops back
  to level 1. Same UX inside the SVG sub-editor.
- New layersUsingAsset(project, assetId) helper walks every layer
  collection in the project (scenes, clips, button assets, svg
  assets). All six polygon-mutation actions (addPolygonPoint,
  removePolygonPoint, setPolygonPointCount, reversePolygonPoints,
  movePolygonVertex, insertPolygonPointOnEdge) now use it, so
  changes to the asset's points propagate to kf.props.points on
  layers in any scope. Fixes "no node added on dblclick of a
  freeform line inside an SVG asset".
- SvgLayerPanel buttons reset padding:0 — the global button reset
  was eating the 22×22 row-button area with box-sizing:border-box
  and clipping the Phosphor icons out of view.
2026-04-25 23:39:19 +02:00
115 changed files with 19577 additions and 999 deletions

No files matched your search

+9
View File
@@ -1,7 +1,9 @@
node_modules
dist
dist-electron
dist-ssr
dist-archive
release
*.local
.vite
@@ -16,3 +18,10 @@ dist-archive
/logo.xcf
/logo_v2.xcf
/ruwio.png
.codex
misc/
# Locally staged ffmpeg binaries. Release-managed binaries can be force-added.
/bin/linux/ffmpeg
/bin/darwin/ffmpeg
/bin/win32/ffmpeg.exe
+154
View File
@@ -0,0 +1,154 @@
<p align="center">
<img src="src/assets/logo.png" alt="RUWIO" width="320" />
</p>
<p align="center">
<strong>A timeline-based animation editor that runs in your browser.</strong>
</p>
<p align="center">
<img src="src/assets/ruwio_screenshot.png" alt="RUWIO editor screenshot" width="100%" />
</p>
---
RUWIO is a browser-native authoring tool for short interactive animations.
It pairs a Konva-driven canvas with a keyframe timeline, exports a
self-contained HTML/JS bundle, and ships everything as a Vue 3 single-page
app you can serve from any static host.
## Highlights
- **Keyframes everywhere** — animate position, rotation, scale, fill, stroke,
shadow, and shape-specific properties. Optional separated position /
rotation tracks. End keyframes terminate a layer cleanly.
- **Effects**
- **Kaleidoscope** — radial repeats of a layer or a clip instance,
with per-arm time offset for time-pinwheel effects.
- **Wiggle** — deterministic procedural shake (position + rotation).
- **Masks** — destination-in / destination-out cascade, scoped to
track groups, clips, and SVG assets.
- **Asset kinds** — rect, circle, polygon (with vertex / bezier editing),
freeform path, text (14 bundled Google fonts + custom .woff2 / .ttf
upload), images, audio, button-with-states, nested SVG assets, and
reusable clips with their own internal timeline.
- **Scenes** — multiple pages per project; jumpToScene / jumpTo / jumpToFrame
scripted actions; markers; per-scene background video and audio tracks.
- **Render parity** — the canvas2d offline renderer, the Konva live stage,
and the exported runtime all share the same sampling math. What you see
on stage is what gets exported.
- **Eyedropper** colour picking, **motion-path** overlay, global
**seconds / frames** display toggle, custom font upload to
localStorage — the polish kit.
## Quickstart
```bash
npm install
npm run dev # start the editor at http://localhost:5200
npm run type-check # vue-tsc strict pass
npm run build # production bundle to dist/
npm run pack # zip dist/ into dist-archive/
```
## Electron desktop build
RUWIO can also run as an Electron desktop app. The desktop shell uses the
same Vite-built app from `dist/` plus the compiled Electron entry points in
`dist-electron/`.
```bash
npm install
npm run desktop:dev # start Vite, then launch Electron against localhost
npm run desktop:dir # build an unpacked desktop app into release/
npm run desktop:pack # build platform package artifacts into release/
npm run desktop:build # build platform installer/package artifacts into release/
```
`desktop:dir` is the quickest compile check because it produces an unpacked app
without creating an installer. `desktop:pack` and `desktop:build` use
`electron-builder` and target AppImage on Linux, NSIS on Windows, and DMG on
macOS.
Desktop MP4 export runs inside Electron and uses ffmpeg from
`resources/bin/<platform>/` in packaged builds. The build scripts run
`npm run stage:ffmpeg -- --optional`, which copies `FFMPEG_PATH` or `ffmpeg` on
`PATH` into `bin/<platform>/` when available. For release artifacts, replace
that local copy with redistributable static ffmpeg binaries for Linux, Windows,
and macOS.
If no bundled binary is present, MP4 export still falls back to `FFMPEG_PATH`
or system `ffmpeg` and reports a clear error when neither can be executed.
Before shipping bundled ffmpeg, document the binary source, version, configured
codecs, and GPL/LGPL obligations for that build.
## MP4 render server
MP4 export is handled by the optional render server in `server/`. It binds to
`127.0.0.1:6000` by default and requires a token before it will accept render
jobs.
```bash
cd server
npm ci
HOST=127.0.0.1 PORT=6000 RENDER_TOKEN="choose-a-long-random-token" npm start
```
Point the app at the server with matching Vite env values:
```bash
VITE_RENDER_SERVER=http://localhost:6000 VITE_RENDER_TOKEN="choose-a-long-random-token" npm run dev
```
The server exposes `HOST=0.0.0.0` only when explicitly requested. If you expose
it beyond localhost, set `RENDER_ALLOWED_ORIGINS` to the exact app origins that
should be allowed. Chromium sandboxing stays enabled by default; use
`CHROME_NO_SANDBOX=1` only for a deployment environment that cannot run the
sandbox.
The included `docker-compose.yml` keeps the published host port on
`127.0.0.1:6000` and requires `RENDER_TOKEN` in the environment before it will
start.
Render-server checks:
```bash
npm run test:render-server # health/auth/validation route smoke
npm run test:render-server:e2e # renders a tiny MP4 and verifies it with ffprobe
```
The E2E check requires Chromium, ffmpeg, and ffprobe. Set
`PUPPETEER_EXECUTABLE_PATH` when Chromium is not at `/usr/bin/chromium`.
## Stack
- Vue 3 (`<script setup>`) + Pinia state
- Konva for the live canvas (vue-konva bindings)
- Vite for dev / build
- TypeScript everywhere; tests live in `src/devtools/tests.ts`
and run inside the app via the in-browser test overlay
- `gifenc`, `upng-js` for image-sequence exports
## Project layout
```
src/
components/ Vue SFCs (CanvasStage, Timeline, PropertiesPanel, …)
composables/ Reusable hooks (theme, time-format, eyedropper, …)
lib/ Pure logic: render.ts, exportPlayer.ts, interpolate.ts,
wiggle.ts, kaleido.ts, ruwFile.ts (zip pack/unpack), …
stores/ Pinia stores
types/ Scene + project type definitions
i18n/ English + German translations
devtools/ In-app test runner + tests
public/
fonts/ Bundled .woff2 files
```
## Tests
The test runner lives inside the app. Open the editor (`npm run dev`)
and hit `Ctrl+Shift+Alt+T` to toggle the overlay; from there you can
run individual suites or the whole pile. The runner, all suites and
the components they exercise are plain TypeScript / Vue, so the tests
run the real code path — no separate harness.
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+16
View File
@@ -0,0 +1,16 @@
# ffmpeg release binaries
Desktop MP4 export looks for ffmpeg at:
- `bin/linux/ffmpeg`
- `bin/darwin/ffmpeg`
- `bin/win32/ffmpeg.exe`
These binaries are packaged into Electron resources as `resources/bin/<platform>/`.
Use `npm run stage:ffmpeg -- --source /path/to/ffmpeg` to copy a local binary
into the correct folder, or set `FFMPEG_PATH` when running the app to use an
external ffmpeg without staging it.
Release builds should use a redistributable static ffmpeg build for each target
platform. Record the source URL, version, configured codecs, and license terms
for every binary in this folder before publishing an installer.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

+228
View File
@@ -0,0 +1,228 @@
# Electron Desktop Plan
## Goal
Package RUWIO as a desktop app while keeping the existing Vue/Vite renderer as the primary application. The Electron layer should start small: host the built app, expose native filesystem dialogs, and later absorb the current server-side MP4 render workflow so desktop users do not need a separate render server.
## Current App Shape
- The UI is a Vue 3 + Vite single-page app in `src/`.
- The production web build is produced by `npm run build` into `dist/`.
- Project save/load is currently browser-driven:
- JSON project export/import lives in `src/lib/projectFile.ts`.
- `.ruw` bundle export/import lives in `src/lib/ruwFile.ts`.
- User-triggered file downloads use `Blob`, object URLs, and anchor clicks.
- Still/GIF/APNG style exports are implemented client-side.
- MP4 rendering is currently split out into `server/server.js`, using Express, Puppeteer Core, Chromium, and ffmpeg.
- The app already has PWA assets and static-host assumptions, which are compatible with an Electron renderer if paths are handled carefully.
## Recommended Architecture
Use a standard Electron split:
- `electron/main.ts`
- Creates the main `BrowserWindow`.
- Loads the Vite dev server during development.
- Loads `dist/index.html` in packaged builds.
- Owns native dialogs, app menu, recent files, and privileged filesystem/process work.
- `electron/preload.ts`
- Exposes a small typed API on `window.ruwioDesktop`.
- Uses `contextBridge` and IPC.
- Does not expose Node primitives directly.
- Vue renderer
- Remains browser-compatible.
- Detects the desktop API when available.
- Falls back to current browser file picker/download behavior on the web.
Keep Electron as an optional target rather than replacing the browser build. The same app should continue to run with `npm run dev`, `npm run build`, and static hosting.
## Package Additions
Add these development dependencies:
- `electron`
- `electron-builder` or `electron-forge`
- `concurrently` for local dev convenience
- `wait-on` if the Electron dev command waits for Vite
Recommended scripts:
```json
{
"dev": "vite",
"desktop:dev": "concurrently \"vite\" \"wait-on http://localhost:5200 && electron .\"",
"desktop:build": "npm run build && electron-builder",
"desktop:pack": "npm run build && electron-builder --dir"
}
```
The exact script shape can be adjusted once the Electron entry files exist.
## Phase 1: Minimal Desktop Shell
1. Add Electron source files under `electron/`.
2. Configure TypeScript output for the Electron main/preload files.
3. Create a secure `BrowserWindow`:
- `contextIsolation: true`
- `nodeIntegration: false`
- `sandbox: true` if preload needs stay small enough to support it
4. Load `http://localhost:5200` in development.
5. Load the built `dist/index.html` in production.
6. Set app name, icon, minimum window size, and basic lifecycle handling for macOS/Windows/Linux.
7. Add a basic app menu with New/Open/Save/Save As/Export/Quit placeholders.
Acceptance check:
- `npm run desktop:dev` opens the existing app.
- `npm run desktop:pack` creates an unpacked app.
- The normal browser dev/build flows still work.
## Phase 2: Desktop File API
Add IPC-backed file operations instead of immediately rewriting the app's save/load model.
Expose a preload API like:
```ts
window.ruwioDesktop = {
openProject(): Promise<{ name: string; bytes: Uint8Array } | null>,
saveProject(options: { suggestedName: string; bytes: Uint8Array }): Promise<{ path: string } | null>,
saveJson(options: { suggestedName: string; text: string }): Promise<{ path: string } | null>,
exportBlob(options: { suggestedName: string; bytes: Uint8Array }): Promise<{ path: string } | null>
}
```
Renderer changes:
- Add a small desktop adapter module, for example `src/lib/desktopBridge.ts`.
- In `downloadProject` and `downloadRuwFile`, use the desktop bridge when present and fall back to browser downloads otherwise.
- For loading, keep the current `<input type="file">` path as the web fallback and add desktop open dialog support for Electron.
- Preserve existing `.ruw` bundle logic. The desktop API should move bytes to and from disk, not duplicate parsing/packing rules.
Acceptance check:
- Open `.ruw` and legacy `.json` projects from a native dialog.
- Save `.ruw` projects through a native save dialog.
- Browser import/export behavior remains unchanged.
## Phase 3: Native Menus And App State
Wire menu actions to renderer commands through IPC.
Planned menu commands:
- New Project
- Open
- Save
- Save As
- Export HTML/JS bundle
- Export PNG/GIF/APNG/MP4 as available
- Undo/Redo if the existing history store can expose commands
- Toggle fullscreen/preview/test overlay where appropriate
Implementation notes:
- The main process should not mutate project state directly.
- The renderer should own project state and respond to menu events.
- Track the current file path in the desktop bridge or a small Pinia/composable integration.
- Add a dirty-state signal so the main process can confirm before closing a window with unsaved work.
Acceptance check:
- Menu shortcuts trigger the same code paths as toolbar buttons.
- Closing the app warns when there are unsaved changes.
- Recent files can be added after the open/save path is stable.
## Phase 4: Desktop MP4 Rendering
Replace the external render server requirement for desktop builds.
Preferred approach:
1. Move the core render job orchestration out of `server/server.js` into a reusable Node module.
2. Keep the existing Express server as a thin wrapper around that module for web/server deployments.
3. Call the same render module from Electron main via IPC.
4. Stream progress back to the renderer.
5. Save the resulting MP4 through a native save dialog.
Rendering dependencies:
- Bundle or locate Chromium. Electron already ships Chromium, but Puppeteer against Electron's browser runtime needs careful validation. The safer first version is to keep Puppeteer or Playwright with an explicit executable path.
- Bundle or locate ffmpeg. For a consumer desktop app, prefer an embedded ffmpeg binary per platform or document a clear install requirement until bundling is solved.
- Keep temporary frames/pipes in the OS temp directory, as the current server does.
Acceptance check:
- MP4 export works offline in the packaged desktop app.
- Progress, cancel, and failure states match the current server export UX.
- The existing `server/` deployment still runs.
## Phase 5: Packaging And Release
Configure packaging metadata:
- App id, product name, copyright.
- Icons for macOS, Windows, and Linux.
- File associations for `.ruw`.
- Build targets:
- macOS: `.dmg` or `.zip`
- Windows: `nsis`
- Linux: `AppImage` first, optionally `.deb`
- Include `dist/`, Electron compiled files, static assets, and any render binaries.
- Exclude source maps and development-only files unless needed for debugging.
Later release tasks:
- Code signing and notarization for macOS.
- Windows signing.
- Auto-update channel if RUWIO will ship outside app stores.
- CI builds for all target platforms.
Acceptance check:
- A clean checkout can produce a packaged desktop artifact.
- The artifact launches without a local dev server.
- File associations open `.ruw` projects when double-clicked.
## Security Rules
- Keep Node access out of the Vue renderer.
- Use `contextBridge` with a narrow allowlist.
- Validate IPC payloads in the main process.
- Do not pass arbitrary file paths from renderer to main without checks.
- Restrict navigation to the app origin in development and local files in production.
- Disable or guard `window.open`.
- Keep the render process isolated from arbitrary project content where possible, especially if imported SVG/HTML assets can contain script-like payloads.
## Risk Areas
- Asset paths under `file://` may behave differently than Vite dev paths. Test fonts, icons, manifest assets, imported images, audio, and exported runtime files in packaged builds.
- Browser download code is synchronous from the user's perspective, while native save dialogs are async and cancellable. UI states should handle cancellation cleanly.
- MP4 export is the largest unknown because Chromium and ffmpeg packaging varies by platform.
- Large `.ruw` files may put pressure on IPC if sent as huge arrays. If this becomes a problem, switch to main-process file handles or temporary files.
- Existing in-browser tests need an Electron smoke layer because packaged-app failures may not appear in Vite.
## Suggested Implementation Order
1. Add Electron shell and dev/build scripts.
2. Package the current app without native integrations.
3. Add preload typings and a tiny `desktopBridge`.
4. Move project open/save to native dialogs.
5. Add native export save paths for existing browser-generated blobs.
6. Add app menu and unsaved-change handling.
7. Refactor `server/server.js` render logic into a reusable Node module.
8. Add desktop MP4 export through Electron main.
9. Configure packaging icons, file associations, and platform targets.
10. Add CI/package smoke checks.
## First Pull Request Scope
Keep the first PR intentionally small:
- Electron main/preload files.
- Desktop dev and pack scripts.
- Production loading of `dist/index.html`.
- Basic secure window configuration.
- No changes to project save/load or rendering behavior yet.
That gives RUWIO a launchable desktop shell before any app behavior changes, making the rest of the migration easier to test in small increments.
+174
View File
@@ -0,0 +1,174 @@
# Security Review
Date: 2026-06-05
Scope reviewed:
- Vue/Vite renderer in `src/`
- Electron main/preload in `electron/`
- MP4 render server in `server/`
- Project import/export paths for `.json`, `.ruw`, backups, assets, fonts, and generated HTML exports
- Build/dependency posture at the repository root
## Summary
The desktop shell starts from a good Electron baseline: `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`, and a narrow preload bridge. I did not find direct Node exposure to the Vue renderer.
The highest-risk area is the standalone MP4 render server. It accepts unauthenticated cross-origin render jobs, large request bodies, and attacker-controlled scene data, then renders it in Chromium with `--no-sandbox` and feeds frames to `ffmpeg`. That combination should be treated as a network-exposed compute/sandbox boundary until it is tightened.
The second broad theme is input hardening. Project files and `.ruw` bundles are structurally checked, but not deeply schema-validated or bounded before the app uses canvas sizes, frame counts, assets, and timelines. A crafted file can likely cause heavy CPU/memory use in both browser and server render paths.
## Findings
### High: Render Server Is Unauthenticated And Cross-Origin
Evidence:
- `server/server.js:19` enables default `cors()`, allowing browser requests from any origin.
- `server/server.js:20` accepts JSON bodies up to `200mb`.
- `server/server.js:27` starts render jobs without authentication, CSRF protection, origin checks, rate limits, or concurrency limits.
- `docker-compose.yml:4-5` publishes port `6000` on the host.
Impact:
Any site reachable by a user's browser, or any network client that can reach the service, can submit expensive render jobs. This can exhaust CPU, memory, disk, Chromium processes, or `ffmpeg` work queues. If the server is ever exposed beyond localhost, it becomes a public unauthenticated render farm.
Recommendations:
- Bind the service to localhost by default, or require an explicit opt-in for network exposure.
- Replace default `cors()` with an allowlist for the app origin.
- Add authentication or a per-session token for `/render`, `/events`, `/file`, and `DELETE`.
- Add request rate limits, maximum active jobs, queue limits, and per-job timeouts.
- Lower the default body limit and reject requests before parsing when possible.
### High: Chromium Renders Untrusted Data With Sandbox Disabled
Evidence:
- `server/server.js:126-134` launches Chromium with `--no-sandbox` and `--disable-setuid-sandbox`.
- `server/server.js:166-167` loads `render.html` and injects caller-provided scene data into the page.
- `server/Dockerfile:1-20` uses the default root user; no non-root `USER` is configured.
Impact:
The server renders attacker-controlled image/audio/video/SVG-like asset data in Chromium. If a browser engine, image decoder, font decoder, media decoder, or canvas path is exploited, disabling the Chromium sandbox and running the container as root increases the blast radius.
Recommendations:
- Run the container as a non-root user.
- Prefer enabling the Chromium sandbox. If the container needs extra setup, document and configure it rather than relying on `--no-sandbox`.
- Treat each render job as untrusted: use process-level timeouts, memory/CPU limits, and container hardening.
- Consider a separate locked-down worker container for renders.
### Medium: Project And Bundle Imports Lack Deep Validation And Bounds
Evidence:
- `src/lib/projectFile.ts:39-67` checks only top-level `magic`, `canvas`, `scenes`, and `assets` shape before returning the imported object as `Project`.
- `src/lib/zipUtil.ts:121-171` parses ZIP entries into memory without explicit caps on entry count, central directory size, total extracted size, or path/name length.
- `server/server.js:115-119` derives `fps`, `duration`, and `totalFrames` directly from request data.
- `server/server.js:135-138` uses request-provided canvas dimensions as the Chromium viewport.
Impact:
A crafted `.json`, `.ruw`, backup, or server request can specify extreme canvas sizes, frame counts, layer/keyframe arrays, base64 assets, font data, or ZIP metadata. The likely outcome is denial of service: browser tab hangs, Electron renderer memory pressure, server CPU exhaustion, or failed/very slow exports.
Recommendations:
- Add a real project schema validator at file and server boundaries.
- Clamp canvas dimensions, duration, fps, layer counts, keyframe counts, asset count, asset byte size, and total project size.
- Add ZIP reader bounds and clearer errors for malformed/truncated archives.
- Normalize imported projects into a safe internal shape instead of trusting arbitrary object fields.
### Medium: Render Server Dependency Installs Are Not Reproducible
Evidence:
- `server/package.json` exists, but no `server/package-lock.json` is present.
- `server/Dockerfile:15-16` copies only `package.json` and runs `npm install --omit=dev`.
- `npm audit --prefix server --omit=dev` cannot run without a lockfile.
Impact:
Server builds may pull different transitive dependency versions over time. That makes vulnerability tracking, incident response, and reproducible releases harder.
Recommendations:
- Commit `server/package-lock.json`.
- Use `npm ci --omit=dev` in the server Dockerfile.
- Add server dependency auditing to CI.
### Medium: Development Test Overlay Ships In The Production Bundle
Evidence:
- `src/App.vue:38` statically imports `./devtools/TestOverlay.vue`.
- `src/App.vue:806` always mounts the component.
- `npm run build` output shows `src/devtools/tests.ts` pulled into the production bundle through static/dynamic import relationships.
Impact:
This increases production attack surface and bundle size. Even if the test runner is intended to open only from a development-only Electron menu item, the code is still shipped to every web and desktop user.
Recommendations:
- Gate the test overlay behind `import.meta.env.DEV`.
- Load it with `defineAsyncComponent` or dynamic import only in development.
- Ensure production builds do not include `src/devtools/tests.ts`.
### Low: Electron Navigation And External Link Policy Could Be Stricter
Evidence:
- `electron/main.cts:40-44` opens any `http://` or `https://` URL externally.
- `electron/main.cts:47-51` uses string `startsWith` checks for navigation allow/deny decisions.
Impact:
This is not a direct Node compromise because the renderer is isolated. Still, if untrusted content ever reaches a clickable/openable path, arbitrary external links can be launched. String-prefix navigation checks are also easier to get wrong than parsed URL origin/path checks.
Recommendations:
- Prefer parsed URL checks over `startsWith`.
- Allow only expected origins in development.
- Consider allowing only `https:` external links, or an explicit domain allowlist for help/about links.
### Low: No Content Security Policy Is Defined
Evidence:
- `index.html` and built `dist/index.html` have no CSP meta tag.
Impact:
Vue's default escaping reduces XSS risk, and Electron disables Node integration. A CSP would still provide defense in depth for the web app, generated asset previews, and Electron renderer by limiting script, media, image, and connection destinations.
Recommendations:
- Add a CSP for the app shell.
- Account for required `data:`/`blob:` sources for user assets and exports.
- Add Electron-specific CSP validation during packaged smoke tests.
## Positive Notes
- Electron uses `contextIsolation`, disables `nodeIntegration`, enables renderer sandboxing, and exposes only a small preload API.
- The preload bridge does not expose arbitrary IPC invocation or Node primitives.
- The Electron app denies `window.open` in-app and prevents most unexpected navigation.
- `.ruw` uses STORE-only ZIP handling, avoiding decompression bombs from deflated entries.
- The root production dependency audit completed with `0 vulnerabilities`.
- A shallow secret scan found no obvious committed `.env`, key, secret, PEM, or certificate files outside dependencies.
## Verification Performed
- `npm run build` passed.
- `npm audit --omit=dev` passed with `0 vulnerabilities`.
- `npm audit --prefix server --omit=dev` could not run because `server/` has no lockfile.
## Suggested Remediation Order
1. Lock down the render server: localhost binding, CORS allowlist, auth/token, rate limits, job limits, and timeouts.
2. Run render workers as non-root and remove Chromium `--no-sandbox` if feasible.
3. Add schema validation and resource bounds for imported projects, `.ruw` bundles, backups, and render requests.
4. Add `server/package-lock.json`, switch Docker to `npm ci --omit=dev`, and audit server dependencies in CI.
5. Remove devtools/test runner code from production bundles.
6. Add CSP and stricter Electron URL parsing/allowlists.
+318
View File
@@ -0,0 +1,318 @@
# Security Remediation Plan
Date: 2026-06-05
Source review: `dev_docs/02_security-review.md`
## Goal
Close the security issues from the review without changing RUWIO's core authoring workflow. The first priority is to make the MP4 render server safe to run on a developer machine by default; the second is to make project and bundle input handling bounded and reproducible.
## Principles
- Secure defaults first: local-only render server, explicit opt-in for network exposure, and bounded work.
- Validate at every trust boundary: imported files, `.ruw` bundles, render requests, Electron IPC, and generated/exported HTML.
- Keep browser and desktop builds working while hardening server-only paths.
- Add tests for security behavior so fixes do not drift quietly.
## Phase 0: Baseline And Guardrails
Files likely touched:
- `dev_docs/02_security-review.md`
- `package.json`
- `server/package.json`
- `server/server.js`
- `src/devtools/tests.ts`
Tasks:
1. Capture a clean baseline with `npm run build`.
2. Add or document a lightweight verification command for render-server checks once server tests exist.
3. Decide the initial production limits so later validation has concrete numbers:
- maximum canvas width and height
- maximum render fps
- maximum render duration and total frames
- maximum layer, clip, keyframe, scene, asset, and point counts
- maximum JSON body, `.json` project, `.ruw` bundle, per-ZIP-entry, and total extracted asset size
4. Record the selected limits in this document or a small constants module.
Acceptance:
- Existing build still passes before remediation starts.
- Limits are explicit instead of scattered magic numbers.
## Phase 1: Lock Down The Render Server
Addresses:
- High: unauthenticated cross-origin render server
- Part of Medium: unbounded render requests
Files likely touched:
- `server/server.js`
- `src/lib/serverExport.ts`
- `src/components/ExportDialog.vue`
- `docker-compose.yml`
- `README.md`
Tasks:
1. Bind the server to `127.0.0.1` by default:
- Add `HOST=127.0.0.1` default in `server/server.js`.
- Use `app.listen(PORT, HOST, ...)`.
- Change Docker Compose publishing from `6000:6000` to `127.0.0.1:6000:6000`.
- Require an explicit `HOST=0.0.0.0` or compose override for LAN exposure.
2. Replace default `cors()` with an allowlist:
- Add `RENDER_ALLOWED_ORIGINS`, defaulting to the Vite dev origin and localhost app origins that are actually needed.
- Reject browser requests with unexpected `Origin` values.
- Keep non-browser/local CLI requests possible when they have no `Origin` and pass auth.
3. Add authentication:
- Add `RENDER_TOKEN`.
- Require `Authorization: Bearer <token>` or `X-RUWIO-Render-Token` for `POST /render`.
- Because `EventSource` cannot send custom headers, have `POST /render` return a random per-job access token, then require that token for `/render/:id/events`, `/render/:id/file`, and `DELETE /render/:id`.
- Add render-server settings in the client if the token must be user-configurable.
4. Add basic abuse controls:
- Lower `express.json` from `200mb` to a documented default, with an environment override.
- Reject requests with a too-large `Content-Length` before JSON parsing.
- Add per-IP request rate limits for `/render`.
- Add `MAX_ACTIVE_JOBS` and `MAX_QUEUED_JOBS`; return `429` or `503` when full.
- Add a per-job timeout that aborts Puppeteer and `ffmpeg`.
5. Improve job cleanup:
- Delete failed and aborted job directories.
- Remove completed job metadata after download TTL expires.
- Ensure `ffmpeg` is killed when Chromium/page work fails.
Acceptance:
- A default server only listens on localhost.
- Cross-origin requests from an unlisted origin fail.
- Requests without the configured token fail.
- A completed job can still be started, watched, downloaded, and deleted by the RUWIO client.
- Concurrent jobs, over-large bodies, and over-long renders are rejected with clear errors.
## Phase 2: Harden Chromium And The Container
Addresses:
- High: Chromium renders untrusted data with sandbox disabled
Files likely touched:
- `server/server.js`
- `server/Dockerfile`
- `docker-compose.yml`
- `README.md`
Tasks:
1. Run the container as a non-root user:
- Create an app user/group in `server/Dockerfile`.
- Ensure `/app` and temporary render directories are writable by that user.
- Set `USER` before `CMD`.
2. Remove unconditional sandbox-disabling Chromium flags:
- Try launching Chromium without `--no-sandbox` and `--disable-setuid-sandbox`.
- Keep an explicit `CHROME_NO_SANDBOX=1` escape hatch only if needed for a known deployment environment.
- Log a loud warning when the escape hatch is used.
3. Add process and container limits:
- Configure Docker Compose memory/CPU/pids limits where supported.
- Add Node-side child-process kill paths for abort, timeout, and client disconnect.
- Consider using a dedicated worker module/process so Express request handling and rendering are isolated.
4. Limit network reachability from render jobs:
- Prefer rendering local/data assets only.
- If external URLs must remain supported, document the SSRF/privacy tradeoff and add an allowlist or opt-in later.
Acceptance:
- The Docker image runs as a non-root user.
- The normal render path works without `--no-sandbox`, or the documented fallback is explicit and visible.
- Aborted/timed-out jobs leave no running Chromium or `ffmpeg` process.
## Phase 3: Add Shared Project Validation And Resource Bounds
Addresses:
- Medium: project and bundle imports lack deep validation and bounds
- Part of High/Medium: unbounded render workload
Files likely touched:
- `src/types/scene.ts`
- new `src/lib/projectLimits.ts`
- new `src/lib/projectValidation.ts`
- `src/lib/projectFile.ts`
- `src/lib/ruwFile.ts`
- `src/lib/zipUtil.ts`
- `server/server.js`
- possibly `server/validation.js` or a shared package if server reuse becomes necessary
Tasks:
1. Add centralized limits:
- Keep numeric bounds in one module.
- Use browser-safe limits for imports and stricter server-safe limits for rendering if needed.
2. Add schema validation at import boundaries:
- Validate `Project`, `Scene`, `Asset`, `Layer`, `Keyframe`, clips, actions, background media, and canvas data.
- Reject non-finite numbers, negative dimensions, invalid colors where practical, oversized strings, and unknown critical shapes.
- Normalize optional arrays and legacy fields into safe defaults.
- Return friendly `ProjectFileError` messages rather than raw parser errors.
3. Add render-request validation:
- Validate `scene` and `options` before creating a job.
- Clamp or reject `fps`, `duration`, `crf`, `preset`, canvas size, and total frame count.
- Compute `totalFrames` only after validation.
4. Harden ZIP parsing:
- Add caps for entry count, archive byte length, central directory range, name length, per-entry size, and total extracted size.
- Check every local/central directory offset and data range before creating `DataView` or `subarray`.
- Reject path traversal and absolute paths even though extraction is in-memory.
- Preserve STORE-only behavior.
5. Add import tests:
- Valid legacy `.json` and `.ruw` still load.
- Malformed JSON reports a friendly error.
- Oversized canvas, extreme fps/duration/keyframes/assets, malformed ZIP offsets, too many entries, and missing assets are rejected.
Acceptance:
- Project import returns a safe internal `Project` shape.
- `.ruw` parsing fails early for oversized or malformed archives.
- Render server rejects pathological render requests before launching Chromium.
- Existing normal projects still open and export.
## Phase 4: Make Server Dependencies Reproducible
Addresses:
- Medium: render server dependency installs are not reproducible
Files likely touched:
- `server/package-lock.json`
- `server/Dockerfile`
- root CI config when one exists
- `README.md`
Tasks:
1. Generate and commit `server/package-lock.json`.
2. Replace `npm install --omit=dev` with `npm ci --omit=dev` in `server/Dockerfile`.
3. Add server audit/documentation:
- `npm audit --prefix server --omit=dev`
- Add the command to local release checks or CI.
4. Rebuild the server image from a clean checkout to verify the lockfile path.
Acceptance:
- `npm audit --prefix server --omit=dev` can run.
- Docker builds use the committed lockfile.
- Repeated builds install identical dependency versions.
## Phase 5: Remove Devtools From Production Bundles
Addresses:
- Medium: development test overlay ships in production
Files likely touched:
- `src/App.vue`
- `src/devtools/TestOverlay.vue`
- `src/devtools/tests.ts`
Tasks:
1. Replace the static `TestOverlay` import with a development-only dynamic import:
- Use `defineAsyncComponent` only when `import.meta.env.DEV` is true.
- Mount the overlay only in development.
2. Guard menu/command paths that open the test runner:
- Keep Electron menu gating already present.
- Ensure renderer-side command handling ignores `test-runner` in production.
3. Verify bundle output:
- Run `npm run build`.
- Confirm production chunks do not include `src/devtools/tests.ts` or the test overlay.
Acceptance:
- Production build output excludes devtools tests.
- Development build still opens the test overlay.
## Phase 6: Tighten Electron Navigation And External Links
Addresses:
- Low: Electron navigation and external link policy could be stricter
Files likely touched:
- `electron/main.cts`
Tasks:
1. Replace string-prefix URL checks with parsed `URL` checks.
2. Define allowed renderer origins:
- Development: exactly the configured Vite origin.
- Production: the packaged `file://` app entry and same-directory app assets.
3. Restrict external opening:
- Prefer `https:` only.
- Add an explicit allowlist for help/about domains if external links are needed.
- Deny `file:`, custom protocols, `javascript:`, and unexpected `http:`.
4. Add small unit or smoke tests if Electron test infrastructure exists; otherwise document manual packaged-app checks.
Acceptance:
- App navigation works in dev and packaged builds.
- Unexpected app navigation is prevented.
- External links are opened only when they match the policy.
## Phase 7: Add Content Security Policy
Addresses:
- Low: no Content Security Policy is defined
Files likely touched:
- `index.html`
- possibly Vite config if separate dev/prod CSP handling is needed
- generated export code only if app-shell CSP breaks previews
Tasks:
1. Add an app-shell CSP meta tag.
2. Start with a policy that supports current app behavior:
- scripts from self
- images/media/fonts from self plus `data:`/`blob:` where required for user assets
- connections to self plus configured render server during development/export
- no object/embed
3. Verify the Electron renderer and browser app do not log CSP violations for normal workflows.
4. Keep generated HTML exports separate if they need a looser asset policy; do not silently weaken the app shell for exports.
Acceptance:
- App loads in browser dev, production preview, and Electron.
- Common import/export/preview workflows work without unexpected CSP violations.
- CSP blocks inline or remote script execution in the app shell.
## Suggested Pull Request Order
1. Render server exposure and authentication.
2. Render server job limits, body limits, and cleanup.
3. Container non-root user and Chromium sandbox work.
4. Shared limits plus render-request validation.
5. Project and `.ruw` import validation.
6. Server lockfile and Docker `npm ci`.
7. Production devtools gating.
8. Electron URL policy.
9. CSP.
## Final Verification Checklist
- `npm run build`
- `npm audit --omit=dev`
- `npm audit --prefix server --omit=dev`
- Render server rejects unauthenticated `/render`.
- Render server rejects disallowed `Origin`.
- Render server rejects oversized request bodies and excessive frame counts.
- Normal MP4 render still completes through the app.
- Malformed `.json` and `.ruw` files fail with friendly errors.
- Production bundle does not contain `src/devtools/tests.ts`.
- Packaged Electron app opens only expected app URLs and approved external links.
@@ -0,0 +1,291 @@
# Electron Rendering Integration Plan
Date: 2026-06-05
## Verdict
Yes, the MP4 rendering part can be included in the Electron app.
The current render server is already made from desktop-compatible pieces:
- Node orchestration in `server/server.js`
- a browser render surface in `server/render.html`
- frame capture through Chromium
- MP4 encoding through `ffmpeg`
- progress, cancellation, temp files, validation, and cleanup around the job
The main caveat is that the server cannot be bundled as-is and expected to work in a packaged desktop app. It currently assumes:
- `puppeteer-core` is installed in the server package
- Chromium exists at `PUPPETEER_EXECUTABLE_PATH` or `/usr/bin/chromium`
- `ffmpeg` exists on `PATH`
- the Vue app talks to `http://localhost:6000` with `VITE_RENDER_SERVER`
- Electron Builder packages only `dist/`, `dist-electron/`, and root `package.json`
So the feature is feasible, but the right implementation is to move the render job into the Electron main process behind IPC, not to expose an HTTP render server from the desktop app.
## Recommended Architecture
Use Electron as the local render host:
- Vue renderer keeps owning project state and export UI.
- Preload exposes a narrow desktop render API on `window.ruwioDesktop`.
- Electron main validates render requests, starts/cancels jobs, captures frames, runs `ffmpeg`, and returns a saved MP4 path or file bytes.
- The standalone `server/` package remains available for web or self-hosted server deployments.
Preferred render surface:
- Use a hidden/offscreen Electron `BrowserWindow` to load a packaged render page.
- Call `webContents.executeJavaScript` or `webContents.send` to initialize the scene and seek each frame.
- Use `webContents.capturePage()` for PNG frame capture.
- Pipe PNG frames into `ffmpeg` the same way `server/server.js` does today.
This avoids bundling a second Chromium through Puppeteer. Electron already ships Chromium, so the desktop build only needs to solve `ffmpeg` packaging.
Fallback/spike option:
- Start the existing Express render server as a child process bound to `127.0.0.1` with a random token.
- Point the renderer at that local URL.
- This is useful for a quick feasibility spike, but it keeps the localhost server, token plumbing, Puppeteer, and external Chromium dependency in the desktop app. It should not be the final architecture.
## Dependency Strategy
### Chromium
Do not add a separate Chromium binary for the first native implementation. Use Electron's bundled Chromium through a hidden render window.
Only keep Puppeteer for the standalone server unless a spike proves Electron capture cannot match render quality or performance.
### ffmpeg
`ffmpeg` is the real packaged-app dependency.
Implementation options:
1. Bundle per-platform `ffmpeg` binaries as `extraResources`.
2. Use a maintained npm package that supplies platform-specific binaries.
3. Support a user-configured/system `ffmpeg` path as a fallback.
Recommended first production path:
- Add an internal resolver such as `electron/ffmpegPath.cts`.
- In development, prefer `process.env.FFMPEG_PATH`, then `ffmpeg` on `PATH`.
- In packaged builds, prefer `process.resourcesPath/bin/<platform>/ffmpeg`.
- Show a friendly export error if no usable binary is found.
## Implementation Plan
## Phase 1: Extract Shared Render Validation
Goal: keep server and desktop limits identical.
Tasks:
1. Move request validation and playback range helpers out of `server/server.js` into a shared module.
2. Keep the module Node-compatible and browser-safe where practical.
3. Export:
- `validateRenderRequest(scene, options)`
- `playbackRange(scene)`
- render limits and allowed ffmpeg presets
4. Update `server/server.js` to use the shared validator.
5. Add focused tests for invalid dimensions, fps, duration, frame count, layers, keyframes, `crf`, and preset.
Acceptance:
- Existing server behavior is unchanged.
- Desktop rendering can reuse the same validation before launching any render surface or child process.
## Phase 2: Add Desktop Render IPC Contract
Goal: let the Vue app request MP4 rendering without knowing whether the backend is HTTP or Electron.
Tasks:
1. Extend `src/types/desktop.d.ts` with a render API:
```ts
renderMp4(options: {
scene: Scene
fps?: number
crf?: number
preset?: string
suggestedName?: string
}): Promise<{ path: string } | null>
onRenderProgress(callback: (event: {
jobId: string
progress: number
done: boolean
error?: string
}) => void): () => void
cancelRender(jobId: string): Promise<void>
```
2. Add preload wrappers that call `ipcRenderer.invoke` and subscribe to progress events.
3. Add main-process handlers:
- `ruwio:render-mp4`
- `ruwio:render-cancel`
4. Validate every IPC payload in main before starting a job.
5. Keep job IDs and abort controllers in main, not in the Vue renderer.
Acceptance:
- A renderer call can start a fake/stub render and receive progress.
- Cancel reaches the main process.
- Invalid payloads fail before any child process or hidden window is created.
## Phase 3: Build The Electron Render Worker
Goal: replace Puppeteer with Electron's own Chromium for desktop exports.
Tasks:
1. Create an Electron-only render worker module, for example `electron/renderWorker.cts`.
2. Package a render page:
- first pass: reuse `server/render.html` as an extra resource
- later pass: consider generating a render page from the shared browser renderer to reduce drift
3. Create a hidden `BrowserWindow` per job or a serialized reusable worker window:
- `show: false`
- `webPreferences.sandbox: true`
- no Node integration
- restricted navigation
4. Load the render page from a local file URL.
5. Initialize the scene.
6. For each frame:
- seek to the target time
- wait for the render function to settle
- capture the page or canvas bounds
- write PNG bytes to `ffmpeg.stdin`
- emit progress over IPC
7. On cancel or timeout:
- abort the frame loop
- kill `ffmpeg`
- close the worker window
- remove temp files
Acceptance:
- A tiny scene renders to MP4 in `npm run desktop:dev`.
- Progress updates reach `ExportDialog`.
- Cancel stops capture and `ffmpeg`.
- Temp directories are removed on success, failure, and cancel.
## Phase 4: Package ffmpeg
Goal: make MP4 export work in packaged desktop builds.
Tasks:
1. Choose the binary source:
- committed/release-managed binaries under a `vendor/` or `bin/` folder, or
- npm package binaries copied into Electron Builder resources
2. Add Electron Builder `extraResources` entries for:
- Linux ffmpeg
- Windows ffmpeg.exe
- macOS ffmpeg
- render worker HTML/assets
3. Add `ffmpeg` path resolution for dev and packaged builds.
4. Verify executable permissions on macOS/Linux packaged output.
5. Document licensing obligations for the selected `ffmpeg` build.
Acceptance:
- `npm run desktop:pack` includes the render page and ffmpeg.
- The unpacked app exports MP4 without a separately installed render server.
- Missing ffmpeg produces a clear user-facing error.
## Phase 5: Switch ExportDialog To Desktop Rendering
Goal: use native rendering in Electron and keep the current server path for the web app.
Tasks:
1. Add `src/lib/desktopRenderExport.ts`.
2. In `ExportDialog.vue`, route MP4 export as:
- if `window.ruwioDesktop?.renderMp4` exists: use desktop IPC render
- else: use current `downloadFromServer`
3. Reuse current progress UI and cancellation behavior.
4. Save the finished MP4 through a native save dialog from main.
5. Keep `VITE_RENDER_SERVER` support for browser builds and remote/self-hosted workflows.
Acceptance:
- Desktop users do not need `cd server && npm run start`.
- Browser users can still use the external render server.
- The MP4 button reports a helpful error when no server is configured in browser mode.
## Phase 6: Keep Standalone Server Working
Goal: avoid breaking existing server deployments.
Tasks:
1. Keep `server/server.js` as an Express wrapper around shared validation/render helpers where useful.
2. Keep `server/render.html` or replace it only after desktop and server render outputs are compared.
3. Preserve `/health`, `/render`, `/events`, `/file`, and `DELETE` behavior.
4. Add a smoke test or manual check for server MP4 export after shared refactors.
Acceptance:
- `cd server && npm run start` still starts the local render server.
- The Vue app can still render through `VITE_RENDER_SERVER` and `VITE_RENDER_TOKEN`.
## Phase 7: Verification Matrix
Run these before calling the integration complete:
- `npm run build`
- `npm run desktop:pack`
- `npm run test:render-server`
- `npm run test:render-server:e2e`
- desktop dev MP4 export of a small scene
- desktop packaged MP4 export of a small scene
- cancel an MP4 export after progress starts
- export a scene with image assets
- export a scene with text/fonts
- export a scene at non-default fps
- export a scene with hidden layers skipped
- server-mode MP4 export still works
Manual platform checks:
- Linux AppImage
- Windows NSIS build
- macOS DMG build
## Risks And Mitigations
- **ffmpeg packaging varies by platform**: solve with explicit binary resolution and an unpacked-app smoke check before installer work.
- **render drift between `server/render.html` and `src/lib/render.ts`**: keep parity tests, then consider generating the desktop/server render page from shared code.
- **large scenes can freeze the desktop app**: keep render work in main-controlled worker windows, serialize jobs initially, enforce the same render limits as the server.
- **IPC can move large payloads**: scenes are JSON-sized today, but avoid sending frame buffers over IPC; frame capture and `ffmpeg` piping should stay in main.
- **untrusted project assets reach Chromium**: keep the hidden render window sandboxed, restrict navigation, block external network requests unless explicitly allowed, and keep existing project validation work moving.
## Suggested First Pull Request
Keep the first PR small and proof-oriented:
1. Extract shared render validation from `server/server.js`.
2. Add a stubbed desktop render IPC contract.
3. Add `desktopRenderExport.ts` and route the MP4 button to desktop IPC when available.
4. Return a fake progress/done result in development or behind a temporary flag.
That proves the app integration and cancellation shape before dealing with frame capture and ffmpeg packaging.
## Suggested Second Pull Request
Implement the real Electron render worker:
1. Hidden render window.
2. Frame capture loop.
3. `ffmpeg` stdin piping.
4. Temp directory cleanup.
5. Dev-only `ffmpeg` path resolution.
## Suggested Third Pull Request
Make it shippable:
1. Package ffmpeg and render worker assets.
2. Verify `desktop:pack`.
3. Add packaged-app manual test notes.
4. Document ffmpeg licensing and update README desktop export notes.
@@ -0,0 +1,308 @@
# Electron Rendering Integration — Plan 2 (Review Findings + Remaining Work)
Date: 2026-06-05
This document supersedes `04_electron-rendering-integration-plan.md` for ongoing
work. It is self-contained: you do not need the original plan to continue from
here. It records what is already implemented, what was found during review, and
the concrete remaining TODOs in priority order.
## Goal
Render MP4 inside the Electron desktop app without the standalone HTTP render
server, while keeping that standalone server working for the web/self-hosted
build. Desktop uses Electron's own Chromium (a hidden render window) for frame
capture and a bundled `ffmpeg` for H.264 encoding.
## Architecture (as built)
- **Vue renderer** owns project state and the export UI (`ExportDialog.vue`).
For MP4 it calls `renderMp4OnDesktop()` when `window.ruwioDesktop.renderMp4`
exists, otherwise falls back to the HTTP server path (`downloadFromServer`).
- **Preload** (`electron/preload.cts`) exposes a narrow API on
`window.ruwioDesktop`: `renderMp4`, `onRenderProgress`, `cancelRender`.
- **Main process** (`electron/main.cts`) validates every IPC payload, shows a
native save dialog, owns job IDs + `AbortController`s, and forwards progress
events. IPC channels: `ruwio:render-mp4`, `ruwio:render-cancel`, progress on
`ruwio:render-progress`.
- **Render worker** (`electron/renderWorker.cts`) creates a hidden
`BrowserWindow`, loads the packaged render page, seeks each frame, captures
it, and pipes PNG bytes into `ffmpeg` stdin. Mirrors the server frame loop.
- **Shared validation** (`shared/renderValidation.cjs`) is the single source of
render limits, `playbackRange`, and `validateRenderRequest`, used by both
`server/server.js` and `electron/main.cts`.
- **ffmpeg resolution** (`electron/ffmpegPath.cts`): `FFMPEG_PATH` env →
`process.resourcesPath/bin/<platform>/<ffmpeg>` (packaged) → `ffmpeg` on PATH.
- **Staging script** (`scripts/stage-ffmpeg.cjs`) copies an ffmpeg binary into
`bin/<platform>/` for packaging. Wired into `desktop:build`/`desktop:pack`
via `stage:ffmpeg -- --optional`.
- **Packaging** (`package.json` `build`): `extraResources` ships
`server/render.html` and `bin/`; `files` ships `shared/renderValidation.cjs`.
## Key files
| Concern | File |
| --- | --- |
| Renderer export routing | `src/components/ExportDialog.vue` (`exportMp4Server`) |
| Renderer desktop client | `src/lib/desktopRenderExport.ts` |
| Desktop API types | `src/types/desktop.d.ts` |
| Preload bridge | `electron/preload.cts` |
| Main IPC + save dialog | `electron/main.cts` (`handleRenderMp4`, `handleRenderCancel`) |
| Frame capture + ffmpeg | `electron/renderWorker.cts` |
| ffmpeg path resolver | `electron/ffmpegPath.cts` |
| Shared validation | `shared/renderValidation.cjs` |
| Render page (capture surface) | `server/render.html` (`window.__animatrInit`, `window.__animatrSeek`) |
| Standalone server | `server/server.js` |
| ffmpeg staging | `scripts/stage-ffmpeg.cjs` |
| Desktop render verification | `scripts/verify-*.cjs` + runner `scripts/verify-desktop-render.cjs` (`npm run test:render-desktop`) |
## Current verified state
Last verified: 2026-06-06 (Linux, DISPLAY available, system ffmpeg/ffprobe 7.1.4).
- `tsc -p tsconfig.electron.json` — passes.
- `npm run build` (vue-tsc + vite) — passes.
- `npm run test:render-validation` — 7/7 pass.
- `npm run test:render-server` — pass.
- `npm run test:render-server:e2e` — pass (server still produces a playable
H.264 MP4; confirms the shared `render.html` change below is server-safe).
- `npm run test:render-desktop` — **all 6 desktop checks pass.** New aggregated
Electron harness suite (`scripts/verify-*.cjs`, runner
`scripts/verify-desktop-render.cjs`) that drives the real worker headlessly:
- capture: hidden-window `capturePage` yields advancing, non-blank frames.
- export: the real `renderMp4Job` produces a 25-frame H.264 MP4 with motion.
- cancel: mid-export abort rejects cleanly, no partial file.
- timeout: the inactivity watchdog aborts a wedged render (~2s).
- assets: local `data:` assets load; remote `http:` is blocked, no hang.
- background: transparent regions flatten to white (not black).
- `npm run desktop:pack` — succeeds. Packaged resources verified:
`resources/server/render.html`, executable `resources/bin/linux/ffmpeg`
(runs `-version`), and `app.asar` contains `dist-electron/{main,renderWorker,
ffmpegPath}.cjs` + `shared/renderValidation.cjs`.
Findings #1–#6 are all resolved (see status tags below).
Not yet done:
- A manual GUI desktop export (dev or packaged) through the save dialog. The
worker logic itself is proven end-to-end by `test:render-desktop`; only the
click-through has not been recorded.
- **`bin/linux/ffmpeg` is the dynamically-linked Debian system binary (~358 KB),
not a redistributable static build.** Fine for local dev; NOT release-safe.
- Windows/macOS ffmpeg binaries and licensing notes (Phase C #10–11).
- Platform installer builds (AppImage / NSIS / DMG) and their manual checks.
## Review findings (ordered by severity)
### 1. CRITICAL — `capturePage()` on a non-shown, non-offscreen window — RESOLVED (does not reproduce)
Verified 2026-06-06 via `scripts/verify-capture.cjs`: a hidden, non-offscreen
window's `capturePage()` returns real, non-blank frames whose contents advance
frame-to-frame (linearly-stepping position hash over a moving rect). No
offscreen mode was needed. The remaining staleness risk was finding #2.
Original analysis below.
`renderWorker.cts` creates the render window with `show: false` but **not**
`webPreferences.offscreen: true`, then calls `webContents.capturePage()` once
per frame. Hidden, non-offscreen windows are not composited by Chromium, so
`capturePage()` can return blank or stale frames. This is the single biggest
risk and cannot be confirmed by reading code — it needs a real export of a
moving scene, frame-stepped, to confirm frames actually advance.
Fix direction if it reproduces:
- Switch the render window to `webPreferences.offscreen: true` and drive capture
off the `paint` event (`win.webContents.on('paint', (e, dirty, image) => ...)`
/ `beginFrameSubscription`), **or**
- Show the window positioned off-screen (e.g. far negative coordinates) so the
compositor paints it, then `capturePage`.
Offscreen mode also naturally solves finding #2.
### 2. HIGH — No wait for a compositor paint between seek and capture — RESOLVED (was real; fixed)
Confirmed real and reproducible: without a settle, `capturePage` intermittently
returned the *previous* frame (stale/off-by-one) in ~1/3 of runs. Fixed in
`server/render.html`: `window.__animatrSeek` now returns a Promise that resolves
only after a double `requestAnimationFrame` (with a 1s anti-hang fallback), so
both the desktop worker (`await executeJavaScript`) and the server (Puppeteer
`await evaluate`) wait for a paint before capturing. 6/6 PASS after the fix;
`test:render-server:e2e` confirms the server path is unaffected.
Original analysis below.
`__animatrSeek` runs a synchronous 2D-canvas `drawFrame`, but the worker calls
`capturePage` immediately after `executeJavaScript(...seek)` with no wait for a
paint. This risks an off-by-one frame or tearing. The original plan explicitly
required "wait for the render function to settle."
Fix direction: await a double `requestAnimationFrame` inside the page (or, in
offscreen mode, await the next `paint` event) before capturing.
### 3. MEDIUM — No render timeout in the desktop worker — RESOLVED
Added an inactivity watchdog in `renderWorker.cts`: a combined `AbortController`
(`jobAbort`) trips on user cancel or after `RENDER_INACTIVITY_TIMEOUT_MS`
(default 2 min, override via `RUWIO_RENDER_TIMEOUT_MS`) with no progress. The
timer is re-armed on every `onProgress`, and each per-frame await is wrapped in
`raceAbort(...)` so a wedged `executeJavaScript`/`capturePage` is interrupted,
ffmpeg is killed, and the partial file removed. Verified by
`scripts/verify-timeout.cjs` (aborts ~2s with message "render timed out").
Original analysis below.
`server/server.js` auto-aborts a stuck job via `JOB_TIMEOUT_MS`. The desktop
worker has no watchdog, so a hung `executeJavaScript`/`capturePage` wedges the
export with only manual cancel as escape.
Fix direction: add a timeout that aborts the job's `AbortController` after N
seconds of no progress (reset the timer on each `onProgress`).
### 4. MEDIUM — Transparent window flattens to black under `yuv420p` — RESOLVED
Decision: match the server (flatten onto white). The render window is now
`transparent: false` / `backgroundColor: '#ffffff'`, so transparent regions
(e.g. `backgroundVideo` scenes, where `render.html` skips the bg fill) capture
as white instead of black. Verified by `scripts/verify-bg.cjs` (corner pixel
`rgb(255,255,255)`, zero near-black pixels).
Original analysis below.
The render window is `transparent: true`. Opaque scenes are fine because
`render.html` fills `canvas.background` over the full frame, but any genuinely
transparent region flattens onto **black** (vs the server's Puppeteer path,
which flattens onto white via `omitBackground:false`). Decide this consciously:
either make the render window background opaque, or document MP4 transparency
behavior as black-filled.
### 5. LOW — Outbound network not blocked in the render window — RESOLVED
The render window now uses an isolated session partition (`ruwio-render`) with a
`session.webRequest.onBeforeRequest` filter on `['*://*/*']` that cancels all
http/https requests; local schemes (`file:`/`data:`/`blob:`) are unmatched and
load normally. Verified by `scripts/verify-assets.cjs`: a local `data:` image
draws (1600 px), a remote `http:` asset is blocked (0 px) and does not hang.
Original analysis below.
The render window denies `window.open` and `will-navigate`, but does not block
network requests. A project asset with an `http(s)` `src` will be fetched by the
hidden window. The original plan listed "block external network requests unless
explicitly allowed." Low risk for a local app; close it for defense-in-depth via
`session.webRequest.onBeforeRequest` or a CSP on the render page.
### 6. LOW — Desktop jobs are not serialized in main — RESOLVED
`main.cts` now guards with a module-level `renderActive` flag set at the top of
`handleRenderMp4` (before the save dialog) and reset in a `finally`; a second
concurrent IPC invoke throws "A render is already in progress". Mirrors the
server's `MAX_ACTIVE_JOBS=1`.
Original analysis below.
`handleRenderMp4` will run concurrent jobs; only the `ExportDialog`
`if (progress.value) return` guard prevents it from the UI, and IPC is directly
invokable. The server enforces `MAX_ACTIVE_JOBS`. Add a one-line guard in main
(reject or queue when a job is already active).
## What is already correct (do not re-do)
- Partial output file is removed on failure/cancel; ffmpeg is drained and the
window closed in `finally`.
- Cancel-before-jobId race handled in `desktopRenderExport.ts` via
`pendingAbort`, including the window where main is blocked on the save dialog.
- ffmpeg ENOENT surfaces the friendly `missingFfmpegMessage()` through both the
`spawn`-error and `exit`-`!started` branches.
- Packaging wiring is consistent: render page + `bin/` in `extraResources`,
`shared/renderValidation.cjs` in `files`, resolvers read `process.resourcesPath`.
- Validation parity: both server and main call the same `validateRenderRequest`;
desktop uses frozen `DEFAULT_LIMITS`, server overlays env-configurable limits.
- Filename sanitization (`sanitizeMp4Name`) strips path separators before the
save dialog.
## Remaining TODOs
### Phase A — Prove frame capture works — DONE
Findings #1 and #2 resolved (see status tags above). The acceptance criteria
(moving scene → correct MP4; clean cancel; no partial file) are covered by
`npm run test:render-desktop` (capture/export/cancel checks).
### Phase B — Robustness — DONE
Findings #3, #4, #5, #6 all implemented and verified (status tags above).
### Phase C — Packaging + cross-platform (partly done)
8. `npm run desktop:pack` + packaged-resource verification — DONE for Linux
(see "Current verified state").
9. **TODO (manual GUI):** launch the unpacked app and export MP4 with no
standalone server running. Click-through not yet recorded.
10. **TODO:** stage reviewed *redistributable static* ffmpeg binaries for Linux
(`bin/linux/ffmpeg`, replace the current dynamic system copy), Windows
(`bin/win32/ffmpeg.exe`), and macOS (`bin/darwin/ffmpeg`).
11. **TODO:** record ffmpeg release notes per bundled binary: source URL,
upstream version, platform/arch, static vs dynamic, codecs (esp.
`libx264`), GPL/LGPL obligations, attribution/license files to ship.
### Phase D — Verification matrix
Automated checks — DONE and recorded under "Current verified state"
(`build`, `desktop:pack`, `test:render-validation`, `test:render-server`,
`test:render-server:e2e`, `test:render-desktop`).
**TODO (manual GUI):**
- desktop dev export of a small scene through the save dialog
- desktop packaged export of a small scene
- scenes with: image assets; text/non-default fonts; non-default fps;
hidden layers skipped
- server-mode MP4 export via the browser fallback
- Platform builds: Linux AppImage, Windows NSIS, macOS DMG
## Manual check commands (reference)
Server fallback smoke (needs Chromium, ffmpeg, ffprobe):
```bash
npm run test:render-server
npm run test:render-server:e2e
```
Browser fallback against the standalone server:
```bash
# terminal 1
cd server
HOST=127.0.0.1 PORT=6000 RENDER_TOKEN="choose-a-long-random-token" npm start
# terminal 2
VITE_RENDER_SERVER=http://localhost:6000 VITE_RENDER_TOKEN="choose-a-long-random-token" npm run dev
```
Stage a platform ffmpeg for packaging:
```bash
npm run stage:ffmpeg -- --source /path/to/ffmpeg
```
## Risks carried forward
- **Frame capture on hidden windows** (finding #1) is the gating risk for the
whole desktop path. Resolve Phase A before any packaging work.
- **Render drift** between `server/render.html` and `src/lib/render.ts`: keep
validation/parity in mind; consider generating the render page from shared
code later to avoid divergence.
- **ffmpeg packaging varies by platform**: solve with explicit binary
resolution (done) plus an unpacked-app smoke check before installer work.
@@ -0,0 +1,121 @@
# Electron MP4 Render Manual Checks
Date: 2026-06-05
Use this checklist before treating desktop MP4 export as release-ready.
## Automated Desktop Render Checks
Before the manual checks below, run the headless harness suite (requires a
display, ffmpeg, and ffprobe). It drives the real worker end-to-end and covers
frame capture, MP4 output, cancel, the inactivity watchdog, the render-window
network block, and transparent-region flattening:
```bash
npm run test:render-desktop
```
All six checks should print `RESULT: PASS` and the run ends with
`ALL DESKTOP RENDER CHECKS PASSED`.
## Build Checks
1. Stage a redistributable ffmpeg binary for the target platform:
```bash
npm run stage:ffmpeg -- --source /path/to/ffmpeg
```
2. Build an unpacked app:
```bash
npm run desktop:pack
```
3. Confirm packaged resources exist:
```bash
find release/linux-unpacked/resources -maxdepth 4 -type f | sort | rg 'server/render.html|bin/.*/ffmpeg'
```
4. Confirm Unix executable permissions on Linux/macOS:
```bash
ls -l release/linux-unpacked/resources/bin/linux/ffmpeg
```
5. Confirm the packaged binary runs:
```bash
release/linux-unpacked/resources/bin/linux/ffmpeg -version
```
## App Checks
1. Launch the unpacked app.
2. Create a tiny scene with at least one visible animated layer.
3. Export MP4 at the default FPS and confirm:
- a native save dialog appears
- progress advances in the export dialog
- the final file exists at the selected path
- the file opens in a standard media player
4. Start another MP4 export and cancel after progress starts. Confirm:
- progress stops
- the app remains responsive
- no partial MP4 remains at the selected path
5. Repeat with:
- image assets
- text layers using non-default fonts
- non-default FPS
- layers hidden from render
## Server Fallback Checks
Automated route smoke:
```bash
npm run test:render-server
```
Automated MP4 E2E smoke:
```bash
npm run test:render-server:e2e
```
The E2E smoke starts the standalone server on a temporary localhost port,
submits a tiny scene, waits for SSE completion, downloads the MP4, and verifies
the H.264 stream with `ffprobe`. It requires Chromium, ffmpeg, and ffprobe.
Manual browser fallback:
1. Start the standalone server:
```bash
cd server
HOST=127.0.0.1 PORT=6000 RENDER_TOKEN="choose-a-long-random-token" npm start
```
2. Start the browser app with matching render settings:
```bash
VITE_RENDER_SERVER=http://localhost:6000 VITE_RENDER_TOKEN="choose-a-long-random-token" npm run dev
```
3. Export MP4 from the browser app and confirm the server path still works.
## ffmpeg Release Notes
For every bundled ffmpeg binary, record:
- source URL
- upstream version
- target platform and architecture
- whether the build is static or dynamically linked
- configured codecs, especially `libx264`
- GPL/LGPL license obligations
- required attribution or license files to ship with the app
The local staging script may copy a system ffmpeg. That is useful for package
path verification, but release artifacts should use reviewed redistributable
binaries for each platform.
+356
View File
@@ -0,0 +1,356 @@
# Electron Rendering — Fix Plan (Second-Review Findings #7–#13)
Date: 2026-06-06
This document is a follow-on to `05_electron-rendering-integration-plan_2.md`.
That plan tracked findings **#1–#6** (all resolved). A second review of the
uncommitted desktop-render changes surfaced a new set of issues, numbered
**#7–#13** here to avoid collision. Two of them (#7, #8) are user-facing data
loss / crash bugs and should be fixed before any GUI/packaging sign-off.
> **Status (2026-06-06): all of #7–#13 are now IMPLEMENTED and verified.**
> #10 and #11 took the interim-mitigation path (per-frame resize back to canvas
> size + fail-fast on canvases larger than the display), not the offscreen
> rewrite; #13 pads odd dimensions up to even on both render paths. All six
> `test:render-desktop` checks pass, as do the server smoke + e2e tests; #7, #8,
> and #13 were additionally verified with throwaway harnesses (overwrite safety;
> early-ffmpeg-exit surfaces an error without crash or hang; a 101×101 canvas
> encodes to a clean 102×102 MP4). See the per-finding "Status" notes below.
> Note: plan_2's "What is already correct" section claims *"Partial output file
> is removed on failure/cancel"* as a safety property. That is precisely the
> mechanism behind finding **#7** — the worker writes ffmpeg output straight to
> the user's chosen path, so "removing the partial file" deletes a pre-existing
> file the user chose to overwrite. The note should be corrected when #7 lands.
## Summary table
| # | Sev | File | One-line |
| --- | --- | --- | --- |
| 7 | CRITICAL | `electron/renderWorker.cts` | Direct-to-target ffmpeg write + `fs.rm` destroys a pre-existing file on cancel/ffmpeg failure (data loss). |
| 8 | HIGH | `electron/renderWorker.cts` | No `error` listener on `ffmpeg.stdin`; an early ffmpeg exit makes the next `write()` throw an uncaught EPIPE that crashes the main process. |
| 9 | HIGH (trivial) | `package.json` | `"packageManager": "traversal@1.0.0"` is a bogus value; breaks corepack-driven tooling. |
| 10 | MEDIUM | `electron/renderWorker.cts` | `capturePage` returns frames at the display scale factor → HiDPI output is N× the requested resolution. |
| 11 | MEDIUM | `electron/renderWorker.cts` | A non-offscreen render window can't be captured larger than the display → canvases > screen are truncated. |
| 12 | MEDIUM (perf) | `electron/renderWorker.cts` + `server/render.html` | Hidden-window rAF is throttled, so `__animatrSeek` hits its 1 s fallback every frame → renders crawl at ~1 fps. |
| 13 | LOW (optional) | `electron/renderWorker.cts` + `server/server.js` | Odd canvas width/height fails `yuv420p`/libx264 (shared latent bug). |
Recommended sequencing: **#9 (1 line)** → **#7, #8 (correctness)** →
**#12 (one line, big perf win)** → decide the **#10/#11 strategy (offscreen vs.
mitigations)** → **#13 (optional hardening)**.
---
## #7 — CRITICAL: desktop export destroys an existing file on cancel/failure
> **Status: IMPLEMENTED.** Worker renders to a sibling
> `.<name>.<jobId>.partial` file and promotes it with `rename` (rm-then-rename
> fallback on Windows) only after `ffmpegDone` resolves; the failure/cancel path
> only ever removes the temp file. One extra change was required: ffmpeg infers
> its muxer from the output extension, and the temp file has none, so the spawn
> now passes an explicit `-f mp4` (this also hardens a final path the user typed
> without a `.mp4` extension). Verified: cancel mid-render leaves a pre-existing
> sentinel file byte-identical with no leftover `.partial`.
### Problem
`renderMp4Job` spawns ffmpeg writing **directly** to the user-chosen path:
```ts
const ffmpeg = spawn(ffmpegPath, [ '-y', ..., input.outputPath ])
```
and on any error/cancel does:
```ts
await fs.rm(input.outputPath, { force: true }).catch(() => {})
```
`ffmpeg -y <outputPath>` opens and **truncates** the target the instant it
starts. If the user chose to overwrite an existing file (the native save dialog
explicitly offers this), then any of:
- user clicks Cancel mid-render,
- the inactivity watchdog fires,
- ffmpeg exits non-zero,
leaves the original file already truncated, and the catch block then deletes it.
**Net result: the user's pre-existing file is gone and there is no replacement.**
The standalone server never has this problem because it renders into a private
`mkdtemp` workDir and only exposes the finished file.
### Fix (render to a sibling temp file, promote on success)
Write to a temp file in the **same directory** as the target (same filesystem ⇒
`rename` is atomic and can't hit `EXDEV`), and only touch the real path once the
render has fully succeeded:
```ts
const finalPath = input.outputPath
const tmpPath = path.join(
path.dirname(finalPath),
`.${path.basename(finalPath)}.${input.jobId}.partial`,
)
// spawn ffmpeg with tmpPath instead of finalPath
// success path, after `await ffmpegDone`:
await fs.rm(finalPath, { force: true }) // Windows rename won't replace an existing dest
await fs.rename(tmpPath, finalPath)
return { path: finalPath }
// catch path:
await fs.rm(tmpPath, { force: true }).catch(() => {}) // NEVER finalPath
```
Notes:
- The `rm(finalPath)` before `rename` only runs after a **successful** render
(tmp file is complete), so the old file is replaced, never lost.
- Keep `'-movflags', '+faststart'` — ffmpeg still finalizes into `tmpPath`.
- Optionally sweep stale `.*.partial` files in the target dir on worker start
(best-effort), to cover hard crashes.
### Verification
- New `scripts/verify-overwrite.cjs`: pre-create a sentinel file at the target
path, start an export to it, cancel mid-render, assert the **sentinel bytes
are unchanged** and no `.partial` file remains.
- Re-run `scripts/verify-cancel.cjs` (already passing) — must stay green.
---
## #8 — HIGH: an early ffmpeg exit crashes/hangs the worker
> **Status: IMPLEMENTED.** The investigation found the EPIPE was only one of
> three failure modes when ffmpeg exits mid-render; all three are now fixed:
> 1. **EPIPE crash** — added `ffmpeg.stdin.on('error', …)` so a write to the
> dead pipe can't become an uncaughtException.
> 2. **Unhandled-rejection crash** — `ffmpegDone` rejects mid-loop, long before
> the post-loop `await` attaches a handler; under Node's default
> `--unhandled-rejections=throw` that terminated the process. Now a no-op
> handler is attached at creation (`void ffmpegDone.catch(() => {})`); real
> handling still flows through `await ffmpegDone`. (This is why the original
> cancel test passed but the early-exit path died — the cancel path attaches
> its handler within Node's grace window, the early-exit path does not.)
> 3. **Backpressure hang** — when ffmpeg is dead, `stdin.write` returns false and
> the old `await stdin.once('drain')` waited forever (no drain ever comes, and
> it wasn't abortable). The wait now races `ffmpegDone` and `raceAbort(signal)`.
>
> Also added `if (settled) break` at the top of the frame loop so a dead ffmpeg
> stops being fed. Verified: a stub ffmpeg that exits non-zero mid-stream makes
> `renderMp4Job` reject with the ffmpeg error, and the process survives and exits
> 0 (no `UNCAUGHT_EXCEPTION` / `UNHANDLED_REJECTION`, no hang).
### Problem
The only error listeners are on the `ffmpeg` ChildProcess (`spawn`/`error`/
`exit`) and `ffmpeg.stderr`. There is **no listener on `ffmpeg.stdin`**. If
ffmpeg exits early (malformed frame, disk full, codec/resource error), the next
loop iteration runs:
```ts
if (!ffmpeg.stdin.write(png)) { ... }
```
Writing to a pipe whose read end is closed emits an **asynchronous** `'error'`
(EPIPE) on the stdin stream. Because it is async, the surrounding `try/catch`
does not catch it; with no `'error'` listener it becomes an
`uncaughtException` and takes down the **entire Electron app** (not just the
export). The standalone server has the same write pattern, but there it only
crashes an isolated, restartable server process — in the desktop app it kills
the user's editor.
### Fix
1. Swallow the stdin error (the real failure is already surfaced via the
`exit`/`ffmpegDone` path):
```ts
ffmpeg.stdin.on('error', () => { /* EPIPE after early exit; surfaced via ffmpegDone */ })
```
2. Stop feeding a dead ffmpeg so the *real* error surfaces promptly. `settled`
is already set in the `exit` handler — check it at the top of the loop:
```ts
for (let i = 0; i < input.options.totalFrames; i += 1) {
if (signal.aborted) throw abortReason(signal)
if (settled) break // ffmpeg already exited; quit feeding it
...
ffmpeg.stdin.write(png)
}
ffmpeg.stdin.end()
await ffmpegDone // rejects with "ffmpeg exited with code N: <stderr>"
```
### Verification
- New `scripts/verify-ffmpeg-early-exit.cjs`: induce an early ffmpeg exit (e.g.
point `FFMPEG_PATH` at a stub that reads a few bytes then `exit(1)`, or kill
the ffmpeg child mid-stream) and assert `renderMp4Job` **rejects** with a
surfaced ffmpeg error and the **process does not crash** (the test harness
itself surviving is the assertion).
---
## #9 — HIGH (trivial): bogus `packageManager` field
> **Status: IMPLEMENTED.** Line removed from `package.json`; valid JSON, no
> references remain.
### Problem
`package.json:6`:
```json
"packageManager": "traversal@1.0.0",
```
`traversal` is not a package manager. It is referenced nowhere and is absent
from `package-lock.json` — almost certainly an accidental insertion. In any
corepack-enabled environment (Node ships corepack; CI or contributors may have
run `corepack enable`), invoking npm/yarn/pnpm reads this field and errors
(`Unsupported package manager 'traversal'`), blocking installs and builds.
### Fix
Delete the line. (If a manager really must be pinned, use a real value like
`"npm@10.x"`.)
### Verification
`npm install` / `npm ci` and `corepack npm -v` succeed in a clean checkout.
---
## #10 / #11 / #12 — Capture fidelity & performance
These three share a root cause: the render window is a **hidden, non-offscreen**
`BrowserWindow`, and `capturePage` is coupled to the physical display
(scale factor and size) and to Chromium's background throttling. The strategic
fix is offscreen rendering (already floated in plan_2 finding #1); pragmatic
per-issue mitigations are listed if offscreen is deferred.
### #10 — MEDIUM: HiDPI doubles the output resolution
> **Status: IMPLEMENTED (interim mitigation).** The frame loop now calls
> `frame.getSize()` and, when the captured `NativeImage` doesn't match the
> requested canvas, `frame.resize({width, height})` back to the canvas dims
> before `toPNG()`. No-op on scale-factor-1 displays. The offscreen "deep fix"
> below was not taken. (Can't be reproduced on this scale-1 box; should be
> sanity-checked once on a HiDPI machine.)
`capturePage(captureRect)` takes a rect in DIPs but returns a `NativeImage` at
the **display's device scale factor**. On a 2× display a 1920×1080 canvas yields
a 3840×2160 PNG, so the MP4 is double the requested resolution and ~4× the size
— diverging from the server, which pins `deviceScaleFactor: 1`. Likely
unnoticed so far because the verify harness runs at scale factor 1.
Interim mitigation (normalize every frame to the requested size):
```ts
const frame = await raceAbort(renderWindow.webContents.capturePage(captureRect), signal)
const { width, height } = frame.getSize()
const png = (width === captureRect.width && height === captureRect.height
? frame
: frame.resize({ width: captureRect.width, height: captureRect.height })
).toPNG()
```
(Do **not** use `app.commandLine.appendSwitch('force-device-scale-factor','1')`
— it is global and would degrade the editor UI on HiDPI.)
### #11 — MEDIUM: canvases larger than the display are truncated
> **Status: IMPLEMENTED (interim mitigation).** `main.cts` now compares the
> normalized canvas against `screen.getPrimaryDisplay().size` before starting and
> throws a clear error ("Canvas WxH is larger than this display …") rather than
> silently truncating. This is desktop-only: the web/server path keeps using a
> headless viewport and still accepts up to 4096². The offscreen "deep fix" below
> (which would let the desktop path render oversize canvases too) was not taken.
A non-offscreen window can't be composited/captured beyond the display bounds,
so a canvas larger than the screen (validation allows up to 4096²) produces a
clipped frame. The server avoids this with a headless Chrome viewport.
Interim mitigation: in `main.cts`, before starting, compare the (normalized)
canvas against `screen.getPrimaryDisplay().workAreaSize` and **fail fast with a
clear error** rather than silently truncating; or document a desktop max-canvas
limit. (The real fix is offscreen.)
### #12 — MEDIUM (perf): hidden-window rAF throttling ⇒ ~1 fps
> **Status: IMPLEMENTED.** `backgroundThrottling: false` added to the render
> window's `webPreferences`. (#10 and #11 remain open; the offscreen-rendering
> "deep fix" below was not taken.)
`__animatrSeek` now resolves after a double `requestAnimationFrame`, with a
`setTimeout(finish, 1000)` anti-hang fallback. But the render window is
`show: false` with **no `backgroundThrottling: false`**, so Chromium throttles/
pauses rAF for the hidden document — the rAFs don't fire promptly and the **1 s
fallback becomes the per-frame path**. A 10 s @ 30 fps render then takes ~5 min.
(The verify harness checks correctness, not throughput, so this slipped through.)
Fix (one line, large win):
```ts
webPreferences: {
...
backgroundThrottling: false, // keep rAF firing while the window is hidden
}
```
### Deep fix (recommended) — offscreen rendering, subsumes #10/#11/#12
Switch the render window to `webPreferences: { offscreen: true }`:
- renders to an in-memory bitmap **independent of the physical display** (fixes
#11 oversize) at a controllable scale (fixes #10 HiDPI),
- isn't background-throttled and pairs with `webContents.setFrameRate(...)`
(fixes #12),
- capture via either the `paint` event / `beginFrameSubscription` or
`capturePage` (still pair with the existing rAF settle to avoid the
off-by-one from plan_2 #2).
Cost/risk: changes the capture mechanism, so the **entire** `test:render-desktop`
suite (all 6 checks) must be re-run, plus new HiDPI + large-canvas checks, plus
a GPU-vs-software-rendering sanity pass on each platform. If offscreen lands,
#10/#11/#12 close together and the interim mitigations above are unnecessary.
### Verification (either path)
- New `scripts/verify-resolution.cjs`: render and `ffprobe` the output; assert
width/height **exactly equal** the canvas dims. (Won't reproduce #10 on a
scale-factor-1 CI box; note that, and run it once on a HiDPI machine.)
- New `scripts/verify-large-canvas.cjs`: render a canvas larger than the test
display; assert full-size, non-truncated frames.
- New throughput assertion (extend `verify-export.cjs`): time N frames and fail
if per-frame wall-clock is near the 1 s fallback — guards the #12 regression.
---
## #13 — LOW (optional): odd canvas dimensions fail `yuv420p`
> **Status: IMPLEMENTED (pad to even).** Both ffmpeg spawns (desktop worker and
> standalone server) now carry `-vf pad=ceil(iw/2)*2:ceil(ih/2)*2`, so an odd
> canvas encodes (e.g. 101×101 → 102×102) instead of crashing with "height not
> divisible by 2". Validation was left permissive (no reject). Verified by a
> throwaway harness rendering a 101×101 scene to a clean 102×102 H.264 MP4, and
> the server e2e test still passes.
### Problem
libx264 with `-pix_fmt yuv420p` requires **even** width/height. Validation
allows any integer 1..4096, so an odd-dimension canvas (e.g. 101×101) makes
ffmpeg exit with a confusing "height not divisible by 2" error. This is shared
with the standalone server (pre-existing), but the new desktop path is a fresh
surface for it. On HiDPI it's accidentally masked because the 2× capture is
always even — fixing #10 (normalize to canvas size) re-exposes it.
### Fix (pick one, apply to both desktop worker and server)
- Pad to even in the ffmpeg args (least surprising, adds ≤1 px):
`'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2'`, **or**
- Reject odd dimensions in `shared/renderValidation.cjs` with a clear message.
### Verification
Add an odd-dimension case (e.g. 101×101) to `shared/renderValidation.test.cjs`
and/or a desktop check asserting a clean result instead of an ffmpeg crash.
---
## Out of scope / already correct (do not redo)
- Validation parity, the abort/watchdog plumbing, the cancel-before-jobId race,
ENOENT friendly message, packaging wiring, and the `__animatrSeek` paint
settle (plan_2 #2) are all sound and unchanged by this plan.
- Image assets are imported as `data:` URLs, so the render window's
`*://*/*` network block (plan_2 #5) does not drop them — no action needed.
## Suggested doc bookkeeping
When these land, update `05_electron-rendering-integration-plan_2.md`:
- correct the "Partial output file is removed on failure/cancel" note (#7),
- and `06_electron-rendering-manual-checks.md` with the new verify scripts.
@@ -0,0 +1,366 @@
# Electron E2E & Capture Verification — Test Plan
Date: 2026-06-06
This plan defines how we verify (a) the **capture/render fidelity** of the
desktop and server export paths and (b) **all Electron-specific behaviour**, and
answers the open question of **whether the browser build and the Electron build
need separate test coverage**.
It builds on what already exists — do not re-invent these:
- the in-app **Test Runner** (`src/devtools/{testRunner.ts,tests.ts,TestOverlay.vue}`),
- the Node/Electron **render harnesses** (`scripts/verify-*.cjs`),
- the **server** tests (`server/{server.smoke,render.e2e}.test.cjs`),
- the **shared validation** unit tests (`shared/renderValidation.test.cjs`),
- the manual checklist (`06_electron-rendering-manual-checks.md`).
> **TL;DR**
> 1. **Don't fork the suite per build.** The frontend is ~99% shared and gated
> only on `window.ruwioDesktop`. Test the shared core **once**; cover the small,
> enumerable set of divergences with **seam-targeted** tests.
> 2. **Capture fidelity** is verified with one **golden-scene matrix** run through
> all three renderers (editor `drawSceneFrame`, desktop `capturePage`, server
> `screenshot`). The same matrix doubles as the **render.ts ⇄ render.html
> parity** check — the highest-value new test, because those two renderers are
> hand-duplicated and silently drift.
> 3. **Unlock the ~130 existing in-app suites for CI** by adding one headless
> auto-run hook, then host it under both a browser runner and an Electron
> runner. This is the cheapest large coverage win.
> 4. **Add the missing layer:** an Electron **app-level e2e** that launches the
> real app and drives menu commands / the export→save-dialog→file flow.
---
## 1. The system under test (what actually diverges)
### 1.1 Two renderer implementations (critical)
| Renderer | Code | Used by |
| --- | --- | --- |
| **#1 TS `drawSceneFrame`** | `src/lib/render.ts` | Live editor preview (`CanvasStage.vue` `drawPreview`), **and every client export**: GIF (`gifExport.ts`), APNG (`apngExport.ts`), WebM (`videoExport.ts`), still PNG/JPG (`stillImageExport.ts`), clipboard frame — all via `src/lib/frameExport.ts` `renderFrames()`. |
| **#2 vanilla-JS reimpl** | inlined in `server/render.html` (`drawFrame`/`sample`/`drawLayerNode`/`drawMaskShape`), exposed as `window.__animatrInit` / `window.__animatrSeek` | **Both MP4 backends**: the Puppeteer **server** (`server/server.js`) *and* the Electron **desktop worker** (`electron/renderWorker.cts`). |
These two are **functionally the same renderer maintained twice**. Renderer #1
is heavily covered by in-app pixel tests; Renderer #2 has only a non-blank/motion
fingerprint today. A feature added to `render.ts` (new asset kind, easing,
shadow/blur tweak, mask mode) that is not mirrored into `render.html` produces
**correct previews/GIFs but wrong MP4s** with no failing test. **Parity between
#1 and #2 is the single biggest coverage gap.**
### 1.2 Browser vs Electron — the full divergence set
Detection is **only** `window.ruwioDesktop` presence (set by `electron/preload.cts`).
No user-agent sniffing anywhere. The divergences are:
| # | Divergence | Where | Build | Test owner (proposed) |
| --- | --- | --- | --- | --- |
| D1 | **MP4 backend switch** `hasDesktopRender()` → desktop IPC vs server HTTP | `src/lib/desktopRenderExport.ts:12`, `ExportDialog.vue` (`exportMp4Server`) | both | unit (inject/remove bridge) + each backend's e2e |
| D2 | **Native save dialog** (`dialog.showSaveDialog`); `null` return on cancel | `electron/main.cts` `runRenderMp4` | electron | Electron app e2e (stubbed dialog) |
| D3 | **18 IPC menu commands** via `onMenuCommand` → `runDesktopMenuCommand` | `electron/main.cts` `createMenu`, `src/App.vue`, `src/types/desktop.d.ts` | electron | Electron app e2e |
| D4 | **Service worker** registered only in browser | `src/main.ts` (`!window.ruwioDesktop`) | browser | jsdom/browser unit |
| D5 | **Bundled ffmpeg + capturePage** pipeline | `electron/renderWorker.cts`, `electron/ffmpegPath.cts` | electron | capture/render e2e |
| D6 | **`renderActive` single-job guard** (mirrors server `MAX_ACTIVE_JOBS=1`) | `electron/main.cts` `handleRenderMp4` | electron | Electron app e2e / worker e2e |
| D7 | **Canvas-larger-than-display** hard error (#11) | `electron/main.cts` `runRenderMp4` | electron | Electron e2e / main unit |
| D8 | **Hardened render window** — `show:false`, white bg (#4), `backgroundThrottling:false` (#12), isolated partition, `http(s)` request block (#5) | `electron/renderWorker.cts` `createRenderWindow` | electron | capture/render e2e (extend `verify-assets`/`verify-bg`) |
| D9 | **Navigation/external-link hardening** (`setWindowOpenHandler`, `will-navigate`, `shell.openExternal` https-only) | `electron/main.cts` | electron | Electron app e2e (or main unit) |
| D10 | **`darwin` menu-role branches** | `electron/main.cts` `createMenu` | electron (mac) | smoke / manual |
| D11 | **HiDPI frame normalization** (#10), **odd-dim pad** (#13) | `electron/renderWorker.cts`, `server/server.js` | electron + server | capture/render e2e |
**Conclusion on the user's question:** *Yes, differentiate — but minimally.* The
shared frontend (stores, `drawSceneFrame`, UI components, all non-MP4 exports) is
build-agnostic and is tested **once**. Only D1–D11 need build-specific tests, and
each is a small, named seam. We do **not** maintain two parallel frontend suites.
---
## 2. Current coverage inventory & gaps
### Layer A — in-app Test Runner (`src/devtools/`) — renderer, **manual-only**
- Custom `suite`/`test`/`expect` DSL (`testRunner.ts`), ~130 suites in `tests.ts`,
results UI in `TestOverlay.vue`. Triggered by the dev menu `test-runner` command
(`App.vue`, gated on `import.meta.env.DEV`).
- Runs **in the live renderer** against real Pinia store, Konva stage, and DOM.
- Covers: scene store, interpolation/`sampleLayer`, **pixel-level `drawSceneFrame`**
(asset kinds, masks, groups, clip instances, shadow, blur, crop), client exports
(GIF/APNG magic bytes + `renderFrames` counts/scaling/pixels), serialization
round-trips, and a wide set of UI components + a live-DOM smoke suite.
- **Gap:** no headless invocation, **no exit code**, results only in UI/clipboard
→ invisible to CI. It is the largest existing asset and is currently un-automatable.
### Layer B — Node/Electron harnesses — **CI-shaped (exit codes)**
- `scripts/verify-*.cjs` via `npm run test:render-desktop` (spawns the real
`electron` binary): `verify-capture` (hidden window → `render.html` →
`capturePage` → BGRA fingerprint: non-blank, right size, motion advances),
`verify-export` (real worker → ffmpeg → ffprobe), `verify-cancel`,
`verify-timeout` (watchdog), `verify-assets` (D8 network block), `verify-bg`
(D8 white-flatten).
- `server/render.e2e.test.cjs` (full server lifecycle → SSE → download → ffprobe),
`server/server.smoke.test.cjs` (routes/auth/validation), `shared/renderValidation.test.cjs`.
- **Gaps:** capture is verified for *liveness/motion*, **not fidelity** (no golden
comparison, no breadth across asset kinds for the MP4 path); no render.ts⇄render.html
parity; no throughput (#12) assertion; #10/#11/#13 fixes have no regression test;
no app-level e2e (menus, dialog, `renderActive`); the in-app suite isn't reachable.
### Cross-cutting gaps
- **No CI** (`.github/workflows` absent) — every layer is run by hand.
- **No frontend unit framework** (no vitest/jsdom/test-utils) — only `node:test`
(`.cjs`) and the in-app runner.
---
## 3. Target architecture — the test pyramid
```
┌─────────────────────────────────────────────────────────┐
L3 app │ Electron app e2e (Playwright _electron): launch real app,│ NEW
│ menu commands D3, export→save-dialog→file D2, renderActive│
│ D6, oversize D7, nav-hardening D9, preload bridge shape │
├─────────────────────────────────────────────────────────┤
L2 e2e │ Capture & render e2e (extend scripts/verify-*): │ EXTEND
render │ • golden-scene matrix across 3 renderers (fidelity) │
│ • render.ts ⇄ render.html PARITY (§4) │
│ • #10 resolution / #11 oversize / #12 throughput / #13 │
│ • server render.e2e breadth parity │
├─────────────────────────────────────────────────────────┤
L1 comp │ In-app Test Runner, run HEADLESS under two hosts: │ UNLOCK
/unit │ • browser host (no ruwioDesktop) │
│ • electron host (ruwioDesktop present) → covers D1/D4 │
├─────────────────────────────────────────────────────────┤
L0 unit │ node:test: shared/renderValidation (have), pure helpers, │ KEEP/ADD
│ hasDesktopRender() seam, sanitizeMp4Name, ffmpegPath │
└─────────────────────────────────────────────────────────┘
```
The shared frontend is exercised once at **L1**; build divergences live at **L0
seam tests** and **L2/L3**.
---
## 4. Capture verification — the core design
**Goal:** prove that, for a known scene at a known time, every capture backend
produces the **correct** pixels — not merely a non-blank, advancing frame.
### 4.1 Golden-scene matrix
A small set of deterministic scene JSONs (`test/fixtures/scenes/*.json`) covering
the rendering feature matrix:
- asset kinds: `rect`, `circle`, `text`, `polygon` (open/closed, bezier), `image` (`data:` PNG)
- modifiers: shadow (opacity/blur/distance/angle), blur, stroke, opacity, rotation/scale
- compositing: top-level mask (in/out), grouped mask, multi-layer z-order via `trackOrder`
- clip instances (trim/startTime), background solid vs `backgroundVideo`/transparent
- a few sampled times per scene (e.g. t = 0, mid, end)
Each scene is **renderer-agnostic** (the same object the editor serializes via
`activeSceneClone()` and ships to both backends).
### 4.2 Three capture backends, one fixture set
Run every (scene, time) through:
1. **Editor renderer #1** — `drawSceneFrame(ctx, scene, t, images)` into an
offscreen canvas → `getImageData`. (Runs in the L1 host; no Electron.)
2. **Desktop capture** — hidden `BrowserWindow` → `render.html` →
`__animatrInit`/`__animatrSeek(t)` → `capturePage(rect)` → bitmap.
(Extend `scripts/verify-capture.cjs`.)
3. **Server capture** — Puppeteer page → `render.html` → `__animatrSeek(t)` →
`page.screenshot(clip)`. (Extend `server/render.e2e.test.cjs` or a sibling.)
### 4.3 What we assert
- **Parity (#1 ⇄ #2):** backend-2/3 output must match backend-1 (`drawSceneFrame`)
within a per-pixel tolerance. **This is the render.ts ⇄ render.html drift guard.**
Treat `drawSceneFrame` (the unit-tested TS renderer) as the spec.
- **Self-fidelity of `capturePage`:** compare `capturePage` output to render.html's
own `canvas.toDataURL()` for the same frame → proves the *capture step* is faithful
(no scale-factor inflation #10, no stale/off-by-one frame, no size clipping #11).
- **Golden regression:** optionally commit reference PNGs; diff with tolerance to
catch unintended renderer changes over time.
### 4.4 Determinism hazards (must be controlled or tests flake)
| Hazard | Mitigation |
| --- | --- |
| **Fonts** — `render.html` & `render.ts` use `sans-serif`; glyphs differ per OS | bundle a single test webfont and force it in fixtures; **or** exclude `text` from pixel-exact parity (assert bounding-box/coverage instead); pin CI OS for text goldens |
| **Anti-aliasing / GPU vs software** | run capture with `--disable-gpu`; compare with tolerance (e.g. ≤2/255 per channel, ≤0.1% pixels over threshold) |
| **Device scale factor** | pin to 1 in tests; #10 normalization makes desktop match; server already pins `deviceScaleFactor:1` |
| **Image decode / paint timing** | always `__animatrSeek` (double-rAF settle) before capture; `__animatrInit` resolves after first decode |
| **#10/#11 only manifest off scale-1 / large displays** | mark those assertions; run once on a HiDPI box / large virtual display (note in manual-checks) |
### 4.5 Fold the recent fixes in as regressions
- **#10 resolution:** `verify-resolution.cjs` — capture a scene, assert
`frame.getSize()`/ffprobe dims **exactly equal** the canvas (no-op on scale-1 CI;
real value on HiDPI — note it).
- **#11 oversize:** assert `main.runRenderMp4` rejects a canvas > display with the
clear error (drive via L3 app e2e, or extract the guard to a testable pure fn).
- **#12 throughput:** extend `verify-export.cjs` to time N frames and **fail if
per-frame wall-clock approaches the 1 s `__animatrSeek` fallback** (guards the
`backgroundThrottling` regression).
- **#13 odd dims:** render a 101×101 scene, ffprobe → expect a clean 102×102 MP4
(already proven by a throwaway harness; promote it to `verify-odd-dims.cjs` and add
the server equivalent). Mirror in `shared/renderValidation.test.cjs` if we ever
switch to reject-mode.
---
## 5. Unlocking the in-app Test Runner for CI (cheapest big win)
The ~130 suites already exercise the shared frontend. Make them headless:
1. **Auto-run hook.** On boot, if `location.search` has `?__runtests=1` (or
`import.meta.env.MODE === 'test'`), after mount call
`runAll(registerAllTests)` and publish results:
```ts
// src/devtools/headless.ts (new)
const results = await runAll(registerAllTests, { delayMs: 0 })
;(window as any).__ruwioTestResults = results
document.body.setAttribute('data-tests-done',
results.some(r => !r.ok) ? 'fail' : 'pass')
```
Keep it dev/test-only (tree-shaken from prod as the overlay already is).
2. **Two hosts, one suite:**
- **Browser host:** serve the built/dev app under Playwright (Chromium), open
`?__runtests=1`, wait for `data-tests-done`, read `__ruwioTestResults`, set
exit code. `window.ruwioDesktop` absent → exercises the **browser** branch (D1
server path selection, D4 SW gating present at module load).
- **Electron host:** Playwright `_electron` launches the dev app, same hook,
`page.evaluate(() => window.__ruwioTestResults)`. `window.ruwioDesktop`
present → exercises the **electron** branch.
3. **Tagging divergent suites.** A handful of suites assert build-specific behaviour;
add a convention (e.g. suite name prefix `desktop:` / `browser:`) and let the
headless hook skip the inapplicable set per host. Most suites are build-agnostic
and run under both.
This turns the existing investment into the L1 layer for **both** builds with a
single hook — and is itself the cleanest demonstration of "differentiate by host,
not by duplicating tests."
> Caveat: the runner mutates the live app and pins locale to `en` (it snapshots and
> restores store state). That is fine for a dedicated test page/run; do not point it
> at a user session.
---
## 6. The missing layer — Electron app-level e2e (L3)
Nothing today launches the **real assembled app** and drives the main↔renderer
seam (menus, native dialog, IPC round-trips, window hardening). Proposed:
**Tooling: Playwright `_electron`** (`electron.launch({ args: ['.'] })`), because
it (a) launches the packaged/dev main, (b) hands back the renderer as a `Page` for
UI assertions, (c) can `evaluate` in both main and renderer, and (d) the same
Playwright install serves the L1 browser host. (Alternative: `wdio-electron-service`
— stronger native-API mocking, heavier. Decision in §9.)
**Test-mode hook in `electron/main.cts`** (guarded by `RUWIO_E2E=1`), so native,
human-only surfaces become deterministic:
- **Stub the save dialog:** when `RUWIO_E2E`, `runRenderMp4` uses a fixed temp path
(or honours `RUWIO_E2E_SAVE_PATH`) instead of `dialog.showSaveDialog`. Lets the
full export→encode→file flow run unattended (covers D2, D5).
- **Fire menu commands programmatically:** expose `ipcMain.handle('ruwio:test-menu',
cmd => sendAppCommand(cmd))` (or call the existing `sendAppCommand`) so the e2e can
assert each of the 18 commands routes correctly in `App.vue` (covers D3).
- Keep the hook tiny and entirely behind the env flag.
**L3 scenarios:**
- Each menu command D3 produces its renderer effect (export dialog opens, undo/redo,
play/pause, panel toggle, etc.).
- Export MP4 end-to-end via the stubbed dialog → file exists, plays (ffprobe) → D2/D5.
- Cancel mid-export → no partial at the final path (we already fixed #7; assert it at
app level too).
- Second concurrent export rejected by `renderActive` → D6.
- Oversize canvas → clear error surfaced to the renderer → D7/#11.
- Preload bridge shape: `window.ruwioDesktop` exposes exactly the documented surface
(`platform`, `versions`, `onMenuCommand`, `renderMp4`, `onRenderProgress`,
`cancelRender`) and nothing more → contract test for `preload.cts`/`desktop.d.ts`.
- Navigation hardening D9: `window.open`/in-page nav to a non-allowlisted URL is denied
(can assert via main-process evaluate of the handlers).
---
## 7. Browser-only & seam unit tests (L0)
- **`hasDesktopRender()` switch (D1):** unit test that injects a fake
`window.ruwioDesktop.renderMp4` and asserts MP4 export calls the desktop path;
removes it and asserts the server path. Pure, fast, no Electron.
- **Service-worker gating (D4):** import `src/main.ts` logic under jsdom with/without
`window.ruwioDesktop` and assert `navigator.serviceWorker.register` is called only
in the browser case.
- **`sanitizeMp4Name`, `resolveFfmpegPath`, `playbackRange`, validation limits:** plain
`node:test` (validation already covered; add the others).
These need a tiny **jsdom + node:test** (or vitest) setup — the first frontend unit
harness in the repo. Keep it minimal; the bulk of component coverage stays in L1.
---
## 8. Deliverables & sequencing
Phased so each phase is independently valuable and CI-wireable.
**Phase 1 — Regression-proof the fixes we just shipped (small, high value)**
- `scripts/verify-odd-dims.cjs` (#13) + server odd-dims case; add to `verify-desktop-render.cjs` aggregator.
- `scripts/verify-resolution.cjs` (#10, no-op on scale-1 — documented).
- throughput assertion in `verify-export.cjs` (#12).
- `npm run test:render-desktop` picks these up automatically.
**Phase 2 — Capture fidelity & renderer parity (the core ask)**
- `test/fixtures/scenes/*.json` golden matrix (§4.1).
- `scripts/verify-capture-fidelity.cjs` — desktop `capturePage` vs `drawSceneFrame`
vs render.html `toDataURL`, with the §4.4 tolerance/determinism controls.
- server-side sibling reusing the same fixtures (extend `render.e2e.test.cjs`).
- a shared pixel-diff util (`test/lib/pixelDiff.cjs`).
- **Outcome:** render.ts ⇄ render.html drift is now caught.
**Phase 3 — Unlock the in-app suite headlessly**
- `src/devtools/headless.ts` auto-run hook + boot wiring (§5).
- `test/e2e/inapp.browser.spec.ts` (Playwright Chromium) and
`test/e2e/inapp.electron.spec.ts` (Playwright `_electron`).
- suite tagging convention for build-specific suites.
**Phase 4 — Electron app e2e (L3)**
- `RUWIO_E2E` test-mode hook in `electron/main.cts` (dialog stub + menu-fire IPC).
- `test/e2e/app.electron.spec.ts` covering D2/D3/D5/D6/D7/D9 + preload contract.
**Phase 5 — L0 seam units + CI**
- jsdom/node:test for D1/D4 + pure helpers.
- A root `test` script aggregating the CI-safe layers, and a first
`.github/workflows/test.yml` (matrix: lint/build, node `test:*`,
`test:render-*` with ffmpeg/Chromium, Playwright browser + electron jobs).
- Update `06_electron-rendering-manual-checks.md` to point at the new automated checks
and list what still needs a HiDPI/macOS box (#10, D10).
### Proposed `package.json` scripts (final shape)
```jsonc
"test": "npm run test:unit && npm run test:render-validation && npm run test:render-server && npm run test:capture-fidelity",
"test:unit": "node --test test/unit/*.test.cjs",
"test:capture-fidelity": "tsc -p tsconfig.electron.json && node scripts/verify-capture-fidelity.cjs",
"test:e2e:inapp": "playwright test test/e2e/inapp.*.spec.ts",
"test:e2e:app": "playwright test test/e2e/app.electron.spec.ts"
// existing: test:render-validation, test:render-server[:e2e], test:render-desktop
```
---
## 9. Open decisions (need a call before building Phases 3–4)
1. **App-e2e tool:** Playwright `_electron` (recommended — one tool for browser +
electron, renderer Page access) vs `wdio-electron-service` (better native-API
mocking) vs a lighter spawn-electron-and-assert-IPC harness (no UI driving).
2. **Golden images:** commit reference PNGs (catches *any* renderer change, but
needs a blessing workflow + pins CI OS for fonts) **or** parity-only (compare the
three live renderers to each other, no committed baselines — less brittle, but
can't catch a change that drifts all three together). Recommendation: **parity-first**,
add goldens later for a curated non-text subset.
3. **Text fidelity:** bundle a test font for deterministic glyphs, or exempt `text`
from pixel-exact parity (structural assertion only). Affects fixture design.
4. **In-app runner in CI:** accept that it mutates a live app on a dedicated test page
(recommended) — or carve a subset that is side-effect-free for faster CI.
5. **New frontend unit harness:** introduce **vitest + jsdom** (richer, watch mode,
Vue ecosystem) vs stay on **node:test + jsdom** (zero new deps). Recommendation:
vitest, since L0/L1 will grow.
---
## 10. Out of scope / explicitly not doing
- Re-testing the shared, build-agnostic frontend twice. It is covered once at L1 and
run under both hosts.
- Driving real native OS menus / native save dialogs in automation (brittle); we use
the IPC menu-fire hook and the dialog stub instead, and keep one **manual** pass in
`06_electron-rendering-manual-checks.md` for true native behaviour and macOS roles (D10).
- The offscreen-rendering rewrite (deferred per the fix-plan); if it lands later,
the §4 capture matrix is exactly the harness that re-validates it.
+293
View File
@@ -0,0 +1,293 @@
# Export Environment Copy Plan
Date: 2026-06-07
## Goal
Make the export dialog copy describe what actually happens in each runtime:
- **Browser/PWA:** MP4 uses the render server; WebM records through browser
`MediaRecorder`.
- **Electron desktop:** MP4 uses the desktop render bridge and bundled ffmpeg;
WebM still records through the Chromium `MediaRecorder` runtime inside
Electron.
The current labels are accurate for the browser build, but misleading inside
Electron:
- `SERVER - FRAME-ACCURATE` on MP4, even when Electron is rendering locally.
- `BROWSER - REALTIME` on WebM, even when the app is running as Electron.
## Current Code
Primary files:
- `src/components/ExportDialog.vue`
- `src/i18n/messages.ts`
- `src/lib/desktopRenderExport.ts`
- `src/types/desktop.d.ts`
Runtime detection already exists as an exported helper in
`desktopRenderExport.ts`:
```ts
export function hasDesktopRender(): boolean {
return !!window.ruwioDesktop?.renderMp4
}
```
This is the right signal for MP4 copy because `ExportDialog.vue` routes MP4
through `renderMp4OnDesktop()` when `hasDesktopRender()` returns true, otherwise
it calls `downloadFromServer()`. The MP4 copy should reuse this exact function
rather than reimplementing the predicate, so the text can never drift from the
route (see Implementation Plan step 1).
`window.ruwioDesktop` is injected by the Electron preload before any app module
loads and never changes during a session, so a runtime check is stable at
module-init time — no reactivity is required to read it correctly.
## Proposed Copy Matrix
### MP4
Browser/PWA:
- Badge: `Server · Frame-by-frame`
- Description: `H.264 export via render server`
- Progress: `Rendering MP4 with render server...`
Electron:
- Badge: `Desktop · Frame-by-frame`
- Description: `H.264 export via bundled ffmpeg`
- Progress: `Rendering MP4 in desktop app...`
Rationale: both MP4 paths render deterministic frames through `server/render.html`.
The key difference is where rendering happens: HTTP render server vs Electron
desktop worker plus ffmpeg.
Avoid `Frame-accurate` in the short badge. It sounds like a stronger quality
claim than the dialog needs, and it obscures the real user-facing distinction:
server vs desktop. The help text can explain frame-by-frame rendering if needed.
Badge style: store badges in title case with a `·` middot separator, matching
the existing sibling badges (`gifBadge: 'Browser · Frame-by-frame'`,
`apngBadge: 'Browser · Lossless'`). The `.badge` CSS already applies
`text-transform: uppercase`, so do **not** write the strings in all-caps or
switch to a `-` hyphen separator — that would render MP4/WebM inconsistently
next to GIF/APNG in the same column.
### WebM
Browser/PWA:
- Badge: `Browser · Realtime`
- Description: `Recorded in real time with MediaRecorder`
- Progress: `Rendering WebM in browser realtime...`
Electron:
- Badge: `Chromium · Realtime`
- Description: `Recorded in real time by Electron's Chromium runtime`
- Progress: `Rendering WebM in Chromium realtime...`
Rationale: the implementation does not become a desktop ffmpeg pipeline in
Electron. It still uses `MediaRecorder` from the renderer process. Calling that
`browser` in the desktop app is imprecise, but `Chromium` is technically true
and short enough for the badge.
## Implementation Plan
1. Add environment booleans in `ExportDialog.vue`.
```ts
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const isDesktop = computed(() => !!window.ruwioDesktop)
const hasDesktopMp4 = computed(() => hasDesktopRender())
```
Use `hasDesktopMp4` for MP4 text. It wraps `hasDesktopRender()` — the *same*
function the MP4 route already calls in `exportMp4Server()` — so the copy
decision and the actual route share one predicate and can never diverge. Do
not reimplement `!!window.ruwioDesktop?.renderMp4` inline.
Use `isDesktop` for WebM text because WebM is still available through renderer
Chromium even if MP4 desktop rendering is unavailable; there is no existing
helper for that distinction, so a local boolean is fine.
2. Rename `exportMp4Server()` to `exportMp4()`.
The current name is now misleading because the function already branches:
- Desktop: `renderMp4OnDesktop(scene, ...)`
- Browser/server: `downloadFromServer(scene, ...)`
3. Replace static MP4 bindings.
Current:
```vue
{{ t('export.mp4Badge') }}
{{ t('export.mp4Desc') }}
```
Proposed:
```vue
{{ t(hasDesktopMp4 ? 'export.mp4DesktopBadge' : 'export.mp4ServerBadge') }}
{{ t(hasDesktopMp4 ? 'export.mp4DesktopDesc' : 'export.mp4ServerDesc') }}
```
4. Replace static WebM bindings.
Current:
```vue
{{ t('export.webmBadge') }}
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
```
Proposed:
```vue
{{ t(isDesktop ? 'export.webmDesktopBadge' : 'export.webmBrowserBadge') }}
{{
videoSupported
? t(isDesktop ? 'export.webmDesktopDesc' : 'export.webmBrowserDesc')
: t('export.webmNotSupported')
}}
```
5. Split progress labels.
MP4 currently uses:
```ts
label: t('export.labelMp4')
```
Proposed:
```ts
label: t(hasDesktopMp4.value ? 'export.labelMp4Desktop' : 'export.labelMp4Server')
```
WebM currently uses:
```ts
label: t('export.labelWebm')
```
Proposed:
```ts
label: t(isDesktop.value ? 'export.labelWebmDesktop' : 'export.labelWebmBrowser')
```
6. Update `src/i18n/messages.ts` in both locales.
English keys to add:
```ts
mp4ServerBadge: 'Server · Frame-by-frame',
mp4ServerDesc: 'H.264 export via render server',
mp4DesktopBadge: 'Desktop · Frame-by-frame',
mp4DesktopDesc: 'H.264 export via bundled ffmpeg',
webmBrowserBadge: 'Browser · Realtime',
webmBrowserDesc: 'Recorded in real time with MediaRecorder',
webmDesktopBadge: 'Chromium · Realtime',
webmDesktopDesc: "Recorded in real time by Electron's Chromium runtime",
labelMp4Server: 'Rendering MP4 with render server...',
labelMp4Desktop: 'Rendering MP4 in desktop app...',
labelWebmBrowser: 'Rendering WebM in browser realtime...',
labelWebmDesktop: 'Rendering WebM in Chromium realtime...',
```
Badges stay title case with a `·` separator; the `.badge` CSS uppercases
them at render time (see the MP4 copy-matrix note above). German keys should
mirror the meaning rather than preserve the old wording.
7. Fix the export-button help string `exportHelp` in `messages.ts`.
This is separate from the help dialog and is easy to miss. Both locales
currently hardcode the misleading claim:
- `messages.ts:43` (en): "...MP4 needs the render server; WebM records in
real time in the browser."
- `messages.ts:1153` (de): "...MP4 braucht den Render-Server. WebM wird
direkt im Browser in Echtzeit aufgenommen."
Add runtime-branched variants (e.g. `exportHelpServer` / `exportHelpDesktop`)
and select with the same `isDesktop` signal wherever `t('...exportHelp')` is
consumed. This is exactly the "globally visible text saying MP4 always needs
the render server" that must not be left in place.
8. Update help-dialog format text in `messages.ts`, and branch it now.
The strings live at `help.content.export.formats.mp4` / `.webm`
(`messages.ts:1081/1083` en, `:1923/1925` de) and `exportHelp` (handled in
step 7). Browser help should describe:
- MP4 as render-server frame-by-frame export.
- WebM as browser `MediaRecorder` realtime recording.
Electron help should describe:
- MP4 as bundled-ffmpeg desktop frame-by-frame export.
- WebM as Chromium `MediaRecorder` realtime recording.
Wiring the branch is cheaper than the old draft assumed. `HelpDialog.vue`
defines `CHAPTERS: Chapter[]` as a module-level `const` with static
`bodyKey` strings, but `window.ruwioDesktop` is set at preload before that
module loads and never changes, so a plain ternary on the `bodyKey` resolves
correctly at module-init time without making `CHAPTERS` reactive:
```ts
{ bodyKey: window.ruwioDesktop?.renderMp4
? 'help.content.export.formats.mp4Desktop'
: 'help.content.export.formats.mp4' },
{ bodyKey: window.ruwioDesktop
? 'help.content.export.formats.webmDesktop'
: 'help.content.export.formats.webm' },
```
Add the `*Desktop` keys in both locales and wire the branch in the same
change. Do not defer this and leave the help dialog asserting MP4 always
needs the render server.
## Verification
Browser/Vite:
- Open the export dialog.
- MP4 badge renders `SERVER · FRAME-BY-FRAME` (stored title case, uppercased by CSS).
- MP4 description mentions the render server.
- WebM badge renders `BROWSER · REALTIME`.
- WebM description mentions `MediaRecorder`.
- Export-button help (`exportHelp`) and help dialog describe MP4 as a
render-server export.
Electron:
- Open the export dialog.
- MP4 badge renders `DESKTOP · FRAME-BY-FRAME`.
- MP4 description mentions bundled ffmpeg.
- WebM badge renders `CHROMIUM · REALTIME`.
- WebM description mentions Electron Chromium.
- Export-button help (`exportHelp`) and help dialog describe MP4 as a
bundled-ffmpeg desktop export, not the render server.
Build checks:
```bash
npm run build
npm run desktop:dir
```
Optional automated coverage:
- Add a small in-app devtools test that stubs/removes `window.ruwioDesktop` and
verifies the selected i18n keys or rendered badge text. `src/devtools/headless.ts`
already reads `!!window.ruwioDesktop` (line 19) and is the natural home for it.
- Add an Electron harness assertion that export dialog MP4 copy is desktop copy.
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -2,9 +2,14 @@ services:
animatr-render:
build: ./server
ports:
- "6000:6000"
- "127.0.0.1:6000:6000"
environment:
- NODE_ENV=production
- PORT=6000
- HOST=0.0.0.0
- RENDER_TOKEN=${RENDER_TOKEN:?set RENDER_TOKEN before starting the render server}
shm_size: "2gb"
pids_limit: 256
mem_limit: 2g
cpus: 2
restart: unless-stopped
+30
View File
@@ -0,0 +1,30 @@
import { app } from 'electron'
import fs from 'node:fs'
import path from 'node:path'
export function resolveFfmpegPath(): string {
const envPath = process.env.FFMPEG_PATH?.trim()
if (envPath) return envPath
const exe = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const bundledPath = path.join(process.resourcesPath, 'bin', process.platform, exe)
if (isUsableBinary(bundledPath)) return bundledPath
return 'ffmpeg'
}
export function missingFfmpegMessage(): string {
if (app.isPackaged) {
return 'ffmpeg is not bundled with this RUWIO build, or it cannot be executed. Install ffmpeg or set FFMPEG_PATH to export MP4.'
}
return 'ffmpeg was not found. Install ffmpeg or set FFMPEG_PATH to export MP4.'
}
function isUsableBinary(file: string): boolean {
try {
fs.accessSync(file, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK)
return true
} catch {
return false
}
}
+403
View File
@@ -0,0 +1,403 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } from 'electron'
import type { BrowserWindow as BrowserWindowInstance, Event, IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { renderMp4Job } from './renderWorker.cjs'
const { validateRenderRequest } = require('../shared/renderValidation.cjs') as {
validateRenderRequest(scene: unknown, options: unknown): RenderValidation
}
const isDev = !app.isPackaged
const isE2E = process.env.RUWIO_E2E === '1'
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
const devServerOrigin = new URL(devServerUrl).origin
const appIconPath = isDev
? path.join(process.cwd(), 'build', 'icon.png')
: path.join(process.resourcesPath, 'build', 'icon.png')
let mainWindow: BrowserWindowInstance | null = null
const renderJobs = new Map<string, AbortController>()
// Serialize desktop renders: one active export at a time, mirroring the
// standalone server's MAX_ACTIVE_JOBS=1 (finding #6). The UI also guards this,
// but IPC is directly invokable, so enforce it in main too.
let renderActive = false
interface RenderValidation {
ok: boolean
error?: string
scene?: Parameters<typeof renderMp4Job>[0]['scene']
options?: Parameters<typeof renderMp4Job>[0]['options']
}
interface RenderMp4Payload {
scene?: unknown
fps?: unknown
crf?: unknown
preset?: unknown
suggestedName?: unknown
}
function sendAppCommand(command: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return
mainWindow.webContents.send('ruwio:menu-command', command)
}
declare global {
// Playwright's Electron runner can evaluate in the main process and call this
// env-gated hook without expanding the preload bridge surface.
var __ruwioTestMenuCommand: ((command: string) => void) | undefined
}
function sendRenderProgress(
target: Electron.WebContents,
event: { jobId: string; progress: number; done: boolean; error?: string },
): void {
if (!target.isDestroyed()) target.send('ruwio:render-progress', event)
}
function sanitizeMp4Name(value: unknown): string {
const raw = typeof value === 'string' && value.trim() ? value.trim() : 'animation.mp4'
const noPath = raw.replace(/[\\/]+/g, '-')
const safe = noPath.replace(/[^a-z0-9äöüß._-]+/gi, '-').replace(/^-+|-+$/g, '')
const named = safe || 'animation.mp4'
return named.toLowerCase().endsWith('.mp4') ? named : `${named}.mp4`
}
async function handleRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload): Promise<{ path: string } | null> {
if (renderActive) {
throw new Error('A render is already in progress')
}
renderActive = true
try {
return await runRenderMp4(event, payload)
} finally {
renderActive = false
}
}
async function runRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload): Promise<{ path: string } | null> {
const validation = validateRenderRequest(payload?.scene, {
fps: payload?.fps,
crf: payload?.crf,
preset: payload?.preset,
})
if (!validation.ok || !validation.scene || !validation.options) {
throw new Error(validation.error || 'invalid render request')
}
// The desktop path captures a hidden, on-screen BrowserWindow, which Chromium
// can't composite/capture beyond the physical display bounds — a canvas larger
// than the screen would silently yield truncated frames. Fail fast with a clear
// message instead (finding #11). The web/server path uses a headless viewport
// and has no such limit, so this is desktop-only.
const { width: canvasWidth, height: canvasHeight } = validation.scene.canvas
const { width: displayWidth, height: displayHeight } = screen.getPrimaryDisplay().size
if (canvasWidth > displayWidth || canvasHeight > displayHeight) {
throw new Error(
`Canvas ${canvasWidth}×${canvasHeight} is larger than this display ` +
`(${displayWidth}×${displayHeight}). Desktop export can't capture a canvas ` +
`bigger than the screen — reduce the canvas size and try again.`,
)
}
const window = BrowserWindow.fromWebContents(event.sender)
const saveOptions = {
title: 'Export MP4',
defaultPath: sanitizeMp4Name(payload?.suggestedName),
filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
}
const e2eSavePath = isE2E
? process.env.RUWIO_E2E_SAVE_PATH || path.join(app.getPath('temp'), saveOptions.defaultPath)
: ''
const saveResult = e2eSavePath
? { canceled: false, filePath: e2eSavePath }
: window
? await dialog.showSaveDialog(window, saveOptions)
: await dialog.showSaveDialog(saveOptions)
if (saveResult.canceled || !saveResult.filePath) return null
const jobId = randomUUID()
const controller = new AbortController()
renderJobs.set(jobId, controller)
sendRenderProgress(event.sender, { jobId, progress: 0, done: false })
try {
await renderMp4Job({
jobId,
scene: validation.scene,
options: validation.options,
outputPath: saveResult.filePath,
signal: controller.signal,
onProgress: (progress) => {
sendRenderProgress(event.sender, { jobId, progress, done: false })
},
})
sendRenderProgress(event.sender, { jobId, progress: 1, done: true })
return { path: saveResult.filePath }
} catch (err) {
const message = (err as Error).message || String(err)
sendRenderProgress(event.sender, { jobId, progress: 0, done: true, error: message })
throw err
} finally {
renderJobs.delete(jobId)
}
}
async function handleRenderCancel(_event: IpcMainInvokeEvent, jobId: unknown): Promise<void> {
if (typeof jobId !== 'string' || !jobId) return
renderJobs.get(jobId)?.abort()
}
function parseUrl(raw: string): URL | null {
try {
return new URL(raw)
} catch {
return null
}
}
function isAllowedAppNavigation(raw: string): boolean {
const url = parseUrl(raw)
if (!url) return false
if (isDev) return url.origin === devServerOrigin
if (url.protocol !== 'file:') return false
const appEntry = path.join(app.getAppPath(), 'dist', 'index.html')
return path.normalize(fileURLToPath(url)) === path.normalize(appEntry)
}
function isAllowedExternalUrl(raw: string): boolean {
const url = parseUrl(raw)
return url?.protocol === 'https:'
}
function createMainWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 860,
minWidth: 1024,
minHeight: 700,
title: 'RUWIO',
icon: appIconPath,
show: false,
backgroundColor: '#14171f',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
mainWindow.once('ready-to-show', () => {
mainWindow?.show()
})
mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => {
if (isAllowedExternalUrl(url)) {
void shell.openExternal(url)
}
return { action: 'deny' }
})
mainWindow.webContents.on('will-navigate', (event: Event, url: string) => {
if (!isAllowedAppNavigation(url)) {
event.preventDefault()
}
})
if (isDev) {
void mainWindow.loadURL(devServerUrl)
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
void mainWindow.loadFile(path.join(app.getAppPath(), 'dist', 'index.html'))
}
}
function createMenu(): void {
const template: MenuItemConstructorOptions[] = [
{
label: 'File',
submenu: [
{
label: 'New Project',
accelerator: 'CmdOrCtrl+N',
click: () => sendAppCommand('new-project'),
},
{
label: 'Open...',
accelerator: 'CmdOrCtrl+O',
click: () => sendAppCommand('open-project'),
},
{
label: 'Recent Projects',
accelerator: 'CmdOrCtrl+Shift+O',
click: () => sendAppCommand('recent-projects'),
},
{ type: 'separator' },
{
label: 'Save',
accelerator: 'CmdOrCtrl+S',
click: () => sendAppCommand('save-project'),
},
{
label: 'Save As...',
accelerator: 'CmdOrCtrl+Shift+S',
click: () => sendAppCommand('save-project-as'),
},
{ type: 'separator' },
{
label: 'Export...',
accelerator: 'CmdOrCtrl+E',
click: () => sendAppCommand('export-project'),
},
{ type: 'separator' },
process.platform === 'darwin' ? { role: 'close' } : { role: 'quit' },
],
},
{
label: 'Edit',
submenu: [
{
label: 'Undo',
accelerator: 'CmdOrCtrl+Z',
click: () => sendAppCommand('undo'),
},
{
label: 'Redo',
accelerator: 'CmdOrCtrl+Shift+Z',
click: () => sendAppCommand('redo'),
},
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Copy Current Frame',
accelerator: 'CmdOrCtrl+Alt+C',
click: () => sendAppCommand('copy-current-frame'),
},
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{
label: 'Play / Pause',
accelerator: 'Space',
click: () => sendAppCommand('toggle-playback'),
},
{
label: 'Stop',
accelerator: 'Escape',
click: () => sendAppCommand('stop-playback'),
},
{
label: 'Jump to Start',
accelerator: 'R',
click: () => sendAppCommand('rewind'),
},
{
label: 'Fullscreen Preview',
accelerator: 'Shift+Space',
click: () => sendAppCommand('fullscreen-preview'),
},
{ type: 'separator' },
{
label: 'Toggle Side Panels',
accelerator: 'Tab',
click: () => sendAppCommand('toggle-panels'),
},
{
label: 'Media Library',
accelerator: 'CmdOrCtrl+M',
click: () => sendAppCommand('media-library'),
},
{
label: 'Document Settings',
accelerator: 'CmdOrCtrl+,',
click: () => sendAppCommand('document-settings'),
},
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
],
},
{
label: 'Help',
submenu: [
{
label: 'RUWIO Help',
accelerator: 'F1',
click: () => sendAppCommand('help'),
},
{
label: 'About RUWIO',
click: () => sendAppCommand('about'),
},
{ type: 'separator' },
{
label: 'Open Test Runner',
accelerator: 'CmdOrCtrl+Shift+Alt+T',
click: () => sendAppCommand('test-runner'),
},
],
},
]
if (process.platform === 'darwin') {
template.unshift({
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
})
}
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
app.setName('RUWIO')
app.whenReady().then(() => {
ipcMain.handle('ruwio:render-mp4', handleRenderMp4)
ipcMain.handle('ruwio:render-cancel', handleRenderCancel)
if (isE2E) {
globalThis.__ruwioTestMenuCommand = (command: string) => sendAppCommand(command)
}
createMenu()
createMainWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+46
View File
@@ -0,0 +1,46 @@
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('ruwioDesktop', {
platform: process.platform,
versions: {
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
},
onMenuCommand(callback: (command: string) => void) {
const listener = (_event: Electron.IpcRendererEvent, command: string) => callback(command)
ipcRenderer.on('ruwio:menu-command', listener)
return () => {
ipcRenderer.removeListener('ruwio:menu-command', listener)
}
},
renderMp4(options: {
scene: unknown
fps?: number
crf?: number
preset?: string
suggestedName?: string
}) {
return ipcRenderer.invoke('ruwio:render-mp4', options)
},
onRenderProgress(callback: (event: {
jobId: string
progress: number
done: boolean
error?: string
}) => void) {
const listener = (_event: Electron.IpcRendererEvent, payload: {
jobId: string
progress: number
done: boolean
error?: string
}) => callback(payload)
ipcRenderer.on('ruwio:render-progress', listener)
return () => {
ipcRenderer.removeListener('ruwio:render-progress', listener)
}
},
cancelRender(jobId: string) {
return ipcRenderer.invoke('ruwio:render-cancel', jobId)
},
})
+309
View File
@@ -0,0 +1,309 @@
import { BrowserWindow, app } from 'electron'
import type { Rectangle } from 'electron'
import { spawn } from 'node:child_process'
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { missingFfmpegMessage, resolveFfmpegPath } from './ffmpegPath.cjs'
const validation = require('../shared/renderValidation.cjs') as {
playbackRange(scene: RenderScene): { start: number; end: number }
}
// Inactivity watchdog: abort a job if no frame makes progress for this long.
// The timer is re-armed on every onProgress, so steady renders never trip it;
// only a wedged executeJavaScript/capturePage does. Mirrors the standalone
// server's RENDER_JOB_TIMEOUT_MS safety net (finding #3).
function readTimeoutMs(): number {
const raw = Number(process.env.RUWIO_RENDER_TIMEOUT_MS)
return Number.isFinite(raw) && raw >= 1000 ? raw : 2 * 60 * 1000
}
const RENDER_INACTIVITY_TIMEOUT_MS = readTimeoutMs()
// Isolated, in-memory session for the hidden render window so its webRequest
// filter can't affect the main app session (finding #5).
const RENDER_PARTITION = 'ruwio-render'
function abortReason(signal: AbortSignal): Error {
return signal.reason instanceof Error ? signal.reason : new Error('aborted')
}
// Reject as soon as `signal` aborts, otherwise settle with `promise`. Lets a
// timeout/cancel interrupt an await that would otherwise hang (finding #3).
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(abortReason(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(abortReason(signal))
signal.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(err) => {
signal.removeEventListener('abort', onAbort)
reject(err)
},
)
})
}
export interface RenderScene {
canvas: { width: number; height: number }
duration: number
layers: Array<{ keyframes: Array<{ time: number }> }>
assets?: unknown[]
[key: string]: unknown
}
export interface RenderOptions {
fps: number
duration: number
totalFrames: number
crf: string
preset: string
}
export interface RenderJobInput {
jobId: string
scene: RenderScene
options: RenderOptions
outputPath: string
signal: AbortSignal
onProgress: (progress: number) => void
}
export async function renderMp4Job(input: RenderJobInput): Promise<void> {
const renderWindow = createRenderWindow(input.scene)
const ffmpegPath = resolveFfmpegPath()
let ffmpegErr = ''
let settled = false
let started = false
// Render into a sibling temp file and promote it on success, so a cancelled
// or failed render never truncates a pre-existing file the user chose to
// overwrite (finding #7). Same directory => rename stays on one filesystem.
const finalPath = input.outputPath
const tmpPath = path.join(
path.dirname(finalPath),
`.${path.basename(finalPath)}.${input.jobId}.partial`,
)
// Combined abort: trips on user cancel (input.signal) or the inactivity
// watchdog. Used to kill ffmpeg and to interrupt per-frame awaits.
const jobAbort = new AbortController()
const onExternalAbort = () => jobAbort.abort(new Error('aborted'))
if (input.signal.aborted) onExternalAbort()
else input.signal.addEventListener('abort', onExternalAbort, { once: true })
let watchdog: ReturnType<typeof setTimeout> | undefined
const armWatchdog = () => {
if (watchdog) clearTimeout(watchdog)
watchdog = setTimeout(
() => jobAbort.abort(new Error('render timed out')),
RENDER_INACTIVITY_TIMEOUT_MS,
)
}
const ffmpeg = spawn(ffmpegPath, [
'-y',
'-f', 'image2pipe',
'-framerate', String(input.options.fps),
'-i', '-',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
// libx264 + yuv420p require even dimensions, but validation allows any
// canvas in 1..4096 — an odd width/height would otherwise make ffmpeg exit
// with a confusing "height not divisible by 2" error. Pad up to the next
// even size (≤1px edge column/row) so odd canvases encode (finding #13).
'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
'-preset', input.options.preset,
'-crf', input.options.crf,
'-movflags', '+faststart',
// Force the muxer explicitly: the temp filename has no .mp4 extension, and
// the final path may lack one too, so don't rely on extension inference.
'-f', 'mp4',
tmpPath,
])
// If ffmpeg exits early (encode error, disk full, ...), the next stdin write
// emits an async 'error' (EPIPE). Without a listener that becomes an
// uncaughtException and crashes the app; the real failure still surfaces via
// the 'exit'/ffmpegDone path below (finding #8).
ffmpeg.stdin.on('error', () => {
/* noop: surfaced via ffmpegDone */
})
const abort = () => {
try {
ffmpeg.kill('SIGKILL')
} catch {
/* noop */
}
}
jobAbort.signal.addEventListener('abort', abort, { once: true })
const ffmpegReady = new Promise<void>((resolve, reject) => {
ffmpeg.once('spawn', () => resolve())
ffmpeg.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') reject(new Error(missingFfmpegMessage()))
else reject(err)
})
})
const ffmpegDone = new Promise<void>((resolve, reject) => {
ffmpeg.stderr.on('data', (d: Buffer) => {
ffmpegErr += d.toString()
})
ffmpeg.on('spawn', () => {
started = true
})
ffmpeg.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') reject(new Error(missingFfmpegMessage()))
else reject(err)
})
ffmpeg.on('exit', (code) => {
settled = true
if (code === 0) resolve()
else if (jobAbort.signal.aborted) reject(abortReason(jobAbort.signal))
else if (!started) reject(new Error(missingFfmpegMessage()))
else reject(new Error(`ffmpeg exited with code ${code}: ${ffmpegErr.slice(-500)}`))
})
})
// ffmpeg can fail mid-render, so ffmpegDone may reject long before the
// `await ffmpegDone` after the loop attaches a handler. Keep a handler on it
// from creation so that gap can't trip Node's unhandled-rejection process
// termination; real handling still flows through `await ffmpegDone` (#8).
void ffmpegDone.catch(() => {})
const signal = jobAbort.signal
try {
armWatchdog()
await raceAbort(ffmpegReady, signal)
await raceAbort(loadRenderPage(renderWindow), signal)
await raceAbort(
renderWindow.webContents.executeJavaScript(
`window.__animatrInit(${JSON.stringify(input.scene)})`,
true,
),
signal,
)
const { start: rangeIn } = validation.playbackRange(input.scene)
const captureRect: Rectangle = {
x: 0,
y: 0,
width: input.scene.canvas.width,
height: input.scene.canvas.height,
}
for (let i = 0; i < input.options.totalFrames; i += 1) {
if (signal.aborted) throw abortReason(signal)
// ffmpeg already exited (e.g. encode error): stop feeding it so the real
// error surfaces via `await ffmpegDone` below instead of an EPIPE (#8).
if (settled) break
const time = rangeIn + Math.min(i / input.options.fps, input.options.duration)
await raceAbort(
renderWindow.webContents.executeJavaScript(`window.__animatrSeek(${time})`, true),
signal,
)
const frame = await raceAbort(renderWindow.webContents.capturePage(captureRect), signal)
// capturePage returns a NativeImage at the display's device scale factor,
// so on a HiDPI screen the bitmap is N× the requested canvas and the MP4
// comes out at the wrong resolution. Normalize every frame back to the
// canvas dimensions — the standalone server pins deviceScaleFactor:1 for
// the same reason. No-op on scale-factor-1 displays (finding #10).
const frameSize = frame.getSize()
const png = (frameSize.width === captureRect.width && frameSize.height === captureRect.height
? frame
: frame.resize({ width: captureRect.width, height: captureRect.height })
).toPNG()
if (!ffmpeg.stdin.write(png)) {
// Wait for backpressure to clear — but don't hang forever if ffmpeg has
// already died (no 'drain' will ever fire) or the job is aborted. Racing
// ffmpegDone surfaces an early ffmpeg exit here instead of wedging (#8).
await raceAbort(
Promise.race([
new Promise<void>((resolve) => ffmpeg.stdin.once('drain', resolve)),
ffmpegDone,
]),
signal,
)
}
input.onProgress((i + 1) / input.options.totalFrames)
armWatchdog()
}
ffmpeg.stdin.end()
await ffmpegDone
// Render succeeded and tmpPath is complete: promote it into place. Try a
// plain rename first (atomic replace on POSIX); Windows won't overwrite an
// existing destination, so fall back to remove-then-rename there (#7).
try {
await fs.rename(tmpPath, finalPath)
} catch {
await fs.rm(finalPath, { force: true })
await fs.rename(tmpPath, finalPath)
}
} catch (err) {
if (!settled) abort()
try {
ffmpeg.stdin.destroy()
} catch {
/* noop */
}
// Only ever remove our temp file, never the user's chosen target (#7).
await fs.rm(tmpPath, { force: true }).catch(() => {})
await ffmpegDone.catch(() => {})
throw err
} finally {
if (watchdog) clearTimeout(watchdog)
input.signal.removeEventListener('abort', onExternalAbort)
jobAbort.signal.removeEventListener('abort', abort)
if (!renderWindow.isDestroyed()) renderWindow.close()
}
}
function createRenderWindow(scene: RenderScene): BrowserWindow {
const win = new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
// Opaque white background so transparent regions (e.g. backgroundVideo
// scenes) flatten onto white under yuv420p — matching the standalone
// server's Puppeteer screenshot (omitBackground:false), not black (#4).
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: RENDER_PARTITION,
// The window is never shown, so Chromium would throttle/pause rAF and the
// per-frame paint settle in __animatrSeek would hit its 1s fallback every
// frame (~1fps renders). Keep timers/rAF running while hidden (finding #12).
backgroundThrottling: false,
},
})
// Defense-in-depth: the render page loads only local (file:/data:/blob:)
// assets, so block any networked request the captured project might carry
// (finding #5). The url filter only matches http/https, leaving local
// schemes untouched. Scoped to this window's isolated session partition.
win.webContents.session.webRequest.onBeforeRequest(
{ urls: ['*://*/*'] },
(_details, callback) => callback({ cancel: true }),
)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('will-navigate', (event) => event.preventDefault())
return win
}
async function loadRenderPage(win: BrowserWindow): Promise<void> {
const renderHtml = app.isPackaged
? path.join(process.resourcesPath, 'server', 'render.html')
: path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+4
View File
@@ -7,6 +7,10 @@
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#17171b" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http://localhost:6000 http://127.0.0.1:6000 ws://localhost:5200 ws://127.0.0.1:5200; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
/>
<title>RUWIO</title>
</head>
<body>
+3782 -3
View File
File diff suppressed because it is too large. Load diff
+68 -3
View File
@@ -1,14 +1,26 @@
{
"name": "ruwio",
"private": true,
"version": "1.2.2",
"version": "1.3.2",
"type": "module",
"main": "dist-electron/main.cjs",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"desktop:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5200 && npm run desktop:start\"",
"desktop:start": "tsc -p tsconfig.electron.json && electron .",
"desktop:build": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder",
"desktop:pack": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder",
"desktop:dir": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder --dir",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"pack": "bash scripts/pack.sh"
"pack": "bash scripts/pack.sh",
"test:render-validation": "node --test shared/renderValidation.test.cjs",
"test:render-server": "node --test server/server.smoke.test.cjs",
"test:render-server:e2e": "node --test server/render.e2e.test.cjs",
"test:render-desktop": "tsc -p tsconfig.electron.json && node scripts/verify-desktop-render.cjs",
"test:capture-fidelity": "tsc -p tsconfig.electron.json && node scripts/run-electron-harness.cjs scripts/verify-capture-fidelity.cjs",
"stage:ffmpeg": "node scripts/stage-ffmpeg.cjs"
},
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
@@ -22,8 +34,61 @@
"devDependencies": {
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"concurrently": "^9.2.1",
"electron": "^42.3.3",
"electron-builder": "^26.8.1",
"typescript": "^5.6.3",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
"vue-tsc": "^2.1.10",
"wait-on": "^8.0.5"
},
"build": {
"appId": "app.ruwio.desktop",
"productName": "RUWIO",
"icon": "icon.png",
"beforeBuild": "scripts/electron-before-build.cjs",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"shared/renderValidation.cjs",
"package.json"
],
"extraResources": [
{
"from": "build/icon.png",
"to": "build/icon.png"
},
{
"from": "server/render.html",
"to": "server/render.html"
},
{
"from": "bin",
"to": "bin",
"filter": [
"**/*"
]
}
],
"linux": {
"target": [
"AppImage"
],
"category": "Graphics"
},
"win": {
"target": [
"nsis"
]
},
"mac": {
"target": [
"dmg"
],
"category": "public.app-category.graphics-design"
}
}
}
-68
View File
@@ -1,68 +0,0 @@
# Plan — SVG asset authoring view
## Concept
A new asset kind `svg` (sibling to `button`) with its own sub-editor scope. Created from a "+" button under the Images section. The sub-editor reuses the canvas + shape primitives but has no time axis: each sub-layer is a single static frame.
## 1. Data model — `src/types/scene.ts`
- Extend `AssetKind` union with `'svg'`.
- Add `SvgAsset { id; kind: 'svg'; name; canvas: { width; height; background? }; layers: Layer[] }`.
- Add to the `Asset` discriminated union.
- Layers reuse the existing `Layer` type — single keyframe at `t=0` is all the renderer/sampler needs.
## 2. Store scope — `src/stores/scene.ts`
- New state: `activeSvgAssetId: string | null`.
- Actions (mirroring the button pair):
- `addSvgAsset(name, canvas)` → creates asset, returns it.
- `enterSvgAssetEdit(assetId)` → sets `activeSvgAssetId`, clears layer / clip / button selections, parks playhead at 0.
- `exitSvgAssetEdit()` → nulls scope + selection.
- The existing `addLayer(assetId)` and `addAsset(...)` already write into the active scope (button / clip / main); extend the same scope-resolver so `activeSvgAssetId` is recognised. That means `addSampleRect / addSampleCircle / addSampleText / addSamplePolygon / startFreeformDrawing` all work unchanged inside the SVG scope.
- Block keyframe-creating paths inside SVG scope (`upsertKeyframe`, the "+ keyframe" button, arrow-nudge keyframe writes) — silently bake into the single frame at `t=0` instead of inserting a new one. Cheaper and cleaner than scrubbing them out post-hoc.
## 3. AssetSidebar — `src/components/AssetSidebar.vue`
- Add a `section-action` "+" button to the Images section header (same pattern as Clips/Buttons section).
- Handler: prompt for name (and maybe canvas size — default `300×300`) → `addSvgAsset(...)` → `enterSvgAssetEdit(asset.id)`.
- SVG assets show up in the Images section list as their own row (icon + name + click-to-edit + drag-to-stage to place a layer that references the asset). Pattern matches Buttons section.
## 4. Sub-editor wiring — `src/App.vue`
- Scope footer: add a third branch alongside `activeClipId` / `activeButtonAssetId`. SvgAsset name + back button → `exitSvgAssetEdit()`.
- **Layout swap** (the "left side replaces library" bit): when `activeSvgAssetId` is truthy:
- The `.sidebar` slot renders a new `SvgLayerPanel` instead of `AssetSidebar`.
- The `.timeline` footer area is collapsed / hidden (toggle a `panels-no-timeline` class on `.app` that flips `--timeline-height` to 0 and skips rendering Timeline). Same machinery the TAB-panels feature uses, so the visual transition is consistent.
- Header transport (play/pause/rewind/playhead) stays but should be disabled in SVG scope — there's nothing to play. Easiest: gate them on `!activeSvgAssetId`.
## 5. New component — `src/components/SvgLayerPanel.vue`
A purpose-built layer list. Cannot extract from Timeline — its track-name column is too coupled to the time axis. Surface area:
- One row per layer in `asset.layers`, top = front.
- Per row: type icon, inline-rename name, eye toggle (`editorHidden`), lock toggle, delete.
- Drag-to-reorder calling `moveLayer`.
- Top toolbar with the same shape-creation buttons currently in AssetSidebar (rect / circle / text / polygon / freeform). Reuse the existing `addSample*` handlers — they already obey active scope once step 2 lands.
- Selection state: existing `selectedLayerIds` works as-is.
## 6. CanvasStage — `src/components/CanvasStage.vue`
- Already drives off `activeButtonAssetId` for the button stage. Add a parallel branch for `activeSvgAssetId` that resolves the source layers + canvas size from the SVG asset.
- Skip the playhead animation: sample layers at `t=0` regardless of `store.playhead`. (Or: lock playhead to 0 in SVG scope so existing sampling Just Works.)
- Disable / hide UI not relevant to authoring statics: keyframe overlays, in/out brackets, scrub guidance.
## 7. Rendering the SVG asset back in the main scene — `src/lib/render.ts`
When a layer references an `SvgAsset`, draw its sub-layers via the existing layer/group draw path at `t=0`, scaled into the asset's `canvas` rect (same idea as `drawButtonFrame`). New helper `drawSvgFrame(ctx, asset, scene, images)` — small, reuses `drawMaskedGroup` like the button code does.
## 8. Persistence
No work. `SvgAsset.layers` is just `Layer[]`; goes through `projectForSave` → JSON → `.ruw` round-trip transparently. The only gate is the `AssetKind` union — once that's extended, save/load + zip/unzip + scene-clone paths all continue to work unchanged.
## 9. i18n
Strings: section-action button label/tooltip, scope footer label, layer-panel toolbar tooltips, the new-svg prompt copy. EN + DE.
## 10. Tests (devtools/tests.ts)
- Store: `addSvgAsset` shape, `enterSvgAssetEdit` flips scope + clears selection, `exitSvgAssetEdit` reverses it, `addLayer` inside SVG scope appends to `asset.layers`.
- Sidebar: "+" button under Images creates an asset and enters edit mode.
- App: while `activeSvgAssetId`, sidebar shows the layer panel and the timeline area is hidden.
- Render: a scene containing a layer referencing an SVG asset draws the asset's child shapes (snapshot via canvas pixel sample, like the existing button rendering tests).
- Round-trip: save + reload preserves `kind: 'svg'` and the nested `layers`.
## 11. Decisions (confirmed)
- **Canvas size on creation**: inherit from current scene; user can resize later via the existing resize button.
- **Background**: transparent.
- **Placement after creation**: drop user straight into the sub-editor.
- **Existing keyframe data**: strip on entry — SVG sub-layers carry a single t=0 keyframe.
Implementation order: 1–2 (types + store) → 3 + 4 (sidebar entry + scope swap) → 5 (layer panel) → 6 (canvas stage scope) → 7 (main-scene render) → 8 (i18n) → 9 (tests).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
module.exports = async function beforeBuild() {
return false
}
+21
View File
@@ -0,0 +1,21 @@
const { spawnSync } = require('node:child_process')
const electron = require('electron')
const args = process.argv.slice(2)
if (!args.length) {
console.error('usage: node scripts/run-electron-harness.cjs <script> [args...]')
process.exit(2)
}
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
env.ELECTRON_DISABLE_SANDBOX = '1'
const res = spawnSync(electron, ['--no-sandbox', ...args], {
stdio: 'inherit',
env,
argv0: electron,
})
process.exit(res.status ?? 1)
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
const { copyFileSync, chmodSync, existsSync, mkdirSync, statSync } = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const args = process.argv.slice(2)
const optional = args.includes('--optional')
const sourceIndex = args.indexOf('--source')
const explicitSource = sourceIndex >= 0 ? args[sourceIndex + 1] : ''
const root = path.resolve(__dirname, '..')
const exe = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const targetDir = path.join(root, 'bin', process.platform)
const target = path.join(targetDir, exe)
try {
mkdirSync(targetDir, { recursive: true })
if (existsSync(target) && statSync(target).isFile()) {
chmodIfNeeded(target)
console.log(`ffmpeg already staged at ${path.relative(root, target)}`)
process.exit(0)
}
const source = explicitSource || process.env.FFMPEG_PATH || findOnPath()
if (!source) {
finishWithoutBinary('ffmpeg was not found. Set FFMPEG_PATH or pass --source /path/to/ffmpeg.')
}
const resolvedSource = path.resolve(source)
if (!existsSync(resolvedSource) || !statSync(resolvedSource).isFile()) {
finishWithoutBinary(`ffmpeg source is not a file: ${source}`)
}
copyFileSync(resolvedSource, target)
chmodIfNeeded(target)
console.log(`staged ffmpeg from ${resolvedSource} to ${path.relative(root, target)}`)
} catch (err) {
if (optional) {
console.warn(`warning: could not stage ffmpeg: ${err.message}`)
process.exit(0)
}
throw err
}
function findOnPath() {
const command = process.platform === 'win32' ? 'where' : 'which'
const result = spawnSync(command, [exe], { encoding: 'utf8' })
if (result.status !== 0) return ''
return result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ''
}
function chmodIfNeeded(file) {
if (process.platform !== 'win32') chmodSync(file, 0o755)
}
function finishWithoutBinary(message) {
if (optional) {
console.warn(`warning: ${message}`)
process.exit(0)
}
console.error(message)
process.exit(1)
}
+109
View File
@@ -0,0 +1,109 @@
// Phase B verification of the render-window network block (finding #5):
// - a local data: image asset still loads and draws (block must not break it)
// - an http: image asset is blocked and skipped without hanging the render
//
// Uses the REAL worker window (createRenderWindow) by loading render.html in a
// window built the same way, so it exercises the actual webRequest filter.
//
// Run: node_modules/.bin/electron scripts/verify-assets.cjs
const { app, BrowserWindow } = require('electron')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const RENDER_PARTITION = 'ruwio-render'
// 40x40 green square as an inline SVG data URL (no network needed).
const greenSvg =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%2300ff00'/%3E%3C/svg%3E"
function makeScene(src) {
return {
canvas: { width: 160, height: 120, background: '#202030' },
duration: 1,
assets: [{ id: 'img1', kind: 'image', width: 40, height: 40, src }],
layers: [
{
id: 'l1',
assetId: 'img1',
visible: true,
base: { x: 80, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [],
},
],
}
}
function createRenderWindow(scene) {
const win = new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: RENDER_PARTITION,
},
})
win.webContents.session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_d, cb) =>
cb({ cancel: true }),
)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('will-navigate', (e) => e.preventDefault())
return win
}
function countGreen(image) {
const bmp = image.getBitmap() // BGRA
let green = 0
for (let i = 0; i < bmp.length; i += 4) {
if (bmp[i] < 120 && bmp[i + 1] > 150 && bmp[i + 2] < 120) green++
}
return green
}
async function renderOnce(src) {
const scene = makeScene(src)
const win = createRenderWindow(scene)
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
// Bound by Promise.race so a network-blocked asset that mis-handles can't hang.
const initDone = win.webContents.executeJavaScript(
`window.__animatrInit(${JSON.stringify(scene)})`,
true,
)
const timedOut = await Promise.race([
initDone.then(() => false),
new Promise((r) => setTimeout(() => r(true), 5000)),
])
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
const green = countGreen(img)
if (!win.isDestroyed()) win.close()
return { green, timedOut }
}
app.on('window-all-closed', () => {})
app.whenReady().then(async () => {
const local = await renderOnce(greenSvg)
const remote = await renderOnce('http://10.255.255.1/never.png') // unroutable; must not hang
const notes = []
let pass = true
// ~1600 green px expected for a 40x40 image; allow for AA.
if (local.green < 1000) { pass = false; notes.push(`local data: image did not draw (green=${local.green})`) }
if (local.timedOut) { pass = false; notes.push('local image init timed out') }
if (remote.green > 50) { pass = false; notes.push(`remote image drew despite block (green=${remote.green})`) }
if (remote.timedOut) { pass = false; notes.push('remote (blocked) image init hung past 5s') }
console.log(`local: green=${local.green} timedOut=${local.timedOut}`)
console.log(`remote(blocked): green=${remote.green} timedOut=${remote.timedOut}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
})
+89
View File
@@ -0,0 +1,89 @@
// Phase B verification of transparent-region flattening (finding #4): a scene
// with backgroundVideo set makes render.html skip the background fill, leaving
// transparent areas. With the opaque-white render window those areas must
// capture as white (~255), matching the server's omitBackground:false, not
// black. Builds the window exactly like renderWorker.createRenderWindow.
//
// Run: node_modules/.bin/electron scripts/verify-bg.cjs
const { app, BrowserWindow } = require('electron')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const RENDER_PARTITION = 'ruwio-render'
// backgroundVideo present -> render.html does NOT fill canvas.background.
const scene = {
canvas: { width: 160, height: 120, background: '#202030' },
backgroundVideo: { src: 'data:video/mp4,', duration: 1 },
duration: 1,
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff0000' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 30, y: 30, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [],
},
],
}
function createRenderWindow() {
const win = new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: RENDER_PARTITION,
},
})
win.webContents.session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_d, cb) =>
cb({ cancel: true }),
)
return win
}
app.on('window-all-closed', () => {})
app.whenReady().then(async () => {
const win = createRenderWindow()
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
const bmp = img.getBitmap() // BGRA
// Sample a corner pixel (far from the red rect at 30,30): index 0 == (0,0).
const corner = { b: bmp[0], g: bmp[1], r: bmp[2], a: bmp[3] }
let white = 0
let black = 0
for (let i = 0; i < bmp.length; i += 4) {
if (bmp[i] > 240 && bmp[i + 1] > 240 && bmp[i + 2] > 240) white++
if (bmp[i] < 15 && bmp[i + 1] < 15 && bmp[i + 2] < 15) black++
}
const notes = []
let pass = true
if (!(corner.r > 240 && corner.g > 240 && corner.b > 240)) {
pass = false
notes.push(`corner is not white: rgb(${corner.r},${corner.g},${corner.b})`)
}
if (black > 100) {
pass = false
notes.push(`${black} near-black px present (transparent flattened to black?)`)
}
console.log(`corner=rgb(${corner.r},${corner.g},${corner.b}) whitePx=${white} blackPx=${black}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
if (!win.isDestroyed()) win.close()
app.exit(pass ? 0 : 1)
})
+100
View File
@@ -0,0 +1,100 @@
// Phase A cancel verification: abort renderMp4Job mid-export, confirm it
// rejects, leaves no partial output file, and the ffmpeg child is gone.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-cancel.cjs
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 24
const duration = 6 // long enough that we can cancel before it finishes
const scene = {
canvas: { width: 480, height: 360, background: '#202030' },
duration,
assets: [{ id: 'a1', kind: 'rect', width: 60, height: 60, fill: '#40c0ff' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 40, y: 180, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 40 } },
{ time: 6, props: { x: 420 } },
],
},
],
}
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-cancel.mp4')
fs.rmSync(outPath, { force: true })
const controller = new AbortController()
let progressAtCancel = null
const job = renderMp4Job({
jobId: 'cancel',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: (p) => {
// Cancel once we've made real progress but are nowhere near done.
if (progressAtCancel === null && p > 0.1) {
progressAtCancel = p
controller.abort()
}
},
})
let rejected = false
let message = ''
try {
await job
} catch (err) {
rejected = true
message = err.message
}
// Give the OS a moment to reap the killed ffmpeg + finish fs.rm cleanup.
await new Promise((r) => setTimeout(r, 300))
const notes = []
let pass = true
if (progressAtCancel === null) {
pass = false
notes.push('never reached a progress point to cancel at (job too fast)')
}
if (!rejected) {
pass = false
notes.push('job resolved instead of rejecting after abort')
} else if (!/abort/i.test(message)) {
notes.push(`rejected, but message was: ${message}`)
}
if (fs.existsSync(outPath)) {
pass = false
notes.push(`partial output left behind: ${outPath} (${fs.statSync(outPath).size}B)`)
}
console.log(`progressAtCancel=${progressAtCancel} rejected=${rejected} message="${message}"`)
console.log(`output exists after cancel: ${fs.existsSync(outPath)}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
// Suppress Electron's default "quit when all windows close": renderMp4Job
// closes its render window, and the auto-quit would race our result printing.
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+120
View File
@@ -0,0 +1,120 @@
// Compares Electron capturePage output against render.html's own canvas pixels
// for deterministic fixture scenes. This guards the capture step itself:
// wrong scale-factor normalization, stale frames, and clipping show up here.
const { app, BrowserWindow, nativeImage } = require('electron')
const fs = require('node:fs')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { diffBgra } = require('../test/lib/pixelDiff.cjs')
const fixtures = [
{
file: path.join(process.cwd(), 'test', 'fixtures', 'scenes', 'capture-basic.json'),
times: [0, 0.5, 1],
},
]
function createRenderWindow(scene) {
return new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
backgroundThrottling: false,
partition: 'ruwio-capture-fidelity',
},
})
}
async function main() {
let pass = true
const notes = []
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
for (const fixture of fixtures) {
const scene = JSON.parse(fs.readFileSync(fixture.file, 'utf8'))
const win = createRenderWindow(scene)
win.webContents.session.webRequest.onBeforeRequest(
{ urls: ['*://*/*'] },
(_details, callback) => callback({ cancel: true }),
)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('will-navigate', (event) => event.preventDefault())
try {
await win.loadURL(pathToFileURL(renderHtml).toString())
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
for (const t of fixture.times) {
await win.webContents.executeJavaScript(`window.__animatrSeek(${t})`, true)
const capture = await win.webContents.capturePage({
x: 0,
y: 0,
width: scene.canvas.width,
height: scene.canvas.height,
})
const canvasDataUrl = await win.webContents.executeJavaScript(
`document.querySelector('canvas').toDataURL('image/png')`,
true,
)
const expected = nativeImage.createFromDataURL(canvasDataUrl)
const captureSize = capture.getSize()
const expectedSize = expected.getSize()
if (captureSize.width !== scene.canvas.width || captureSize.height !== scene.canvas.height) {
pass = false
notes.push(`${path.basename(fixture.file)} t=${t}: capture size ${captureSize.width}x${captureSize.height}`)
continue
}
if (expectedSize.width !== scene.canvas.width || expectedSize.height !== scene.canvas.height) {
pass = false
notes.push(`${path.basename(fixture.file)} t=${t}: canvas size ${expectedSize.width}x${expectedSize.height}`)
continue
}
const diff = diffBgra(capture.getBitmap(), expected.getBitmap(), {
// capturePage composites the hidden window, which introduces
// sub-perceptual AA/rounding noise (~maxDelta 6/255 on shape edges)
// versus the raw canvas toDataURL. Tolerate that; genuine render
// drift (wrong color, missing or stale shape, off-by-one frame)
// produces much larger deltas over a contiguous region and still
// trips the ratio gate. The explicit size checks above catch #10/#11.
perChannelTolerance: 8,
maxMismatchedRatio: 0.02,
})
console.log(
`${path.basename(fixture.file)} t=${t}: mismatch=${diff.mismatchedPixels}/${diff.pixels} ` +
`(${(diff.mismatchedRatio * 100).toFixed(4)}%) maxDelta=${diff.maxDelta} meanDelta=${diff.meanDelta.toFixed(3)}`,
)
if (!diff.ok) {
pass = false
notes.push(
`${path.basename(fixture.file)} t=${t}: diff exceeds tolerance ` +
`${diff.mismatchedPixels}/${diff.pixels}, maxDelta=${diff.maxDelta}`,
)
}
}
} finally {
if (!win.isDestroyed()) win.close()
}
}
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.commandLine.appendSwitch('disable-gpu')
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+138
View File
@@ -0,0 +1,138 @@
// Phase A verification harness (05_electron-rendering-integration-plan_2.md, finding #1/#2).
//
// Reproduces renderWorker.cts's window-creation + capturePage path against a
// scene with one moving + fading layer, then checks that captured frames are
// (a) non-blank and (b) actually advance over time. This validates or refutes
// "capturePage on a hidden, non-offscreen window returns blank/stale frames".
//
// Run: node_modules/.bin/electron scripts/verify-capture.cjs
// Set OFFSCREEN=1 to exercise the offscreen+paint-settle code path instead.
const { app, BrowserWindow } = require('electron')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const OFFSCREEN = process.env.OFFSCREEN === '1'
// A 320x240 scene: a red rect slides left->right and fades over 2s on a dark bg.
const scene = {
canvas: { width: 320, height: 240, background: '#202030' },
duration: 2,
assets: [{ id: 'a1', kind: 'rect', width: 40, height: 40, fill: '#ff4040' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 20, y: 100, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 20 } },
{ time: 2, props: { x: 260 } },
],
},
],
}
function createRenderWindow() {
return new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
transparent: true,
backgroundColor: '#00000000',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
offscreen: OFFSCREEN,
},
})
}
// Cheap fingerprint of a NativeImage: total pixels, count of non-(near)background
// pixels, and a coarse sum so we can tell frames apart.
function fingerprint(image) {
const bmp = image.getBitmap() // BGRA
let nonBg = 0
let redish = 0
// Position-weighted hash: changes under pure translation (an order-independent
// sum would not), so two frames that differ only in where the rect sits hash
// differently.
let hash = 0
for (let i = 0; i < bmp.length; i += 4) {
const b = bmp[i]
const g = bmp[i + 1]
const r = bmp[i + 2]
const px = i / 4
if (Math.abs(r - 0x20) + Math.abs(g - 0x20) + Math.abs(b - 0x30) > 60) nonBg++
if (r > 150 && g < 120 && b < 120) {
redish++
hash = (hash + px * 2654435761) >>> 0
}
}
return { pixels: bmp.length / 4, nonBg, redish, hash, size: image.getSize() }
}
async function main() {
const win = createRenderWindow()
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
const captureRect = { x: 0, y: 0, width: scene.canvas.width, height: scene.canvas.height }
const times = [0, 0.5, 1, 1.5, 2]
const frames = []
for (const t of times) {
// __animatrSeek now resolves only after a compositor paint settle, so the
// worker just awaits it — no extra settle here (mirrors renderWorker.cts).
await win.webContents.executeJavaScript(`window.__animatrSeek(${t})`, true)
const image = await win.webContents.capturePage(captureRect)
frames.push({ t, ...fingerprint(image) })
}
// Analysis
let pass = true
const notes = []
for (const f of frames) {
if (f.size.width !== scene.canvas.width || f.size.height !== scene.canvas.height) {
pass = false
notes.push(`frame t=${f.t}: wrong size ${f.size.width}x${f.size.height}`)
}
if (f.nonBg < 50) {
pass = false
notes.push(`frame t=${f.t}: looks blank (nonBg=${f.nonBg})`)
}
}
// Frames must differ (motion advanced, not frozen on frame 0).
const hashes = new Set(frames.map((f) => f.hash))
if (hashes.size < frames.length) {
pass = false
notes.push(`frames not all distinct: ${frames.length} captured, ${hashes.size} unique hashes`)
}
console.log('OFFSCREEN =', OFFSCREEN)
console.log('captures:')
for (const f of frames) {
console.log(
` t=${f.t} nonBg=${f.nonBg} redish=${f.redish} hash=${f.hash} size=${f.size.width}x${f.size.height}`,
)
}
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
if (!win.isDestroyed()) win.close()
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+44
View File
@@ -0,0 +1,44 @@
// Runs the desktop-render verification harnesses (Phase A/B of
// 05_electron-rendering-integration-plan_2.md) under Electron and aggregates the
// results. Each harness exits 0 on PASS, non-zero on FAIL.
//
// npm run test:render-desktop (compiles electron TS first, then runs this)
//
// Requires a display (DISPLAY set), ffmpeg + ffprobe on PATH, and a built
// dist-electron/ (the npm script's `tsc` step handles the latter).
const { spawnSync } = require('node:child_process')
const path = require('node:path')
const electron = require('electron') // resolves to the electron binary path
const electronEnv = { ...process.env }
delete electronEnv.ELECTRON_RUN_AS_NODE
electronEnv.ELECTRON_DISABLE_SANDBOX = '1'
const harnesses = [
['capture', 'verify-capture.cjs', 'hidden-window capturePage produces advancing, non-blank frames (#1/#2)'],
['capture-fidelity', 'verify-capture-fidelity.cjs', 'capturePage matches render.html canvas pixels'],
['export', 'verify-export.cjs', 'real worker renders a playable H.264 MP4 with motion'],
['resolution', 'verify-resolution.cjs', 'MP4 dimensions match the requested canvas on HiDPI (#10)'],
['odd-dims', 'verify-odd-dims.cjs', 'odd canvas dimensions pad to an even MP4 instead of failing (#13)'],
['cancel', 'verify-cancel.cjs', 'cancel mid-export aborts cleanly, no partial file'],
['timeout', 'verify-timeout.cjs', 'inactivity watchdog aborts a wedged render (#3)'],
['assets', 'verify-assets.cjs', 'local data: assets load; remote http: blocked, no hang (#5)'],
['background', 'verify-bg.cjs', 'transparent regions flatten to white, not black (#4)'],
]
let failures = 0
for (const [name, file, desc] of harnesses) {
process.stdout.write(`\n=== ${name}: ${desc} ===\n`)
const res = spawnSync(electron, ['--no-sandbox', path.join(__dirname, file)], {
stdio: ['ignore', 'inherit', 'ignore'], // drop Electron's chatty stderr
env: electronEnv,
})
if (res.status !== 0) {
failures += 1
process.stdout.write(`*** ${name} FAILED (exit ${res.status})\n`)
}
}
process.stdout.write(`\n${failures === 0 ? 'ALL DESKTOP RENDER CHECKS PASSED' : failures + ' CHECK(S) FAILED'}\n`)
process.exit(failures === 0 ? 0 : 1)
+125
View File
@@ -0,0 +1,125 @@
// Phase A end-to-end verification: drives the REAL compiled renderMp4Job
// (dist-electron/renderWorker.cjs) through ffmpeg and ffprobes the result.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-export.cjs
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
const { execFileSync } = require('node:child_process')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 12
const duration = 2
const scene = {
canvas: { width: 320, height: 240, background: '#202030' },
duration,
assets: [{ id: 'a1', kind: 'rect', width: 40, height: 40, fill: '#ff4040' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 20, y: 100, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 20 } },
{ time: 2, props: { x: 260 } },
],
},
],
}
const options = {
fps,
duration,
totalFrames: fps * duration + 1,
crf: '20',
preset: 'ultrafast',
}
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-export.mp4')
fs.rmSync(outPath, { force: true })
const controller = new AbortController()
let lastProgress = 0
const startedAt = Date.now()
await renderMp4Job({
jobId: 'verify',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: (p) => {
lastProgress = p
},
})
const elapsedMs = Date.now() - startedAt
const msPerFrame = elapsedMs / options.totalFrames
const notes = []
let pass = true
if (!fs.existsSync(outPath)) {
pass = false
notes.push('output file missing')
} else {
const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(1)
const probe = JSON.parse(
execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=codec_name,width,height,nb_read_frames',
'-count_frames',
'-of', 'json',
outPath,
]).toString(),
)
const s = probe.streams[0]
console.log(`output: ${outPath} (${sizeKb} KB)`)
console.log(`codec=${s.codec_name} ${s.width}x${s.height} frames=${s.nb_read_frames} progress=${lastProgress}`)
console.log(`elapsed=${elapsedMs}ms (${msPerFrame.toFixed(1)}ms/frame)`)
if (s.codec_name !== 'h264') { pass = false; notes.push(`codec ${s.codec_name} != h264`) }
if (s.width !== 320 || s.height !== 240) { pass = false; notes.push(`wrong dims ${s.width}x${s.height}`) }
if (Number(s.nb_read_frames) !== options.totalFrames) {
pass = false
notes.push(`frames ${s.nb_read_frames} != expected ${options.totalFrames}`)
}
if (lastProgress !== 1) { pass = false; notes.push(`final progress ${lastProgress} != 1`) }
const maxMsPerFrame = Number(process.env.RUWIO_VERIFY_MAX_MS_PER_FRAME || 800)
if (msPerFrame > maxMsPerFrame) {
pass = false
notes.push(
`render throughput ${msPerFrame.toFixed(1)}ms/frame exceeds ${maxMsPerFrame}ms/frame ` +
'(hidden render window may be throttled)',
)
}
// Decode first & last frame, confirm the rect moved (motion preserved).
const f0 = path.join(os.tmpdir(), 'ruwio-f0.png')
const f1 = path.join(os.tmpdir(), 'ruwio-flast.png')
execFileSync('ffmpeg', ['-y', '-v', 'error', '-i', outPath, '-vf', 'select=eq(n\\,0)', '-frames:v', '1', f0])
execFileSync('ffmpeg', ['-y', '-v', 'error', '-sseof', '-0.1', '-i', outPath, '-frames:v', '1', f1])
const sz0 = fs.statSync(f0).size
const sz1 = fs.statSync(f1).size
const b0 = fs.readFileSync(f0)
const b1 = fs.readFileSync(f1)
const same = b0.length === b1.length && b0.equals(b1)
console.log(`first frame ${sz0}B, last frame ${sz1}B, identical=${same}`)
if (same) { pass = false; notes.push('first and last frame identical (no motion)') }
}
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
// renderMp4Job closes its render window; suppress the default auto-quit so it
// doesn't race result printing.
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+66
View File
@@ -0,0 +1,66 @@
// Regression for odd canvas dimensions (#13): yuv420p MP4 export pads to even
// dimensions instead of failing with "height not divisible by 2".
const { app } = require('electron')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const scene = {
canvas: { width: 101, height: 101, background: '#111827' },
duration: 0.5,
assets: [{ id: 'rect', kind: 'rect', width: 45, height: 35, fill: '#f97316' }],
layers: [
{
id: 'layer-rect',
assetId: 'rect',
visible: true,
base: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 38 } },
{ time: 0.5, props: { x: 62 } },
],
},
],
}
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-odd-dims.mp4')
fs.rmSync(outPath, { force: true })
await renderMp4Job({
jobId: 'verify-odd-dims',
scene,
options: { fps: 4, duration: 0.5, totalFrames: 3, crf: '28', preset: 'ultrafast' },
outputPath: outPath,
signal: new AbortController().signal,
onProgress: () => {},
})
const probe = JSON.parse(execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=codec_name,width,height,nb_read_frames',
'-count_frames',
'-of', 'json',
outPath,
]).toString())
const stream = probe.streams[0]
const pass =
stream.codec_name === 'h264' &&
stream.width === 102 &&
stream.height === 102 &&
Number(stream.nb_read_frames) === 3
console.log(`codec=${stream.codec_name} dimensions=${stream.width}x${stream.height} frames=${stream.nb_read_frames}`)
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+58
View File
@@ -0,0 +1,58 @@
// Regression for capture resolution normalization (#10). On scale-factor-1
// displays this is a no-op, while HiDPI displays catch capturePage inflation.
const { app } = require('electron')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const scene = {
canvas: { width: 320, height: 240, background: '#ffffff' },
duration: 0.25,
assets: [{ id: 'rect', kind: 'rect', width: 80, height: 60, fill: '#2563eb' }],
layers: [
{
id: 'layer-rect',
assetId: 'rect',
visible: true,
base: { x: 160, y: 120, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [{ time: 0, props: {} }],
},
],
}
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-resolution.mp4')
fs.rmSync(outPath, { force: true })
await renderMp4Job({
jobId: 'verify-resolution',
scene,
options: { fps: 2, duration: 0.25, totalFrames: 1, crf: '28', preset: 'ultrafast' },
outputPath: outPath,
signal: new AbortController().signal,
onProgress: () => {},
})
const probe = JSON.parse(execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'json',
outPath,
]).toString())
const stream = probe.streams[0]
const pass = stream.width === scene.canvas.width && stream.height === scene.canvas.height
console.log(`ffprobe dimensions: ${stream.width}x${stream.height}`)
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+88
View File
@@ -0,0 +1,88 @@
// Phase B verification of the inactivity watchdog (finding #3): point the
// worker at a render page whose __animatrSeek never resolves, with a short
// RUWIO_RENDER_TIMEOUT_MS, and confirm the job aborts with "render timed out"
// and leaves no partial file.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-timeout.cjs
// Must be set BEFORE requiring the worker — the timeout is read at module load.
process.env.RUWIO_RENDER_TIMEOUT_MS = '2000'
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
// Build a temp project dir with a server/render.html that hangs on seek, then
// chdir into it so the worker's loadRenderPage (process.cwd()/server/render.html)
// picks it up.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruwio-timeout-'))
fs.mkdirSync(path.join(tmpDir, 'server'))
fs.writeFileSync(
path.join(tmpDir, 'server', 'render.html'),
`<!doctype html><html><body><script>
window.__animatrInit = function(){ return Promise.resolve(true) }
window.__animatrSeek = function(){ return new Promise(function(){}) } // never resolves
</script></body></html>`,
)
process.chdir(tmpDir)
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 12
const duration = 2
const scene = {
canvas: { width: 160, height: 120, background: '#202030' },
duration,
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff4040' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 20, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [{ time: 0, props: { x: 20 } }, { time: 2, props: { x: 140 } }],
},
],
}
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
app.on('window-all-closed', () => {})
app.whenReady().then(async () => {
const outPath = path.join(tmpDir, 'out.mp4')
const controller = new AbortController()
const startedAt = process.hrtime.bigint()
let rejected = false
let message = ''
try {
await renderMp4Job({
jobId: 'timeout',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: () => {},
})
} catch (err) {
rejected = true
message = err.message
}
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6
await new Promise((r) => setTimeout(r, 200))
const notes = []
let pass = true
if (!rejected) { pass = false; notes.push('job did not reject') }
if (!/timed out/i.test(message)) { pass = false; notes.push(`message was "${message}", expected "render timed out"`) }
if (elapsedMs < 1500 || elapsedMs > 8000) { pass = false; notes.push(`elapsed ${elapsedMs.toFixed(0)}ms outside [1500,8000] (timeout=2000)`) }
if (fs.existsSync(outPath)) { pass = false; notes.push('partial output left behind') }
console.log(`rejected=${rejected} message="${message}" elapsed=${elapsedMs.toFixed(0)}ms outExists=${fs.existsSync(outPath)}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
fs.rmSync(tmpDir, { recursive: true, force: true })
app.exit(pass ? 0 : 1)
})
+7 -2
View File
@@ -10,11 +10,16 @@ RUN apt-get update && apt-get install -y \
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PORT=6000
ENV HOST=127.0.0.1
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
RUN groupadd -r ruwio && useradd -r -g ruwio -d /app -s /usr/sbin/nologin ruwio \
&& chown -R ruwio:ruwio /app
USER ruwio
EXPOSE 6000
CMD ["node", "server.js"]
+2007
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -6,10 +6,11 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
"dev": "node server.js",
"test:smoke": "node --test server.smoke.test.cjs",
"test:e2e": "node --test render.e2e.test.cjs"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2",
"puppeteer-core": "^23.11.1"
}
+310
View File
@@ -0,0 +1,310 @@
const assert = require('node:assert/strict')
const { spawn } = require('node:child_process')
const { mkdtemp, readFile, rm } = require('node:fs/promises')
const net = require('node:net')
const os = require('node:os')
const path = require('node:path')
const { test } = require('node:test')
const HOST = '127.0.0.1'
const TOKEN = 'server-e2e-token'
const CHROME = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
test('render server produces a playable MP4 for a tiny scene', async (t) => {
const ffmpeg = await commandPath('ffmpeg')
const ffprobe = await commandPath('ffprobe')
if (!ffmpeg || !ffprobe || !(await canAccess(CHROME))) {
t.skip('requires ffmpeg, ffprobe, and Chromium')
return
}
const port = await freePort()
const tmp = await mkdtemp(path.join(os.tmpdir(), 'ruwio-render-e2e-'))
const server = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: {
...process.env,
HOST,
PORT: String(port),
RENDER_TOKEN: TOKEN,
RENDER_JOB_TTL_MS: '1000',
RENDER_JOB_TIMEOUT_MS: '30000',
PUPPETEER_EXECUTABLE_PATH: CHROME,
CHROME_NO_SANDBOX: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
server.stdout.on('data', (chunk) => {
output += chunk.toString()
})
server.stderr.on('data', (chunk) => {
output += chunk.toString()
})
t.after(async () => {
if (server.exitCode === null) {
server.kill('SIGTERM')
await waitForExit(server)
}
await rm(tmp, { recursive: true, force: true })
})
await waitForServer(server, port, () => output)
const base = `http://${HOST}:${port}`
const start = await fetch(`${base}/render`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ scene: tinyScene(), options: { fps: 2, crf: 28, preset: 'ultrafast' } }),
})
const startText = await start.text()
assert.equal(start.status, 200, startText)
const { jobId, accessToken } = JSON.parse(startText)
assert.equal(typeof jobId, 'string')
assert.equal(typeof accessToken, 'string')
const done = await waitForDone(`${base}/render/${jobId}/events?token=${encodeURIComponent(accessToken)}`)
assert.equal(done.done, true)
assert.equal(done.error, null)
assert.equal(done.progress, 1)
assert.equal(done.url, `/render/${jobId}/file?token=${encodeURIComponent(accessToken)}`)
const file = await fetch(`${base}${done.url}`)
assert.equal(file.status, 200)
const outPath = path.join(tmp, 'tiny.mp4')
await require('node:fs/promises').writeFile(outPath, Buffer.from(await file.arrayBuffer()))
const metadata = await ffprobeJson(outPath)
const video = metadata.streams.find((stream) => stream.codec_type === 'video')
assert.ok(video, 'expected a video stream')
assert.equal(video.codec_name, 'h264')
assert.equal(video.width, 64)
assert.equal(video.height, 64)
})
test('render server pads odd canvas dimensions to an even MP4', async (t) => {
const ffmpeg = await commandPath('ffmpeg')
const ffprobe = await commandPath('ffprobe')
if (!ffmpeg || !ffprobe || !(await canAccess(CHROME))) {
t.skip('requires ffmpeg, ffprobe, and Chromium')
return
}
const port = await freePort()
const tmp = await mkdtemp(path.join(os.tmpdir(), 'ruwio-render-e2e-'))
const server = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: {
...process.env,
HOST,
PORT: String(port),
RENDER_TOKEN: TOKEN,
RENDER_JOB_TTL_MS: '1000',
RENDER_JOB_TIMEOUT_MS: '30000',
PUPPETEER_EXECUTABLE_PATH: CHROME,
CHROME_NO_SANDBOX: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
server.stdout.on('data', (chunk) => {
output += chunk.toString()
})
server.stderr.on('data', (chunk) => {
output += chunk.toString()
})
t.after(async () => {
if (server.exitCode === null) {
server.kill('SIGTERM')
await waitForExit(server)
}
await rm(tmp, { recursive: true, force: true })
})
await waitForServer(server, port, () => output)
const base = `http://${HOST}:${port}`
const start = await fetch(`${base}/render`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ scene: oddScene(), options: { fps: 2, crf: 28, preset: 'ultrafast' } }),
})
const startText = await start.text()
assert.equal(start.status, 200, startText)
const { jobId, accessToken } = JSON.parse(startText)
const done = await waitForDone(`${base}/render/${jobId}/events?token=${encodeURIComponent(accessToken)}`)
assert.equal(done.done, true)
assert.equal(done.error, null)
assert.equal(done.progress, 1)
const file = await fetch(`${base}${done.url}`)
assert.equal(file.status, 200)
const outPath = path.join(tmp, 'odd.mp4')
await require('node:fs/promises').writeFile(outPath, Buffer.from(await file.arrayBuffer()))
const metadata = await ffprobeJson(outPath)
const video = metadata.streams.find((stream) => stream.codec_type === 'video')
assert.ok(video, 'expected a video stream')
assert.equal(video.codec_name, 'h264')
assert.equal(video.width, 102)
assert.equal(video.height, 102)
})
function tinyScene() {
return {
canvas: { width: 64, height: 64, background: '#ffffff' },
duration: 1,
fps: 2,
assets: [
{ id: 'asset-rect', kind: 'rect', name: 'Box', width: 24, height: 24, fill: '#22cc88' },
],
layers: [
{
id: 'layer-rect',
name: 'Box',
assetId: 'asset-rect',
visible: true,
base: { x: 16, y: 32, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ id: 'kf-0', time: 0, easing: 'linear', props: { x: 16 } },
{ id: 'kf-1', time: 0.5, easing: 'linear', props: { x: 48 } },
],
},
],
}
}
function oddScene() {
const s = tinyScene()
s.canvas.width = 101
s.canvas.height = 101
s.layers[0].base.x = 50
s.layers[0].base.y = 50
s.layers[0].keyframes = [
{ id: 'kf-0', time: 0, easing: 'linear', props: { x: 42 } },
{ id: 'kf-1', time: 0.5, easing: 'linear', props: { x: 58 } },
]
return s
}
async function waitForDone(url) {
const res = await fetch(url)
assert.equal(res.status, 200)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const deadline = Date.now() + 30000
while (Date.now() < deadline) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const messages = buffer.split('\n\n')
buffer = messages.pop() || ''
for (const message of messages) {
const line = message.split('\n').find((entry) => entry.startsWith('data: '))
if (!line) continue
const payload = JSON.parse(line.slice(6))
if (payload.done) return payload
}
}
throw new Error('render did not finish before timeout')
}
function ffprobeJson(file) {
return new Promise((resolve, reject) => {
const child = spawn('ffprobe', [
'-v', 'error',
'-show_streams',
'-of', 'json',
file,
])
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => {
stdout += chunk.toString()
})
child.stderr.on('data', (chunk) => {
stderr += chunk.toString()
})
child.on('error', reject)
child.on('exit', (code) => {
if (code === 0) resolve(JSON.parse(stdout))
else reject(new Error(`ffprobe exited with code ${code}: ${stderr}`))
})
})
}
function commandPath(command) {
return new Promise((resolve) => {
const probe = spawn(process.platform === 'win32' ? 'where' : 'which', [command])
probe.on('error', () => resolve(''))
probe.on('exit', (code) => resolve(code === 0 ? command : ''))
})
}
async function canAccess(file) {
try {
await readFile(file)
return true
} catch {
return false
}
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', reject)
server.listen(0, HOST, () => {
const address = server.address()
server.close(() => {
if (!address || typeof address === 'string') reject(new Error('could not allocate test port'))
else resolve(address.port)
})
})
})
}
async function waitForServer(server, port, output) {
const deadline = Date.now() + 5000
while (Date.now() < deadline) {
if (server.exitCode !== null) {
throw new Error(`server exited early with code ${server.exitCode}\n${output()}`)
}
try {
const res = await fetch(`http://${HOST}:${port}/health`)
if (res.ok) return
} catch {
/* retry */
}
await delay(50)
}
throw new Error(`server did not start\n${output()}`)
}
function waitForExit(child) {
return new Promise((resolve) => {
child.once('exit', () => resolve())
setTimeout(() => {
if (child.exitCode === null) child.kill('SIGKILL')
resolve()
}, 1000).unref()
})
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+73 -1
View File
@@ -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<kfs.length-1;i++){
var a=kfs[i], b=kfs[i+1];
if(time>=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);
@@ -386,7 +445,20 @@
return true;
});
};
window.__animatrSeek=function(time){ drawFrame(time); };
window.__animatrSeek=function(time){
drawFrame(time);
// Resolve only after the compositor has had a chance to paint, so a
// subsequent capturePage()/screenshot() sees THIS frame and not the
// previous one. Without this wait, Electron's capturePage on a hidden
// window intermittently returns the prior frame (stale/off-by-one).
// setTimeout is an anti-hang fallback in case rAF is throttled while hidden.
return new Promise(function(res){
var done=false;
function finish(){ if(!done){ done=true; res(true); } }
requestAnimationFrame(function(){ requestAnimationFrame(finish); });
setTimeout(finish, 1000);
});
};
})();
</script>
</body>
+208 -52
View File
@@ -1,53 +1,98 @@
import express from 'express'
import cors from 'cors'
import path from 'node:path'
import fs from 'node:fs/promises'
import os from 'node:os'
import { randomUUID } from 'node:crypto'
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import puppeteer from 'puppeteer-core'
import renderValidation from '../shared/renderValidation.cjs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const PORT = Number(process.env.PORT || 6000)
const HOST = process.env.HOST || '127.0.0.1'
const PORT = readIntEnv('PORT', 6000, 1, 65535)
const CHROME_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
const RENDER_HTML = path.join(__dirname, 'render.html')
const RENDER_TOKEN = process.env.RENDER_TOKEN || ''
const ALLOWED_ORIGINS = new Set(
(process.env.RENDER_ALLOWED_ORIGINS ||
'http://localhost:5200,http://127.0.0.1:5200,http://localhost:5173,http://127.0.0.1:5173,http://localhost:6000,http://127.0.0.1:6000')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean),
)
const BODY_LIMIT_BYTES = readSizeEnv('RENDER_MAX_BODY', 15 * 1024 * 1024)
const MAX_ACTIVE_JOBS = readIntEnv('MAX_ACTIVE_JOBS', 1, 1, 16)
const JOB_TIMEOUT_MS = readIntEnv('RENDER_JOB_TIMEOUT_MS', 2 * 60 * 1000, 1000, 60 * 60 * 1000)
const JOB_TTL_MS = readIntEnv('RENDER_JOB_TTL_MS', 10 * 60 * 1000, 1000, 24 * 60 * 60 * 1000)
const RATE_LIMIT_WINDOW_MS = readIntEnv('RENDER_RATE_WINDOW_MS', 60 * 1000, 1000, 60 * 60 * 1000)
const RATE_LIMIT_MAX = readIntEnv('RENDER_RATE_MAX', 8, 1, 1000)
const MAX_CANVAS_WIDTH = readIntEnv('RENDER_MAX_CANVAS_WIDTH', 4096, 1, 16384)
const MAX_CANVAS_HEIGHT = readIntEnv('RENDER_MAX_CANVAS_HEIGHT', 4096, 1, 16384)
const MAX_FPS = readIntEnv('RENDER_MAX_FPS', 60, 1, 240)
const MAX_DURATION = readIntEnv('RENDER_MAX_DURATION', 60, 1, 60 * 60)
const MAX_FRAMES = readIntEnv('RENDER_MAX_FRAMES', 3600, 1, 250000)
const MAX_LAYERS = readIntEnv('RENDER_MAX_LAYERS', 500, 1, 100000)
const MAX_KEYFRAMES = readIntEnv('RENDER_MAX_KEYFRAMES', 5000, 1, 1000000)
const MAX_ASSETS = readIntEnv('RENDER_MAX_ASSETS', 500, 1, 100000)
const RENDER_LIMITS = {
maxCanvasWidth: MAX_CANVAS_WIDTH,
maxCanvasHeight: MAX_CANVAS_HEIGHT,
maxFps: MAX_FPS,
maxDuration: MAX_DURATION,
maxFrames: MAX_FRAMES,
maxLayers: MAX_LAYERS,
maxKeyframes: MAX_KEYFRAMES,
maxAssets: MAX_ASSETS,
}
const { playbackRange, validateRenderRequest: validateSharedRenderRequest } = renderValidation
const app = express()
app.use(cors())
app.use(express.json({ limit: '200mb' }))
app.disable('x-powered-by')
app.use(originGuard)
app.use(rejectLargeBodies)
app.use(express.json({ limit: BODY_LIMIT_BYTES }))
/** @type {Map<string, Job>} */
const jobs = new Map()
const rateBuckets = new Map()
app.get('/health', (_req, res) => res.json({ ok: true }))
app.post('/render', (req, res) => {
app.post('/render', requireRenderToken, rateLimitRender, (req, res) => {
if (activeJobCount() >= MAX_ACTIVE_JOBS) {
return res.status(503).json({ error: 'render server is busy' })
}
const { scene, options } = req.body || {}
if (!scene || !scene.canvas || !Array.isArray(scene.layers)) {
return res.status(400).json({ error: 'invalid scene' })
const validation = validateRenderRequest(scene, options || {})
if (!validation.ok) {
return res.status(400).json({ error: validation.error })
}
const job = {
id: randomUUID(),
accessToken: randomToken(),
progress: 0,
done: false,
error: null,
filePath: null,
workDir: null,
subscribers: new Set(),
abort: new AbortController(),
cleanupTimer: null,
}
jobs.set(job.id, job)
res.json({ jobId: job.id })
runJob(job, scene, options || {}).catch((err) => {
res.json({ jobId: job.id, accessToken: job.accessToken })
runJob(job, validation.scene, validation.options).catch((err) => {
job.error = err?.message || String(err)
job.done = true
notify(job)
scheduleCleanup(job, 0)
})
})
app.get('/render/:id/events', (req, res) => {
app.get('/render/:id/events', requireJobToken, (req, res) => {
const job = jobs.get(req.params.id)
if (!job) return res.status(404).end()
res.writeHead(200, {
@@ -60,7 +105,7 @@ app.get('/render/:id/events', (req, res) => {
progress: job.progress,
done: job.done,
error: job.error,
url: job.done && !job.error ? `/render/${job.id}/file` : null,
url: job.done && !job.error ? `/render/${job.id}/file?token=${encodeURIComponent(job.accessToken)}` : null,
}
res.write(`data: ${JSON.stringify(payload)}\n\n`)
}
@@ -73,65 +118,127 @@ app.get('/render/:id/events', (req, res) => {
})
})
app.get('/render/:id/file', (req, res) => {
app.get('/render/:id/file', requireJobToken, (req, res) => {
const job = jobs.get(req.params.id)
if (!job || !job.filePath || job.error) return res.status(404).end()
res.download(job.filePath, 'animation.mp4')
})
app.delete('/render/:id', (req, res) => {
app.delete('/render/:id', requireJobToken, (req, res) => {
const job = jobs.get(req.params.id)
if (!job) return res.status(404).end()
job.abort.abort()
if (job.done) scheduleCleanup(job, 0)
res.json({ ok: true })
})
app.use((err, _req, res, _next) => {
if (err?.type === 'entity.too.large') {
return res.status(413).json({ error: `request body exceeds ${BODY_LIMIT_BYTES} bytes` })
}
if (err instanceof SyntaxError && 'body' in err) {
return res.status(400).json({ error: 'invalid JSON body' })
}
console.error(err)
return res.status(500).json({ error: 'internal server error' })
})
function originGuard(req, res, next) {
const origin = req.get('Origin')
if (origin) {
if (!ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({ error: 'origin is not allowed' })
}
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-RUWIO-Render-Token')
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS')
}
if (req.method === 'OPTIONS') return res.status(204).end()
next()
}
function rejectLargeBodies(req, res, next) {
const len = Number(req.get('Content-Length') || 0)
if (Number.isFinite(len) && len > BODY_LIMIT_BYTES) {
return res.status(413).json({ error: `request body exceeds ${BODY_LIMIT_BYTES} bytes` })
}
next()
}
function requireRenderToken(req, res, next) {
if (!RENDER_TOKEN) {
return res.status(503).json({ error: 'render server requires RENDER_TOKEN to be configured' })
}
const token = bearerToken(req) || req.get('X-RUWIO-Render-Token') || ''
if (!secureEqual(token, RENDER_TOKEN)) return res.status(401).json({ error: 'invalid render token' })
next()
}
function requireJobToken(req, res, next) {
const job = jobs.get(req.params.id)
if (!job) return res.status(404).end()
const token = bearerToken(req) || req.get('X-RUWIO-Render-Token') || String(req.query.token || '')
if (!secureEqual(token, job.accessToken)) return res.status(401).json({ error: 'invalid job token' })
next()
}
function rateLimitRender(req, res, next) {
const ip = req.ip || req.socket.remoteAddress || 'unknown'
const now = Date.now()
const bucket = rateBuckets.get(ip)
if (!bucket || now - bucket.startedAt > RATE_LIMIT_WINDOW_MS) {
rateBuckets.set(ip, { startedAt: now, count: 1 })
return next()
}
bucket.count += 1
if (bucket.count > RATE_LIMIT_MAX) return res.status(429).json({ error: 'too many render requests' })
next()
}
function bearerToken(req) {
const auth = req.get('Authorization') || ''
return auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : ''
}
function secureEqual(a, b) {
if (!a || !b) return false
const left = Buffer.from(String(a))
const right = Buffer.from(String(b))
return left.length === right.length && timingSafeEqual(left, right)
}
function randomToken() {
return randomBytes(32).toString('base64url')
}
function activeJobCount() {
let count = 0
for (const job of jobs.values()) {
if (!job.done) count += 1
}
return count
}
function notify(job) {
for (const sub of Array.from(job.subscribers)) {
try { sub() } catch { /* noop */ }
}
}
function effectiveEnd(scene) {
let max = 0
for (const layer of scene.layers) {
for (const kf of layer.keyframes) {
if (kf.time > max) max = kf.time
}
}
if (max <= 0) return scene.duration
return Math.min(max, scene.duration)
}
function playbackRange(scene) {
const autoEnd = effectiveEnd(scene)
const start = Math.max(0, Math.min(scene.duration, scene.inTime == null ? 0 : scene.inTime))
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
const end = Math.max(start, Math.min(scene.duration, rawEnd))
return { start, end }
}
async function runJob(job, scene, options) {
const fps = Number(options.fps || scene.fps || 60)
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn)
const duration = Number(options.duration || rangeLen)
const totalFrames = Math.max(1, Math.round(duration * fps))
const crf = String(options.crf ?? 18)
const preset = String(options.preset ?? 'fast')
const { start: rangeIn } = playbackRange(scene)
const { fps, duration, totalFrames, crf, preset } = options
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'animatr-'))
job.workDir = workDir
const outPath = path.join(workDir, 'out.mp4')
const timeout = setTimeout(() => job.abort.abort(), JOB_TIMEOUT_MS)
const browser = await puppeteer.launch({
executablePath: CHROME_PATH,
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--hide-scrollbars',
],
args: chromeArgs(),
defaultViewport: {
width: scene.canvas.width,
height: scene.canvas.height,
@@ -146,6 +253,9 @@ async function runJob(job, scene, options) {
'-i', '-',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
// libx264 + yuv420p require even dimensions; pad an odd canvas up to the
// next even size so it encodes instead of crashing ffmpeg (finding #13).
'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
'-preset', preset,
'-crf', crf,
'-movflags', '+faststart',
@@ -190,18 +300,64 @@ async function runJob(job, scene, options) {
job.done = true
notify(job)
setTimeout(() => cleanupJob(job.id, workDir).catch(() => {}), 10 * 60 * 1000)
scheduleCleanup(job, JOB_TTL_MS)
} finally {
clearTimeout(timeout)
if (!job.done) {
try { ffmpeg.kill('SIGKILL') } catch { /* noop */ }
}
await browser.close().catch(() => {})
}
}
async function cleanupJob(jobId, workDir) {
jobs.delete(jobId)
await fs.rm(workDir, { recursive: true, force: true })
function scheduleCleanup(job, delayMs) {
if (job.cleanupTimer) clearTimeout(job.cleanupTimer)
job.cleanupTimer = setTimeout(() => cleanupJob(job.id).catch(() => {}), delayMs)
}
app.listen(PORT, () => {
console.log(`animatr render server listening on :${PORT}`)
async function cleanupJob(jobId) {
const job = jobs.get(jobId)
if (!job) return
jobs.delete(jobId)
if (job.workDir) await fs.rm(job.workDir, { recursive: true, force: true })
}
function validateRenderRequest(scene, options) {
return validateSharedRenderRequest(scene, options, RENDER_LIMITS)
}
function chromeArgs() {
const args = ['--disable-dev-shm-usage', '--hide-scrollbars']
if (process.env.CHROME_NO_SANDBOX === '1') {
console.warn('WARNING: CHROME_NO_SANDBOX=1 disables Chromium sandboxing for render jobs.')
args.push('--no-sandbox', '--disable-setuid-sandbox')
}
return args
}
function readIntEnv(name, fallback, min, max) {
const raw = process.env[name]
if (!raw) return fallback
const n = Number.parseInt(raw, 10)
if (!Number.isFinite(n) || n < min || n > max) {
throw new Error(`${name} must be an integer between ${min} and ${max}`)
}
return n
}
function readSizeEnv(name, fallback) {
const raw = process.env[name]
if (!raw) return fallback
const match = raw.trim().match(/^(\d+)(b|kb|mb)?$/i)
if (!match) throw new Error(`${name} must be a byte size like 1048576 or 15mb`)
const amount = Number.parseInt(match[1], 10)
const unit = (match[2] || 'b').toLowerCase()
const mult = unit === 'mb' ? 1024 * 1024 : unit === 'kb' ? 1024 : 1
return amount * mult
}
app.listen(PORT, HOST, () => {
console.log(`animatr render server listening on ${HOST}:${PORT}`)
console.log(`chromium: ${CHROME_PATH}`)
if (!RENDER_TOKEN) console.warn('WARNING: RENDER_TOKEN is not set; POST /render is disabled.')
})
+139
View File
@@ -0,0 +1,139 @@
const assert = require('node:assert/strict')
const { spawn } = require('node:child_process')
const net = require('node:net')
const { test } = require('node:test')
const HOST = '127.0.0.1'
const TOKEN = 'server-smoke-token'
test('render server health, auth, and validation routes work', async (t) => {
const port = await freePort()
const server = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: {
...process.env,
HOST,
PORT: String(port),
RENDER_TOKEN: TOKEN,
RENDER_MAX_BODY: '1mb',
RENDER_RATE_MAX: '100',
PUPPETEER_EXECUTABLE_PATH: '/not-used-by-smoke-test',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
server.stdout.on('data', (chunk) => {
output += chunk.toString()
})
server.stderr.on('data', (chunk) => {
output += chunk.toString()
})
t.after(async () => {
if (server.exitCode === null) {
server.kill('SIGTERM')
await waitForExit(server)
}
})
await waitForServer(server, port, () => output)
const base = `http://${HOST}:${port}`
const health = await fetch(`${base}/health`)
assert.equal(health.status, 200)
assert.deepEqual(await health.json(), { ok: true })
const missingToken = await postRender(base, null, invalidRenderBody())
assert.equal(missingToken.status, 401)
assert.equal((await missingToken.json()).error, 'invalid render token')
const badToken = await postRender(base, 'wrong-token', invalidRenderBody())
assert.equal(badToken.status, 401)
assert.equal((await badToken.json()).error, 'invalid render token')
const invalidRender = await postRender(base, TOKEN, invalidRenderBody())
assert.equal(invalidRender.status, 400)
assert.match((await invalidRender.json()).error, /canvas must be/)
const missingJobEvents = await fetch(`${base}/render/does-not-exist/events?token=bad`)
assert.equal(missingJobEvents.status, 404)
const missingJobFile = await fetch(`${base}/render/does-not-exist/file?token=bad`)
assert.equal(missingJobFile.status, 404)
const missingJobDelete = await fetch(`${base}/render/does-not-exist?token=bad`, { method: 'DELETE' })
assert.equal(missingJobDelete.status, 404)
})
function invalidRenderBody() {
return {
scene: {
canvas: { width: 0, height: 100 },
duration: 1,
fps: 30,
layers: [],
assets: [],
},
options: { fps: 30 },
}
}
function postRender(base, token, body) {
const headers = { 'Content-Type': 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
return fetch(`${base}/render`, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', reject)
server.listen(0, HOST, () => {
const address = server.address()
server.close(() => {
if (!address || typeof address === 'string') reject(new Error('could not allocate test port'))
else resolve(address.port)
})
})
})
}
async function waitForServer(server, port, output) {
const deadline = Date.now() + 5000
let lastError = null
while (Date.now() < deadline) {
if (server.exitCode !== null) {
throw new Error(`server exited early with code ${server.exitCode}\n${output()}`)
}
try {
const res = await fetch(`http://${HOST}:${port}/health`)
if (res.ok) return
} catch (err) {
lastError = err
}
await delay(50)
}
throw new Error(`server did not start: ${lastError?.message || 'timeout'}\n${output()}`)
}
function waitForExit(child) {
return new Promise((resolve) => {
child.once('exit', () => resolve())
setTimeout(() => {
if (child.exitCode === null) child.kill('SIGKILL')
resolve()
}, 1000).unref()
})
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+153
View File
@@ -0,0 +1,153 @@
const DEFAULT_LIMITS = Object.freeze({
maxCanvasWidth: 4096,
maxCanvasHeight: 4096,
maxFps: 60,
maxDuration: 60,
maxFrames: 3600,
maxLayers: 500,
maxKeyframes: 5000,
maxAssets: 500,
})
const ALLOWED_FFMPEG_PRESETS = Object.freeze([
'ultrafast',
'superfast',
'veryfast',
'faster',
'fast',
'medium',
'slow',
'slower',
'veryslow',
])
function effectiveEnd(scene) {
let max = 0
for (const layer of scene.layers) {
for (const kf of layer.keyframes) {
if (kf.time > max) max = kf.time
}
}
if (max <= 0) return scene.duration
return Math.min(max, scene.duration)
}
function playbackRange(scene) {
const autoEnd = effectiveEnd(scene)
const start = Math.max(0, Math.min(scene.duration, scene.inTime == null ? 0 : scene.inTime))
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
const end = Math.max(start, Math.min(scene.duration, rawEnd))
return { start, end }
}
function validateRenderRequest(scene, options = {}, limits = DEFAULT_LIMITS) {
if (!scene || typeof scene !== 'object') return { ok: false, error: 'invalid scene' }
if (!scene.canvas || typeof scene.canvas !== 'object') return { ok: false, error: 'invalid canvas' }
const width = toBoundedInt(scene.canvas.width, 1, limits.maxCanvasWidth)
const height = toBoundedInt(scene.canvas.height, 1, limits.maxCanvasHeight)
if (!width || !height) {
return {
ok: false,
error: `canvas must be between 1 and ${limits.maxCanvasWidth}x${limits.maxCanvasHeight}`,
}
}
if (!Array.isArray(scene.layers)) return { ok: false, error: 'scene layers must be an array' }
if (scene.layers.length > limits.maxLayers) {
return { ok: false, error: `scene has more than ${limits.maxLayers} layers` }
}
if (Array.isArray(scene.assets) && scene.assets.length > limits.maxAssets) {
return { ok: false, error: `scene has more than ${limits.maxAssets} assets` }
}
let keyframes = 0
for (const layer of scene.layers) {
if (!Array.isArray(layer?.keyframes)) return { ok: false, error: 'every layer must have keyframes' }
keyframes += layer.keyframes.length
if (keyframes > limits.maxKeyframes) {
return { ok: false, error: `scene has more than ${limits.maxKeyframes} keyframes` }
}
for (const kf of layer.keyframes) {
if (!Number.isFinite(Number(kf?.time))) {
return { ok: false, error: 'keyframe times must be finite numbers' }
}
}
}
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` }
}
const fps = toBoundedInt(options.fps ?? scene.fps ?? 60, 1, limits.maxFps)
if (!fps) return { ok: false, error: `fps must be between 1 and ${limits.maxFps}` }
const normalizedScene = { ...scene, canvas: { ...scene.canvas, width, height }, duration }
const range = playbackRange(normalizedScene)
const requestedDuration = Number(options.duration ?? Math.max(0, range.end - range.start))
if (
!Number.isFinite(requestedDuration) ||
requestedDuration <= 0 ||
requestedDuration > limits.maxDuration
) {
return { ok: false, error: `render duration must be between 0 and ${limits.maxDuration} seconds` }
}
const totalFrames = Math.max(1, Math.round(requestedDuration * fps))
if (totalFrames > limits.maxFrames) {
return { ok: false, error: `render has more than ${limits.maxFrames} frames` }
}
const crf = toBoundedInt(options.crf ?? 18, 0, 51)
if (crf == null) return { ok: false, error: 'crf must be between 0 and 51' }
const preset = String(options.preset ?? 'fast')
if (!ALLOWED_FFMPEG_PRESETS.includes(preset)) {
return { ok: false, error: 'invalid ffmpeg preset' }
}
return {
ok: true,
scene: normalizedScene,
options: { fps, duration: requestedDuration, totalFrames, crf: String(crf), preset },
}
}
function toBoundedInt(value, min, max) {
const n = Number(value)
if (!Number.isFinite(n)) return null
const i = Math.round(n)
if (i < min || i > max) return null
return i
}
module.exports = {
ALLOWED_FFMPEG_PRESETS,
DEFAULT_LIMITS,
playbackRange,
validateRenderRequest,
}
+93
View File
@@ -0,0 +1,93 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { validateRenderRequest, playbackRange } = require('./renderValidation.cjs')
function scene(overrides = {}) {
return {
canvas: { width: 640, height: 360 },
duration: 2,
fps: 30,
layers: [
{
keyframes: [
{ time: 0, props: {} },
{ time: 1, props: {} },
],
},
],
assets: [],
...overrides,
}
}
test('accepts a normal render request', () => {
const result = validateRenderRequest(scene(), { fps: 30, crf: 18, preset: 'fast' })
assert.equal(result.ok, true)
assert.equal(result.options.totalFrames, 30)
})
test('rejects invalid dimensions', () => {
const result = validateRenderRequest(scene({ canvas: { width: 0, height: 360 } }))
assert.equal(result.ok, false)
assert.match(result.error, /canvas/)
})
test('rejects invalid fps and frame count', () => {
assert.equal(validateRenderRequest(scene(), { fps: 0 }).ok, false)
assert.equal(validateRenderRequest(scene({ duration: 61 }), { fps: 60 }).ok, false)
})
test('rejects invalid duration', () => {
const result = validateRenderRequest(scene({ duration: 0 }))
assert.equal(result.ok, false)
assert.match(result.error, /duration/)
})
test('rejects too many layers and keyframes', () => {
const layers = Array.from({ length: 501 }, () => ({ keyframes: [] }))
assert.equal(validateRenderRequest(scene({ layers })).ok, false)
const keyframes = Array.from({ length: 5001 }, (_, i) => ({ time: i / 60, props: {} }))
assert.equal(validateRenderRequest(scene({ layers: [{ keyframes }] })).ok, false)
})
test('rejects invalid crf and preset', () => {
assert.equal(validateRenderRequest(scene(), { crf: 52 }).ok, false)
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 })
})
+112 -10
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
PhArrowClockwise,
PhArrowCounterClockwise,
@@ -12,6 +12,7 @@ import {
PhFloppyDisk,
PhGearSix,
PhImageSquare,
PhMonitorPlay,
PhPause,
PhPencilSimpleLine,
PhPlay,
@@ -28,14 +29,12 @@ import ClipartDrawer from './components/ClipartDrawer.vue'
import SceneDrawer from './components/SceneDrawer.vue'
import DocumentDrawer from './components/DocumentDrawer.vue'
import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.vue'
import PropertiesPanel from './components/PropertiesPanel.vue'
import SvgLayerPanel from './components/SvgLayerPanel.vue'
import Timeline from './components/Timeline.vue'
import TooltipHost from './components/TooltipHost.vue'
import WelcomeDialog from './components/WelcomeDialog.vue'
import TestOverlay from './devtools/TestOverlay.vue'
import ruwioLogo from './assets/logo.png'
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
import { t } from './i18n'
@@ -44,9 +43,9 @@ import { useHistory } from './composables/useHistory'
import { useAudioRuntime } from './composables/useAudioRuntime'
import { useDocumentDrawer } from './composables/useDocumentDrawer'
import { usePlaybackLoop } from './composables/usePlaybackLoop'
import { useTimeFormat } from './composables/useTimeFormat'
import { useProjectName } from './composables/useProjectName'
import { shouldChromeClickDeselect } from './lib/chromeClick'
import { renderSceneToStillImage } from './lib/stillImageExport'
import { ProjectFileError, pickProjectFile } from './lib/projectFile'
import { readAnyProjectFile } from './lib/ruwFile'
import {
@@ -70,6 +69,12 @@ const welcomeOpen = ref(false)
const fullscreenOpen = ref(false)
const aboutOpen = ref(false)
const helpOpen = ref(false)
const isDesktop = computed(() => !!window.ruwioDesktop)
const canUseTestRunner = import.meta.env.DEV || !!window.ruwioDesktop
const TestOverlay = canUseTestRunner
? defineAsyncComponent(() => import('./devtools/TestOverlay.vue'))
: null
const ExportDialog = defineAsyncComponent(() => import('./components/ExportDialog.vue'))
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
const DEFAULT_TIMELINE_HEIGHT = 220
const MIN_TIMELINE_HEIGHT = 120
@@ -165,6 +170,7 @@ const isDirty = ref(false)
usePlaybackLoop()
useAudioRuntime()
const store = useSceneStore()
const { formatTime } = useTimeFormat()
const clipart = useClipartStore()
const history = useHistory()
const deselectAll = useDeselectAll()
@@ -190,6 +196,11 @@ function stop() {
store.pause()
}
function openFullscreenPreview() {
store.pause()
fullscreenOpen.value = true
}
async function saveProject() {
// projectForSave stitches the current editor-session UI state
// (zoom, pan, overlay toggles, timeline display) onto the
@@ -218,6 +229,12 @@ async function saveProject() {
await markClean()
}
async function saveProjectAs() {
currentProjectId.value = null
currentProjectName.value = null
await saveProject()
}
function openProjectNameDrawer() {
// Clicking the project name in the topbar toggles the document
// drawer — edits the name inline alongside the rest of the document
@@ -303,6 +320,7 @@ async function copyStillToClipboard() {
return
}
try {
const { renderSceneToStillImage } = await import('./lib/stillImageExport')
const scene = JSON.parse(JSON.stringify(store.scene))
const blob = await renderSceneToStillImage(scene, store.playhead, 'png')
// ClipboardItem is widely available in modern Chromium / Firefox
@@ -414,7 +432,7 @@ function nudgeSelection(dx: number, dy: number): boolean {
function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
e.preventDefault()
testsOpen.value = true
if (canUseTestRunner) testsOpen.value = true
return
}
// TAB toggles the side panels (asset sidebar + properties aside)
@@ -496,8 +514,7 @@ function onKeyDown(e: KeyboardEvent) {
if (isEditable(e.target)) return
e.preventDefault()
if (e.shiftKey) {
store.pause()
fullscreenOpen.value = true
openFullscreenPreview()
return
}
store.togglePlay()
@@ -544,12 +561,79 @@ function onChromeClick(e: MouseEvent) {
deselectAll()
}
async function runDesktopMenuCommand(command: string) {
switch (command) {
case 'new-project':
await startNewProject()
break
case 'open-project':
await importFromFile()
break
case 'recent-projects':
welcomeOpen.value = true
break
case 'save-project':
await saveProject()
break
case 'save-project-as':
await saveProjectAs()
break
case 'export-project':
exportOpen.value = true
break
case 'undo':
history.undo()
break
case 'redo':
history.redo()
break
case 'copy-current-frame':
await copyStillToClipboard()
break
case 'toggle-playback':
store.togglePlay()
break
case 'stop-playback':
stop()
break
case 'rewind':
rewind()
break
case 'fullscreen-preview':
openFullscreenPreview()
break
case 'toggle-panels':
panelsHidden.value = !panelsHidden.value
break
case 'media-library':
clipart.toggle()
break
case 'document-settings':
settingsOpen.value = true
break
case 'help':
helpOpen.value = true
break
case 'about':
aboutOpen.value = true
break
case 'test-runner':
if (canUseTestRunner) testsOpen.value = true
break
}
}
let removeDesktopMenuHandler: (() => void) | null = null
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('contextmenu', onDocumentContextMenu)
document.addEventListener('paste', onPaste)
document.addEventListener('click', onChromeClick)
window.addEventListener('resize', onWindowResize)
removeDesktopMenuHandler = window.ruwioDesktop?.onMenuCommand?.((command) => {
void runDesktopMenuCommand(command)
}) ?? null
void clipart.initialize()
bootWelcome()
})
@@ -559,6 +643,8 @@ onBeforeUnmount(() => {
document.removeEventListener('paste', onPaste)
document.removeEventListener('click', onChromeClick)
window.removeEventListener('resize', onWindowResize)
removeDesktopMenuHandler?.()
removeDesktopMenuHandler = null
})
</script>
@@ -624,6 +710,13 @@ onBeforeUnmount(() => {
@click="store.togglePlay()"
/>
<div class="transport-side transport-right">
<IconButton
:icon="PhMonitorPlay"
icon-weight="regular"
size="md"
:title="t('app.fullscreenPlayback')"
@click="openFullscreenPreview"
/>
<IconButton
:icon="PhStop"
icon-weight="fill"
@@ -631,7 +724,7 @@ onBeforeUnmount(() => {
:title="t('app.stop')"
@click="stop"
/>
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration.toFixed(2) }}s</span>
<span class="time">{{ formatTime(store.playhead) }} / {{ formatTime(store.scene.duration) }}</span>
</div>
</div>
<div class="topbar-right">
@@ -665,7 +758,10 @@ onBeforeUnmount(() => {
</button>
<button
class="export-btn"
v-tooltip="{ label: t('app.export'), help: t('app.exportHelp') }"
v-tooltip="{
label: t('app.export'),
help: t(isDesktop ? 'app.exportHelpDesktop' : 'app.exportHelpServer'),
}"
@click="exportOpen = true"
>
<PhExport :size="14" weight="bold" />
@@ -725,7 +821,7 @@ onBeforeUnmount(() => {
</footer>
<DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" />
<component :is="TestOverlay" v-if="TestOverlay" v-model="testsOpen" />
<FullscreenPreview v-model="fullscreenOpen" />
<AboutDialog v-model="aboutOpen" />
<HelpDialog v-model="helpOpen" />
@@ -928,6 +1024,12 @@ onBeforeUnmount(() => {
color: var(--text);
}
.icon-btn.active {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-soft);
}
/* Unsaved-changes indicator on the save button. A small accent dot
pinned to the top-right of the icon, visible at a glance. `position:
relative` on the button lets the dot anchor inside it. */
Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

+12 -4
View File
@@ -9,6 +9,9 @@ import {
import { useSceneStore } from '@/stores/scene'
import { t } from '@/i18n'
import type { Action } from '@/types/scene'
import { useTimeFormat } from '@/composables/useTimeFormat'
const { timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
const props = defineProps<{ actions: Action[] }>()
const emit = defineEmits<{ 'update:actions': [value: Action[]] }>()
@@ -154,13 +157,13 @@ function onTypeChange(index: number, newType: Action['type']) {
</div>
<div v-if="a.type === 'jumpTo'" class="params">
<label>
{{ t('actions.param.time') }}
{{ t('actions.param.time') }} ({{ unitLabel }})
<input
type="number"
step="0.01"
:step="inputStep"
min="0"
:value="a.time"
@input="setAt(i, { type: 'jumpTo', time: Number(($event.target as HTMLInputElement).value) })"
:value="timeToInputValue(a.time)"
@input="setAt(i, { type: 'jumpTo', time: inputValueToSeconds(Number(($event.target as HTMLInputElement).value)) })"
/>
</label>
</div>
@@ -363,6 +366,11 @@ function onTypeChange(index: number, newType: Action['type']) {
font-family: inherit;
}
.params input[type='number'] {
width: 96px;
font-variant-numeric: tabular-nums;
}
.params textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
+10 -85
View File
@@ -21,6 +21,7 @@ import {
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { useTimeFormat } from '@/composables/useTimeFormat'
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
import {
clearAudioDragSource,
@@ -32,7 +33,7 @@ import {
setClipDragSource,
} from '@/composables/useClipDragSource'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { confirmDialog, promptDialog, ToggleSwitch } from '@/components/ui'
import { loadSceneImages, renderButtonThumbnail, renderSvgThumbnail } from '@/lib/render'
import { t } from '@/i18n'
@@ -42,6 +43,7 @@ const fileInput = ref<HTMLInputElement | null>(null)
const uploadError = ref<string | null>(null)
const { enabled: showThumbs } = useAssetThumbsPref()
const { formatTime } = useTimeFormat()
/** Per-section collapsed state, persisted so the user's layout
* survives reloads. Each key is false (expanded) by default —
@@ -140,7 +142,7 @@ async function addSampleText() {
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
text,
fontSize: 48,
fill: '#f5d76e',
fill: '#000000',
})
store.addLayer(asset.id)
}
@@ -622,21 +624,11 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
>
<div class="assets-header">
<h3>Assets</h3>
<label
class="thumbs-switch"
<ToggleSwitch
v-model="showThumbs"
:label="t('sidebar.thumbsToggle')"
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
>
<span class="thumbs-switch-label">{{ t('sidebar.thumbsToggle') }}</span>
<input
type="checkbox"
class="thumbs-switch-input"
:checked="showThumbs"
@change="showThumbs = ($event.target as HTMLInputElement).checked"
/>
<span class="thumbs-switch-track">
<span class="thumbs-switch-knob"></span>
</span>
</label>
/>
</div>
<button class="upload-btn" @click="triggerUpload">
@@ -782,7 +774,7 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
<div class="clip-meta">
<span class="clip-name" v-tooltip="clip.name">{{ clip.name }}</span>
<span class="clip-sub">
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ clip.duration.toFixed(1) }}s
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ formatTime(clip.duration, { short: true, decimals: 1 }) }}
</span>
</div>
<div class="clip-actions">
@@ -882,7 +874,7 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
</div>
<div class="clip-meta">
<span class="clip-name" v-tooltip="asset.name">{{ asset.name }}</span>
<span class="clip-sub">{{ asset.duration.toFixed(2) }}s</span>
<span class="clip-sub">{{ formatTime(asset.duration) }}</span>
</div>
<div class="clip-actions">
<button
@@ -986,73 +978,6 @@ h4 {
gap: 10px;
}
/* Compact slide switch labelled "Thumbs" — native checkbox stays hidden
* for a11y (keyboard + screen readers), the visible track + knob is
* driven purely by the `:checked` state. */
.thumbs-switch {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
font-size: 11px;
color: var(--text-dim);
}
.thumbs-switch-label {
font-weight: 500;
letter-spacing: 0.01em;
}
.thumbs-switch-input {
/* Keep focus behaviour via the native input; the visual sits next to it. */
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.thumbs-switch-track {
position: relative;
width: 26px;
height: 14px;
border-radius: 999px;
background: var(--border);
transition: background 140ms var(--ease);
flex-shrink: 0;
}
.thumbs-switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--panel);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
transition: left 140ms var(--ease), background 140ms var(--ease);
}
.thumbs-switch-input:checked + .thumbs-switch-track {
background: var(--accent);
}
.thumbs-switch-input:checked + .thumbs-switch-track .thumbs-switch-knob {
left: 14px;
background: #fff;
}
.thumbs-switch-input:focus-visible + .thumbs-switch-track {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.thumbs-switch:hover .thumbs-switch-track {
filter: brightness(1.08);
}
/* Section rows: unified layout — [caret + title][action?][count]. The
* count always sits at the far right so the right column is visually
* stable regardless of whether a row has a create-action (+) beside it. */
+623 -49
View File
@@ -9,6 +9,7 @@ import {
PhCursorClick,
PhFrameCorners,
PhMaskHappy,
PhPath,
PhPencilSimple,
PhPolygon,
PhSlidersHorizontal,
@@ -20,13 +21,23 @@ import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from '@/lib/inter
import { importImageFile, loadImage } from '@/lib/fileLoader'
import { useClipartStore } from '@/stores/clipart'
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
import { fontCssFamily, fontsReady } from '@/lib/fonts'
import {
clipArmLocalTime,
clipArmTranslate,
kaleidoAngleDegrees,
kaleidoCountFor,
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,
@@ -41,6 +52,7 @@ import { t } from '@/i18n'
import { useDeselectAll } from '@/composables/useDeselectAll'
import { useKfSelection } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
import { showMotionPaths } from '@/composables/useShowMotionPaths'
import { expandedTrackOrder } from '@/lib/sceneOrder'
import { CLIP_FALLBACK_BOUNDS, computeClipBounds, type Rect } from '@/lib/clipBounds'
@@ -540,6 +552,51 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false }
}
/** Returns [0..N-1] when kaleido is on, else [0]. The Konva templates
* iterate this and call `withK(cfg, ki, n)` to add the per-copy
* rotation. For ki>0 the copy is non-listening so only the source
* receives clicks/drag/transform events. */
function kaleidoIndicesForLayer(layer: Layer): number[] {
const n = kaleidoCountFor(layer)
if (n <= 1) return [0]
return Array.from({ length: n }, (_, i) => i)
}
/** Augments a layer's Konva config with the kaleido per-copy
* position + rotation. Pivot lookup matches render.ts: live drag
* override → keyframed `props.kaleidoPivotX/Y` → static layer
* override → layer's own (x, y). Non-source copies are
* listening:false to keep events on the source. */
function withK<T extends Record<string, unknown>>(
cfg: T,
layer: Layer,
props: AnimatableProps,
ki: number,
n: number,
): T {
if (n <= 1 || ki === 0) return cfg
const live = liveKaleidoPivot.value
const liveOverride = live && live.instanceId === layer.id
const pivotX = liveOverride
? live.x
: props.kaleidoPivotX ?? layer.kaleidoPivotX ?? props.x
const pivotY = liveOverride
? live.y
: props.kaleidoPivotY ?? layer.kaleidoPivotY ?? props.y
const angle = kaleidoAngleDegrees(ki, n)
const a = (angle * Math.PI) / 180
const dx = props.x - pivotX
const dy = props.y - pivotY
return {
...cfg,
x: pivotX + dx * Math.cos(a) - dy * Math.sin(a),
y: pivotY + dx * Math.sin(a) + dy * Math.cos(a),
rotation: props.rotation + angle,
listening: false,
draggable: false,
} as T
}
/** SVG-asset child wrapper. Same listening/drag config as button
* children, but also pins `strokeScaleEnabled: false`. svgStackForAsset
* pre-multiplies each child's strokeWidth by the outer placement
@@ -707,6 +764,138 @@ 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<CameraProps | null>(() => {
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 ? 1.4 : 1) / zoom.value,
strokeScaleEnabled: false,
dash: [4 / zoom.value, 3 / zoom.value],
hitStrokeWidth: Math.max(8 / zoom.value, 6),
listening: true,
draggable: selected && !camera.locked,
}
})
const cameraResizeDragStart = ref<CameraProps | null>(null)
function cameraOutputAspect(): number {
const canvas = store.scene.canvas
const width = Number.isFinite(canvas.width) && canvas.width > 0 ? canvas.width : 1
const height = Number.isFinite(canvas.height) && canvas.height > 0 ? canvas.height : 1
return width / height
}
const cameraResizeHandleConfig = computed(() => {
const frame = cameraFrame.value
const camera = store.scene.camera
if (!frame || !camera || !store.selectedCamera || camera.locked) return null
const size = 10 / zoom.value
return {
id: 'camera-resize-handle',
name: 'camera-resize-handle',
x: frame.x + frame.width - size / 2,
y: frame.y + frame.height - size / 2,
width: size,
height: size,
fill: '#f8fafc',
stroke: '#38bdf8',
strokeWidth: 1 / zoom.value,
strokeScaleEnabled: false,
draggable: true,
listening: true,
}
})
function selectCameraFromStage(e?: Konva.KonvaEventObject<MouseEvent>) {
if (e) e.cancelBubble = true
store.selectCamera(true)
}
function onCameraDragMove(e: Konva.KonvaEventObject<DragEvent>) {
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<DragEvent>) {
onCameraDragMove(e)
}
function onCameraResizeStart(e: Konva.KonvaEventObject<DragEvent | MouseEvent>) {
e.cancelBubble = true
if (store.scene.camera?.locked) return
store.selectCamera(true)
cameraResizeDragStart.value = cameraFrame.value ? { ...cameraFrame.value } : null
}
function onCameraResizeDrag(e: Konva.KonvaEventObject<DragEvent>) {
e.cancelBubble = true
const camera = store.scene.camera
const start = cameraResizeDragStart.value ?? cameraFrame.value
if (!camera || camera.locked || !start) return
const node = e.target
const handleSize = cameraResizeHandleConfig.value?.width ?? 10 / zoom.value
const aspect = cameraOutputAspect()
const widthFromPointer = Math.max(1, node.x() + handleSize / 2 - start.x)
const heightFromPointer = Math.max(1, node.y() + handleSize / 2 - start.y)
const widthDelta = Math.abs(widthFromPointer - start.width) / Math.max(1, start.width)
const heightDelta = Math.abs(heightFromPointer - start.height) / Math.max(1, start.height)
const width = Math.max(
1,
Math.round(widthDelta >= heightDelta ? widthFromPointer : heightFromPointer * aspect),
)
const height = Math.max(1, Math.round(width / aspect))
node.x(start.x + width - handleSize / 2)
node.y(start.y + height - handleSize / 2)
store.upsertCameraKeyframe(store.playhead, {
x: Math.round(start.x),
y: Math.round(start.y),
width,
height,
})
}
function onCameraResizeEnd(e: Konva.KonvaEventObject<DragEvent>) {
onCameraResizeDrag(e)
cameraResizeDragStart.value = null
}
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,
@@ -906,6 +1095,10 @@ interface RenderedClipInstance {
* no measurable layers (template falls back to a placeholder). */
bounds: Rect | null
items: RenderedClipLayer[]
/** Kaleido arm index (0 = source). Static (non-kaleido) instances
* always emit a single bundle with armIndex 0, armCount 1. */
armIndex: number
armCount: number
}
type StackItem =
@@ -1051,39 +1244,50 @@ const stackItems = computed<StackItem[]>(() => {
if (!clip) continue
const trimStart = inst.trimStart ?? 0
const trimEnd = inst.trimEnd ?? 0
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
const visibleDur = Math.max(0, clip.duration - trimStart - trimEnd)
const offsetIntoBlock = store.playhead - inst.startTime
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
const localTime = trimStart + offsetIntoBlock
// Bundle the clip's layers under a single clip-instance entry
// so the template can wrap them in a draggable Konva group that
// carries the instance's x / y / scale transform.
const items: RenderedClipLayer[] = []
for (let k = clip.layers.length - 1; k >= 0; k--) {
const cl = clip.layers[k]
if (!cl.visible) continue
if (!isLayerActiveAt(cl, localTime)) continue
const ca = assetMap.value.get(cl.assetId)
if (!ca) continue
items.push({
instanceId: inst.id,
layer: cl,
asset: ca,
props: sampleLayer(cl, localTime),
// For kaleido instances delayed arms can outlive the source's
// window; widen the early-out so they survive into the loop.
const armCount = kaleidoCountFor(inst)
const offsetSec = inst.kaleidoOffset ?? 0
const maxArmDelay = armCount > 1 && offsetSec !== 0
? Math.max(0, (armCount - 1) * offsetSec)
: 0
if (offsetIntoBlock < 0 || offsetIntoBlock > visibleDur + maxArmDelay) continue
const baseLocal = trimStart + offsetIntoBlock
const bounds = computeClipBounds(clip, assetMap.value)
// Bundle each kaleido arm separately. Each arm samples its own
// localT so a delayed arm shows the right clip frame for *its*
// playback offset (not the source's). Static (non-kaleido)
// instances run this loop once with armIndex 0.
for (let armIndex = 0; armIndex < armCount; armIndex++) {
const localT = clipArmLocalTime(inst, baseLocal, armIndex, visibleDur)
if (localT === null) continue
const items: RenderedClipLayer[] = []
for (let k = clip.layers.length - 1; k >= 0; k--) {
const cl = clip.layers[k]
if (!cl.visible) continue
if (!isLayerActiveAt(cl, localT)) continue
const ca = assetMap.value.get(cl.assetId)
if (!ca) continue
items.push({
instanceId: inst.id,
layer: cl,
asset: ca,
props: sampleLayer(cl, localT),
})
}
out.push({
kind: 'clipInstance',
bundle: {
instance: inst,
bounds,
items,
armIndex,
armCount,
},
})
}
// Always push the bundle when the instance is in time-range,
// even if no items are renderable at this playhead: the frame
// rect still needs to render so the clip can be selected (and
// the transformer framed) on empty frames.
out.push({
kind: 'clipInstance',
bundle: {
instance: inst,
bounds: computeClipBounds(clip, assetMap.value),
items,
},
})
}
}
return out
@@ -1303,11 +1507,17 @@ function circleConfig(
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
const fill = props.fill ?? asset.fill
const base = baseNodeConfig(layer, props)
// Read fontsReady so this config recomputes once bundled fonts
// resolve — otherwise Konva caches a measurement made against the
// fallback family and the text is rendered too narrow.
void fontsReady.value
const family = fontCssFamily(asset.fontFamily)
if (isLayerGhostLocked(layer)) {
return {
...base,
text: asset.text,
fontSize: asset.fontSize,
fontFamily: family,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: undefined,
@@ -1320,6 +1530,7 @@ function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props
...base,
text: asset.text,
fontSize: asset.fontSize,
fontFamily: family,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
...(layer.isMask ? {} : strokeCfg(props, asset)),
@@ -1508,6 +1719,191 @@ function clipInstanceGroupConfig(inst: ClipInstance) {
}
}
/** Per-arm Konva Group config for a kaleido'd clip instance. ki=0 is
* the source (events go here), ki>0 are decorative non-listening
* copies. Spatial layout mirrors lib/kaleido.ts: rotateCopies=true
* rotates the entire local space around the pivot (contents follow);
* rotateCopies=false places the instance translated around the pivot
* without rotating its contents. */
function clipInstanceKaleidoGroupConfig(inst: ClipInstance, ki: number) {
const base = clipInstanceGroupConfig(inst)
const n = kaleidoCountFor(inst)
if (n <= 1 || ki === 0) return base
// Read the pivot through `effectiveKaleidoPivot` so a live drag
// overrides the stored value — keeps every arm orbiting around
// the cursor in real time during the drag.
const { x: px, y: py } = effectiveKaleidoPivot(inst)
if (shouldRotateClipCopies(inst)) {
// Rotate the source's local space around the pivot. Konva doesn't
// expose a "rotate around an arbitrary point" knob in a single
// node, but we can replicate it by translating to the pivot,
// rotating, and translating back — folded into x / y / rotation.
const a = (kaleidoAngleDegrees(ki, n) * Math.PI) / 180
const dx = (inst.x ?? 0) - px
const dy = (inst.y ?? 0) - py
const rotatedX = px + dx * Math.cos(a) - dy * Math.sin(a)
const rotatedY = py + dx * Math.sin(a) + dy * Math.cos(a)
return {
...base,
x: rotatedX,
y: rotatedY,
rotation: (inst.rotation ?? 0) + kaleidoAngleDegrees(ki, n),
listening: false,
draggable: false,
}
}
// rotateCopies = false: position around the pivot but keep the
// source orientation. Compute the rotated translate inline so we
// pick up the live pivot too.
const a = (kaleidoAngleDegrees(ki, n) * Math.PI) / 180
const ix = inst.x ?? 0
const iy = inst.y ?? 0
const dx = ix - px
const dy = iy - py
return {
...base,
x: px + dx * Math.cos(a) - dy * Math.sin(a),
y: py + dx * Math.sin(a) + dy * Math.cos(a),
listening: false,
draggable: false,
}
}
/** Live (mid-drag) override for the kaleido pivot. Pinia mutations
* alone don't propagate fast enough through vue-konva while Konva's
* drag loop is active — the kaleido arms only reposition at mouse-
* up. Mirroring the `liveLayerPoses` pattern: a local Vue ref is
* written on every dragmove and consumed by the per-arm config
* helper so the orbit follows the pointer in real time, then
* committed to the store on dragend. */
const liveKaleidoPivot = ref<{ instanceId: string; x: number; y: number } | null>(null)
function effectiveKaleidoPivot(inst: ClipInstance): { x: number; y: number } {
const live = liveKaleidoPivot.value
if (live && live.instanceId === inst.id) return { x: live.x, y: live.y }
return {
x: inst.kaleidoPivotX ?? (inst.x ?? 0),
y: inst.kaleidoPivotY ?? (inst.y ?? 0),
}
}
/** Visible pivot handle for a kaleido'd clip instance OR layer.
* Appears at the pivot world position whenever the entity is
* selected and has kaleido on. Drag is wired manually via window-
* level pointer events — Konva's `draggable: true` doesn't fire
* dragmove reliably for shapes nested inside the clipFunc'd canvas
* group here, so we drive position-from-pointer ourselves. */
type KaleidoPivotEntity =
| { kind: 'clip'; instanceId: string; x: number; y: number }
| { kind: 'layer'; layerId: string; x: number; y: number }
const kaleidoPivotHandle = computed<KaleidoPivotEntity | null>(() => {
// Clip instance takes precedence (more specific selection).
const ciId = store.selectedClipInstanceId
if (ciId) {
const inst = store.scene.clipInstances?.find((c) => c.id === ciId)
if (!inst || !inst.kaleido) return null
const { x, y } = effectiveKaleidoPivot(inst)
return { kind: 'clip', instanceId: inst.id, x, y }
}
const lyId = store.selectedLayerId
if (lyId) {
const layer = store.activeLayers.find((l) => l.id === lyId)
if (!layer || !layer.kaleido) return null
// Skip button-asset layers — kaleido is disabled for them and
// a stray pivot handle would be confusing.
const asset = assetMap.value.get(layer.assetId)
if (asset?.kind === 'button') return null
const sampled = sampleLayer(layer, store.playhead)
const live = liveKaleidoPivot.value
// Pivot lookup matches render.ts: live drag → keyframed prop →
// legacy static layer field → sampled layer position.
const x = live && live.instanceId === lyId
? live.x
: sampled.kaleidoPivotX ?? layer.kaleidoPivotX ?? sampled.x
const y = live && live.instanceId === lyId
? live.y
: sampled.kaleidoPivotY ?? layer.kaleidoPivotY ?? sampled.y
return { kind: 'layer', layerId: layer.id, x, y }
}
return null
})
const kaleidoPivotHandleConfig = computed(() => {
const h = kaleidoPivotHandle.value
if (!h) return null
return {
x: h.x,
y: h.y,
radius: 9 / zoom.value,
stroke: '#ff7a59',
strokeWidth: 2 / zoom.value,
strokeScaleEnabled: false,
fill: 'rgba(255, 122, 89, 0.18)',
name: 'kaleido-pivot-handle',
}
})
let pivotDragEntity: KaleidoPivotEntity | null = null
let pivotDragKonvaLayer: Konva.Layer | null = null
function clientToCanvas(clientX: number, clientY: number) {
const el = container.value
if (!el) return null
const rect = el.getBoundingClientRect()
const sx = clientX - rect.left
const sy = clientY - rect.top
return {
x: (sx - viewX.value) / zoom.value,
y: (sy - viewY.value) / zoom.value,
}
}
function onKaleidoPivotMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
e.cancelBubble = true
const h = kaleidoPivotHandle.value
if (!h) return
pivotDragEntity = h
pivotDragKonvaLayer = e.target.getLayer()
window.addEventListener('pointermove', onKaleidoPivotPointerMove)
window.addEventListener('pointerup', onKaleidoPivotPointerUp, { once: true })
}
function onKaleidoPivotPointerMove(e: PointerEvent) {
if (!pivotDragEntity) return
const c = clientToCanvas(e.clientX, e.clientY)
if (!c) return
// The live ref is keyed by entityId — both clip-instance ids and
// layer ids are universally-unique uids, so a single map is fine.
const id = pivotDragEntity.kind === 'clip'
? pivotDragEntity.instanceId
: pivotDragEntity.layerId
liveKaleidoPivot.value = { instanceId: id, x: c.x, y: c.y }
pivotDragKonvaLayer?.batchDraw()
}
function onKaleidoPivotPointerUp() {
window.removeEventListener('pointermove', onKaleidoPivotPointerMove)
if (pivotDragEntity && liveKaleidoPivot.value) {
if (pivotDragEntity.kind === 'clip') {
store.setClipInstanceKaleido(pivotDragEntity.instanceId, {
kaleidoPivotX: liveKaleidoPivot.value.x,
kaleidoPivotY: liveKaleidoPivot.value.y,
})
} else {
// Layer pivot is keyframable. Upsert at the current playhead
// so dropping multiple kfs animates the orbit centre.
store.upsertKeyframe(pivotDragEntity.layerId, store.playhead, {
kaleidoPivotX: liveKaleidoPivot.value.x,
kaleidoPivotY: liveKaleidoPivot.value.y,
})
}
}
liveKaleidoPivot.value = null
pivotDragEntity = null
pivotDragKonvaLayer = null
}
/** Invisible frame rect sized to the clip's union bbox. Sits as the
* first child of the ci-group so:
* - the group's client rect is dominated by this stable rectangle,
@@ -1611,12 +2007,14 @@ function clipCircleConfig(item: RenderedClipLayer) {
}
function clipTextConfig(item: RenderedClipLayer) {
void fontsReady.value
const asset = item.asset as Extract<Asset, { kind: 'text' }>
const fill = item.props.fill ?? asset.fill
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
text: asset.text,
fontSize: asset.fontSize,
fontFamily: fontCssFamily(asset.fontFamily),
offsetX: item.layer.offsetX ?? 0,
offsetY: item.layer.offsetY ?? 0,
...strokeCfg(item.props, asset),
@@ -1757,10 +2155,10 @@ interface VertexHandle {
}
const polygonVertexHandles = computed<VertexHandle[]>(() => {
// Vertex handles only apply to a SOLO polygon selection — in a
// mixed multi-selection the shared transformer bounding box owns
// scale / rotate, and per-vertex handles would visually fight it.
if (store.selectedLayerIds.length > 1) return []
// Vertex handles only apply to a SOLO polygon selection in
// *vertex-edit mode* (entered via double-click). Level-1
// selection of a polygon shows the regular transformer instead.
if (!polygonInVertexEdit.value) return []
const id = store.selectedLayerId
if (!id) return []
const layer = store.activeLayers.find((l) => l.id === id)
@@ -2085,10 +2483,26 @@ const selectedPolygonLayer = computed(() => {
return { layer, asset }
})
/** Layer id of the polygon currently in vertex-edit mode. A
* polygon's first selection lands at "level 1" — the regular
* transformer (size + rotation) attaches like any other shape.
* Double-click promotes the polygon to "level 2" / vertex edit:
* per-vertex handles + tangent handles + edge-insert dbl-clicks
* light up. Cleared on selection change, scope change, or Escape. */
const polygonVertexEditId = ref<string | null>(null)
const polygonInVertexEdit = computed(() => {
if (store.selectedLayerIds.length > 1) return false
const id = store.selectedLayerId
if (!id) return false
return polygonVertexEditId.value === id
})
watch(
() => store.selectedLayerId,
() => {
selectedVertexIndices.value = new Set()
polygonVertexEditId.value = null
closeVertexMenu()
},
)
@@ -2313,8 +2727,10 @@ function attachTransformer(retriesLeft = 3) {
// Multi-select: wrap every selected layer node in one transformer
// so the bounding box + scale / rotate handles span the group.
// A SOLE polygon selection still bypasses the transformer because
// a single polygon is edited via vertex handles; mixed selections
// A SOLE polygon selection bypasses the transformer ONLY when
// the user has promoted it to vertex-edit mode (double-click);
// in level-1 / single-click selection the polygon gets the
// regular transformer like any other shape. Mixed selections
// that happen to include a polygon fold it into the shared bbox.
const ids = store.selectedLayerIds.length
? store.selectedLayerIds
@@ -2333,7 +2749,13 @@ function attachTransformer(retriesLeft = 3) {
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) continue
const asset = assetMap.value.get(layer.assetId)
if (asset?.kind === 'polygon' && single) continue
if (
asset?.kind === 'polygon' &&
single &&
polygonInVertexEdit.value
) {
continue
}
const node = stage.findOne<Konva.Node>(`#${id}`)
if (!node) {
anyMissing = true
@@ -2359,6 +2781,7 @@ watch(
store.selectedClipInstanceId,
stackItems.value,
store.playhead,
polygonVertexEditId.value,
] as const,
async () => {
await nextTick()
@@ -2537,6 +2960,89 @@ const animationPath = computed<PathOverlay | null>(() => {
}
})
/** Motion-path overlay for the currently-selected layer (toggle-
* driven, off by default). Renders the layer's position-keyframe
* path even when no keyframe is explicitly selected — handy for
* visualizing the trajectory while tweaking other props. Skipped
* when `animationPath` is already producing one (the kf-selection
* view wins, since it offers richer interaction). */
interface MotionPathOverlay {
layerId: string
segments: { points: number[] }[]
dots: { keyframeId: string; time: number; x: number; y: number }[]
}
const motionPathOverlay = computed<MotionPathOverlay | null>(() => {
if (!showMotionPaths.value) return null
if (animationPath.value) return null
const id = store.selectedLayerId
if (!id) return null
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return null
const arr = layer.separated ? layer.positionKeyframes : layer.keyframes
if (!arr || arr.length < 2) return null
const sorted = [...arr]
.filter((k) => k.kind !== 'end')
.sort((a, b) => a.time - b.time)
if (sorted.length < 2) return null
const fps = store.scene.fps || 60
const segments: { points: number[] }[] = []
for (let i = 0; i < sorted.length - 1; i++) {
const flat = sampleSegment(layer, sorted[i].time, sorted[i + 1].time, fps)
if (flat.length >= 4) segments.push({ points: flat })
}
if (!segments.length) return null
const dots = sorted.map((k) => {
const s = sampleLayer(layer, k.time)
return { keyframeId: k.id, time: k.time, x: s.x, y: s.y }
})
return { layerId: layer.id, segments, dots }
})
const motionPathLines = computed(() => {
const path = motionPathOverlay.value
if (!path) return []
return path.segments.map((seg, i) => ({
key: `mp-${i}`,
config: {
points: seg.points,
stroke: '#a47bff',
strokeWidth: 1.25 / zoom.value,
strokeScaleEnabled: false,
listening: false,
lineCap: 'round',
lineJoin: 'round',
opacity: 0.65,
dash: [5 / zoom.value, 4 / zoom.value],
},
}))
})
const motionPathDots = computed(() => {
const path = motionPathOverlay.value
if (!path) return []
return path.dots.map((d) => ({
key: `mp-dot-${d.keyframeId}`,
config: {
x: d.x,
y: d.y,
radius: 4 / zoom.value,
fill: '#a47bff',
stroke: '#ffffff',
strokeWidth: 1.25 / zoom.value,
strokeScaleEnabled: false,
name: 'motion-path-dot',
},
time: d.time,
}))
})
function onMotionPathDotClick(time: number) {
// Click a dot → seek the playhead to that keyframe's time. Cheap
// navigation aid for layers with many keyframes.
store.setPlayhead(time)
}
const animationGhostSegments = computed(() => {
const path = animationPath.value
if (!path) return []
@@ -2700,7 +3206,9 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
// selection — inside a multi-select the box always targets
// layers so new picks add to the existing group.
const mode: 'vertex' | 'layer' =
selectedPolygonLayer.value && store.selectedLayerIds.length <= 1
selectedPolygonLayer.value &&
store.selectedLayerIds.length <= 1 &&
polygonInVertexEdit.value
? 'vertex'
: 'layer'
boxSelect.value = {
@@ -2763,6 +3271,8 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
if (target.name?.() === 'tangent-handle') return
if (target.name?.() === 'path-frame-dot') return
if (target.name?.() === 'motion-path-dot') return
if (target.name?.() === 'kaleido-pivot-handle') return
if (vertexMenu.value) vertexMenu.value = null
if (target === target.getStage()) {
deselectAll()
@@ -2894,6 +3404,16 @@ function onContainerDblClick(e: MouseEvent) {
return
}
if (asset.kind !== 'polygon') return
// First dbl-click on a level-1-selected polygon → enter
// vertex-edit mode (level 2). The transformer detaches in the
// attachTransformer watcher, vertex handles light up. Subsequent
// dbl-clicks (already in vertex-edit mode) fall through to the
// edge-insert / open-polygon resume gestures below. SVG-edit
// scope skips this beat since polygons are always level-2 there.
if (!polygonInVertexEdit.value) {
polygonVertexEditId.value = layer.id
return
}
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
if (pts.length < 1) return
@@ -4111,9 +4631,13 @@ function onResizeHandlePointerUp(e: PointerEvent) {
"
>
<template v-if="entry.kind === 'layer'">
<template
v-for="ki in kaleidoIndicesForLayer(entry.item.layer)"
:key="`${entry.item.layer.id}:k${ki}`"
>
<v-group
v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)"
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
:config="withK(buttonLayerGroupConfig(entry.item.layer, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4157,6 +4681,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
<v-group
v-else-if="entry.item.asset.kind === 'button' && buttonAssetFor(entry.item.asset)"
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
v-show="ki === 0"
@mouseenter="onButtonEnter(entry.item.layer)"
@mouseleave="onButtonLeave(entry.item.layer)"
@mousedown="onButtonDown(entry.item.layer)"
@@ -4197,7 +4722,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
</v-group>
<v-rect
v-else-if="entry.item.asset.kind === 'rect'"
:config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="withK(rectConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4205,7 +4730,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-circle
v-else-if="entry.item.asset.kind === 'circle'"
:config="circleConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="withK(circleConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4213,7 +4738,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-text
v-else-if="entry.item.asset.kind === 'text'"
:config="textConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="withK(textConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4221,7 +4746,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-line
v-else-if="entry.item.asset.kind === 'polygon'"
:config="polygonConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="withK(polygonConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4229,21 +4754,23 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-image
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
:config="withK(imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!, entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
</template>
</template>
<template v-else>
<v-group
:config="clipInstanceGroupConfig(entry.bundle.instance)"
@mousedown="store.selectClipInstance(entry.bundle.instance.id)"
@dragend="onClipInstanceDragEnd($event, entry.bundle.instance.id)"
@transformend="onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
:config="clipInstanceKaleidoGroupConfig(entry.bundle.instance, entry.bundle.armIndex)"
@mousedown="entry.bundle.armIndex === 0 && store.selectClipInstance(entry.bundle.instance.id)"
@dragend="entry.bundle.armIndex === 0 && onClipInstanceDragEnd($event, entry.bundle.instance.id)"
@transformend="entry.bundle.armIndex === 0 && onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
>
<v-rect
v-if="entry.bundle.armIndex === 0"
:config="clipFrameConfig(
entry.bundle.instance.id,
entry.bundle.bounds,
@@ -4251,7 +4778,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<template
v-for="sub in entry.bundle.items"
:key="`ci:${entry.bundle.instance.id}:${sub.layer.id}`"
:key="`ci:${entry.bundle.instance.id}:${sub.layer.id}:k${entry.bundle.armIndex}`"
>
<v-rect
v-if="sub.asset.kind === 'rect'"
@@ -4330,6 +4857,21 @@ function onResizeHandlePointerUp(e: PointerEvent) {
<v-line :config="centerCrosshairConfigs.horizontal" />
<v-line :config="centerCrosshairConfigs.vertical" />
</template>
<v-rect
v-if="cameraFrameConfig"
:config="cameraFrameConfig"
@mousedown="selectCameraFromStage"
@dragmove="onCameraDragMove"
@dragend="onCameraDragEnd"
/>
<v-rect
v-if="cameraResizeHandleConfig"
:config="cameraResizeHandleConfig"
@mousedown="onCameraResizeStart"
@dragstart="onCameraResizeStart"
@dragmove="onCameraResizeDrag"
@dragend="onCameraResizeEnd"
/>
<v-rect
v-for="h in canvasResizeHandles"
:key="`canvas-resize-${h.edge}`"
@@ -4419,6 +4961,22 @@ function onResizeHandlePointerUp(e: PointerEvent) {
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
/>
<v-line
v-for="line in motionPathLines"
:key="line.key"
:config="line.config"
/>
<v-circle
v-for="d in motionPathDots"
:key="d.key"
:config="d.config"
@mousedown.stop="onMotionPathDotClick(d.time)"
/>
<v-circle
v-if="kaleidoPivotHandleConfig"
:config="kaleidoPivotHandleConfig"
@mousedown="onKaleidoPivotMouseDown"
/>
<v-line v-if="drawingPreviewConfig" :config="drawingPreviewConfig" />
<v-circle
v-for="d in drawingDotsConfig"
@@ -4517,6 +5075,22 @@ function onResizeHandlePointerUp(e: PointerEvent) {
>
<PhCursorClick :size="13" />
</button>
<button
class="zc-btn camera-btn"
:class="{ active: store.scene.camera?.enabled, selected: store.selectedCamera }"
v-tooltip="store.scene.camera?.enabled ? t('canvas.cameraOff') : t('canvas.cameraOn')"
@click="toggleCameraEnabled"
>
<PhFrameCorners :size="13" />
</button>
<button
class="zc-btn motion-paths-btn"
:class="{ active: showMotionPaths }"
v-tooltip="{ label: t('canvas.motionPaths.label'), help: t('canvas.motionPaths.help') }"
@click="showMotionPaths = !showMotionPaths"
>
<PhPath :size="13" :weight="showMotionPaths ? 'fill' : 'regular'" />
</button>
</div>
<div class="dims">{{ effectiveCanvas.width }} × {{ effectiveCanvas.height }}</div>
+13 -7
View File
@@ -5,6 +5,7 @@ import { useSceneStore } from '@/stores/scene'
import { confirmDialog, promptDialog, dialogState } from '@/components/ui'
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
import { useProjectName } from '@/composables/useProjectName'
import { useTimeFormat } from '@/composables/useTimeFormat'
import {
parsePresetInput,
presetLabel,
@@ -17,6 +18,7 @@ const store = useSceneStore()
const { open } = useDocumentDrawer()
const { projectName, renameProject } = useProjectName()
const { presets, addPreset, removePreset } = useCanvasPresets()
const { mode: timeMode, timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
const rootEl = ref<HTMLElement | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
@@ -272,12 +274,11 @@ const bgTransparent = computed({
},
})
// Duration input follows the global seconds/frames toggle. Storage
// stays in seconds; conversion happens here at the UI boundary.
const duration = computed({
// Round display to 2 decimals so a frame-aligned duration like
// 4.733333… shows as 4.73 in the input, matching the header
// format. Setter passes the user-typed value through unchanged.
get: () => Math.round(store.scene.duration * 100) / 100,
set: (v: number) => store.setDuration(v),
get: () => timeToInputValue(store.scene.duration),
set: (v: number) => store.setDuration(inputValueToSeconds(v)),
})
const fps = computed({
@@ -364,8 +365,13 @@ async function onRemovePreset(p: CanvasPreset) {
<input type="number" min="1" step="1" v-model.number="height" />
</label>
<label class="field">
<span>{{ t('settings.duration') }}</span>
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
<span>{{ t('settings.duration') }} ({{ unitLabel }})</span>
<input
type="number"
:min="timeMode === 'frames' ? 1 : 0.1"
:step="inputStep"
v-model.number="duration"
/>
</label>
<label class="field">
<span>{{ t('settings.fps') }}</span>
+32 -56
View File
@@ -14,8 +14,9 @@ import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { useShowWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
import { showLocked } from '@/composables/useShowLocked'
import { Modal } from '@/components/ui'
import { Modal, ToggleSwitch } from '@/components/ui'
import { setLocale, t, useI18n } from '@/i18n'
import type { Locale } from '@/i18n'
@@ -27,6 +28,7 @@ const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref()
const { enabled: thumbsEnabled } = useAssetThumbsPref()
const { enabled: welcomeOnStart } = useShowWelcomeOnStart()
const { enabled: fullscreenAutoplay } = useFullscreenPreviewAutoplay()
const { locale } = useI18n()
// Re-check the stored clipart folder whenever settings opens — covers the
@@ -245,9 +247,10 @@ function applyTheme(t: Theme) {
</div>
<label class="row-toggle">
<span class="switch" :class="{ on: tooltipsEnabled }">
<input type="checkbox" v-model="tooltipsEnabled" />
</span>
<ToggleSwitch
v-model="tooltipsEnabled"
:aria-label="t('settings.tooltipsLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.tooltipsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.tooltipsDesc') }}</div>
@@ -255,9 +258,10 @@ function applyTheme(t: Theme) {
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: showLocked }">
<input type="checkbox" v-model="showLocked" />
</span>
<ToggleSwitch
v-model="showLocked"
:aria-label="t('settings.showLockedLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.showLockedLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.showLockedDesc') }}</div>
@@ -265,9 +269,10 @@ function applyTheme(t: Theme) {
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: thumbsEnabled }">
<input type="checkbox" v-model="thumbsEnabled" />
</span>
<ToggleSwitch
v-model="thumbsEnabled"
:aria-label="t('settings.assetThumbsLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.assetThumbsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.assetThumbsDesc') }}</div>
@@ -275,15 +280,27 @@ function applyTheme(t: Theme) {
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: welcomeOnStart }">
<input type="checkbox" v-model="welcomeOnStart" />
</span>
<ToggleSwitch
v-model="welcomeOnStart"
:aria-label="t('settings.welcomeOnStartLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.welcomeOnStartLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.welcomeOnStartDesc') }}</div>
</div>
</label>
<label class="row-toggle">
<ToggleSwitch
v-model="fullscreenAutoplay"
:aria-label="t('settings.fullscreenAutoplayLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.fullscreenAutoplayLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.fullscreenAutoplayDesc') }}</div>
</div>
</label>
<div class="setting-block">
<div class="setting-heading">{{ t('settings.appearanceTitle') }}</div>
<div class="setting-desc">{{ t('settings.themeHint') }}</div>
@@ -550,10 +567,10 @@ input[type='number'] {
letter-spacing: normal;
}
/* Switch + two-line label. Defensive resets defeat the scoped
/* Switch row + two-line label. Defensive resets defeat the scoped
blanket `label { flex-direction: column; text-transform: uppercase;
font-size: 10px; flex: 1 1 0; }` that form-row labels in this file
depend on — without the reset that rule would stack the switch on
depend on — without the reset that rule would stack the control on
top of the text and uppercase everything inside. */
.row-toggle {
display: flex;
@@ -599,47 +616,6 @@ input[type='number'] {
text-transform: none;
}
.row-toggle .switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.row-toggle .switch.on {
background: var(--accent);
}
.row-toggle .switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.row-toggle .switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.row-toggle .switch.on::after {
transform: translateX(14px);
}
.clipart-row {
display: flex;
flex-direction: column;
+82 -78
View File
@@ -12,17 +12,11 @@ import {
PhGif,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadSceneAsGif } from '@/lib/gifExport'
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
import { downloadFromServer } from '@/lib/serverExport'
import { downloadRuwFile } from '@/lib/ruwFile'
import { downloadSceneAsStillImage } from '@/lib/stillImageExport'
import { useProjectName } from '@/composables/useProjectName'
import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui'
import { Modal, ToggleSwitch } from '@/components/ui'
import { t } from '@/i18n'
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -38,7 +32,22 @@ interface ProgressState {
}
const progress = ref<ProgressState | null>(null)
const videoSupported = pickMimeType() !== null
const VIDEO_MIME_CANDIDATES = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
'video/mp4;codecs=h264',
'video/mp4',
]
function canRecordVideo(): boolean {
if (typeof MediaRecorder === 'undefined') return false
return VIDEO_MIME_CANDIDATES.some((mime) => MediaRecorder.isTypeSupported?.(mime))
}
const videoSupported = canRecordVideo()
const isDesktop = computed(() => !!window.ruwioDesktop)
const hasDesktopMp4 = computed(() => hasDesktopRender())
// Opt-in: strip layers that are hidden in the editor (`editorHidden`)
// from the exported scene. Default off — editor-hide is a workflow
@@ -197,17 +206,20 @@ function activeSceneClone() {
return scene
}
function exportScene() {
async function exportScene() {
const { downloadSceneExport } = await import('@/lib/exportPlayer')
downloadSceneExport(projectClone())
emit('update:modelValue', false)
}
function exportRuntime() {
async function exportRuntime() {
const { downloadRuntime } = await import('@/lib/exportPlayer')
downloadRuntime()
emit('update:modelValue', false)
}
function exportRuwBundle() {
async function exportRuwBundle() {
const { downloadRuwFile } = await import('@/lib/ruwFile')
// Re-import target: .ruw is the round-trippable format the welcome
// dialog + file-picker accept. Pull the project via projectForSave
// so the editor-session UI state (zoom, toggles, …) rides along.
@@ -218,7 +230,8 @@ function exportRuwBundle() {
emit('update:modelValue', false)
}
function exportHtml() {
async function exportHtml() {
const { downloadExampleHtml } = await import('@/lib/exportPlayer')
downloadExampleHtml(projectClone())
emit('update:modelValue', false)
}
@@ -233,6 +246,7 @@ async function exportStillImage(format: 'png' | 'jpg') {
const opts = targetSizeChanged.value
? { targetWidth: targetWidth.value, targetHeight: targetHeight.value }
: undefined
const { downloadSceneAsStillImage } = await import('@/lib/stillImageExport')
await downloadSceneAsStillImage(
activeSceneClone(),
store.playhead,
@@ -249,11 +263,12 @@ async function exportWebm() {
progress.value = {
progress: 0,
controller,
label: t('export.labelWebm'),
label: t(isDesktop.value ? 'export.labelWebmDesktop' : 'export.labelWebmBrowser'),
error: null,
}
store.pause()
try {
const { downloadSceneAsVideo } = await import('@/lib/videoExport')
await downloadSceneAsVideo(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
@@ -282,6 +297,7 @@ async function exportGif() {
}
store.pause()
try {
const { downloadSceneAsGif } = await import('@/lib/gifExport')
await downloadSceneAsGif(
activeSceneClone(),
frameExportOpts(controller, (p) => {
@@ -310,6 +326,7 @@ async function exportApng() {
}
store.pause()
try {
const { downloadSceneAsApng } = await import('@/lib/apngExport')
await downloadSceneAsApng(
activeSceneClone(),
frameExportOpts(controller, (p) => {
@@ -338,6 +355,7 @@ async function exportApngPreview() {
}
store.pause()
try {
const { downloadSceneAsApngPreview } = await import('@/lib/apngExport')
await downloadSceneAsApngPreview(
activeSceneClone(),
frameExportOpts(controller, (p) => {
@@ -355,23 +373,44 @@ async function exportApngPreview() {
}
}
async function exportMp4Server() {
async function exportMp4() {
if (progress.value) return
const controller = new AbortController()
const base = (projectName.value ?? 'animation').toString()
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'animation'
progress.value = {
progress: 0,
controller,
label: t('export.labelMp4'),
label: t(hasDesktopMp4.value ? 'export.labelMp4Desktop' : 'export.labelMp4Server'),
error: null,
}
store.pause()
try {
await downloadFromServer(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
const scene = activeSceneClone()
if (hasDesktopRender()) {
const { renderMp4OnDesktop } = await import('@/lib/desktopRenderExport')
const result = await renderMp4OnDesktop(scene, {
signal: controller.signal,
fps: targetFps.value,
suggestedName: `${safe}.mp4`,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
if (!result) {
progress.value = null
return
}
} else {
const { downloadFromServer } = await import('@/lib/serverExport')
await downloadFromServer(scene, {
signal: controller.signal,
fps: targetFps.value,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
}
progress.value = null
emit('update:modelValue', false)
} catch (err) {
@@ -415,13 +454,12 @@ function onUpdate(v: boolean) {
:class="{ disabled: !hasHiddenLayers }"
v-tooltip="hasHiddenLayers ? undefined : t('export.skipHiddenNone')"
>
<span class="switch" :class="{ on: skipHidden && hasHiddenLayers }">
<input
type="checkbox"
v-model="skipHidden"
:disabled="!hasHiddenLayers"
/>
</span>
<ToggleSwitch
:model-value="skipHidden && hasHiddenLayers"
:aria-label="t('export.skipHidden')"
:disabled="!hasHiddenLayers"
@update:model-value="skipHidden = $event"
/>
<div class="export-toggle-text">
<div class="export-toggle-label">{{ t('export.skipHidden') }}</div>
<div class="export-toggle-desc">{{ t('export.skipHiddenDesc') }}</div>
@@ -557,14 +595,18 @@ function onUpdate(v: boolean) {
</button>
</div>
<button class="opt" @click="exportMp4Server">
<button class="opt" @click="exportMp4">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
MP4 Video
<span class="badge accent">{{ t('export.mp4Badge') }}</span>
<span class="badge accent">
{{ t(hasDesktopMp4 ? 'export.mp4DesktopBadge' : 'export.mp4ServerBadge') }}
</span>
</div>
<div class="opt-desc">
{{ t(hasDesktopMp4 ? 'export.mp4DesktopDesc' : 'export.mp4ServerDesc') }}
</div>
<div class="opt-desc">{{ t('export.mp4Desc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
@@ -574,10 +616,16 @@ function onUpdate(v: boolean) {
<div class="opt-text">
<div class="opt-title">
WebM Video
<span class="badge">{{ t('export.webmBadge') }}</span>
<span class="badge">
{{ t(isDesktop ? 'export.webmDesktopBadge' : 'export.webmBrowserBadge') }}
</span>
</div>
<div class="opt-desc">
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
{{
videoSupported
? t(isDesktop ? 'export.webmDesktopDesc' : 'export.webmBrowserDesc')
: t('export.webmNotSupported')
}}
</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
@@ -735,50 +783,6 @@ function onUpdate(v: boolean) {
line-height: 1.35;
}
/* iOS-style switch. The <input> is a transparent overlay that still
handles keyboard focus and click targeting; the visual is drawn by
the span + ::after pill. */
.switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.switch.on {
background: var(--accent);
}
.switch input {
position: absolute;
inset: 0;
margin: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: inherit;
}
.switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.switch.on::after {
transform: translateX(14px);
}
.target-size {
display: flex;
align-items: center;
+418
View File
@@ -0,0 +1,418 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhCaretDown, PhCheck, PhTrash, PhUploadSimple } from '@phosphor-icons/vue'
import {
AVAILABLE_FONTS,
BUILTIN_FONTS,
fontDef,
removeCustomFont,
uploadCustomFont,
} from '@/lib/fonts'
const props = defineProps<{
// Empty string = system-sans (no override). Mirrors the storage
// shape: TextAsset.fontFamily is undefined for the default.
modelValue: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const open = ref(false)
const trigger = ref<HTMLButtonElement | null>(null)
const popover = ref<HTMLDivElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const errorMsg = ref<string | null>(null)
const selected = computed(() => fontDef(props.modelValue || undefined))
const customFonts = computed(() => AVAILABLE_FONTS.value.filter(f => f.custom))
function toggle() {
open.value = !open.value
}
function pick(id: string) {
// Storage convention: 'system-sans' clears the override.
emit('update:modelValue', id === 'system-sans' ? '' : id)
open.value = false
trigger.value?.focus()
}
function onUploadClick() {
errorMsg.value = null
fileInput.value?.click()
}
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
// Reset value so picking the same file twice still triggers change.
input.value = ''
if (!file) return
try {
const newFont = await uploadCustomFont(file)
// Auto-select the freshly uploaded font.
emit('update:modelValue', newFont.id)
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : 'Upload failed.'
}
}
function onDelete(id: string, e: MouseEvent) {
e.stopPropagation()
// If the currently selected font is the one being deleted, fall
// back to system sans — otherwise the asset is left pointing at a
// dead id and silently renders in the fallback family.
if (props.modelValue === id) emit('update:modelValue', '')
removeCustomFont(id)
}
function onWindowPointerDown(e: PointerEvent) {
if (!open.value) return
const t = e.target as Node | null
if (!t) return
if (trigger.value?.contains(t)) return
if (popover.value?.contains(t)) return
open.value = false
}
function onKeydown(e: KeyboardEvent) {
if (!open.value) return
if (e.key === 'Escape') {
e.preventDefault()
open.value = false
trigger.value?.focus()
}
}
onMounted(() => {
window.addEventListener('pointerdown', onWindowPointerDown, true)
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', onWindowPointerDown, true)
window.removeEventListener('keydown', onKeydown)
})
const popStyle = ref<Record<string, string>>({})
watch(open, async (isOpen) => {
if (!isOpen) return
await Promise.resolve()
const t = trigger.value
if (!t) return
const r = t.getBoundingClientRect()
popStyle.value = {
left: `${r.left}px`,
top: `${r.bottom + 4}px`,
minWidth: `${r.width}px`,
}
})
function isActive(id: string): boolean {
return (props.modelValue || 'system-sans') === id
}
</script>
<template>
<div class="font-picker">
<button
ref="trigger"
type="button"
class="trigger"
:class="{ open }"
:aria-expanded="open"
@click="toggle"
>
<span class="label" :style="{ fontFamily: selected.family }">{{ selected.label }}</span>
<PhCaretDown :size="12" class="caret" />
</button>
<input
ref="fileInput"
type="file"
accept=".woff2,.woff,.ttf,.otf,font/woff2,font/woff,font/ttf,font/otf"
class="hidden-file-input"
@change="onFileChange"
/>
<Teleport to="body">
<div
v-if="open"
ref="popover"
class="font-popover"
:style="popStyle"
role="listbox"
>
<div class="font-popover-section">
<button
v-for="f in BUILTIN_FONTS"
:key="f.id"
type="button"
class="font-option"
:class="{ active: isActive(f.id) }"
role="option"
:aria-selected="isActive(f.id)"
@click="pick(f.id)"
>
<PhCheck
:size="12"
class="check"
:style="{ visibility: isActive(f.id) ? 'visible' : 'hidden' }"
/>
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span>
</button>
</div>
<div v-if="customFonts.length > 0" class="font-popover-divider"></div>
<div v-if="customFonts.length > 0" class="font-popover-section">
<div class="section-title">Your fonts</div>
<div
v-for="f in customFonts"
:key="f.id"
class="custom-font-row"
:class="{ active: isActive(f.id) }"
>
<button
type="button"
class="font-option custom-font-select"
role="option"
:aria-selected="isActive(f.id)"
@click="pick(f.id)"
>
<PhCheck
:size="12"
class="check"
:style="{ visibility: isActive(f.id) ? 'visible' : 'hidden' }"
/>
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span>
</button>
<button
type="button"
class="delete-custom"
title="Remove this font"
@click="onDelete(f.id, $event)"
>
<PhTrash :size="12" />
</button>
</div>
</div>
<div class="font-popover-divider"></div>
<button type="button" class="font-option upload-btn" @click="onUploadClick">
<PhUploadSimple :size="12" class="check" />
<span class="option-label">Upload font…</span>
</button>
<div v-if="errorMsg" class="upload-error">{{ errorMsg }}</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.font-picker {
position: relative;
width: 100%;
}
.trigger {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 6px 8px;
font-size: 12px;
text-transform: none;
letter-spacing: 0;
cursor: pointer;
text-align: left;
}
.trigger:hover {
border-color: var(--border-strong, var(--border));
}
.trigger.open,
.trigger:focus-visible {
border-color: var(--accent);
outline: none;
}
.trigger .label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trigger .caret {
flex-shrink: 0;
color: var(--text-dim);
transition: transform 0.15s ease;
}
.trigger.open .caret {
transform: rotate(180deg);
}
.hidden-file-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
</style>
<style>
/* Popover is teleported to <body>, so the styles need to be global. */
.font-popover {
position: fixed;
z-index: 1000;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
padding: 4px;
display: flex;
flex-direction: column;
gap: 1px;
max-height: 360px;
overflow-y: auto;
}
.font-popover-section {
display: flex;
flex-direction: column;
gap: 1px;
}
.font-popover-divider {
height: 1px;
background: var(--border);
margin: 4px 2px;
}
.font-popover .section-title {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
padding: 4px 8px 2px;
}
.font-popover .font-option {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
padding: 6px 8px;
font-size: 13px;
text-transform: none;
letter-spacing: 0;
cursor: pointer;
text-align: left;
}
.font-popover .font-option:hover {
background: var(--panel-hover, var(--panel-alt));
}
.font-popover .font-option.active {
background: var(--accent-soft);
color: var(--accent);
}
.font-popover .custom-font-row {
display: flex;
align-items: center;
width: 100%;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
}
.font-popover .custom-font-row:hover {
background: var(--panel-hover, var(--panel-alt));
}
.font-popover .custom-font-row.active {
background: var(--accent-soft);
color: var(--accent);
}
.font-popover .custom-font-select {
flex: 1 1 auto;
min-width: 0;
border: 0;
border-radius: 0;
color: inherit;
padding: 6px 0 6px 8px;
}
.font-popover .custom-font-select:hover {
background: transparent;
}
.font-popover .check {
flex-shrink: 0;
}
.font-popover .option-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.font-popover .delete-custom {
flex-shrink: 0;
width: 22px;
height: 22px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
margin-right: 4px;
opacity: 0;
}
.font-popover .custom-font-row:hover .delete-custom,
.font-popover .delete-custom:focus-visible {
opacity: 1;
}
.font-popover .delete-custom:hover {
color: var(--danger, #ff6b6b);
border-color: var(--danger, #ff6b6b);
}
.font-popover .upload-btn {
color: var(--text-dim);
}
.font-popover .upload-btn:hover {
color: var(--text);
}
.font-popover .upload-error {
font-size: 11px;
color: var(--danger, #ff6b6b);
padding: 4px 8px;
}
</style>
+20 -5
View File
@@ -2,8 +2,14 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { cameraAspect, isCameraEnabled } from '@/lib/camera'
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat'
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
const { formatTime } = useTimeFormat()
const { enabled: autoplay } = useFullscreenPreviewAutoplay()
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -23,7 +29,14 @@ let isClosing = false
let player: PlayerHandle | null = null
const aspect = computed(
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
() => {
if (isCameraEnabled(store.scene)) {
const time = props.modelValue ? localTime.value : store.playhead
const a = cameraAspect(store.scene, time)
return `${a} / 1`
}
return `${store.scene.canvas.width} / ${store.scene.canvas.height}`
},
)
const rangeIn = computed(() => store.playbackIn)
@@ -40,13 +53,14 @@ async function startPreview() {
// stage. Otherwise the two runtimes fight — editor audio pool +
// preview audio pool would both emit the same mp3s at once.
store.pause()
playing.value = true
playing.value = autoplay.value
showControls.value = false
const start = rangeIn.value
const end = rangeOut.value
const head = store.playhead
const initialTime = head < start || head >= end ? start : head
mountAt(el, initialTime, true)
localTime.value = initialTime
mountAt(el, initialTime, autoplay.value)
requestBrowserFullscreen()
}
@@ -56,6 +70,7 @@ async function startPreview() {
* player and rebuild at the same playhead so visual continuity stays
* intact across the switch. */
function mountAt(el: HTMLElement, time: number, wasPlaying: boolean) {
localTime.value = time
player = mountPlayer(el, store.project, {
loop: true,
onFrame(t: number, paused: boolean) {
@@ -90,7 +105,7 @@ watch(
if (!props.modelValue) return
const still = frameEl.value
if (!still) return
mountAt(still, keepTime, true)
mountAt(still, keepTime, playing.value)
})
},
)
@@ -240,7 +255,7 @@ onBeforeUnmount(() => {
const timeLabel = computed(() => {
const end = rangeOut.value || 0
return `${localTime.value.toFixed(2)}s / ${end.toFixed(2)}s`
return `${formatTime(localTime.value)} / ${formatTime(end)}`
})
</script>
+14 -2
View File
@@ -17,6 +17,7 @@ import {
} from '@phosphor-icons/vue'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -63,6 +64,9 @@ interface Chapter {
titleKey: string
}
const isDesktop = !!window.ruwioDesktop
const hasDesktopMp4 = hasDesktopRender()
const CHAPTERS: Chapter[] = [
{
id: 'getting-started',
@@ -509,8 +513,16 @@ const CHAPTERS: Chapter[] = [
{ bodyKey: 'help.content.export.formats.runtime' },
{ bodyKey: 'help.content.export.formats.scene' },
{ bodyKey: 'help.content.export.formats.html' },
{ bodyKey: 'help.content.export.formats.mp4' },
{ bodyKey: 'help.content.export.formats.webm' },
{
bodyKey: hasDesktopMp4
? 'help.content.export.formats.mp4Desktop'
: 'help.content.export.formats.mp4',
},
{
bodyKey: isDesktop
? 'help.content.export.formats.webmDesktop'
: 'help.content.export.formats.webm',
},
],
},
{
+531 -7
View File
@@ -6,7 +6,10 @@ import {
PhDiamond,
PhEye,
PhEyeSlash,
PhEyedropper,
PhFlower,
PhMaskHappy,
PhWaveSine,
PhMinus,
PhPolygon,
PhPlus,
@@ -14,15 +17,36 @@ import {
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleCamera } from '@/lib/camera'
import { sampleLayer } from '@/lib/interpolate'
import type { Action, AnimatableProps, Layer } from '@/types/scene'
import type { Action, AnimatableProps, CameraProps, Layer } from '@/types/scene'
import RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue'
import FontPicker from './FontPicker.vue'
import { ToggleSwitch } from '@/components/ui'
import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat'
import { useEyeDropper } from '@/composables/useEyeDropper'
const { formatTime, timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
const { supported: eyeDropperSupported, pick: pickColor } = useEyeDropper()
async function pickFillColor() {
const c = await pickColor()
if (c) fillColor.value = c
}
async function pickStrokeColor() {
const c = await pickColor()
if (c) strokeColor.value = c
}
async function pickShadowColor() {
const c = await pickColor()
if (c) shadowColorValue.value = c
}
const SECTION_STORAGE_KEY = 'animatr:propSections'
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button'
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button' | 'effects'
const defaultOpen: Record<SectionKey, boolean> = {
transform: true,
@@ -32,6 +56,7 @@ const defaultOpen: Record<SectionKey, boolean> = {
shadow: false,
mask: false,
button: true,
effects: false,
}
function readStoredOpen(): Record<SectionKey, boolean> {
@@ -74,6 +99,81 @@ const sampled = computed<AnimatableProps | null>(() =>
selected.value ? sampleLayer(selected.value, store.playhead) : null,
)
const selectedCameraTrack = computed(() =>
store.selectedCamera ? (store.scene.camera ?? null) : null,
)
const sampledCamera = computed<CameraProps | null>(() =>
selectedCameraTrack.value ? sampleCamera(store.scene, store.playhead) : null,
)
const cameraHasKeyframeHere = computed(() => {
const camera = selectedCameraTrack.value
if (!camera) return false
return camera.keyframes.some((kf) => Math.abs(kf.time - store.playhead) < 1e-3)
})
const cameraEnabled = computed<boolean>({
get: () => selectedCameraTrack.value?.enabled ?? false,
set: (v) => {
if (v) store.enableCamera()
else store.disableCamera()
},
})
const cameraOverlayVisible = computed<boolean>({
get: () => selectedCameraTrack.value?.overlayVisible !== false,
set: (v) => store.setCameraOverlayVisible(v),
})
const cameraLocked = computed<boolean>({
get: () => selectedCameraTrack.value?.locked === true,
set: (v) => store.setCameraLocked(v),
})
function setCameraProp(patch: Partial<CameraProps>) {
const camera = selectedCameraTrack.value
if (!camera || camera.locked) return
store.upsertCameraKeyframe(store.playhead, patch)
}
function cameraOutputAspect(): number {
const width = Number.isFinite(store.scene.canvas.width) && store.scene.canvas.width > 0
? store.scene.canvas.width
: 1
const height = Number.isFinite(store.scene.canvas.height) && store.scene.canvas.height > 0
? store.scene.canvas.height
: 1
return width / height
}
const cameraX = computed<number>({
get: () => Math.round(sampledCamera.value?.x ?? 0),
set: (v) => setCameraProp({ x: v }),
})
const cameraY = computed<number>({
get: () => Math.round(sampledCamera.value?.y ?? 0),
set: (v) => setCameraProp({ y: v }),
})
const cameraWidth = computed<number>({
get: () => Math.round(sampledCamera.value?.width ?? store.scene.canvas.width),
set: (v) => {
const width = Math.max(1, v)
setCameraProp({ width, height: Math.max(1, Math.round(width / cameraOutputAspect())) })
},
})
const cameraHeight = computed<number>({
get: () => Math.round(sampledCamera.value?.height ?? store.scene.canvas.height),
set: (v) => {
const height = Math.max(1, v)
setCameraProp({ width: Math.max(1, Math.round(height * cameraOutputAspect())), height })
},
})
function removeCameraKeyframeHere() {
store.removeCameraKeyframeAt(store.playhead)
}
/** True when the user has more than one layer in the selection.
* Drives the multi-edit flow: writers fan out across every
* editable selected layer, kind-specific sections collapse, and
@@ -137,6 +237,17 @@ const textAsset = computed(() => {
return a && a.kind === 'text' ? a : null
})
// Empty string represents the system-sans default. Stored as
// `undefined` on the asset so the model field round-trips cleanly
// through JSON without leaking a bogus 'system-sans' id.
const textFontModel = computed({
get: () => textAsset.value?.fontFamily ?? '',
set: (id: string) => {
if (!textAsset.value) return
textAsset.value.fontFamily = id || undefined
},
})
const polygonAsset = computed(() => {
const a = selectedAsset.value
return a && a.kind === 'polygon' ? a : null
@@ -147,6 +258,128 @@ const buttonAsset = computed(() => {
return a && a.kind === 'button' ? a : null
})
// Kaleido is allowed on any non-button layer. Buttons are excluded
// because radial copies multiply hit areas and break click routing.
const canKaleido = computed(() => {
const a = selectedAsset.value
if (!a) return false
return a.kind !== 'button'
})
const selectedClipInstance = computed(() => {
const id = store.selectedClipInstanceId
if (!id) return null
return store.scene.clipInstances?.find((c) => c.id === id) ?? null
})
const ciKaleidoOn = computed(() => !!selectedClipInstance.value?.kaleido)
const ciKaleidoCount = computed(() => selectedClipInstance.value?.kaleidoCount ?? 6)
// Offset is stored as seconds (the renderer's unit) but presented in
// whichever unit the user picked globally (Timeline ruler toggle) —
// frames or seconds. Conversion at the UI boundary via useTimeFormat.
const ciKaleidoOffsetInput = computed(() =>
timeToInputValue(selectedClipInstance.value?.kaleidoOffset ?? 0),
)
const ciPivotX = computed(() =>
Math.round(selectedClipInstance.value?.kaleidoPivotX ?? selectedClipInstance.value?.x ?? 0),
)
const ciPivotY = computed(() =>
Math.round(selectedClipInstance.value?.kaleidoPivotY ?? selectedClipInstance.value?.y ?? 0),
)
const ciRotateCopies = computed(() => selectedClipInstance.value?.kaleidoRotateCopies !== false)
function setCiKaleido(patch: Parameters<typeof store.setClipInstanceKaleido>[1]) {
const inst = selectedClipInstance.value
if (!inst) return
store.setClipInstanceKaleido(inst.id, patch)
}
function setCiKaleidoOffset(value: number) {
setCiKaleido({ kaleidoOffset: inputValueToSeconds(value) })
}
const kaleidoOn = computed(() => !!selected.value?.kaleido)
const kaleidoCountValue = computed(() => selected.value?.kaleidoCount ?? 6)
/** Display value for the layer's kaleido pivot inputs. Reads the
* sampled keyframe value first (so a moving pivot animates the
* number too), then the layer's static legacy override, then the
* layer's own (x, y). Rounded to int for compact display. */
const kaleidoPivotX = computed(() => {
const l = selected.value
if (!l) return 0
const s = sampled.value
if (s?.kaleidoPivotX !== undefined) return Math.round(s.kaleidoPivotX)
if (l.kaleidoPivotX !== undefined) return Math.round(l.kaleidoPivotX)
return Math.round(s?.x ?? 0)
})
const kaleidoPivotY = computed(() => {
const l = selected.value
if (!l) return 0
const s = sampled.value
if (s?.kaleidoPivotY !== undefined) return Math.round(s.kaleidoPivotY)
if (l.kaleidoPivotY !== undefined) return Math.round(l.kaleidoPivotY)
return Math.round(s?.y ?? 0)
})
function setKaleidoOn(on: boolean) {
if (!selected.value) return
store.setLayerKaleido(selected.value.id, on)
}
/** Pivot writes go through the keyframe pipeline — same flow as
* Transform X/Y. Animators can drop multiple kfs and the orbit
* centre interpolates between them. */
function setKaleidoPivot(patch: { x?: number; y?: number }) {
if (!selected.value) return
const props: Partial<import('@/types/scene').AnimatableProps> = {}
if (patch.x !== undefined) props.kaleidoPivotX = patch.x
if (patch.y !== undefined) props.kaleidoPivotY = patch.y
store.upsertKeyframe(selected.value.id, store.playhead, props)
}
const wiggleOn = computed(() => !!selected.value?.wiggle)
const wiggleAmplitude = computed<number>({
get: () => selected.value?.wiggle?.amplitude ?? 8,
set: (v: number) => {
if (!selected.value) return
store.setLayerWiggle(selected.value.id, { amplitude: v })
},
})
const wiggleRotation = computed<number>({
get: () => selected.value?.wiggle?.rotation ?? 2,
set: (v: number) => {
if (!selected.value) return
store.setLayerWiggle(selected.value.id, { rotation: v })
},
})
const wiggleFrequency = computed<number>({
get: () => selected.value?.wiggle?.frequency ?? 2,
set: (v: number) => {
if (!selected.value) return
store.setLayerWiggle(selected.value.id, { frequency: v })
},
})
function setWiggleOn(on: boolean) {
if (!selected.value) return
if (on) {
// Seed with the pleasant defaults animators tend to start with;
// they can dial it down from there.
store.setLayerWiggle(selected.value.id, { amplitude: 8, rotation: 2, frequency: 2 })
} else {
store.setLayerWiggle(selected.value.id, null)
}
}
function onKaleidoCountInput(e: Event) {
if (!selected.value) return
const v = Number((e.target as HTMLInputElement).value)
if (!Number.isFinite(v)) return
store.setLayerKaleidoCount(selected.value.id, v)
}
const polygonPointCount = computed<number>({
get: () => polygonAsset.value?.points.length ?? 0,
set: (v: number) => {
@@ -443,7 +676,62 @@ function deleteSelection() {
<template>
<div class="panel">
<h3>Properties</h3>
<template v-if="selected && sampled && editableLayers.length > 0">
<template v-if="selectedCameraTrack && sampledCamera">
<div class="multi-banner">{{ t('properties.camera.banner') }}</div>
<section class="prop-group">
<button class="group-header" @click="toggleSection('transform')">
<component :is="sectionOpen.transform ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.camera.frame') }}</span>
</button>
<div v-if="sectionOpen.transform" class="group-body">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.enabled') }}</span>
<ToggleSwitch
v-model="cameraEnabled"
:aria-label="t('properties.camera.enabled')"
/>
</div>
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.overlay') }}</span>
<ToggleSwitch
v-model="cameraOverlayVisible"
:aria-label="t('properties.camera.overlay')"
/>
</div>
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.locked') }}</span>
<ToggleSwitch
v-model="cameraLocked"
:aria-label="t('properties.camera.locked')"
/>
</div>
<div class="row">
<label>X<input type="number" v-model.number="cameraX" :disabled="cameraLocked" /></label>
<label>Y<input type="number" v-model.number="cameraY" :disabled="cameraLocked" /></label>
</div>
<div class="row">
<label>{{ t('properties.camera.width') }}<input type="number" min="1" v-model.number="cameraWidth" :disabled="cameraLocked" /></label>
<label>{{ t('properties.camera.height') }}<input type="number" min="1" v-model.number="cameraHeight" :disabled="cameraLocked" /></label>
</div>
<div class="kf-state">
<span>
{{ cameraHasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ formatTime(store.playhead) }}</span>
</span>
<button
v-if="cameraHasKeyframeHere"
class="kf-btn"
v-tooltip="t('properties.removeKeyframe')"
:disabled="cameraLocked"
@click="removeCameraKeyframeHere"
>
<PhMinus :size="12" />
</button>
</div>
</div>
</section>
</template>
<template v-else-if="selected && sampled && editableLayers.length > 0">
<div v-if="multiSelected" class="multi-banner">
{{ t('properties.multiSelected', { n: editableLayers.length }) }}
</div>
@@ -453,7 +741,7 @@ function deleteSelection() {
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
<span class="kf-label">
{{ hasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
<span class="kf-time">@ {{ formatTime(store.playhead) }}</span>
</span>
<button v-if="hasKeyframeHere" class="kf-btn" v-tooltip="t('properties.removeKeyframe')" @click="removeKeyframeHere">
<PhX :size="12" weight="bold" />
@@ -484,8 +772,12 @@ function deleteSelection() {
<span>{{ t('properties.sections.text') }}</span>
</button>
<div v-if="sectionOpen.text" class="group-body">
<label>Text<input type="text" v-model="textAsset.text" /></label>
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
<label>{{ t('properties.text.text') }}<input type="text" v-model="textAsset.text" /></label>
<label>{{ t('properties.text.fontSize') }}<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
<label>
{{ t('properties.text.font') }}
<FontPicker v-model="textFontModel" />
</label>
</div>
</section>
@@ -532,6 +824,16 @@ function deleteSelection() {
@input="fillColor = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ fillTransparent ? '—' : fillColor.toUpperCase() }}</span>
<button
v-if="eyeDropperSupported"
type="button"
class="icon-toggle eyedropper-btn"
:disabled="fillTransparent"
v-tooltip="t('properties.eyedropper')"
@click="pickFillColor"
>
<PhEyedropper :size="13" />
</button>
<button
type="button"
class="icon-toggle alpha-toggle-btn"
@@ -554,6 +856,16 @@ function deleteSelection() {
@input="strokeColor = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ strokeTransparent ? '—' : strokeColor.toUpperCase() }}</span>
<button
v-if="eyeDropperSupported"
type="button"
class="icon-toggle eyedropper-btn"
:disabled="strokeTransparent"
v-tooltip="t('properties.eyedropper')"
@click="pickStrokeColor"
>
<PhEyedropper :size="13" />
</button>
<button
type="button"
class="icon-toggle alpha-toggle-btn"
@@ -572,7 +884,7 @@ function deleteSelection() {
:min="0"
:max="32"
:step="0.5"
:format="(v: number) => `${v}px`"
:format="(v: number) => `${Math.round(v * 2) / 2}px`"
/>
</label>
<label class="slider-label">
@@ -608,6 +920,15 @@ function deleteSelection() {
@input="shadowColorValue = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ shadowColorValue.toUpperCase() }}</span>
<button
v-if="eyeDropperSupported"
type="button"
class="icon-toggle eyedropper-btn"
v-tooltip="t('properties.eyedropper')"
@click="pickShadowColor"
>
<PhEyedropper :size="13" />
</button>
</div>
</label>
<label class="slider-label">
@@ -698,6 +1019,107 @@ function deleteSelection() {
</div>
</section>
<section v-if="canKaleido && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('effects')">
<component :is="sectionOpen.effects ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.effects') }}</span>
</button>
<div v-if="sectionOpen.effects" class="group-body">
<!-- Wiggle -->
<div class="effect-block">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.wiggle.toggle') }}</span>
<button
type="button"
class="icon-toggle"
:class="{ active: wiggleOn }"
v-tooltip="t('properties.wiggle.toggle')"
@click="setWiggleOn(!wiggleOn)"
>
<PhWaveSine :size="14" :weight="wiggleOn ? 'fill' : 'regular'" />
</button>
</div>
<label v-if="wiggleOn" class="slider-label">
{{ t('properties.wiggle.amplitude') }}
<RangeSlider
v-model="wiggleAmplitude"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<label v-if="wiggleOn" class="slider-label">
{{ t('properties.wiggle.rotation') }}
<RangeSlider
v-model="wiggleRotation"
:min="0"
:max="45"
:step="0.5"
:format="(v: number) => `${Math.round(v * 2) / 2}°`"
/>
</label>
<label v-if="wiggleOn" class="slider-label">
{{ t('properties.wiggle.frequency') }}
<RangeSlider
v-model="wiggleFrequency"
:min="0"
:max="10"
:step="0.1"
:format="(v: number) => `${Math.round(v * 10) / 10} Hz`"
/>
</label>
<p v-if="wiggleOn" class="hint">{{ t('properties.wiggle.hint') }}</p>
</div>
<!-- Kaleidoscope -->
<div class="effect-block">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.kaleido.toggle') }}</span>
<button
type="button"
class="icon-toggle"
:class="{ active: kaleidoOn }"
v-tooltip="t('properties.kaleido.toggle')"
@click="setKaleidoOn(!kaleidoOn)"
>
<PhFlower :size="14" :weight="kaleidoOn ? 'fill' : 'regular'" />
</button>
</div>
<label v-if="kaleidoOn">
{{ t('properties.kaleido.repeat') }}
<input
type="number"
min="2"
max="64"
step="1"
:value="kaleidoCountValue"
@input="onKaleidoCountInput($event)"
/>
</label>
<div v-if="kaleidoOn" class="row">
<label>
{{ t('properties.kaleido.pivotX') }}
<input
type="number"
:value="kaleidoPivotX"
@input="setKaleidoPivot({ x: Number(($event.target as HTMLInputElement).value) })"
/>
</label>
<label>
{{ t('properties.kaleido.pivotY') }}
<input
type="number"
:value="kaleidoPivotY"
@input="setKaleidoPivot({ y: Number(($event.target as HTMLInputElement).value) })"
/>
</label>
</div>
<p v-if="kaleidoOn" class="hint">{{ t('properties.kaleido.hint') }}</p>
</div>
</div>
</section>
<section v-if="buttonAsset && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('button')">
<component :is="sectionOpen.button ? PhCaretDown : PhCaretRight" :size="11" />
@@ -720,6 +1142,79 @@ function deleteSelection() {
}}
</button>
</template>
<template v-else-if="selectedClipInstance">
<div class="multi-banner">{{ t('properties.clipInstance.banner') }}</div>
<section class="prop-group">
<button class="group-header" @click="toggleSection('effects')">
<component :is="sectionOpen.effects ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.effects') }}</span>
</button>
<div v-if="sectionOpen.effects" class="group-body">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.kaleido.toggle') }}</span>
<button
type="button"
class="icon-toggle"
:class="{ active: ciKaleidoOn }"
v-tooltip="t('properties.kaleido.toggle')"
@click="setCiKaleido({ kaleido: !ciKaleidoOn })"
>
<PhFlower :size="14" :weight="ciKaleidoOn ? 'fill' : 'regular'" />
</button>
</div>
<label v-if="ciKaleidoOn">
{{ t('properties.kaleido.repeat') }}
<input
type="number"
min="2"
max="64"
step="1"
:value="ciKaleidoCount"
@input="setCiKaleido({ kaleidoCount: Number(($event.target as HTMLInputElement).value) })"
/>
</label>
<label v-if="ciKaleidoOn">
{{ t('properties.kaleido.offset') }} ({{ unitLabel }})
<input
type="number"
:step="inputStep"
:value="ciKaleidoOffsetInput"
@input="setCiKaleidoOffset(Number(($event.target as HTMLInputElement).value))"
/>
</label>
<div v-if="ciKaleidoOn" class="row">
<label>
{{ t('properties.kaleido.pivotX') }}
<input
type="number"
:value="ciPivotX"
@input="setCiKaleido({ kaleidoPivotX: Number(($event.target as HTMLInputElement).value) })"
/>
</label>
<label>
{{ t('properties.kaleido.pivotY') }}
<input
type="number"
:value="ciPivotY"
@input="setCiKaleido({ kaleidoPivotY: Number(($event.target as HTMLInputElement).value) })"
/>
</label>
</div>
<div v-if="ciKaleidoOn" class="toggle-row">
<span class="toggle-label">{{ t('properties.kaleido.rotateCopies') }}</span>
<button
type="button"
class="icon-toggle"
:class="{ active: ciRotateCopies }"
@click="setCiKaleido({ kaleidoRotateCopies: !ciRotateCopies })"
>
<PhFlower :size="14" :weight="ciRotateCopies ? 'fill' : 'regular'" />
</button>
</div>
<p v-if="ciKaleidoOn" class="hint">{{ t('properties.kaleido.hintClip') }}</p>
</div>
</section>
</template>
<p v-else-if="selected && editableLayers.length === 0" class="hint">
{{ t('properties.lockedLayer') }}
</p>
@@ -766,6 +1261,20 @@ input {
letter-spacing: 0;
}
/* Number inputs hold short numeric values. Capping the width keeps
them from stretching to the panel's full ~260px and aligns the
panel visually with the more compact slider controls. Inputs
inside a `.row` (two side-by-side labels) still split that row
evenly because their label parent is `flex: 1 1 0`. */
input[type='number'] {
max-width: 96px;
font-variant-numeric: tabular-nums;
}
.row input[type='number'] {
max-width: none;
}
.row {
display: flex;
gap: 8px;
@@ -818,6 +1327,21 @@ input {
padding: 0 2px 10px;
}
/* Sub-block inside the Effects section. The thin top border visually
separates Wiggle from Kaleidoscope without forcing a second
collapsible header — they share one section but each toggles
independently. */
.effect-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.effect-block + .effect-block {
padding-top: 10px;
border-top: 1px solid var(--border);
}
.toggle-row {
display: flex;
align-items: center;
+12 -4
View File
@@ -6,6 +6,9 @@ import { t } from '@/i18n'
import { Modal } from '@/components/ui'
import ActionEditor from './ActionEditor.vue'
import type { Action } from '@/types/scene'
import { useTimeFormat } from '@/composables/useTimeFormat'
const { timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
const props = defineProps<{
modelValue: boolean
@@ -73,13 +76,13 @@ function removeFrame() {
<div v-if="frame" class="body">
<div class="row">
<label>
{{ t('scriptFrameDialog.time') }}
{{ t('scriptFrameDialog.time') }} ({{ unitLabel }})
<input
type="number"
step="0.01"
:step="inputStep"
min="0"
:value="localTime"
@input="localTime = Number(($event.target as HTMLInputElement).value)"
:value="timeToInputValue(localTime)"
@input="localTime = inputValueToSeconds(Number(($event.target as HTMLInputElement).value))"
/>
</label>
<label class="grow">
@@ -144,6 +147,11 @@ function removeFrame() {
font-family: inherit;
}
.row input[type='number'] {
width: 96px;
font-variant-numeric: tabular-nums;
}
.btn {
display: inline-flex;
align-items: center;
+6 -1
View File
@@ -77,7 +77,7 @@ async function addText() {
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
text,
fontSize: 48,
fill: '#f5d76e',
fill: '#000000',
})
store.addLayer(asset.id)
}
@@ -301,6 +301,7 @@ function onDragEnd() {
flex: 1 1 0;
min-width: 0;
height: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -409,6 +410,10 @@ function onDragEnd() {
.row-btn {
width: 22px;
height: 22px;
/* Override the global `button { padding: 5px 10px }` reset —
with box-sizing:border-box that padding eats the entire 22×22
box and clips the icon out of view ("icons missing"). */
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
+301 -16
View File
@@ -16,6 +16,7 @@ import {
PhLockOpen,
PhEye,
PhEyeSlash,
PhFrameCorners,
PhMagnet,
PhMaskHappy,
PhCircle,
@@ -39,6 +40,7 @@ import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDra
import { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource'
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
import { sampleLayer } from '@/lib/interpolate'
import { sampleCamera } from '@/lib/camera'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog, promptDialog } from '@/components/ui'
import ScriptFrameDialog from './ScriptFrameDialog.vue'
@@ -347,6 +349,9 @@ function allKeyframeTimes(ignoreKeyframeId?: string): number[] {
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
}
for (const k of store.scene.camera?.keyframes ?? []) {
if (k.id !== ignoreKeyframeId) out.push(k.time)
}
return out
}
@@ -786,6 +791,138 @@ function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
store.upsertKeyframe(track.layerId, time, props)
}
const cameraTimelineVisible = computed(
() =>
!store.activeClipId &&
!store.activeButtonAssetId &&
!store.activeSvgAssetId &&
!!store.scene.camera,
)
function onCameraTrackClick() {
store.selectCamera(true)
}
function onCameraLaneDblClick(e: MouseEvent) {
e.stopPropagation()
const camera = store.scene.camera
if (!camera || camera.locked) return
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const time = snapTime(ratio * duration.value)
store.setPlayhead(time)
store.selectCamera(true)
store.upsertCameraKeyframe(time, sampleCamera(store.scene, time))
}
function onCameraKeyframeClick(e: MouseEvent, time: number) {
e.stopPropagation()
if (cameraKfDrag.value?.moved) {
cameraKfDrag.value = null
return
}
cameraKfDrag.value = null
store.selectCamera(true)
store.setPlayhead(time)
}
function onCameraKeyframeDblClick(e: MouseEvent, keyframeId: string) {
e.stopPropagation()
const camera = store.scene.camera
if (!camera || camera.locked) return
store.removeCameraKeyframe(keyframeId)
}
function onCameraKeyframeContextMenu(e: MouseEvent, keyframeId: string) {
e.preventDefault()
e.stopPropagation()
const camera = store.scene.camera
if (!camera || camera.locked) return
const kf = camera.keyframes.find((item) => item.id === keyframeId)
if (!kf) return
const sorted = camera.keyframes.slice().sort((a, b) => a.time - b.time)
menu.value = {
target: 'camera',
keyframeId: kf.id,
time: kf.time,
x: e.clientX,
y: e.clientY,
currentEasing: kf.easing,
isFirst: sorted[0]?.id === kf.id,
isLast: sorted[sorted.length - 1]?.id === kf.id,
isHeld: !!kf.hold,
isEnd: false,
hasEnd: false,
}
store.selectCamera(true)
}
interface CameraKfDrag {
keyframeId: string
lane: HTMLElement
pointerId: number
startX: number
moved: boolean
initialTime: number
}
const cameraKfDrag = ref<CameraKfDrag | null>(null)
function onCameraKeyframePointerDown(e: PointerEvent, keyframeId: string) {
if (e.button !== 0) return
const camera = store.scene.camera
if (!camera || camera.locked) return
const kfEl = e.currentTarget as HTMLElement
const lane = kfEl.parentElement
const kf = camera.keyframes.find((item) => item.id === keyframeId)
if (!lane || !kf) return
e.stopPropagation()
kfEl.setPointerCapture(e.pointerId)
store.selectCamera(true)
cameraKfDrag.value = {
keyframeId,
lane,
pointerId: e.pointerId,
startX: e.clientX,
moved: false,
initialTime: kf.time,
}
}
function onCameraKeyframePointerMove(e: PointerEvent) {
const d = cameraKfDrag.value
if (!d || d.pointerId !== e.pointerId) return
if (!d.moved && Math.abs(e.clientX - d.startX) < 3) return
d.moved = true
const rect = d.lane.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const rawNewTime = ratio * duration.value
const newTime = snapToCandidates(
rawNewTime,
[...allKeyframeTimesExcluding(new Set([d.keyframeId])), store.playhead],
{
enabled: snapEnabled.value,
pxPerSec: effectivePxPerSec.value,
pixelThreshold: SNAP_PX,
duration: duration.value,
},
)
store.setCameraKeyframeTime(d.keyframeId, newTime)
store.setPlayhead(newTime)
}
function onCameraKeyframePointerUp(e: PointerEvent) {
const d = cameraKfDrag.value
if (!d || d.pointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
if (!d.moved) cameraKfDrag.value = null
}
function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
e.stopPropagation()
if (kfDrag.value?.moved) {
@@ -1262,6 +1399,9 @@ function allKeyframeTimesExcluding(ignoreIds: Set<string>): number[] {
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
}
for (const k of store.scene.camera?.keyframes ?? []) {
if (!ignoreIds.has(k.id)) out.push(k.time)
}
return out
}
@@ -1374,6 +1514,14 @@ interface KfSegment {
tail?: boolean
}
interface KfSegmentSource {
id: string
time: number
easing: Easing
hold?: boolean
kind?: 'normal' | 'end'
}
/** Render-time filter for the kf v-for. Hides end kfs that share a
* time slot with a normal kf — common after the end-kf migration
* on legacy projects, where stacking a square (end) on top of a
@@ -1391,7 +1539,7 @@ function displayKfs(kfs: Keyframe[]): Keyframe[] {
})
}
function segmentsOf(kfs: Keyframe[], duration: number, buttonMode = false): KfSegment[] {
function segmentsOf(kfs: KfSegmentSource[], duration: number, buttonMode = false): KfSegment[] {
if (kfs.length === 0) return []
// End kfs are visibility markers, not interpolation participants.
// Pull them out for tail computation; everything else flows through
@@ -1527,7 +1675,8 @@ const EASING_OPTIONS: { value: Easing; label: string }[] = [
]
interface KfMenuState {
layerId: string
target: 'layer' | 'camera'
layerId?: string
keyframeId: string
time: number
x: number
@@ -1566,6 +1715,7 @@ function onKeyframeContextMenu(
.sort((a, b) => a.time - b.time)
const hasEnd = layer.keyframes.some((k) => k.kind === 'end')
menu.value = {
target: 'layer',
layerId,
keyframeId: kf.id,
time: kf.time,
@@ -1583,7 +1733,11 @@ function onKeyframeContextMenu(
function toggleHold() {
if (!menu.value) return
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
if (menu.value.target === 'camera') {
store.setCameraKeyframeHold(menu.value.keyframeId, !menu.value.isHeld)
} else if (menu.value.layerId) {
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
}
menu.value = null
}
@@ -1595,7 +1749,7 @@ function toggleHold() {
* we delete the existing one before promoting the new one. */
function toggleEndKind() {
const m = menu.value
if (!m) return
if (!m || m.target !== 'layer' || !m.layerId) return
const layer = store.activeLayers.find((l) => l.id === m.layerId)
if (!layer) return
const kf = layer.keyframes.find((k) => k.id === m.keyframeId)
@@ -1619,21 +1773,30 @@ function toggleEndKind() {
function applyEasing(easing: Easing) {
if (!menu.value) return
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
if (menu.value.target === 'camera') {
store.setCameraKeyframeEasing(menu.value.keyframeId, easing)
} else if (menu.value.layerId) {
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
}
menu.value = null
}
function deleteFromMenu() {
if (!menu.value) return
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
if (menu.value.target === 'camera') {
store.removeCameraKeyframe(menu.value.keyframeId)
} else if (menu.value.layerId) {
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
}
menu.value = null
}
function duplicateFromMenu() {
if (!menu.value) return
const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId)
if (!menu.value || menu.value.target !== 'layer' || !menu.value.layerId) return
const layerId = menu.value.layerId
const newId = store.duplicateKeyframe(layerId, menu.value.keyframeId)
if (newId) {
const layer = store.activeLayers.find((l) => l.id === menu.value!.layerId)
const layer = store.activeLayers.find((l) => l.id === layerId)
const all = layer
? [
...layer.keyframes,
@@ -2169,7 +2332,7 @@ function audioClipBlockStyle(clip: AudioClip) {
function audioClipTooltip(clip: AudioClip): string {
const asset = audioAssetById.value.get(clip.audioAssetId)
if (!asset) return ''
return `${asset.name} · ${audibleDurationOf(clip).toFixed(2)}s`
return `${asset.name} · ${formatKfTime(audibleDurationOf(clip))}`
}
/** Resolved peaks for a clip's asset — undefined while the decoder
@@ -2664,10 +2827,10 @@ function onScriptLaneClick(e: MouseEvent, trackId: string) {
function trimTooltip(ci: ClipInstance): string {
const name = clipById.value.get(ci.clipId)?.name ?? '?'
const dur = visibleDurationOf(ci).toFixed(2)
const dur = formatKfTime(visibleDurationOf(ci))
const trims: string[] = []
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: (ci.trimStart ?? 0).toFixed(2) }))
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: (ci.trimEnd ?? 0).toFixed(2) }))
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: formatKfTime(ci.trimStart ?? 0) }))
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: formatKfTime(ci.trimEnd ?? 0) }))
const trimsStr = trims.length ? ` · ${trims.join(' · ')}` : ''
return t('timeline.trimTooltip', { name, duration: dur, trims: trimsStr })
}
@@ -2787,10 +2950,56 @@ const trackMenuRef = ref<HTMLDivElement | null>(null)
function onLayerTrackNameClick(e: MouseEvent, track: TimelineLayerTrack) {
if (isTrackEffectivelyLocked(track)) return
if (e.shiftKey && track.isParent) {
if (!track.isParent) {
// Sub-tracks (separated position / rotation lanes) can't carry
// their own selection — just route to the parent layer.
store.selectLayer(track.layerId)
return
}
// Ctrl / Cmd → toggle this layer in/out of the selection.
if (e.ctrlKey || e.metaKey) {
store.toggleLayerSelection(track.layerId)
return
}
// Shift → range select: every parent-layer track between the
// current anchor (selectedLayerId) and the clicked one in the
// visible timeline order joins the selection.
if (e.shiftKey) {
const anchorId = store.selectedLayerId
if (!anchorId || anchorId === track.layerId) {
store.selectLayer(track.layerId)
return
}
const layerOrder: string[] = []
for (const tr of tracks.value) {
if (tr.type === 'layer' && tr.isParent) layerOrder.push(tr.layerId)
}
const a = layerOrder.indexOf(anchorId)
const b = layerOrder.indexOf(track.layerId)
if (a < 0 || b < 0) {
store.selectLayer(track.layerId)
return
}
const [from, to] = a <= b ? [a, b] : [b, a]
const range = layerOrder.slice(from, to + 1)
// Skip layers the user can't actually act on (locked or inside
// a locked parent group). selectLayer / toggle paths skip these
// too, so the range stays consistent with the rest of the UX.
const usable = range.filter((id) => {
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || layer.locked) return false
if (lockedGroupChildIds.value.has(id)) return false
return true
})
if (!usable.length) return
store.selectedLayerIds = usable
// Anchor moves to the most-recently clicked id so the next
// shift-click extends from there. Matches typical file-manager
// shift-select behaviour.
store.selectedLayerId = track.layerId
store.selectedClipInstanceId = null
return
}
store.selectLayer(track.layerId)
}
@@ -3488,6 +3697,81 @@ onBeforeUnmount(() => {
@pointercancel="onTracksPointerUp"
>
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div>
<div
v-if="cameraTimelineVisible"
class="track camera-track"
:class="{ inactive: !store.selectedCamera, locked: !!store.scene.camera?.locked }"
>
<span
class="track-name parent"
:class="{ selected: store.selectedCamera }"
@click="onCameraTrackClick"
>
<PhFrameCorners :size="12" class="name-badge" />
<span class="track-label">{{ t('timeline.cameraTrackName') }}</span>
<button
class="eye-btn"
:class="{ hidden: store.scene.camera?.overlayVisible === false }"
v-tooltip="
store.scene.camera?.overlayVisible === false
? t('timeline.showInEditor')
: t('timeline.hideInEditor')
"
@click.stop="store.setCameraOverlayVisible(store.scene.camera?.overlayVisible === false)"
@mousedown.stop
>
<component :is="store.scene.camera?.overlayVisible === false ? PhEyeSlash : PhEye" :size="12" />
</button>
<button
class="lock-btn"
:class="{ locked: store.scene.camera?.locked }"
v-tooltip="store.scene.camera?.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setCameraLocked(!store.scene.camera?.locked)"
@mousedown.stop
>
<span class="lock-icon rest">
<component :is="store.scene.camera?.locked ? PhLock : PhLockOpen" :size="12" />
</span>
<span class="lock-icon alt">
<component
:is="store.scene.camera?.locked ? PhLockOpen : PhLock"
:size="12"
:weight="store.scene.camera?.locked ? 'fill' : 'regular'"
/>
</span>
</button>
</span>
<div
class="track-lane camera-lane"
:style="{ width: laneWidth + 'px' }"
@click="onLaneClick"
@dblclick="onCameraLaneDblClick"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="seg in segmentsOf(store.scene.camera?.keyframes ?? [], duration)"
:key="`camera-${seg.id}`"
class="kf-seg"
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
:style="segmentStyle(seg)"
/>
<span
v-for="kf in store.scene.camera?.keyframes ?? []"
:key="kf.id"
class="kf camera-kf"
:class="{ hold: kf.hold }"
:style="{ left: pxFromTime(kf.time) + 'px' }"
v-tooltip="t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })"
@pointerdown="onCameraKeyframePointerDown($event, kf.id)"
@pointermove="onCameraKeyframePointerMove"
@pointerup="onCameraKeyframePointerUp"
@pointercancel="onCameraKeyframePointerUp"
@click="onCameraKeyframeClick($event, kf.time)"
@dblclick="onCameraKeyframeDblClick($event, kf.id)"
@contextmenu="onCameraKeyframeContextMenu($event, kf.id)"
/>
</div>
</div>
<template v-for="(track, idx) in tracks" :key="
track.type === 'layer'
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
@@ -4215,6 +4499,7 @@ onBeforeUnmount(() => {
<div class="kf-menu-divider"></div>
</template>
<button
v-if="menu.target === 'layer'"
class="kf-menu-item"
:class="{ active: menu.isEnd }"
@click="toggleEndKind"
@@ -4228,8 +4513,8 @@ onBeforeUnmount(() => {
class="kf-menu-trailing-check"
/>
</button>
<div class="kf-menu-divider"></div>
<button v-if="!menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu">
<div v-if="menu.target === 'layer'" class="kf-menu-divider"></div>
<button v-if="menu.target === 'layer' && !menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
+5 -46
View File
@@ -21,7 +21,7 @@ import {
pickBackupFile,
readBackupFile,
} from '@/lib/backupFile'
import { confirmDialog, Modal } from '@/components/ui'
import { confirmDialog, Modal, ToggleSwitch } from '@/components/ui'
import { showWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { t } from '@/i18n'
@@ -366,9 +366,10 @@ watch(
<div v-if="backupError" class="backup-error">{{ backupError }}</div>
<template #footer>
<label class="ftr-toggle" v-tooltip="t('welcome.showOnStartHelp')">
<span class="switch sm" :class="{ on: showWelcomeOnStart }">
<input type="checkbox" v-model="showWelcomeOnStart" />
</span>
<ToggleSwitch
v-model="showWelcomeOnStart"
:aria-label="t('welcome.showOnStart')"
/>
<span class="ftr-toggle-label">{{ t('welcome.showOnStart') }}</span>
</label>
<span class="ftr-sep"></span>
@@ -676,8 +677,6 @@ watch(
border-radius: var(--radius-sm);
}
/* Compact footer toggle — same visual language as the other toggles
in the app but sized for the row. */
.ftr-toggle {
display: inline-flex;
align-items: center;
@@ -691,44 +690,4 @@ watch(
.ftr-toggle-label {
white-space: nowrap;
}
.switch.sm {
position: relative;
display: inline-block;
width: 28px;
height: 16px;
background: var(--border);
border-radius: 999px;
flex-shrink: 0;
transition: background 140ms var(--ease);
}
.switch.sm input {
opacity: 0;
width: 100%;
height: 100%;
margin: 0;
cursor: pointer;
}
.switch.sm::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.switch.sm.on {
background: var(--accent);
}
.switch.sm.on::after {
transform: translateX(12px);
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
defineProps<{
modelValue: boolean
label?: string
ariaLabel?: string
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
function onChange(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).checked)
}
</script>
<template>
<span class="ui-switch thumbs-switch" :class="{ disabled }">
<span v-if="$slots.default || label" class="ui-switch-label thumbs-switch-label">
<slot>{{ label }}</slot>
</span>
<input
type="checkbox"
class="ui-switch-input thumbs-switch-input"
:checked="modelValue"
:disabled="disabled"
:aria-label="ariaLabel || label"
@change="onChange"
/>
<span class="ui-switch-track">
<span class="ui-switch-knob"></span>
</span>
</span>
</template>
<style scoped>
.ui-switch {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
color: var(--text-dim);
cursor: pointer;
font-size: 11px;
line-height: 1;
user-select: none;
-webkit-user-select: none;
}
.ui-switch.disabled {
cursor: not-allowed;
}
.ui-switch-label {
font-weight: 500;
letter-spacing: 0.01em;
}
.ui-switch-input {
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.ui-switch-track {
position: relative;
z-index: 1;
width: 26px;
height: 14px;
border-radius: 999px;
background: var(--border);
transition: background 140ms var(--ease);
flex-shrink: 0;
}
.ui-switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--panel);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
transition: left 140ms var(--ease), background 140ms var(--ease);
}
.ui-switch-input:checked + .ui-switch-track {
background: var(--accent);
}
.ui-switch-input:checked + .ui-switch-track .ui-switch-knob {
left: 14px;
background: #fff;
}
.ui-switch-input:focus-visible + .ui-switch-track {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.ui-switch:hover:not(.disabled) .ui-switch-track {
filter: brightness(1.08);
}
</style>
+1
View File
@@ -2,5 +2,6 @@ export { default as DialogsHost } from './DialogsHost.vue'
export { default as IconButton } from './IconButton.vue'
export { default as Modal } from './Modal.vue'
export { default as Toast } from './Toast.vue'
export { default as ToggleSwitch } from './ToggleSwitch.vue'
export { confirmDialog, dialogState, promptDialog } from './dialogs'
export type { ConfirmOptions, PromptOptions } from './dialogs'
+1
View File
@@ -12,6 +12,7 @@ export function useDeselectAll() {
return function deselectAll() {
store.selectLayer(null)
store.selectClipInstance(null)
store.selectCamera(false)
selectedKfs.value = []
}
}
+40
View File
@@ -0,0 +1,40 @@
// Wraps the browser's EyeDropper API (Chromium 95+). Provides a
// reactive `supported` flag plus a `pick()` helper that resolves to
// the chosen sRGBHex string (or null on cancel/abort). Callers
// should hide the pipette button when `supported` is false to keep
// Safari + Firefox UX clean.
import { ref } from 'vue'
interface EyeDropperResult {
sRGBHex: string
}
interface EyeDropperConstructor {
new (): { open(opts?: { signal?: AbortSignal }): Promise<EyeDropperResult> }
}
function detectSupport(): boolean {
return (
typeof window !== 'undefined' &&
typeof (window as unknown as { EyeDropper?: unknown }).EyeDropper === 'function'
)
}
export function useEyeDropper() {
// Re-detect on every call so a test (or a polyfill that loads
// late) can see updates without juggling Vite's module cache.
const supported = ref(detectSupport())
async function pick(): Promise<string | null> {
if (!detectSupport()) return null
const Ctor = (window as unknown as { EyeDropper: EyeDropperConstructor }).EyeDropper
try {
const result = await new Ctor().open()
return result.sRGBHex
} catch {
// User pressed Esc / dismissed the picker — treat as cancel.
return null
}
}
return { supported, pick }
}
@@ -0,0 +1,27 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'animatr.fullscreenPreviewAutoplay'
function read(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === 'true'
} catch {
return false
}
}
// Module-level shared ref so settings and fullscreen preview use the
// same preference without prop drilling through the app shell.
export const fullscreenPreviewAutoplay = ref<boolean>(read())
watch(fullscreenPreviewAutoplay, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
export function useFullscreenPreviewAutoplay() {
return { enabled: fullscreenPreviewAutoplay }
}
+27
View File
@@ -0,0 +1,27 @@
import { ref, watch } from 'vue'
const KEY = 'animatr:showMotionPaths'
function read(): boolean {
try {
const v = localStorage.getItem(KEY)
return v === null ? false : v === '1'
} catch {
return false
}
}
/** Module-level shared ref. When true, the canvas paints the
* position-keyframe path of the currently-selected layer (or
* layers) as a dashed polyline with a dot per keyframe — even
* when no keyframe is explicitly selected. Defaults to off so it
* doesn't clutter the canvas for users who never wanted it. */
export const showMotionPaths = ref(read())
watch(showMotionPaths, (v) => {
try {
localStorage.setItem(KEY, v ? '1' : '0')
} catch {
/* ignore */
}
})
+79
View File
@@ -0,0 +1,79 @@
// Single source of truth for "how do we format / parse a time?".
// The Timeline ruler already exposes a seconds/frames toggle backed
// by `store.editor.timelineDisplayMode`. This composable surfaces
// that mode globally so headers, dialogs, properties, tooltips and
// every other time display follows the user's choice — keeping them
// in sync with the ruler instead of forcing the user to think in two
// units at once.
//
// Storage stays in seconds everywhere (renderer + .ruw files).
// Conversion happens at the UI boundary using `scene.fps`.
import { computed } from 'vue'
import { useSceneStore } from '@/stores/scene'
export type TimeDisplayMode = 'seconds' | 'frames'
export function useTimeFormat() {
const store = useSceneStore()
const mode = computed<TimeDisplayMode>(() => store.editor.timelineDisplayMode)
const fps = computed(() => (store.scene.fps > 0 ? store.scene.fps : 60))
/** seconds → frames (rounded to int). */
const secondsToFrames = (s: number): number => Math.round(s * fps.value)
/** frames → seconds. */
const framesToSeconds = (f: number): number => f / fps.value
/** Format a time for display. In frames mode emits e.g. `"42 fr"`,
* in seconds mode `"1.42s"`. Pass `{ short: true }` to drop the
* trailing zeros for ruler-style ticks ("1s" instead of "1.00s"). */
function formatTime(
seconds: number,
opts: { short?: boolean; decimals?: number } = {},
): string {
if (mode.value === 'frames') return `${secondsToFrames(seconds)} fr`
const decimals = opts.decimals ?? 2
if (opts.short) {
const fixed = seconds.toFixed(decimals)
return `${fixed.replace(/0+$/, '').replace(/\.$/, '')}s`
}
return `${seconds.toFixed(decimals)}s`
}
/** Number suitable to plug into an `<input type="number" :value>`.
* In frames mode returns an integer (frames). In seconds mode
* returns a 2-decimal-rounded number so the input doesn't show
* e.g. `1.4181818181`. */
function timeToInputValue(seconds: number): number {
if (mode.value === 'frames') return secondsToFrames(seconds)
return Math.round(seconds * 100) / 100
}
/** Inverse of `timeToInputValue` — read back what the user typed
* into a time input and convert to seconds for storage. */
function inputValueToSeconds(value: number): number {
if (!Number.isFinite(value)) return 0
if (mode.value === 'frames') return framesToSeconds(Math.round(value))
return value
}
/** Sensible step + decimal placement for an `<input type="number">`
* bound to a time. Frames mode: step 1, integer. Seconds: 0.01. */
const inputStep = computed(() => (mode.value === 'frames' ? '1' : '0.01'))
/** Unit suffix for labels (`"frames"` or `"seconds"`). */
const unitLabel = computed(() => (mode.value === 'frames' ? 'frames' : 'seconds'))
return {
mode,
fps,
secondsToFrames,
framesToSeconds,
formatTime,
timeToInputValue,
inputValueToSeconds,
inputStep,
unitLabel,
}
}
+53
View File
@@ -0,0 +1,53 @@
import { useI18n } from '@/i18n'
import { useSceneStore } from '@/stores/scene'
import { runAll, type TestResult } from './testRunner'
import { registerAllTests } from './tests'
declare global {
interface Window {
__ruwioTestResults?: TestResult[]
}
}
function shouldRunSuite(name: string, desktop: boolean): boolean {
if (name.startsWith('desktop:')) return desktop
if (name.startsWith('browser:')) return !desktop
return true
}
export async function runHeadlessTests(): Promise<TestResult[]> {
const desktop = !!window.ruwioDesktop
const store = useSceneStore()
const { locale } = useI18n()
const snapshot = JSON.parse(JSON.stringify(store.$state))
const previousLocale = locale.value
document.body.setAttribute('data-tests-done', 'running')
document.body.setAttribute('data-test-host', desktop ? 'electron' : 'browser')
locale.value = 'en'
try {
const results = await runAll(registerAllTests, {
interTestDelayMs: 0,
suiteFilter: (name) => shouldRunSuite(name, desktop),
})
window.__ruwioTestResults = results
document.body.setAttribute('data-tests-done', results.some((r) => !r.ok) ? 'fail' : 'pass')
return results
} catch (err) {
const result: TestResult = {
suite: 'headless',
name: 'runAll',
ok: false,
error: (err as Error).message || String(err),
stack: (err as Error).stack,
durationMs: 0,
}
window.__ruwioTestResults = [result]
document.body.setAttribute('data-tests-done', 'fail')
return [result]
} finally {
store.$state = snapshot
locale.value = previousLocale
}
}
+4 -2
View File
@@ -133,6 +133,7 @@ export interface RunOptions {
onTestStart?: (suite: string, name: string) => void
onResult?: (result: TestResult) => void
interTestDelayMs?: number
suiteFilter?: (suite: string) => boolean
}
export async function runAll(
@@ -141,11 +142,12 @@ export async function runAll(
): Promise<TestResult[]> {
suites.length = 0
register()
const total = suites.reduce((a, s) => a + s.tests.length, 0)
const runnableSuites = opts.suiteFilter ? suites.filter((s) => opts.suiteFilter?.(s.name)) : suites
const total = runnableSuites.reduce((a, s) => a + s.tests.length, 0)
const results: TestResult[] = []
let done = 0
const delay = Math.max(0, opts.interTestDelayMs ?? 0)
for (const s of suites) {
for (const s of runnableSuites) {
for (const t of s.tests) {
opts.onTestStart?.(s.name, t.name)
await new Promise((r) => requestAnimationFrame(() => r(null)))
+2597 -13
View File
File diff suppressed because it is too large. Load diff
+98 -20
View File
@@ -6,6 +6,7 @@ export const en = {
pause: 'Pause (Space)',
play: 'Play (Space)',
stop: 'Stop',
fullscreenPlayback: 'Fullscreen playback (Shift+Space)',
recentProjects: 'Recent projects',
copyStill: {
label: 'Copy current frame',
@@ -39,8 +40,10 @@ export const en = {
untitledProject: 'Untitled project',
saveProjectDirtyHelp:
'Your project has unsaved changes. Ctrl/Cmd+S writes them to browser storage.',
exportHelp:
'Save as runtime script, HTML, or video. MP4 needs the render server; WebM records in real time in the browser.',
exportHelpServer:
'Save as runtime script, HTML, or video. MP4 uses the render server; WebM records in real time with MediaRecorder.',
exportHelpDesktop:
"Save as runtime script, HTML, or video. MP4 renders in the desktop app with bundled ffmpeg; WebM records in Electron's Chromium runtime.",
replaceDialog: {
title: 'Replace project?',
message:
@@ -191,8 +194,18 @@ export const en = {
noLayer: 'No layer selected.',
lockedLayer:
'Selected layer is locked. Unlock the track (or its group) to edit properties.',
camera: {
banner: 'Output camera selected',
frame: 'Camera Frame',
enabled: 'Enabled',
overlay: 'Overlay',
locked: 'Locked',
width: 'Width',
height: 'Height',
},
fill: 'Fill',
transparent: 'Transparent',
eyedropper: 'Pick a colour from anywhere on the page',
opacity: 'Opacity',
edgeBlur: 'Edge blur',
sections: {
@@ -201,6 +214,32 @@ export const en = {
polygon: 'Polygon',
appearance: 'Appearance',
button: 'On Click',
effects: 'Effects',
},
text: {
text: 'Text',
fontSize: 'Font Size',
font: 'Font',
},
kaleido: {
toggle: 'Kaleido',
repeat: 'Repeat',
offset: 'Start offset',
rotateCopies: 'Rotate copies outward',
pivotX: 'Pivot X',
pivotY: 'Pivot Y',
hint: 'Renders the layer N times around its pivot.',
hintClip: 'Renders the clip N times around the pivot. Each copy starts at a different point in the clip when "Start offset" is set.',
},
clipInstance: {
banner: 'Clip instance — only kaleidoscope is editable here. Use the canvas to move/resize.',
},
wiggle: {
toggle: 'Wiggle',
amplitude: 'Position amplitude',
rotation: 'Rotation amplitude',
frequency: 'Frequency',
hint: 'Adds procedural shake on top of the keyframe sample. Deterministic per layer — same time always gives the same wobble.',
},
polygonPoints: 'Points',
polygonPointsHint: 'Changing the point count rewrites all keyframes; new points are inserted at the midpoint of the last edge.',
@@ -310,6 +349,7 @@ export const en = {
renameGroup: 'Rename group',
renameScriptTrack: 'Rename script track',
resizeTrackColumn: 'Drag to resize track name column',
cameraTrackName: 'Camera',
clipTrackName: 'Clip track',
toggleTimeUnit: 'Toggle time unit',
snapOn: 'Snapping on (playhead & keyframes snap together)',
@@ -373,6 +413,12 @@ export const en = {
stagePreviewOff: 'Turn stage preview off — buttons behave as normal assets',
centerCrosshairOn: 'Show centre crosshair',
centerCrosshairOff: 'Hide centre crosshair',
cameraOn: 'Enable output camera',
cameraOff: 'Disable output camera',
motionPaths: {
label: 'Show motion paths',
help: 'Draws the position-keyframe trajectory of the selected layer on the canvas. Click a dot to seek to that keyframe.',
},
vertexCorner: 'Corner',
vertexCurve: 'Curve (smooth)',
vertexSplitCurve: 'Split curve',
@@ -422,6 +468,9 @@ export const en = {
welcomeOnStartLabel: 'Show welcome screen on start',
welcomeOnStartDesc:
'Open the welcome / recents dialog when RUWIO launches. Off → boot straight into the last edited project. The last file is always restored either way; the welcome dialog just gives you a chance to switch.',
fullscreenAutoplayLabel: 'Autoplay in fullscreen preview',
fullscreenAutoplayDesc:
'Start playback immediately when fullscreen preview opens. When off, fullscreen opens paused and Space starts it.',
bgVideoLabel: 'Video',
bgVideoNone: 'No background video',
bgVideoPickFile: 'Pick video file…',
@@ -478,10 +527,14 @@ export const en = {
sceneDesc: 'Scene data only — requires the runtime to be loaded',
exampleTitle: 'Example HTML',
exampleDesc: 'Standalone HTML — just open it in a browser',
mp4Badge: 'Server · Frame-accurate',
mp4Desc: 'H.264, any resolution — requires render server',
webmBadge: 'Browser · Realtime',
webmDesc: 'No server — recorded in real time',
mp4ServerBadge: 'Server · Frame-by-frame',
mp4ServerDesc: 'H.264 export via render server',
mp4DesktopBadge: 'Desktop · Frame-by-frame',
mp4DesktopDesc: 'H.264 export via bundled ffmpeg',
webmBrowserBadge: 'Browser · Realtime',
webmBrowserDesc: 'Recorded in real time with MediaRecorder',
webmDesktopBadge: 'Chromium · Realtime',
webmDesktopDesc: "Recorded in real time by Electron's Chromium runtime",
webmNotSupported: 'Browser does not support MediaRecorder',
gifBadge: 'Browser · Frame-by-frame',
gifDesc: '256-colour palette, broadest compatibility',
@@ -493,8 +546,10 @@ export const en = {
cancel: 'Cancel',
failed: 'Export failed',
back: 'Back',
labelWebm: 'Rendering WebM (browser, realtime)…',
labelMp4: 'Rendering MP4 (server)…',
labelWebmBrowser: 'Rendering WebM in browser realtime...',
labelWebmDesktop: 'Rendering WebM in Chromium realtime...',
labelMp4Server: 'Rendering MP4 with render server...',
labelMp4Desktop: 'Rendering MP4 in desktop app...',
labelGif: 'Rendering GIF (browser, frame-by-frame)…',
labelApng: 'Rendering APNG (browser, frame-by-frame)…',
labelApngPreview: 'Rendering APNG + preview page…',
@@ -1047,9 +1102,13 @@ play
html:
'<strong>HTML example:</strong> A quick standalone page with everything wired up.',
mp4:
'<strong>MP4 video:</strong> Frame-accurate server render. This needs the render server configured in Settings.',
'<strong>MP4 video:</strong> Frame-by-frame H.264 export via the render server.',
mp4Desktop:
'<strong>MP4 video:</strong> Frame-by-frame H.264 export in the desktop app with bundled ffmpeg.',
webm:
'<strong>WebM video:</strong> Browser-side realtime recording. This depends on <code>MediaRecorder</code> support.',
'<strong>WebM video:</strong> Browser realtime recording with <code>MediaRecorder</code>.',
webmDesktop:
"<strong>WebM video:</strong> Realtime recording with <code>MediaRecorder</code> in Electron's Chromium runtime.",
},
skipHiddenTitle: 'Skip hidden layers',
skipHiddenBody:
@@ -1085,6 +1144,7 @@ export const de: DeepPartial<Messages> = {
pause: 'Pause (Leertaste)',
play: 'Abspielen (Leertaste)',
stop: 'Stop',
fullscreenPlayback: 'Fullscreen-Wiedergabe (Shift+Leertaste)',
recentProjects: 'Letzte Projekte',
copyStill: {
label: 'Aktuellen Frame kopieren',
@@ -1118,8 +1178,10 @@ export const de: DeepPartial<Messages> = {
untitledProject: 'Unbenanntes Projekt',
saveProjectDirtyHelp:
'Dein Projekt hat ungespeicherte Änderungen. Mit Ctrl/Cmd+S wird es im Browser gespeichert.',
exportHelp:
'Als Runtime, HTML oder Video exportieren. MP4 braucht den Render-Server. WebM wird direkt im Browser in Echtzeit aufgenommen.',
exportHelpServer:
'Als Runtime, HTML oder Video exportieren. MP4 nutzt den Render-Server. WebM wird in Echtzeit mit MediaRecorder aufgenommen.',
exportHelpDesktop:
'Als Runtime, HTML oder Video exportieren. MP4 rendert in der Desktop-App mit gebündeltem ffmpeg. WebM wird in Electrons Chromium-Runtime aufgenommen.',
replaceDialog: {
title: 'Projekt ersetzen?',
message:
@@ -1277,6 +1339,12 @@ export const de: DeepPartial<Messages> = {
assetThumbsLabel: 'Asset-Vorschaubilder',
assetThumbsDesc:
'Zeigt kleine Vorschauen in Assets, Clips und Buttons. Aus ist kompakter, an ist übersichtlicher.',
welcomeOnStartLabel: 'Willkommensfenster beim Start anzeigen',
welcomeOnStartDesc:
'Öffnet beim Start von RUWIO den Willkommen-/Letzte-Projekte-Dialog. Aus startet direkt mit dem zuletzt bearbeiteten Projekt.',
fullscreenAutoplayLabel: 'Autoplay in Fullscreen-Vorschau',
fullscreenAutoplayDesc:
'Startet die Wiedergabe sofort, wenn die Fullscreen-Vorschau geöffnet wird. Wenn aus, öffnet sie pausiert und Leertaste startet sie.',
bgVideoLabel: 'Video',
bgVideoNone: 'Kein Hintergrundvideo',
bgVideoPickFile: 'Videodatei wählen…',
@@ -1332,10 +1400,14 @@ export const de: DeepPartial<Messages> = {
sceneDesc: 'Nur Projektdaten: Die Runtime muss schon geladen sein.',
exampleTitle: 'HTML-Beispiel',
exampleDesc: 'Eigenständige HTML-Datei: Einfach im Browser öffnen.',
mp4Badge: 'Server · Frame-genau',
mp4Desc: 'H.264 in jeder Auflösung: braucht den Render-Server.',
webmBadge: 'Browser · Echtzeit',
webmDesc: 'Ohne Server: direkt im Browser aufgenommen.',
mp4ServerBadge: 'Server · Frame-für-Frame',
mp4ServerDesc: 'H.264-Export über den Render-Server',
mp4DesktopBadge: 'Desktop · Frame-für-Frame',
mp4DesktopDesc: 'H.264-Export über gebündeltes ffmpeg',
webmBrowserBadge: 'Browser · Echtzeit',
webmBrowserDesc: 'Echtzeit-Aufnahme mit MediaRecorder',
webmDesktopBadge: 'Chromium · Echtzeit',
webmDesktopDesc: 'Echtzeit-Aufnahme durch Electrons Chromium-Runtime',
webmNotSupported: 'Dieser Browser unterstützt MediaRecorder nicht.',
gifBadge: 'Browser · Frame-für-Frame',
gifDesc: '256-Farben-Palette, maximale Kompatibilität.',
@@ -1347,8 +1419,10 @@ export const de: DeepPartial<Messages> = {
cancel: 'Abbrechen',
failed: 'Export fehlgeschlagen',
back: 'Zurück',
labelWebm: 'WebM wird gerendert (Browser, Echtzeit)…',
labelMp4: 'MP4 wird gerendert (Server)…',
labelWebmBrowser: 'WebM wird in Browser-Echtzeit gerendert...',
labelWebmDesktop: 'WebM wird in Chromium-Echtzeit gerendert...',
labelMp4Server: 'MP4 wird mit dem Render-Server gerendert...',
labelMp4Desktop: 'MP4 wird in der Desktop-App gerendert...',
labelGif: 'GIF wird gerendert (Browser, frame-für-frame)…',
labelApng: 'APNG wird gerendert (Browser, frame-für-frame)…',
labelApngPreview: 'APNG + Vorschau werden gerendert…',
@@ -1889,9 +1963,13 @@ play
html:
'<strong>HTML-Beispiel:</strong> Eine schnelle eigenständige Seite mit allem fertig verdrahtet.',
mp4:
'<strong>MP4-Video:</strong> Frame-genauer Server-Render. Dafür muss der Render-Server in den Einstellungen konfiguriert sein.',
'<strong>MP4-Video:</strong> Frame-für-Frame-H.264-Export über den Render-Server.',
mp4Desktop:
'<strong>MP4-Video:</strong> Frame-für-Frame-H.264-Export in der Desktop-App mit gebündeltem ffmpeg.',
webm:
'<strong>WebM-Video:</strong> Browser-seitige Echtzeit-Aufnahme. Das hängt von <code>MediaRecorder</code> ab.',
'<strong>WebM-Video:</strong> Echtzeit-Aufnahme im Browser mit <code>MediaRecorder</code>.',
webmDesktop:
'<strong>WebM-Video:</strong> Echtzeit-Aufnahme mit <code>MediaRecorder</code> in Electrons Chromium-Runtime.',
},
skipHiddenTitle: 'Versteckte Ebenen auslassen',
skipHiddenBody:
+201
View File
@@ -0,0 +1,201 @@
import type { CameraKeyframe, CameraProps, CameraTrack, Easing, Scene } from '@/types/scene'
import { drawSceneFrame } from './render'
const CAMERA_MIN_SIZE = 1
const easings: Record<Easing, (t: number) => number> = {
linear: (t) => t,
easeIn: (t) => t * t,
easeOut: (t) => 1 - (1 - t) ** 2,
easeInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2),
}
function uid() {
return Math.random().toString(36).slice(2, 10)
}
function finite(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function positive(value: unknown, fallback: number): number {
return Math.max(CAMERA_MIN_SIZE, finite(value, fallback))
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
function mergeCameraProps(base: CameraProps, props: Partial<CameraProps> | undefined): CameraProps {
return {
x: finite(props?.x, base.x),
y: finite(props?.y, base.y),
width: positive(props?.width, base.width),
height: positive(props?.height, base.height),
}
}
function lerpCameraProps(a: CameraProps, b: CameraProps, t: number): CameraProps {
return {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
width: positive(lerp(a.width, b.width, t), a.width),
height: positive(lerp(a.height, b.height, t), a.height),
}
}
export function defaultCameraProps(scene: Pick<Scene, 'canvas'>): CameraProps {
return {
x: 0,
y: 0,
width: positive(scene.canvas?.width, 1280),
height: positive(scene.canvas?.height, 720),
}
}
export function createDefaultCameraTrack(
scene: Pick<Scene, 'canvas'>,
enabled = true,
): CameraTrack {
return {
id: uid(),
name: 'Camera',
enabled,
base: defaultCameraProps(scene),
keyframes: [],
overlayVisible: true,
locked: false,
}
}
export function normalizeCameraTrack(
scene: Pick<Scene, 'canvas'>,
camera: unknown,
): CameraTrack | undefined {
if (!camera || typeof camera !== 'object') return undefined
const raw = camera as Partial<CameraTrack>
const fallback = defaultCameraProps(scene)
const base = mergeCameraProps(fallback, raw.base)
const keyframes = Array.isArray(raw.keyframes)
? raw.keyframes.flatMap((kf): CameraKeyframe[] => {
if (!kf || typeof kf !== 'object') return []
const rawKf = kf as Partial<CameraKeyframe>
const props = mergeCameraProps(base, rawKf.props)
const storedProps: Partial<CameraProps> = {}
if (rawKf.props && typeof rawKf.props === 'object') {
const p = rawKf.props as Partial<CameraProps>
if (typeof p.x === 'number' && Number.isFinite(p.x)) storedProps.x = props.x
if (typeof p.y === 'number' && Number.isFinite(p.y)) storedProps.y = props.y
if (typeof p.width === 'number' && Number.isFinite(p.width)) storedProps.width = props.width
if (typeof p.height === 'number' && Number.isFinite(p.height)) storedProps.height = props.height
}
return [
{
id: typeof rawKf.id === 'string' && rawKf.id ? rawKf.id : uid(),
time: Math.max(0, finite(rawKf.time, 0)),
props: storedProps,
easing:
rawKf.easing === 'easeIn' ||
rawKf.easing === 'easeOut' ||
rawKf.easing === 'easeInOut' ||
rawKf.easing === 'linear'
? rawKf.easing
: 'linear',
hold: rawKf.hold === true,
},
]
})
: []
keyframes.sort((a, b) => a.time - b.time)
return {
id: typeof raw.id === 'string' && raw.id ? raw.id : uid(),
name: typeof raw.name === 'string' ? raw.name : 'Camera',
enabled: raw.enabled === true,
base,
keyframes,
overlayVisible: raw.overlayVisible !== false,
locked: raw.locked === true,
}
}
export function isCameraEnabled(scene: Scene): boolean {
const camera = normalizeCameraTrack(scene, scene.camera)
return !!camera?.enabled
}
export function sampleCamera(scene: Scene, time: number): CameraProps {
const camera = normalizeCameraTrack(scene, scene.camera)
if (!camera) return defaultCameraProps(scene)
const base = mergeCameraProps(defaultCameraProps(scene), camera.base)
const sorted = camera.keyframes.slice().sort((a, b) => a.time - b.time)
if (!sorted.length) return base
if (time <= sorted[0].time) return mergeCameraProps(base, sorted[0].props)
const last = sorted[sorted.length - 1]
if (time >= last.time) return mergeCameraProps(base, last.props)
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
if (time >= a.time && time <= b.time) {
if (a.hold) {
if (time >= b.time) return mergeCameraProps(base, b.props)
return mergeCameraProps(base, a.props)
}
const span = Math.max(1e-9, b.time - a.time)
const t = easings[b.easing]((time - a.time) / span)
return lerpCameraProps(mergeCameraProps(base, a.props), mergeCameraProps(base, b.props), t)
}
}
return base
}
export function cameraAspect(scene: Scene, time = 0): number {
const camera = sampleCamera(scene, time)
return camera.width / camera.height
}
export function renderSceneFrameForOutput(
ctx: CanvasRenderingContext2D,
scene: Scene,
time: number,
images: Record<string, HTMLImageElement>,
options?: {
width?: number
height?: number
useCamera?: boolean
cropStage?: boolean
},
): void {
const outputWidth = Math.max(1, Math.round(options?.width ?? scene.canvas.width))
const outputHeight = Math.max(1, Math.round(options?.height ?? scene.canvas.height))
const useCamera = options?.useCamera ?? isCameraEnabled(scene)
if (!useCamera) {
ctx.save()
const scaleX = outputWidth / scene.canvas.width
const scaleY = outputHeight / scene.canvas.height
if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY)
drawSceneFrame(ctx, scene, time, images, { crop: options?.cropStage })
ctx.restore()
return
}
const camera = sampleCamera(scene, time)
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, outputWidth, outputHeight)
const bg = scene.canvas.background
if (!scene.backgroundVideo && bg && bg !== 'transparent') {
ctx.fillStyle = bg
ctx.fillRect(0, 0, outputWidth, outputHeight)
}
ctx.restore()
ctx.save()
ctx.scale(outputWidth / camera.width, outputHeight / camera.height)
ctx.translate(-camera.x, -camera.y)
drawSceneFrame(ctx, scene, time, images, {
crop: options?.cropStage ?? false,
clear: false,
background: false,
})
ctx.restore()
}
+4 -1
View File
@@ -22,9 +22,12 @@ export interface Rect {
* depends on font metrics — so we fall back to the asset's declared
* width/height when present and a 40×40 placeholder otherwise. */
function assetFootprint(asset: Asset): { w: number; h: number } {
if (asset.kind === 'rect' || asset.kind === 'image' || asset.kind === 'button') {
if (asset.kind === 'rect' || asset.kind === 'image') {
return { w: asset.width, h: asset.height }
}
if (asset.kind === 'button' || asset.kind === 'svg') {
return { w: asset.canvas.width, h: asset.canvas.height }
}
if (asset.kind === 'circle') {
const d = Math.max(1, asset.radius * 2)
return { w: d, h: d }
+65
View File
@@ -0,0 +1,65 @@
import type { Scene } from '@/types/scene'
export interface DesktopRenderOptions {
onProgress?: (progress: number) => void
signal?: AbortSignal
fps?: number
crf?: number
preset?: string
suggestedName?: string
}
export function hasDesktopRender(): boolean {
return !!window.ruwioDesktop?.renderMp4
}
export async function renderMp4OnDesktop(
scene: Scene,
opts: DesktopRenderOptions = {},
): Promise<{ path: string } | null> {
const desktop = window.ruwioDesktop
if (!desktop?.renderMp4) throw new Error('Desktop MP4 rendering is not available')
let jobId: string | null = null
let settled = false
let pendingAbort = false
const cleanupProgress = desktop.onRenderProgress?.((event) => {
jobId = event.jobId
if (typeof event.progress === 'number') opts.onProgress?.(event.progress)
if (pendingAbort && !settled && jobId) {
pendingAbort = false
void desktop.cancelRender?.(jobId)
}
})
const abort = () => {
if (settled) return
if (jobId) void desktop.cancelRender?.(jobId)
else pendingAbort = true
}
if (opts.signal) {
if (opts.signal.aborted) {
throw new DOMException('aborted', 'AbortError')
}
opts.signal.addEventListener('abort', abort, { once: true })
}
try {
const result = await desktop.renderMp4({
scene,
fps: opts.fps,
crf: opts.crf,
preset: opts.preset,
suggestedName: opts.suggestedName,
})
return result
} catch (err) {
if (opts.signal?.aborted) throw new DOMException('aborted', 'AbortError')
throw err
} finally {
settled = true
cleanupProgress?.()
opts.signal?.removeEventListener('abort', abort)
}
}
+373 -115
View File
@@ -1,4 +1,5 @@
import type { Project, Scene } from '@/types/scene'
import type { Asset, Project, Scene } from '@/types/scene'
import { AVAILABLE_FONTS, fontFaceCss, fontFamilyMapSnapshot, fontPrimary, SYSTEM_SANS } from './fonts'
/** Input accepted by every export helper below. A Project exports all
* its scenes (jumpToScene works in the bundle); a single Scene is
@@ -10,10 +11,22 @@ function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial<Project>).scenes)
}
const RUNTIME_VERSION = '27'
const RUNTIME_VERSION = '36'
const ENGINE_SOURCE = `(function(){
/** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage
* and are dynamic) make it into the exported runtime. */
function engineSource(): string {
const fontMapJson = JSON.stringify(fontFamilyMapSnapshot())
return ENGINE_TEMPLATE.replace('__FONT_FAMILY_MAP__', fontMapJson)
}
const ENGINE_TEMPLATE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
var FONT_FAMILIES = __FONT_FAMILY_MAP__;
function fontFam(asset){
return (asset && asset.fontFamily && FONT_FAMILIES[asset.fontFamily]) || FONT_FAMILIES['system-sans'] || 'sans-serif';
}
function easing(name, t){
if(name==='easeIn')return t*t;
@@ -94,6 +107,17 @@ const ENGINE_SOURCE = `(function(){
if(a.shadowColor && b.shadowColor) out.shadowColor=lerpColor(a.shadowColor,b.shadowColor,t);
else if(b.shadowColor) out.shadowColor=b.shadowColor;
else if(a.shadowColor) out.shadowColor=a.shadowColor;
// Kaleido pivot: mirror src/lib/interpolate.ts. A one-sided
// override lerps from the layer's own (x, y) on the unset side
// to the explicit value on the other.
var apx=a.kaleidoPivotX, bpx=b.kaleidoPivotX;
if(apx!=null || bpx!=null){
out.kaleidoPivotX = lerp(apx!=null?apx:a.x, bpx!=null?bpx:b.x, t);
}
var apy=a.kaleidoPivotY, bpy=b.kaleidoPivotY;
if(apy!=null || bpy!=null){
out.kaleidoPivotY = lerp(apy!=null?apy:a.y, bpy!=null?bpy:b.y, t);
}
return out;
}
// Interpolate a single keyframe track against 'base' at 'time'.
@@ -127,21 +151,111 @@ const ENGINE_SOURCE = `(function(){
}
return merge(base);
}
// Mirror of src/lib/wiggle.ts — keep in lockstep so editor and
// exported runtime render an identical wobble at the same time.
function wiggleHash32(s){
var h=5381;
for(var i=0;i<s.length;i++){ h=((h<<5)+h+s.charCodeAt(i))|0; }
return h>>>0;
}
function wiggleSeeds(id){
var h=wiggleHash32(id);
return [(h^0x9e3779b9)>>>0, (h^0x85ebca6b)>>>0, (h^0xc2b2ae35)>>>0];
}
function wiggleNoise(seed, t){
var a=Math.sin(seed*12.9898+t*78.233);
var b=Math.sin(seed*4.31+t*11.71);
var c=Math.sin(seed*7.71+t*23.5);
return (a+b+c)/3;
}
function applyWiggle(props, layer, time){
var w=layer.wiggle;
if(!w) return props;
var amp=(w.amplitude==null?8:w.amplitude);
var rot=(w.rotation==null?2:w.rotation);
var freq=(w.frequency==null?2:w.frequency);
if(amp<=0 && rot<=0) return props;
if(freq<=0) return props;
var seeds=wiggleSeeds(layer.id);
var tt=time*freq;
var out={};
for(var k in props) out[k]=props[k];
out.x=props.x+amp*wiggleNoise(seeds[0],tt);
out.y=props.y+amp*wiggleNoise(seeds[1],tt);
out.rotation=props.rotation+rot*wiggleNoise(seeds[2],tt);
return out;
}
function sample(layer,time){
var main=sampleKeyframeSet(layer.keyframes, layer.base, time);
if(!layer.separated) return main;
// Separated mode: position and rotation come from their own tracks
// if present, merged back over the main-track values. This matches
// the editor's sampleLayer() so animations on either side of the
// Separate-toggle play identically.
var pos = layer.positionKeyframes ? sampleKeyframeSet(layer.positionKeyframes, layer.base, time) : main;
var rot = layer.rotationKeyframes ? sampleKeyframeSet(layer.rotationKeyframes, layer.base, time) : main;
var outSep={};
for(var k in main) outSep[k]=main[k];
outSep.x=pos.x;
outSep.y=pos.y;
outSep.rotation=rot.rotation;
return outSep;
var result;
if(!layer.separated){
result=main;
} else {
// Separated mode: position and rotation come from their own
// tracks if present, merged back over the main-track values.
// Matches the editor's sampleLayer().
var pos = layer.positionKeyframes ? sampleKeyframeSet(layer.positionKeyframes, layer.base, time) : main;
var rot = layer.rotationKeyframes ? sampleKeyframeSet(layer.rotationKeyframes, layer.base, time) : main;
result={};
for(var k in main) result[k]=main[k];
result.x=pos.x;
result.y=pos.y;
result.rotation=rot.rotation;
}
if(layer.wiggle) return applyWiggle(result, layer, time);
return result;
}
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<kfs.length-1;i++){
var a=kfs[i], b=kfs[i+1];
if(time>=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){
@@ -185,7 +299,8 @@ const ENGINE_SOURCE = `(function(){
trackOrder: page.trackOrder || [],
backgroundVideo: page.backgroundVideo,
audioTracks: page.audioTracks || [],
audioClips: page.audioClips || []
audioClips: page.audioClips || [],
camera: page.camera
};
}
if(payload && payload.scenes && payload.scenes.length){
@@ -220,7 +335,8 @@ const ENGINE_SOURCE = `(function(){
trackOrder: legacy.trackOrder || [],
backgroundVideo: legacy.backgroundVideo,
audioTracks: legacy.audioTracks || [],
audioClips: legacy.audioClips || []
audioClips: legacy.audioClips || [],
camera: legacy.camera
}],
activeSceneId: '__legacy__'
};
@@ -676,6 +792,105 @@ const ENGINE_SOURCE = `(function(){
}
if(closed) tctx.closePath();
}
// Kaleidoscope: number of radial copies for an entity (Layer or
// ClipInstance). 1 = no kaleido. Mirrors lib/kaleido.ts.
function kalCount(e){
if(!e || !e.kaleido) return 1;
var n=Math.floor(e.kaleidoCount||0);
return n<2?1:n;
}
function kalAngle(i,n){ return n<=1?0:(i*360)/n; }
// Mirror of layerArmTransform in src/lib/kaleido.ts. Pivot lookup:
// sampled props (keyframed) → layer's static legacy override →
// layer's own sampled (x, y).
function layerArmTf(layer, props, i, n){
if(i===0||n<=1) return {x:props.x,y:props.y,rotation:props.rotation};
var ppx=(props.kaleidoPivotX==null?null:props.kaleidoPivotX);
var ppy=(props.kaleidoPivotY==null?null:props.kaleidoPivotY);
var lpx=(layer.kaleidoPivotX==null?null:layer.kaleidoPivotX);
var lpy=(layer.kaleidoPivotY==null?null:layer.kaleidoPivotY);
var px=(ppx!=null?ppx:(lpx!=null?lpx:props.x));
var py=(ppy!=null?ppy:(lpy!=null?lpy:props.y));
var ang=kalAngle(i,n);
var a=ang*Math.PI/180;
var dx=props.x-px, dy=props.y-py;
return {
x: px+dx*Math.cos(a)-dy*Math.sin(a),
y: py+dx*Math.sin(a)+dy*Math.cos(a),
rotation: props.rotation+ang
};
}
function clipKaleidoPivot(inst){
var ix=inst.x||0, iy=inst.y||0;
return {
x:(inst.kaleidoPivotX==null?ix:inst.kaleidoPivotX),
y:(inst.kaleidoPivotY==null?iy:inst.kaleidoPivotY)
};
}
function clipArmLocalT(inst, baseLocal, i, trimmedDur){
if(trimmedDur<=0) return null;
var off=inst.kaleidoOffset||0;
var trimS=inst.trimStart||0;
var offsetIntoBlock=baseLocal-trimS;
// Arm 0 (and the no-shift case) → source's own time. Arms i>0
// with a non-zero offset start later.
var shifted=(i===0||off===0)?offsetIntoBlock:(offsetIntoBlock-i*off);
var EPS=1e-9;
if(shifted < -EPS || shifted > trimmedDur+EPS) return null;
if(shifted<0) shifted=0;
if(shifted>trimmedDur) shifted=trimmedDur;
return trimS+shifted;
}
function clipArmTrans(inst, i, n){
var ix=inst.x||0, iy=inst.y||0;
if(i===0||n<=1) return {x:ix,y:iy};
var rot=inst.kaleidoRotateCopies!==false;
if(rot) return {x:ix,y:iy};
var pv=clipKaleidoPivot(inst);
var a=kalAngle(i,n)*Math.PI/180;
var dx=ix-pv.x, dy=iy-pv.y;
return {
x:pv.x+dx*Math.cos(a)-dy*Math.sin(a),
y:pv.y+dx*Math.sin(a)+dy*Math.cos(a)
};
}
function drawClipInstance(inst, clip, time){
var trimS=inst.trimStart||0;
var trimE=inst.trimEnd||0;
var visibleClip=Math.max(0,clip.duration-trimS-trimE);
var offsetIntoBlock=time-inst.startTime;
var isx=(inst.scaleX==null?1:inst.scaleX);
var isy=(inst.scaleY==null?1:inst.scaleY);
var irot=inst.rotation||0;
var n=kalCount(inst);
var rotateCopies=inst.kaleidoRotateCopies!==false;
var pv=clipKaleidoPivot(inst);
var baseLocal=trimS+offsetIntoBlock;
// Delayed arms can outlive the source's own window — keep
// rendering until the LAST arm has finished playing.
var offSec=inst.kaleidoOffset||0;
var maxArmDelay=(n>1 && offSec!==0)?Math.max(0,(n-1)*offSec):0;
if(offsetIntoBlock<0||offsetIntoBlock>visibleClip+maxArmDelay) return;
for(var i=0;i<n;i++){
var ka=kalAngle(i,n);
var lt=clipArmLocalT(inst, baseLocal, i, visibleClip);
if(lt===null) continue;
var tr=clipArmTrans(inst, i, n);
ctx.save();
if(n>1 && i>0 && rotateCopies){
ctx.translate(pv.x,pv.y);
ctx.rotate(ka*Math.PI/180);
ctx.translate(-pv.x,-pv.y);
}
ctx.translate(tr.x,tr.y);
if(irot!==0) ctx.rotate(irot*Math.PI/180);
ctx.scale(isx,isy);
for(var li=clip.layers.length-1;li>=0;li--){
drawLayer(clip.layers[li], lt);
}
ctx.restore();
}
}
function drawMaskShape(ctxm,layer,time,buttonMode){
var asset=assetMap[layer.assetId];
if(!asset) return;
@@ -684,58 +899,72 @@ const ENGINE_SOURCE = `(function(){
if(!buttonLayerActiveAt(layer,time)) return;
} else if(!layerActiveAt(layer,time)) return;
var p=sample(layer,time);
ctxm.save();
ctxm.translate(p.x,p.y);
ctxm.rotate(p.rotation*Math.PI/180);
ctxm.scale(p.scaleX,p.scaleY);
ctxm.fillStyle='#000000';
if(asset.kind==='rect'){
var mrOff=layer.offsetX!=null?layer.offsetX:asset.width/2;
var mrOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
ctxm.fillRect(-mrOff,-mrOffY,asset.width,asset.height);
} else if(asset.kind==='circle'){
var mcOff=layer.offsetX!=null?layer.offsetX:0;
var mcOffY=layer.offsetY!=null?layer.offsetY:0;
ctxm.beginPath();
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
ctxm.fill();
} else if(asset.kind==='text'){
var mtOff=layer.offsetX!=null?layer.offsetX:0;
var mtOffY=layer.offsetY!=null?layer.offsetY:0;
ctxm.font=asset.fontSize+'px sans-serif';
ctxm.textBaseline='top';
ctxm.fillText(asset.text,-mtOff,-mtOffY);
} else {
var mpOffX=layer.offsetX!=null?layer.offsetX:0;
var mpOffY=layer.offsetY!=null?layer.offsetY:0;
var mpPts=p.points||asset.points||[];
var mpClosed=asset.closed!==false;
if(mpClosed && mpPts.length>=3){
tracePolyPath(ctxm,mpPts,asset.points,mpOffX,mpOffY,true);
var kn=kalCount(layer);
for(var ki=0;ki<kn;ki++){
var tfm=layerArmTf(layer, p, ki, kn);
ctxm.save();
ctxm.translate(tfm.x,tfm.y);
ctxm.rotate(tfm.rotation*Math.PI/180);
ctxm.scale(p.scaleX,p.scaleY);
ctxm.fillStyle='#000000';
if(asset.kind==='rect'){
var mrOff=layer.offsetX!=null?layer.offsetX:asset.width/2;
var mrOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
ctxm.fillRect(-mrOff,-mrOffY,asset.width,asset.height);
} else if(asset.kind==='circle'){
var mcOff=layer.offsetX!=null?layer.offsetX:0;
var mcOffY=layer.offsetY!=null?layer.offsetY:0;
ctxm.beginPath();
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
ctxm.fill();
} else if(asset.kind==='text'){
var mtOff=layer.offsetX!=null?layer.offsetX:0;
var mtOffY=layer.offsetY!=null?layer.offsetY:0;
ctxm.font=asset.fontSize+'px '+fontFam(asset);
ctxm.textBaseline='top';
ctxm.fillText(asset.text,-mtOff,-mtOffY);
} else {
var mpOffX=layer.offsetX!=null?layer.offsetX:0;
var mpOffY=layer.offsetY!=null?layer.offsetY:0;
var mpPts=p.points||asset.points||[];
var mpClosed=asset.closed!==false;
if(mpClosed && mpPts.length>=3){
tracePolyPath(ctxm,mpPts,asset.points,mpOffX,mpOffY,true);
ctxm.fill();
}
}
ctxm.restore();
}
ctxm.restore();
}
// Mirror of isLayerActiveAt + layerActiveRange in interpolate.ts.
// A layer is "alive" from its earliest NORMAL kf onward, ending
// either at the earliest 'end' kf or at +Infinity when none exists.
// Layers without any normal kf are static (always visible).
// End kfs only live on .keyframes (the main track).
function layerActiveAt(layer,time){
var main=layer.keyframes||[];
var pos=layer.positionKeyframes||[];
var rot=layer.rotationKeyframes||[];
var total=main.length+pos.length+rot.length;
if(total<=1) return true;
var mn=Infinity, mx=-Infinity, lastHold=false;
function scan(arr){
var foundNormal=false, mn=Infinity;
function scanNormal(arr){
for(var i=0;i<arr.length;i++){
var k=arr[i], t=k.time;
if(t<mn) mn=t;
if(t>mx){ mx=t; lastHold=!!k.hold; }
else if(t===mx && k.hold){ lastHold=true; }
var k=arr[i];
if(k.kind==='end') continue;
foundNormal=true;
if(k.time<mn) mn=k.time;
}
}
scan(main); scan(pos); scan(rot);
if(lastHold) mx=Infinity;
scanNormal(main); scanNormal(pos); scanNormal(rot);
if(!foundNormal) return true;
var endTime=null;
for(var ei=0;ei<main.length;ei++){
var ek=main[ei];
if(ek.kind!=='end') continue;
if(endTime===null || ek.time<endTime) endTime=ek.time;
}
var mx=(endTime===null)?Infinity:endTime;
var EPS=1e-6;
return time>=mn-EPS && time<=mx+EPS;
return time>=mn-EPS && time<mx-EPS;
}
// Button-asset visibility: a still-frame (hold) kf shows ONLY on
// its own state frame, not on the state frames that follow. Mirrors
@@ -767,10 +996,15 @@ const ENGINE_SOURCE = `(function(){
if(!buttonLayerActiveAt(layer,time)) return;
} else if(!layerActiveAt(layer,time)) return;
var p=sample(layer,time);
// Kaleido off for button-asset layers — multiplying their hit
// areas would break click routing.
var kn=(asset.kind==='button')?1:kalCount(layer);
for(var ki=0;ki<kn;ki++){
var tfL=layerArmTf(layer, p, ki, kn);
ctx.save();
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
ctx.translate(p.x,p.y);
ctx.rotate(p.rotation*Math.PI/180);
ctx.translate(tfL.x,tfL.y);
ctx.rotate(tfL.rotation*Math.PI/180);
ctx.scale(p.scaleX,p.scaleY);
var sOp=p.shadowOpacity||0;
var sBl=p.shadowBlur||0;
@@ -827,7 +1061,7 @@ const ENGINE_SOURCE = `(function(){
}else if(asset.kind==='text'){
var tOffX=layer.offsetX!=null?layer.offsetX:0;
var tOffY=layer.offsetY!=null?layer.offsetY:0;
ctx.font=asset.fontSize+'px sans-serif';
ctx.font=asset.fontSize+'px '+fontFam(asset);
ctx.textBaseline='top';
var tFill=p.fill||asset.fill;
if(tFill && tFill!=='transparent'){
@@ -922,26 +1156,38 @@ const ENGINE_SOURCE = `(function(){
ctx.shadowOffsetX=0;
ctx.shadowOffsetY=0;
ctx.restore();
}
}
function draw(time){
var lw=scene.canvas.width,lh=scene.canvas.height;
var cw=size.w,ch=size.h;
if(!cw||!ch)return;
buttonInstancesInFrame.length=0;
var s=Math.min(cw/lw,ch/lh);
var ox=(cw-lw*s)/2;
var oy=(ch-lh*s)/2;
var useCamera=!!(scene.camera && scene.camera.enabled);
var cam=useCamera?sampleCamera(scene,time):null;
var vw=useCamera?cam.width:lw;
var vh=useCamera?cam.height:lh;
var s=Math.min(cw/vw,ch/vh);
var ox=(cw-vw*s)/2;
var oy=(ch-vh*s)/2;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cw,ch);
ctx.save();
ctx.translate(ox,oy);
ctx.scale(s,s);
ctx.beginPath();
ctx.rect(0,0,lw,lh);
ctx.rect(0,0,vw,vh);
ctx.clip();
if(!scene.backgroundVideo){
if(useCamera && !scene.backgroundVideo && scene.canvas.background && scene.canvas.background !== 'transparent'){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
ctx.fillRect(0,0,vw,vh);
}
if(useCamera) ctx.translate(-cam.x,-cam.y);
if(!scene.backgroundVideo){
if(!useCamera){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
}
}
var layerById={};
for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li];
@@ -990,27 +1236,7 @@ const ENGINE_SOURCE = `(function(){
var inst2=tInsts[ti];
var clip2=clipById2[inst2.clipId];
if(!clip2)continue;
var tS=inst2.trimStart||0;
var tE=inst2.trimEnd||0;
var visibleClip=Math.max(0,clip2.duration-tS-tE);
var offset=time-inst2.startTime;
if(offset<0||offset>visibleClip)continue;
var localT=tS+offset;
var ix2=inst2.x||0, iy2=inst2.y||0;
var isx2=(inst2.scaleX==null?1:inst2.scaleX);
var isy2=(inst2.scaleY==null?1:inst2.scaleY);
var irot2=inst2.rotation||0;
var needsXf=ix2!==0||iy2!==0||isx2!==1||isy2!==1||irot2!==0;
if(needsXf){
ctx.save();
ctx.translate(ix2,iy2);
if(irot2!==0) ctx.rotate(irot2*Math.PI/180);
ctx.scale(isx2,isy2);
}
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
drawLayer(clip2.layers[cl2],localT);
}
if(needsXf) ctx.restore();
drawClipInstance(inst2, clip2, time);
}
continue;
}
@@ -1038,27 +1264,7 @@ const ENGINE_SOURCE = `(function(){
var inst4=insts3[ti3];
var clip4=clipById2[inst4.clipId];
if(!clip4) continue;
var tS4=inst4.trimStart||0;
var tE4=inst4.trimEnd||0;
var vis4=Math.max(0,clip4.duration-tS4-tE4);
var off4=time-inst4.startTime;
if(off4<0||off4>vis4) continue;
var lt4=tS4+off4;
var ix4=inst4.x||0, iy4=inst4.y||0;
var isx4=(inst4.scaleX==null?1:inst4.scaleX);
var isy4=(inst4.scaleY==null?1:inst4.scaleY);
var irot4=inst4.rotation||0;
var needsXf4=ix4!==0||iy4!==0||isx4!==1||isy4!==1||irot4!==0;
if(needsXf4){
ctx.save();
ctx.translate(ix4,iy4);
if(irot4!==0) ctx.rotate(irot4*Math.PI/180);
ctx.scale(isx4,isy4);
}
for(var cl4=clip4.layers.length-1;cl4>=0;cl4--){
drawLayer(clip4.layers[cl4],lt4);
}
if(needsXf4) ctx.restore();
drawClipInstance(inst4, clip4, time);
}
};
if(!gMasks.length){
@@ -1256,7 +1462,7 @@ let cachedApi: any = null
function ensureEngine() {
if (cachedApi) return
// eslint-disable-next-line no-eval
;(0, eval)(ENGINE_SOURCE)
;(0, eval)(engineSource())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cachedApi = (window as any).AnimatrPlayer
}
@@ -1330,6 +1536,22 @@ function jsonForExport(payload: ExportPayload): string {
return JSON.stringify(out)
}
function collectFontIds(payload: ExportPayload): string[] {
const ids = new Set<string>()
const assets: readonly Asset[] = payload.assets ?? []
for (const a of assets) {
if (a.kind === 'text' && a.fontFamily) ids.add(a.fontFamily)
}
return Array.from(ids)
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
function sceneToLegacyProject(scene: Scene): Project {
return {
version: 1,
@@ -1355,12 +1577,27 @@ function sceneToLegacyProject(scene: Scene): Project {
backgroundVideo: scene.backgroundVideo,
audioTracks: scene.audioTracks ?? [],
audioClips: scene.audioClips ?? [],
camera: scene.camera,
},
],
activeSceneId: '__legacy__',
}
}
function exportPreviewAspect(payload: ExportPayload): string {
const project = isProject(payload) ? payload : sceneToLegacyProject(payload)
const page =
project.scenes.find((s) => s.id === project.activeSceneId) ??
project.scenes[0]
const camera = page?.camera
if (camera?.enabled) {
const width = Math.max(1, camera.base?.width ?? project.canvas.width)
const height = Math.max(1, camera.base?.height ?? project.canvas.height)
return `${width} / ${height}`
}
return `${project.canvas.width} / ${project.canvas.height}`
}
export interface ExportOptions {
loop?: boolean
}
@@ -1368,7 +1605,7 @@ export interface ExportOptions {
export function generateRuntimeScript(): string {
return (
'/* RUWIO runtime v' + RUNTIME_VERSION + ' — generated ' + new Date().toISOString() + ' */\n' +
ENGINE_SOURCE
engineSource()
)
}
@@ -1386,7 +1623,7 @@ export function generateSceneScript(payload: ExportPayload, options: ExportOptio
export function generateBundledScript(payload: ExportPayload, options: ExportOptions = {}): string {
return (
'/* RUWIO bundled export — generated ' + new Date().toISOString() + ' */\n' +
ENGINE_SOURCE +
engineSource() +
'\n' +
generateSceneScriptBody(payload, options)
)
@@ -1411,10 +1648,29 @@ export function downloadBundledExport(payload: ExportPayload, filename = 'animat
}
export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string {
const runtime = ENGINE_SOURCE
const runtime = engineSource()
const sceneBoot = generateSceneScriptBody(payload, options)
const canvas = isProject(payload) ? payload.canvas : payload.canvas
const aspect = `${canvas.width} / ${canvas.height}`
const aspect = exportPreviewAspect(payload)
const usedFontIds = collectFontIds(payload)
const fonts = AVAILABLE_FONTS.value
const usedFonts = usedFontIds
.map(id => fonts.find(f => f.id === id))
.filter((f): f is typeof fonts[number] => !!f && !!f.file)
const builtinUsed = usedFonts.filter(f => !f.custom)
const customUsed = usedFonts.filter(f => f.custom)
const fontHint = usedFonts.length === 0 ? '' : `
<div class="usage">
<h2>Fonts used in this animation</h2>
<p style="margin-bottom: 8px;">This preview uses ${SYSTEM_SANS.label} for missing fonts. To render with the correct typefaces, host the font files alongside this HTML and add the @font-face rules below to your CSS:</p>
<ul style="margin: 0 0 12px 18px; padding: 0; font-size: 12px; color: #6e6e73;">
${builtinUsed.map(f => ` <li>${fontPrimary(f.id)} — <code>${f.file.replace(/^\//, '')}</code></li>`).join('\n')}${
customUsed.length > 0
? '\n' + customUsed.map(f => ` <li>${escapeHtml(f.label)} — <em>locally uploaded; the @font-face below has the file inlined as a data URL</em></li>`).join('\n')
: ''
}
</ul>
<pre>${escapeHtml(fontFaceCss(usedFontIds))}</pre>
</div>`
return `<!doctype html>
<html lang="en">
<head>
@@ -1489,6 +1745,8 @@ ${sceneBoot}
</script>
</div>
${fontHint}
<div class="usage">
<h2>Embed on your own page</h2>
<pre>&lt;!-- once per page --&gt;
+271
View File
@@ -0,0 +1,271 @@
// Curated set of Google Fonts shipped with the editor as local
// .woff2 files (in /public/fonts), plus user-uploaded fonts kept in
// localStorage as data-URLs. Adding a built-in needs three things:
// (1) drop the .woff2 next to the others, (2) add an entry to
// BUILTIN_FONTS, (3) the example-HTML hint picks it up automatically.
// Custom (user-uploaded) fonts flow through uploadCustomFont().
import { computed, ref } from 'vue'
/** Reactive flag that flips to true once all bundled fonts have
* resolved. Read it inside a computed/watch on the canvas to force
* a re-render after fonts arrive — otherwise Konva's first paint
* would use the fallback family and never refresh. */
export const fontsReady = ref(false)
export interface FontDef {
/** Stable id stored on TextAsset.fontFamily — never rename. */
id: string
/** Human-readable label shown in the picker. */
label: string
/** CSS font-family string (with fallback) used at render time. */
family: string
/** Path to the .woff2 (built-ins) or data-URL (custom). Empty for
* the system-sans fallback. */
file: string
/** True for user-uploaded fonts. Built-ins are false/undefined. */
custom?: boolean
}
export const SYSTEM_SANS: FontDef = {
id: 'system-sans',
label: 'System Sans',
family:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
file: '',
}
export const BUILTIN_FONTS: FontDef[] = [
SYSTEM_SANS,
{ id: 'inter', label: 'Inter', family: '"Inter", sans-serif', file: '/fonts/Inter-Regular.woff2' },
{ id: 'roboto', label: 'Roboto', family: '"Roboto", sans-serif', file: '/fonts/Roboto-Regular.woff2' },
{ id: 'montserrat', label: 'Montserrat', family: '"Montserrat", sans-serif', file: '/fonts/Montserrat-Regular.woff2' },
{ id: 'nunito', label: 'Nunito', family: '"Nunito", sans-serif', file: '/fonts/Nunito-Regular.woff2' },
{ id: 'figtree', label: 'Figtree', family: '"Figtree", sans-serif', file: '/fonts/Figtree-Regular.woff2' },
{ id: 'gabarito', label: 'Gabarito', family: '"Gabarito", sans-serif', file: '/fonts/Gabarito-Regular.woff2' },
{ id: 'lora', label: 'Lora', family: '"Lora", serif', file: '/fonts/Lora-Regular.woff2' },
{ id: 'playfair', label: 'Playfair Display', family: '"Playfair Display", serif', file: '/fonts/PlayfairDisplay-Regular.woff2' },
{ id: 'archivo-black', label: 'Archivo Black', family: '"Archivo Black", sans-serif', file: '/fonts/ArchivoBlack-Regular.woff2' },
{ id: 'bebas', label: 'Bebas Neue', family: '"Bebas Neue", sans-serif', file: '/fonts/BebasNeue-Regular.woff2' },
{ id: 'press-start', label: 'Press Start 2P', family: '"Press Start 2P", monospace', file: '/fonts/PressStart2P-Regular.woff2' },
{ id: 'source-code', label: 'Source Code Pro', family: '"Source Code Pro", monospace', file: '/fonts/SourceCodePro-Regular.woff2' },
{ id: 'marker', label: 'Permanent Marker', family: '"Permanent Marker", cursive', file: '/fonts/PermanentMarker-Regular.woff2' },
]
// ----- Custom (user-uploaded) fonts -----------------------------------------
const CUSTOM_FONTS_KEY = 'animatr:customFonts'
const CUSTOM_ID_PREFIX = 'custom-'
interface CustomFontStored {
id: string
label: string
/** data:font/woff2;base64,... — also accepts ttf / otf data URLs. */
dataUrl: string
}
interface CustomFontsBlob {
version: 1
fonts: CustomFontStored[]
}
function readCustomFontsFromStorage(): CustomFontStored[] {
if (typeof localStorage === 'undefined') return []
try {
const raw = localStorage.getItem(CUSTOM_FONTS_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as CustomFontsBlob
if (parsed?.version !== 1 || !Array.isArray(parsed.fonts)) return []
return parsed.fonts.filter(f => f && typeof f.id === 'string' && typeof f.dataUrl === 'string')
} catch {
return []
}
}
function persistCustomFonts(fonts: CustomFontStored[]): void {
if (typeof localStorage === 'undefined') return
const blob: CustomFontsBlob = { version: 1, fonts }
localStorage.setItem(CUSTOM_FONTS_KEY, JSON.stringify(blob))
}
const customFontsState = ref<CustomFontStored[]>(readCustomFontsFromStorage())
function customFamilyName(id: string): string {
// Sanitize so the id can be embedded in a font-family identifier
// without escaping. Custom ids only ever come from cryptoId() so
// they're already safe, but we belt-and-suspenders for safety.
return `RUWIO Custom ${id.replace(/[^a-zA-Z0-9_-]/g, '')}`
}
function customToDef(c: CustomFontStored): FontDef {
const fam = customFamilyName(c.id)
return {
id: c.id,
label: c.label,
family: `"${fam}", sans-serif`,
file: c.dataUrl,
custom: true,
}
}
/** Reactive list of all available fonts: built-ins + user-uploaded. */
export const AVAILABLE_FONTS = computed<FontDef[]>(() => {
return [...BUILTIN_FONTS, ...customFontsState.value.map(customToDef)]
})
function fontByIdSnapshot(id: string): FontDef | undefined {
for (const f of BUILTIN_FONTS) if (f.id === id) return f
const c = customFontsState.value.find(x => x.id === id)
return c ? customToDef(c) : undefined
}
export function fontDef(id: string | undefined | null): FontDef {
if (!id) return SYSTEM_SANS
return fontByIdSnapshot(id) ?? SYSTEM_SANS
}
export function fontCssFamily(id: string | undefined | null): string {
return fontDef(id).family
}
/** Primary CSS family name only (no fallback list). Needed for
* ctx.font shorthand and FontFace registration. */
export function fontPrimary(id: string | undefined | null): string {
const f = fontDef(id).family
const head = f.split(',', 1)[0]?.trim() ?? f
return head.replace(/^"(.*)"$/, '$1')
}
let loadPromise: Promise<void> | null = null
function registerFontFace(family: string, src: string, format?: string): Promise<void> {
if (typeof document === 'undefined' || !document.fonts) return Promise.resolve()
const srcSpec = format ? `url(${src}) format('${format}')` : `url(${src})`
const face = new FontFace(family, srcSpec, {
style: 'normal',
weight: '400',
display: 'swap',
})
return face.load().then(loaded => {
document.fonts.add(loaded)
})
}
export function loadFonts(): Promise<void> {
if (loadPromise) return loadPromise
if (typeof document === 'undefined' || !document.fonts) {
loadPromise = Promise.resolve()
return loadPromise
}
const builtinTasks = BUILTIN_FONTS
.filter(f => f.file)
.map(f =>
registerFontFace(fontPrimary(f.id), f.file, 'woff2').catch(() => {
// Best-effort. Failure to load a single font shouldn't block
// the editor — text falls back to the next family in the list.
}),
)
const customTasks = customFontsState.value.map(c =>
registerFontFace(customFamilyName(c.id), c.dataUrl).catch(() => {}),
)
loadPromise = Promise.all([...builtinTasks, ...customTasks]).then(() => {
fontsReady.value = true
})
return loadPromise
}
// ----- Upload / remove API --------------------------------------------------
function cryptoId(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID().replace(/-/g, '')
return 'x' + Math.random().toString(36).slice(2) + Date.now().toString(36)
}
function dataUrlFor(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'))
reader.readAsDataURL(file)
})
}
const ACCEPTED_EXT = /\.(woff2?|ttf|otf)$/i
export async function uploadCustomFont(file: File): Promise<FontDef> {
if (!ACCEPTED_EXT.test(file.name)) {
throw new Error('Unsupported font format. Use .woff2, .woff, .ttf, or .otf.')
}
const dataUrl = await dataUrlFor(file)
const id = `${CUSTOM_ID_PREFIX}${cryptoId()}`
const label = file.name.replace(ACCEPTED_EXT, '')
// Register with FontFace before persisting so a corrupt file fails
// loud instead of poisoning localStorage.
await registerFontFace(customFamilyName(id), dataUrl)
const next: CustomFontStored = { id, label, dataUrl }
const all = [...customFontsState.value, next]
try {
persistCustomFonts(all)
} catch (err) {
// Quota: report a friendly message but keep the live registration
// (the font still works for this session, it just won't survive
// a reload).
throw new Error(
'Could not save font to local storage — likely quota full. The font is loaded for this session only.',
)
}
customFontsState.value = all
return customToDef(next)
}
export function removeCustomFont(id: string): void {
const all = customFontsState.value.filter(f => f.id !== id)
if (all.length === customFontsState.value.length) return
persistCustomFonts(all)
customFontsState.value = all
// Drop the FontFace too so a freshly added font with the same id
// isn't shadowed (in practice the id is random so this is defensive).
if (typeof document !== 'undefined' && document.fonts) {
const fam = customFamilyName(id)
const faces = Array.from(document.fonts as unknown as Iterable<FontFace>)
for (const f of faces) if (f.family === fam) document.fonts.delete(f)
}
}
export function isCustomFontId(id: string | undefined | null): boolean {
return !!id && id.startsWith(CUSTOM_ID_PREFIX)
}
/** Snapshot of {id → CSS family} used by the export player engine.
* Built fresh per export so user-uploaded fonts get included. */
export function fontFamilyMapSnapshot(): Record<string, string> {
const out: Record<string, string> = {}
for (const f of BUILTIN_FONTS) out[f.id] = f.family
for (const c of customFontsState.value) out[c.id] = customToDef(c).family
return out
}
/** @font-face block users can paste into their own CSS when bundling
* the example HTML — used by the export preview hint. Custom fonts
* are emitted with the data-URL inlined so the snippet is self-
* contained even though the export itself doesn't bundle them. */
export function fontFaceCss(ids: string[]): string {
const seen = new Set<string>()
const out: string[] = []
for (const id of ids) {
if (seen.has(id)) continue
seen.add(id)
const f = fontByIdSnapshot(id)
if (!f || !f.file) continue
if (f.custom) {
out.push(
`@font-face {\n font-family: '${customFamilyName(f.id)}';\n src: url('${f.file}');\n font-weight: 400;\n font-style: normal;\n font-display: swap;\n}`,
)
} else {
out.push(
`@font-face {\n font-family: '${fontPrimary(f.id)}';\n src: url('.${f.file}') format('woff2');\n font-weight: 400;\n font-style: normal;\n font-display: swap;\n}`,
)
}
}
return out.join('\n')
}
+6 -10
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render'
import { renderSceneFrameForOutput } from './camera'
import { loadSceneImages } from './render'
/** End-of-playback time: clamp to the scene's last keyframe if the
* scene explicitly ends earlier than its nominal duration. Matches
@@ -81,14 +82,6 @@ export async function renderFrames(
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('2D context unavailable')
// Scale scene coords into target pixels. drawSceneFrame runs its
// own save/restore so the transform survives across frames, and
// any internal `setTransform(1,0,0,1,0,0)` (top-level mask
// compositing) is sandwiched inside its own save/restore too.
const scaleX = width / srcWidth
const scaleY = height / srcHeight
if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY)
const images = await loadSceneImages(scene)
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn)
@@ -106,7 +99,10 @@ export async function renderFrames(
throw new DOMException('aborted', 'AbortError')
}
const t = rangeIn + i / fps
drawSceneFrame(ctx, scene, Math.min(t, rangeOut), images)
renderSceneFrameForOutput(ctx, scene, Math.min(t, rangeOut), images, {
width,
height,
})
await onFrame({
scene,
canvas,
+37 -13
View File
@@ -1,4 +1,5 @@
import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
import { applyWiggle } from './wiggle'
const easings: Record<Easing, (t: number) => number> = {
linear: (t) => t,
@@ -68,6 +69,22 @@ export function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): An
if (a.strokeWidth !== undefined && b.strokeWidth !== undefined) out.strokeWidth = lerp(a.strokeWidth, b.strokeWidth, t)
else if (b.strokeWidth !== undefined) out.strokeWidth = b.strokeWidth
else if (a.strokeWidth !== undefined) out.strokeWidth = a.strokeWidth
// Kaleido pivot defaults to the layer's own (x, y) when unset on a
// keyframe — so a one-sided override (only b has kaleidoPivotX, a
// doesn't) lerps from a.x → b.kaleidoPivotX instead of snapping to
// b's value at t=0+. Same on the reverse direction.
{
const ax = a.kaleidoPivotX
const bx = b.kaleidoPivotX
if (ax !== undefined || bx !== undefined) {
out.kaleidoPivotX = lerp(ax ?? a.x, bx ?? b.x, t)
}
const ay = a.kaleidoPivotY
const by = b.kaleidoPivotY
if (ay !== undefined || by !== undefined) {
out.kaleidoPivotY = lerp(ay ?? a.y, by ?? b.y, t)
}
}
if (a.points && b.points && a.points.length === b.points.length) {
out.points = a.points.map((pa, i) => {
const pb = b.points![i]
@@ -220,18 +237,25 @@ export function isButtonLayerActiveAt(layer: Layer, time: number): boolean {
export function sampleLayer(layer: Layer, time: number): AnimatableProps {
const main = sampleKeyframeSet(layer.keyframes, layer.base, time)
if (!layer.separated) return main
const posSource = layer.positionKeyframes
const rotSource = layer.rotationKeyframes
const pos = posSource ? sampleKeyframeSet(posSource, layer.base, time) : main
const rot = rotSource ? sampleKeyframeSet(rotSource, layer.base, time) : main
return {
...main,
x: pos.x,
y: pos.y,
rotation: rot.rotation,
let result: AnimatableProps
if (!layer.separated) {
result = main
} else {
const posSource = layer.positionKeyframes
const rotSource = layer.rotationKeyframes
const pos = posSource ? sampleKeyframeSet(posSource, layer.base, time) : main
const rot = rotSource ? sampleKeyframeSet(rotSource, layer.base, time) : main
result = {
...main,
x: pos.x,
y: pos.y,
rotation: rot.rotation,
}
}
// Wiggle stacks on top of the keyframe sample — every renderer goes
// through this function so the modifier lights up everywhere
// (canvas2d, Konva stage, button-state, clip-internal). The export
// engine mirrors the same math in JS.
if (layer.wiggle) return applyWiggle(result, layer, time)
return result
}
+151
View File
@@ -0,0 +1,151 @@
// Math helpers for the kaleidoscope feature. Kept as pure data so
// the canvas2d offline renderer (lib/render.ts), the Konva live
// stage (components/CanvasStage.vue), AND the exported runtime
// (lib/exportPlayer.ts engine source — by mirroring the formulas in
// JS) all derive identical geometry from the same definitions.
import type { AnimatableProps, Layer, ClipInstance } from '@/types/scene'
/** Number of radial copies to render. Returns 1 when kaleido is off
* or the count is below the floor — the caller should treat 1 as
* "no kaleido, just render once unchanged". */
export function kaleidoCountFor(entity: { kaleido?: boolean; kaleidoCount?: number }): number {
if (!entity.kaleido) return 1
const n = Math.floor(entity.kaleidoCount ?? 0)
if (n < 2) return 1
return n
}
/** Per-copy radial angle in degrees. Index 0 → 0° (the source). */
export function kaleidoAngleDegrees(index: number, count: number): number {
if (count <= 1) return 0
return (index * 360) / count
}
/** ClipInstance kaleido pivot in world space. Defaults to the
* instance's own position when no explicit pivot is set. */
export function clipKaleidoPivot(inst: ClipInstance): { x: number; y: number } {
const ix = inst.x ?? 0
const iy = inst.y ?? 0
return {
x: inst.kaleidoPivotX ?? ix,
y: inst.kaleidoPivotY ?? iy,
}
}
/** Per-arm local clip time (seconds). Returns `null` when the arm is
* outside the clip's playable range — i.e. the arm hasn't started
* yet (its delayed start is still in the future) or it has already
* finished. Callers must skip arms where this returns null.
*
* Semantics: arm `i` is delayed by `i * kaleidoOffset` seconds
* relative to the source. So with offset = 0.5s and four arms, at
* scene time `t` arm 0 shows the source frame, arm 1 shows the
* frame from 0.5s earlier in the clip's playback (equivalent to
* "arm 1 starts 0.5s after arm 0"), arm 2 from 1s earlier, etc.
* Before its delayed start the arm is hidden; after the clip's end
* it stays gone.
*/
export function clipArmLocalTime(
inst: ClipInstance,
baseLocal: number,
index: number,
trimmedDuration: number,
): number | null {
if (trimmedDuration <= 0) return null
const offset = inst.kaleidoOffset ?? 0
const trimStart = inst.trimStart ?? 0
const offsetIntoBlock = baseLocal - trimStart
// Arm 0 (and the no-shift case) just uses the source's own clip-
// local time; arms i>0 with a non-zero offset are delayed.
const shifted = (index === 0 || offset === 0)
? offsetIntoBlock
: offsetIntoBlock - index * offset
const EPS = 1e-9
if (shifted < -EPS || shifted > trimmedDuration + EPS) return null
return trimStart + Math.max(0, Math.min(trimmedDuration, shifted))
}
/**
* For rotateCopies=false: compute the rotated *position* of the
* instance's anchor around the kaleido pivot. The caller then
* translates by this rotated position (instead of the original
* (ix, iy)) so the contents land in a circle around the pivot
* without rotating themselves.
*/
export function clipArmTranslate(
inst: ClipInstance,
index: number,
count: number,
): { x: number; y: number } {
const ix = inst.x ?? 0
const iy = inst.y ?? 0
if (index === 0 || count <= 1) return { x: ix, y: iy }
const rotateCopies = inst.kaleidoRotateCopies !== false
if (rotateCopies) return { x: ix, y: iy }
const { x: px, y: py } = clipKaleidoPivot(inst)
const a = (kaleidoAngleDegrees(index, count) * Math.PI) / 180
const dx = ix - px
const dy = iy - py
return {
x: px + dx * Math.cos(a) - dy * Math.sin(a),
y: py + dx * Math.sin(a) + dy * Math.cos(a),
}
}
/** Bool helper: should the per-arm transform also rotate the
* contents (true) or only translate them around the pivot (false)?
* Default true mirrors the natural kaleidoscope look. Layers don't
* expose a toggle — they always rotate copies. */
export function shouldRotateClipCopies(inst: ClipInstance): boolean {
return inst.kaleidoRotateCopies !== false
}
/** Convenience wrapper for the layer side, where the math is
* simpler (contents always rotate with the copy). */
export function layerKaleidoCount(layer: Layer): number {
return kaleidoCountFor(layer)
}
/** Effective pivot for a layer. Lookup chain:
* 1. `props.kaleidoPivotX/Y` — keyframed value (lets the pivot
* animate)
* 2. `Layer.kaleidoPivotX/Y` — legacy static override (kept for
* back-compat with projects saved before the feature went
* keyframable)
* 3. layer's sampled (x, y) — default
*/
export function layerKaleidoPivot(
layer: Layer,
props: AnimatableProps,
): { x: number; y: number } {
return {
x: props.kaleidoPivotX ?? layer.kaleidoPivotX ?? props.x,
y: props.kaleidoPivotY ?? layer.kaleidoPivotY ?? props.y,
}
}
/** World-space (x, y, rotation) for arm `index` of a kaleido'd
* layer. The source (index 0) is unchanged. Higher indices rotate
* around the pivot — both their position AND their orientation,
* since layer kaleido always rotates copies. */
export function layerArmTransform(
layer: Layer,
props: AnimatableProps,
index: number,
count: number,
): { x: number; y: number; rotation: number } {
if (index === 0 || count <= 1) {
return { x: props.x, y: props.y, rotation: props.rotation }
}
const { x: px, y: py } = layerKaleidoPivot(layer, props)
const angle = kaleidoAngleDegrees(index, count)
const a = (angle * Math.PI) / 180
const dx = props.x - px
const dy = props.y - py
return {
x: px + dx * Math.cos(a) - dy * Math.sin(a),
y: py + dx * Math.sin(a) + dy * Math.cos(a),
rotation: props.rotation + angle,
}
}
+191 -112
View File
@@ -1,6 +1,17 @@
import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene, SvgAsset } from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene'
import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from './interpolate'
import { fontCssFamily } from './fonts'
import {
clipArmLocalTime,
clipArmTranslate,
clipKaleidoPivot,
kaleidoAngleDegrees,
kaleidoCountFor,
layerArmTransform,
layerKaleidoCount,
shouldRotateClipCopies,
} from './kaleido'
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
const result: Record<string, HTMLImageElement> = {}
@@ -149,11 +160,16 @@ function drawLayers(
if (!asset) continue
const p = sampleLayer(layer, time)
ctx.save()
ctx.globalAlpha *= p.opacity
ctx.translate(p.x, p.y)
ctx.rotate((p.rotation * Math.PI) / 180)
ctx.scale(p.scaleX, p.scaleY)
// Kaleido is disabled on button-asset layers (button assets render
// their own internal layers and click routing must stay 1:1).
const kCount = asset.kind === 'button' ? 1 : layerKaleidoCount(layer)
for (let kf = 0; kf < kCount; kf++) {
const tf = layerArmTransform(layer, p, kf, kCount)
ctx.save()
ctx.globalAlpha *= p.opacity
ctx.translate(tf.x, tf.y)
ctx.rotate((tf.rotation * Math.PI) / 180)
ctx.scale(p.scaleX, p.scaleY)
const shadow = computeShadow(p)
applyShadow(ctx, shadow)
@@ -202,7 +218,7 @@ function drawLayers(
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const fill = p.fill ?? asset.fill
ctx.font = `${asset.fontSize}px sans-serif`
ctx.font = `${asset.fontSize}px ${fontCssFamily(asset.fontFamily)}`
ctx.textBaseline = 'top'
if (fill && fill !== 'transparent') {
ctx.fillStyle = fill
@@ -260,8 +276,63 @@ function drawLayers(
drawMaskedGroup(ctx, asset.layers, assetMap, images, 0, undefined, true)
}
clearShadow(ctx)
ctx.restore()
clearShadow(ctx)
ctx.restore()
}
}
}
/** Renders one clip instance — including kaleidoscope replication
* when configured. Shared by the top-level scene loop and the
* in-group child-drawing path so both honour the same kaleido math. */
function renderClipInstance(
target: CanvasRenderingContext2D,
inst: import('@/types/scene').ClipInstance,
clip: import('@/types/scene').Clip,
time: number,
assetMap: Map<string, Asset>,
images: Record<string, HTMLImageElement>,
) {
const trimStart = inst.trimStart ?? 0
const trimEnd = inst.trimEnd ?? 0
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
const offsetIntoBlock = time - inst.startTime
const isx = inst.scaleX ?? 1
const isy = inst.scaleY ?? 1
const irot = inst.rotation ?? 0
const kCount = kaleidoCountFor(inst)
const rotateCopies = shouldRotateClipCopies(inst)
const pivot = clipKaleidoPivot(inst)
const baseLocal = trimStart + offsetIntoBlock
// With kaleido + offset, delayed arms can still be valid after the
// source's window has ended (they started later, so they finish
// later). The early-out for the source's window only applies when
// there's no delay-staggered arm to render.
const offsetSec = inst.kaleidoOffset ?? 0
const maxArmDelay = kCount > 1 && offsetSec !== 0 ? Math.max(0, (kCount - 1) * offsetSec) : 0
if (offsetIntoBlock < 0 || offsetIntoBlock > visible + maxArmDelay) return
for (let kf = 0; kf < kCount; kf++) {
const kAngle = kaleidoAngleDegrees(kf, kCount)
const localT = clipArmLocalTime(inst, baseLocal, kf, visible)
if (localT === null) continue
const trans = clipArmTranslate(inst, kf, kCount)
target.save()
if (kCount > 1 && kf > 0 && rotateCopies) {
// Rotate the entire local space around the pivot — contents
// follow the rotation, giving the natural kaleido look.
target.translate(pivot.x, pivot.y)
target.rotate((kAngle * Math.PI) / 180)
target.translate(-pivot.x, -pivot.y)
}
target.translate(trans.x, trans.y)
if (irot !== 0) target.rotate((irot * Math.PI) / 180)
target.scale(isx, isy)
drawMaskedGroup(target, clip.layers, assetMap, images, localT)
target.restore()
}
}
@@ -285,38 +356,45 @@ function drawMaskShape(
if (!isButtonLayerActiveAt(layer, time)) return
} else if (!isLayerActiveAt(layer, time)) return
const p = sampleLayer(layer, time)
ctx.save()
ctx.translate(p.x, p.y)
ctx.rotate((p.rotation * Math.PI) / 180)
ctx.scale(p.scaleX, p.scaleY)
ctx.fillStyle = '#000000'
if (asset.kind === 'rect') {
const offX = layer.offsetX ?? asset.width / 2
const offY = layer.offsetY ?? asset.height / 2
ctx.fillRect(-offX, -offY, asset.width, asset.height)
} else if (asset.kind === 'circle') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
ctx.beginPath()
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
ctx.fill()
} else if (asset.kind === 'text') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
ctx.font = `${asset.fontSize}px sans-serif`
ctx.textBaseline = 'top'
ctx.fillText(asset.text, -offX, -offY)
} else {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const pts = p.points ?? asset.points
const closed = asset.closed !== false
if (closed && pts.length >= 3) {
tracePolygonPath(ctx, pts, asset.points, offX, offY, true)
// Mask layers honour kaleido — every radial copy contributes to the
// composite mask buffer, so the cumulative mask is the union of the
// N rotated shapes.
const kCount = layerKaleidoCount(layer)
for (let kf = 0; kf < kCount; kf++) {
const tf = layerArmTransform(layer, p, kf, kCount)
ctx.save()
ctx.translate(tf.x, tf.y)
ctx.rotate((tf.rotation * Math.PI) / 180)
ctx.scale(p.scaleX, p.scaleY)
ctx.fillStyle = '#000000'
if (asset.kind === 'rect') {
const offX = layer.offsetX ?? asset.width / 2
const offY = layer.offsetY ?? asset.height / 2
ctx.fillRect(-offX, -offY, asset.width, asset.height)
} else if (asset.kind === 'circle') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
ctx.beginPath()
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
ctx.fill()
} else if (asset.kind === 'text') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
ctx.font = `${asset.fontSize}px ${fontCssFamily(asset.fontFamily)}`
ctx.textBaseline = 'top'
ctx.fillText(asset.text, -offX, -offY)
} else {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const pts = p.points ?? asset.points
const closed = asset.closed !== false
if (closed && pts.length >= 3) {
tracePolygonPath(ctx, pts, asset.points, offX, offY, true)
ctx.fill()
}
}
ctx.restore()
}
ctx.restore()
}
/** Render the contents of a scene-level TrackGroup. Unlike
@@ -343,22 +421,27 @@ function drawTrackGroup(
images: Record<string, HTMLImageElement>,
buttonStates?: Map<string, ButtonState>,
): void {
// Partition into masks vs. renderable ids (layer ids + clip-track
// ids). Editor-hidden clip tracks drop out here so the cascade
// matches the Konva stage.
const masks: Layer[] = []
const renderIds: string[] = []
// Walk the child list bottom-up so the mask cascade follows
// stack order: a mask masks layers below it but not layers
// above it. Items are kept as their resolved kind (layer or
// clip-track id) plus a flag for masks; the loop below applies
// each item to the offscreen buffer in z-order.
type Item =
| { kind: 'mask'; layer: Layer }
| { kind: 'layer'; id: string }
| { kind: 'clip'; id: string }
const items: Item[] = []
for (let k = childIds.length - 1; k >= 0; k--) {
const cid = childIds[k]
const layer = layerById.get(cid)
if (layer) {
if (layer.isMask) masks.push(layer)
else renderIds.push(cid)
if (layer.isMask) items.push({ kind: 'mask', layer })
else items.push({ kind: 'layer', id: cid })
continue
}
if (clipTrackById.get(cid)?.editorHidden) continue
if (instancesByTrack.has(cid)) {
renderIds.push(cid)
items.push({ kind: 'clip', id: cid })
}
// Script / audio tracks don't paint on the canvas — skip silently.
}
@@ -373,31 +456,15 @@ function drawTrackGroup(
for (const inst of instances) {
const clip = clipById.get(inst.clipId)
if (!clip) continue
const trimStart = inst.trimStart ?? 0
const trimEnd = inst.trimEnd ?? 0
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
const offsetIntoBlock = time - inst.startTime
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
const ix = inst.x ?? 0
const iy = inst.y ?? 0
const isx = inst.scaleX ?? 1
const isy = inst.scaleY ?? 1
const irot = inst.rotation ?? 0
const needsTransform =
ix !== 0 || iy !== 0 || isx !== 1 || isy !== 1 || irot !== 0
if (needsTransform) {
target.save()
target.translate(ix, iy)
if (irot !== 0) target.rotate((irot * Math.PI) / 180)
target.scale(isx, isy)
}
drawLayers(target, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
if (needsTransform) target.restore()
renderClipInstance(target, inst, clip, time, assetMap, images)
}
}
if (masks.length === 0) {
for (const id of renderIds) drawChild(ctx, id)
const hasMask = items.some((it) => it.kind === 'mask')
if (!hasMask) {
for (const it of items) {
if (it.kind === 'layer' || it.kind === 'clip') drawChild(ctx, it.id)
}
return
}
@@ -406,19 +473,26 @@ function drawTrackGroup(
off.height = ctx.canvas.height
const offCtx = off.getContext('2d')
if (!offCtx) {
for (const id of renderIds) drawChild(ctx, id)
for (const it of items) {
if (it.kind === 'layer' || it.kind === 'clip') drawChild(ctx, it.id)
}
return
}
const m = ctx.getTransform()
offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
for (const id of renderIds) drawChild(offCtx, id)
for (const mask of masks) {
if (!isLayerActiveAt(mask, time)) continue
offCtx.save()
offCtx.globalCompositeOperation =
mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
drawMaskShape(offCtx, mask, assetMap, time)
offCtx.restore()
// Incremental cascade: render content / apply mask in z-order.
for (const it of items) {
if (it.kind === 'mask') {
const mask = it.layer
if (!isLayerActiveAt(mask, time)) continue
offCtx.save()
offCtx.globalCompositeOperation =
mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
drawMaskShape(offCtx, mask, assetMap, time)
offCtx.restore()
continue
}
drawChild(offCtx, it.id)
}
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
@@ -435,9 +509,8 @@ function drawMaskedGroup(
buttonStates?: Map<string, ButtonState>,
buttonMode?: boolean,
) {
const masks = children.filter((l) => l.isMask)
const content = children.filter((l) => !l.isMask)
if (masks.length === 0) {
const hasMask = children.some((l) => l.isMask)
if (!hasMask) {
drawLayers(ctx, children, assetMap, images, time, buttonStates, buttonMode)
return
}
@@ -449,20 +522,38 @@ function drawMaskedGroup(
off.height = h
const offCtx = off.getContext('2d')
if (!offCtx) {
drawLayers(ctx, content, assetMap, images, time, buttonStates, buttonMode)
drawLayers(
ctx,
children.filter((l) => !l.isMask),
assetMap, images, time, buttonStates, buttonMode,
)
return
}
const m = ctx.getTransform()
offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
drawLayers(offCtx, content, assetMap, images, time, buttonStates, buttonMode)
for (const mask of masks) {
// Bottom-up traversal so masks only affect what was drawn below
// them in the z-order — matches the top-level drawSceneFrame
// cascade. A mask placed in the middle of the stack masks just
// the layers underneath; layers above the mask render on top
// unmasked. Was previously "collect all masks, apply to all
// content" which applied a mid-stack mask to layers above it too.
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i]
if (!child.visible) continue
if (child.editorHidden) continue
if (buttonMode) {
if (!isButtonLayerActiveAt(mask, time)) continue
} else if (!isLayerActiveAt(mask, time)) continue
offCtx.save()
offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
drawMaskShape(offCtx, mask, assetMap, time, buttonMode)
offCtx.restore()
if (!isButtonLayerActiveAt(child, time)) continue
} else if (!isLayerActiveAt(child, time)) continue
if (child.isMask) {
offCtx.save()
offCtx.globalCompositeOperation =
child.maskMode === 'negative' ? 'destination-out' : 'destination-in'
drawMaskShape(offCtx, child, assetMap, time, buttonMode)
offCtx.restore()
} else {
drawLayers(offCtx, [child], assetMap, images, time, buttonStates, buttonMode)
}
}
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
@@ -475,11 +566,18 @@ export function drawSceneFrame(
scene: Scene,
time: number,
images: Record<string, HTMLImageElement>,
options?: { crop?: boolean; buttonStates?: Map<string, ButtonState> },
options?: {
crop?: boolean
clear?: boolean
background?: boolean
buttonStates?: Map<string, ButtonState>
},
) {
const lw = scene.canvas.width
const lh = scene.canvas.height
const crop = options?.crop !== false
const clear = options?.clear !== false
const paintBackground = options?.background !== false
const buttonStates = options?.buttonStates
ctx.save()
@@ -492,13 +590,13 @@ export function drawSceneFrame(
// Mandatory for transparent-background scenes so subsequent frames
// don't accumulate alpha; harmless for opaque ones since `fillRect`
// below paints over everything.
ctx.clearRect(0, 0, lw, lh)
if (clear) ctx.clearRect(0, 0, lw, lh)
// A background video takes over the backdrop — leave the stage area
// transparent so the DOM video underneath is visible. Same treatment
// when the canvas background is explicitly 'transparent' so
// APNG/WebM exports carry the alpha channel out to the file.
const bg = scene.canvas.background
if (!scene.backgroundVideo && bg && bg !== 'transparent') {
if (paintBackground && !scene.backgroundVideo && bg && bg !== 'transparent') {
ctx.fillStyle = bg
ctx.fillRect(0, 0, lw, lh)
}
@@ -508,7 +606,7 @@ export function drawSceneFrame(
const layerById = new Map<string, Layer>()
for (const l of scene.layers) layerById.set(l.id, l)
const instancesByTrack = new Map<string, typeof scene.clipInstances>()
const instancesByTrack = new Map<string, NonNullable<typeof scene.clipInstances>>()
for (const ci of scene.clipInstances ?? []) {
const arr = instancesByTrack.get(ci.clipTrackId) ?? []
arr.push(ci)
@@ -577,26 +675,7 @@ export function drawSceneFrame(
for (const inst of instances) {
const clip = clipById.get(inst.clipId)
if (!clip) continue
const trimStart = inst.trimStart ?? 0
const trimEnd = inst.trimEnd ?? 0
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
const offsetIntoBlock = time - inst.startTime
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
const ix = inst.x ?? 0
const iy = inst.y ?? 0
const isx = inst.scaleX ?? 1
const isy = inst.scaleY ?? 1
const irot = inst.rotation ?? 0
const needsTransform =
ix !== 0 || iy !== 0 || isx !== 1 || isy !== 1 || irot !== 0
if (needsTransform) {
drawCtx.save()
drawCtx.translate(ix, iy)
if (irot !== 0) drawCtx.rotate((irot * Math.PI) / 180)
drawCtx.scale(isx, isy)
}
drawLayers(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
if (needsTransform) drawCtx.restore()
renderClipInstance(drawCtx, inst, clip, time, assetMap, images)
}
continue
}
+4 -1
View File
@@ -119,7 +119,10 @@ function visitSrcs(project: Project, visit: (h: SrcHolder) => string): void {
}
for (const page of project.scenes) {
if (page.backgroundVideo?.src) {
const replaced = visit({ src: page.backgroundVideo.src, name: page.backgroundVideo.name })
// BackgroundVideo has no `name` field — synthesise one from the
// page so the extracted file gets a readable filename in the
// zip (e.g. "scene-bg-page1.mp4" instead of a uuid).
const replaced = visit({ src: page.backgroundVideo.src, name: `scene-bg-${page.id}` })
if (replaced !== page.backgroundVideo.src) page.backgroundVideo.src = replaced
}
}
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more