diff --git a/src/components/shell/Toolbar.tsx b/src/components/shell/Toolbar.tsx index b231e1f..f557daa 100644 --- a/src/components/shell/Toolbar.tsx +++ b/src/components/shell/Toolbar.tsx @@ -44,7 +44,7 @@ import { createDefaultProject } from '@/editor/scene' import { useEditorStore } from '@/editor/store' import type { ExportOptions } from '@/io/export-options' import { downloadProject, LARGE_PROJECT_PERSIST_LAYER_THRESHOLD, openProjectFile } from '@/io/project' -import { downloadLottie, exportLottie, readLottieFromFile } from '@/io/lottie' +import { downloadLottie, exportLottie, readLottieFromFile, computeLottieImportProgress, formatLottieImportProgress } from '@/io/lottie' import { computeSvgImportProgress, createImportLayerIds, @@ -92,7 +92,8 @@ export function Toolbar() { const [isExporting, setIsExporting] = useState(false) const [isImportingSvg, setIsImportingSvg] = useState(false) const [isImportingHtml, setIsImportingHtml] = useState(false) - const isImporting = isImportingSvg || isImportingHtml + const [isImportingLottie, setIsImportingLottie] = useState(false) + const isImporting = isImportingSvg || isImportingHtml || isImportingLottie const project = useEditorStore((state) => state.project) const activeArtboardId = useEditorStore((state) => state.activeArtboardId) const playbackState = useEditorStore((state) => state.playbackState) @@ -353,23 +354,53 @@ export function Toolbar() { return } - const imported = await readLottieFromFile(file) - if (!imported) { + setIsImportingLottie(true) + const toastId = showToast({ + title: 'Importing Lottie', + description: formatLottieImportProgress({ stage: 'parsing' }), + variant: 'loading', + progress: computeLottieImportProgress({ stage: 'parsing' }), + }) + await waitForPaint() + + try { + const imported = await readLottieFromFile(file, { + onProgress: (progress) => { + updateToast(toastId, { + description: formatLottieImportProgress(progress), + progress: computeLottieImportProgress(progress), + }) + }, + }) + + dismissToast(toastId) + + if (!imported) { + showToast({ + title: 'Lottie import failed', + description: `"${file.name}" is not a valid Lottie JSON file.`, + variant: 'error', + }) + return + } + + setProject(imported) + requestAnimationFrame(fitCanvasToScreen) showToast({ + title: 'Lottie opened', + description: `Loaded ${imported.layers.length} layer${imported.layers.length === 1 ? '' : 's'} as a new project.`, + variant: 'success', + }) + } catch (error) { + updateToast(toastId, { title: 'Lottie import failed', - description: `"${file.name}" is not a valid Lottie JSON file.`, + description: + error instanceof Error ? error.message : 'Something went wrong while importing.', variant: 'error', }) - return + } finally { + setIsImportingLottie(false) } - - setProject(imported) - requestAnimationFrame(fitCanvasToScreen) - showToast({ - title: 'Lottie opened', - description: 'Loaded animation as a new project.', - variant: 'success', - }) } const handleExport = async (options: ExportOptions) => { diff --git a/src/editor/animation.ts b/src/editor/animation.ts index 0f9140d..764c53e 100644 --- a/src/editor/animation.ts +++ b/src/editor/animation.ts @@ -9,6 +9,12 @@ import type { NumericAnimatableProperty, Shape, } from '@/editor/types' +import { + applyGroupTransformsToShape, + layerHasGroupAnimation, + type AnimatedShapeContext, +} from '@/editor/group-animation' +export type { AnimatedShapeContext } from '@/editor/group-animation' import { sampleEasing } from '@/editor/easing' import { layerHasAnimation } from '@/editor/layer-animation' import type { AffineMatrix } from '@/io/svg-transform' @@ -139,7 +145,7 @@ export function samplePropertyAtTime( return sampleNumericTrackAtTime(track, time, fallback) } -function sampleNumericTrackAtTime(track: Keyframe[], time: number, fallback: number): number { +export function sampleNumericTrackAtTime(track: Keyframe[], time: number, fallback: number): number { return sampleSegmentValue(track, time, fallback, (current, next, eased) => lerp(current.value as number, next.value as number, eased), ) @@ -310,14 +316,19 @@ function layerNeedsMatrixSampling(layer: Layer): boolean { ) } -export function getAnimatedShape(layer: Layer, time: number): Shape { - if (!layerHasAnimation(layer) && !layerNeedsMatrixSampling(layer)) { +export function getAnimatedShape( + layer: Layer, + time: number, + context?: AnimatedShapeContext, +): Shape { + const hasGroupAnimation = layerHasGroupAnimation(layer, context?.layerGroups) + if (!layerHasAnimation(layer) && !layerNeedsMatrixSampling(layer) && !hasGroupAnimation) { return layer.shape } const cached = animatedShapeCache.get(layer) if (cached && cached.time === time) { - return cached.shape + return applyGroupTransformsToShape(cached.shape, layer, time, context) } const { shape } = layer @@ -390,6 +401,8 @@ export function getAnimatedShape(layer: Layer, time: number): Shape { } } + const resultWithGroups = applyGroupTransformsToShape(result, layer, time, context) + animatedShapeCache.set(layer, { time, shape: result }) - return result + return resultWithGroups } diff --git a/src/editor/group-animation.ts b/src/editor/group-animation.ts new file mode 100644 index 0000000..673a27f --- /dev/null +++ b/src/editor/group-animation.ts @@ -0,0 +1,138 @@ +import { sampleNumericTrackAtTime } from '@/editor/animation' +import { getShapeBounds } from '@/editor/bounds' +import type { Keyframe, Layer, LayerGroupMeta, Shape } from '@/editor/types' + +export type AnimatedShapeContext = { + layerGroups?: Record +} + +function getGroupAncestorChain( + groupId: string | null, + layerGroups?: Record, +): string[] { + if (!groupId || !layerGroups) { + return [] + } + + const chain: string[] = [] + let current: string | null = groupId + + while (current && layerGroups[current]) { + chain.push(current) + current = layerGroups[current]!.parentGroupId + } + + return chain.reverse() +} + +function sampleGroupNumeric( + keyframes: Keyframe[], + property: Keyframe['property'], + time: number, + fallback: number, +): number { + if (keyframes.length === 0) { + return fallback + } + + const track = keyframes + .filter((keyframe) => keyframe.property === property) + .sort((left, right) => left.time - right.time) + + return sampleNumericTrackAtTime(track, time, fallback) +} + +function getShapeCenter(shape: Shape): { x: number; y: number } { + const bounds = getShapeBounds(shape) + return { + x: bounds.x + bounds.width / 2, + y: bounds.y + bounds.height / 2, + } +} + +function centerToTopLeft( + center: { x: number; y: number }, + shape: Shape, +): Pick { + const bounds = getShapeBounds(shape) + + if (shape.type === 'ellipse') { + return { x: center.x, y: center.y } + } + + return { + x: center.x - bounds.width / 2, + y: center.y - bounds.height / 2, + } +} + +export function applyGroupTransformsToShape( + shape: Shape, + layer: Layer, + time: number, + context?: AnimatedShapeContext, +): Shape { + const layerGroups = context?.layerGroups + if (!layerGroups || !layer.groupId) { + return shape + } + + let center = getShapeCenter(shape) + let rotation = shape.rotation + let scaleX = shape.scaleX + let scaleY = shape.scaleY + let opacity = shape.opacity + + for (const groupId of getGroupAncestorChain(layer.groupId, layerGroups)) { + const group = layerGroups[groupId] + const keyframes = group?.keyframes ?? [] + if (keyframes.length === 0) { + continue + } + + const translateX = sampleGroupNumeric(keyframes, 'x', time, 0) + const translateY = sampleGroupNumeric(keyframes, 'y', time, 0) + const groupRotation = sampleGroupNumeric(keyframes, 'rotation', time, 0) + const groupScaleX = sampleGroupNumeric(keyframes, 'scaleX', time, 1) + const groupScaleY = sampleGroupNumeric(keyframes, 'scaleY', time, 1) + const groupOpacity = sampleGroupNumeric(keyframes, 'opacity', time, 1) + + if (groupRotation !== 0) { + rotation += groupRotation + } + + if (translateX !== 0) { + center = { ...center, x: center.x + translateX } + } + + if (translateY !== 0) { + center = { ...center, y: center.y + translateY } + } + + scaleX *= groupScaleX + scaleY *= groupScaleY + opacity *= groupOpacity + } + + return { + ...shape, + ...centerToTopLeft(center, shape), + rotation, + scaleX, + scaleY, + opacity, + } +} + +export function layerHasGroupAnimation( + layer: Layer, + layerGroups?: Record, +): boolean { + if (!layerGroups || !layer.groupId) { + return false + } + + return getGroupAncestorChain(layer.groupId, layerGroups).some( + (groupId) => (layerGroups[groupId]?.keyframes?.length ?? 0) > 0, + ) +} diff --git a/src/editor/store.ts b/src/editor/store.ts index 71eb486..11daa57 100644 --- a/src/editor/store.ts +++ b/src/editor/store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { DEFAULT_CUSTOM_BEZIER } from '@/editor/easing' -import { getAnimatedShape } from '@/editor/animation' +import { getAnimatedShape as resolveAnimatedShape } from '@/editor/animation' import { getShapeBounds } from '@/editor/bounds' import { applyPresetToLayers, type PresetId, type PresetOptions } from '@/editor/presets' import { @@ -390,7 +390,9 @@ function getSelectedAlignItems(state: EditorStore) { ) .map((layer) => ({ id: layer.id, - shape: getAnimatedShape(layer, state.currentTime), + shape: resolveAnimatedShape(layer, state.currentTime, { + layerGroups: state.project.layerGroups, + }), })) } @@ -443,7 +445,7 @@ function restoreSnapshot(snapshot: ReturnType): Partial((set) => ({ +export const useEditorStore = create((set, get) => ({ project: initialProject, activeArtboardId: initialProject.artboards[0]?.id ?? null, selectedLayerIds: [], @@ -1965,7 +1967,9 @@ export const useEditorStore = create((set) => ({ const boundsList = state.selectedLayerIds .map((id) => state.project.layers.find((layer) => layer.id === id)) .filter((layer): layer is Layer => Boolean(layer)) - .map((layer) => getShapeBounds(getAnimatedShape(layer, state.currentTime))) + .map((layer) => getShapeBounds(resolveAnimatedShape(layer, state.currentTime, { + layerGroups: state.project.layerGroups, + }))) if (boundsList.length === 0) { return state @@ -2030,7 +2034,10 @@ export const useEditorStore = create((set) => ({ history: pushSnapshot(state.history, createSnapshot(state.project, state.selectedLayerIds)), })), - getAnimatedShape, + getAnimatedShape: (layer, time) => { + const { project } = get() + return resolveAnimatedShape(layer, time, { layerGroups: project.layerGroups }) + }, })) export function useActiveArtboard(): Artboard { diff --git a/src/editor/transforms.test.ts b/src/editor/transforms.test.ts index 626854e..299e41a 100644 --- a/src/editor/transforms.test.ts +++ b/src/editor/transforms.test.ts @@ -22,4 +22,29 @@ describe('buildShapeTransform', () => { expect(transform).toBe('translate(200 300) rotate(0) translate(0 40) scale(1.2 0.7) translate(0 -40)') }) + + it('rotates paths around their visual center', () => { + const transform = buildShapeTransform({ + id: 'shape-2', + type: 'path', + x: 226, + y: 364, + rotation: 90, + points: [ + { x: 0, y: 0 }, + { x: 0, y: 36 }, + ], + closed: false, + fill: 'none', + stroke: '#141416', + strokeWidth: 1.3, + opacity: 1, + scaleX: 1, + scaleY: 1, + }) + + expect(transform).toBe( + 'translate(226 382) rotate(90) scale(1 1) translate(0 -18)', + ) + }) }) diff --git a/src/editor/transforms.ts b/src/editor/transforms.ts index 1738234..ef909fb 100644 --- a/src/editor/transforms.ts +++ b/src/editor/transforms.ts @@ -1,4 +1,5 @@ import type { Shape } from '@/editor/types' +import { getShapeBounds } from '@/editor/bounds' export function buildShapeTransform(shape: Shape): string { const scaleX = shape.scaleX @@ -19,7 +20,13 @@ export function buildShapeTransform(shape: Shape): string { return `translate(${shape.x} ${shape.y}) rotate(${shape.rotation}) translate(0 ${shape.ry}) scale(${scaleX} ${scaleY}) translate(0 ${-shape.ry})` } - // Imported/baked path points already live in world space. Keyframed deltas are - // decomposed as translate(x,y) → rotate → scale around the origin. - return `translate(${shape.x} ${shape.y}) rotate(${shape.rotation}) scale(${scaleX} ${scaleY})` + // Rotate paths around their visual center so imported wheel spokes stay aligned. + if (shape.type === 'path') { + const bounds = getShapeBounds(shape) + const pivotX = bounds.width / 2 + const pivotY = bounds.height / 2 + return `translate(${shape.x + pivotX} ${shape.y + pivotY}) rotate(${shape.rotation}) scale(${scaleX} ${scaleY}) translate(${-pivotX} ${-pivotY})` + } + + return '' } diff --git a/src/editor/types.ts b/src/editor/types.ts index 6cb0842..6ef7f29 100644 --- a/src/editor/types.ts +++ b/src/editor/types.ts @@ -1,6 +1,6 @@ import { BRAND, UI_PATH_STROKE } from '@/lib/brand-colors' -export const PROJECT_VERSION = 12 as const +export const PROJECT_VERSION = 13 as const export const DEFAULT_PROJECT_FPS = 30 export type CanvasSettings = { @@ -199,6 +199,11 @@ export type Layer = { export type LayerGroupMeta = { name: string parentGroupId: string | null + /** DOM child-index path from the root for import matching. */ + nodePath?: number[] + /** Class names declared on the source element. */ + classNames?: string[] + keyframes?: Keyframe[] } export type Marker = { diff --git a/src/io/css-keyframes.ts b/src/io/css-keyframes.ts index bc6711d..5c236ef 100644 --- a/src/io/css-keyframes.ts +++ b/src/io/css-keyframes.ts @@ -1,12 +1,8 @@ -import { createId } from '@/editor/scene' +import { createId, createRectShape } from '@/editor/scene' import { getShapeBounds } from '@/editor/bounds' -import type { AnimatableProperty, Keyframe, Layer, Shape } from '@/editor/types' -import { - applyMatrixToPoint, - IDENTITY_MATRIX, - multiplyMatrix, - type AffineMatrix, -} from '@/io/svg-transform' +import type { AnimatableProperty, EasingType, Keyframe, Layer, LayerGroupMeta, Shape } from '@/editor/types' +import { sampleEasing } from '@/editor/easing' +import { getNodePath } from '@/io/svg-matrix-batch' import { yieldToUi } from '@/lib/yield-to-ui' function roundCoord(value: number): number { @@ -90,6 +86,306 @@ export type CssAnimationTrack = { steps: CssKeyframeStep[] } +export type CssTimingFunction = + | { type: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' } + | { type: 'steps'; count: number; jump: 'start' | 'end' | 'none' } + +export type CssAnimationDirection = 'normal' | 'reverse' | 'alternate' | 'alternate-reverse' + +export type CssAnimationBinding = { + animationName: string + duration: number + delay: number + direction: CssAnimationDirection + timingFunction: CssTimingFunction +} + +const ANIMATION_DIRECTIONS = new Set([ + 'normal', + 'reverse', + 'alternate', + 'alternate-reverse', +]) + +function tokenizeAnimationShorthand(value: string): string[] { + const tokens: string[] = [] + let current = '' + let depth = 0 + + for (const char of value) { + if (char === '(') { + depth += 1 + } else if (char === ')') { + depth -= 1 + } + + if (char === ' ' && depth === 0) { + if (current.length > 0) { + tokens.push(current) + current = '' + } + continue + } + + current += char + } + + if (current.length > 0) { + tokens.push(current) + } + + return tokens +} + +function isDurationToken(token: string, variables: Map): boolean { + const resolved = resolveCssVar(token, variables) + return parseDurationSeconds(resolved) !== null +} + +function parseTimingFunctionToken(token: string): CssTimingFunction | null { + const normalized = token.trim().toLowerCase() + if (normalized === 'linear') { + return { type: 'linear' } + } + if (normalized === 'ease') { + return { type: 'ease' } + } + if (normalized === 'ease-in') { + return { type: 'ease-in' } + } + if (normalized === 'ease-out') { + return { type: 'ease-out' } + } + if (normalized === 'ease-in-out') { + return { type: 'ease-in-out' } + } + + const stepsMatch = normalized.match(/^steps\(\s*([0-9]+)\s*(?:,\s*(start|end|jump-start|jump-end|jump-none))?\s*\)$/) + if (stepsMatch) { + const count = Number.parseInt(stepsMatch[1]!, 10) + const jumpToken = stepsMatch[2] ?? 'end' + const jump = + jumpToken === 'start' || jumpToken === 'jump-start' + ? 'start' + : jumpToken === 'jump-none' + ? 'none' + : 'end' + return { type: 'steps', count: Number.isFinite(count) && count > 0 ? count : 1, jump } + } + + return null +} + +export function parseAnimationShorthand( + shorthand: string, + variables: Map, +): Omit & { animationName?: string } { + const tokens = tokenizeAnimationShorthand(shorthand.trim()) + const binding: CssAnimationBinding = { + animationName: '', + duration: 3, + delay: 0, + direction: 'normal', + timingFunction: { type: 'linear' }, + } + + const durations: number[] = [] + + for (const token of tokens) { + const lower = token.toLowerCase() + if (binding.animationName.length === 0 && !isDurationToken(token, variables)) { + const timing = parseTimingFunctionToken(token) + if (timing) { + binding.timingFunction = timing + continue + } + if (ANIMATION_DIRECTIONS.has(lower as CssAnimationDirection)) { + binding.direction = lower as CssAnimationDirection + continue + } + if (lower === 'infinite' || /^[0-9.]+$/.test(lower)) { + continue + } + binding.animationName = token + continue + } + + if (isDurationToken(token, variables)) { + const resolved = resolveCssVar(token, variables) + const duration = parseDurationSeconds(resolved) + if (duration !== null) { + durations.push(duration) + } + continue + } + + const timing = parseTimingFunctionToken(token) + if (timing) { + binding.timingFunction = timing + continue + } + + if (ANIMATION_DIRECTIONS.has(lower as CssAnimationDirection)) { + binding.direction = lower as CssAnimationDirection + } + } + + if (durations[0] !== undefined) { + binding.duration = Math.abs(durations[0]) + } + if (durations[1] !== undefined) { + binding.delay = durations[1] + } + + return binding +} + +export function parseClassAnimationBindings(css: string): Map { + const variables = parseCssVariables(css) + const bindings = new Map() + const classPattern = /\.([a-zA-Z_][\w-]*)\s*\{([^}]*)\}/g + let match: RegExpExecArray | null + + while ((match = classPattern.exec(css)) !== null) { + const className = match[1]! + const body = match[2]! + const animationDecl = body.match(/animation:\s*([^;]+)/)?.[1]?.trim() + if (!animationDecl) { + continue + } + + const parsed = parseAnimationShorthand(animationDecl, variables) + if (!parsed.animationName) { + continue + } + + bindings.set(className, { + animationName: parsed.animationName, + duration: parsed.duration, + delay: parsed.delay, + direction: parsed.direction, + timingFunction: parsed.timingFunction, + }) + } + + return bindings +} + +export function parseElementAnimationOverrides( + element: Element, + variables: Map, +): Partial> { + const style = element.getAttribute('style') + if (!style) { + return {} + } + + const overrides: Partial> = + {} + + const delayMatch = style.match(/animation-delay\s*:\s*([^;]+)/i)?.[1]?.trim() + if (delayMatch) { + const delay = parseDurationSeconds(resolveCssVar(delayMatch, variables)) + if (delay !== null) { + overrides.delay = delay + } + } + + const directionMatch = style.match(/animation-direction\s*:\s*([^;]+)/i)?.[1]?.trim() + if (directionMatch && ANIMATION_DIRECTIONS.has(directionMatch.toLowerCase() as CssAnimationDirection)) { + overrides.direction = directionMatch.toLowerCase() as CssAnimationDirection + } + + const timingMatch = style.match(/animation-timing-function\s*:\s*([^;]+)/i)?.[1]?.trim() + if (timingMatch) { + const timing = parseTimingFunctionToken(resolveCssVar(timingMatch, variables)) + if (timing) { + overrides.timingFunction = timing + } + } + + const animationMatch = style.match(/animation\s*:\s*([^;]+)/i)?.[1]?.trim() + if (animationMatch) { + const parsed = parseAnimationShorthand(animationMatch, variables) + if (parsed.delay !== 0) { + overrides.delay = parsed.delay + } + if (parsed.direction !== 'normal') { + overrides.direction = parsed.direction + } + if (parsed.timingFunction.type !== 'linear') { + overrides.timingFunction = parsed.timingFunction + } + if (parsed.duration !== 3) { + overrides.duration = parsed.duration + } + } + + return overrides +} + +export function cssTimingToEasing(timing: CssTimingFunction): EasingType { + if (timing.type === 'steps') { + return 'hold' + } + + switch (timing.type) { + case 'ease-in': + return 'easeIn' + case 'ease-out': + return 'easeOut' + case 'ease-in-out': + case 'ease': + return 'easeInOut' + default: + return 'linear' + } +} + +function applyCssTiming(progress: number, timing: CssTimingFunction): number { + const clamped = Math.max(0, Math.min(1, progress)) + + if (timing.type === 'steps') { + const count = Math.max(1, timing.count) + if (count === 1) { + return 0 + } + + if (timing.jump === 'start') { + return Math.min(1, Math.ceil(clamped * count) / count) + } + + return Math.min(1, Math.floor(clamped * count) / (count - 1)) + } + + return sampleEasing(clamped, cssTimingToEasing(timing)) +} + +function resolveAnimationLocalTime( + globalTime: number, + binding: CssAnimationBinding, +): number | null { + const activeTime = globalTime - binding.delay + if (activeTime < 0 || binding.duration <= 0) { + return null + } + + let loopTime = activeTime % binding.duration + if (activeTime > 0 && loopTime === 0) { + loopTime = binding.duration + } + + const iteration = Math.floor(activeTime / binding.duration) + let reversed = binding.direction === 'reverse' + if (binding.direction === 'alternate') { + reversed = iteration % 2 === 1 + } else if (binding.direction === 'alternate-reverse') { + reversed = iteration % 2 === 0 + } + + return reversed ? binding.duration - loopTime : loopTime +} + const SHAPE_TAGS = new Set([ 'rect', 'circle', @@ -232,16 +528,16 @@ export function resolveCssVar(value: string, variables: Map): st function parseDurationSeconds(value: string): number | null { const trimmed = value.trim() - const secondsMatch = trimmed.match(/^([0-9.]+)s$/) + const secondsMatch = trimmed.match(/^(-?[0-9.]+)s$/) if (secondsMatch) { const duration = Number.parseFloat(secondsMatch[1]!) - return Number.isFinite(duration) && duration > 0 ? duration : null + return Number.isFinite(duration) ? duration : null } - const msMatch = trimmed.match(/^([0-9.]+)ms$/) + const msMatch = trimmed.match(/^(-?[0-9.]+)ms$/) if (msMatch) { const duration = Number.parseFloat(msMatch[1]!) / 1000 - return Number.isFinite(duration) && duration > 0 ? duration : null + return Number.isFinite(duration) ? duration : null } return null @@ -400,12 +696,14 @@ function addKeyframe( time: number, property: AnimatableProperty, value: number | string, + easing: EasingType = 'linear', ) { const existing = keyframes.find( (keyframe) => keyframe.time === time && keyframe.property === property, ) if (existing) { existing.value = value + existing.easing = easing return } @@ -414,7 +712,7 @@ function addKeyframe( time, property, value, - easing: 'linear', + easing, }) } @@ -464,6 +762,35 @@ type AnimatedAncestor = { element: Element track: CssAnimationTrack className: string + binding: CssAnimationBinding +} + +function resolveAnimationBinding( + element: Element, + className: string, + css: string, + track: CssAnimationTrack, +): CssAnimationBinding { + const variables = parseCssVariables(css) + const classBindings = parseClassAnimationBindings(css) + const base = + classBindings.get(className) ?? + ({ + animationName: '', + duration: track.duration, + delay: 0, + direction: 'normal', + timingFunction: { type: 'linear' }, + } satisfies CssAnimationBinding) + const overrides = parseElementAnimationOverrides(element, variables) + + return { + ...base, + duration: overrides.duration ?? base.duration ?? track.duration, + delay: overrides.delay ?? base.delay, + direction: overrides.direction ?? base.direction, + timingFunction: overrides.timingFunction ?? base.timingFunction, + } } function getElementClassNames(element: Element): string[] { @@ -488,10 +815,6 @@ function getElementClassNames(element: Element): string[] { return [] } -function elementHasAnimationClass(element: Element, classToAnimation: Map): boolean { - return getElementClassNames(element).some((className) => classToAnimation.has(className)) -} - function getAnimatedAncestorChain(shapeElement: Element, css: string): AnimatedAncestor[] { const classToAnimation = parseClassToAnimationMap(css) const tracks = parseCssKeyframeTracks(css) @@ -513,7 +836,12 @@ function getAnimatedAncestorChain(shapeElement: Element, css: string): AnimatedA const track = tracks.get(animationName) if (track) { - chain.push({ element: current, track, className }) + chain.push({ + element: current, + track, + className, + binding: resolveAnimationBinding(current, className, css, track), + }) } } @@ -527,23 +855,43 @@ function getAnimatedAncestorChain(shapeElement: Element, css: string): AnimatedA return chain.reverse() } -function collectShapesUntilNestedAnimation( +function collectScopedShapesForAncestor( root: Element, - classToAnimation: Map, + ancestorChain: AnimatedAncestor[], + ancestorIndex: number, ): Element[] { const tag = root.tagName.toLowerCase() if (SHAPE_TAGS.has(tag)) { return [root] } + const nestedClassNames = new Set( + ancestorChain.slice(ancestorIndex + 1).map((entry) => entry.className), + ) + const immediateNextClass = ancestorChain[ancestorIndex + 1]?.className const shapes: Element[] = [] + const walk = (node: Element) => { for (const child of [...node.children]) { - if (child !== root && elementHasAnimationClass(child, classToAnimation)) { + const childClasses = getElementClassNames(child) + const childTag = child.tagName.toLowerCase() + const entersNextAnimatedGroup = + immediateNextClass !== undefined && + childClasses.includes(immediateNextClass) && + !SHAPE_TAGS.has(childTag) + const skipsDeeperAnimatedSubtree = childClasses.some( + (className) => nestedClassNames.has(className) && className !== immediateNextClass, + ) + + if (skipsDeeperAnimatedSubtree && !entersNextAnimatedGroup) { + continue + } + + if (entersNextAnimatedGroup) { + walk(child) continue } - const childTag = child.tagName.toLowerCase() if (SHAPE_TAGS.has(childTag)) { shapes.push(child) } else { @@ -599,17 +947,58 @@ function parseTransformOriginForClass(css: string, className: string): string | return match?.[1]?.trim() } +function parseTransformBoxForClass(css: string, className: string): string | undefined { + const escaped = escapeRegExp(className) + const match = css.match(new RegExp(`\\.${escaped}\\s*\\{[^}]*transform-box:\\s*([^;\\}]+)`)) + return match?.[1]?.trim().toLowerCase() +} + +function resolveAxisOrigin( + token: string, + axis: 'x' | 'y', + bounds: { x: number; y: number; width: number; height: number }, +): number { + const normalized = token.trim().toLowerCase() + + if (normalized.endsWith('%')) { + const percent = Number.parseFloat(normalized) / 100 + return axis === 'x' + ? bounds.x + bounds.width * percent + : bounds.y + bounds.height * percent + } + + if (normalized === 'left') { + return bounds.x + } + + if (normalized === 'right') { + return bounds.x + bounds.width + } + + if (normalized === 'top') { + return bounds.y + } + + if (normalized === 'bottom') { + return bounds.y + bounds.height + } + + return axis === 'x' ? bounds.x + bounds.width / 2 : bounds.y + bounds.height / 2 +} + function resolveTransformOrigin( originValue: string | undefined, bounds: { x: number; y: number; width: number; height: number }, ): { x: number; y: number } { const normalized = (originValue ?? 'center').trim().toLowerCase() + const tokens = normalized.split(/\s+/).filter(Boolean) + const xToken = tokens[0] ?? 'center' + const yToken = tokens[1] ?? tokens[0] ?? 'center' - if (normalized.includes('bottom')) { - return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height } + return { + x: resolveAxisOrigin(xToken, 'x', bounds), + y: resolveAxisOrigin(yToken, 'y', bounds), } - - return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 } } type TransformComponents = { @@ -650,48 +1039,6 @@ function lerp(a: number, b: number, t: number): number { return a + (b - a) * t } -function cssStepToMatrix(step: CssKeyframeStep, originX: number, originY: number): AffineMatrix { - const components = parseTransformComponents(step.transform ?? '') - let matrix = IDENTITY_MATRIX - - if (components.translateX !== 0 || components.translateY !== 0) { - matrix = multiplyMatrix(matrix, { - a: 1, - b: 0, - c: 0, - d: 1, - e: components.translateX, - f: components.translateY, - }) - } - - if (components.rotate !== 0) { - const angle = (components.rotate * Math.PI) / 180 - const cos = Math.cos(angle) - const sin = Math.sin(angle) - const rotate = { a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 } - const toOrigin = { a: 1, b: 0, c: 0, d: 1, e: -originX, f: -originY } - const back = { a: 1, b: 0, c: 0, d: 1, e: originX, f: originY } - matrix = multiplyMatrix(matrix, multiplyMatrix(back, multiplyMatrix(rotate, toOrigin))) - } - - if (components.scaleX !== 1 || components.scaleY !== 1) { - const toOrigin = { a: 1, b: 0, c: 0, d: 1, e: -originX, f: -originY } - const scale = { - a: components.scaleX, - b: 0, - c: 0, - d: components.scaleY, - e: 0, - f: 0, - } - const back = { a: 1, b: 0, c: 0, d: 1, e: originX, f: originY } - matrix = multiplyMatrix(matrix, multiplyMatrix(back, multiplyMatrix(scale, toOrigin))) - } - - return matrix -} - function formatTransformComponents(components: TransformComponents): string { const parts: string[] = [] @@ -736,16 +1083,18 @@ function ensureTrackSteps(steps: CssKeyframeStep[]): CssKeyframeStep[] { return sorted } -function sampleTrackAtTime(track: CssAnimationTrack, time: number): CssKeyframeStep { +function sampleTrackAtTime( + track: CssAnimationTrack, + globalTime: number, + binding: CssAnimationBinding, +): CssKeyframeStep { const steps = ensureTrackSteps(track.steps) - let loopTime = 0 - if (track.duration > 0) { - loopTime = time % track.duration - if (time > 0 && loopTime === 0) { - loopTime = track.duration - } + const localTime = resolveAnimationLocalTime(globalTime, binding) + if (localTime === null) { + return steps[0]! } - const percent = track.duration > 0 ? (loopTime / track.duration) * 100 : 0 + + const percent = binding.duration > 0 ? (localTime / binding.duration) * 100 : 0 let previous = steps[0]! for (let index = 1; index < steps.length; index += 1) { @@ -753,15 +1102,19 @@ function sampleTrackAtTime(track: CssAnimationTrack, time: number): CssKeyframeS if (percent <= next.percent) { const range = next.percent - previous.percent const amount = range > 0 ? (percent - previous.percent) / range : 0 + const easedAmount = + binding.timingFunction.type === 'steps' + ? applyCssTiming(amount, binding.timingFunction) + : amount return { percent, - transform: interpolateTransformCss(previous.transform, next.transform, amount), + transform: interpolateTransformCss(previous.transform, next.transform, easedAmount), opacity: previous.opacity !== undefined || next.opacity !== undefined - ? lerp(previous.opacity ?? 1, next.opacity ?? 1, amount) + ? lerp(previous.opacity ?? 1, next.opacity ?? 1, easedAmount) : undefined, - fill: amount < 0.5 ? previous.fill : next.fill, - stroke: amount < 0.5 ? previous.stroke : next.stroke, + fill: easedAmount < 0.5 ? previous.fill : next.fill, + stroke: easedAmount < 0.5 ? previous.stroke : next.stroke, } } @@ -775,37 +1128,69 @@ function roundTime(time: number): number { return Math.round(time * 1000) / 1000 } -function collectSparseSampleTimes(tracks: CssAnimationTrack[], projectDuration: number): number[] { - const localDuration = Math.max(...tracks.map((track) => track.duration), 0) - const cycleTimes = new Set([0]) +function collectLocalBoundaryTimes( + track: CssAnimationTrack, + binding: CssAnimationBinding, +): number[] { + const times = new Set([0]) + const duration = binding.duration > 0 ? binding.duration : track.duration - for (const track of tracks) { - for (const step of ensureTrackSteps(track.steps)) { - cycleTimes.add(roundTime((step.percent / 100) * track.duration)) - } + for (const step of ensureTrackSteps(track.steps)) { + times.add(roundTime((step.percent / 100) * duration)) + } + + if (duration > 0) { + times.add(duration) } - if (localDuration > 0) { - cycleTimes.add(localDuration) + if (binding.timingFunction.type === 'steps' && binding.timingFunction.count > 1) { + const sortedSteps = ensureTrackSteps(track.steps) + for (let index = 0; index < sortedSteps.length - 1; index += 1) { + const previous = sortedSteps[index]! + const next = sortedSteps[index + 1]! + const segStart = (previous.percent / 100) * duration + const segEnd = (next.percent / 100) * duration + const stepCount = binding.timingFunction.count + + for (let stepIndex = 1; stepIndex < stepCount; stepIndex += 1) { + times.add(roundTime(segStart + ((segEnd - segStart) * stepIndex) / stepCount)) + } + } } - const sortedCycleTimes = [...cycleTimes].sort((a, b) => a - b) - const times = new Set() + return [...times].sort((a, b) => a - b) +} + +function collectSparseSampleTimes( + ancestors: AnimatedAncestor[], + projectDuration: number, +): number[] { + const times = new Set([0, projectDuration]) + + for (const { track, binding } of ancestors) { + const duration = binding.duration > 0 ? binding.duration : track.duration + if (duration <= 0) { + continue + } + + const localBoundaryTimes = collectLocalBoundaryTimes(track, binding) + let iteration = 0 + + while (iteration < 10_000) { + const iterationStart = binding.delay + iteration * duration + if (iterationStart > projectDuration + duration) { + break + } - if (localDuration > 0 && localDuration < projectDuration) { - for (let offset = 0; offset < projectDuration; offset += localDuration) { - for (const time of sortedCycleTimes) { - const absolute = roundTime(offset + time) - if (absolute <= projectDuration) { + for (const local of localBoundaryTimes) { + const absolute = roundTime(iterationStart + local) + if (absolute >= 0 && absolute <= projectDuration) { times.add(absolute) } } + + iteration += 1 } - } else { - for (const time of sortedCycleTimes) { - times.add(time) - } - times.add(projectDuration) } return [...times].sort((a, b) => a - b) @@ -828,8 +1213,8 @@ function sampleOpacityFromAncestors( ): number { let opacity = fallback - for (const { track } of ancestors) { - const step = sampleTrackAtTime(track, time) + for (const { track, binding } of ancestors) { + const step = sampleTrackAtTime(track, time, binding) if (step.opacity !== undefined && Number.isFinite(step.opacity)) { opacity = step.opacity } @@ -859,18 +1244,113 @@ function rotatePointAround( } } -function shouldApplyRotationToLayer( +function applyAncestorTransformToSample( + center: { x: number; y: number }, + rotation: number, + scaleX: number, + scaleY: number, ancestor: AnimatedAncestor, step: CssKeyframeStep, + scopedShapes: Element[], + css: string, + parseShape: (element: Element) => Shape | null, +): { center: { x: number; y: number }; rotation: number; scaleX: number; scaleY: number } { + const components = parseTransformComponents(step.transform ?? '') + const transformBox = parseTransformBoxForClass(css, ancestor.className) + const originBounds = + (transformBox === 'fill-box' || transformBox === 'stroke-box') && scopedShapes.length === 1 + ? (() => { + const shape = parseShape(scopedShapes[0]!) + return shape ? getShapeBounds(shape) : getBoundsForElements(scopedShapes, parseShape) + })() + : getBoundsForElements(scopedShapes, parseShape) + const origin = resolveTransformOrigin( + parseTransformOriginForClass(css, ancestor.className), + originBounds, + ) + const spinOnly = + components.rotate !== 0 && components.translateX === 0 && components.translateY === 0 + + if (components.rotate !== 0) { + if (ancestor.className === 'vg-wheel') { + rotation += components.rotate + } else if (spinOnly) { + center = rotatePointAround(center, origin, components.rotate) + rotation += components.rotate + } else { + rotation += components.rotate + } + } + + if (components.translateX !== 0) { + center = { ...center, x: center.x + components.translateX } + } + + if (components.translateY !== 0) { + center = { ...center, y: center.y + components.translateY } + } + + return { + center, + rotation, + scaleX: scaleX * components.scaleX, + scaleY: scaleY * components.scaleY, + } +} + +function ancestorAnimatesProperty( + ancestor: AnimatedAncestor, + property: AnimatableProperty, ): boolean { - if (ancestor.className === 'vg-wheel') { - return true + if (property === 'opacity') { + return ancestor.track.steps.some((step) => step.opacity !== undefined) } - const components = parseTransformComponents(step.transform ?? '') - return ( - components.rotate !== 0 && components.translateX === 0 && components.translateY === 0 - ) + if (property === 'rotation') { + return ancestor.track.steps.some((step) => /rotate/.test(step.transform ?? '')) + } + + if (property === 'x' || property === 'y') { + return ancestor.track.steps.some((step) => /translate|rotate/.test(step.transform ?? '')) + } + + if (property === 'scaleX' || property === 'scaleY') { + return ancestor.track.steps.some((step) => /scale/.test(step.transform ?? '')) + } + + return false +} + +function resolveSegmentEasing( + ancestors: AnimatedAncestor[], + property: AnimatableProperty, +): EasingType { + const sources = ancestors.filter((ancestor) => ancestorAnimatesProperty(ancestor, property)) + if (sources.length === 0) { + return 'linear' + } + + if (sources.length === 1) { + return cssTimingToEasing(sources[0]!.binding.timingFunction) + } + + if (property === 'x' || property === 'y') { + const stepsSource = sources.find( + (ancestor) => ancestor.binding.timingFunction.type === 'steps', + ) + if (stepsSource) { + return 'hold' + } + + return 'linear' + } + + if (property === 'opacity' || property === 'rotation') { + const source = [...sources].reverse()[0] + return source ? cssTimingToEasing(source.binding.timingFunction) : 'linear' + } + + return 'linear' } function buildShapeAnimationKeyframes( @@ -879,61 +1359,53 @@ function buildShapeAnimationKeyframes( css: string, projectDuration: number, parseShape: (element: Element) => Shape | null, + skipGroupClasses?: Set, ): Keyframe[] { - if (ancestors.length === 0) { + const filteredAncestors = skipGroupClasses + ? ancestors.filter((ancestor) => !skipGroupClasses.has(ancestor.className)) + : ancestors + + if (filteredAncestors.length === 0) { return [] } - const classToAnimation = parseClassToAnimationMap(css) - const tracks = ancestors.map((entry) => entry.track) - const sampleTimes = collectSparseSampleTimes(tracks, projectDuration) + const sampleTimes = collectSparseSampleTimes(filteredAncestors, projectDuration) const bounds = getShapeBounds(baseShape) const samples: ShapeAnimationSample[] = [] for (const time of sampleTimes) { let center = getShapeCenter(baseShape) - let matrix = IDENTITY_MATRIX let rotation = baseShape.rotation let scaleX = baseShape.scaleX let scaleY = baseShape.scaleY - for (const { element, track, className } of ancestors) { - const step = sampleTrackAtTime(track, time) - const components = parseTransformComponents(step.transform ?? '') - const scopedShapes = collectShapesUntilNestedAnimation(element, classToAnimation) - const originBounds = getBoundsForElements(scopedShapes, parseShape) - const origin = resolveTransformOrigin( - parseTransformOriginForClass(css, className), - originBounds, + for (const [ancestorIndex, ancestor] of filteredAncestors.entries()) { + const { track, binding } = ancestor + const step = sampleTrackAtTime(track, time, binding) + const scopedShapes = collectScopedShapesForAncestor( + ancestor.element, + filteredAncestors, + ancestorIndex, ) - - if (shouldApplyRotationToLayer({ element, track, className }, step)) { - if (components.rotate !== 0) { - center = rotatePointAround(center, origin, components.rotate) - rotation += components.rotate - } - - if (components.translateX !== 0 || components.translateY !== 0) { - center = { - x: center.x + components.translateX, - y: center.y + components.translateY, - } - } - } else { - matrix = multiplyMatrix(matrix, cssStepToMatrix(step, origin.x, origin.y)) - } - - scaleX *= components.scaleX - scaleY *= components.scaleY + ;({ center, rotation, scaleX, scaleY } = applyAncestorTransformToSample( + center, + rotation, + scaleX, + scaleY, + ancestor, + step, + scopedShapes, + css, + parseShape, + )) } - const transformedCenter = applyMatrixToPoint(matrix, center.x, center.y) const position = baseShape.type === 'ellipse' - ? transformedCenter + ? center : { - x: transformedCenter.x - bounds.width / 2, - y: transformedCenter.y - bounds.height / 2, + x: center.x - bounds.width / 2, + y: center.y - bounds.height / 2, } samples.push({ @@ -962,10 +1434,15 @@ function buildShapeAnimationKeyframes( return } - for (const sample of samples) { + for (let index = 0; index < samples.length; index += 1) { + const sample = samples[index]! const value = sample[property as keyof ShapeAnimationSample] if (typeof value === 'number' || typeof value === 'string') { - addKeyframe(keyframes, sample.time, property, value) + const easing = + index < samples.length - 1 + ? resolveSegmentEasing(filteredAncestors, property) + : 'linear' + addKeyframe(keyframes, sample.time, property, value, easing) } } } @@ -996,6 +1473,138 @@ function buildShapeAnimationKeyframes( return keyframes } +function nodePathsEqual(left: number[] | undefined, right: number[]): boolean { + if (!left || left.length !== right.length) { + return false + } + + return left.every((value, index) => value === right[index]) +} + +function findGroupIdByNodePath( + layerGroups: Record, + nodePath: number[], +): string | null { + for (const [groupId, meta] of Object.entries(layerGroups)) { + if (nodePathsEqual(meta.nodePath, nodePath)) { + return groupId + } + } + + return null +} + +function deltaTranslateKeyframes(keyframes: Keyframe[]): Keyframe[] { + const baseline = new Map() + + for (const keyframe of keyframes) { + if ( + keyframe.time === 0 && + typeof keyframe.value === 'number' && + (keyframe.property === 'x' || keyframe.property === 'y') + ) { + baseline.set(keyframe.property, keyframe.value) + } + } + + return keyframes.map((keyframe) => { + if ( + (keyframe.property === 'x' || keyframe.property === 'y') && + typeof keyframe.value === 'number' + ) { + return { + ...keyframe, + value: keyframe.value - (baseline.get(keyframe.property) ?? 0), + } + } + + return keyframe + }) +} + +function buildGroupTransformKeyframes( + ancestor: AnimatedAncestor, + css: string, + projectDuration: number, + parseShape: (element: Element) => Shape | null, +): Keyframe[] { + const baseShape = createRectShape(0, 0, 100, 40) + const keyframes = buildShapeAnimationKeyframes( + baseShape, + [ancestor], + css, + projectDuration, + parseShape, + ) + + return deltaTranslateKeyframes(keyframes) +} + +function collectAnimatedGroupElements(svg: Element, css: string): Element[] { + const classToAnimation = parseClassToAnimationMap(css) + const groups: Element[] = [] + + const walk = (node: Element) => { + if (node.tagName.toLowerCase() === 'g') { + const classes = getElementClassNames(node) + if (classes.some((className) => classToAnimation.has(className))) { + groups.push(node) + } + } + + for (const child of [...node.children]) { + walk(child) + } + } + + walk(svg) + return groups +} + +export function attachGroupAnimationsFromCss( + svg: Element, + css: string, + layerGroups: Record | undefined, + projectDuration: number, + parseShape: (element: Element) => Shape | null, +): { layerGroups: Record; promotedGroupClasses: Set } { + const nextGroups = { ...(layerGroups ?? {}) } + const promotedGroupClasses = new Set() + const resolvedDuration = projectDuration > 0 ? projectDuration : 3 + + for (const groupElement of collectAnimatedGroupElements(svg, css)) { + const ancestors = getAnimatedAncestorChain(groupElement, css) + const ownAncestor = ancestors[ancestors.length - 1] + if (!ownAncestor) { + continue + } + + const groupId = findGroupIdByNodePath(nextGroups, getNodePath(groupElement)) + if (!groupId) { + continue + } + + const keyframes = buildGroupTransformKeyframes( + ownAncestor, + css, + resolvedDuration, + parseShape, + ) + + if (keyframes.length === 0) { + continue + } + + promotedGroupClasses.add(ownAncestor.className) + nextGroups[groupId] = { + ...nextGroups[groupId]!, + keyframes, + } + } + + return { layerGroups: nextGroups, promotedGroupClasses } +} + function collectAnimatedShapeElements(svg: Element, css: string): Element[] { const shapes: Element[] = [] @@ -1020,6 +1629,7 @@ export type BuildCssLayersOptions = { keyframes: Keyframe[], ) => Layer onProgress?: (current: number, total: number) => void + skipGroupClasses?: Set } function buildLayersFromCssTracksCore( @@ -1053,6 +1663,7 @@ function buildLayersFromCssTracksCore( css, resolvedDuration, options.parseShape, + options.skipGroupClasses, ) if (keyframes.length === 0) { continue @@ -1123,6 +1734,7 @@ export async function buildLayersFromCssTracksAsync( css, resolvedDuration, options.parseShape, + options.skipGroupClasses, ) if (keyframes.length === 0) { continue diff --git a/src/io/html-import.test.ts b/src/io/html-import.test.ts index e8d553b..f6ca7c2 100644 --- a/src/io/html-import.test.ts +++ b/src/io/html-import.test.ts @@ -11,9 +11,23 @@ import { readHtmlImportFromFile, } from '@/io/html-import' import { createDefaultProject, createLayerFromShape, createRectShape } from '@/editor/scene' +import { getAnimatedShape } from '@/editor/animation' +import type { Project } from '@/editor/types' import { createArtboard } from '@/editor/types' import { DEFAULT_EXPORT_OPTIONS } from '@/io/export-options' +function animationContext(project: Project) { + return { layerGroups: project.layerGroups } +} + +function hasMotion(project: Project): boolean { + if (project.layerGroups && Object.values(project.layerGroups).some((group) => (group.keyframes?.length ?? 0) > 0)) { + return true + } + + return project.layers.some((layer) => layer.keyframes.length > 0) +} + describe('html import', () => { it('parses css keyframe tracks and durations', () => { const css = ` @@ -299,7 +313,7 @@ describe('html import', () => { expect(imported).not.toBeNull() expect(imported!.layers.length).toBeGreaterThan(0) expect(imported?.duration).toBe(26) - expect(imported?.layers.some((layer) => layer.keyframes.length > 0)).toBe(true) + expect(hasMotion(imported!)).toBe(true) }) it('imports static scenery and preserves inherited group stroke colors', () => { @@ -321,16 +335,20 @@ describe('html import', () => { ` const imported = importHtmlAnimation(html) + const context = animationContext(imported!) expect(imported).not.toBeNull() expect(imported!.layers.length).toBeGreaterThanOrEqual(3) - const animatedLines = imported!.layers.filter( - (layer) => layer.keyframes.length > 0 && layer.shape.type === 'path', + const sceneryLines = imported!.layers.filter( + (layer) => layer.shape.type === 'path' && layer.shape.stroke === '#717177', ) - expect(animatedLines.length).toBe(2) - expect(animatedLines.every((layer) => layer.shape.fill === 'none')).toBe(true) - expect(animatedLines.every((layer) => layer.shape.stroke === '#717177')).toBe(true) + expect(sceneryLines.length).toBe(2) + expect( + sceneryLines.every( + (layer) => getAnimatedShape(layer, 0, context).x !== getAnimatedShape(layer, 2, context).x, + ), + ).toBe(true) const track = imported!.layers.find( (layer) => layer.keyframes.length === 0 && layer.shape.stroke === '#141416', @@ -370,6 +388,7 @@ describe('html import', () => { ` const imported = importHtmlAnimation(html) + const context = animationContext(imported!) expect(imported).not.toBeNull() @@ -377,37 +396,30 @@ describe('html import', () => { (layer) => layer.shape.type === 'rect' && layer.shape.fill === '#fafafa', ) const wheels = imported!.layers.filter( - (layer) => layer.shape.type === 'ellipse' && layer.keyframes.length > 0, + (layer) => layer.shape.type === 'ellipse', ) - expect(body?.keyframes.some((keyframe) => keyframe.property === 'y')).toBe(true) + expect(getAnimatedShape(body!, 0, context).y).not.toBeCloseTo(getAnimatedShape(body!, 1.5, context).y, 0) expect(wheels).toHaveLength(2) - expect(wheels.every((layer) => layer.keyframes.some((keyframe) => keyframe.property === 'rotation'))).toBe( - true, - ) + expect( + getAnimatedShape(wheels[0]!, 0.5, context).rotation - + getAnimatedShape(wheels[0]!, 0, context).rotation, + ).toBeGreaterThan(90) const maxWheelRotation = Math.max( - ...wheels.flatMap((layer) => - layer.keyframes - .filter((keyframe) => keyframe.property === 'rotation') - .map((keyframe) => keyframe.value as number), - ), + ...wheels.map((layer) => getAnimatedShape(layer, 1, context).rotation), ) expect(maxWheelRotation).toBeGreaterThan(90) const maxBodyY = Math.max( - ...body!.keyframes - .filter((keyframe) => keyframe.property === 'y') - .map((keyframe) => keyframe.value as number), + ...[0, 0.5, 1, 1.5, 2, 2.5, 3].map((time) => getAnimatedShape(body!, time, context).y), ) const minBodyY = Math.min( - ...body!.keyframes - .filter((keyframe) => keyframe.property === 'y') - .map((keyframe) => keyframe.value as number), + ...[0, 0.5, 1, 1.5, 2, 2.5, 3].map((time) => getAnimatedShape(body!, time, context).y), ) expect(maxBodyY - minBodyY).toBeGreaterThan(0.5) expect(body!.keyframes.some((keyframe) => keyframe.property === 'fill')).toBe(false) - expect(body!.keyframes.length).toBeLessThan(80) + expect((body!.keyframes.length || Object.values(imported!.layerGroups ?? {}).flatMap((group) => group.keyframes ?? []).length)).toBeLessThan(80) }) it('falls back to static svg import when css tracks cannot build layers', () => { @@ -469,10 +481,103 @@ describe('html import', () => { expect(connector).toBeDefined() const wheel = imported!.layers.find( - (layer) => layer.shape.type === 'ellipse' && layer.keyframes.some((kf) => kf.property === 'rotation'), + (layer) => layer.shape.type === 'ellipse', ) - expect(wheel?.keyframes.some((kf) => kf.property === 'rotation' && (kf.value as number) >= 180)).toBe( + expect( + getAnimatedShape(wheel!, 0.5, animationContext(imported!)).rotation >= 180, + ).toBe(true) + }) + + it('parses animation-delay from inline styles for staggered streaks', () => { + const html = ` + + + + + + ` + + const imported = importHtmlAnimation(html) + expect(imported).not.toBeNull() + + const streaks = imported!.layers.filter((layer) => layer.keyframes.length > 0) + expect(streaks).toHaveLength(3) + + const opacityAtZero = streaks.map((layer) => getAnimatedShape(layer, 0).opacity) + expect(opacityAtZero[0]).toBeLessThan(0.2) + expect(opacityAtZero[1]).toBeGreaterThan(opacityAtZero[0] + 0.3) + + const opacityTracks = streaks.map((layer) => + layer.keyframes + .filter((kf) => kf.property === 'opacity') + .map((kf) => `${kf.time}:${kf.value}`) + .join(','), + ) + expect(new Set(opacityTracks).size).toBe(3) + expect(streaks[0]!.keyframes.some((kf) => kf.property === 'opacity' && kf.easing === 'easeIn')).toBe( true, ) }) + + it('applies steps and alternate timing for jitter animations', () => { + const html = ` + + + + + + ` + + const imported = importHtmlAnimation(html) + const context = animationContext(imported!) + expect(imported).not.toBeNull() + + const body = imported!.layers.find((layer) => layer.shape.type === 'rect') + const ySamples = [0, 0.085, 0.17, 0.255].map((time) => getAnimatedShape(body!, time, context).y) + const uniqueY = [...new Set(ySamples.map((value) => Math.round(value * 100) / 100))] + + expect(uniqueY.length).toBeLessThanOrEqual(3) + expect(Math.max(...ySamples) - Math.min(...ySamples)).toBeGreaterThan(0.4) + }) + + it('exports ease-in-out easing for bob animations', () => { + const html = ` + + + + + + ` + + const imported = importHtmlAnimation(html) + const bobGroup = Object.values(imported!.layerGroups ?? {}).find((group) => + group.classNames?.includes('vg-train'), + ) + + expect( + bobGroup?.keyframes?.some((kf) => kf.property === 'y' && kf.easing === 'easeInOut'), + ).toBe(true) + }) }) diff --git a/src/io/html-import.ts b/src/io/html-import.ts index 0542719..0d963eb 100644 --- a/src/io/html-import.ts +++ b/src/io/html-import.ts @@ -4,6 +4,7 @@ import { buildLayersFromCssTracksAsync, mergeAnimatedKeyframesIntoStaticLayers, parseCssKeyframeTracks, + attachGroupAnimationsFromCss, } from '@/io/css-keyframes' import { isHtmlFile, @@ -136,6 +137,13 @@ export async function importHtmlAnimationAsync( } const baseProject = svgImportToProject(staticImported) + const { layerGroups, promotedGroupClasses } = attachGroupAnimationsFromCss( + svg, + css, + baseProject.layerGroups, + Math.max(...[...tracks.values()].map((track) => track.duration), 0), + parseShapeElement, + ) options?.onProgress?.({ stage: 'animating' }) await yieldToUi() @@ -153,11 +161,15 @@ export async function importHtmlAnimationAsync( onProgress: (current, total) => { options?.onProgress?.({ stage: 'animating', current, total }) }, + skipGroupClasses: promotedGroupClasses, }, ) if (animatedLayers.length === 0) { - return baseProject + return { + ...baseProject, + layerGroups, + } } options?.onProgress?.({ stage: 'merging' }) @@ -168,6 +180,7 @@ export async function importHtmlAnimationAsync( return { ...baseProject, + layerGroups, duration: resolvedDuration, loopOut: resolvedDuration, layers: mergeAnimatedKeyframesIntoStaticLayers(baseProject.layers, animatedLayers), @@ -196,6 +209,13 @@ export function importHtmlAnimation(raw: string): Project | null { } const baseProject = svgImportToProject(staticImported) + const { layerGroups, promotedGroupClasses } = attachGroupAnimationsFromCss( + svg, + css, + baseProject.layerGroups, + Math.max(...[...tracks.values()].map((track) => track.duration), 0), + parseShapeElement, + ) const { layers: animatedLayers, duration: cssDuration } = buildLayersFromCssTracks( svg, css, @@ -206,11 +226,15 @@ export function importHtmlAnimation(raw: string): Project | null { ...createLayerFromShape(shape, index, artboardId, name), keyframes, }), + skipGroupClasses: promotedGroupClasses, }, ) if (animatedLayers.length === 0) { - return baseProject + return { + ...baseProject, + layerGroups, + } } const resolvedDuration = @@ -218,6 +242,7 @@ export function importHtmlAnimation(raw: string): Project | null { return { ...baseProject, + layerGroups, duration: resolvedDuration, loopOut: resolvedDuration, layers: mergeAnimatedKeyframesIntoStaticLayers(baseProject.layers, animatedLayers), diff --git a/src/io/lottie.ts b/src/io/lottie.ts index f10e496..367338c 100644 --- a/src/io/lottie.ts +++ b/src/io/lottie.ts @@ -5,6 +5,39 @@ import { DEFAULT_ARTBOARD, DEFAULT_CANVAS, PROJECT_VERSION, createArtboard } fro import { getAnimatedShape } from '@/editor/animation' import { createId, createPathShape } from '@/editor/scene' import { openFilePicker } from '@/io/file-picker' +import { waitForPaint } from '@/lib/yield-to-ui' + +export type LottieImportStage = 'parsing' | 'building' + +export type LottieImportProgress = { + stage: LottieImportStage + current?: number + total?: number +} + +export function computeLottieImportProgress(progress: LottieImportProgress): number { + if (progress.stage === 'parsing') { + return 12 + } + + if (progress.total && progress.total > 0 && progress.current !== undefined) { + return 12 + Math.round((progress.current / progress.total) * 83) + } + + return 35 +} + +export function formatLottieImportProgress(progress: LottieImportProgress): string { + if (progress.stage === 'parsing') { + return 'Reading Lottie JSON…' + } + + if (progress.total && progress.total > 0 && progress.current !== undefined) { + return `Building layers (${progress.current}/${progress.total})…` + } + + return 'Building Lottie layers…' +} function pathPointToLottieHandles(point: PathPoint): { inHandle: [number, number] @@ -476,9 +509,14 @@ export function downloadLottie(project: Project, filename = 'animation.json', ar URL.revokeObjectURL(url) } +type LottieImportOptions = { + onProgress?: (progress: LottieImportProgress) => void +} + // Import for rect/ellipse/path shape layers with basic transform keyframes. -export function importLottie(raw: string): Project | null { +export function importLottie(raw: string, options?: LottieImportOptions): Project | null { try { + options?.onProgress?.({ stage: 'parsing' }) const data = JSON.parse(raw) as { w?: number h?: number @@ -508,8 +546,11 @@ export function importLottie(raw: string): Project | null { backgroundColor: DEFAULT_ARTBOARD.backgroundColor, }) const layers: Layer[] = [] + const lottieLayers = data.layers ?? [] + const total = lottieLayers.length - for (const lottieLayer of data.layers ?? []) { + for (const [layerIndex, lottieLayer] of lottieLayers.entries()) { + options?.onProgress?.({ stage: 'building', current: layerIndex + 1, total }) const items = findLottieShapeItems(lottieLayer.shapes) if (!items) { continue @@ -708,9 +749,23 @@ function lottieColorToHex(color: number[]): string { return `#${toHex(r)}${toHex(g)}${toHex(b)}` } -export async function readLottieFromFile(file: File): Promise { +export async function importLottieAsync( + raw: string, + options?: LottieImportOptions, +): Promise { + options?.onProgress?.({ stage: 'parsing' }) + await waitForPaint() + return importLottie(raw, options) +} + +export async function readLottieFromFile( + file: File, + options?: LottieImportOptions, +): Promise { try { - return importLottie(await file.text()) + options?.onProgress?.({ stage: 'parsing' }) + await waitForPaint() + return importLottie(await file.text(), options) } catch { return null } diff --git a/src/io/migrate.ts b/src/io/migrate.ts index b62b35c..76d414c 100644 --- a/src/io/migrate.ts +++ b/src/io/migrate.ts @@ -322,7 +322,7 @@ function migrateV10toV11(project: Project): Project { function migrateV11toV12(project: Project): Project { return { ...project, - version: PROJECT_VERSION, + version: 12 as unknown as Project['version'], layers: project.layers.map((layer) => ({ ...layer, shape: normalizeShapeScale(layer.shape), @@ -343,6 +343,14 @@ function migrateV11toV12(project: Project): Project { } } +function migrateV12toV13(project: Project): Project { + return { + ...project, + version: PROJECT_VERSION, + layerGroups: project.layerGroups, + } +} + export function migrateProject(parsed: LegacyProject): Project { let project: LegacyProject = parsed @@ -387,7 +395,11 @@ export function migrateProject(parsed: LegacyProject): Project { } if (project.version === 11) { - return migrateV11toV12(project as Project) + project = migrateV11toV12(project as Project) as LegacyProject + } + + if (project.version === 12) { + return migrateV12toV13(project as Project) } if (project.version === PROJECT_VERSION) { diff --git a/src/io/svg-import.ts b/src/io/svg-import.ts index 46f8f0e..067fc69 100644 --- a/src/io/svg-import.ts +++ b/src/io/svg-import.ts @@ -616,9 +616,13 @@ function collectLayers( node.getAttribute('id') || node.getAttribute('data-name') || `Group ${Object.keys(context.groups).length + 1}` + const classAttribute = node.getAttribute('class') + const classNames = classAttribute?.split(/\s+/).filter(Boolean) ?? [] context.groups[activeGroupId] = { name, parentGroupId: inheritedGroupId, + nodePath: getNodePath(node), + classNames: classNames.length > 0 ? classNames : undefined, } } diff --git a/src/io/train-404-diagnose.test.ts b/src/io/train-404-diagnose.test.ts index 988166e..b6e4f78 100644 --- a/src/io/train-404-diagnose.test.ts +++ b/src/io/train-404-diagnose.test.ts @@ -32,11 +32,15 @@ const TRAIN_HTML_FIXTURE = `