From 24941d19976bd8860eef9b3de62c22f2f840bf78 Mon Sep 17 00:00:00 2001 From: vgomx <50592489+vgomx@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:39:57 +0200 Subject: [PATCH 1/4] Improve HTML/SVG CSS animation import and fix train artwork fidelity. Add scaleX/scaleY support, normalize line/path placement for keyframe merge, correct ellipse pivot math for wheel spin, and show progress while importing HTML animations. Co-authored-by: Cursor --- src/components/canvas/ShapeView.tsx | 3 +- src/components/shell/Toolbar.tsx | 81 +- .../properties/AnimationPropertyField.tsx | 5 +- .../shell/properties/AnimationTab.tsx | 2 +- .../shell/properties/PropertyTabs.tsx | 35 +- src/components/timeline/Timeline.tsx | 3 +- src/editor/align.test.ts | 2 +- src/editor/animation.test.ts | 9 +- src/editor/animation.ts | 7 +- src/editor/bounds.ts | 41 +- src/editor/layer-animation.test.ts | 5 +- src/editor/layer-animation.ts | 10 +- src/editor/layer-tree.test.ts | 2 +- src/editor/path-nodes.test.ts | 2 +- src/editor/presets.ts | 57 +- src/editor/scale.test.ts | 95 ++ src/editor/scale.ts | 46 + src/editor/scene.ts | 24 +- src/editor/smart-animate.test.ts | 6 +- src/editor/smart-animate.ts | 6 +- src/editor/store.ts | 3 +- src/editor/transforms.test.ts | 25 + src/editor/transforms.ts | 14 +- src/editor/types.ts | 14 +- src/io/css-keyframes.ts | 1099 +++++++++++++++++ src/io/html-import.test.ts | 284 ++++- src/io/html-import.ts | 406 ++---- src/io/lottie.ts | 32 +- src/io/migrate.test.ts | 2 +- src/io/migrate.ts | 33 +- src/io/svg-export.test.ts | 3 +- src/io/svg-import.test.ts | 102 ++ src/io/svg-import.ts | 246 +++- src/io/svg-smil.test.ts | 3 +- src/io/svg-smil.ts | 25 +- src/io/svg-transform.ts | 6 +- src/io/train-404-diagnose.test.ts | 97 ++ src/lib/templates.bounce.test.ts | 48 + src/lib/templates.ts | 47 +- src/lib/templates/train-perf-sample.ts | 12 +- vite.config.ts | 5 +- 41 files changed, 2485 insertions(+), 462 deletions(-) create mode 100644 src/editor/scale.test.ts create mode 100644 src/editor/scale.ts create mode 100644 src/editor/transforms.test.ts create mode 100644 src/io/css-keyframes.ts create mode 100644 src/io/train-404-diagnose.test.ts create mode 100644 src/lib/templates.bounce.test.ts diff --git a/src/components/canvas/ShapeView.tsx b/src/components/canvas/ShapeView.tsx index 3a20591..b1970fd 100644 --- a/src/components/canvas/ShapeView.tsx +++ b/src/components/canvas/ShapeView.tsx @@ -50,7 +50,8 @@ function shapePropsEqual(previous: Shape, next: Shape): boolean { previous.y !== next.y || previous.rotation !== next.rotation || previous.opacity !== next.opacity || - previous.scale !== next.scale || + previous.scaleX !== next.scaleX || + previous.scaleY !== next.scaleY || previous.fill !== next.fill || previous.stroke !== next.stroke || previous.strokeWidth !== next.strokeWidth diff --git a/src/components/shell/Toolbar.tsx b/src/components/shell/Toolbar.tsx index ffdf11f..e7091b1 100644 --- a/src/components/shell/Toolbar.tsx +++ b/src/components/shell/Toolbar.tsx @@ -62,7 +62,7 @@ import { downloadGif } from '@/io/gif-export' import { downloadCssKeyframes } from '@/io/css-export' import { downloadReactComponent } from '@/io/react-export' import { downloadAnimatedHtml } from '@/io/embed-export' -import { readHtmlImportFromFile } from '@/io/html-import' +import { readHtmlImportFromFile, formatHtmlImportProgress } from '@/io/html-import' import { dismissToast, showToast, updateToast } from '@/lib/toast' const LottieDialog = lazy(() => @@ -89,6 +89,8 @@ export function Toolbar() { const [lottiePreviewData, setLottiePreviewData] = useState(null) const [isExporting, setIsExporting] = useState(false) const [isImportingSvg, setIsImportingSvg] = useState(false) + const [isImportingHtml, setIsImportingHtml] = useState(false) + const isImporting = isImportingSvg || isImportingHtml const project = useEditorStore((state) => state.project) const activeArtboardId = useEditorStore((state) => state.activeArtboardId) const playbackState = useEditorStore((state) => state.playbackState) @@ -281,34 +283,61 @@ export function Toolbar() { return } - const result = await readHtmlImportFromFile(file) - if (result.status !== 'ok') { - if (result.status === 'rejected') { - if (result.reason === 'bundler') { - showToast({ - title: 'JavaScript bundle not supported', - description: - 'This HTML file animates with JavaScript at runtime (e.g. a map or interactive bundle). Open Animator imports CSS @keyframes or SVG SMIL only. Export animated SVG or Lottie from the source instead.', - variant: 'error', - }) - } else { - showToast({ - title: 'Not an HTML file', - description: `"${result.fileName}" is not a supported HTML animation. Choose a .html or .htm file.`, - variant: 'error', + setIsImportingHtml(true) + const toastId = showToast({ + title: 'Importing HTML', + description: formatHtmlImportProgress({ stage: 'parsing' }), + variant: 'loading', + }) + + try { + const result = await readHtmlImportFromFile(file, { + onProgress: (progress) => { + updateToast(toastId, { + description: formatHtmlImportProgress(progress), }) + }, + }) + + dismissToast(toastId) + + if (result.status !== 'ok') { + if (result.status === 'rejected') { + if (result.reason === 'bundler') { + showToast({ + title: 'JavaScript bundle not supported', + description: + 'This HTML file animates with JavaScript at runtime (e.g. a map or interactive bundle). Open Animator imports CSS @keyframes or SVG SMIL only. Export animated SVG or Lottie from the source instead.', + variant: 'error', + }) + } else { + showToast({ + title: 'Not an HTML file', + description: `"${result.fileName}" is not a supported HTML animation. Choose a .html or .htm file.`, + variant: 'error', + }) + } } + return } - return - } - setProject(result.value) - requestAnimationFrame(fitCanvasToScreen) - showToast({ - title: 'HTML animation opened', - description: `Loaded ${result.value.layers.length} animated layer${result.value.layers.length === 1 ? '' : 's'} as a new project.`, - variant: 'success', - }) + setProject(result.value) + requestAnimationFrame(fitCanvasToScreen) + showToast({ + title: 'HTML animation opened', + description: `Loaded ${result.value.layers.length} layer${result.value.layers.length === 1 ? '' : 's'} as a new project.`, + variant: 'success', + }) + } catch (error) { + updateToast(toastId, { + title: 'HTML import failed', + description: + error instanceof Error ? error.message : 'Something went wrong while importing.', + variant: 'error', + }) + } finally { + setIsImportingHtml(false) + } } const handleLottieFileSelection = async (file: File | undefined) => { @@ -715,7 +744,7 @@ export function Toolbar() { - diff --git a/src/components/shell/properties/AnimationPropertyField.tsx b/src/components/shell/properties/AnimationPropertyField.tsx index 4e81671..93f3420 100644 --- a/src/components/shell/properties/AnimationPropertyField.tsx +++ b/src/components/shell/properties/AnimationPropertyField.tsx @@ -14,7 +14,8 @@ const PROPERTY_LABELS: Record = { x: 'X', y: 'Y', opacity: 'Opacity', - scale: 'Scale', + scaleX: 'Scale X', + scaleY: 'Scale Y', rotation: 'Rotation', fill: 'Fill', stroke: 'Stroke', @@ -168,7 +169,7 @@ export function formatAnimationValue( return `${value.toFixed(1)}°` } - if (property === 'scale') { + if (property === 'scaleX' || property === 'scaleY') { return value.toFixed(2) } diff --git a/src/components/shell/properties/AnimationTab.tsx b/src/components/shell/properties/AnimationTab.tsx index 6b82374..eeea3c3 100644 --- a/src/components/shell/properties/AnimationTab.tsx +++ b/src/components/shell/properties/AnimationTab.tsx @@ -14,7 +14,7 @@ import { PanelSection } from '@/components/shell/properties/PanelSection' import type { AnimatableProperty, BezierHandle, EasingType, Layer, Shape } from '@/editor/types' import { isColorProperty } from '@/editor/types' -const TRANSFORM_PROPERTIES: AnimatableProperty[] = ['x', 'y', 'rotation', 'scale'] +const TRANSFORM_PROPERTIES: AnimatableProperty[] = ['x', 'y', 'rotation', 'scaleX', 'scaleY'] const APPEARANCE_PROPERTIES: AnimatableProperty[] = ['opacity', 'fill', 'stroke'] type AnimationTabProps = { diff --git a/src/components/shell/properties/PropertyTabs.tsx b/src/components/shell/properties/PropertyTabs.tsx index 5a13181..a167640 100644 --- a/src/components/shell/properties/PropertyTabs.tsx +++ b/src/components/shell/properties/PropertyTabs.tsx @@ -57,7 +57,8 @@ export function DesignTab({ const xField = sharedNumber(shapes, 'x') const yField = sharedNumber(shapes, 'y') const rotationField = sharedNumber(shapes, 'rotation') - const scaleField = sharedNumber(shapes, 'scale') + const scaleXField = sharedNumber(shapes, 'scaleX') + const scaleYField = sharedNumber(shapes, 'scaleY') const opacityField = sharedNumber(shapes, 'opacity', 1) const fillField = sharedString(shapes, 'fill') const strokeField = sharedString(shapes, 'stroke', 'none') @@ -151,6 +152,28 @@ export function DesignTab({ /> ) : null} + + onUpdateShape({ scaleX: Number(value) })} + /> + onUpdateShape({ scaleY: Number(value) })} + /> + onUpdateShape({ rotation: Number(value) })} /> - onUpdateShape({ scale: Number(value) })} - /> = { x: 'X', y: 'Y', opacity: 'Opacity', - scale: 'Scale', + scaleX: 'Scale X', + scaleY: 'Scale Y', rotation: 'Rotation', fill: 'Fill', stroke: 'Stroke', diff --git a/src/editor/align.test.ts b/src/editor/align.test.ts index 3d418be..ec3beb8 100644 --- a/src/editor/align.test.ts +++ b/src/editor/align.test.ts @@ -19,7 +19,7 @@ const rect: Shape = { stroke: '#000000', strokeWidth: 1, opacity: 1, - scale: 1, + scaleX: 1, scaleY: 1, } describe('align', () => { diff --git a/src/editor/animation.test.ts b/src/editor/animation.test.ts index 471fdb8..8aa3053 100644 --- a/src/editor/animation.test.ts +++ b/src/editor/animation.test.ts @@ -37,13 +37,14 @@ describe('samplePropertyAtTime', () => { expect(samplePropertyAtTime(keyframes, 'opacity', 1, 1)).toBe(0.75) }) - it('interpolates rotation keyframes', () => { + it('steps to the next value at the end of a hold segment', () => { const keyframes: Keyframe[] = [ - { id: 'a', time: 0, property: 'rotation', value: 0 }, - { id: 'b', time: 2, property: 'rotation', value: 90 }, + { id: 'a', time: 0, property: 'scaleX', value: 1, easing: 'hold' }, + { id: 'b', time: 0.5, property: 'scaleX', value: 1.2 }, ] - expect(samplePropertyAtTime(keyframes, 'rotation', 1, 0)).toBe(45) + expect(samplePropertyAtTime(keyframes, 'scaleX', 0.25, 1)).toBe(1) + expect(samplePropertyAtTime(keyframes, 'scaleX', 0.5, 1)).toBe(1.2) }) }) diff --git a/src/editor/animation.ts b/src/editor/animation.ts index 66fc733..0f9140d 100644 --- a/src/editor/animation.ts +++ b/src/editor/animation.ts @@ -117,6 +117,10 @@ function sampleSegmentValue( } const progress = (time - current.time) / span + if (current.easing === 'hold') { + return time < next.time ? (current.value as number) : (next.value as number) + } + const eased = applyEasing(progress, current.easing, current.bezier) return interpolate(current, next, eased) } @@ -340,7 +344,8 @@ export function getAnimatedShape(layer: Layer, time: number): Shape { y: sampleNumeric('y', shape.y), rotation: sampleNumeric('rotation', shape.rotation), opacity: sampleNumeric('opacity', shape.opacity), - scale: sampleNumeric('scale', shape.scale), + scaleX: sampleNumeric('scaleX', shape.scaleX), + scaleY: sampleNumeric('scaleY', shape.scaleY), fill: sampleColor('fill', shape.fill), stroke: sampleColor('stroke', shape.stroke), } diff --git a/src/editor/bounds.ts b/src/editor/bounds.ts index bfce60c..74848d1 100644 --- a/src/editor/bounds.ts +++ b/src/editor/bounds.ts @@ -17,14 +17,14 @@ export function getShapeBounds(shape: Shape): ShapeBounds { return { x: shape.x, y: shape.y, - width: shape.width * shape.scale, - height: shape.height * shape.scale, + width: shape.width * shape.scaleX, + height: shape.height * shape.scaleY, } } if (shape.type === 'text') { - const width = estimateTextWidth(shape.text, shape.fontSize) * shape.scale - const height = shape.fontSize * 1.2 * shape.scale + const width = estimateTextWidth(shape.text, shape.fontSize) * (shape.scaleX || 1) + const height = shape.fontSize * 1.2 * shape.scaleY return { x: shape.x, y: shape.y - height, @@ -51,21 +51,24 @@ export function getShapeBounds(shape: Shape): ShapeBounds { const minY = Math.min(...ys) const maxX = Math.max(...xs) const maxY = Math.max(...ys) + const offsetX = shape.localCoords ? 0 : shape.x + const offsetY = shape.localCoords ? 0 : shape.y + return { - x: minX, - y: minY, + x: minX + offsetX, + y: minY + offsetY, width: maxX - minX, height: maxY - minY, } } if (shape.type === 'ellipse') { - const width = shape.rx * 2 * shape.scale - const height = shape.ry * 2 * shape.scale + const width = shape.rx * 2 * shape.scaleX + const height = shape.ry * 2 * shape.scaleY return { - x: shape.x - shape.rx * shape.scale, - y: shape.y - shape.ry * shape.scale, + x: shape.x - shape.rx * shape.scaleX, + y: shape.y - shape.ry * shape.scaleY, width, height, } @@ -86,7 +89,7 @@ export function applyResize( const minSize = 16 if (shape.type === 'text') { - const scale = shape.scale || 1 + const scaleY = shape.scaleY || 1 let left = anchor.x let top = anchor.y let right = anchor.x + anchor.width @@ -106,7 +109,7 @@ export function applyResize( } const nextHeight = bottom - top - const fontSize = Math.max(12, nextHeight / 1.2 / scale) + const fontSize = Math.max(12, nextHeight / 1.2 / scaleY) return { x: left, @@ -134,13 +137,14 @@ export function applyResize( bottom = Math.max(pointerY, top + minSize) } - const scale = shape.scale || 1 + const scaleX = shape.scaleX || 1 + const scaleY = shape.scaleY || 1 return { x: left, y: top, - width: (right - left) / scale, - height: (bottom - top) / scale, + width: (right - left) / scaleX, + height: (bottom - top) / scaleY, } } @@ -163,15 +167,16 @@ export function applyResize( bottom = Math.max(pointerY, top + minSize) } - const scale = shape.scale || 1 + const scaleX = shape.scaleX || 1 + const scaleY = shape.scaleY || 1 const centerX = (left + right) / 2 const centerY = (top + bottom) / 2 return { x: centerX, y: centerY, - rx: (right - left) / 2 / scale, - ry: (bottom - top) / 2 / scale, + rx: (right - left) / 2 / scaleX, + ry: (bottom - top) / 2 / scaleY, } } diff --git a/src/editor/layer-animation.test.ts b/src/editor/layer-animation.test.ts index 35995b2..91ac16c 100644 --- a/src/editor/layer-animation.test.ts +++ b/src/editor/layer-animation.test.ts @@ -30,7 +30,10 @@ describe('layer-animation', () => { { time: 2, a: 1.1, b: 0, c: 0, d: 1.1, e: 10, f: 5 }, ]) - expect(display.some((keyframe) => keyframe.property === 'scale' && keyframe.time === 2)).toBe( + expect(display.some((keyframe) => keyframe.property === 'scaleX' && keyframe.time === 2)).toBe( + true, + ) + expect(display.some((keyframe) => keyframe.property === 'scaleY' && keyframe.time === 2)).toBe( true, ) expect(display.some((keyframe) => keyframe.property === 'x' && keyframe.time === 2)).toBe(true) diff --git a/src/editor/layer-animation.ts b/src/editor/layer-animation.ts index eb1e6fe..80d3d83 100644 --- a/src/editor/layer-animation.ts +++ b/src/editor/layer-animation.ts @@ -50,7 +50,7 @@ export function matrixKeyframesToDisplayKeyframes(matrixKeyframes: MatrixKeyfram const addKeyframe = ( time: number, - property: 'x' | 'y' | 'rotation' | 'scale', + property: 'x' | 'y' | 'rotation' | 'scaleX' | 'scaleY', value: number, ) => { const existing = keyframes.find( @@ -93,9 +93,11 @@ export function matrixKeyframesToDisplayKeyframes(matrixKeyframes: MatrixKeyfram addKeyframe(0, 'rotation', 0) addKeyframe(sample.time, 'rotation', decomposed.rotation) } - if (Math.abs(decomposed.scale - 1) > 0.001) { - addKeyframe(0, 'scale', 1) - addKeyframe(sample.time, 'scale', decomposed.scale) + if (Math.abs(decomposed.scaleX - 1) > 0.001 || Math.abs(decomposed.scaleY - 1) > 0.001) { + addKeyframe(0, 'scaleX', 1) + addKeyframe(0, 'scaleY', 1) + addKeyframe(sample.time, 'scaleX', decomposed.scaleX) + addKeyframe(sample.time, 'scaleY', decomposed.scaleY) } } diff --git a/src/editor/layer-tree.test.ts b/src/editor/layer-tree.test.ts index c1d2572..0c97df5 100644 --- a/src/editor/layer-tree.test.ts +++ b/src/editor/layer-tree.test.ts @@ -25,7 +25,7 @@ function makeLayer(id: string, groupId: string | null = null): Layer { width: 10, height: 10, rotation: 0, - scale: 1, + scaleX: 1, scaleY: 1, opacity: 1, fill: '#000000', stroke: 'none', diff --git a/src/editor/path-nodes.test.ts b/src/editor/path-nodes.test.ts index 48e6164..24a860d 100644 --- a/src/editor/path-nodes.test.ts +++ b/src/editor/path-nodes.test.ts @@ -45,7 +45,7 @@ describe('path-nodes', () => { stroke: 'none', strokeWidth: 0, opacity: 1, - scale: 1, + scaleX: 1, scaleY: 1, closed: false, points: [ { diff --git a/src/editor/presets.ts b/src/editor/presets.ts index da7e7c7..0d5341d 100644 --- a/src/editor/presets.ts +++ b/src/editor/presets.ts @@ -1,7 +1,8 @@ -import { createId } from '@/editor/scene' -import { getShapeBounds } from '@/editor/bounds' +import type { Keyframe, Layer, Project } from '@/editor/types' import { getExportArtboard } from '@/editor/artboard-utils' -import type { Keyframe, Layer, Project, Shape } from '@/editor/types' +import { getShapeBounds } from '@/editor/bounds' +import { createId } from '@/editor/scene' +import type { Keyframe as Kf } from '@/editor/types' export type PresetId = | 'bounce' @@ -46,17 +47,29 @@ export const ANIMATION_PRESETS: AnimationPreset[] = [ function kf( time: number, - property: Keyframe['property'], + property: Kf['property'], value: number | string, - easing: Keyframe['easing'] = 'easeInOut', + easing: Kf['easing'] = 'easeInOut', ): Keyframe { return { id: createId(), time, property, value, easing } } -function sampleShape(layer: Layer): Shape { +function sampleShape(layer: Layer) { return layer.shape } +function uniformScaleKeyframes( + time: number, + scaleX: number, + scaleY: number, + easing: Kf['easing'] = 'easeInOut', +): Keyframe[] { + return [ + kf(time, 'scaleX', scaleX, easing), + kf(time, 'scaleY', scaleY, easing), + ] +} + export function generatePresetKeyframes( layer: Layer, presetId: PresetId, @@ -75,12 +88,16 @@ export function generatePresetKeyframes( switch (presetId) { case 'bounce': { const floor = artboard.height - bounds.height - 40 + const impactTime = midTime + const recoverTime = midTime + duration * 0.08 + const squashX = shape.scaleX * (1 + 0.14 * intensity) + const squashY = shape.scaleY * (1 - 0.28 * intensity) return [ kf(startTime, 'y', shape.y, 'easeOut'), - kf(midTime, 'y', floor, 'bounce'), + kf(impactTime, 'y', floor, 'easeIn'), + ...uniformScaleKeyframes(impactTime, squashX, squashY, 'easeOut'), + ...uniformScaleKeyframes(recoverTime, shape.scaleX, shape.scaleY, 'easeInOut'), kf(endTime, 'y', floor - 30 * intensity, 'bounce'), - kf(endTime, 'scale', shape.scale, 'easeOut'), - kf(midTime, 'scale', shape.scale * (1 + 0.08 * intensity), 'easeIn'), ] } case 'fadeIn': @@ -115,9 +132,14 @@ export function generatePresetKeyframes( ] case 'pulse': return [ - kf(startTime, 'scale', shape.scale, 'easeInOut'), - kf(midTime, 'scale', shape.scale * (1 + 0.15 * intensity), 'easeInOut'), - kf(endTime, 'scale', shape.scale, 'easeInOut'), + ...uniformScaleKeyframes(startTime, shape.scaleX, shape.scaleY, 'easeInOut'), + ...uniformScaleKeyframes( + midTime, + shape.scaleX * (1 + 0.15 * intensity), + shape.scaleY * (1 + 0.15 * intensity), + 'easeInOut', + ), + ...uniformScaleKeyframes(endTime, shape.scaleX, shape.scaleY, 'easeInOut'), ] case 'spin': return [ @@ -126,9 +148,14 @@ export function generatePresetKeyframes( ] case 'pop': return [ - kf(startTime, 'scale', shape.scale * 0.6, 'easeOut'), - kf(startTime + duration * 0.35, 'scale', shape.scale * (1 + 0.2 * intensity), 'easeOut'), - kf(endTime, 'scale', shape.scale, 'spring'), + ...uniformScaleKeyframes(startTime, shape.scaleX * 0.6, shape.scaleY * 0.6, 'easeOut'), + ...uniformScaleKeyframes( + startTime + duration * 0.35, + shape.scaleX * (1 + 0.2 * intensity), + shape.scaleY * (1 + 0.2 * intensity), + 'easeOut', + ), + ...uniformScaleKeyframes(endTime, shape.scaleX, shape.scaleY, 'spring'), ] case 'shake': { const amount = 8 * intensity diff --git a/src/editor/scale.test.ts b/src/editor/scale.test.ts new file mode 100644 index 0000000..6b037e2 --- /dev/null +++ b/src/editor/scale.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' + +import { migrateScaleKeyframes, normalizeShapeScale } from '@/editor/scale' +import { migrateProject } from '@/io/migrate' +import { PROJECT_VERSION, type AnimatableProperty } from '@/editor/types' + +describe('scale migration', () => { + it('normalizes legacy uniform scale onto both axes', () => { + const shape = normalizeShapeScale({ + id: 'shape-1', + type: 'rect', + x: 0, + y: 0, + rotation: 0, + width: 100, + height: 100, + fill: '#000000', + stroke: '#ffffff', + strokeWidth: 1, + opacity: 1, + scale: 1.5, + } as Parameters[0]) + + expect(shape.scaleX).toBe(1.5) + expect(shape.scaleY).toBe(1.5) + expect('scale' in shape).toBe(false) + }) + + it('splits legacy scale keyframes into scaleX and scaleY', () => { + const keyframes = migrateScaleKeyframes([ + { + id: 'kf-1', + time: 1, + property: 'scale' as AnimatableProperty, + value: 0.8, + easing: 'easeOut', + }, + ]) + + expect(keyframes).toHaveLength(2) + expect(keyframes.map((keyframe) => keyframe.property).sort()).toEqual(['scaleX', 'scaleY']) + expect(keyframes.every((keyframe) => keyframe.value === 0.8)).toBe(true) + }) + + it('migrates v11 projects to scaleX and scaleY', () => { + const migrated = migrateProject({ + version: 11, + canvas: { backgroundColor: '#ffffff' }, + artboards: [{ id: 'artboard-1', name: 'Artboard', width: 800, height: 600, backgroundColor: '#ffffff' }], + fps: 30, + duration: 2, + loopIn: 0, + loopOut: 2, + guides: [], + states: [], + markers: [], + layers: [ + { + id: 'layer-1', + artboardId: 'artboard-1', + name: 'Ball', + visible: true, + locked: false, + groupId: null, + delay: 0, + shape: { + id: 'shape-1', + type: 'ellipse', + x: 100, + y: 100, + rotation: 0, + rx: 20, + ry: 20, + fill: '#ff0000', + stroke: '#000000', + strokeWidth: 1, + opacity: 1, + scale: 1, + }, + keyframes: [ + { id: 'kf-1', time: 0.5, property: 'scale', value: 0.7, easing: 'easeOut' }, + ], + }, + ], + } as never) + + expect(migrated.version).toBe(PROJECT_VERSION) + expect(migrated.layers[0]?.shape).toMatchObject({ scaleX: 1, scaleY: 1 }) + expect(migrated.layers[0]?.keyframes.some((keyframe) => keyframe.property === 'scaleX')).toBe(true) + expect(migrated.layers[0]?.keyframes.some((keyframe) => keyframe.property === 'scaleY')).toBe(true) + expect(migrated.layers[0]?.keyframes.some((keyframe) => (keyframe.property as string) === 'scale')).toBe( + false, + ) + }) +}) diff --git a/src/editor/scale.ts b/src/editor/scale.ts new file mode 100644 index 0000000..7cd7c25 --- /dev/null +++ b/src/editor/scale.ts @@ -0,0 +1,46 @@ +import { createId } from '@/editor/scene' +import type { Keyframe, Shape } from '@/editor/types' + +export const DEFAULT_SCALE = 1 + +type LegacyScaleShape = Shape & { scale?: number } + +export function getShapeScaleX(shape: Pick): number { + return shape.scaleX +} + +export function getShapeScaleY(shape: Pick): number { + return shape.scaleY +} + +export function normalizeShapeScale(shape: T): T { + const legacyScale = shape.scale + const scaleX = shape.scaleX ?? legacyScale ?? DEFAULT_SCALE + const scaleY = shape.scaleY ?? legacyScale ?? DEFAULT_SCALE + const { scale: _legacyScale, ...rest } = shape + + return { + ...rest, + scaleX, + scaleY, + } as T +} + +export function migrateScaleKeyframes(keyframes: Keyframe[]): Keyframe[] { + const migrated: Keyframe[] = [] + + for (const keyframe of keyframes) { + if ((keyframe.property as string) === 'scale') { + const value = typeof keyframe.value === 'number' ? keyframe.value : DEFAULT_SCALE + migrated.push( + { ...keyframe, property: 'scaleX', value }, + { ...keyframe, id: createId(), property: 'scaleY', value }, + ) + continue + } + + migrated.push(keyframe) + } + + return migrated +} diff --git a/src/editor/scene.ts b/src/editor/scene.ts index 7f861d2..78c4cb5 100644 --- a/src/editor/scene.ts +++ b/src/editor/scene.ts @@ -61,7 +61,8 @@ function baseShape(type: ShapeType): Shape { stroke: SHAPE_STROKE_PRIMARY, strokeWidth: 2, opacity: 1, - scale: 1, + scaleX: 1, + scaleY: 1, } return shape } @@ -80,7 +81,8 @@ function baseShape(type: ShapeType): Shape { stroke: 'none', strokeWidth: 0, opacity: 1, - scale: 1, + scaleX: 1, + scaleY: 1, } return shape } @@ -98,7 +100,8 @@ function baseShape(type: ShapeType): Shape { stroke: UI_PATH_STROKE, strokeWidth: 2, opacity: 1, - scale: 1, + scaleX: 1, + scaleY: 1, } return shape } @@ -115,7 +118,8 @@ function baseShape(type: ShapeType): Shape { stroke: SHAPE_STROKE_SECONDARY, strokeWidth: 2, opacity: 1, - scale: 1, + scaleX: 1, + scaleY: 1, } return shape } @@ -152,7 +156,8 @@ export function createRectShape(x: number, y: number, width: number, height: num stroke: SHAPE_STROKE_SECONDARY, strokeWidth: 2, opacity: 1, - scale: 1, + scaleX: 1, + scaleY: 1, } } @@ -174,7 +179,8 @@ export function createEllipseShape( stroke: SHAPE_STROKE_PRIMARY, strokeWidth: 2, opacity: 1, - scale: 1, + scaleX: 1, + scaleY: 1, } } @@ -192,7 +198,8 @@ export function createTextShape(x: number, y: number): TextShape { stroke: 'none', strokeWidth: 0, opacity: 1, - scale: 1, + scaleX: 1, + scaleY: 1, } } @@ -209,7 +216,8 @@ export function createPathShape(points: PathPoint[], closed = false): PathShape stroke: UI_PATH_STROKE, strokeWidth: 2, opacity: 1, - scale: 1, + scaleX: 1, + scaleY: 1, } } diff --git a/src/editor/smart-animate.test.ts b/src/editor/smart-animate.test.ts index ca6431f..6c9d53c 100644 --- a/src/editor/smart-animate.test.ts +++ b/src/editor/smart-animate.test.ts @@ -43,7 +43,7 @@ function createTestProject(): Project { stroke: '#155e75', strokeWidth: 2, opacity: 1, - scale: 1, + scaleX: 1, scaleY: 1, }, keyframes: [], }, @@ -105,7 +105,7 @@ describe('smart animate', () => { y: 0, rotation: 0, opacity: 1, - scale: 1, + scaleX: 1, scaleY: 1, fill: '#000000', stroke: '#000000', strokeWidth: 1, @@ -129,7 +129,7 @@ describe('smart animate', () => { y: 0, rotation: 0, opacity: 1, - scale: 1, + scaleX: 1, scaleY: 1, fill: '#000000', stroke: '#000000', strokeWidth: 1, diff --git a/src/editor/smart-animate.ts b/src/editor/smart-animate.ts index 87e5fa9..36882d3 100644 --- a/src/editor/smart-animate.ts +++ b/src/editor/smart-animate.ts @@ -28,7 +28,8 @@ export function snapshotFromShape(layer: Layer, shape: Shape): LayerStateSnapsho y: shape.y, rotation: shape.rotation, opacity: layer.visible ? shape.opacity : 0, - scale: shape.scale, + scaleX: shape.scaleX, + scaleY: shape.scaleY, fill: shape.fill, stroke: shape.stroke, strokeWidth: shape.strokeWidth, @@ -62,7 +63,8 @@ function propertiesForSnapshot(snapshot: LayerStateSnapshot): AnimatableProperty 'y', 'rotation', 'opacity', - 'scale', + 'scaleX', + 'scaleY', 'fill', 'stroke', ] diff --git a/src/editor/store.ts b/src/editor/store.ts index beddc68..71eb486 100644 --- a/src/editor/store.ts +++ b/src/editor/store.ts @@ -83,7 +83,8 @@ const animatableProperties = new Set([ 'x', 'y', 'opacity', - 'scale', + 'scaleX', + 'scaleY', 'rotation', 'fill', 'stroke', diff --git a/src/editor/transforms.test.ts b/src/editor/transforms.test.ts new file mode 100644 index 0000000..626854e --- /dev/null +++ b/src/editor/transforms.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import { buildShapeTransform } from '@/editor/transforms' + +describe('buildShapeTransform', () => { + it('anchors ellipse squash to the bottom contact point', () => { + const transform = buildShapeTransform({ + id: 'shape-1', + type: 'ellipse', + x: 200, + y: 300, + rotation: 0, + rx: 40, + ry: 40, + fill: '#ff0000', + stroke: '#000000', + strokeWidth: 1, + opacity: 1, + scaleX: 1.2, + scaleY: 0.7, + }) + + expect(transform).toBe('translate(200 300) rotate(0) translate(0 40) scale(1.2 0.7) translate(0 -40)') + }) +}) diff --git a/src/editor/transforms.ts b/src/editor/transforms.ts index 8056e90..1738234 100644 --- a/src/editor/transforms.ts +++ b/src/editor/transforms.ts @@ -1,17 +1,25 @@ import type { Shape } from '@/editor/types' export function buildShapeTransform(shape: Shape): string { + const scaleX = shape.scaleX + const scaleY = shape.scaleY + if (shape.type === 'text') { - return `rotate(${shape.rotation} ${shape.x} ${shape.y}) scale(${shape.scale})` + return `rotate(${shape.rotation} ${shape.x} ${shape.y}) scale(${scaleX} ${scaleY})` } if (shape.type === 'rect') { const width = shape.width const height = shape.height - return `translate(${shape.x + width / 2} ${shape.y + height / 2}) rotate(${shape.rotation}) scale(${shape.scale}) translate(${-width / 2} ${-height / 2})` + return `translate(${shape.x + width / 2} ${shape.y + height / 2}) rotate(${shape.rotation}) scale(${scaleX} ${scaleY}) translate(${-width / 2} ${-height / 2})` + } + + // Rotate around the ellipse center; anchor non-uniform squash to the bottom contact point. + if (shape.type === 'ellipse') { + 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(${shape.scale})` + return `translate(${shape.x} ${shape.y}) rotate(${shape.rotation}) scale(${scaleX} ${scaleY})` } diff --git a/src/editor/types.ts b/src/editor/types.ts index 5f626ec..6cb0842 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 = 11 as const +export const PROJECT_VERSION = 12 as const export const DEFAULT_PROJECT_FPS = 30 export type CanvasSettings = { @@ -55,7 +55,8 @@ export type NumericAnimatableProperty = | 'x' | 'y' | 'opacity' - | 'scale' + | 'scaleX' + | 'scaleY' | 'rotation' | 'width' | 'height' @@ -141,7 +142,8 @@ export type BaseShape = { stroke: string strokeWidth: number opacity: number - scale: number + scaleX: number + scaleY: number } export type RectShape = BaseShape & { @@ -215,7 +217,8 @@ export type LayerStateSnapshot = { y: number rotation: number opacity: number - scale: number + scaleX: number + scaleY: number fill: string stroke: string strokeWidth: number @@ -290,7 +293,8 @@ export const NUMERIC_ANIMATABLE_PROPERTIES: NumericAnimatableProperty[] = [ 'x', 'y', 'opacity', - 'scale', + 'scaleX', + 'scaleY', 'rotation', 'width', 'height', diff --git a/src/io/css-keyframes.ts b/src/io/css-keyframes.ts new file mode 100644 index 0000000..d94c9c8 --- /dev/null +++ b/src/io/css-keyframes.ts @@ -0,0 +1,1099 @@ +import { createId } from '@/editor/scene' +import { getShapeBounds } from '@/editor/bounds' +import type { AnimatableProperty, Keyframe, Layer, Shape } from '@/editor/types' +import { createLayerFromShape } from '@/editor/scene' +import { + applyMatrixToPoint, + IDENTITY_MATRIX, + multiplyMatrix, + type AffineMatrix, +} from '@/io/svg-transform' + +function roundCoord(value: number): number { + return Math.round(value * 10) / 10 +} + +export function shapeMatchKey(shape: Shape): string { + const parts = [ + shape.type, + String(roundCoord(shape.x)), + String(roundCoord(shape.y)), + shape.fill, + shape.stroke, + String(roundCoord(shape.strokeWidth)), + String(roundCoord(shape.opacity)), + ] + + if (shape.type === 'rect' || shape.type === 'ellipse') { + parts.push(String(roundCoord(shape.width)), String(roundCoord(shape.height))) + } + + if (shape.type === 'path' && shape.points.length > 0) { + parts.push(String(shape.points.length)) + parts.push(String(roundCoord(shape.points[0]!.x)), String(roundCoord(shape.points[0]!.y))) + const last = shape.points[shape.points.length - 1]! + parts.push(String(roundCoord(last.x)), String(roundCoord(last.y))) + } + + return parts.join('|') +} + +export function mergeAnimatedKeyframesIntoStaticLayers( + staticLayers: Layer[], + animatedLayers: Layer[], +): Layer[] { + const animatedByKey = new Map() + + for (const layer of animatedLayers) { + if (layer.keyframes.length === 0) { + continue + } + + const key = shapeMatchKey(layer.shape) + const bucket = animatedByKey.get(key) ?? [] + bucket.push(layer) + animatedByKey.set(key, bucket) + } + + const usedAnimated = new Set() + + return staticLayers.map((staticLayer) => { + const candidates = animatedByKey.get(shapeMatchKey(staticLayer.shape)) + if (!candidates || candidates.length === 0) { + return staticLayer + } + + const match = candidates.find((candidate) => !usedAnimated.has(candidate)) ?? candidates[0]! + usedAnimated.add(match) + + return { + ...staticLayer, + keyframes: match.keyframes, + } + }) +} + +export type CssKeyframeStep = { + percent: number + transform?: string + opacity?: number + fill?: string + stroke?: string +} + +export type CssAnimationTrack = { + duration: number + steps: CssKeyframeStep[] +} + +const SHAPE_TAGS = new Set([ + 'rect', + 'circle', + 'ellipse', + 'path', + 'text', + 'line', + 'polyline', + 'polygon', +]) + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function extractKeyframesBlocks(css: string): Array<{ name: string; body: string }> { + const blocks: Array<{ name: string; body: string }> = [] + const startPattern = /@keyframes\s+([a-zA-Z0-9_-]+)\s*\{/g + let match: RegExpExecArray | null + + while ((match = startPattern.exec(css)) !== null) { + const name = match[1]! + let depth = 1 + let index = startPattern.lastIndex + let body = '' + + while (index < css.length && depth > 0) { + const char = css[index]! + if (char === '{') { + depth += 1 + } else if (char === '}') { + depth -= 1 + } + + if (depth > 0) { + body += char + } + + index += 1 + } + + blocks.push({ name, body }) + } + + return blocks +} + +function selectorToPercent(selector: string): number | null { + const trimmed = selector.trim() + if (trimmed === 'from') { + return 0 + } + if (trimmed === 'to') { + return 100 + } + + const percentMatch = trimmed.match(/^([0-9.]+)%$/) + if (percentMatch) { + const percent = Number.parseFloat(percentMatch[1]!) + return Number.isFinite(percent) ? percent : null + } + + return null +} + +function parseStepDeclarations(declarations: string): Omit { + const step: Omit = {} + + const transform = declarations.match(/transform:\s*([^;]+)/)?.[1]?.trim() + if (transform) { + step.transform = transform + } + + const opacity = declarations.match(/opacity:\s*([^;]+)/)?.[1]?.trim() + if (opacity) { + step.opacity = Number.parseFloat(opacity) + } + + const fill = declarations.match(/fill:\s*([^;]+)/)?.[1]?.trim() + if (fill) { + step.fill = fill + } + + const stroke = declarations.match(/stroke:\s*([^;]+)/)?.[1]?.trim() + if (stroke) { + step.stroke = stroke + } + + return step +} + +function parseKeyframeSteps(body: string): CssKeyframeStep[] { + const steps: CssKeyframeStep[] = [] + const stepPattern = + /((?:from|to|[0-9.]+%(?:\s*,\s*(?:from|to|[0-9.]+%))*))\s*\{([^}]+)\}/g + let stepMatch: RegExpExecArray | null + + while ((stepMatch = stepPattern.exec(body)) !== null) { + const selectors = stepMatch[1]!.split(',').map((value) => value.trim()) + const declarations = stepMatch[2]! + const parsed = parseStepDeclarations(declarations) + + for (const selector of selectors) { + const percent = selectorToPercent(selector) + if (percent === null || !Number.isFinite(percent)) { + continue + } + + steps.push({ percent, ...parsed }) + } + } + + steps.sort((a, b) => a.percent - b.percent) + return steps +} + +export function parseCssVariables(css: string): Map { + const variables = new Map() + const blockPattern = /[^{}]+\{([^}]*--[\w-]+:[^}]*)\}/g + let match: RegExpExecArray | null + + while ((match = blockPattern.exec(css)) !== null) { + const body = match[1]! + const varPattern = /(--[\w-]+)\s*:\s*([^;]+)/g + let varMatch: RegExpExecArray | null + + while ((varMatch = varPattern.exec(body)) !== null) { + variables.set(varMatch[1]!, varMatch[2]!.trim()) + } + } + + return variables +} + +export function resolveCssVar(value: string, variables: Map): string { + return value.replace(/var\(\s*(--[\w-]+)\s*\)/g, (full, name: string) => { + return variables.get(name) ?? full + }) +} + +function parseDurationSeconds(value: string): number | null { + const trimmed = value.trim() + const secondsMatch = trimmed.match(/^([0-9.]+)s$/) + if (secondsMatch) { + const duration = Number.parseFloat(secondsMatch[1]!) + return Number.isFinite(duration) && duration > 0 ? duration : null + } + + 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 null +} + +export function parseAnimationClassMap( + css: string, + variables: Map, +): Map { + const map = 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 parts = animationDecl.split(/\s+/).filter(Boolean) + const animationName = parts[0] + if (!animationName) { + continue + } + + map.set(animationName, className) + } + + return map +} + +function resolveTrackDuration( + css: string, + animationName: string, + className: string | undefined, + variables: Map, +): number { + const candidates: string[] = [] + + if (className) { + const escapedClass = escapeRegExp(className) + candidates.push(`\\.${escapedClass}\\s*\\{[^}]*animation:\\s*([^;]+)`) + } + + const escapedName = escapeRegExp(animationName) + candidates.push(`animation:\\s*${escapedName}\\s+([^;\\s]+)`) + + for (const pattern of candidates) { + const match = css.match(new RegExp(pattern)) + const animationDecl = match?.[1]?.trim() + if (!animationDecl) { + continue + } + + const parts = animationDecl.split(/\s+/).filter(Boolean) + const durationToken = parts.find((part, index) => index > 0 && part !== 'linear' && part !== 'infinite') + if (!durationToken) { + continue + } + + const resolved = resolveCssVar(durationToken, variables) + const duration = parseDurationSeconds(resolved) + if (duration !== null) { + return duration + } + } + + return 3 +} + +export function parseCssKeyframeTracks(css: string): Map { + const variables = parseCssVariables(css) + const classMap = parseAnimationClassMap(css, variables) + const tracks = new Map() + + for (const block of extractKeyframesBlocks(css)) { + const steps = parseKeyframeSteps(block.body) + if (steps.length === 0) { + continue + } + + const className = classMap.get(block.name) + tracks.set(block.name, { + duration: resolveTrackDuration(css, block.name, className, variables), + steps, + }) + } + + return tracks +} + +export function applyCssTransformToShape(shape: Shape, transformCss: string): Shape { + const rotationMatch = transformCss.match(/rotate\(([-0-9.]+)deg\)/) + const scaleMatch = transformCss.match(/scale\(([-0-9.]+)(?:,\s*([-0-9.]+))?\)/) + const translateXMatch = transformCss.match(/translateX\(([-0-9.]+)px\)/) + const translateYMatch = transformCss.match(/translateY\(([-0-9.]+)px\)/) + const translates = [ + ...transformCss.matchAll(/translate\(([-0-9.]+)px,\s*([-0-9.]+)px\)/g), + ] + + const rotation = rotationMatch ? Number.parseFloat(rotationMatch[1]!) : shape.rotation + const scaleX = scaleMatch ? Number.parseFloat(scaleMatch[1]!) : shape.scaleX + const scaleY = scaleMatch + ? Number.parseFloat(scaleMatch[2] ?? scaleMatch[1]!) + : shape.scaleY + + if (translates.length >= 1) { + if (shape.type === 'rect' && translates.length >= 2) { + const centerX = Number.parseFloat(translates[0]![1]!) + const centerY = Number.parseFloat(translates[0]![2]!) + return { + ...shape, + x: centerX - shape.width / 2, + y: centerY - shape.height / 2, + rotation, + scaleX, + scaleY, + } + } + + return { + ...shape, + x: Number.parseFloat(translates[0]![1]!), + y: Number.parseFloat(translates[0]![2]!), + rotation, + scaleX, + scaleY, + } + } + + let x = shape.x + let y = shape.y + + if (translateXMatch) { + x = shape.x + Number.parseFloat(translateXMatch[1]!) + } + + if (translateYMatch) { + y = shape.y + Number.parseFloat(translateYMatch[1]!) + } + + return { + ...shape, + x, + y, + rotation, + scaleX, + scaleY, + } +} + +function shapeAtStep(baseShape: Shape, step: CssKeyframeStep): Shape { + let next: Shape = { ...baseShape } + + if (step.transform) { + next = applyCssTransformToShape(next, step.transform) + } + + if (step.opacity !== undefined && Number.isFinite(step.opacity)) { + next = { ...next, opacity: step.opacity } + } + + if (step.fill) { + next = { ...next, fill: step.fill } + } + + if (step.stroke) { + next = { ...next, stroke: step.stroke } + } + + return next +} + +function addKeyframe( + keyframes: Keyframe[], + time: number, + property: AnimatableProperty, + value: number | string, +) { + const existing = keyframes.find( + (keyframe) => keyframe.time === time && keyframe.property === property, + ) + if (existing) { + existing.value = value + return + } + + keyframes.push({ + id: createId(), + time, + property, + value, + easing: 'linear', + }) +} + +export function collectShapeElements(element: Element): Element[] { + const tag = element.tagName.toLowerCase() + if (SHAPE_TAGS.has(tag)) { + return [element] + } + + const shapes: Element[] = [] + const walk = (node: Element) => { + const nodeTag = node.tagName.toLowerCase() + if (SHAPE_TAGS.has(nodeTag)) { + shapes.push(node) + return + } + + for (const child of node.children) { + walk(child) + } + } + + walk(element) + return shapes +} + +export function parseClassToAnimationMap(css: string): Map { + const variables = parseCssVariables(css) + const animationToClass = parseAnimationClassMap(css, variables) + const classToAnimation = new Map() + + for (const [animationName, className] of animationToClass) { + classToAnimation.set(className, animationName) + } + + return classToAnimation +} + +export function getApplicableTracksForShape( + shapeElement: Element, + css: string, +): CssAnimationTrack[] { + return getAnimatedAncestorChain(shapeElement, css).map((entry) => entry.track) +} + +type AnimatedAncestor = { + element: Element + track: CssAnimationTrack + className: string +} + +function getElementClassNames(element: Element): string[] { + if (element.classList && element.classList.length > 0) { + return [...element.classList] + } + + const classAttribute = element.getAttribute('class') + if (classAttribute) { + return classAttribute.split(/\s+/).filter(Boolean) + } + + const className = (element as SVGElement).className + if (className && typeof className === 'object' && 'baseVal' in className) { + return className.baseVal.split(/\s+/).filter(Boolean) + } + + if (typeof className === 'string' && className.length > 0) { + return className.split(/\s+/).filter(Boolean) + } + + 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) + const chain: AnimatedAncestor[] = [] + + let current: Element | null = shapeElement + while (current) { + const tag = current.tagName.toLowerCase() + if (tag === 'html' || tag === 'body' || tag === 'div') { + break + } + + const classes = getElementClassNames(current) + for (const className of classes) { + const animationName = classToAnimation.get(className) + if (!animationName) { + continue + } + + const track = tracks.get(animationName) + if (track) { + chain.push({ element: current, track, className }) + } + } + + if (tag === 'svg') { + break + } + + current = current.parentElement + } + + return chain.reverse() +} + +function collectShapesUntilNestedAnimation( + root: Element, + classToAnimation: Map, +): Element[] { + const tag = root.tagName.toLowerCase() + if (SHAPE_TAGS.has(tag)) { + return [root] + } + + const shapes: Element[] = [] + const walk = (node: Element) => { + for (const child of [...node.children]) { + if (child !== root && elementHasAnimationClass(child, classToAnimation)) { + continue + } + + const childTag = child.tagName.toLowerCase() + if (SHAPE_TAGS.has(childTag)) { + shapes.push(child) + } else { + walk(child) + } + } + } + + walk(root) + return shapes +} + +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 getBoundsForElements( + elements: Element[], + parseShape: (element: Element) => Shape | null, +): { x: number; y: number; width: number; height: number } { + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const element of elements) { + const shape = parseShape(element) + if (!shape) { + continue + } + + const bounds = getShapeBounds(shape) + minX = Math.min(minX, bounds.x) + minY = Math.min(minY, bounds.y) + maxX = Math.max(maxX, bounds.x + bounds.width) + maxY = Math.max(maxY, bounds.y + bounds.height) + } + + if (!Number.isFinite(minX)) { + return { x: 0, y: 0, width: 0, height: 0 } + } + + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } +} + +function parseTransformOriginForClass(css: string, className: string): string | undefined { + const escaped = escapeRegExp(className) + const match = css.match(new RegExp(`\\.${escaped}\\s*\\{[^}]*transform-origin:\\s*([^;\\}]+)`)) + return match?.[1]?.trim() +} + +function resolveTransformOrigin( + originValue: string | undefined, + bounds: { x: number; y: number; width: number; height: number }, +): { x: number; y: number } { + const normalized = (originValue ?? 'center').trim().toLowerCase() + + if (normalized.includes('bottom')) { + return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height } + } + + return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 } +} + +type TransformComponents = { + translateX: number + translateY: number + rotate: number + scaleX: number + scaleY: number +} + +function parseTransformComponents(transform: string): TransformComponents { + const translateXMatch = transform.match(/translateX\(([-0-9.]+)px\)/) + const translateYMatch = transform.match(/translateY\(([-0-9.]+)px\)/) + const translateMatch = transform.match(/translate\(([-0-9.]+)px,\s*([-0-9.]+)px\)/) + const rotateMatch = transform.match(/rotate\(([-0-9.]+)deg\)/) + const scaleMatch = transform.match(/scale\(([-0-9.]+)(?:,\s*([-0-9.]+))?\)/) + + return { + translateX: translateXMatch + ? Number.parseFloat(translateXMatch[1]!) + : translateMatch + ? Number.parseFloat(translateMatch[1]!) + : 0, + translateY: translateYMatch + ? Number.parseFloat(translateYMatch[1]!) + : translateMatch + ? Number.parseFloat(translateMatch[2]!) + : 0, + rotate: rotateMatch ? Number.parseFloat(rotateMatch[1]!) : 0, + scaleX: scaleMatch ? Number.parseFloat(scaleMatch[1]!) : 1, + scaleY: scaleMatch + ? Number.parseFloat(scaleMatch[2] ?? scaleMatch[1]!) + : 1, + } +} + +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[] = [] + + if (components.translateX !== 0) { + parts.push(`translateX(${components.translateX}px)`) + } + + if (components.translateY !== 0) { + parts.push(`translateY(${components.translateY}px)`) + } + + if (components.rotate !== 0) { + parts.push(`rotate(${components.rotate}deg)`) + } + + if (components.scaleX !== 1 || components.scaleY !== 1) { + parts.push(`scale(${components.scaleX}, ${components.scaleY})`) + } + + return parts.join(' ') +} + +function interpolateTransformCss(from: string | undefined, to: string | undefined, t: number): string { + const start = parseTransformComponents(from ?? '') + const end = parseTransformComponents(to ?? '') + + return formatTransformComponents({ + translateX: lerp(start.translateX, end.translateX, t), + translateY: lerp(start.translateY, end.translateY, t), + rotate: lerp(start.rotate, end.rotate, t), + scaleX: lerp(start.scaleX, end.scaleX, t), + scaleY: lerp(start.scaleY, end.scaleY, t), + }) +} + +function ensureTrackSteps(steps: CssKeyframeStep[]): CssKeyframeStep[] { + const sorted = [...steps].sort((a, b) => a.percent - b.percent) + if (sorted.length === 0 || sorted[0]!.percent > 0) { + sorted.unshift({ percent: 0 }) + } + + return sorted +} + +function sampleTrackAtTime(track: CssAnimationTrack, time: number): 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 percent = track.duration > 0 ? (loopTime / track.duration) * 100 : 0 + + let previous = steps[0]! + for (let index = 1; index < steps.length; index += 1) { + const next = steps[index]! + if (percent <= next.percent) { + const range = next.percent - previous.percent + const amount = range > 0 ? (percent - previous.percent) / range : 0 + return { + percent, + transform: interpolateTransformCss(previous.transform, next.transform, amount), + opacity: + previous.opacity !== undefined || next.opacity !== undefined + ? lerp(previous.opacity ?? 1, next.opacity ?? 1, amount) + : undefined, + fill: amount < 0.5 ? previous.fill : next.fill, + stroke: amount < 0.5 ? previous.stroke : next.stroke, + } + } + + previous = next + } + + return steps[steps.length - 1]! +} + +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]) + + for (const track of tracks) { + for (const step of ensureTrackSteps(track.steps)) { + cycleTimes.add(roundTime((step.percent / 100) * track.duration)) + } + } + + if (localDuration > 0) { + cycleTimes.add(localDuration) + } + + const sortedCycleTimes = [...cycleTimes].sort((a, b) => a - b) + const times = new Set() + + 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) { + times.add(absolute) + } + } + } + } else { + for (const time of sortedCycleTimes) { + times.add(time) + } + times.add(projectDuration) + } + + return [...times].sort((a, b) => a - b) +} + +type ShapeAnimationSample = { + time: number + x: number + y: number + rotation: number + scaleX: number + scaleY: number + opacity: number +} + +function sampleOpacityFromAncestors( + ancestors: AnimatedAncestor[], + time: number, + fallback: number, +): number { + let opacity = fallback + + for (const { track } of ancestors) { + const step = sampleTrackAtTime(track, time) + if (step.opacity !== undefined && Number.isFinite(step.opacity)) { + opacity = step.opacity + } + } + + return opacity +} + +function rotatePointAround( + point: { x: number; y: number }, + pivot: { x: number; y: number }, + angleDeg: number, +): { x: number; y: number } { + if (angleDeg === 0) { + return point + } + + const angle = (angleDeg * Math.PI) / 180 + const cos = Math.cos(angle) + const sin = Math.sin(angle) + const dx = point.x - pivot.x + const dy = point.y - pivot.y + + return { + x: pivot.x + cos * dx - sin * dy, + y: pivot.y + sin * dx + cos * dy, + } +} + +function shouldApplyRotationToLayer( + ancestor: AnimatedAncestor, + step: CssKeyframeStep, +): boolean { + if (ancestor.className === 'vg-wheel') { + return true + } + + const components = parseTransformComponents(step.transform ?? '') + return ( + components.rotate !== 0 && components.translateX === 0 && components.translateY === 0 + ) +} + +function buildShapeAnimationKeyframes( + baseShape: Shape, + ancestors: AnimatedAncestor[], + css: string, + projectDuration: number, + parseShape: (element: Element) => Shape | null, +): Keyframe[] { + if (ancestors.length === 0) { + return [] + } + + const classToAnimation = parseClassToAnimationMap(css) + const tracks = ancestors.map((entry) => entry.track) + const sampleTimes = collectSparseSampleTimes(tracks, projectDuration) + const center = getShapeCenter(baseShape) + 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, + ) + + 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 + } + + const transformedCenter = applyMatrixToPoint(matrix, center.x, center.y) + const position = + baseShape.type === 'ellipse' + ? transformedCenter + : { + x: transformedCenter.x - bounds.width / 2, + y: transformedCenter.y - bounds.height / 2, + } + + samples.push({ + time, + x: position.x, + y: position.y, + rotation, + scaleX, + scaleY, + opacity: sampleOpacityFromAncestors(ancestors, time, baseShape.opacity), + }) + } + + const keyframes: Keyframe[] = [] + const base = samples[0] + if (!base) { + return keyframes + } + + const maybeAdd = ( + property: AnimatableProperty, + values: Array, + compare: (value: number | string) => boolean, + ) => { + if (!values.some(compare)) { + return + } + + for (const sample of samples) { + const value = sample[property as keyof ShapeAnimationSample] + if (typeof value === 'number' || typeof value === 'string') { + addKeyframe(keyframes, sample.time, property, value) + } + } + } + + maybeAdd('x', samples.map((sample) => sample.x), (value) => Math.abs((value as number) - baseShape.x) > 0.01) + maybeAdd('y', samples.map((sample) => sample.y), (value) => Math.abs((value as number) - baseShape.y) > 0.01) + maybeAdd( + 'rotation', + samples.map((sample) => sample.rotation), + (value) => Math.abs((value as number) - baseShape.rotation) > 0.01, + ) + maybeAdd( + 'scaleX', + samples.map((sample) => sample.scaleX), + (value) => Math.abs((value as number) - baseShape.scaleX) > 0.001, + ) + maybeAdd( + 'scaleY', + samples.map((sample) => sample.scaleY), + (value) => Math.abs((value as number) - baseShape.scaleY) > 0.001, + ) + maybeAdd( + 'opacity', + samples.map((sample) => sample.opacity), + (value) => Math.abs((value as number) - baseShape.opacity) > 0.001, + ) + + return keyframes +} + +function collectAnimatedShapeElements(svg: Element, css: string): Element[] { + const shapes: Element[] = [] + + for (const tag of SHAPE_TAGS) { + for (const element of svg.querySelectorAll(tag)) { + if (getAnimatedAncestorChain(element, css).length > 0) { + shapes.push(element) + } + } + } + + return shapes +} + +export type BuildCssLayersOptions = { + parseShape: (element: Element) => Shape | null + createLayer: ( + shape: Shape, + index: number, + artboardId: string, + name: string, + keyframes: Keyframe[], + ) => Layer + onProgress?: (current: number, total: number) => void +} + +export function buildLayersFromCssTracks( + svg: Element, + css: string, + artboardId: string, + options: BuildCssLayersOptions, +): { layers: Layer[]; duration: number } { + const tracks = parseCssKeyframeTracks(css) + const projectDuration = Math.max(...[...tracks.values()].map((track) => track.duration), 0) + const resolvedDuration = projectDuration > 0 ? projectDuration : 3 + const shapeElements = collectAnimatedShapeElements(svg, css) + const layers: Layer[] = [] + let index = 0 + + for (const shapeElement of shapeElements) { + options.onProgress?.(index + 1, shapeElements.length) + const ancestors = getAnimatedAncestorChain(shapeElement, css) + if (ancestors.length === 0) { + continue + } + + const parsedShape = options.parseShape(shapeElement) + if (!parsedShape) { + continue + } + + const keyframes = buildShapeAnimationKeyframes( + parsedShape, + ancestors, + css, + resolvedDuration, + options.parseShape, + ) + if (keyframes.length === 0) { + continue + } + + const layerName = + shapeElement.getAttribute('id') || + shapeElement.parentElement?.getAttribute('id') || + getElementClassNames(shapeElement.parentElement ?? shapeElement)[0] || + `Layer ${index + 1}` + + layers.push( + options.createLayer( + { ...parsedShape, id: createId() }, + index, + artboardId, + layerName, + keyframes, + ), + ) + index += 1 + } + + return { layers, duration: resolvedDuration } +} diff --git a/src/io/html-import.test.ts b/src/io/html-import.test.ts index 8c8ff9b..e8d553b 100644 --- a/src/io/html-import.test.ts +++ b/src/io/html-import.test.ts @@ -43,7 +43,8 @@ describe('html import', () => { expect(next.x).toBe(100) expect(next.y).toBe(100) expect(next.rotation).toBe(15) - expect(next.scale).toBe(1.1) + expect(next.scaleX).toBe(1.1) + expect(next.scaleY).toBe(1.1) }) it('parses duration from exported animated svg css', () => { @@ -114,9 +115,8 @@ describe('html import', () => { const imported = importHtmlAnimation(html) expect(imported).not.toBeNull() - expect(imported?.layers).toHaveLength(1) + expect(imported?.layers.some((layer) => layer.keyframes.some((keyframe) => keyframe.property === 'x'))).toBe(true) expect(imported?.duration).toBe(2) - expect(imported?.layers[0]?.keyframes.some((keyframe) => keyframe.property === 'x')).toBe(true) }) it('falls back to static svg import when no css animation is present', () => { @@ -196,7 +196,283 @@ describe('html import', () => { const imported = importHtmlAnimation(`${svg}`) expect(imported).not.toBeNull() - expect(imported?.layers).toHaveLength(1) expect(imported?.duration).toBe(1.5) + expect(imported?.layers.some((layer) => layer.keyframes.length > 0)).toBe(true) + }) + + it('parses from/to keyframe selectors', () => { + const css = ` + @keyframes slide { + from { transform: translateX(0px); } + to { transform: translateX(-100px); } + } + .slide { animation: slide 2s linear infinite; } + ` + + const tracks = parseCssKeyframeTracks(css) + const track = tracks.get('slide') + + expect(track?.steps).toHaveLength(2) + expect(track?.steps[0]?.percent).toBe(0) + expect(track?.steps[1]?.percent).toBe(100) + expect(track?.steps[1]?.transform).toContain('translateX') + }) + + it('parses combined percent selectors', () => { + const css = ` + @keyframes bob { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-4px); } + } + .bob { animation: bob 1s ease infinite; } + ` + + const tracks = parseCssKeyframeTracks(css) + const track = tracks.get('bob') + + expect(track?.steps).toHaveLength(3) + expect(track?.steps.filter((step) => step.percent === 0)).toHaveLength(1) + expect(track?.steps.filter((step) => step.percent === 100)).toHaveLength(1) + }) + + it('maps animation names to class names and resolves css variable durations', () => { + const css = ` + :root { --t-scroll: 26s; } + .vg-world { animation: vgworld var(--t-scroll) linear infinite; } + @keyframes vgworld { + from { transform: translateX(0); } + to { transform: translateX(-1200px); } + } + ` + + const tracks = parseCssKeyframeTracks(css) + const track = tracks.get('vgworld') + + expect(track?.duration).toBe(26) + expect(track?.steps).toHaveLength(2) + }) + + it('applies translateX and translateY as relative offsets', () => { + const shape = createRectShape(100, 200, 50, 50) + const moved = applyCssTransformToShape(shape, 'translateX(-30px) translateY(5px)') + + expect(moved.x).toBe(70) + expect(moved.y).toBe(205) + }) + + it('applies combined translateY and rotate transforms', () => { + const shape = createRectShape(0, 0, 100, 50) + const next = applyCssTransformToShape(shape, 'translateY(-3px) rotate(-0.22deg)') + + expect(next.y).toBe(-3) + expect(next.rotation).toBeCloseTo(-0.22) + }) + + it('imports train-like html with mismatched animation and class names', () => { + const html = ` + + + + + + + + + ` + + const imported = importHtmlAnimation(html) + + 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) + }) + + it('imports static scenery and preserves inherited group stroke colors', () => { + const html = ` + + + + + + + + ` + + const imported = importHtmlAnimation(html) + + expect(imported).not.toBeNull() + expect(imported!.layers.length).toBeGreaterThanOrEqual(3) + + const animatedLines = imported!.layers.filter( + (layer) => layer.keyframes.length > 0 && layer.shape.type === 'path', + ) + 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) + + const track = imported!.layers.find( + (layer) => layer.keyframes.length === 0 && layer.shape.stroke === '#141416', + ) + expect(track).toBeDefined() + }) + + it('composes nested train bob, jitter, and wheel spin animations', () => { + const html = ` + + + + + + + + + + + + + + ` + + const imported = importHtmlAnimation(html) + + expect(imported).not.toBeNull() + + const body = imported!.layers.find( + (layer) => layer.shape.type === 'rect' && layer.shape.fill === '#fafafa', + ) + const wheels = imported!.layers.filter( + (layer) => layer.shape.type === 'ellipse' && layer.keyframes.length > 0, + ) + + expect(body?.keyframes.some((keyframe) => keyframe.property === 'y')).toBe(true) + expect(wheels).toHaveLength(2) + expect(wheels.every((layer) => layer.keyframes.some((keyframe) => keyframe.property === 'rotation'))).toBe( + true, + ) + + const maxWheelRotation = Math.max( + ...wheels.flatMap((layer) => + layer.keyframes + .filter((keyframe) => keyframe.property === 'rotation') + .map((keyframe) => keyframe.value as number), + ), + ) + expect(maxWheelRotation).toBeGreaterThan(90) + + const maxBodyY = Math.max( + ...body!.keyframes + .filter((keyframe) => keyframe.property === 'y') + .map((keyframe) => keyframe.value as number), + ) + const minBodyY = Math.min( + ...body!.keyframes + .filter((keyframe) => keyframe.property === 'y') + .map((keyframe) => keyframe.value as number), + ) + expect(maxBodyY - minBodyY).toBeGreaterThan(0.5) + expect(body!.keyframes.some((keyframe) => keyframe.property === 'fill')).toBe(false) + expect(body!.keyframes.length).toBeLessThan(80) + }) + + it('falls back to static svg import when css tracks cannot build layers', () => { + const html = ` + + + + ` + + const imported = importHtmlAnimation(html) + + expect(imported).not.toBeNull() + expect(imported?.layers).toHaveLength(1) + expect(imported?.layers[0]?.shape.type).toBe('rect') + }) + + it('keeps static layer count when merging css animations', () => { + const html = ` + + + + + + + + + + + + + + + ` + + const imported = importHtmlAnimation(html) + + expect(imported).not.toBeNull() + const staticOnly = importHtmlAnimation( + html.replace(/ + + + ` + const document = new DOMParser().parseFromString(markup, 'image/svg+xml') + const svg = document.documentElement + + const { layers } = buildLayersFromCssTracks(svg, svg.querySelector('style')!.textContent!, 'artboard', { + parseShape: parseShapeElement, + createLayer: (shape, index, artboardId, name, keyframes) => ({ + ...createLayerFromShape(shape, index, artboardId, name), + keyframes, + }), + }) + + expect(layers).toHaveLength(1) + expect(layers[0]?.keyframes.some((keyframe) => keyframe.property === 'x')).toBe(true) + }) + + it('imports svg with css keyframe animations', () => { + const svg = ` + + + + + ` + + const project = importSvgAsProject(svg) + + expect(project).not.toBeNull() + expect(project?.layers.length).toBeGreaterThan(0) + expect(project?.duration).toBeGreaterThanOrEqual(2) + expect(project?.layers.some((layer) => layer.keyframes.some((keyframe) => keyframe.property === 'x'))).toBe( + true, + ) + }) + + it('imports svg with inherited group styles via parseShapeElement', () => { + const svg = ` + + + + + + + ` + + const staticProject = importSvgAsProject(svg.replace(/ + + + + + + + + + + + + + + + + + +` describe('train-404-bg.html import', () => { it('keeps pantograph connectors visible and spins wheels around their centers', () => { - const html = readFileSync(TRAIN_HTML_PATH, 'utf8') + const html = TRAIN_HTML_FIXTURE const imported = importHtmlAnimation(html) const staticOnly = importHtmlAnimation(html.replace(/ + + + + + + ` + + 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) + expect(imported).not.toBeNull() + + const body = imported!.layers.find((layer) => layer.shape.type === 'rect') + const yKeyframes = body!.keyframes.filter((kf) => kf.property === 'y').sort((a, b) => a.time - b.time) + const yValues = yKeyframes.map((kf) => kf.value as number) + const uniqueY = [...new Set(yValues.map((value) => Math.round(value * 100) / 100))] + + expect(uniqueY.length).toBeLessThanOrEqual(3) + expect(Math.max(...yValues) - Math.min(...yValues)).toBeGreaterThan(0.4) + }) + + it('exports ease-in-out easing for bob animations', () => { + const html = ` + + + + + + ` + + const imported = importHtmlAnimation(html) + const body = imported!.layers.find((layer) => layer.shape.type === 'rect') + const yKeyframes = body!.keyframes.filter((kf) => kf.property === 'y') + + expect(yKeyframes.some((kf) => kf.easing === 'easeInOut')).toBe(true) + }) }) diff --git a/src/io/train-404-diagnose.test.ts b/src/io/train-404-diagnose.test.ts index 988166e..18913ea 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 = `