0.6.0: background video, in/out range, top-level masks, track icons
- background video layer (document-level), syncs to playhead in editor and fullscreen - playback in/out brackets on the scrub ruler; i/o set them, r rewinds to in-point - togglePlay, tick, fullscreen loop, WebM + server FFmpeg export all honor [in, out] - top-level mask layers cascade over everything below until a group boundary - fullscreen preview slaves localTime to the bg video so it stops stuttering - welcome "new project" now actually loads a blank scene (was a no-op) - default canvas background hardened to white via normalizeScene - new keyframe button (+) in the name-spacer, backspace/delete removes selected kfs - drag-end auto-selects the created/shifted keyframe so the path is visible - animation path updates live during drag and spans every selected keyframe - track rows show an icon per asset kind (rect/circle/image/text/polygon/freeform) - default mask mode is negative, buttons read − then + - sticky track-name columns raised above playhead/brackets; orange bracket styling - tests for playback range, blank scene, mask defaults, cascading top-level masks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
69de48ea8d
commit
d4c4830fe2
17 files changed
+1332
-153
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "animatr",
|
||||
"private": true,
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+34
-3
@@ -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<scene.assets.length;i++) am[scene.assets[i].id]=scene.assets[i];
|
||||
var layerById={};
|
||||
for(var li=0;li<scene.layers.length;li++) layerById[scene.layers[li].id]=scene.layers[li];
|
||||
@@ -285,10 +287,32 @@
|
||||
var groupById2={}; var groups2=scene.groups||[];
|
||||
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
|
||||
if(!rawOrder.length){ rawOrder=[]; for(var oj=0;oj<scene.layers.length;oj++) rawOrder.push(scene.layers[oj].id); }
|
||||
var hasTopMask=false;
|
||||
for(var hm=0;hm<rawOrder.length;hm++){ var hmL=layerById[rawOrder[hm]]; if(hmL && hmL.isMask){ hasTopMask=true; break; } }
|
||||
var mainCtx=ctx, topOff=null;
|
||||
if(hasTopMask){
|
||||
topOff=document.createElement('canvas');
|
||||
topOff.width=canvas.width; topOff.height=canvas.height;
|
||||
var topOffCtx=topOff.getContext('2d');
|
||||
if(topOffCtx){
|
||||
var tmT=ctx.getTransform();
|
||||
topOffCtx.setTransform(tmT.a,tmT.b,tmT.c,tmT.d,tmT.e,tmT.f);
|
||||
ctx=topOffCtx;
|
||||
} else { topOff=null; }
|
||||
}
|
||||
for(var oi=rawOrder.length-1;oi>=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<tInsts.length;ti++){
|
||||
@@ -341,6 +365,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
if(topOff){
|
||||
ctx=mainCtx;
|
||||
ctx.save();
|
||||
ctx.setTransform(1,0,0,1,0,0);
|
||||
ctx.drawImage(topOff,0,0);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
window.__animatrInit=function(s){
|
||||
|
||||
+12
-3
@@ -103,10 +103,19 @@ function effectiveEnd(scene) {
|
||||
return Math.min(max, scene.duration)
|
||||
}
|
||||
|
||||
function playbackRange(scene) {
|
||||
const autoEnd = effectiveEnd(scene)
|
||||
const start = Math.max(0, Math.min(scene.duration, scene.inTime == null ? 0 : scene.inTime))
|
||||
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
|
||||
const end = Math.max(start, Math.min(scene.duration, rawEnd))
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
async function runJob(job, scene, options) {
|
||||
const fps = Number(options.fps || scene.fps || 60)
|
||||
const sceneEnd = effectiveEnd(scene)
|
||||
const duration = Number(options.duration || sceneEnd)
|
||||
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
|
||||
const rangeLen = Math.max(0, rangeOut - rangeIn)
|
||||
const duration = Number(options.duration || rangeLen)
|
||||
const totalFrames = Math.max(1, Math.round(duration * fps))
|
||||
const crf = String(options.crf ?? 18)
|
||||
const preset = String(options.preset ?? 'fast')
|
||||
@@ -162,7 +171,7 @@ async function runJob(job, scene, options) {
|
||||
try { ffmpeg.kill('SIGKILL') } catch { /* noop */ }
|
||||
throw new Error('aborted')
|
||||
}
|
||||
const t = Math.min(i / fps, duration)
|
||||
const t = rangeIn + Math.min(i / fps, duration)
|
||||
await page.evaluate((time) => window.__animatrSeek(time), t)
|
||||
const buf = await page.screenshot({
|
||||
type: 'png',
|
||||
|
||||
+30
-3
@@ -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(() => {
|
||||
<WelcomeDialog
|
||||
v-model="welcomeOpen"
|
||||
@open="openFromRecent"
|
||||
@new="() => { currentProjectId = null; currentProjectName = null }"
|
||||
@new="startNewProject"
|
||||
@import="importFromFile"
|
||||
/>
|
||||
<Toast :visible="!!loadError" variant="error">
|
||||
|
||||
+260
-72
@@ -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<HTMLDivElement | null>(null)
|
||||
const containerSize = ref({ width: 0, height: 0 })
|
||||
const stageRef = ref<{ getStage(): Konva.Stage } | null>(null)
|
||||
const bgVideoEl = ref<HTMLVideoElement | null>(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<PathOverlay | null>(() => {
|
||||
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<string>()
|
||||
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<PathOverlay | null>(() => {
|
||||
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<PathDot[]>(() => {
|
||||
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<string | null>(null)
|
||||
|
||||
const hoveredPathDot = ref<number | null>(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<DragEvent>, frameIndex: number) {
|
||||
function onPathDotDragMove(e: Konva.KonvaEventObject<DragEvent>, 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<DragEvent>) {
|
||||
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<DragEvent>) {
|
||||
}
|
||||
} 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<Event>) {
|
||||
const node = e.target
|
||||
const id = node.id()
|
||||
@@ -2086,6 +2252,16 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div ref="container" class="stage-container" :class="{ drawing: store.drawingMode === 'freeform' }">
|
||||
<video
|
||||
v-if="store.scene.backgroundVideo"
|
||||
ref="bgVideoEl"
|
||||
class="bg-video"
|
||||
:style="bgVideoStyle"
|
||||
:src="store.scene.backgroundVideo.src"
|
||||
muted
|
||||
playsinline
|
||||
preload="auto"
|
||||
></video>
|
||||
<v-stage
|
||||
ref="stageRef"
|
||||
:config="stageConfig"
|
||||
@@ -2217,15 +2393,19 @@ onBeforeUnmount(() => {
|
||||
:key="g.key"
|
||||
:config="g.config"
|
||||
/>
|
||||
<v-line v-if="animationPathLineConfig" :config="animationPathLineConfig" />
|
||||
<v-line
|
||||
v-for="line in animationPathLines"
|
||||
:key="line.key"
|
||||
:config="line.config"
|
||||
/>
|
||||
<v-circle
|
||||
v-for="d in animationPathDots"
|
||||
:key="d.key"
|
||||
:config="animationDotConfig(d)"
|
||||
@mouseenter="onPathDotMouseEnter(d.frameIndex, d.isEnd)"
|
||||
@mouseenter="onPathDotMouseEnter(d)"
|
||||
@mouseleave="onPathDotMouseLeave"
|
||||
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d.frameIndex)"
|
||||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d.frameIndex)"
|
||||
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
|
||||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
|
||||
/>
|
||||
<v-line v-if="drawingPreviewConfig" :config="drawingPreviewConfig" />
|
||||
<v-circle
|
||||
@@ -2325,6 +2505,14 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-video {
|
||||
position: absolute;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.stage-container.drawing {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
@@ -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<TabId>('document')
|
||||
|
||||
const bgVideoError = ref<string | null>(null)
|
||||
const bgFileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
interface ProbedVideo {
|
||||
src: string
|
||||
width: number
|
||||
height: number
|
||||
duration: number
|
||||
name: string
|
||||
}
|
||||
|
||||
function probeVideo(src: string, name: string): Promise<ProbedVideo> {
|
||||
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<string>((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) {
|
||||
<span class="hex">{{ background.toUpperCase() }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="clipart-row">
|
||||
<div class="clipart-label">{{ t('settings.bgVideoLabel') }}</div>
|
||||
<div class="clipart-value">
|
||||
<PhFilmReel :size="13" />
|
||||
<span v-if="store.scene.backgroundVideo" class="clipart-folder-name">
|
||||
{{ bgVideoDisplayName() }}
|
||||
</span>
|
||||
<span v-else class="clipart-folder-empty">{{ t('settings.bgVideoNone') }}</span>
|
||||
<button
|
||||
v-if="store.scene.backgroundVideo"
|
||||
class="ghost-btn inline-clear"
|
||||
:title="t('settings.bgVideoClear')"
|
||||
@click="clearBgVideo"
|
||||
>
|
||||
<PhX :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clipart-actions">
|
||||
<button class="primary-btn" @click="pickBgVideoFile">
|
||||
{{ t('settings.bgVideoPickFile') }}
|
||||
</button>
|
||||
<button class="ghost-btn" @click="pickBgVideoUrl">
|
||||
{{ t('settings.bgVideoPickUrl') }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="bgFileInput"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style="display: none"
|
||||
@change="onBgFileSelected"
|
||||
/>
|
||||
<p v-if="bgVideoError" class="error-line">{{ bgVideoError }}</p>
|
||||
<p class="hint">{{ t('settings.bgVideoHint') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'timeline'">
|
||||
@@ -165,6 +323,7 @@ function applyTheme(t: Theme) {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
<section v-else-if="activeTab === 'clipart'">
|
||||
<h3>{{ t('settings.tab.clipart') }}</h3>
|
||||
<div class="clipart-row">
|
||||
|
||||
@@ -14,6 +14,7 @@ const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
const frameEl = ref<HTMLDivElement | null>(null)
|
||||
const rootEl = ref<HTMLDivElement | null>(null)
|
||||
const progressTrackEl = ref<HTMLDivElement | null>(null)
|
||||
const bgVideoEl = ref<HTMLVideoElement | null>(null)
|
||||
const viewportSize = ref({ w: 0, h: 0 })
|
||||
const images = ref<Record<string, HTMLImageElement>>({})
|
||||
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 <video> ref was bound;
|
||||
// make sure the background video is actually started now that the DOM
|
||||
// is mounted.
|
||||
const v = bgVideoEl.value
|
||||
if (v) {
|
||||
v.currentTime = localTime.value
|
||||
try {
|
||||
await v.play()
|
||||
} catch {
|
||||
/* ignore autoplay errors */
|
||||
}
|
||||
}
|
||||
await requestBrowserFullscreen()
|
||||
}
|
||||
|
||||
@@ -152,9 +184,11 @@ function onRootMouseLeave() {
|
||||
}
|
||||
|
||||
const progressPct = computed(() => {
|
||||
const end = sceneEnd.value
|
||||
if (!end) return 0
|
||||
return Math.max(0, Math.min(1, localTime.value / end)) * 100
|
||||
const start = rangeIn.value
|
||||
const end = rangeOut.value
|
||||
const len = end - start
|
||||
if (len <= 0) return 0
|
||||
return Math.max(0, Math.min(1, (localTime.value - start) / len)) * 100
|
||||
})
|
||||
|
||||
function seekFromEvent(e: PointerEvent) {
|
||||
@@ -163,8 +197,9 @@ function seekFromEvent(e: PointerEvent) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const end = sceneEnd.value
|
||||
localTime.value = ratio * end
|
||||
const start = rangeIn.value
|
||||
const end = rangeOut.value
|
||||
localTime.value = start + ratio * (end - start)
|
||||
draw()
|
||||
}
|
||||
|
||||
@@ -227,6 +262,31 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(playing, async (p) => {
|
||||
const v = bgVideoEl.value
|
||||
if (!v) return
|
||||
if (p) {
|
||||
v.currentTime = localTime.value
|
||||
try {
|
||||
await v.play()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
v.pause()
|
||||
v.currentTime = localTime.value
|
||||
}
|
||||
})
|
||||
// Keep the video frame aligned with localTime when we are NOT letting the
|
||||
// video drive time (paused or actively scrubbing). During normal playback
|
||||
// the RAF loop reads v.currentTime, so we must not seek here.
|
||||
watch(localTime, (t) => {
|
||||
const v = bgVideoEl.value
|
||||
if (!v) return
|
||||
if (playing.value && !scrubbing.value) return
|
||||
v.currentTime = t
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
@@ -256,7 +316,7 @@ watch(frameEl, (el, _old, onCleanup) => {
|
||||
})
|
||||
|
||||
const timeLabel = computed(() => {
|
||||
const end = sceneEnd.value || 0
|
||||
const end = rangeOut.value || 0
|
||||
return `${localTime.value.toFixed(2)}s / ${end.toFixed(2)}s`
|
||||
})
|
||||
</script>
|
||||
@@ -274,6 +334,15 @@ const timeLabel = computed(() => {
|
||||
@dblclick.self="close"
|
||||
>
|
||||
<div ref="frameEl" class="fs-frame" :style="{ aspectRatio: aspect }">
|
||||
<video
|
||||
v-if="store.scene.backgroundVideo"
|
||||
ref="bgVideoEl"
|
||||
class="fs-bg-video"
|
||||
:src="store.scene.backgroundVideo.src"
|
||||
muted
|
||||
playsinline
|
||||
preload="auto"
|
||||
></video>
|
||||
<canvas ref="canvasEl" />
|
||||
</div>
|
||||
|
||||
@@ -324,6 +393,7 @@ const timeLabel = computed(() => {
|
||||
}
|
||||
|
||||
.fs-frame {
|
||||
position: relative;
|
||||
width: min(100vw, 100vh * (16 / 9));
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
@@ -332,10 +402,22 @@ const timeLabel = computed(() => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fs-bg-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.fs-frame canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fs-controls {
|
||||
|
||||
@@ -593,21 +593,21 @@ function removeKeyframeHere() {
|
||||
<button
|
||||
type="button"
|
||||
class="icon-toggle"
|
||||
:class="{ active: (selected.maskMode ?? 'positive') === 'positive' }"
|
||||
:title="t('properties.maskPositive')"
|
||||
@click="store.setLayerMaskMode(selected.id, 'positive')"
|
||||
>
|
||||
<PhPlus :size="13" weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-toggle"
|
||||
:class="{ active: selected.maskMode === 'negative', danger: selected.maskMode === 'negative' }"
|
||||
:class="{ active: (selected.maskMode ?? 'negative') === 'negative', danger: (selected.maskMode ?? 'negative') === 'negative' }"
|
||||
:title="t('properties.maskNegative')"
|
||||
@click="store.setLayerMaskMode(selected.id, 'negative')"
|
||||
>
|
||||
<PhMinus :size="13" weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-toggle"
|
||||
:class="{ active: selected.maskMode === 'positive' }"
|
||||
:title="t('properties.maskPositive')"
|
||||
@click="store.setLayerMaskMode(selected.id, 'positive')"
|
||||
>
|
||||
<PhPlus :size="13" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="selected.isMask && maskInGroup" class="status-active">
|
||||
|
||||
+251
-5
@@ -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<AnimatableProps> = {
|
||||
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<string, Asset>()
|
||||
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<string>()
|
||||
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(() => {
|
||||
>
|
||||
<PhFolderPlus :size="12" />
|
||||
</button>
|
||||
<button
|
||||
class="snap-btn add-kf-btn"
|
||||
:title="t('timeline.addKeyframeTooltip')"
|
||||
:disabled="!canCreateKf"
|
||||
@click="addKeyframeForSelected"
|
||||
>
|
||||
<PhPlus :size="12" :weight="'bold'" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="scrub-ruler"
|
||||
@@ -1610,6 +1732,37 @@ onBeforeUnmount(() => {
|
||||
<span class="tick-line"></span>
|
||||
<span class="tick-label">{{ formatTick(t) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="store.playbackIn > 0"
|
||||
class="range-dim dim-before"
|
||||
:style="{ width: pxFromTime(store.playbackIn) + 'px' }"
|
||||
></span>
|
||||
<span
|
||||
v-if="store.playbackOut < duration"
|
||||
class="range-dim dim-after"
|
||||
:style="{
|
||||
left: pxFromTime(store.playbackOut) + 'px',
|
||||
width: pxFromTime(duration - store.playbackOut) + 'px',
|
||||
}"
|
||||
></span>
|
||||
<span
|
||||
class="range-bracket bracket-in"
|
||||
:title="t('timeline.inPoint')"
|
||||
:style="{ left: pxFromTime(store.playbackIn) + 'px' }"
|
||||
@pointerdown="onInBracketPointerDown"
|
||||
@pointermove="onInBracketPointerMove"
|
||||
@pointerup="onInBracketPointerUp"
|
||||
@pointercancel="onInBracketPointerUp"
|
||||
></span>
|
||||
<span
|
||||
class="range-bracket bracket-out"
|
||||
:title="t('timeline.outPoint')"
|
||||
:style="{ left: pxFromTime(store.playbackOut) + 'px' }"
|
||||
@pointerdown="onOutBracketPointerDown"
|
||||
@pointermove="onOutBracketPointerMove"
|
||||
@pointerup="onOutBracketPointerUp"
|
||||
@pointercancel="onOutBracketPointerUp"
|
||||
></span>
|
||||
<span class="scrub-handle" :style="{ left: pxFromTime(store.playhead) + 'px' }">
|
||||
<span class="scrub-handle-head"></span>
|
||||
<span class="scrub-handle-line"></span>
|
||||
@@ -1693,6 +1846,12 @@ onBeforeUnmount(() => {
|
||||
class="name-badge"
|
||||
:class="{ 'mask-negative': track.layer.maskMode === 'negative' }"
|
||||
/>
|
||||
<component
|
||||
v-else-if="track.isParent && assetIconFor(track.layer)"
|
||||
:is="assetIconFor(track.layer)"
|
||||
:size="11"
|
||||
class="name-badge"
|
||||
/>
|
||||
<span v-if="!track.isParent" class="sub-indent">↳</span>
|
||||
<span class="track-label">{{ track.label }}</span>
|
||||
<button
|
||||
@@ -2184,7 +2343,7 @@ onBeforeUnmount(() => {
|
||||
.name-spacer {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
z-index: 30;
|
||||
width: var(--track-name-width);
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
@@ -2221,6 +2380,17 @@ onBeforeUnmount(() => {
|
||||
border-color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.snap-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
background: var(--bg);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.snap-btn.add-kf-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
@@ -2300,6 +2470,76 @@ onBeforeUnmount(() => {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.range-dim {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.range-dim.dim-before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.range-bracket {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
cursor: ew-resize;
|
||||
z-index: 19;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.range-bracket::before,
|
||||
.range-bracket::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: var(--range-bracket);
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
.range-bracket::before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.range-bracket::after {
|
||||
top: 0;
|
||||
width: 8px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.range-bracket.bracket-in::before {
|
||||
left: 0;
|
||||
}
|
||||
.range-bracket.bracket-in::after {
|
||||
left: 0;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
.range-bracket.bracket-in {
|
||||
transform: translateX(-0.5px);
|
||||
}
|
||||
|
||||
.range-bracket.bracket-out::before {
|
||||
right: 0;
|
||||
}
|
||||
.range-bracket.bracket-out::after {
|
||||
right: 0;
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
.range-bracket.bracket-out {
|
||||
transform: translateX(calc(0.5px - 10px));
|
||||
}
|
||||
|
||||
.range-bracket:hover::before,
|
||||
.range-bracket:hover::after {
|
||||
background: var(--range-hover);
|
||||
}
|
||||
|
||||
.scrub-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -2319,6 +2559,13 @@ onBeforeUnmount(() => {
|
||||
margin-top: 2px;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 55%, 50% 100%, 0 55%);
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
|
||||
pointer-events: auto;
|
||||
cursor: ew-resize;
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
.scrub-handle-head:hover {
|
||||
background: var(--range-hover);
|
||||
}
|
||||
|
||||
.scrub-handle-line {
|
||||
@@ -2519,7 +2766,7 @@ onBeforeUnmount(() => {
|
||||
.track-name {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
z-index: 30;
|
||||
width: var(--track-name-width);
|
||||
padding: 0 12px;
|
||||
color: var(--text-dim);
|
||||
@@ -2632,9 +2879,7 @@ onBeforeUnmount(() => {
|
||||
background: var(--accent);
|
||||
pointer-events: none;
|
||||
transform: translateX(-0.5px);
|
||||
/* Must render above the sticky track-name column (z-index 5) and the
|
||||
resize strip (z-index 3) so the playhead stays visible at time=0 or
|
||||
when scrolled. */
|
||||
/* Above segments/keyframes but below sticky label columns (z-index 30). */
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
@@ -2913,6 +3158,7 @@ onBeforeUnmount(() => {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
gap: 4px;
|
||||
padding-left: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
+234
-8
@@ -1,5 +1,5 @@
|
||||
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { createBlankScene, useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer, lerpColor } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
import {
|
||||
@@ -549,10 +549,10 @@ export function registerAllTests(): void {
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Rew', width: 60, height: 60, fill: '#cc5555' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 2, { x: 600 })
|
||||
s.setPlayhead(s.effectiveEnd)
|
||||
s.setPlayhead(s.playbackOut)
|
||||
await tick()
|
||||
s.togglePlay()
|
||||
expect(s.playhead).toBe(0)
|
||||
expect(s.playhead).toBe(s.playbackIn)
|
||||
expect(s.playing).toBe(true)
|
||||
s.pause()
|
||||
await tick()
|
||||
@@ -570,17 +570,17 @@ export function registerAllTests(): void {
|
||||
await tick()
|
||||
})
|
||||
|
||||
test('tick stops at effectiveEnd', async () => {
|
||||
test('tick stops at playbackOut', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'End', width: 60, height: 60, fill: '#dd7799' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 2, { x: 400 })
|
||||
s.play()
|
||||
await tick()
|
||||
s.tick(10)
|
||||
s.tick(s.scene.duration + 1)
|
||||
await tick()
|
||||
expect(s.playing).toBe(false)
|
||||
expect(s.playhead).toBe(s.effectiveEnd)
|
||||
expect(s.playhead).toBe(s.playbackOut)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1577,14 +1577,14 @@ export function registerAllTests(): void {
|
||||
})
|
||||
|
||||
suite('mask layers — store', () => {
|
||||
test('setLayerMask(true) enables positive mask by default', () => {
|
||||
test('setLayerMask(true) enables negative mask by default', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.setLayerMask(l.id, true)
|
||||
const layer = s.scene.layers.find((x) => x.id === l.id)!
|
||||
expect(layer.isMask).toBe(true)
|
||||
expect(layer.maskMode).toBe('positive')
|
||||
expect(layer.maskMode).toBe('negative')
|
||||
})
|
||||
|
||||
test('setLayerMask(true, "negative") stores negative mode', () => {
|
||||
@@ -2761,4 +2761,230 @@ export function registerAllTests(): void {
|
||||
expect(maskOutside[3]).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — playback range (in/out)', () => {
|
||||
test('defaults: playbackIn=0, playbackOut=duration', () => {
|
||||
const s = makeFreshStore()
|
||||
expect(s.playbackIn).toBe(0)
|
||||
expect(s.playbackOut).toBe(s.scene.duration)
|
||||
expect(s.scene.inTime).toBe(undefined)
|
||||
expect(s.scene.outTime).toBe(undefined)
|
||||
})
|
||||
|
||||
test('setPlaybackRange clamps values into [0, duration]', () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
s.setPlaybackRange(-5, 20)
|
||||
expect(s.playbackIn).toBe(0)
|
||||
expect(s.playbackOut).toBe(10)
|
||||
})
|
||||
|
||||
test('setPlaybackRange keeps out >= in', () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
s.setPlaybackRange(6, null)
|
||||
s.setPlaybackRange(null, 3)
|
||||
// out is clamped up to in (6), not down to 3
|
||||
expect(s.playbackOut).toBe(6)
|
||||
})
|
||||
|
||||
test('setPlaybackRange clamps playhead into new range', () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
s.setPlayhead(1)
|
||||
s.setPlaybackRange(4, 8)
|
||||
expect(s.playhead).toBe(4)
|
||||
s.setPlayhead(9)
|
||||
// tick isn't involved; setPlayhead itself doesn't clamp to range, so
|
||||
// push it with setPlaybackRange again:
|
||||
s.setPlaybackRange(4, 7)
|
||||
expect(s.playhead).toBe(7)
|
||||
})
|
||||
|
||||
test('setPlaybackRange null for both leaves scene alone', () => {
|
||||
const s = makeFreshStore()
|
||||
s.setPlaybackRange(2, 5)
|
||||
s.setPlaybackRange(null, null)
|
||||
expect(s.playbackIn).toBe(2)
|
||||
expect(s.playbackOut).toBe(5)
|
||||
})
|
||||
|
||||
test('togglePlay snaps playhead to playbackIn when outside range', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
s.setPlaybackRange(3, 7)
|
||||
s.setPlayhead(1) // before in
|
||||
await tick()
|
||||
s.togglePlay()
|
||||
expect(s.playhead).toBe(3)
|
||||
expect(s.playing).toBe(true)
|
||||
s.pause()
|
||||
await tick()
|
||||
})
|
||||
|
||||
test('tick stops exactly at playbackOut within range', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
s.setPlaybackRange(2, 5)
|
||||
s.setPlayhead(2)
|
||||
s.play()
|
||||
await tick()
|
||||
s.tick(4) // would put playhead at 6, past out=5
|
||||
await tick()
|
||||
expect(s.playhead).toBe(5)
|
||||
expect(s.playing).toBe(false)
|
||||
})
|
||||
|
||||
test('setPlaybackRange(0, null) deletes inTime when outTime is unset', () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
s.setPlaybackRange(3, null)
|
||||
expect(s.scene.inTime).toBe(3)
|
||||
s.setPlaybackRange(0, null)
|
||||
expect(s.scene.inTime).toBe(undefined)
|
||||
})
|
||||
|
||||
test('setPlaybackRange(null, duration) deletes outTime when inTime is unset', () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
s.setPlaybackRange(null, 7)
|
||||
expect(s.scene.outTime).toBe(7)
|
||||
s.setPlaybackRange(null, 10)
|
||||
expect(s.scene.outTime).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — blank scene & normalization', () => {
|
||||
test('createBlankScene has 1280x720 white bg and 10s duration', () => {
|
||||
const s = createBlankScene()
|
||||
expect(s.version).toBe(1)
|
||||
expect(s.canvas.width).toBe(1280)
|
||||
expect(s.canvas.height).toBe(720)
|
||||
expect(s.canvas.background).toBe('#ffffff')
|
||||
expect(s.duration).toBe(10)
|
||||
expect(s.fps).toBe(60)
|
||||
expect(s.assets).toHaveLength(0)
|
||||
expect(s.layers).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('loadScene(createBlankScene()) resets selection, playhead, playing', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#f00' })
|
||||
s.addLayer(a.id)
|
||||
s.setPlayhead(3)
|
||||
s.play()
|
||||
await tick()
|
||||
s.loadScene(createBlankScene())
|
||||
expect(s.scene.layers).toHaveLength(0)
|
||||
expect(s.scene.assets).toHaveLength(0)
|
||||
expect(s.scene.canvas.background).toBe('#ffffff')
|
||||
expect(s.playhead).toBe(0)
|
||||
expect(s.playing).toBe(false)
|
||||
expect(s.selectedLayerId).toBe(null)
|
||||
})
|
||||
|
||||
test('loadScene fills in canvas.background when missing', () => {
|
||||
const s = makeFreshStore()
|
||||
const broken = {
|
||||
version: 1 as const,
|
||||
canvas: { width: 640, height: 480, background: '' },
|
||||
duration: 5,
|
||||
fps: 30,
|
||||
assets: [],
|
||||
layers: [],
|
||||
}
|
||||
s.loadScene(broken)
|
||||
expect(s.scene.canvas.background).toBe('#ffffff')
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — mask defaults', () => {
|
||||
test('setLayerMask(true) defaults to negative mask mode', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.setLayerMask(l.id, true)
|
||||
const layer = s.scene.layers.find((x) => x.id === l.id)!
|
||||
expect(layer.isMask).toBe(true)
|
||||
expect(layer.maskMode).toBe('negative')
|
||||
})
|
||||
})
|
||||
|
||||
suite('render — top-level masks cascade', () => {
|
||||
test('top-level positive mask clears content outside the mask shape', () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 100
|
||||
s.scene.canvas.height = 100
|
||||
s.scene.canvas.background = 'transparent'
|
||||
// Full-stage red rect
|
||||
const content = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'BG',
|
||||
width: 100,
|
||||
height: 100,
|
||||
fill: '#ff0000',
|
||||
})
|
||||
const contentLayer = s.addLayer(content.id)
|
||||
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
|
||||
// Mask layer above it in the stack (top-level, NO group)
|
||||
const mask = s.addAsset({
|
||||
kind: 'circle',
|
||||
name: 'M',
|
||||
radius: 20,
|
||||
fill: '#ffffff',
|
||||
})
|
||||
const maskLayer = s.addLayer(mask.id)
|
||||
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
|
||||
s.setLayerMask(maskLayer.id, true, 'positive')
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 100
|
||||
canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawSceneFrame(ctx, s.scene, 0, {})
|
||||
// Inside mask circle → red kept
|
||||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||||
expect(inside[0] > 200).toBe(true)
|
||||
expect(inside[3] > 200).toBe(true)
|
||||
// Outside mask circle → cleared
|
||||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||||
expect(outside[3]).toBe(0)
|
||||
})
|
||||
|
||||
test('top-level negative mask cuts a hole in the content below', () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 100
|
||||
s.scene.canvas.height = 100
|
||||
s.scene.canvas.background = 'transparent'
|
||||
const content = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'BG',
|
||||
width: 100,
|
||||
height: 100,
|
||||
fill: '#ff0000',
|
||||
})
|
||||
const contentLayer = s.addLayer(content.id)
|
||||
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
|
||||
const mask = s.addAsset({
|
||||
kind: 'circle',
|
||||
name: 'M',
|
||||
radius: 15,
|
||||
fill: '#ffffff',
|
||||
})
|
||||
const maskLayer = s.addLayer(mask.id)
|
||||
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
|
||||
s.setLayerMask(maskLayer.id, true, 'negative')
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 100
|
||||
canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawSceneFrame(ctx, s.scene, 0, {})
|
||||
// Inside the circle → hole (cleared)
|
||||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||||
expect(inside[3]).toBe(0)
|
||||
// Outside circle → red kept
|
||||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||||
expect(outside[0] > 200).toBe(true)
|
||||
expect(outside[3] > 200).toBe(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -160,6 +160,7 @@ export const en = {
|
||||
dropNewTrackHint: 'Drop a clip here to add a new track',
|
||||
addGroup: 'Add group',
|
||||
addGroupTooltip: 'Create a new track group',
|
||||
addKeyframeTooltip: 'Add keyframe for the selected track at the playhead',
|
||||
groupChildCount: '{n} tracks',
|
||||
groupEmpty: 'Drop tracks here',
|
||||
renameGroupDialog: {
|
||||
@@ -175,6 +176,8 @@ export const en = {
|
||||
snapOff: 'Snapping off',
|
||||
hideLocked: 'Hide locked tracks',
|
||||
showLocked: 'Show locked tracks',
|
||||
inPoint: 'In point — drag to set where playback and export start',
|
||||
outPoint: 'Out point — drag to set where playback and export end',
|
||||
stillFrameTooltip: 'Still frame {time} — holds until the next keyframe',
|
||||
keyframeTooltip: 'Keyframe {time} — drag to move, right-click for options',
|
||||
trimTooltip: '{name} · {duration}s{trims} · Drag edges to trim, double-click to open',
|
||||
@@ -229,6 +232,27 @@ export const en = {
|
||||
clipart: 'Clipart',
|
||||
theme: 'UI Theme',
|
||||
},
|
||||
bgVideoLabel: 'Video',
|
||||
bgVideoNone: 'No background video',
|
||||
bgVideoPickFile: 'Pick video file…',
|
||||
bgVideoPickUrl: 'Set URL…',
|
||||
bgVideoClear: 'Remove background video',
|
||||
bgVideoLocalFile: 'Local video file',
|
||||
bgVideoHint:
|
||||
'The video plays behind all layers and tracks the playhead. For best results use MP4 (H.264). The source is stored with the project (data URL for local files) or referenced by URL. In the export the video is not embedded — the exported HTML references the same URL.',
|
||||
bgVideoStageSizeTitle: 'Apply video dimensions?',
|
||||
bgVideoStageSizeMessage:
|
||||
'The video is {w}×{h}. Your canvas is currently {cw}×{ch}. Resize the canvas to match the video?',
|
||||
bgVideoStageSizeConfirm: 'Resize canvas',
|
||||
bgVideoStageSizeCancel: 'Keep current size',
|
||||
bgVideoDurationTitle: 'Extend scene duration?',
|
||||
bgVideoDurationMessage:
|
||||
'The video is {d}s. Scene duration is {cd}s. Extend the scene to match the video length?',
|
||||
bgVideoDurationConfirm: 'Extend duration',
|
||||
bgVideoDurationCancel: 'Keep current duration',
|
||||
bgVideoUrlTitle: 'Background video URL',
|
||||
bgVideoUrlMessage: 'Paste the video URL (must be reachable by the browser):',
|
||||
bgVideoUrlConfirm: 'Load',
|
||||
canvas: 'Canvas',
|
||||
width: 'Width',
|
||||
height: 'Height',
|
||||
|
||||
+80
-10
@@ -1,6 +1,6 @@
|
||||
import type { Scene } from '@/types/scene'
|
||||
|
||||
const RUNTIME_VERSION = '1'
|
||||
const RUNTIME_VERSION = '2'
|
||||
|
||||
const ENGINE_SOURCE = `(function(){
|
||||
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
||||
@@ -140,11 +140,26 @@ const ENGINE_SOURCE = `(function(){
|
||||
if(!container)return;
|
||||
opts=opts||{};
|
||||
var loop=opts.loop!==false;
|
||||
var sceneEnd=effectiveEnd(scene);
|
||||
// Honour explicit in/out points; fall back to 0 and the animation's own end.
|
||||
var rangeIn=Math.max(0, Math.min(scene.duration, scene.inTime==null?0:scene.inTime));
|
||||
var autoEnd=effectiveEnd(scene);
|
||||
var rangeOut=Math.max(rangeIn, Math.min(scene.duration, scene.outTime==null?autoEnd:scene.outTime));
|
||||
var sceneEnd=rangeOut;
|
||||
var cs=getComputedStyle(container);
|
||||
if(cs.position==='static')container.style.position='relative';
|
||||
var bgVideo=null;
|
||||
if(scene.backgroundVideo && scene.backgroundVideo.src){
|
||||
bgVideo=document.createElement('video');
|
||||
bgVideo.src=scene.backgroundVideo.src;
|
||||
bgVideo.muted=true;
|
||||
bgVideo.playsInline=true;
|
||||
bgVideo.loop=!!loop;
|
||||
bgVideo.preload='auto';
|
||||
bgVideo.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:0;';
|
||||
container.appendChild(bgVideo);
|
||||
}
|
||||
var canvas=document.createElement('canvas');
|
||||
canvas.style.cssText='display:block;width:100%;height:100%;';
|
||||
canvas.style.cssText='display:block;width:100%;height:100%;position:relative;z-index:1;';
|
||||
container.appendChild(canvas);
|
||||
var ctx=canvas.getContext('2d');
|
||||
var dpr=window.devicePixelRatio||1;
|
||||
@@ -162,13 +177,28 @@ const ENGINE_SOURCE = `(function(){
|
||||
scene.assets.forEach(function(a){assetMap[a.id]=a;});
|
||||
var imgs={};
|
||||
var start=0;
|
||||
var bgStarted=false;
|
||||
function syncBgVideo(t){
|
||||
if(!bgVideo) return;
|
||||
if(!bgStarted){
|
||||
bgStarted=true;
|
||||
try{ bgVideo.play(); }catch(e){}
|
||||
}
|
||||
if(Math.abs(bgVideo.currentTime-t)>0.2){
|
||||
bgVideo.currentTime=t;
|
||||
}
|
||||
}
|
||||
var rangeLen=Math.max(0, rangeOut-rangeIn);
|
||||
function frame(now){
|
||||
if(!start)start=now;
|
||||
var t=(now-start)/1000;
|
||||
if(sceneEnd<=0){ draw(0); requestAnimationFrame(frame); return; }
|
||||
if(loop) t=t%sceneEnd;
|
||||
else if(t>sceneEnd) t=sceneEnd;
|
||||
var elapsed=(now-start)/1000;
|
||||
var t=rangeIn;
|
||||
if(rangeLen<=0){ draw(t); syncBgVideo(t); requestAnimationFrame(frame); return; }
|
||||
if(loop) t=rangeIn+(elapsed%rangeLen);
|
||||
else if(elapsed>=rangeLen) t=rangeOut;
|
||||
else t=rangeIn+elapsed;
|
||||
draw(t);
|
||||
syncBgVideo(t);
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
function tracePolyPath(tctx,sp,ap,offX,offY,closed){
|
||||
@@ -364,8 +394,10 @@ const ENGINE_SOURCE = `(function(){
|
||||
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 layerById={};
|
||||
for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li];
|
||||
var clipById2={};
|
||||
@@ -381,10 +413,32 @@ const ENGINE_SOURCE = `(function(){
|
||||
var groupById2={}; var groups2=scene.groups||[];
|
||||
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
|
||||
if(!rawOrder.length){ rawOrder=[]; for(var lj=0;lj<scene.layers.length;lj++) rawOrder.push(scene.layers[lj].id); }
|
||||
var hasTopMask=false;
|
||||
for(var hm=0;hm<rawOrder.length;hm++){ var hmL=layerById[rawOrder[hm]]; if(hmL && hmL.isMask){ hasTopMask=true; break; } }
|
||||
var mainCtx=ctx, topOff=null;
|
||||
if(hasTopMask){
|
||||
topOff=document.createElement('canvas');
|
||||
topOff.width=canvas.width; topOff.height=canvas.height;
|
||||
var topOffCtx=topOff.getContext('2d');
|
||||
if(topOffCtx){
|
||||
var tmT=ctx.getTransform();
|
||||
topOffCtx.setTransform(tmT.a,tmT.b,tmT.c,tmT.d,tmT.e,tmT.f);
|
||||
ctx=topOffCtx;
|
||||
} else { topOff=null; }
|
||||
}
|
||||
for(var oi=rawOrder.length-1;oi>=0;oi--){
|
||||
var id=rawOrder[oi];
|
||||
var lyr=layerById[id];
|
||||
if(lyr){ drawLayer(lyr,time); continue; }
|
||||
if(lyr){
|
||||
if(lyr.isMask){
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation=(lyr.maskMode==='negative'?'destination-out':'destination-in');
|
||||
drawMaskShape(ctx,lyr,time);
|
||||
ctx.restore();
|
||||
continue;
|
||||
}
|
||||
drawLayer(lyr,time); continue;
|
||||
}
|
||||
var tInsts=instByTrack[id]||[];
|
||||
if(tInsts.length){
|
||||
for(var ti=0;ti<tInsts.length;ti++){
|
||||
@@ -437,6 +491,13 @@ const ENGINE_SOURCE = `(function(){
|
||||
}
|
||||
}
|
||||
}
|
||||
if(topOff){
|
||||
ctx=mainCtx;
|
||||
ctx.save();
|
||||
ctx.setTransform(1,0,0,1,0,0);
|
||||
ctx.drawImage(topOff,0,0);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
loadImages(scene.assets).then(function(loaded){
|
||||
@@ -476,6 +537,15 @@ const SCENE_BOOT_SOURCE = `(function(){
|
||||
`
|
||||
|
||||
function jsonForExport(scene: Scene): string {
|
||||
// Data-URL background videos are stripped from the export — local video
|
||||
// files can't be embedded reasonably. The user needs to host the media
|
||||
// externally and set a URL in settings before exporting.
|
||||
const bg = scene.backgroundVideo
|
||||
if (bg && bg.src && bg.src.startsWith('data:')) {
|
||||
const { backgroundVideo, ...rest } = scene
|
||||
void backgroundVideo
|
||||
return JSON.stringify(rest)
|
||||
}
|
||||
return JSON.stringify(scene)
|
||||
}
|
||||
|
||||
|
||||
+52
-5
@@ -345,8 +345,12 @@ export function drawSceneFrame(
|
||||
ctx.rect(0, 0, lw, lh)
|
||||
ctx.clip()
|
||||
}
|
||||
ctx.fillStyle = scene.canvas.background
|
||||
ctx.fillRect(0, 0, lw, lh)
|
||||
// A background video takes over the backdrop — keep the stage area
|
||||
// transparent so the DOM video underneath is visible.
|
||||
if (!scene.backgroundVideo) {
|
||||
ctx.fillStyle = scene.canvas.background
|
||||
ctx.fillRect(0, 0, lw, lh)
|
||||
}
|
||||
|
||||
const assetMap = new Map<string, Asset>()
|
||||
for (const a of scene.assets) assetMap.set(a.id, a)
|
||||
@@ -367,11 +371,47 @@ export function drawSceneFrame(
|
||||
const rawOrder = scene.trackOrder?.length
|
||||
? scene.trackOrder
|
||||
: scene.layers.map((l) => l.id)
|
||||
|
||||
// Top-level mask layers (i.e. not inside a group) mask everything below
|
||||
// them in the stack until the very bottom. Render everything into an
|
||||
// offscreen buffer so we can apply each mask to whatever has been drawn
|
||||
// so far; we only pay for this when at least one top-level mask exists.
|
||||
const hasTopLevelMask = rawOrder.some((id) => {
|
||||
const l = layerById.get(id)
|
||||
return !!l && !!l.isMask
|
||||
})
|
||||
|
||||
let drawCtx: CanvasRenderingContext2D = ctx
|
||||
let off: HTMLCanvasElement | null = null
|
||||
if (hasTopLevelMask) {
|
||||
off = document.createElement('canvas')
|
||||
off.width = ctx.canvas.width
|
||||
off.height = ctx.canvas.height
|
||||
const oc = off.getContext('2d')
|
||||
if (oc) {
|
||||
const m = ctx.getTransform()
|
||||
oc.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
|
||||
drawCtx = oc
|
||||
} else {
|
||||
off = null
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = rawOrder.length - 1; i >= 0; i--) {
|
||||
const id = rawOrder[i]
|
||||
const layer = layerById.get(id)
|
||||
if (layer) {
|
||||
drawLayers(ctx, [layer], assetMap, images, time)
|
||||
if (layer.isMask) {
|
||||
// Apply this mask to everything already drawn into the offscreen
|
||||
// buffer (the entire stack below it).
|
||||
drawCtx.save()
|
||||
drawCtx.globalCompositeOperation =
|
||||
layer.maskMode === 'negative' ? 'destination-out' : 'destination-in'
|
||||
drawMaskShape(drawCtx, layer, assetMap, time)
|
||||
drawCtx.restore()
|
||||
} else {
|
||||
drawLayers(drawCtx, [layer], assetMap, images, time)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const instances = instancesByTrack.get(id) ?? []
|
||||
@@ -384,7 +424,7 @@ export function drawSceneFrame(
|
||||
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
|
||||
const offsetIntoBlock = time - inst.startTime
|
||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
||||
drawLayers(ctx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
|
||||
drawLayers(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -395,9 +435,16 @@ export function drawSceneFrame(
|
||||
const cl = layerById.get(group.childIds[k])
|
||||
if (cl) childLayers.push(cl)
|
||||
}
|
||||
drawMaskedGroup(ctx, childLayers, assetMap, images, time)
|
||||
drawMaskedGroup(drawCtx, childLayers, assetMap, images, time)
|
||||
}
|
||||
}
|
||||
|
||||
if (off) {
|
||||
ctx.save()
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.drawImage(off, 0, 0)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
+14
-5
@@ -12,6 +12,14 @@ function effectiveEnd(scene: Scene): number {
|
||||
return Math.min(max, scene.duration)
|
||||
}
|
||||
|
||||
function playbackRange(scene: Scene): { start: number; end: number } {
|
||||
const autoEnd = effectiveEnd(scene)
|
||||
const start = Math.max(0, Math.min(scene.duration, scene.inTime ?? 0))
|
||||
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
|
||||
const end = Math.max(start, Math.min(scene.duration, rawEnd))
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export interface VideoExportResult {
|
||||
blob: Blob
|
||||
mimeType: string
|
||||
@@ -56,8 +64,10 @@ export async function renderSceneToVideo(
|
||||
if (!ctx) throw new Error('2D context unavailable')
|
||||
|
||||
const images = await loadSceneImages(scene)
|
||||
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
|
||||
const rangeLen = Math.max(0, rangeOut - rangeIn)
|
||||
|
||||
drawSceneFrame(ctx, scene, 0, images)
|
||||
drawSceneFrame(ctx, scene, rangeIn, images)
|
||||
|
||||
const stream = canvas.captureStream(scene.fps)
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
@@ -76,7 +86,6 @@ export async function renderSceneToVideo(
|
||||
|
||||
recorder.start()
|
||||
const startedAt = performance.now()
|
||||
const renderDuration = effectiveEnd(scene)
|
||||
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
let raf = 0
|
||||
@@ -93,10 +102,10 @@ export async function renderSceneToVideo(
|
||||
return
|
||||
}
|
||||
const elapsed = (performance.now() - startedAt) / 1000
|
||||
const t = Math.min(elapsed, renderDuration)
|
||||
const t = rangeIn + Math.min(elapsed, rangeLen)
|
||||
drawSceneFrame(ctx, scene, t, images)
|
||||
opts.onProgress?.(renderDuration > 0 ? Math.min(1, t / renderDuration) : 1)
|
||||
if (elapsed >= renderDuration) {
|
||||
opts.onProgress?.(rangeLen > 0 ? Math.min(1, elapsed / rangeLen) : 1)
|
||||
if (elapsed >= rangeLen) {
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch {
|
||||
|
||||
+57
-13
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import type {
|
||||
AnimatableProps,
|
||||
Asset,
|
||||
BackgroundVideo,
|
||||
Clip,
|
||||
ClipInstance,
|
||||
ClipTrack,
|
||||
@@ -111,6 +112,7 @@ function normalizeScene(scene: Scene): Scene {
|
||||
scene.clipTracks ||= []
|
||||
scene.clipInstances ||= []
|
||||
scene.groups ||= []
|
||||
if (!scene.canvas.background) scene.canvas.background = '#ffffff'
|
||||
|
||||
const known = new Set<string>()
|
||||
for (const l of scene.layers) known.add(l.id)
|
||||
@@ -154,16 +156,20 @@ function normalizeScene(scene: Scene): Scene {
|
||||
}
|
||||
|
||||
|
||||
export function createBlankScene(): Scene {
|
||||
return normalizeScene({
|
||||
version: 1,
|
||||
canvas: { width: 1280, height: 720, background: '#ffffff' },
|
||||
duration: 10,
|
||||
fps: 60,
|
||||
assets: [],
|
||||
layers: [],
|
||||
})
|
||||
}
|
||||
|
||||
export const useSceneStore = defineStore('scene', {
|
||||
state: (): State => ({
|
||||
scene: normalizeScene({
|
||||
version: 1,
|
||||
canvas: { width: 1280, height: 720, background: '#ffffff' },
|
||||
duration: 10,
|
||||
fps: 60,
|
||||
assets: [],
|
||||
layers: [],
|
||||
}),
|
||||
scene: createBlankScene(),
|
||||
selectedLayerId: null,
|
||||
playhead: 0,
|
||||
playing: false,
|
||||
@@ -219,6 +225,18 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (max <= 0) return this.activeDuration
|
||||
return Math.min(max, this.activeDuration)
|
||||
},
|
||||
/** Effective in-point for playback/export. Defaults to 0, clamped to duration. */
|
||||
playbackIn(): number {
|
||||
if (this.activeClipId) return 0
|
||||
const v = this.scene.inTime ?? 0
|
||||
return Math.max(0, Math.min(this.scene.duration, v))
|
||||
},
|
||||
/** Effective out-point for playback/export. Defaults to duration, clamped >=in. */
|
||||
playbackOut(): number {
|
||||
if (this.activeClipId) return this.activeDuration
|
||||
const v = this.scene.outTime ?? this.scene.duration
|
||||
return Math.max(this.playbackIn, Math.min(this.scene.duration, v))
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
addAsset(asset: AssetInit): Asset {
|
||||
@@ -325,6 +343,30 @@ export const useSceneStore = defineStore('scene', {
|
||||
setCanvasBackground(color: string) {
|
||||
this.scene.canvas.background = color
|
||||
},
|
||||
setBackgroundVideo(video: BackgroundVideo | null) {
|
||||
if (video && video.src) this.scene.backgroundVideo = { ...video }
|
||||
else delete this.scene.backgroundVideo
|
||||
},
|
||||
setPlaybackRange(inTime: number | null, outTime: number | null) {
|
||||
const dur = this.scene.duration
|
||||
const hasIn = inTime !== null && inTime !== undefined
|
||||
const hasOut = outTime !== null && outTime !== undefined
|
||||
if (hasIn) {
|
||||
const v = Math.max(0, Math.min(dur, inTime!))
|
||||
if (v === 0 && this.scene.outTime === undefined) delete this.scene.inTime
|
||||
else this.scene.inTime = v
|
||||
}
|
||||
if (hasOut) {
|
||||
const v = Math.max(this.scene.inTime ?? 0, Math.min(dur, outTime!))
|
||||
if (v === dur && this.scene.inTime === undefined) delete this.scene.outTime
|
||||
else this.scene.outTime = v
|
||||
}
|
||||
// Re-clamp playhead into the new range
|
||||
const lo = this.scene.inTime ?? 0
|
||||
const hi = this.scene.outTime ?? dur
|
||||
if (this.playhead < lo) this.playhead = lo
|
||||
else if (this.playhead > hi) this.playhead = hi
|
||||
},
|
||||
upsertKeyframe(layerId: string, time: number, props: Partial<AnimatableProps>) {
|
||||
const layer = this.activeLayers.find((l) => l.id === layerId)
|
||||
if (!layer) return
|
||||
@@ -935,7 +977,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (isMask) {
|
||||
layer.isMask = true
|
||||
if (mode) layer.maskMode = mode
|
||||
else if (!layer.maskMode) layer.maskMode = 'positive'
|
||||
else if (!layer.maskMode) layer.maskMode = 'negative'
|
||||
} else {
|
||||
delete layer.isMask
|
||||
delete layer.maskMode
|
||||
@@ -1219,15 +1261,17 @@ export const useSceneStore = defineStore('scene', {
|
||||
togglePlay() {
|
||||
if (this.playing) this.pause()
|
||||
else {
|
||||
const end = this.effectiveEnd
|
||||
if (end <= 0) return
|
||||
if (this.playhead >= end) this.playhead = 0
|
||||
const end = this.playbackOut
|
||||
const start = this.playbackIn
|
||||
if (end <= start) return
|
||||
// If the playhead sits outside the in/out range, snap to the in-point.
|
||||
if (this.playhead < start || this.playhead >= end) this.playhead = start
|
||||
this.play()
|
||||
}
|
||||
},
|
||||
tick(deltaSeconds: number) {
|
||||
if (!this.playing) return
|
||||
const end = this.effectiveEnd
|
||||
const end = this.playbackOut
|
||||
const next = this.playhead + deltaSeconds
|
||||
if (next >= end) {
|
||||
this.playhead = end
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
--accent-soft-strong: rgba(110, 168, 255, 0.22);
|
||||
--danger: #ff7a7a;
|
||||
--danger-soft: rgba(255, 122, 122, 0.12);
|
||||
--range-bracket: #f59e0b;
|
||||
--range-hover: #ffffff;
|
||||
--radius: 6px;
|
||||
--radius-sm: 4px;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
@@ -36,6 +38,8 @@
|
||||
--accent-soft-strong: rgba(43, 106, 245, 0.18);
|
||||
--danger: #d63c3c;
|
||||
--danger-soft: rgba(214, 60, 60, 0.09);
|
||||
--range-bracket: #d97706;
|
||||
--range-hover: #111111;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 8px 24px rgba(20, 25, 40, 0.12);
|
||||
color-scheme: light;
|
||||
|
||||
@@ -154,6 +154,14 @@ export interface ClipInstance {
|
||||
trimEnd?: number
|
||||
}
|
||||
|
||||
export interface BackgroundVideo {
|
||||
src: string
|
||||
offsetTime?: number
|
||||
loop?: boolean
|
||||
muted?: boolean
|
||||
opacity?: number
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
version: 1
|
||||
canvas: {
|
||||
@@ -163,6 +171,10 @@ export interface Scene {
|
||||
}
|
||||
duration: number
|
||||
fps: number
|
||||
/** Optional in-point for playback & export. Defaults to 0. */
|
||||
inTime?: number
|
||||
/** Optional out-point for playback & export. Defaults to `duration`. */
|
||||
outTime?: number
|
||||
assets: Asset[]
|
||||
layers: Layer[]
|
||||
clips?: Clip[]
|
||||
@@ -170,6 +182,7 @@ export interface Scene {
|
||||
clipInstances?: ClipInstance[]
|
||||
trackOrder?: string[]
|
||||
groups?: TrackGroup[]
|
||||
backgroundVideo?: BackgroundVideo
|
||||
}
|
||||
|
||||
export const DEFAULT_BASE_PROPS: AnimatableProps = {
|
||||
|
||||
Reference in new issue
Block a user