diff --git a/package.json b/package.json index 84ade20..9c0aeab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "animatr", "private": true, - "version": "0.5.6", + "version": "0.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/render.html b/server/render.html index a5aa5d4..9e4ed4c 100644 --- a/server/render.html +++ b/server/render.html @@ -269,8 +269,10 @@ var lw=scene.canvas.width, lh=scene.canvas.height; ctx.save(); ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip(); - ctx.fillStyle=scene.canvas.background; - ctx.fillRect(0,0,lw,lh); + if(!scene.backgroundVideo){ + ctx.fillStyle=scene.canvas.background; + ctx.fillRect(0,0,lw,lh); + } var am={}; for(var i=0;i=0;oi--){ var id=rawOrder[oi]; var lyr=layerById[id]; - if(lyr){ drawLayerNode(lyr, am, time); continue; } + if(lyr){ + if(lyr.isMask){ + ctx.save(); + ctx.globalCompositeOperation=(lyr.maskMode==='negative'?'destination-out':'destination-in'); + drawMaskShape(ctx, lyr, am, time); + ctx.restore(); + continue; + } + drawLayerNode(lyr, am, time); continue; + } var tInsts=instByTrack[id]||[]; if(tInsts.length){ for(var ti=0;ti window.__animatrSeek(time), t) const buf = await page.screenshot({ type: 'png', diff --git a/src/App.vue b/src/App.vue index ae6fec9..af07034 100644 --- a/src/App.vue +++ b/src/App.vue @@ -46,7 +46,7 @@ import { } from './lib/projectStore' import { importImageFile } from './lib/fileLoader' import { useClipartStore } from './stores/clipart' -import { useSceneStore } from './stores/scene' +import { createBlankScene, useSceneStore } from './stores/scene' const settingsOpen = ref(false) const exportOpen = ref(false) @@ -137,7 +137,7 @@ const clipart = useClipartStore() const history = useHistory() function rewind() { - store.setPlayhead(0) + store.setPlayhead(store.playbackIn) } function stop() { @@ -215,6 +215,15 @@ function openFromRecent(project: StoredProject) { loadError.value = null } +function startNewProject() { + const blank = createBlankScene() + store.loadScene(blank) + history.resetTo(blank) + currentProjectId.value = null + currentProjectName.value = null + loadError.value = null +} + function showSavedToast(msg: string) { savedToast.value = msg setTimeout(() => { @@ -270,6 +279,24 @@ function onKeyDown(e: KeyboardEvent) { return } } + if (!isEditable(e.target) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { + const key = e.key.toLowerCase() + if (key === 'i') { + e.preventDefault() + store.setPlaybackRange(Math.min(store.playhead, store.playbackOut), null) + return + } + if (key === 'o') { + e.preventDefault() + store.setPlaybackRange(null, Math.max(store.playhead, store.playbackIn)) + return + } + if (key === 'r') { + e.preventDefault() + rewind() + return + } + } if (e.code !== 'Space') return if (isEditable(e.target)) return e.preventDefault() @@ -436,7 +463,7 @@ onBeforeUnmount(() => { diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 3023fce..f1ff55f 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -7,7 +7,7 @@ import { sampleLayer } from '@/lib/interpolate' import { importImageFile, loadImage } from '@/lib/fileLoader' import { useClipartStore } from '@/stores/clipart' import { drawSceneFrame, loadSceneImages } from '@/lib/render' -import type { AnimatableProps, Asset, Layer, Point } from '@/types/scene' +import type { AnimatableProps, Asset, Keyframe, Layer, Point } from '@/types/scene' import { t } from '@/i18n' import { useKfSelection } from '@/composables/useKfSelection' import { showLocked } from '@/composables/useShowLocked' @@ -20,6 +20,7 @@ const { selectedKfs } = useKfSelection() const container = ref(null) const containerSize = ref({ width: 0, height: 0 }) const stageRef = ref<{ getStage(): Konva.Stage } | null>(null) +const bgVideoEl = ref(null) const transformerRef = ref<{ getNode(): Konva.Transformer } | null>(null) const zoom = ref(1) @@ -85,14 +86,88 @@ const clipGroupConfig = computed(() => { } }) -const backgroundConfig = computed(() => ({ - x: 0, - y: 0, - width: store.scene.canvas.width, - height: store.scene.canvas.height, - fill: store.scene.canvas.background, - listening: false, -})) +const backgroundConfig = computed(() => { + // Always set `fill` — toggling it in/out of the config across scene + // reloads can leave Konva's Rect with a stale attribute. Use a + // transparent fill when a background video is present so the DOM + // video shows through. + const fill = store.scene.backgroundVideo + ? 'transparent' + : store.scene.canvas.background || '#ffffff' + return { + x: 0, + y: 0, + width: store.scene.canvas.width, + height: store.scene.canvas.height, + listening: false, + fill, + } +}) + +const bgVideoStyle = computed(() => { + const w = store.scene.canvas.width * zoom.value + const h = store.scene.canvas.height * zoom.value + return { + left: `${viewX.value}px`, + top: `${viewY.value}px`, + width: `${w}px`, + height: `${h}px`, + } +}) + +// Playhead sync for the background video — only resync current time on bigger +// deltas so regular playback stays smooth. +const BG_VIDEO_SYNC_THRESHOLD = 0.12 +watch( + () => store.playing, + async (playing) => { + const v = bgVideoEl.value + if (!v) return + if (playing) { + v.currentTime = store.playhead + try { + await v.play() + } catch { + /* autoplay might be blocked if the tab has no user gesture yet */ + } + } else { + v.pause() + v.currentTime = store.playhead + } + }, +) + +watch( + () => store.playhead, + (t) => { + const v = bgVideoEl.value + if (!v) return + const diff = Math.abs(v.currentTime - t) + if (!store.playing) { + v.currentTime = t + return + } + if (diff > BG_VIDEO_SYNC_THRESHOLD) v.currentTime = t + }, +) + +// When the video element mounts (or the src changes) do an initial sync. +watch( + () => store.scene.backgroundVideo?.src, + async () => { + await nextTick() + const v = bgVideoEl.value + if (!v) return + v.currentTime = store.playhead + if (store.playing) { + try { + await v.play() + } catch { + /* ignored */ + } + } + }, +) const canvasBorderConfig = computed(() => ({ x: 0, @@ -1288,11 +1363,15 @@ interface PathSegment { flat: number[] } +interface ActiveSegment { + keyframeId: string + frames: PathFrame[] +} + interface PathOverlay { layerId: string - activeKeyframeId: string | null ghostSegments: PathSegment[] - activeFrames: PathFrame[] + activeSegments: ActiveSegment[] } function sampleSegment(layer: Layer, from: number, to: number, fps: number): number[] { @@ -1313,26 +1392,60 @@ const animationPath = computed(() => { if (sel.length === 0) return null const first = sel[0] if (first.kind === 'rotation') return null - const layer = store.activeLayers.find((l) => l.id === first.layerId) - if (!layer) return null - const arr = first.kind === 'position' ? layer.positionKeyframes : layer.keyframes + const origLayer = store.activeLayers.find((l) => l.id === first.layerId) + if (!origLayer) return null + const arr = first.kind === 'position' ? origLayer.positionKeyframes : origLayer.keyframes if (!arr) return null - const sorted = [...arr].sort((a, b) => a.time - b.time) - const idx = sorted.findIndex((k) => k.id === first.keyframeId) - if (idx < 0) return null + + // While the user drags this layer's object, clone its keyframes so the + // path reflects the live position without mutating the store. If a kf + // at the playhead already exists, override its x/y; otherwise splice + // in a synthetic one so the path gets a live endpoint. + const live = liveLayerPose.value + let layer = origLayer + let workingArr = arr + if (live && live.layerId === origLayer.id) { + const cloned: Keyframe[] = arr.map((k) => ({ ...k, props: { ...k.props } })) + const existing = cloned.find((k) => Math.abs(k.time - store.playhead) < 1e-3) + if (existing) { + existing.props.x = live.x + existing.props.y = live.y + } else { + cloned.push({ + id: '__live__', + time: store.playhead, + props: { x: live.x, y: live.y }, + easing: 'linear', + }) + } + cloned.sort((a, b) => a.time - b.time) + workingArr = cloned + layer = + first.kind === 'position' + ? { ...origLayer, positionKeyframes: cloned } + : { ...origLayer, keyframes: cloned } + } + + // Every selected keyframe on this layer+kind gets an interactive + // active segment (the incoming curve). Other segments stay ghost. + const selectedIds = new Set() + for (const s of sel) { + if (s.layerId !== first.layerId) continue + if (s.kind !== first.kind) continue + selectedIds.add(s.keyframeId) + } + + const sorted = [...workingArr].sort((a, b) => a.time - b.time) const fps = store.scene.fps || 60 const ghostSegments: PathSegment[] = [] - // All segments between consecutive keyframes become ghost segments, - // except the one leading to the selected keyframe which gets interactive dots. - let activeFrames: PathFrame[] = [] - let activeKeyframeId: string | null = null + const activeSegments: ActiveSegment[] = [] for (let i = 0; i < sorted.length - 1; i++) { const a = sorted[i] const b = sorted[i + 1] const dt = b.time - a.time if (dt <= 0) continue - if (b.id === first.keyframeId) { + if (selectedIds.has(b.id)) { const n = Math.max(2, Math.min(240, Math.round(dt * fps))) const frames: PathFrame[] = [] for (let k = 0; k <= n; k++) { @@ -1341,19 +1454,17 @@ const animationPath = computed(() => { const s = sampleLayer(layer, time) frames.push({ index: k, x: s.x, y: s.y, u }) } - activeFrames = frames - activeKeyframeId = b.id + activeSegments.push({ keyframeId: b.id, frames }) } else { const flat = sampleSegment(layer, a.time, b.time, fps) if (flat.length >= 4) ghostSegments.push({ flat }) } } - if (!ghostSegments.length && activeFrames.length === 0) return null + if (!ghostSegments.length && activeSegments.length === 0) return null return { layerId: layer.id, - activeKeyframeId, ghostSegments, - activeFrames, + activeSegments, } }) @@ -1376,38 +1487,65 @@ const animationGhostSegments = computed(() => { })) }) -const animationPathLineConfig = computed(() => { +const animationPathLines = computed(() => { const path = animationPath.value - if (!path || path.activeFrames.length < 2) return null - const flat: number[] = [] - for (const f of path.activeFrames) flat.push(f.x, f.y) - return { - points: flat, - stroke: '#6ea8ff', - strokeWidth: 1.25 / zoom.value, - strokeScaleEnabled: false, - listening: false, - lineCap: 'round', - lineJoin: 'round', + if (!path) return [] + return path.activeSegments + .filter((s) => s.frames.length >= 2) + .map((s) => { + const flat: number[] = [] + for (const f of s.frames) flat.push(f.x, f.y) + return { + key: `apl-${s.keyframeId}`, + config: { + points: flat, + stroke: '#6ea8ff', + strokeWidth: 1.25 / zoom.value, + strokeScaleEnabled: false, + listening: false, + lineCap: 'round', + lineJoin: 'round', + }, + } + }) +}) + +interface PathDot { + key: string + keyframeId: string + frameIndex: number + frameTotal: number + x: number + y: number + isEnd: boolean +} + +const animationPathDots = computed(() => { + const path = animationPath.value + if (!path) return [] + const out: PathDot[] = [] + for (const seg of path.activeSegments) { + const last = seg.frames.length - 1 + for (const f of seg.frames) { + out.push({ + key: `pf-${seg.keyframeId}-${f.index}`, + keyframeId: seg.keyframeId, + frameIndex: f.index, + frameTotal: last, + x: f.x, + y: f.y, + isEnd: f.index === 0 || f.index === last, + }) + } } + return out }) -const animationPathDots = computed(() => { - const path = animationPath.value - if (!path || !path.activeKeyframeId) return [] - return path.activeFrames.map((f) => ({ - key: `pf-${path.activeKeyframeId}-${f.index}`, - frameIndex: f.index, - x: f.x, - y: f.y, - isEnd: f.index === 0 || f.index === path.activeFrames.length - 1, - })) -}) +const hoveredPathDot = ref(null) -const hoveredPathDot = ref(null) - -function animationDotConfig(d: { x: number; y: number; isEnd: boolean; frameIndex: number }) { - const hovered = !d.isEnd && hoveredPathDot.value === d.frameIndex +function animationDotConfig(d: PathDot) { + const hoverKey = `${d.keyframeId}:${d.frameIndex}` + const hovered = !d.isEnd && hoveredPathDot.value === hoverKey const baseRadius = d.isEnd ? 4 : 2.5 const radius = hovered ? 6 : baseRadius return { @@ -1424,9 +1562,9 @@ function animationDotConfig(d: { x: number; y: number; isEnd: boolean; frameInde } } -function onPathDotMouseEnter(frameIndex: number, isEnd: boolean) { - if (isEnd) return - hoveredPathDot.value = frameIndex +function onPathDotMouseEnter(d: PathDot) { + if (d.isEnd) return + hoveredPathDot.value = `${d.keyframeId}:${d.frameIndex}` const stage = stageRef.value?.getStage() const cont = stage?.container() if (cont) cont.style.cursor = 'grab' @@ -1439,26 +1577,24 @@ function onPathDotMouseLeave() { if (cont) cont.style.cursor = '' } -function onPathDotDragMove(e: Konva.KonvaEventObject, frameIndex: number) { +function onPathDotDragMove(e: Konva.KonvaEventObject, d: PathDot) { const path = animationPath.value - if (!path || !path.activeKeyframeId) return + if (!path) return const node = e.target const wx = node.x() const wy = node.y() - const total = path.activeFrames.length - 1 - if (total <= 0) return - const uRaw = frameIndex / total + if (d.frameTotal <= 0) return + const uRaw = d.frameIndex / d.frameTotal if (uRaw <= 0 || uRaw >= 1) return const layer = store.activeLayers.find((l) => l.id === path.layerId) if (!layer) return - const arr = selectedKfs.value[0]?.kind === 'position' - ? layer.positionKeyframes - : layer.keyframes - const kf = arr?.find((k) => k.id === path.activeKeyframeId) + const sel = selectedKfs.value.find((s) => s.keyframeId === d.keyframeId) + const arr = sel?.kind === 'position' ? layer.positionKeyframes : layer.keyframes + const kf = arr?.find((k) => k.id === d.keyframeId) if (!kf) return const easing = kf.easing const t = applyEasing(uRaw, easing) - store.setKeyframeControlFromPoint(path.layerId, path.activeKeyframeId, t, wx, wy) + store.setKeyframeControlFromPoint(path.layerId, d.keyframeId, t, wx, wy) } function applyEasing(t: number, easing: string): number { @@ -1744,7 +1880,7 @@ function handleDragEnd(e: Konva.KonvaEventObject) { if (!layer) return const newX = node.x() const newY = node.y() - if (selectedKfs.value.length > 0) { + if (selectedKfs.value.length > 0 && anySelectedKfAtPlayhead()) { const samp = sampleLayer(layer, store.playhead) const dx = newX - samp.x const dy = newY - samp.y @@ -1753,10 +1889,40 @@ function handleDragEnd(e: Konva.KonvaEventObject) { } } else { store.upsertKeyframe(id, store.playhead, { x: newX, y: newY }) + selectKfAtPlayhead(layer, layer.separated ? 'position' : 'main') } liveLayerPose.value = null } +function anySelectedKfAtPlayhead(): boolean { + for (const k of selectedKfs.value) { + const l = store.activeLayers.find((x) => x.id === k.layerId) + if (!l) continue + const arr = + k.kind === 'position' + ? l.positionKeyframes + : k.kind === 'rotation' + ? l.rotationKeyframes + : l.keyframes + const kf = arr?.find((kf) => kf.id === k.keyframeId) + if (kf && Math.abs(kf.time - store.playhead) < 1e-3) return true + } + return false +} + +function selectKfAtPlayhead(layer: Layer, kind: 'main' | 'position' | 'rotation') { + const arr = + kind === 'position' + ? layer.positionKeyframes + : kind === 'rotation' + ? layer.rotationKeyframes + : layer.keyframes + if (!arr) return + const kf = arr.find((k) => Math.abs(k.time - store.playhead) < 1e-3) + if (!kf) return + selectedKfs.value = [{ layerId: layer.id, kind, keyframeId: kf.id }] +} + function handleTransformEnd(e: Konva.KonvaEventObject) { const node = e.target const id = node.id() @@ -2086,6 +2252,16 @@ onBeforeUnmount(() => {