diff --git a/server/render.html b/server/render.html index 4c1dd90..9059329 100644 --- a/server/render.html +++ b/server/render.html @@ -106,13 +106,17 @@ ctx.fillStyle=fill; ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height); } else if(asset.kind==='circle'){ + var cOffX=layer.offsetX!=null?layer.offsetX:0; + var cOffY=layer.offsetY!=null?layer.offsetY:0; ctx.fillStyle=fill; - ctx.beginPath(); ctx.arc(0,0,asset.radius,0,Math.PI*2); ctx.fill(); + ctx.beginPath(); ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2); ctx.fill(); } else if(asset.kind==='text'){ + var tOffX=layer.offsetX!=null?layer.offsetX:0; + var tOffY=layer.offsetY!=null?layer.offsetY:0; ctx.fillStyle=fill; ctx.font=asset.fontSize+'px sans-serif'; ctx.textBaseline='top'; - ctx.fillText(asset.text,0,0); + ctx.fillText(asset.text,-tOffX,-tOffY); } else if(asset.kind==='image'){ var img=images[asset.id]; if(img){ diff --git a/src/components/AssetSidebar.vue b/src/components/AssetSidebar.vue index 9c8dad1..6fcd0f8 100644 --- a/src/components/AssetSidebar.vue +++ b/src/components/AssetSidebar.vue @@ -1,8 +1,10 @@ + { + + + diff --git a/src/components/ExportDialog.vue b/src/components/ExportDialog.vue index 9db7e12..78315b6 100644 --- a/src/components/ExportDialog.vue +++ b/src/components/ExportDialog.vue @@ -3,12 +3,13 @@ import { onBeforeUnmount, onMounted, ref } from 'vue' import { PhCode, PhDownloadSimple, + PhEngine, PhFileHtml, PhFilmSlate, PhX, } from '@phosphor-icons/vue' import { useSceneStore } from '@/stores/scene' -import { downloadExampleHtml, downloadExport } from '@/lib/exportPlayer' +import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer' import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport' import { downloadFromServer } from '@/lib/serverExport' @@ -31,8 +32,13 @@ function sceneClone() { return JSON.parse(JSON.stringify(store.scene)) } -function exportJs() { - downloadExport(sceneClone()) +function exportScene() { + downloadSceneExport(sceneClone()) + close() +} + +function exportRuntime() { + downloadRuntime() close() } @@ -133,14 +139,26 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
- + + diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 227adf0..528d8ff 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -1,8 +1,18 @@
+

Einbinden in eine eigene Seite

-
<div style="width: 800px; height: 450px;">
+    
<!-- einmal pro Seite -->
+<script src="animatr-runtime.js"></script>
+
+<!-- je Animation ein Container + Scene-Script -->
+<div style="width: 800px; height: 450px;">
   <script src="animation.js"></script>
+</div>
+
+<div style="width: 400px; height: 225px;">
+  <script src="animation-2.js"></script>
 </div>
diff --git a/src/lib/interpolate.ts b/src/lib/interpolate.ts index e43714a..f55d276 100644 --- a/src/lib/interpolate.ts +++ b/src/lib/interpolate.ts @@ -56,20 +56,42 @@ export function sortedKeyframes(layer: Layer): Keyframe[] { return [...layer.keyframes].sort((a, b) => a.time - b.time) } -export function sampleLayer(layer: Layer, time: number): AnimatableProps { - const kfs = sortedKeyframes(layer) - if (!kfs.length) return { ...layer.base } - if (time <= kfs[0].time) return merge(layer.base, kfs[0].props) - const last = kfs[kfs.length - 1] - if (time >= last.time) return merge(layer.base, last.props) - for (let i = 0; i < kfs.length - 1; i++) { - const a = kfs[i] - const b = kfs[i + 1] +function sampleKeyframeSet( + kfs: Keyframe[] | undefined, + base: AnimatableProps, + time: number, +): AnimatableProps { + if (!kfs || !kfs.length) return { ...base } + const sorted = [...kfs].sort((a, b) => a.time - b.time) + if (time <= sorted[0].time) return merge(base, sorted[0].props) + const last = sorted[sorted.length - 1] + if (time >= last.time) return merge(base, last.props) + for (let i = 0; i < sorted.length - 1; i++) { + const a = sorted[i] + const b = sorted[i + 1] if (time >= a.time && time <= b.time) { const raw = (time - a.time) / (b.time - a.time) const t = easings[b.easing](raw) - return lerpProps(merge(layer.base, a.props), merge(layer.base, b.props), t) + return lerpProps(merge(base, a.props), merge(base, b.props), t) } } - return { ...layer.base } + return { ...base } +} + +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, + } } diff --git a/src/lib/render.ts b/src/lib/render.ts index d33dfd8..7f041e4 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -61,15 +61,19 @@ export function drawSceneFrame( ctx.fillStyle = p.fill ?? asset.fill ctx.fillRect(-offX, -offY, asset.width, asset.height) } else if (asset.kind === 'circle') { + const offX = layer.offsetX ?? 0 + const offY = layer.offsetY ?? 0 ctx.fillStyle = p.fill ?? asset.fill ctx.beginPath() - ctx.arc(0, 0, asset.radius, 0, Math.PI * 2) + 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.fillStyle = p.fill ?? asset.fill ctx.font = `${asset.fontSize}px sans-serif` ctx.textBaseline = 'top' - ctx.fillText(asset.text, 0, 0) + ctx.fillText(asset.text, -offX, -offY) } else if (asset.kind === 'image') { const img = images[asset.id] if (img) { diff --git a/src/stores/scene.ts b/src/stores/scene.ts index d05b072..05be3ce 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -16,6 +16,80 @@ interface State { playing: boolean } +function allKeyframes(layer: Layer): Keyframe[] { + const out: Keyframe[] = [...layer.keyframes] + if (layer.positionKeyframes) out.push(...layer.positionKeyframes) + if (layer.rotationKeyframes) out.push(...layer.rotationKeyframes) + return out +} + +function findKf( + layer: Layer, + keyframeId: string, +): { kf: Keyframe; arr: Keyframe[] } | null { + for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { + if (!arr) continue + const kf = arr.find((k) => k.id === keyframeId) + if (kf) return { kf, arr } + } + return null +} + +function sortKfs(arr: Keyframe[]) { + arr.sort((a, b) => a.time - b.time) +} + +function upsertInto( + arr: Keyframe[], + time: number, + props: Partial, + snapshotProps: Partial | null, +) { + const existing = arr.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON) + if (existing) { + existing.props = { ...existing.props, ...props } + return + } + const kf: Keyframe = { + id: uid(), + time, + props: snapshotProps ? { ...snapshotProps, ...props } : { ...props }, + easing: 'linear', + } + arr.push(kf) + sortKfs(arr) +} + +const POSITION_KEYS = new Set(['x', 'y']) +const ROTATION_KEYS = new Set(['rotation']) + +function pickProps( + src: Partial, + keys: Set, +): Partial { + const out: Partial = {} + for (const [k, v] of Object.entries(src)) { + if (keys.has(k) && v !== undefined) (out as Record)[k] = v + } + return out +} + +function omitProps( + src: Partial, + keys: Set, +): Partial { + const out: Partial = {} + for (const [k, v] of Object.entries(src)) { + if (!keys.has(k) && v !== undefined) (out as Record)[k] = v + } + return out +} + +function hasKeys(obj: Partial): boolean { + for (const _ in obj) return true + return false +} + export const useSceneStore = defineStore('scene', { state: (): State => ({ scene: { @@ -34,7 +108,7 @@ export const useSceneStore = defineStore('scene', { lastKeyframeTime(state): number { let max = 0 for (const layer of state.scene.layers) { - for (const kf of layer.keyframes) { + for (const kf of allKeyframes(layer)) { if (kf.time > max) max = kf.time } } @@ -43,7 +117,7 @@ export const useSceneStore = defineStore('scene', { effectiveEnd(state): number { let max = 0 for (const layer of state.scene.layers) { - for (const kf of layer.keyframes) { + for (const kf of allKeyframes(layer)) { if (kf.time > max) max = kf.time } } @@ -80,9 +154,14 @@ export const useSceneStore = defineStore('scene', { }, ], } - if (asset && (asset.kind === 'rect' || asset.kind === 'image')) { - layer.offsetX = asset.width / 2 - layer.offsetY = asset.height / 2 + if (asset) { + if (asset.kind === 'rect' || asset.kind === 'image') { + layer.offsetX = asset.width / 2 + layer.offsetY = asset.height / 2 + } else if (asset.kind === 'circle' || asset.kind === 'text') { + layer.offsetX = 0 + layer.offsetY = 0 + } } this.scene.layers.push(layer) this.selectedLayerId = layer.id @@ -115,39 +194,56 @@ export const useSceneStore = defineStore('scene', { upsertKeyframe(layerId: string, time: number, props: Partial) { const layer = this.scene.layers.find((l) => l.id === layerId) if (!layer) return - const existing = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON) - if (existing) { - existing.props = { ...existing.props, ...props } + if (!layer.separated) { + const snapshot = sampleLayer(layer, time) + upsertInto(layer.keyframes, time, props, snapshot) return } - const snapshot = sampleLayer(layer, time) - const kf: Keyframe = { - id: uid(), - time, - props: { ...snapshot, ...props }, - easing: 'linear', + const posProps = pickProps(props, POSITION_KEYS) + const rotProps = pickProps(props, ROTATION_KEYS) + const otherProps = omitProps(props, new Set(['x', 'y', 'rotation'])) + if (hasKeys(posProps)) { + const arr = (layer.positionKeyframes ||= []) + upsertInto(arr, time, posProps, null) + } + if (hasKeys(rotProps)) { + const arr = (layer.rotationKeyframes ||= []) + upsertInto(arr, time, rotProps, null) + } + if (hasKeys(otherProps)) { + upsertInto(layer.keyframes, time, otherProps, null) } - layer.keyframes.push(kf) - layer.keyframes.sort((a, b) => a.time - b.time) }, removeKeyframeAt(layerId: string, time: number) { const layer = this.scene.layers.find((l) => l.id === layerId) if (!layer) return - layer.keyframes = layer.keyframes.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON) + const filter = (kfs?: Keyframe[]) => + kfs ? kfs.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON) : kfs + layer.keyframes = filter(layer.keyframes) ?? [] + if (layer.positionKeyframes) layer.positionKeyframes = filter(layer.positionKeyframes) + if (layer.rotationKeyframes) layer.rotationKeyframes = filter(layer.rotationKeyframes) }, - setKeyframeEasing(layerId: string, time: number, easing: Easing) { + removeKeyframe(layerId: string, keyframeId: string) { const layer = this.scene.layers.find((l) => l.id === layerId) if (!layer) return - const kf = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON) - if (kf) kf.easing = easing + const found = findKf(layer, keyframeId) + if (!found) return + const idx = found.arr.indexOf(found.kf) + if (idx >= 0) found.arr.splice(idx, 1) + }, + setKeyframeEasing(layerId: string, keyframeId: string, easing: Easing) { + const layer = this.scene.layers.find((l) => l.id === layerId) + if (!layer) return + const found = findKf(layer, keyframeId) + if (found) found.kf.easing = easing }, setKeyframeTime(layerId: string, keyframeId: string, newTime: number) { const layer = this.scene.layers.find((l) => l.id === layerId) if (!layer) return - const kf = layer.keyframes.find((k) => k.id === keyframeId) - if (!kf) return - kf.time = Math.max(0, Math.min(this.scene.duration, newTime)) - layer.keyframes.sort((a, b) => a.time - b.time) + const found = findKf(layer, keyframeId) + if (!found) return + found.kf.time = Math.max(0, Math.min(this.scene.duration, newTime)) + sortKfs(found.arr) }, shiftLayerPivot( layerId: string, @@ -162,21 +258,26 @@ export const useSceneStore = defineStore('scene', { layer.offsetY = (layer.offsetY ?? 0) + dLocalY layer.base.x += dWorldX layer.base.y += dWorldY - for (const kf of layer.keyframes) { - if (kf.props.x !== undefined) kf.props.x += dWorldX - if (kf.props.y !== undefined) kf.props.y += dWorldY + const shift = (kfs?: Keyframe[]) => { + if (!kfs) return + for (const kf of kfs) { + if (kf.props.x !== undefined) kf.props.x += dWorldX + if (kf.props.y !== undefined) kf.props.y += dWorldY + } } + shift(layer.keyframes) + shift(layer.positionKeyframes) }, duplicateKeyframe(layerId: string, keyframeId: string): string | null { const layer = this.scene.layers.find((l) => l.id === layerId) if (!layer) return null - const source = layer.keyframes.find((k) => k.id === keyframeId) - if (!source) return null + const found = findKf(layer, keyframeId) + if (!found) return null + const { kf: source, arr } = found const fps = this.scene.fps > 0 ? this.scene.fps : 60 const offset = Math.max(3 / fps, 0.1) const duration = this.scene.duration - const occupied = (t: number) => - layer.keyframes.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON) + const occupied = (t: number) => arr.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON) let target = source.time + offset while (occupied(target) && target < duration) target += offset if (target > duration) target = duration @@ -192,10 +293,76 @@ export const useSceneStore = defineStore('scene', { props: { ...source.props }, easing: source.easing, } - layer.keyframes.push(newKf) - layer.keyframes.sort((a, b) => a.time - b.time) + arr.push(newKf) + sortKfs(arr) return newKf.id }, + setLayerSeparated(layerId: string, separated: boolean) { + const layer = this.scene.layers.find((l) => l.id === layerId) + if (!layer) return + if (!!layer.separated === separated) return + if (separated) { + const posKfs: Keyframe[] = [] + const rotKfs: Keyframe[] = [] + const remaining: Keyframe[] = [] + for (const kf of layer.keyframes) { + const posProps = pickProps(kf.props, POSITION_KEYS) + const rotProps = pickProps(kf.props, ROTATION_KEYS) + const rest = omitProps(kf.props, new Set(['x', 'y', 'rotation'])) + if (hasKeys(posProps)) { + posKfs.push({ id: uid(), time: kf.time, props: posProps, easing: kf.easing }) + } + if (hasKeys(rotProps)) { + rotKfs.push({ id: uid(), time: kf.time, props: rotProps, easing: kf.easing }) + } + if (hasKeys(rest)) { + remaining.push({ id: uid(), time: kf.time, props: rest, easing: kf.easing }) + } + } + sortKfs(posKfs) + sortKfs(rotKfs) + sortKfs(remaining) + layer.positionKeyframes = posKfs + layer.rotationKeyframes = rotKfs + layer.keyframes = remaining + layer.separated = true + } else { + interface Bucket { + props: Partial + easing: Easing + } + const byTime = new Map() + const addAll = (kfs?: Keyframe[]) => { + if (!kfs) return + for (const kf of kfs) { + const key = Math.round(kf.time * 1e6) + let bucket = byTime.get(key) + if (!bucket) { + bucket = { props: {}, easing: kf.easing } + byTime.set(key, bucket) + } + bucket.props = { ...bucket.props, ...kf.props } + } + } + addAll(layer.keyframes) + addAll(layer.positionKeyframes) + addAll(layer.rotationKeyframes) + const merged: Keyframe[] = [] + for (const [key, bucket] of byTime.entries()) { + merged.push({ + id: uid(), + time: key / 1e6, + props: bucket.props, + easing: bucket.easing, + }) + } + sortKfs(merged) + layer.keyframes = merged + layer.positionKeyframes = undefined + layer.rotationKeyframes = undefined + layer.separated = false + } + }, play() { if (this.playing) return this.playing = true diff --git a/src/types/scene.ts b/src/types/scene.ts index 2b7bccc..9840946 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -63,6 +63,9 @@ export interface Layer { visible: boolean base: AnimatableProps keyframes: Keyframe[] + separated?: boolean + positionKeyframes?: Keyframe[] + rotationKeyframes?: Keyframe[] offsetX?: number offsetY?: number } diff --git a/todo b/todo new file mode 100644 index 0000000..3bfc053 --- /dev/null +++ b/todo @@ -0,0 +1,22 @@ +rotation povot gibts nur bei rect und das objekt rotiert nicht um den pivot sondern immer ums zentrum + +assets sidebar braucht kleine preview thumbnails die man an und aus machen kann + +bei bedarf muss man eine spur in der timeline auf "separate position and rotation" umstellen können. dazu führen wir ein context-menü auf spuren ein + +die animations-engine muss von der eigentlichen animation getrennt sein weil man ja auch mehrere animationen auf einer webseite haben kann und dann will man das nicht alles redundant haben + +die aktuell nicht bearbeiteten timelines müssen etwas weniger opacity haben + +control-drag muss die bühne verschieben + +bei timeline zoom wird bei der aktiven spur der keyframe und connect line vorne nicht weggeschnitten + + +der playhead soll kein gedrehtes quadrat sein so wie die keyframes sondern ein aufrechtes rect + + +baue eine frontent testing engine. mit shift+control+alt+t muss such die app dann selbst durchtesten. schreibe tests für die funktionen die wir bisher haben + + +