Compare commits
10
Commits
c17e2e8a9f
...
184842b2e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
184842b2e0 | ||
|
|
a2fdd1684a | ||
|
|
9e25cf155a | ||
|
|
7d81a50e74 | ||
|
|
80e1510b53 | ||
|
|
27d6a47b72 | ||
|
|
8c4f744afc | ||
|
|
4c2b7c7c35 | ||
|
|
b32b8a6a3d | ||
|
|
127812d3bf |
No files matched your search
+108
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Wraps the browser's EyeDropper API (Chromium 95+). Provides a
|
||||
// reactive `supported` flag plus a `pick()` helper that resolves to
|
||||
// the chosen sRGBHex string (or null on cancel/abort). Callers
|
||||
// should hide the pipette button when `supported` is false to keep
|
||||
// Safari + Firefox UX clean.
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface EyeDropperResult {
|
||||
sRGBHex: string
|
||||
}
|
||||
|
||||
interface EyeDropperConstructor {
|
||||
new (): { open(opts?: { signal?: AbortSignal }): Promise<EyeDropperResult> }
|
||||
}
|
||||
|
||||
function detectSupport(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof (window as unknown as { EyeDropper?: unknown }).EyeDropper === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
export function useEyeDropper() {
|
||||
// Re-detect on every call so a test (or a polyfill that loads
|
||||
// late) can see updates without juggling Vite's module cache.
|
||||
const supported = ref(detectSupport())
|
||||
async function pick(): Promise<string | null> {
|
||||
if (!detectSupport()) return null
|
||||
const Ctor = (window as unknown as { EyeDropper: EyeDropperConstructor }).EyeDropper
|
||||
try {
|
||||
const result = await new Ctor().open()
|
||||
return result.sRGBHex
|
||||
} catch {
|
||||
// User pressed Esc / dismissed the picker — treat as cancel.
|
||||
return null
|
||||
}
|
||||
}
|
||||
return { supported, pick }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const 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 */
|
||||
}
|
||||
})
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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',
|
||||
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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><!-- once per page -->
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '1.2.2'
|
||||
export const APP_VERSION = '1.2.12'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user