Skip to content

Commit ae7aeb4

Browse files
shai-almogclaude
andauthored
Add Lottie/Bodymovin support to the build-time SVG transcoder (#5066)
* Add Lottie/Bodymovin support to the build-time SVG transcoder Lottie / Bodymovin JSON (.json, .lottie) files flow through the same build-time pipeline as SVG: each source file is lowered into the existing SVGDocument model, the SVG JavaCodeGenerator emits the GeneratedSVGImage subclass, and the same SVGRegistry registers it under its source filename. No new Image base class, no new registry, no per-port wiring -- the SVG path's JavaSE reflective load and the iOS / Android Stub weaving already cover the new format. New maven/lottie-transcoder/ module parses Bodymovin JSON with no external deps (built-in JSON parser): shape layers with grouped rectangle / ellipse / bezier path primitives, solid fills and strokes, layer transforms (anchor / position / scale / rotation / opacity), and animated rotation / position / scale collapsed to a 2-keyframe loop. Solid-color layers (ty:1) lower to a filled rect. TranscodeSVGMojo now scans src/main/{svg,lottie,css}/ for .svg / .json / .lottie and dispatches each file to the right transcoder. Resources.registerGeneratedImage strips any extension (not just .svg) so getImage("home") resolves regardless of source format. Hellocodenameone picks up sample lottie_spinner.json and lottie_pulse.json (proper Bodymovin exports -- open and play in lottieeditor.app) plus LottieAnimatedScreenshotTest registered next to the SVG screenshot tests, with the same HTML5 skip list. docs/developer-guide/SVG-Transcoder.asciidoc gains a Lottie section with the feature matrix and troubleshooting. The initializr skill's references/css.md picks up the same material so generated projects' Claude Code skill stays accurate. Lottie test coverage (17 cases): - animated translate / scale / rotation - bezier path "sh" shape with closed cycle - multi-shape fill propagation within a single gr group - stroke color + width extraction from "st" - multi-layer paint order (Lottie back-to-front -> SVG front-to-back) - solid-color layer (ty:1) lowered to a filled rect - unsupported layer type producing an empty group - opacity below 100% baked into the layer style as a 0..1 fraction - RGBA 0..1 -> ARGB int normalization with edge values - ellipse codegen path through gr/tr wrapping - missing top-level dimensions fall back to 100x100 - static-only layers don't emit any animation entries - realistic Bodymovin export (3D vectors, ix indices, gr/tr wrapping) - JSON parser primitives - transcodesToCompilableJava round-trip Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add LottieAnimatedScreenshotTest goldens for iOS, iOS-Metal, and Android Captured from the first CI run that included the new test. Android and iOS-Metal both show the expected animation: red rotating spinner and blue pulsing ellipse across the six progress samples (0%, 20%, 40%, 60%, 80%, 100%) the AbstractAnimationScreenshotTest grid renders. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f8aae70 commit ae7aeb4

21 files changed

Lines changed: 1911 additions & 27 deletions

File tree

CodenameOne/src/com/codename1/ui/util/Resources.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -930,8 +930,15 @@ public static void registerGeneratedImage(String id, Image image) {
930930
}
931931
synchronized (generatedImages) {
932932
generatedImages.put(id, image);
933-
if (id.endsWith(".svg")) {
934-
generatedImages.put(id.substring(0, id.length() - 4), image);
933+
// Register the bare stem too so getImage("home") works whether
934+
// the source asset was home.svg, home.json (Lottie), or
935+
// home.lottie. Keeps the call site format-agnostic.
936+
int dot = id.lastIndexOf('.');
937+
if (dot > 0) {
938+
String stem = id.substring(0, dot);
939+
if (!generatedImages.containsKey(stem)) {
940+
generatedImages.put(stem, image);
941+
}
935942
}
936943
}
937944
}

docs/developer-guide/SVG-Transcoder.asciidoc

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
= Build-Time SVG Images
1+
= Build-Time Vector & Animation Images
22
:source-highlighter: highlight.js
33

4-
The build-time SVG transcoder lets you author UI icons and
5-
illustrations as SVG and have them rendered as native Codename One
6-
`Image` instances on every platform (iOS, Android, JavaSE simulator,
7-
JavaScript) without shipping a runtime SVG parser.
4+
The build-time vector transcoder lets you author UI icons and
5+
illustrations as SVG or Lottie / Bodymovin JSON and have them rendered
6+
as native Codename One `Image` instances on every platform (iOS,
7+
Android, JavaSE simulator, JavaScript) without shipping a runtime SVG
8+
or Lottie parser.
9+
10+
Both formats share the same pipeline: each source file is lowered into
11+
the SVG transcoder's model, the same `JavaCodeGenerator` emits a
12+
`com.codename1.ui.GeneratedSVGImage` subclass, and the same
13+
`SVGRegistry` makes the result available via
14+
`Resources.getImage("name.<ext>")`. The rest of this guide therefore
15+
covers SVG in detail; Lottie gets its own section at the end that only
16+
calls out the parts that differ.
817

918
== Motivation
1019

@@ -202,15 +211,90 @@ with `Timeline`, you must register the image with a `Form`'s animation
202211
manager (or set it as a `Component.setIcon` with `isAnimation()` true)
203212
for the repaint loop to tick the SMIL clock.
204213

214+
== Lottie animations
215+
216+
Lottie / Bodymovin JSON files are picked up by the same `transcode-svg`
217+
goal. Drop them next to your CSS (or under `src/main/lottie/`) and the
218+
Lottie parser lowers each one into the SVG model -- so everything in
219+
the previous sections (sizing keys, registry, theme `url(...)` lookup,
220+
`Resources.getImage(...)`, the per-port wiring) applies unchanged:
221+
222+
[source]
223+
----
224+
src/main/css/
225+
theme.css
226+
spinner.json <-- Lottie/Bodymovin export
227+
----
228+
229+
[source,css]
230+
----
231+
SpinnerStyle {
232+
background: url(spinner.json);
233+
cn1-svg-width: 12mm;
234+
cn1-svg-height: 12mm;
235+
bg-type: image_scaled_fit;
236+
}
237+
----
238+
239+
[source,java]
240+
----
241+
Image spin = Resources.getGlobalResources().getImage("spinner.json");
242+
// or by stem, like a multi-image:
243+
Image spin2 = Resources.getGlobalResources().getImage("spinner");
244+
----
245+
246+
`.lottie` (dotLottie ZIP) files are accepted alongside `.json` for
247+
forward compatibility, but the archive container isn't yet extracted
248+
-- export your animation as a plain Bodymovin JSON for now.
249+
250+
=== Lottie feature coverage
251+
252+
The parser targets the subset of Bodymovin a "spinner" or "pulse"
253+
animation typically uses. Anything outside the subset is dropped
254+
without warning so a file with mixed coverage still produces a renderable
255+
class:
256+
257+
|===
258+
| Feature | Status
259+
260+
| Shape layers (`ty:4`) with grouped `rc` / `el` / `sh` primitives | Full
261+
| Solid color layers (`ty:1`) | Rendered as a filled rect
262+
| Shape fills (`fl`) and strokes (`st`) -- solid colors | Full
263+
| Layer transform (anchor, position, scale, rotation, opacity) -- static | Full
264+
| Animated rotation / position / scale -- 2 keyframes | Full (loops indefinitely over the comp duration)
265+
| Animated colors / opacity | Collapsed to the first keyframe
266+
| Bezier easing on keyframes | Linear interpolation (easing curves ignored)
267+
| Multi-keyframe properties (3+) | Collapsed to first vs. last (matches the SVG codegen's `from`/`to` model)
268+
| Trim path (`tm`), repeater (`rp`), merge (`mm`), rounded corners (`rd`) | Ignored
269+
| Gradient fills (`gf`) / gradient strokes (`gs`) | Ignored
270+
| Text layers, image layers, precomp, mattes, expressions | Ignored
271+
|===
272+
273+
For animations that need higher fidelity than the subset above, export
274+
the relevant frame as an SVG and use the SVG transcoder path directly
275+
-- the runtime classes are identical.
276+
205277
== Troubleshooting
206278

207279
`Resources.getImage("name.svg")` returns null or a 1×1 transparent PNG::
208-
The transcoder didn't run, or it ran with zero SVGs. Confirm the SVG
209-
lives under `src/main/css/` (or `src/main/svg/`) and that the
210-
`transcode-svg` goal is bound in the project POM. For arbitrary
211-
Resources bundles loaded outside the global slot, call
280+
The transcoder didn't run, or it ran with zero source files. Confirm
281+
the asset lives under `src/main/css/` (or `src/main/svg/` /
282+
`src/main/lottie/` for the dedicated dirs) and that the
283+
`transcode-svg` goal is bound in the project POM. The same goal
284+
handles `.svg`, `.json`, and `.lottie` -- one goal, both formats.
285+
For arbitrary Resources bundles loaded outside the global slot, call
212286
`com.codename1.ui.util.Resources.registerGeneratedImage(name, image)`
213-
yourself with a fresh `new com.codename1.generated.svg.YourSvg(widthMm, heightMm)`.
287+
yourself with a fresh
288+
`new com.codename1.generated.svg.YourAsset(widthMm, heightMm)`.
289+
290+
Lottie animation looks frozen or starts halfway through::
291+
The Lottie parser collapses each animated property's keyframe array
292+
to its first and last value, then loops indefinitely over the
293+
composition's duration. Animations with three or more keyframes on
294+
the same property therefore play as a straight first-to-last
295+
interpolation. Re-export the comp split into two-keyframe segments
296+
or use an SVG/SMIL export for that animation if you need exact
297+
keyframe playback.
214298

215299
SVG looks the wrong size::
216300
Switch the rule to `cn1-svg-width` / `cn1-svg-height` in millimeters.

maven/codenameone-maven-plugin/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@
8080
<artifactId>codenameone-svg-transcoder</artifactId>
8181
<version>${project.version}</version>
8282
</dependency>
83+
<dependency>
84+
<groupId>${project.groupId}</groupId>
85+
<artifactId>codenameone-lottie-transcoder</artifactId>
86+
<version>${project.version}</version>
87+
</dependency>
8388
<dependency>
8489
<groupId>org.apache.maven</groupId>
8590
<artifactId>maven-plugin-api</artifactId>

maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.codename1.maven;
22

3+
import com.codename1.lottie.transcoder.LottieTranscoder;
34
import com.codename1.svg.transcoder.SVGTranscoder;
45
import com.codename1.svg.transcoder.SVGTranscoder.GeneratedClass;
56

@@ -33,17 +34,25 @@
3334
import java.util.regex.Pattern;
3435

3536
/**
36-
* Scans an application module for SVG files, transcodes each into a
37+
* Scans an application module for vector animation files (SVG and
38+
* Lottie / Bodymovin JSON), transcodes each into a
3739
* {@code com.codename1.ui.GeneratedSVGImage} subclass under
3840
* {@code target/generated-sources/svg}, and emits a registry class that
3941
* installs each transcoded image into a Resources instance (and the global
4042
* fallback) under its source filename.
4143
*
4244
* <h3>Source layout</h3>
43-
* SVG files are picked up from both {@code src/main/css/} and
44-
* {@code src/main/svg/}. Drop your SVGs next to the CSS file that references
45-
* them; the mojo finds them either way. Theme CSS keeps the natural
46-
* {@code background: url(spinner.svg);} reference.
45+
* Files are picked up from format-specific directories plus the shared
46+
* {@code src/main/css/} directory so designers can drop assets next to the
47+
* theme CSS that references them:
48+
* <ul>
49+
* <li>{@code src/main/svg/} -- {@code *.svg}</li>
50+
* <li>{@code src/main/lottie/} -- {@code *.json}, {@code *.lottie}</li>
51+
* <li>{@code src/main/css/} -- either of the above</li>
52+
* </ul>
53+
* Theme CSS keeps the natural {@code background: url(spinner.svg);}
54+
* reference for SVG, and the same {@code url(...)} syntax resolves
55+
* {@code .json} / {@code .lottie} at runtime.
4756
*
4857
* <h3>CSS hints</h3>
4958
* For each {@code url(*.svg)} occurrence the mojo also looks at the rule's
@@ -67,7 +76,31 @@
6776
requiresDependencyCollection = ResolutionScope.NONE)
6877
public class TranscodeSVGMojo extends AbstractCN1Mojo {
6978

70-
private static final String[] DEFAULT_SVG_DIRS = { "src/main/svg", "src/main/css" };
79+
private static final String[] DEFAULT_SVG_DIRS = {
80+
"src/main/svg",
81+
"src/main/lottie",
82+
"src/main/css"
83+
};
84+
85+
/** Recognized vector source extensions plus the format key the file
86+
* parses as. Order matters only when multiple extensions could match
87+
* the same bytes -- they cannot here. */
88+
private enum VectorFormat {
89+
SVG(".svg"),
90+
LOTTIE_JSON(".json"),
91+
LOTTIE_PACK(".lottie");
92+
93+
final String ext;
94+
VectorFormat(String ext) { this.ext = ext; }
95+
96+
static VectorFormat fromFilename(String name) {
97+
String lower = name.toLowerCase();
98+
for (VectorFormat f : values()) {
99+
if (lower.endsWith(f.ext)) return f;
100+
}
101+
return null;
102+
}
103+
}
71104

72105
private static final String DEFAULT_PACKAGE = "com.codename1.generated.svg";
73106

@@ -136,15 +169,22 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException
136169
packageDir.mkdirs();
137170
for (File svg : svgs) {
138171
String resourceName = svg.getName();
172+
VectorFormat fmt = VectorFormat.fromFilename(resourceName);
173+
if (fmt == null) {
174+
// locateSvgs() already filtered to recognized extensions;
175+
// defensive guard for future format additions.
176+
continue;
177+
}
139178
String className = uniqueClassName(SVGTranscoder.classNameFor(resourceName), usedClassNames);
140179
usedClassNames.add(className);
141180
File outFile = new File(packageDir, className + ".java");
142181
if (outFile.exists() && outFile.lastModified() >= svg.lastModified()) {
143-
getLog().debug("SVG transcoder up-to-date for " + svg.getName());
182+
getLog().debug("Vector transcoder up-to-date for " + svg.getName());
144183
} else {
145-
getLog().info("Transcoding SVG " + svg.getName() + " -> " + className + ".java");
184+
getLog().info("Transcoding " + fmt.name() + " " + svg.getName()
185+
+ " -> " + className + ".java");
146186
try {
147-
SVGTranscoder.transcode(svg, svgPackage, className, outFile);
187+
transcodeByFormat(fmt, svg, svgPackage, className, outFile);
148188
} catch (IOException ex) {
149189
throw new MojoExecutionException("Failed to transcode " + svg, ex);
150190
}
@@ -200,9 +240,12 @@ private static final class CssHint {
200240
float heightMm;
201241
}
202242

203-
/** Scans theme CSS files for {@code url(*.svg)} together with the
204-
* enclosing rule's {@code cn1-source-dpi} / {@code cn1-svg-width} /
205-
* {@code cn1-svg-height}. Returns a map of svgFilename -> CssHint. */
243+
/** Scans theme CSS files for {@code url(*.svg|*.json|*.lottie)}
244+
* together with the enclosing rule's {@code cn1-source-dpi} /
245+
* {@code cn1-svg-width} / {@code cn1-svg-height}. Returns a map of
246+
* filename -> CssHint. The CSS hint vocabulary is the SVG transcoder's
247+
* -- the same {@code cn1-svg-width} property sizes Lottie outputs too
248+
* because both share the {@code GeneratedSVGImage} base. */
206249
private Map<String, CssHint> scanCssHints() throws MojoExecutionException {
207250
Map<String, CssHint> result = new HashMap<String, CssHint>();
208251
File cssDir = new File(project.getBasedir(), "src/main/css");
@@ -213,7 +256,7 @@ private Map<String, CssHint> scanCssHints() throws MojoExecutionException {
213256
collectCss(cssDir, cssFiles);
214257
Pattern blockPattern = Pattern.compile("\\{([^}]*)\\}", Pattern.DOTALL);
215258
Pattern svgUrlPattern = Pattern.compile(
216-
"url\\(\\s*['\"]?\\s*([^'\")\\s]+?\\.svg)\\s*['\"]?\\s*\\)",
259+
"url\\(\\s*['\"]?\\s*([^'\")\\s]+?\\.(?:svg|json|lottie))\\s*['\"]?\\s*\\)",
217260
Pattern.CASE_INSENSITIVE);
218261
Pattern dpiPattern = Pattern.compile(
219262
"cn1-source-dpi\\s*:\\s*([\\w-]+)\\s*;?",
@@ -365,12 +408,28 @@ private static void collect(File dir, List<File> out) {
365408
for (File f : entries) {
366409
if (f.isDirectory()) {
367410
collect(f, out);
368-
} else if (f.getName().toLowerCase().endsWith(".svg")) {
411+
} else if (VectorFormat.fromFilename(f.getName()) != null) {
369412
out.add(f);
370413
}
371414
}
372415
}
373416

417+
private static void transcodeByFormat(VectorFormat fmt, File src,
418+
String pkg, String className, File outFile) throws IOException {
419+
switch (fmt) {
420+
case SVG:
421+
SVGTranscoder.transcode(src, pkg, className, outFile);
422+
break;
423+
case LOTTIE_JSON:
424+
case LOTTIE_PACK:
425+
// .lottie (dotLottie ZIP) needs an extra archive-extract step
426+
// we don't perform here yet -- the parser treats the bytes as
427+
// a JSON document. Drop a plain Lottie JSON for now.
428+
LottieTranscoder.transcode(src, pkg, className, outFile);
429+
break;
430+
}
431+
}
432+
374433
private static void collectCss(File dir, List<File> out) {
375434
File[] entries = dir.listFiles();
376435
if (entries == null) {

maven/lottie-transcoder/pom.xml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
6+
<parent>
7+
<groupId>com.codenameone</groupId>
8+
<artifactId>codenameone</artifactId>
9+
<version>8.0-SNAPSHOT</version>
10+
</parent>
11+
<modelVersion>4.0.0</modelVersion>
12+
13+
<artifactId>codenameone-lottie-transcoder</artifactId>
14+
<version>8.0-SNAPSHOT</version>
15+
<packaging>jar</packaging>
16+
<name>codenameone-lottie-transcoder</name>
17+
<description>
18+
Build-time tool that parses Lottie/Bodymovin JSON animations and
19+
emits Codename One Image subclasses that render them via the
20+
Graphics API. Mirrors the SVG transcoder pipeline. Supports a
21+
useful subset of shape layers (rectangles, ellipses, paths) with
22+
fill/stroke and keyframe-animated transforms.
23+
</description>
24+
25+
<properties>
26+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
27+
<maven.compiler.source>1.8</maven.compiler.source>
28+
<maven.compiler.target>1.8</maven.compiler.target>
29+
</properties>
30+
31+
<build>
32+
<plugins>
33+
<plugin>
34+
<artifactId>maven-compiler-plugin</artifactId>
35+
<configuration>
36+
<source>1.8</source>
37+
<target>1.8</target>
38+
</configuration>
39+
</plugin>
40+
</plugins>
41+
</build>
42+
43+
<dependencies>
44+
<dependency>
45+
<groupId>com.codenameone</groupId>
46+
<artifactId>codenameone-svg-transcoder</artifactId>
47+
<version>${project.version}</version>
48+
</dependency>
49+
<dependency>
50+
<groupId>junit</groupId>
51+
<artifactId>junit</artifactId>
52+
<scope>test</scope>
53+
</dependency>
54+
</dependencies>
55+
</project>

0 commit comments

Comments
 (0)