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(() => {
{ currentProjectId = null; currentProjectName = null }"
+ @new="startNewProject"
@import="importFromFile"
/>
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(() => {
+
{
:key="g.key"
:config="g.config"
/>
-
+
) => onPathDotDragMove(e, d.frameIndex)"
- @dragend="(e: Konva.KonvaEventObject) => onPathDotDragMove(e, d.frameIndex)"
+ @dragmove="(e: Konva.KonvaEventObject) => onPathDotDragMove(e, d)"
+ @dragend="(e: Konva.KonvaEventObject) => onPathDotDragMove(e, d)"
/>
{
overflow: hidden;
}
+.bg-video {
+ position: absolute;
+ object-fit: cover;
+ pointer-events: none;
+ user-select: none;
+ z-index: 0;
+}
+
.stage-container.drawing {
cursor: crosshair;
}
diff --git a/src/components/DocumentSettings.vue b/src/components/DocumentSettings.vue
index ff61167..3ebe87c 100644
--- a/src/components/DocumentSettings.vue
+++ b/src/components/DocumentSettings.vue
@@ -2,17 +2,19 @@
import { computed, ref, watch } from 'vue'
import {
PhClock,
+ PhFilmReel,
PhFolder,
PhFrameCorners,
PhMoon,
PhPalette,
PhSun,
+ PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
-import { Modal } from '@/components/ui'
+import { Modal, confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
@@ -43,6 +45,126 @@ const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
const activeTab = ref('document')
+const bgVideoError = ref(null)
+const bgFileInput = ref(null)
+
+interface ProbedVideo {
+ src: string
+ width: number
+ height: number
+ duration: number
+ name: string
+}
+
+function probeVideo(src: string, name: string): Promise {
+ return new Promise((resolve, reject) => {
+ const v = document.createElement('video')
+ v.preload = 'metadata'
+ v.muted = true
+ v.src = src
+ v.onloadedmetadata = () => {
+ resolve({
+ src,
+ width: v.videoWidth || 1280,
+ height: v.videoHeight || 720,
+ duration: Number.isFinite(v.duration) ? v.duration : 0,
+ name,
+ })
+ }
+ v.onerror = () => reject(new Error('Could not read video metadata.'))
+ })
+}
+
+async function applyProbedVideo(probe: ProbedVideo) {
+ const stageMatches =
+ probe.width === store.scene.canvas.width && probe.height === store.scene.canvas.height
+ if (!stageMatches) {
+ const ok = await confirmDialog({
+ title: t('settings.bgVideoStageSizeTitle'),
+ message: t('settings.bgVideoStageSizeMessage', {
+ w: probe.width,
+ h: probe.height,
+ cw: store.scene.canvas.width,
+ ch: store.scene.canvas.height,
+ }),
+ confirmLabel: t('settings.bgVideoStageSizeConfirm'),
+ cancelLabel: t('settings.bgVideoStageSizeCancel'),
+ })
+ if (ok) store.setCanvasSize(probe.width, probe.height)
+ }
+ if (probe.duration > 0 && probe.duration > store.scene.duration) {
+ const ok = await confirmDialog({
+ title: t('settings.bgVideoDurationTitle'),
+ message: t('settings.bgVideoDurationMessage', {
+ d: probe.duration.toFixed(2),
+ cd: store.scene.duration,
+ }),
+ confirmLabel: t('settings.bgVideoDurationConfirm'),
+ cancelLabel: t('settings.bgVideoDurationCancel'),
+ })
+ if (ok) store.setDuration(probe.duration)
+ }
+ store.setBackgroundVideo({ src: probe.src, muted: true })
+}
+
+async function pickBgVideoFile() {
+ bgVideoError.value = null
+ bgFileInput.value?.click()
+}
+
+async function onBgFileSelected(e: Event) {
+ const input = e.target as HTMLInputElement
+ const file = input.files?.[0]
+ input.value = ''
+ if (!file) return
+ try {
+ const reader = new FileReader()
+ const dataUrl = await new Promise((resolve, reject) => {
+ reader.onload = () => resolve(reader.result as string)
+ reader.onerror = () => reject(reader.error)
+ reader.readAsDataURL(file)
+ })
+ const probe = await probeVideo(dataUrl, file.name)
+ await applyProbedVideo(probe)
+ } catch (err) {
+ bgVideoError.value = err instanceof Error ? err.message : String(err)
+ }
+}
+
+async function pickBgVideoUrl() {
+ bgVideoError.value = null
+ const url = await promptDialog({
+ title: t('settings.bgVideoUrlTitle'),
+ message: t('settings.bgVideoUrlMessage'),
+ defaultValue: store.scene.backgroundVideo?.src ?? '',
+ confirmLabel: t('settings.bgVideoUrlConfirm'),
+ })
+ if (!url) return
+ try {
+ const probe = await probeVideo(url.trim(), url.trim())
+ await applyProbedVideo(probe)
+ } catch (err) {
+ bgVideoError.value = err instanceof Error ? err.message : String(err)
+ }
+}
+
+function clearBgVideo() {
+ store.setBackgroundVideo(null)
+ bgVideoError.value = null
+}
+
+function bgVideoDisplayName(): string {
+ const v = store.scene.backgroundVideo
+ if (!v) return ''
+ if (v.src.startsWith('data:')) return t('settings.bgVideoLocalFile')
+ try {
+ const u = new URL(v.src)
+ return u.pathname.split('/').filter(Boolean).pop() || u.hostname
+ } catch {
+ return v.src.length > 48 ? v.src.slice(0, 48) + '…' : v.src
+ }
+}
+
const width = computed({
get: () => store.scene.canvas.width,
set: (v: number) => store.setCanvasSize(v, store.scene.canvas.height),
@@ -146,6 +268,42 @@ function applyTheme(t: Theme) {
{{ background.toUpperCase() }}
+
+
+
{{ t('settings.bgVideoLabel') }}
+
+
+
+ {{ bgVideoDisplayName() }}
+
+
{{ t('settings.bgVideoNone') }}
+
+
+
+
+
+
+
+
+ {{ bgVideoError }}
+ {{ t('settings.bgVideoHint') }}
@@ -165,6 +323,7 @@ function applyTheme(t: Theme) {
+
{{ t('settings.tab.clipart') }}
diff --git a/src/components/FullscreenPreview.vue b/src/components/FullscreenPreview.vue
index de2912b..5b9eb8f 100644
--- a/src/components/FullscreenPreview.vue
+++ b/src/components/FullscreenPreview.vue
@@ -14,6 +14,7 @@ const canvasEl = ref
(null)
const frameEl = ref(null)
const rootEl = ref(null)
const progressTrackEl = ref(null)
+const bgVideoEl = ref(null)
const viewportSize = ref({ w: 0, h: 0 })
const images = ref>({})
const localTime = ref(0)
@@ -28,7 +29,8 @@ const aspect = computed(
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
)
-const sceneEnd = computed(() => store.effectiveEnd)
+const rangeIn = computed(() => store.playbackIn)
+const rangeOut = computed(() => store.playbackOut)
let raf = 0
let lastFrame = 0
@@ -70,12 +72,27 @@ function draw() {
function loop(now: number) {
if (!props.modelValue) return
if (playing.value) {
- const end = sceneEnd.value
- const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
- if (end > 0) {
- let next = localTime.value + dt
- if (next >= end) next = 0
- localTime.value = next
+ const start = rangeIn.value
+ const end = rangeOut.value
+ if (end > start) {
+ const v = bgVideoEl.value
+ if (v && !v.paused && !v.seeking && !scrubbing.value && v.readyState >= 2) {
+ // Slave localTime to the video element so drift can't build up and
+ // cause compensating seeks — those seeks are what makes the video
+ // stutter visibly.
+ let vt = v.currentTime
+ if (vt >= end || vt < start) {
+ vt = start
+ v.currentTime = vt
+ }
+ localTime.value = vt
+ } else {
+ const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
+ let next = localTime.value + dt
+ if (next >= end) next = start
+ else if (next < start) next = start
+ localTime.value = next
+ }
}
}
lastFrame = now
@@ -95,12 +112,27 @@ function stopLoop() {
}
async function startPreview() {
- localTime.value = store.playhead
+ const start = rangeIn.value
+ const end = rangeOut.value
+ const head = store.playhead
+ localTime.value = head < start || head >= end ? start : head
playing.value = true
showControls.value = false
images.value = await loadSceneImages(store.scene)
resize()
startLoop()
+ // The playing watcher may have fired before the
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index 145ff08..9176e41 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -15,7 +15,14 @@ import {
PhEyeSlash,
PhMagnet,
PhMaskHappy,
+ PhCircle,
+ PhImage,
PhPauseCircle,
+ PhPencilSimpleLine,
+ PhPlus,
+ PhPolygon,
+ PhSquare,
+ PhTextT,
PhTrash,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
@@ -27,6 +34,7 @@ import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composabl
import { showLocked } from '@/composables/useShowLocked'
import type {
AnimatableProps,
+ Asset,
Clip,
ClipInstance,
Easing,
@@ -311,6 +319,51 @@ function onScrubPointerUp(e: PointerEvent) {
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
+function bracketTimeFromEvent(e: PointerEvent, el: HTMLElement): number {
+ const ruler = el.parentElement as HTMLElement | null
+ if (!ruler) return 0
+ const rect = ruler.getBoundingClientRect()
+ if (rect.width <= 0) return 0
+ const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
+ return snapTime(ratio * duration.value)
+}
+
+function onInBracketPointerDown(e: PointerEvent) {
+ e.stopPropagation()
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
+}
+
+function onInBracketPointerMove(e: PointerEvent) {
+ const el = e.currentTarget as HTMLElement
+ if (!el.hasPointerCapture(e.pointerId)) return
+ e.stopPropagation()
+ const t = bracketTimeFromEvent(e, el)
+ store.setPlaybackRange(Math.min(t, store.playbackOut), null)
+}
+
+function onInBracketPointerUp(e: PointerEvent) {
+ const el = e.currentTarget as HTMLElement
+ if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
+}
+
+function onOutBracketPointerDown(e: PointerEvent) {
+ e.stopPropagation()
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
+}
+
+function onOutBracketPointerMove(e: PointerEvent) {
+ const el = e.currentTarget as HTMLElement
+ if (!el.hasPointerCapture(e.pointerId)) return
+ e.stopPropagation()
+ const t = bracketTimeFromEvent(e, el)
+ store.setPlaybackRange(null, Math.max(t, store.playbackIn))
+}
+
+function onOutBracketPointerUp(e: PointerEvent) {
+ const el = e.currentTarget as HTMLElement
+ if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
+}
+
function onLaneClick(e: MouseEvent) {
if (kfDrag.value) return
const el = e.currentTarget as HTMLElement
@@ -320,6 +373,34 @@ function onLaneClick(e: MouseEvent) {
store.setPlayhead(snapTime(ratio * duration.value))
}
+const canCreateKf = computed(() => {
+ const id = store.selectedLayerId
+ if (!id) return false
+ const layer = store.activeLayers.find((l) => l.id === id)
+ return !!layer && !layer.locked
+})
+
+function addKeyframeForSelected() {
+ const id = store.selectedLayerId
+ if (!id) return
+ const layer = store.activeLayers.find((l) => l.id === id)
+ if (!layer || layer.locked) return
+ const time = snapTime(store.playhead)
+ const sampled = sampleLayer(layer, time)
+ const props: Partial = {
+ scaleX: sampled.scaleX,
+ scaleY: sampled.scaleY,
+ opacity: sampled.opacity,
+ }
+ if (sampled.fill) props.fill = sampled.fill
+ if (!layer.separated) {
+ props.x = sampled.x
+ props.y = sampled.y
+ props.rotation = sampled.rotation
+ }
+ store.upsertKeyframe(id, time, props)
+}
+
function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
e.stopPropagation()
const el = e.currentTarget as HTMLElement
@@ -392,6 +473,23 @@ function emitLayerTrack(
}
}
+const assetById = computed(() => {
+ const m = new Map()
+ for (const a of store.scene.assets) m.set(a.id, a)
+ return m
+})
+
+function assetIconFor(layer: Layer) {
+ const asset = assetById.value.get(layer.assetId)
+ if (!asset) return null
+ if (asset.kind === 'rect') return PhSquare
+ if (asset.kind === 'circle') return PhCircle
+ if (asset.kind === 'image') return PhImage
+ if (asset.kind === 'text') return PhTextT
+ if (asset.kind === 'polygon') return asset.closed === false ? PhPencilSimpleLine : PhPolygon
+ return null
+}
+
const lockedGroupChildIds = computed(() => {
const s = new Set()
for (const g of store.scene.groups ?? []) {
@@ -1453,9 +1551,25 @@ function onKeyDown(e: KeyboardEvent) {
}
}
+function isEditableTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) return false
+ const tag = target.tagName
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
+ if (target.isContentEditable) return true
+ return false
+}
+
function onDocumentKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && selectedKfs.value.length) {
selectedKfs.value = []
+ return
+ }
+ if ((e.key === 'Backspace' || e.key === 'Delete') && selectedKfs.value.length) {
+ if (isEditableTarget(e.target)) return
+ e.preventDefault()
+ const targets = [...selectedKfs.value]
+ selectedKfs.value = []
+ for (const k of targets) store.removeKeyframe(k.layerId, k.keyframeId)
}
}
@@ -1591,6 +1705,14 @@ onBeforeUnmount(() => {
>
+
{
{{ formatTick(t) }}
+
+
+
+
@@ -1693,6 +1846,12 @@ onBeforeUnmount(() => {
class="name-badge"
:class="{ 'mask-negative': track.layer.maskMode === 'negative' }"
/>
+
↳
{{ track.label }}