Compare commits

...
10 Commits
Author SHA1 Message Date
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
43 changed files with 4387 additions and 328 deletions

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
FUTURE FUN: THREE FEATURES TO LAND
==================================
End-of-day backlog of ideas Claude pitched and the user liked. Each
sized for ~one patch bump. Wiggle goes first; the others can wait.
1. WIGGLE — procedural shake modifier (per layer)
-------------------------------------------------
Add a "Wiggle" property to Layer. When on, every sampled frame
adds deterministic noise to position and/or rotation, giving any
layer that classic animator wobble. Pairs thematically with Kaleido:
both are per-layer procedural modifiers stacked on top of the base
keyframe sample.
Data model (src/types/scene.ts):
wiggle?: {
amplitude?: number // pixels for x/y
rotation?: number // degrees for rotation
frequency?: number // Hz (cycles per second)
}
// Defaults when `wiggle` is set but a sub-field is missing:
// amplitude=8px, rotation=2°, frequency=2Hz. Toggle off ⇒ delete.
Math (src/lib/wiggle.ts) — shared truth:
function layerSeed(id: string): [number, number, number]
// 3 stable 32-bit ints derived from the layer id, used as
// independent seeds for x / y / rotation noise streams.
function wiggleNoise(seed: number, t: number): number
// Deterministic [-1, 1] output. Sum of three sin waves with
// different irrational phase offsets — cheap, smooth, no
// dependency on a noise lib.
return (
sin(seed * 12.9898 + t * 78.233) +
sin(seed * 4.31 + t * 11.71 ) +
sin(seed * 7.71 + t * 23.5 )
) / 3
function applyWiggle(props, layer, time): props'
// Adds amp * noise(seedX, t * freq) to props.x, etc.
Wire-in:
- src/lib/interpolate.ts sampleLayer: post-process the result of
the keyframe sample with applyWiggle. ALL renderers go through
sampleLayer (canvas2d, Konva, button-state, clip-internal), so a
single hook here lights up everywhere except the export engine.
- src/lib/exportPlayer.ts: mirror the math in JS inside the engine
source so exports look identical. RUNTIME_VERSION bump.
UI (src/components/PropertiesPanel.vue + i18n):
- New "Wiggle" collapsible section, between Mask and Kaleido feels
natural. Toggle (PhWaveSine icon), then three RangeSliders:
amplitude (0–64 px), rotation (0–45°), frequency (0–10 Hz).
Hidden for button-asset layers (consistent with Kaleido).
Store (src/stores/scene.ts):
- setLayerWiggle(id, patch) with same delete-on-default semantics
as Kaleido (rotation=0 ⇒ delete the field, etc.).
Tests:
- wiggleNoise determinism: noise(seed, t) === noise(seed, t).
- wiggleNoise smoothness: |n(t) - n(t+1/60)| stays bounded.
- sampleLayer with wiggle: x/y/rotation drift around base, return
to base when wiggle is off.
- Engine source carries wiggleNoise; same sample at the same time
matches between editor and engine.
- PropertiesPanel renders the new section + writes wiggle fields
back to the store.
2. MOTION-PATH OVERLAY (selected layer's position keyframes)
------------------------------------------------------------
Visualize a layer's keyframed path on the canvas as a faint dashed
line connecting consecutive position keyframes, with each keyframe
rendered as a draggable dot. The position-keyframe drag-handle
infrastructure (PathFrame, activeSegments in CanvasStage.vue ~2562)
is already 70% there — currently only used when a position-kf is
explicitly being dragged. Promote that overlay to "always on while
the layer is selected".
Scope:
- Toggle in the View menu (or a slash-key shortcut) called "Motion
paths" — global flag persisted in localStorage like
`useShowLocked`.
- For the selected layer (and only the selected layer), draw the
overlay even when nothing is being dragged. Reuse the existing
segment / curve-sampling helpers.
- Color follows layer.color so two layers' paths don't collide
visually.
Bigger lift than Wiggle but mostly leveraging existing code.
3. EYEDROPPER (color sampling from anywhere on the page)
--------------------------------------------------------
Tiny feature using the browser's EyeDropper API. Adds a small
pipette icon next to every color swatch in the PropertiesPanel
(fill, stroke, shadow color). Click → fires `new EyeDropper().open()`
→ user picks any pixel on screen → result sets the swatch.
Caveats:
- Chromium-only API. Hide the button when `window.EyeDropper` is
undefined (Safari + Firefox) — a clean progressive enhancement.
- Permission prompt is browser-handled, no extra plumbing.
Smallest of the three. Good "polish day" snack.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.2.2",
"version": "1.2.12",
"type": "module",
"scripts": {
"dev": "vite",
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.
+9 -1
View File
@@ -44,6 +44,7 @@ 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'
@@ -165,6 +166,7 @@ const isDirty = ref(false)
usePlaybackLoop()
useAudioRuntime()
const store = useSceneStore()
const { formatTime } = useTimeFormat()
const clipart = useClipartStore()
const history = useHistory()
const deselectAll = useDeselectAll()
@@ -631,7 +633,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">
@@ -928,6 +930,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. */
+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;
+5 -3
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,
@@ -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)
}
@@ -782,7 +784,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 +884,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
+298 -49
View File
@@ -9,6 +9,7 @@ import {
PhCursorClick,
PhFrameCorners,
PhMaskHappy,
PhPath,
PhPencilSimple,
PhPolygon,
PhSlidersHorizontal,
@@ -20,6 +21,14 @@ 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 type {
Action,
@@ -41,6 +50,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 +550,33 @@ 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 rotation.
* Konva rotates around the node's `(x, y)` world position (offset is
* the local-space anchor), so adding `kAngle` to `rotation` gives the
* desired rotation around the layer's pivot for free. Non-source
* copies are listening:false to keep events on the source. */
function withK<T extends Record<string, unknown>>(cfg: T, ki: number, n: number): T {
if (n <= 1 || ki === 0) return cfg
const kAngle = kaleidoAngleDegrees(ki, n)
const baseRot = typeof cfg.rotation === 'number' ? (cfg.rotation as number) : 0
return {
...cfg,
rotation: baseRot + kAngle,
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
@@ -906,6 +943,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 +1092,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 +1355,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 +1378,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 +1567,47 @@ 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
const trans = clipArmTranslate(inst, ki, n)
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 px = inst.kaleidoPivotX ?? (inst.x ?? 0)
const py = inst.kaleidoPivotY ?? (inst.y ?? 0)
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,
}
}
return {
...base,
x: trans.x,
y: trans.y,
listening: false,
draggable: false,
}
}
/** 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 +1711,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 +1859,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 +2187,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 +2431,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 +2453,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 +2485,7 @@ watch(
store.selectedClipInstanceId,
stackItems.value,
store.playhead,
polygonVertexEditId.value,
] as const,
async () => {
await nextTick()
@@ -2537,6 +2664,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 +2910,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 +2975,7 @@ 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 (vertexMenu.value) vertexMenu.value = null
if (target === target.getStage()) {
deselectAll()
@@ -2894,6 +3107,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 +4334,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), ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4157,6 +4384,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 +4425,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), ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4205,7 +4433,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), ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4213,7 +4441,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), ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4221,7 +4449,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), ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4229,21 +4457,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)!, 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 +4481,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'"
@@ -4419,6 +4649,17 @@ 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-line v-if="drawingPreviewConfig" :config="drawingPreviewConfig" />
<v-circle
v-for="d in drawingDotsConfig"
@@ -4517,6 +4758,14 @@ function onResizeHandlePointerUp(e: PointerEvent) {
>
<PhCursorClick :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>
+380
View File
@@ -0,0 +1,380 @@
<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>
<button
v-for="f in customFonts"
: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
type="button"
class="delete-custom"
title="Remove this font"
@click="onDelete(f.id, $event)"
>
<PhTrash :size="12" />
</button>
</button>
</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 .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;
opacity: 0;
}
.font-popover .font-option:hover .delete-custom {
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>
+4 -1
View File
@@ -4,6 +4,9 @@ import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat'
const { formatTime } = useTimeFormat()
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -240,7 +243,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>
+335 -5
View File
@@ -6,7 +6,10 @@ import {
PhDiamond,
PhEye,
PhEyeSlash,
PhEyedropper,
PhFlower,
PhMaskHappy,
PhWaveSine,
PhMinus,
PhPolygon,
PhPlus,
@@ -18,11 +21,30 @@ import { sampleLayer } from '@/lib/interpolate'
import type { Action, AnimatableProps, Layer } from '@/types/scene'
import RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue'
import FontPicker from './FontPicker.vue'
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' | 'kaleido' | 'wiggle'
const defaultOpen: Record<SectionKey, boolean> = {
transform: true,
@@ -32,6 +54,8 @@ const defaultOpen: Record<SectionKey, boolean> = {
shadow: false,
mask: false,
button: true,
kaleido: false,
wiggle: false,
}
function readStoredOpen(): Record<SectionKey, boolean> {
@@ -137,6 +161,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 +182,96 @@ 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)
function setKaleidoOn(on: boolean) {
if (!selected.value) return
store.setLayerKaleido(selected.value.id, on)
}
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) => {
@@ -453,7 +578,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 +609,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 +661,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 +693,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 +721,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 +757,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 +856,91 @@ function deleteSelection() {
</div>
</section>
<section v-if="canKaleido && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('wiggle')">
<component :is="sectionOpen.wiggle ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.wiggle') }}</span>
</button>
<div v-if="sectionOpen.wiggle" class="group-body">
<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>
</section>
<section v-if="canKaleido && !multiSelected" class="prop-group">
<button class="group-header" @click="toggleSection('kaleido')">
<component :is="sectionOpen.kaleido ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.kaleido') }}</span>
</button>
<div v-if="sectionOpen.kaleido" class="group-body">
<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"
@change="onKaleidoCountInput($event)"
/>
</label>
<p v-if="kaleidoOn" class="hint">{{ t('properties.kaleido.hint') }}</p>
</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 +963,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('kaleido')">
<component :is="sectionOpen.kaleido ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.kaleido') }}</span>
</button>
<div v-if="sectionOpen.kaleido" 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"
@change="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"
@change="setCiKaleidoOffset(Number(($event.target as HTMLInputElement).value))"
/>
</label>
<div v-if="ciKaleidoOn" class="row">
<label>
{{ t('properties.kaleido.pivotX') }}
<input
type="number"
:value="ciPivotX"
@change="setCiKaleido({ kaleidoPivotX: Number(($event.target as HTMLInputElement).value) })"
/>
</label>
<label>
{{ t('properties.kaleido.pivotY') }}
<input
type="number"
:value="ciPivotY"
@change="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 +1082,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;
+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;
+51 -5
View File
@@ -2169,7 +2169,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 +2664,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 +2787,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)
}
+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 }
}
+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,
}
}
+1889 -12
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -193,6 +193,7 @@ export const en = {
'Selected layer is locked. Unlock the track (or its group) to edit properties.',
fill: 'Fill',
transparent: 'Transparent',
eyedropper: 'Pick a colour from anywhere on the page',
opacity: 'Opacity',
edgeBlur: 'Edge blur',
sections: {
@@ -201,6 +202,33 @@ export const en = {
polygon: 'Polygon',
appearance: 'Appearance',
button: 'On Click',
kaleido: 'Kaleidoscope',
wiggle: 'Wiggle',
},
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.',
@@ -373,6 +401,10 @@ export const en = {
stagePreviewOff: 'Turn stage preview off — buttons behave as normal assets',
centerCrosshairOn: 'Show centre crosshair',
centerCrosshairOff: 'Hide centre crosshair',
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',
+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 }
+253 -104
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 = '32'
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;
@@ -127,21 +140,60 @@ 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 loadImages(assets){
@@ -676,6 +728,85 @@ 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; }
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 +815,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 ka=kalAngle(ki,kn);
ctxm.save();
ctxm.translate(p.x,p.y);
ctxm.rotate((p.rotation+ka)*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 +912,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 ka=kalAngle(ki,kn);
ctx.save();
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
ctx.translate(p.x,p.y);
ctx.rotate(p.rotation*Math.PI/180);
ctx.rotate((p.rotation+ka)*Math.PI/180);
ctx.scale(p.scaleX,p.scaleY);
var sOp=p.shadowOpacity||0;
var sBl=p.shadowBlur||0;
@@ -827,7 +977,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,6 +1072,7 @@ const ENGINE_SOURCE = `(function(){
ctx.shadowOffsetX=0;
ctx.shadowOffsetY=0;
ctx.restore();
}
}
function draw(time){
var lw=scene.canvas.width,lh=scene.canvas.height;
@@ -990,27 +1141,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 +1169,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 +1367,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 +1441,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,
@@ -1368,7 +1495,7 @@ export interface ExportOptions {
export function generateRuntimeScript(): string {
return (
'/* RUWIO runtime v' + RUNTIME_VERSION + ' — generated ' + new Date().toISOString() + ' */\n' +
ENGINE_SOURCE
engineSource()
)
}
@@ -1386,7 +1513,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 +1538,30 @@ 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 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 +1636,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')
}
+21 -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,
@@ -220,18 +221,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
}
+109
View File
@@ -0,0 +1,109 @@
// 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 { 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 (pivot is always the layer's own position, contents
* always rotate with the copy). */
export function layerKaleidoCount(layer: Layer): number {
return kaleidoCountFor(layer)
}
+180 -109
View File
@@ -1,6 +1,16 @@
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,
layerKaleidoCount,
shouldRotateClipCopies,
} from './kaleido'
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
const result: Record<string, HTMLImageElement> = {}
@@ -149,11 +159,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 kAngle = kaleidoAngleDegrees(kf, kCount)
ctx.save()
ctx.globalAlpha *= p.opacity
ctx.translate(p.x, p.y)
ctx.rotate(((p.rotation + kAngle) * Math.PI) / 180)
ctx.scale(p.scaleX, p.scaleY)
const shadow = computeShadow(p)
applyShadow(ctx, shadow)
@@ -202,7 +217,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 +275,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 +355,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 kAngle = kaleidoAngleDegrees(kf, kCount)
ctx.save()
ctx.translate(p.x, p.y)
ctx.rotate(((p.rotation + kAngle) * 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 +420,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 +455,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 +472,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 +508,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 +521,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)
@@ -508,7 +598,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 +667,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
}
}
+83
View File
@@ -0,0 +1,83 @@
// Procedural shake modifier — adds deterministic noise to a layer's
// sampled props so it visibly "wobbles" over time. The same time
// always yields the same value (so the editor stage, render.ts
// renderer and exported runtime stay in lockstep), and three
// independent noise streams (x / y / rotation) keep the wobble from
// looking like a single 1D oscillation.
//
// Mirrored as plain JS inside exportPlayer.ts's engine source —
// keep the formulas in `wiggleNoise` and `applyWiggle` byte-for-byte
// equivalent on both sides.
import type { AnimatableProps, Layer } from '@/types/scene'
const DEFAULT_AMPLITUDE = 8
const DEFAULT_ROTATION = 2
const DEFAULT_FREQUENCY = 2
/** Stable 32-bit hash of a string. djb2-ish — good enough for
* picking three uncorrelated seeds per layer. */
function hash32(s: string): number {
let h = 5381
for (let i = 0; i < s.length; i++) {
h = ((h << 5) + h + s.charCodeAt(i)) | 0
}
return h >>> 0
}
/** Three independent seeds for the layer's x, y, rotation noise
* streams. Derived deterministically from the layer id so the same
* layer always wobbles the same way. */
export function layerSeeds(layerId: string): [number, number, number] {
const h = hash32(layerId)
return [
(h ^ 0x9e3779b9) >>> 0,
(h ^ 0x85ebca6b) >>> 0,
(h ^ 0xc2b2ae35) >>> 0,
]
}
/** Sum-of-sines noise in [-1, 1]. Smooth (each input maps to a
* C∞ output) and cheap. The phase constants are irrational ratios
* picked so the three components don't collapse into a low-period
* pattern. */
export function wiggleNoise(seed: number, t: number): number {
const a = Math.sin(seed * 12.9898 + t * 78.233)
const b = Math.sin(seed * 4.31 + t * 11.71)
const c = Math.sin(seed * 7.71 + t * 23.5)
return (a + b + c) / 3
}
/** Returns true when the layer has wiggle on AND at least one of
* the magnitudes is non-zero. */
export function hasWiggle(layer: Layer): boolean {
const w = layer.wiggle
if (!w) return false
const amp = w.amplitude ?? DEFAULT_AMPLITUDE
const rot = w.rotation ?? DEFAULT_ROTATION
return amp > 0 || rot > 0
}
/** Adds wiggle to a layer's sampled props in place — caller passes
* in a mutable copy. Does nothing when the layer has no wiggle. */
export function applyWiggle(
props: AnimatableProps,
layer: Layer,
time: number,
): AnimatableProps {
const w = layer.wiggle
if (!w) return props
const amp = w.amplitude ?? DEFAULT_AMPLITUDE
const rot = w.rotation ?? DEFAULT_ROTATION
const freq = w.frequency ?? DEFAULT_FREQUENCY
if (amp <= 0 && rot <= 0) return props
if (freq <= 0) return props
const [sx, sy, sr] = layerSeeds(layer.id)
const tt = time * freq
return {
...props,
x: props.x + amp * wiggleNoise(sx, tt),
y: props.y + amp * wiggleNoise(sy, tt),
rotation: props.rotation + rot * wiggleNoise(sr, tt),
}
}
+6
View File
@@ -5,6 +5,12 @@ import App from './App.vue'
import './composables/useTheme'
import './styles/global.css'
import { vTooltip } from './directives/tooltip'
import { loadFonts } from './lib/fonts'
// Kick off font loading immediately. Konva text needs the FontFace to
// be resolved before the first paint, so we don't await — instead the
// stage observes document.fonts.ready and re-renders.
loadFonts()
const app = createApp(App)
app.use(createPinia())
+114 -6
View File
@@ -298,6 +298,36 @@ function sortKfs(arr: Keyframe[]) {
arr.sort((a, b) => a.time - b.time)
}
/** Every layer in the project that references the given asset id —
* across the main scene pages, every clip's layer stack, and every
* button / svg asset's nested layers. Used by polygon-mutation
* actions so a point insert / remove updates kf.props.points
* correctly regardless of which scope the layer lives in.
* Without this, scope-specific layers (e.g. a freeform inside an
* SVG asset) would have their kf.points drift out of sync with
* the asset's reference array. */
function layersUsingAsset(project: Project, assetId: string): Layer[] {
const out: Layer[] = []
for (const page of project.scenes ?? []) {
for (const layer of page.layers ?? []) {
if (layer.assetId === assetId) out.push(layer)
}
}
for (const clip of project.clips ?? []) {
for (const layer of clip.layers ?? []) {
if (layer.assetId === assetId) out.push(layer)
}
}
for (const a of project.assets ?? []) {
if (a.kind === 'button' || a.kind === 'svg') {
for (const layer of a.layers ?? []) {
if (layer.assetId === assetId) out.push(layer)
}
}
}
return out
}
function upsertInto(
arr: Keyframe[],
time: number,
@@ -1533,7 +1563,7 @@ export const useSceneStore = defineStore('scene', {
if (!asset || asset.kind !== 'polygon') return
const insertAt = Math.max(0, Math.min(atIndex, asset.points.length))
asset.points.splice(insertAt, 0, { x: point.x, y: point.y })
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
const layersUsing = layersUsingAsset(this.project, assetId)
for (const layer of layersUsing) {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
@@ -1562,7 +1592,7 @@ export const useSceneStore = defineStore('scene', {
return arr
}
asset.points = resize(asset.points)
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
const layersUsing = layersUsingAsset(this.project, assetId)
for (const layer of layersUsing) {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
@@ -1579,7 +1609,7 @@ export const useSceneStore = defineStore('scene', {
if (asset.points.length <= 3) return
if (pointIndex < 0 || pointIndex >= asset.points.length) return
asset.points.splice(pointIndex, 1)
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
const layersUsing = layersUsingAsset(this.project, assetId)
for (const layer of layersUsing) {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
@@ -1800,7 +1830,7 @@ export const useSceneStore = defineStore('scene', {
const type = asset.points[index].type
if (!type || type === 'corner') return
const t = this.playhead
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
const layersUsing = layersUsingAsset(this.project, assetId)
for (const layer of layersUsing) {
// Backfill any existing keyframes that have points but lack tangents
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
@@ -1870,7 +1900,7 @@ export const useSceneStore = defineStore('scene', {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
asset.points.reverse()
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
const layersUsing = layersUsingAsset(this.project, assetId)
for (const layer of layersUsing) {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
@@ -1897,7 +1927,7 @@ export const useSceneStore = defineStore('scene', {
x: prev.x + (next.x - prev.x) * tOnEdge,
y: prev.y + (next.y - prev.y) * tOnEdge,
})
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
const layersUsing = layersUsingAsset(this.project, assetId)
for (const layer of layersUsing) {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
@@ -2133,6 +2163,44 @@ export const useSceneStore = defineStore('scene', {
const n = name.trim()
if (n) layer.name = n
},
setLayerKaleido(id: string, on: boolean) {
const layer = this.activeLayers.find(l => l.id === id)
if (!layer) return
if (on) {
layer.kaleido = true
if (!layer.kaleidoCount || layer.kaleidoCount < 2) layer.kaleidoCount = 6
} else {
// Keep kaleidoCount so re-toggling restores the user's choice.
delete layer.kaleido
}
},
setLayerKaleidoCount(id: string, count: number) {
const layer = this.activeLayers.find(l => l.id === id)
if (!layer) return
const n = Math.max(2, Math.min(64, Math.floor(count)))
layer.kaleidoCount = n
},
setLayerWiggle(
id: string,
patch: Partial<{ amplitude: number; rotation: number; frequency: number }> | null,
) {
const layer = this.activeLayers.find(l => l.id === id)
if (!layer) return
if (patch === null) {
delete layer.wiggle
return
}
const w = (layer.wiggle ||= {})
if (patch.amplitude !== undefined) {
w.amplitude = Math.max(0, patch.amplitude)
}
if (patch.rotation !== undefined) {
w.rotation = Math.max(0, patch.rotation)
}
if (patch.frequency !== undefined) {
w.frequency = Math.max(0, patch.frequency)
}
},
setClipTrackName(id: string, name: string) {
const track = this.scene.clipTracks?.find((t) => t.id === id)
if (track) track.name = name.trim() || undefined
@@ -3215,6 +3283,46 @@ export const useSceneStore = defineStore('scene', {
else inst.rotation = r
}
},
setClipInstanceKaleido(
id: string,
patch: Partial<{
kaleido: boolean
kaleidoCount: number
kaleidoOffset: number
kaleidoRotateCopies: boolean
kaleidoPivotX: number
kaleidoPivotY: number
}>,
) {
const inst = this.scene.clipInstances?.find((ci) => ci.id === id)
if (!inst) return
if (patch.kaleido !== undefined) {
if (patch.kaleido) {
inst.kaleido = true
if (!inst.kaleidoCount || inst.kaleidoCount < 2) inst.kaleidoCount = 6
} else {
delete inst.kaleido
}
}
if (patch.kaleidoCount !== undefined) {
const n = Math.max(2, Math.min(64, Math.floor(patch.kaleidoCount)))
inst.kaleidoCount = n
}
if (patch.kaleidoOffset !== undefined) {
if (patch.kaleidoOffset === 0) delete inst.kaleidoOffset
else inst.kaleidoOffset = patch.kaleidoOffset
}
if (patch.kaleidoRotateCopies !== undefined) {
if (patch.kaleidoRotateCopies) delete inst.kaleidoRotateCopies
else inst.kaleidoRotateCopies = false
}
if (patch.kaleidoPivotX !== undefined) {
inst.kaleidoPivotX = patch.kaleidoPivotX
}
if (patch.kaleidoPivotY !== undefined) {
inst.kaleidoPivotY = patch.kaleidoPivotY
}
},
/** Create an empty group. When `anchorTrackId` is given the group
* lands directly above that track in trackOrder — resolving to
* the parent group's position if the anchor sits inside one. Falls
+40
View File
@@ -116,6 +116,8 @@ export interface TextAsset {
name: string
text: string
fontSize: number
/** Font id from src/lib/fonts.ts AVAILABLE_FONTS. Undefined = system sans. */
fontFamily?: string
fill: string
stroke?: string
strokeWidth?: number
@@ -228,6 +230,24 @@ export interface Layer {
onClick?: Action[]
/** Only meaningful for button layers: initial disabled flag at runtime. */
buttonDisabled?: boolean
/** Render N radial copies around the layer's pivot. Spatial only —
* layers don't get a per-copy time offset (that lives on
* ClipInstance, where it's the headline use case). */
kaleido?: boolean
/** ≥ 2, integer. Total visible copies = kaleidoCount. Disabled
* when `kaleido` is false. */
kaleidoCount?: number
/** Procedural shake. Three noise streams (x, y, rotation) add to
* the sampled props at render time. Deterministic per layer id
* so renders are stable across editor / export. */
wiggle?: {
/** Position amplitude in pixels. Default 8. */
amplitude?: number
/** Rotation amplitude in degrees. Default 2. */
rotation?: number
/** Cycles per second. Default 2. */
frequency?: number
}
}
export interface Clip {
@@ -285,6 +305,26 @@ export interface ClipInstance {
scaleY?: number
/** Degrees clockwise; applied around the group's origin. */
rotation?: number
/** Render the clip N times around `kaleidoPivotX/Y`. Each copy can
* be time-shifted via `kaleidoOffset` so the radial arms show
* different points of the clip's playback simultaneously. */
kaleido?: boolean
/** ≥ 2, integer. */
kaleidoCount?: number
/** Seconds. Local clip time for arm i is
* `(visibleOffsetIntoBlock - i * kaleidoOffset)` wrapped into the
* trimmed sub-window. Default 0 → all arms show the same frame. */
kaleidoOffset?: number
/** When true (default), each copy rotates so its content follows
* the radial direction. When false, copies are translated around
* the pivot but keep the source's orientation — a ring of identical
* un-rotated duplicates. */
kaleidoRotateCopies?: boolean
/** World-space pivot for the radial arrangement. Defaults to the
* instance's own (x, y). Setting it elsewhere lets the user place
* the kaleido centre off the clip. */
kaleidoPivotX?: number
kaleidoPivotY?: number
}
/**
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.2.2'
export const APP_VERSION = '1.2.12'
export const APP_NAME = 'RUWIO'