diff --git a/CodenameOne/src/com/codename1/ui/util/Resources.java b/CodenameOne/src/com/codename1/ui/util/Resources.java index 19dba4f877..f3715ad2a4 100644 --- a/CodenameOne/src/com/codename1/ui/util/Resources.java +++ b/CodenameOne/src/com/codename1/ui/util/Resources.java @@ -930,8 +930,15 @@ public static void registerGeneratedImage(String id, Image image) { } synchronized (generatedImages) { generatedImages.put(id, image); - if (id.endsWith(".svg")) { - generatedImages.put(id.substring(0, id.length() - 4), image); + // Register the bare stem too so getImage("home") works whether + // the source asset was home.svg, home.json (Lottie), or + // home.lottie. Keeps the call site format-agnostic. + int dot = id.lastIndexOf('.'); + if (dot > 0) { + String stem = id.substring(0, dot); + if (!generatedImages.containsKey(stem)) { + generatedImages.put(stem, image); + } } } } diff --git a/docs/developer-guide/SVG-Transcoder.asciidoc b/docs/developer-guide/SVG-Transcoder.asciidoc index d919128ead..5d028d6247 100644 --- a/docs/developer-guide/SVG-Transcoder.asciidoc +++ b/docs/developer-guide/SVG-Transcoder.asciidoc @@ -1,10 +1,19 @@ -= Build-Time SVG Images += Build-Time Vector & Animation Images :source-highlighter: highlight.js -The build-time SVG transcoder lets you author UI icons and -illustrations as SVG and have them rendered as native Codename One -`Image` instances on every platform (iOS, Android, JavaSE simulator, -JavaScript) without shipping a runtime SVG parser. +The build-time vector transcoder lets you author UI icons and +illustrations as SVG or Lottie / Bodymovin JSON and have them rendered +as native Codename One `Image` instances on every platform (iOS, +Android, JavaSE simulator, JavaScript) without shipping a runtime SVG +or Lottie parser. + +Both formats share the same pipeline: each source file is lowered into +the SVG transcoder's model, the same `JavaCodeGenerator` emits a +`com.codename1.ui.GeneratedSVGImage` subclass, and the same +`SVGRegistry` makes the result available via +`Resources.getImage("name.")`. The rest of this guide therefore +covers SVG in detail; Lottie gets its own section at the end that only +calls out the parts that differ. == Motivation @@ -202,15 +211,90 @@ with `Timeline`, you must register the image with a `Form`'s animation manager (or set it as a `Component.setIcon` with `isAnimation()` true) for the repaint loop to tick the SMIL clock. +== Lottie animations + +Lottie / Bodymovin JSON files are picked up by the same `transcode-svg` +goal. Drop them next to your CSS (or under `src/main/lottie/`) and the +Lottie parser lowers each one into the SVG model -- so everything in +the previous sections (sizing keys, registry, theme `url(...)` lookup, +`Resources.getImage(...)`, the per-port wiring) applies unchanged: + +[source] +---- +src/main/css/ + theme.css + spinner.json <-- Lottie/Bodymovin export +---- + +[source,css] +---- +SpinnerStyle { + background: url(spinner.json); + cn1-svg-width: 12mm; + cn1-svg-height: 12mm; + bg-type: image_scaled_fit; +} +---- + +[source,java] +---- +Image spin = Resources.getGlobalResources().getImage("spinner.json"); +// or by stem, like a multi-image: +Image spin2 = Resources.getGlobalResources().getImage("spinner"); +---- + +`.lottie` (dotLottie ZIP) files are accepted alongside `.json` for +forward compatibility, but the archive container isn't yet extracted +-- export your animation as a plain Bodymovin JSON for now. + +=== Lottie feature coverage + +The parser targets the subset of Bodymovin a "spinner" or "pulse" +animation typically uses. Anything outside the subset is dropped +without warning so a file with mixed coverage still produces a renderable +class: + +|=== +| Feature | Status + +| Shape layers (`ty:4`) with grouped `rc` / `el` / `sh` primitives | Full +| Solid color layers (`ty:1`) | Rendered as a filled rect +| Shape fills (`fl`) and strokes (`st`) -- solid colors | Full +| Layer transform (anchor, position, scale, rotation, opacity) -- static | Full +| Animated rotation / position / scale -- 2 keyframes | Full (loops indefinitely over the comp duration) +| Animated colors / opacity | Collapsed to the first keyframe +| Bezier easing on keyframes | Linear interpolation (easing curves ignored) +| Multi-keyframe properties (3+) | Collapsed to first vs. last (matches the SVG codegen's `from`/`to` model) +| Trim path (`tm`), repeater (`rp`), merge (`mm`), rounded corners (`rd`) | Ignored +| Gradient fills (`gf`) / gradient strokes (`gs`) | Ignored +| Text layers, image layers, precomp, mattes, expressions | Ignored +|=== + +For animations that need higher fidelity than the subset above, export +the relevant frame as an SVG and use the SVG transcoder path directly +-- the runtime classes are identical. + == Troubleshooting `Resources.getImage("name.svg")` returns null or a 1×1 transparent PNG:: - The transcoder didn't run, or it ran with zero SVGs. Confirm the SVG - lives under `src/main/css/` (or `src/main/svg/`) and that the - `transcode-svg` goal is bound in the project POM. For arbitrary - Resources bundles loaded outside the global slot, call + The transcoder didn't run, or it ran with zero source files. Confirm + the asset lives under `src/main/css/` (or `src/main/svg/` / + `src/main/lottie/` for the dedicated dirs) and that the + `transcode-svg` goal is bound in the project POM. The same goal + handles `.svg`, `.json`, and `.lottie` -- one goal, both formats. + For arbitrary Resources bundles loaded outside the global slot, call `com.codename1.ui.util.Resources.registerGeneratedImage(name, image)` - yourself with a fresh `new com.codename1.generated.svg.YourSvg(widthMm, heightMm)`. + yourself with a fresh + `new com.codename1.generated.svg.YourAsset(widthMm, heightMm)`. + +Lottie animation looks frozen or starts halfway through:: + The Lottie parser collapses each animated property's keyframe array + to its first and last value, then loops indefinitely over the + composition's duration. Animations with three or more keyframes on + the same property therefore play as a straight first-to-last + interpolation. Re-export the comp split into two-keyframe segments + or use an SVG/SMIL export for that animation if you need exact + keyframe playback. SVG looks the wrong size:: Switch the rule to `cn1-svg-width` / `cn1-svg-height` in millimeters. diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index 5ecf26f9d5..76c8f3e319 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -77,6 +77,11 @@ codenameone-svg-transcoder ${project.version} + + ${project.groupId} + codenameone-lottie-transcoder + ${project.version} + org.apache.maven maven-plugin-api diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java index f3249812db..038677a84d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java @@ -1,5 +1,6 @@ package com.codename1.maven; +import com.codename1.lottie.transcoder.LottieTranscoder; import com.codename1.svg.transcoder.SVGTranscoder; import com.codename1.svg.transcoder.SVGTranscoder.GeneratedClass; @@ -33,17 +34,25 @@ import java.util.regex.Pattern; /** - * Scans an application module for SVG files, transcodes each into a + * Scans an application module for vector animation files (SVG and + * Lottie / Bodymovin JSON), transcodes each into a * {@code com.codename1.ui.GeneratedSVGImage} subclass under * {@code target/generated-sources/svg}, and emits a registry class that * installs each transcoded image into a Resources instance (and the global * fallback) under its source filename. * *

Source layout

- * SVG files are picked up from both {@code src/main/css/} and - * {@code src/main/svg/}. Drop your SVGs next to the CSS file that references - * them; the mojo finds them either way. Theme CSS keeps the natural - * {@code background: url(spinner.svg);} reference. + * Files are picked up from format-specific directories plus the shared + * {@code src/main/css/} directory so designers can drop assets next to the + * theme CSS that references them: + * + * Theme CSS keeps the natural {@code background: url(spinner.svg);} + * reference for SVG, and the same {@code url(...)} syntax resolves + * {@code .json} / {@code .lottie} at runtime. * *

CSS hints

* For each {@code url(*.svg)} occurrence the mojo also looks at the rule's @@ -67,7 +76,31 @@ requiresDependencyCollection = ResolutionScope.NONE) public class TranscodeSVGMojo extends AbstractCN1Mojo { - private static final String[] DEFAULT_SVG_DIRS = { "src/main/svg", "src/main/css" }; + private static final String[] DEFAULT_SVG_DIRS = { + "src/main/svg", + "src/main/lottie", + "src/main/css" + }; + + /** Recognized vector source extensions plus the format key the file + * parses as. Order matters only when multiple extensions could match + * the same bytes -- they cannot here. */ + private enum VectorFormat { + SVG(".svg"), + LOTTIE_JSON(".json"), + LOTTIE_PACK(".lottie"); + + final String ext; + VectorFormat(String ext) { this.ext = ext; } + + static VectorFormat fromFilename(String name) { + String lower = name.toLowerCase(); + for (VectorFormat f : values()) { + if (lower.endsWith(f.ext)) return f; + } + return null; + } + } private static final String DEFAULT_PACKAGE = "com.codename1.generated.svg"; @@ -136,15 +169,22 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException packageDir.mkdirs(); for (File svg : svgs) { String resourceName = svg.getName(); + VectorFormat fmt = VectorFormat.fromFilename(resourceName); + if (fmt == null) { + // locateSvgs() already filtered to recognized extensions; + // defensive guard for future format additions. + continue; + } String className = uniqueClassName(SVGTranscoder.classNameFor(resourceName), usedClassNames); usedClassNames.add(className); File outFile = new File(packageDir, className + ".java"); if (outFile.exists() && outFile.lastModified() >= svg.lastModified()) { - getLog().debug("SVG transcoder up-to-date for " + svg.getName()); + getLog().debug("Vector transcoder up-to-date for " + svg.getName()); } else { - getLog().info("Transcoding SVG " + svg.getName() + " -> " + className + ".java"); + getLog().info("Transcoding " + fmt.name() + " " + svg.getName() + + " -> " + className + ".java"); try { - SVGTranscoder.transcode(svg, svgPackage, className, outFile); + transcodeByFormat(fmt, svg, svgPackage, className, outFile); } catch (IOException ex) { throw new MojoExecutionException("Failed to transcode " + svg, ex); } @@ -200,9 +240,12 @@ private static final class CssHint { float heightMm; } - /** Scans theme CSS files for {@code url(*.svg)} together with the - * enclosing rule's {@code cn1-source-dpi} / {@code cn1-svg-width} / - * {@code cn1-svg-height}. Returns a map of svgFilename -> CssHint. */ + /** Scans theme CSS files for {@code url(*.svg|*.json|*.lottie)} + * together with the enclosing rule's {@code cn1-source-dpi} / + * {@code cn1-svg-width} / {@code cn1-svg-height}. Returns a map of + * filename -> CssHint. The CSS hint vocabulary is the SVG transcoder's + * -- the same {@code cn1-svg-width} property sizes Lottie outputs too + * because both share the {@code GeneratedSVGImage} base. */ private Map scanCssHints() throws MojoExecutionException { Map result = new HashMap(); File cssDir = new File(project.getBasedir(), "src/main/css"); @@ -213,7 +256,7 @@ private Map scanCssHints() throws MojoExecutionException { collectCss(cssDir, cssFiles); Pattern blockPattern = Pattern.compile("\\{([^}]*)\\}", Pattern.DOTALL); Pattern svgUrlPattern = Pattern.compile( - "url\\(\\s*['\"]?\\s*([^'\")\\s]+?\\.svg)\\s*['\"]?\\s*\\)", + "url\\(\\s*['\"]?\\s*([^'\")\\s]+?\\.(?:svg|json|lottie))\\s*['\"]?\\s*\\)", Pattern.CASE_INSENSITIVE); Pattern dpiPattern = Pattern.compile( "cn1-source-dpi\\s*:\\s*([\\w-]+)\\s*;?", @@ -365,12 +408,28 @@ private static void collect(File dir, List out) { for (File f : entries) { if (f.isDirectory()) { collect(f, out); - } else if (f.getName().toLowerCase().endsWith(".svg")) { + } else if (VectorFormat.fromFilename(f.getName()) != null) { out.add(f); } } } + private static void transcodeByFormat(VectorFormat fmt, File src, + String pkg, String className, File outFile) throws IOException { + switch (fmt) { + case SVG: + SVGTranscoder.transcode(src, pkg, className, outFile); + break; + case LOTTIE_JSON: + case LOTTIE_PACK: + // .lottie (dotLottie ZIP) needs an extra archive-extract step + // we don't perform here yet -- the parser treats the bytes as + // a JSON document. Drop a plain Lottie JSON for now. + LottieTranscoder.transcode(src, pkg, className, outFile); + break; + } + } + private static void collectCss(File dir, List out) { File[] entries = dir.listFiles(); if (entries == null) { diff --git a/maven/lottie-transcoder/pom.xml b/maven/lottie-transcoder/pom.xml new file mode 100644 index 0000000000..51b4e0bbb2 --- /dev/null +++ b/maven/lottie-transcoder/pom.xml @@ -0,0 +1,55 @@ + + + + + com.codenameone + codenameone + 8.0-SNAPSHOT + + 4.0.0 + + codenameone-lottie-transcoder + 8.0-SNAPSHOT + jar + codenameone-lottie-transcoder + + Build-time tool that parses Lottie/Bodymovin JSON animations and + emits Codename One Image subclasses that render them via the + Graphics API. Mirrors the SVG transcoder pipeline. Supports a + useful subset of shape layers (rectangles, ellipses, paths) with + fill/stroke and keyframe-animated transforms. + + + + UTF-8 + 1.8 + 1.8 + + + + + + maven-compiler-plugin + + 1.8 + 1.8 + + + + + + + + com.codenameone + codenameone-svg-transcoder + ${project.version} + + + junit + junit + test + + + diff --git a/maven/lottie-transcoder/src/main/java/com/codename1/lottie/transcoder/LottieTranscoder.java b/maven/lottie-transcoder/src/main/java/com/codename1/lottie/transcoder/LottieTranscoder.java new file mode 100644 index 0000000000..bc56e702d5 --- /dev/null +++ b/maven/lottie-transcoder/src/main/java/com/codename1/lottie/transcoder/LottieTranscoder.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + */ +package com.codename1.lottie.transcoder; + +import com.codename1.lottie.transcoder.parser.LottieParser; +import com.codename1.svg.transcoder.codegen.JavaCodeGenerator; +import com.codename1.svg.transcoder.model.SVGDocument; + +import java.io.BufferedInputStream; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; + +/** + * Top-level entry point: parse a Lottie {@code .json} (or {@code .lottie} + * archive container) and emit a Codename One + * {@code GeneratedSVGImage} subclass. The output is byte-identical in + * structure to what {@code SVGTranscoder.transcode} emits -- Lottie is + * just lowered into the SVG model first, so the same {@code SVGRegistry} + * and per-port wiring picks it up at runtime. + */ +public final class LottieTranscoder { + + private LottieTranscoder() { } + + public static void transcode(InputStream in, String packageName, String className, Writer out) throws IOException { + SVGDocument doc = LottieParser.parse(in); + new JavaCodeGenerator(doc, packageName, className).generate(out); + } + + public static void transcode(File file, String packageName, String className, File outFile) throws IOException { + if (outFile.getParentFile() != null) { + outFile.getParentFile().mkdirs(); + } + InputStream in = new BufferedInputStream(new FileInputStream(file)); + try { + Writer w = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(outFile), "UTF-8")); + try { + transcode(in, packageName, className, w); + } finally { + w.close(); + } + } finally { + in.close(); + } + } +} diff --git a/maven/lottie-transcoder/src/main/java/com/codename1/lottie/transcoder/package-info.java b/maven/lottie-transcoder/src/main/java/com/codename1/lottie/transcoder/package-info.java new file mode 100644 index 0000000000..ba8597563b --- /dev/null +++ b/maven/lottie-transcoder/src/main/java/com/codename1/lottie/transcoder/package-info.java @@ -0,0 +1,9 @@ +/** + * Build-time Lottie/Bodymovin JSON transcoder. Parses a Lottie animation + * and produces a Codename One {@code GeneratedSVGImage} subclass by + * lowering the Lottie document into the SVG model the existing transcoder + * already knows how to render. The runtime registry, per-port wiring and + * theme {@code url(...)} lookup are the SVG transcoder's -- nothing new + * is wired at startup. + */ +package com.codename1.lottie.transcoder; diff --git a/maven/lottie-transcoder/src/main/java/com/codename1/lottie/transcoder/parser/JsonParser.java b/maven/lottie-transcoder/src/main/java/com/codename1/lottie/transcoder/parser/JsonParser.java new file mode 100644 index 0000000000..0251084c5f --- /dev/null +++ b/maven/lottie-transcoder/src/main/java/com/codename1/lottie/transcoder/parser/JsonParser.java @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2025, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + */ +package com.codename1.lottie.transcoder.parser; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Minimal recursive-descent JSON parser. Returns nested {@link Map} / + * {@link List} / {@link Double} / {@link String} / {@link Boolean} / null. + * No external dependency -- keeps the transcoder consistent with the SVG + * transcoder's "no Batik / no Jackson" stance. + */ +public final class JsonParser { + + private final String text; + private int pos; + + private JsonParser(String text) { + this.text = text; + this.pos = 0; + } + + public static Object parse(InputStream in) throws IOException { + StringBuilder sb = new StringBuilder(); + Reader r = new InputStreamReader(in, "UTF-8"); + char[] buf = new char[4096]; + int n; + while ((n = r.read(buf)) > 0) { + sb.append(buf, 0, n); + } + return parse(sb.toString()); + } + + public static Object parse(String text) { + JsonParser p = new JsonParser(text); + p.skipWhite(); + Object v = p.readValue(); + p.skipWhite(); + return v; + } + + private Object readValue() { + skipWhite(); + if (pos >= text.length()) { + throw err("unexpected end of input"); + } + char c = text.charAt(pos); + if (c == '{') return readObject(); + if (c == '[') return readArray(); + if (c == '"') return readString(); + if (c == 't' || c == 'f') return readBool(); + if (c == 'n') return readNull(); + if (c == '-' || (c >= '0' && c <= '9')) return readNumber(); + throw err("unexpected character '" + c + "'"); + } + + private Map readObject() { + expect('{'); + Map out = new LinkedHashMap(); + skipWhite(); + if (peek() == '}') { pos++; return out; } + while (true) { + skipWhite(); + String key = readString(); + skipWhite(); + expect(':'); + Object v = readValue(); + out.put(key, v); + skipWhite(); + char c = peek(); + if (c == ',') { pos++; continue; } + if (c == '}') { pos++; return out; } + throw err("expected ',' or '}'"); + } + } + + private List readArray() { + expect('['); + List out = new ArrayList(); + skipWhite(); + if (peek() == ']') { pos++; return out; } + while (true) { + Object v = readValue(); + out.add(v); + skipWhite(); + char c = peek(); + if (c == ',') { pos++; continue; } + if (c == ']') { pos++; return out; } + throw err("expected ',' or ']'"); + } + } + + private String readString() { + expect('"'); + StringBuilder sb = new StringBuilder(); + while (pos < text.length()) { + char c = text.charAt(pos++); + if (c == '"') return sb.toString(); + if (c == '\\') { + if (pos >= text.length()) throw err("bad escape"); + char e = text.charAt(pos++); + switch (e) { + case '"': sb.append('"'); break; + case '\\': sb.append('\\'); break; + case '/': sb.append('/'); break; + case 'b': sb.append('\b'); break; + case 'f': sb.append('\f'); break; + case 'n': sb.append('\n'); break; + case 'r': sb.append('\r'); break; + case 't': sb.append('\t'); break; + case 'u': + if (pos + 4 > text.length()) throw err("bad \\u escape"); + sb.append((char) Integer.parseInt(text.substring(pos, pos + 4), 16)); + pos += 4; + break; + default: throw err("bad escape \\" + e); + } + } else { + sb.append(c); + } + } + throw err("unterminated string"); + } + + private Double readNumber() { + int start = pos; + if (peek() == '-') pos++; + while (pos < text.length()) { + char c = text.charAt(pos); + if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') { + pos++; + } else break; + } + return Double.valueOf(Double.parseDouble(text.substring(start, pos))); + } + + private Boolean readBool() { + if (text.startsWith("true", pos)) { pos += 4; return Boolean.TRUE; } + if (text.startsWith("false", pos)) { pos += 5; return Boolean.FALSE; } + throw err("expected boolean"); + } + + private Object readNull() { + if (text.startsWith("null", pos)) { pos += 4; return null; } + throw err("expected null"); + } + + private void skipWhite() { + while (pos < text.length()) { + char c = text.charAt(pos); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + pos++; + } else break; + } + } + + private void expect(char c) { + if (pos >= text.length() || text.charAt(pos) != c) { + throw err("expected '" + c + "'"); + } + pos++; + } + + private char peek() { + if (pos >= text.length()) throw err("unexpected end of input"); + return text.charAt(pos); + } + + private RuntimeException err(String msg) { + int line = 1; + int col = 1; + for (int i = 0; i < pos && i < text.length(); i++) { + if (text.charAt(i) == '\n') { line++; col = 1; } else col++; + } + return new IllegalArgumentException(msg + " at line " + line + " col " + col); + } + + /** Static helpers so callers do not have to cast on every property read. */ + public static Map asMap(Object o) { + if (o == null) return null; + return (Map) o; + } + + public static List asList(Object o) { + if (o == null) return null; + return (List) o; + } + + public static double asDouble(Object o, double dflt) { + if (o instanceof Number) return ((Number) o).doubleValue(); + return dflt; + } + + public static int asInt(Object o, int dflt) { + if (o instanceof Number) return ((Number) o).intValue(); + return dflt; + } + + public static String asString(Object o, String dflt) { + if (o instanceof String) return (String) o; + return dflt; + } + + public static boolean asBoolean(Object o, boolean dflt) { + if (o instanceof Boolean) return ((Boolean) o).booleanValue(); + if (o instanceof Number) return ((Number) o).intValue() != 0; + return dflt; + } +} diff --git a/maven/lottie-transcoder/src/main/java/com/codename1/lottie/transcoder/parser/LottieParser.java b/maven/lottie-transcoder/src/main/java/com/codename1/lottie/transcoder/parser/LottieParser.java new file mode 100644 index 0000000000..966ee984ac --- /dev/null +++ b/maven/lottie-transcoder/src/main/java/com/codename1/lottie/transcoder/parser/LottieParser.java @@ -0,0 +1,655 @@ +/* + * Copyright (c) 2025, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + */ +package com.codename1.lottie.transcoder.parser; + +import com.codename1.svg.transcoder.model.SVGAnimation; +import com.codename1.svg.transcoder.model.SVGDocument; +import com.codename1.svg.transcoder.model.SVGEllipse; +import com.codename1.svg.transcoder.model.SVGGroup; +import com.codename1.svg.transcoder.model.SVGNode; +import com.codename1.svg.transcoder.model.SVGPath; +import com.codename1.svg.transcoder.model.SVGRect; +import com.codename1.svg.transcoder.model.SVGShape; +import com.codename1.svg.transcoder.parser.PathCommand; +import com.codename1.svg.transcoder.parser.SVGPaint; +import com.codename1.svg.transcoder.parser.SVGStyle; +import com.codename1.svg.transcoder.parser.SVGTransform; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static com.codename1.lottie.transcoder.parser.JsonParser.asBoolean; +import static com.codename1.lottie.transcoder.parser.JsonParser.asDouble; +import static com.codename1.lottie.transcoder.parser.JsonParser.asInt; +import static com.codename1.lottie.transcoder.parser.JsonParser.asList; +import static com.codename1.lottie.transcoder.parser.JsonParser.asMap; +import static com.codename1.lottie.transcoder.parser.JsonParser.asString; + +/** + * Reads a Lottie/Bodymovin JSON animation and produces an + * {@link SVGDocument} the existing SVG transcoder's + * {@code JavaCodeGenerator} can render. The pipeline is identical to the + * SVG one from that point on -- no new Image base class, no new registry, + * no per-port wiring. + * + *

Supported subset:

+ *
    + *
  • Shape layers (ty=4) with grouped {@code rc} / {@code el} / {@code sh} + * primitives, plus {@code fl} fills and {@code st} strokes.
  • + *
  • Solid color layers (ty=1) -- emitted as a filled rect.
  • + *
  • Layer transforms (anchor, position, scale, rotation, opacity). + * Constant values are baked into a static {@link SVGTransform}; + * animated values are emitted as one or more {@link SVGAnimation} + * entries spanning the document's duration.
  • + *
  • Multi-keyframe properties are collapsed to first-vs-last linear + * interpolation -- the SVG codegen only honors that today, and the + * resulting motion still matches simple "spinner" / "pulse" cases.
  • + *
+ * + *

Anything outside the subset (text layers, image layers, mattes, + * expressions, repeaters, bezier easing) is silently dropped. The parser + * still produces a renderable document so a partially-supported file + * does not break the build.

+ */ +public final class LottieParser { + + private LottieParser() { } + + public static SVGDocument parse(InputStream in) throws IOException { + Object root = JsonParser.parse(in); + if (!(root instanceof Map)) { + throw new IllegalArgumentException("Lottie root must be a JSON object"); + } + return parse(asMap(root)); + } + + public static SVGDocument parse(Map root) { + SVGDocument doc = new SVGDocument(); + float w = (float) asDouble(root.get("w"), 100); + float h = (float) asDouble(root.get("h"), 100); + doc.setWidth(w); + doc.setHeight(h); + doc.setViewBoxX(0f); + doc.setViewBoxY(0f); + doc.setViewBoxWidth(w); + doc.setViewBoxHeight(h); + + float frameRate = (float) asDouble(root.get("fr"), 30); + float inFrame = (float) asDouble(root.get("ip"), 0); + float outFrame = (float) asDouble(root.get("op"), 0); + long durationMs = frameRate > 0f && outFrame > inFrame + ? Math.round((outFrame - inFrame) * 1000.0 / frameRate) + : 0L; + float fpsOffset = inFrame; // subtract from raw frame values + + List layers = asList(root.get("layers")); + if (layers == null) { + return doc; + } + // Lottie paints layers in reverse: the last entry in the array is + // drawn first, the first entry on top. SVG/CN1 paints in document + // order, so iterate the layer list back-to-front. + for (int i = layers.size() - 1; i >= 0; i--) { + Map layer = asMap(layers.get(i)); + if (layer == null) continue; + SVGNode emitted = emitLayer(layer, frameRate, fpsOffset, durationMs); + if (emitted != null) { + doc.addChild(emitted); + } + } + return doc; + } + + /** Build the SVG subtree for a single Lottie layer. */ + private static SVGNode emitLayer(Map layer, float frameRate, + float fpsOffset, long durationMs) { + int type = asInt(layer.get("ty"), -1); + SVGGroup g = new SVGGroup(); + applyLayerTransform(g, asMap(layer.get("ks")), frameRate, fpsOffset, durationMs); + + switch (type) { + case 1: // solid color layer + emitSolidLayer(g, layer); + break; + case 4: // shape layer + List shapes = asList(layer.get("shapes")); + if (shapes != null) { + emitShapes(g, shapes, frameRate, fpsOffset, durationMs); + } + break; + default: + // Unsupported layer (text, image, null, precomp, etc.). + // Return an empty group so the document still compiles. + break; + } + return g; + } + + private static void emitSolidLayer(SVGGroup g, Map layer) { + SVGRect r = new SVGRect(); + r.setX(0); + r.setY(0); + r.setWidth((float) asDouble(layer.get("sw"), 0)); + r.setHeight((float) asDouble(layer.get("sh"), 0)); + int argb = parseHexColor(asString(layer.get("sc"), "#000000")); + SVGStyle st = r.getStyle(); + st.setFill(SVGPaint.ofColor(argb)); + st.setStroke(SVGPaint.NONE); + g.addChild(r); + } + + /** Walk a "shapes" array within a single Lottie shape group and append + * the produced SVG nodes to {@code parent}. Lottie's paint convention: + * list entries earlier in the array are drawn on top of later ones, + * and fill/stroke items style the primitives that follow them in the + * list. We collect primitives in document order, scan once for the + * applicable fill/stroke (last wins -- matches AE's "Fill" effect), + * apply them to every primitive, then append in reverse so paint order + * matches Lottie. Nested {@code gr} groups recurse into a sub-{@code }. */ + private static void emitShapes(SVGGroup parent, List shapes, + float frameRate, float fpsOffset, long durationMs) { + List emitted = new ArrayList(); + Integer fillArgb = null; + Float fillOpacity = null; + Integer strokeArgb = null; + Float strokeOpacity = null; + Float strokeWidth = null; + SVGTransform groupTransform = null; + for (Object o : shapes) { + Map s = asMap(o); + if (s == null) continue; + String ty = asString(s.get("ty"), ""); + switch (ty) { + case "rc": { + SVGRect r = emitRect(s); + if (r != null) emitted.add(r); + break; + } + case "el": { + SVGEllipse e = emitEllipse(s); + if (e != null) emitted.add(e); + break; + } + case "sh": { + SVGPath p = emitPath(s); + if (p != null) emitted.add(p); + break; + } + case "fl": { + fillArgb = Integer.valueOf(extractColor(s)); + fillOpacity = extractScalar0to1(s.get("o")); + break; + } + case "st": { + strokeArgb = Integer.valueOf(extractColor(s)); + strokeOpacity = extractScalar0to1(s.get("o")); + strokeWidth = Float.valueOf(extractScalar(s.get("w"), 1f)); + break; + } + case "tr": { + // Shape group transform -- baked as a static matrix on a + // wrapping . Animated shape-group transforms collapse + // to the first keyframe. + groupTransform = staticTransformFrom(s); + break; + } + case "gr": { + SVGGroup child = new SVGGroup(); + List items = asList(s.get("it")); + if (items != null) { + emitShapes(child, items, frameRate, fpsOffset, durationMs); + } + emitted.add(child); + break; + } + default: + // "tm" (trim path), "rp" (repeater), "gf"/"gs" (gradient + // fill/stroke), "mm" (merge), "rd" (rounded corners), + // expressions -- silently ignored. + break; + } + } + + SVGGroup target = parent; + if (groupTransform != null && !groupTransform.isIdentity()) { + target = new SVGGroup(); + target.setTransform(groupTransform); + parent.addChild(target); + } + + // Lottie paints later-list-entries first, so reverse before appending + // so the first item ends up on top. + for (int i = emitted.size() - 1; i >= 0; i--) { + SVGNode n = emitted.get(i); + if (n instanceof SVGShape) { + SVGShape s = (SVGShape) n; + SVGStyle st = s.getStyle(); + if (fillArgb != null) { + int argb = applyOpacity(fillArgb.intValue(), + fillOpacity == null ? 1f : fillOpacity.floatValue()); + st.setFill(SVGPaint.ofColor(argb)); + } else { + st.setFill(SVGPaint.NONE); + } + if (strokeArgb != null) { + int argb = applyOpacity(strokeArgb.intValue(), + strokeOpacity == null ? 1f : strokeOpacity.floatValue()); + st.setStroke(SVGPaint.ofColor(argb)); + if (strokeWidth != null) { + st.setStrokeWidth(strokeWidth); + } + } + } + target.addChild(n); + } + } + + private static SVGRect emitRect(Map s) { + float[] pos = extractVector2(s.get("p"), new float[]{0f, 0f}); + float[] size = extractVector2(s.get("s"), new float[]{0f, 0f}); + float r = extractScalar(s.get("r"), 0f); + if (size[0] <= 0f || size[1] <= 0f) return null; + SVGRect rect = new SVGRect(); + rect.setX(pos[0] - size[0] / 2f); + rect.setY(pos[1] - size[1] / 2f); + rect.setWidth(size[0]); + rect.setHeight(size[1]); + if (r > 0f) { rect.setRx(r); rect.setRy(r); } + return rect; + } + + private static SVGEllipse emitEllipse(Map s) { + float[] pos = extractVector2(s.get("p"), new float[]{0f, 0f}); + float[] size = extractVector2(s.get("s"), new float[]{0f, 0f}); + if (size[0] <= 0f || size[1] <= 0f) return null; + SVGEllipse e = new SVGEllipse(); + e.setCx(pos[0]); + e.setCy(pos[1]); + e.setRx(size[0] / 2f); + e.setRy(size[1] / 2f); + return e; + } + + /** Lottie shape ("sh") encodes a path as vertices + per-vertex in/out + * tangents. Convert to cubic Beziers. */ + private static SVGPath emitPath(Map s) { + Map ks = asMap(s.get("ks")); + if (ks == null) return null; + Map k = asMap(ks.get("k")); + if (k == null) { + // Animated shape -- take the first keyframe's "s" value. + List kfs = asList(ks.get("k")); + if (kfs == null || kfs.isEmpty()) return null; + Map first = asMap(kfs.get(0)); + if (first == null) return null; + List sList = asList(first.get("s")); + if (sList == null || sList.isEmpty()) return null; + k = asMap(sList.get(0)); + if (k == null) return null; + } + List vertices = asList(k.get("v")); + List inTangents = asList(k.get("i")); + List outTangents = asList(k.get("o")); + boolean closed = asBoolean(k.get("c"), false); + if (vertices == null || vertices.isEmpty()) return null; + + List commands = new ArrayList(); + float[] first = pair(vertices.get(0)); + commands.add(new PathCommand(PathCommand.Type.MOVE, + new float[]{ first[0], first[1] })); + int n = vertices.size(); + for (int i = 1; i <= n; i++) { + int prev = i - 1; + int curr = i % n; + if (curr == 0 && !closed) break; + float[] p0 = pair(vertices.get(prev)); + float[] p1 = pair(vertices.get(curr)); + // Lottie tangents are *relative* to the vertex they belong to. + float[] out0 = outTangents != null && prev < outTangents.size() + ? pair(outTangents.get(prev)) : new float[]{0f, 0f}; + float[] in1 = inTangents != null && curr < inTangents.size() + ? pair(inTangents.get(curr)) : new float[]{0f, 0f}; + float c1x = p0[0] + out0[0]; + float c1y = p0[1] + out0[1]; + float c2x = p1[0] + in1[0]; + float c2y = p1[1] + in1[1]; + commands.add(new PathCommand(PathCommand.Type.CUBIC, + new float[]{ c1x, c1y, c2x, c2y, p1[0], p1[1] })); + } + if (closed) { + commands.add(new PathCommand(PathCommand.Type.CLOSE, new float[0])); + } + SVGPath path = new SVGPath(); + path.setCommands(commands); + return path; + } + + /** Apply the layer "ks" block to the group: bake static parts into a + * matrix, emit animateTransform for any animated rotation/position/ + * scale. */ + private static void applyLayerTransform(SVGGroup g, Map ks, + float frameRate, float fpsOffset, long durationMs) { + if (ks == null) return; + + // Decompose into anchor, position, scale, rotation, opacity. + Map a = asMap(ks.get("a")); + Map p = asMap(ks.get("p")); + Map s = asMap(ks.get("s")); + Map r = asMap(ks.get("r")); + Map o = asMap(ks.get("o")); + + float[] anchor = extractInitial(a, new float[]{0f, 0f}); + float[] position = extractInitial(p, new float[]{0f, 0f}); + float[] scale = extractInitial(s, new float[]{100f, 100f}); + float rotation = extractInitial(r, new float[]{0f})[0]; + float opacity = extractInitial(o, new float[]{100f})[0]; + + // Bake the constant transform first so the painter sees the correct + // resting pose for non-animated values. + SVGTransform mt = SVGTransform.identity() + .multiply(SVGTransform.translate(position[0], position[1])) + .multiply(SVGTransform.rotate(rotation, 0, 0)) + .multiply(SVGTransform.scale(scale[0] / 100f, scale[1] / 100f)) + .multiply(SVGTransform.translate(-anchor[0], -anchor[1])); + if (!mt.isIdentity()) { + g.setTransform(mt); + } + if (opacity != 100f) { + g.getStyle().setOpacity(Float.valueOf(opacity / 100f)); + } + + // Animated rotation -- most common Lottie animation. The SVG codegen + // already pre-applies the static transform, so we emit additional + // animateTransform deltas relative to the resting pose. + if (durationMs > 0L) { + emitAnimatedRotation(g, r, durationMs, rotation); + emitAnimatedTranslate(g, p, durationMs, position); + emitAnimatedScale(g, s, durationMs, scale); + } + } + + private static void emitAnimatedRotation(SVGGroup g, Map r, + long durationMs, float restingDeg) { + if (r == null) return; + if (asInt(r.get("a"), 0) != 1) return; + List keyframes = asList(r.get("k")); + if (keyframes == null || keyframes.size() < 2) return; + float[] startEnd = firstAndLastScalar(keyframes); + if (startEnd == null) return; + SVGAnimation an = new SVGAnimation(); + an.setKind(SVGAnimation.Kind.ANIMATE_TRANSFORM); + an.setTransformType(SVGAnimation.TransformType.ROTATE); + an.setBeginMs(0L); + an.setDurMs(durationMs); + an.setRepeatCount(SVGAnimation.REPEAT_INDEFINITE); + an.setFrom(formatRotateValue(startEnd[0] - restingDeg)); + an.setTo(formatRotateValue(startEnd[1] - restingDeg)); + g.addAnimation(an); + } + + private static void emitAnimatedTranslate(SVGGroup g, Map p, + long durationMs, float[] restingXY) { + if (p == null) return; + if (asInt(p.get("a"), 0) != 1) return; + List keyframes = asList(p.get("k")); + if (keyframes == null || keyframes.size() < 2) return; + float[][] startEnd = firstAndLastVector(keyframes); + if (startEnd == null) return; + SVGAnimation an = new SVGAnimation(); + an.setKind(SVGAnimation.Kind.ANIMATE_TRANSFORM); + an.setTransformType(SVGAnimation.TransformType.TRANSLATE); + an.setBeginMs(0L); + an.setDurMs(durationMs); + an.setRepeatCount(SVGAnimation.REPEAT_INDEFINITE); + an.setFrom((startEnd[0][0] - restingXY[0]) + " " + (startEnd[0][1] - restingXY[1])); + an.setTo((startEnd[1][0] - restingXY[0]) + " " + (startEnd[1][1] - restingXY[1])); + g.addAnimation(an); + } + + private static void emitAnimatedScale(SVGGroup g, Map s, + long durationMs, float[] restingScale) { + if (s == null) return; + if (asInt(s.get("a"), 0) != 1) return; + List keyframes = asList(s.get("k")); + if (keyframes == null || keyframes.size() < 2) return; + float[][] startEnd = firstAndLastVector(keyframes); + if (startEnd == null) return; + SVGAnimation an = new SVGAnimation(); + an.setKind(SVGAnimation.Kind.ANIMATE_TRANSFORM); + an.setTransformType(SVGAnimation.TransformType.SCALE); + an.setBeginMs(0L); + an.setDurMs(durationMs); + an.setRepeatCount(SVGAnimation.REPEAT_INDEFINITE); + // Lottie scale is in percent (100 = identity); convert to multiplier + // relative to the resting scale baked into the static transform. + float fx = (startEnd[0][0] / restingScale[0]); + float fy = (startEnd[0][1] / restingScale[1]); + float tx = (startEnd[1][0] / restingScale[0]); + float ty = (startEnd[1][1] / restingScale[1]); + an.setFrom(fx + " " + fy); + an.setTo(tx + " " + ty); + g.addAnimation(an); + } + + private static String formatRotateValue(float deg) { + // SVG rotate transform takes "angle [cx cy]" -- a single scalar is + // sufficient here because the static transform already moved the + // pivot to the anchor point. + return Float.toString(deg); + } + + // --------------------------------------------------------------------- + // Lottie property readers. + // --------------------------------------------------------------------- + + /** Read either a constant scalar/vector or the first keyframe's "s" + * value -- the "resting" value the static transform should use. */ + private static float[] extractInitial(Map prop, float[] fallback) { + if (prop == null) return fallback; + int animated = asInt(prop.get("a"), 0); + Object k = prop.get("k"); + if (animated == 0) { + return floatsFrom(k, fallback); + } + List keyframes = asList(k); + if (keyframes == null || keyframes.isEmpty()) { + return fallback; + } + Map first = asMap(keyframes.get(0)); + if (first == null) return fallback; + Object sv = first.get("s"); + return floatsFrom(sv, fallback); + } + + private static float[] floatsFrom(Object o, float[] fallback) { + if (o instanceof Number) { + return new float[]{ ((Number) o).floatValue() }; + } + List list = asList(o); + if (list == null) return fallback; + float[] out = new float[list.size()]; + for (int i = 0; i < list.size(); i++) { + out[i] = (float) asDouble(list.get(i), 0); + } + return out; + } + + private static float extractScalar(Object prop, float fallback) { + if (prop == null) return fallback; + Map p = asMap(prop); + if (p == null) return fallback; + Object k = p.get("k"); + if (k instanceof Number) return ((Number) k).floatValue(); + List kfs = asList(k); + if (kfs != null && !kfs.isEmpty()) { + Map first = asMap(kfs.get(0)); + if (first != null) { + Object sv = first.get("s"); + if (sv instanceof Number) return ((Number) sv).floatValue(); + List sList = asList(sv); + if (sList != null && !sList.isEmpty()) { + return (float) asDouble(sList.get(0), fallback); + } + } + } + return fallback; + } + + private static Float extractScalar0to1(Object prop) { + float v = extractScalar(prop, 100f) / 100f; + if (v < 0f) v = 0f; + if (v > 1f) v = 1f; + return Float.valueOf(v); + } + + private static float[] extractVector2(Object prop, float[] fallback) { + if (prop == null) return fallback; + Map p = asMap(prop); + if (p == null) return fallback; + Object k = p.get("k"); + List list = asList(k); + if (list != null && !list.isEmpty()) { + Object e0 = list.get(0); + if (e0 instanceof Map) { + // Animated -- take first keyframe's "s". + Map first = asMap(e0); + Object sv = first.get("s"); + List sList = asList(sv); + if (sList != null && sList.size() >= 2) { + return new float[]{ + (float) asDouble(sList.get(0), fallback[0]), + (float) asDouble(sList.get(1), fallback[1]) + }; + } + return fallback; + } + if (list.size() >= 2) { + return new float[]{ + (float) asDouble(list.get(0), fallback[0]), + (float) asDouble(list.get(1), fallback[1]) + }; + } + } + return fallback; + } + + private static int extractColor(Map s) { + Map c = asMap(s.get("c")); + if (c == null) return 0xFF000000; + Object k = c.get("k"); + List list = asList(k); + if (list == null) return 0xFF000000; + // Animated colors collapse to the first keyframe's "s". + if (!list.isEmpty() && list.get(0) instanceof Map) { + Map first = asMap(list.get(0)); + list = asList(first.get("s")); + if (list == null) return 0xFF000000; + } + double r = list.size() > 0 ? asDouble(list.get(0), 0) : 0; + double gC = list.size() > 1 ? asDouble(list.get(1), 0) : 0; + double bC = list.size() > 2 ? asDouble(list.get(2), 0) : 0; + double aC = list.size() > 3 ? asDouble(list.get(3), 1) : 1; + int ri = clampByte((int) Math.round(r * 255)); + int gi = clampByte((int) Math.round(gC * 255)); + int bi = clampByte((int) Math.round(bC * 255)); + int ai = clampByte((int) Math.round(aC * 255)); + return (ai << 24) | (ri << 16) | (gi << 8) | bi; + } + + private static int applyOpacity(int argb, float scale) { + int a = (argb >>> 24) & 0xFF; + a = clampByte(Math.round(a * scale)); + return (a << 24) | (argb & 0x00FFFFFF); + } + + private static SVGTransform staticTransformFrom(Map tr) { + float[] anchor = extractInitial(asMap(tr.get("a")), new float[]{0f, 0f}); + float[] position = extractInitial(asMap(tr.get("p")), new float[]{0f, 0f}); + float[] scale = extractInitial(asMap(tr.get("s")), new float[]{100f, 100f}); + float rotation = extractInitial(asMap(tr.get("r")), new float[]{0f})[0]; + return SVGTransform.identity() + .multiply(SVGTransform.translate(position[0], position[1])) + .multiply(SVGTransform.rotate(rotation, 0, 0)) + .multiply(SVGTransform.scale(scale[0] / 100f, scale[1] / 100f)) + .multiply(SVGTransform.translate(-anchor[0], -anchor[1])); + } + + private static float[] firstAndLastScalar(List keyframes) { + Float s = null; + Float e = null; + for (int i = 0; i < keyframes.size(); i++) { + Map kf = asMap(keyframes.get(i)); + if (kf == null) continue; + Object sv = kf.get("s"); + float v; + if (sv instanceof Number) v = ((Number) sv).floatValue(); + else { + List sList = asList(sv); + if (sList == null || sList.isEmpty()) continue; + v = (float) asDouble(sList.get(0), 0); + } + if (s == null) s = Float.valueOf(v); + e = Float.valueOf(v); + } + if (s == null || e == null) return null; + return new float[]{ s.floatValue(), e.floatValue() }; + } + + private static float[][] firstAndLastVector(List keyframes) { + float[] s = null; + float[] e = null; + for (int i = 0; i < keyframes.size(); i++) { + Map kf = asMap(keyframes.get(i)); + if (kf == null) continue; + List sList = asList(kf.get("s")); + if (sList == null || sList.size() < 2) continue; + float[] v = new float[]{ + (float) asDouble(sList.get(0), 0), + (float) asDouble(sList.get(1), 0) + }; + if (s == null) s = v; + e = v; + } + if (s == null || e == null) return null; + return new float[][]{ s, e }; + } + + private static float[] pair(Object o) { + List list = asList(o); + if (list == null || list.size() < 2) return new float[]{ 0f, 0f }; + return new float[]{ + (float) asDouble(list.get(0), 0), + (float) asDouble(list.get(1), 0) + }; + } + + private static int parseHexColor(String s) { + if (s == null) return 0xFF000000; + String t = s.trim(); + if (t.startsWith("#")) t = t.substring(1); + try { + if (t.length() == 6) { + return 0xFF000000 | Integer.parseInt(t, 16); + } + if (t.length() == 8) { + long l = Long.parseLong(t, 16); + return (int) l; + } + } catch (NumberFormatException ignored) { /* fall through */ } + return 0xFF000000; + } + + private static int clampByte(int v) { + if (v < 0) return 0; + if (v > 255) return 255; + return v; + } +} diff --git a/maven/lottie-transcoder/src/test/java/com/codename1/lottie/LottieParserTest.java b/maven/lottie-transcoder/src/test/java/com/codename1/lottie/LottieParserTest.java new file mode 100644 index 0000000000..b68c0b8d7a --- /dev/null +++ b/maven/lottie-transcoder/src/test/java/com/codename1/lottie/LottieParserTest.java @@ -0,0 +1,485 @@ +/* + * Copyright (c) 2025, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + */ +package com.codename1.lottie; + +import com.codename1.lottie.transcoder.LottieTranscoder; +import com.codename1.lottie.transcoder.parser.LottieParser; +import com.codename1.svg.transcoder.model.SVGAnimation; +import com.codename1.svg.transcoder.model.SVGDocument; +import com.codename1.svg.transcoder.model.SVGGroup; +import com.codename1.svg.transcoder.model.SVGNode; +import com.codename1.svg.transcoder.model.SVGRect; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class LottieParserTest { + + private static final String SIMPLE_RECT = "{\n" + + " \"v\":\"5.7.0\",\"fr\":30,\"ip\":0,\"op\":30,\"w\":100,\"h\":100,\n" + + " \"layers\":[{\n" + + " \"ty\":4,\"nm\":\"sq\",\"ip\":0,\"op\":30,\n" + + " \"ks\":{\n" + + " \"a\":{\"a\":0,\"k\":[0,0]},\n" + + " \"p\":{\"a\":0,\"k\":[50,50]},\n" + + " \"s\":{\"a\":0,\"k\":[100,100]},\n" + + " \"r\":{\"a\":0,\"k\":0},\n" + + " \"o\":{\"a\":0,\"k\":100}\n" + + " },\n" + + " \"shapes\":[\n" + + " {\"ty\":\"rc\",\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[40,40]},\"r\":{\"a\":0,\"k\":0}},\n" + + " {\"ty\":\"fl\",\"c\":{\"a\":0,\"k\":[1,0,0,1]},\"o\":{\"a\":0,\"k\":100}}\n" + + " ]\n" + + " }]\n" + + "}\n"; + + private static final String SPINNING_RECT = "{\n" + + " \"v\":\"5.7.0\",\"fr\":30,\"ip\":0,\"op\":30,\"w\":100,\"h\":100,\n" + + " \"layers\":[{\n" + + " \"ty\":4,\"nm\":\"sq\",\"ip\":0,\"op\":30,\n" + + " \"ks\":{\n" + + " \"a\":{\"a\":0,\"k\":[0,0]},\n" + + " \"p\":{\"a\":0,\"k\":[50,50]},\n" + + " \"s\":{\"a\":0,\"k\":[100,100]},\n" + + " \"r\":{\"a\":1,\"k\":[\n" + + " {\"t\":0,\"s\":[0]},{\"t\":30,\"s\":[360]}\n" + + " ]},\n" + + " \"o\":{\"a\":0,\"k\":100}\n" + + " },\n" + + " \"shapes\":[\n" + + " {\"ty\":\"rc\",\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[30,30]},\"r\":{\"a\":0,\"k\":4}},\n" + + " {\"ty\":\"fl\",\"c\":{\"a\":0,\"k\":[0,0.5,1,1]},\"o\":{\"a\":0,\"k\":100}}\n" + + " ]\n" + + " }]\n" + + "}\n"; + + @Test + public void parsesStaticRectIntoSvgDocument() throws Exception { + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream( + SIMPLE_RECT.getBytes(StandardCharsets.UTF_8))); + assertNotNull(doc); + assertEquals(100f, doc.getWidth(), 0.001f); + assertEquals(100f, doc.getHeight(), 0.001f); + // One layer -> one group -> one rect + assertEquals(1, doc.getChildren().size()); + SVGGroup g = (SVGGroup) doc.getChildren().get(0); + assertEquals(1, g.getChildren().size()); + SVGNode rectNode = g.getChildren().get(0); + assertTrue(rectNode instanceof SVGRect); + SVGRect r = (SVGRect) rectNode; + assertEquals(40f, r.getWidth(), 0.001f); + assertNotNull(r.getStyle().getFill()); + // Layer has anim list -- empty for a static layer. + assertTrue(g.getAnimations().isEmpty()); + } + + @Test + public void emitsRotationAnimationForSpinner() throws Exception { + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream( + SPINNING_RECT.getBytes(StandardCharsets.UTF_8))); + SVGGroup g = (SVGGroup) doc.getChildren().get(0); + // One animateTransform expected. + assertEquals(1, g.getAnimations().size()); + SVGAnimation an = g.getAnimations().get(0); + assertEquals(SVGAnimation.Kind.ANIMATE_TRANSFORM, an.getKind()); + assertEquals(SVGAnimation.TransformType.ROTATE, an.getTransformType()); + assertEquals(1000L, an.getDurMs()); + assertEquals(SVGAnimation.REPEAT_INDEFINITE, an.getRepeatCount()); + } + + /** Real Bodymovin exports use 3D vectors for position / anchor / scale, + * wrap shape primitives in a {@code gr} group with a per-group {@code tr} + * transform, and decorate every property with an {@code ix} index. The + * parser must ignore all of that extra metadata and still produce the + * same renderable subtree as the minimal hand-crafted format. */ + private static final String REAL_BODYMOVIN_SPINNER = + "{\"v\":\"5.7.0\",\"fr\":30,\"ip\":0,\"op\":30,\"w\":120,\"h\":120,\"nm\":\"spin\",\"ddd\":0,\"assets\":[],\n" + + " \"layers\":[{\"ddd\":0,\"ind\":1,\"ty\":4,\"nm\":\"sq\",\"sr\":1,\n" + + " \"ks\":{\n" + + " \"o\":{\"a\":0,\"k\":100,\"ix\":11},\n" + + " \"r\":{\"a\":1,\"k\":[{\"i\":{\"x\":[0.8],\"y\":[0.8]},\"o\":{\"x\":[0.2],\"y\":[0.2]},\"t\":0,\"s\":[0]},{\"t\":30,\"s\":[360]}],\"ix\":10},\n" + + " \"p\":{\"a\":0,\"k\":[60,60,0],\"ix\":2},\n" + + " \"a\":{\"a\":0,\"k\":[0,0,0],\"ix\":1},\n" + + " \"s\":{\"a\":0,\"k\":[100,100,100],\"ix\":6}},\n" + + " \"ao\":0,\n" + + " \"shapes\":[{\"ty\":\"gr\",\"it\":[\n" + + " {\"ty\":\"rc\",\"d\":1,\"s\":{\"a\":0,\"k\":[16,32],\"ix\":2},\"p\":{\"a\":0,\"k\":[0,-32],\"ix\":3},\"r\":{\"a\":0,\"k\":4,\"ix\":4},\"nm\":\"Rect\",\"hd\":false},\n" + + " {\"ty\":\"fl\",\"c\":{\"a\":0,\"k\":[1,0,0,1],\"ix\":4},\"o\":{\"a\":0,\"k\":100,\"ix\":5},\"r\":1,\"bm\":0,\"nm\":\"Fill\",\"hd\":false},\n" + + " {\"ty\":\"tr\",\"p\":{\"a\":0,\"k\":[0,0],\"ix\":2},\"a\":{\"a\":0,\"k\":[0,0],\"ix\":1},\"s\":{\"a\":0,\"k\":[100,100],\"ix\":3},\"r\":{\"a\":0,\"k\":0,\"ix\":6},\"o\":{\"a\":0,\"k\":100,\"ix\":7},\"sk\":{\"a\":0,\"k\":0,\"ix\":4},\"sa\":{\"a\":0,\"k\":0,\"ix\":5},\"nm\":\"Transform\"}\n" + + " ],\"nm\":\"Group\",\"np\":3,\"cix\":2,\"bm\":0,\"ix\":1,\"hd\":false}],\n" + + " \"ip\":0,\"op\":30,\"st\":0,\"bm\":0}],\n" + + " \"markers\":[]}"; + + @Test + public void parsesRealBodymovinExport() throws Exception { + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream( + REAL_BODYMOVIN_SPINNER.getBytes(StandardCharsets.UTF_8))); + assertEquals(120f, doc.getWidth(), 0.001f); + // Layer group with rotation animation + SVGGroup layer = (SVGGroup) doc.getChildren().get(0); + assertEquals(1, layer.getAnimations().size()); + assertEquals(SVGAnimation.TransformType.ROTATE, + layer.getAnimations().get(0).getTransformType()); + // The rect inside the gr/tr wrapping has the fill applied even though + // the fl entry uses normalized 0..1 RGBA quadruplets and an ix index. + SVGRect rect = findFirstRect(layer); + assertNotNull("rect should be reachable through the gr/tr wrapping", rect); + assertEquals(16f, rect.getWidth(), 0.001f); + assertEquals(32f, rect.getHeight(), 0.001f); + assertNotNull(rect.getStyle().getFill()); + assertEquals(0xFFFF0000, rect.getStyle().getFill().getColor()); + } + + private static SVGRect findFirstRect(SVGNode n) { + if (n instanceof SVGRect) return (SVGRect) n; + if (n instanceof SVGGroup) { + for (SVGNode c : ((SVGGroup) n).getChildren()) { + SVGRect r = findFirstRect(c); + if (r != null) return r; + } + } + return null; + } + + @Test + public void transcodesToCompilableJava() throws Exception { + StringWriter w = new StringWriter(); + LottieTranscoder.transcode(new ByteArrayInputStream( + SPINNING_RECT.getBytes(StandardCharsets.UTF_8)), + "com.example", "Spin", w); + String src = w.toString(); + assertTrue(src.contains("package com.example;")); + assertTrue(src.contains("class Spin extends GeneratedSVGImage")); + assertTrue(src.contains("paintSVG")); + } + + // ------------------------------------------------------------------ + // Animation extraction + // ------------------------------------------------------------------ + + private static String layer(String ksBody, String shapes, int op) { + return "{\"v\":\"5.7.0\",\"fr\":30,\"ip\":0,\"op\":" + op + + ",\"w\":100,\"h\":100,\"layers\":[{" + + "\"ty\":4,\"nm\":\"l\",\"ip\":0,\"op\":" + op + + ",\"ks\":{" + ksBody + "}," + + "\"shapes\":" + shapes + "}]}"; + } + + @Test + public void emitsTranslateAnimationForAnimatedPosition() throws Exception { + // Animated position [10,20] -> [60,80] on a layer that rests at [10,20]. + // The static transform bakes in the resting position; the + // animateTransform from/to are deltas relative to that pose, so the + // expected delta is (50, 60) on the to side and (0, 0) on the from side. + String json = layer( + "\"a\":{\"a\":0,\"k\":[0,0]}," + + "\"s\":{\"a\":0,\"k\":[100,100]}," + + "\"r\":{\"a\":0,\"k\":0}," + + "\"o\":{\"a\":0,\"k\":100}," + + "\"p\":{\"a\":1,\"k\":[" + + "{\"t\":0,\"s\":[10,20]}," + + "{\"t\":30,\"s\":[60,80]}" + + "]}", + "[{\"ty\":\"rc\",\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[10,10]},\"r\":{\"a\":0,\"k\":0}}," + + "{\"ty\":\"fl\",\"c\":{\"a\":0,\"k\":[1,0,0,1]},\"o\":{\"a\":0,\"k\":100}}]", + 30); + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + SVGGroup g = (SVGGroup) doc.getChildren().get(0); + SVGAnimation an = findAnimation(g, SVGAnimation.TransformType.TRANSLATE); + assertNotNull("expected an animateTransform translate", an); + assertEquals(1000L, an.getDurMs()); + assertEquals(SVGAnimation.REPEAT_INDEFINITE, an.getRepeatCount()); + assertEquals("0.0 0.0", an.getFrom()); + assertEquals("50.0 60.0", an.getTo()); + } + + @Test + public void emitsScaleAnimationForAnimatedScale() throws Exception { + // Lottie scale is percent. Start at 50% (resting), end at 150% -- + // codegen normalizes to multipliers relative to resting (1.0 -> 3.0). + String json = layer( + "\"a\":{\"a\":0,\"k\":[0,0]}," + + "\"p\":{\"a\":0,\"k\":[0,0]}," + + "\"r\":{\"a\":0,\"k\":0}," + + "\"o\":{\"a\":0,\"k\":100}," + + "\"s\":{\"a\":1,\"k\":[" + + "{\"t\":0,\"s\":[50,50]}," + + "{\"t\":30,\"s\":[150,150]}" + + "]}", + "[{\"ty\":\"el\",\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[20,20]}}," + + "{\"ty\":\"fl\",\"c\":{\"a\":0,\"k\":[0,1,0,1]},\"o\":{\"a\":0,\"k\":100}}]", + 30); + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + SVGGroup g = (SVGGroup) doc.getChildren().get(0); + SVGAnimation an = findAnimation(g, SVGAnimation.TransformType.SCALE); + assertNotNull("expected an animateTransform scale", an); + assertEquals("1.0 1.0", an.getFrom()); + assertEquals("3.0 3.0", an.getTo()); + } + + @Test + public void noAnimationsForFullyStaticLayer() throws Exception { + // Static rect should generate zero animation entries even though + // the layer's "op" defines a non-zero duration. + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream( + SIMPLE_RECT.getBytes(StandardCharsets.UTF_8))); + SVGGroup g = (SVGGroup) doc.getChildren().get(0); + assertTrue("static layer must not emit animations", g.getAnimations().isEmpty()); + } + + // ------------------------------------------------------------------ + // Shape parsing + // ------------------------------------------------------------------ + + @Test + public void parsesBezierPathShape() throws Exception { + // 3-vertex closed path with tangent vectors. The parser converts + // each vertex pair into a cubic curve so we should see one path + // node with the expected command sequence (move + n cubics + + // close). + String shapes = "[{\"ty\":\"sh\",\"ks\":{\"k\":{" + + "\"v\":[[0,0],[10,0],[10,10]]," + + "\"i\":[[0,0],[0,0],[0,0]]," + + "\"o\":[[0,0],[0,0],[0,0]]," + + "\"c\":true}}}," + + "{\"ty\":\"fl\",\"c\":{\"a\":0,\"k\":[0,0,1,1]},\"o\":{\"a\":0,\"k\":100}}]"; + String json = layer( + "\"a\":{\"a\":0,\"k\":[0,0]},\"p\":{\"a\":0,\"k\":[0,0]}," + + "\"s\":{\"a\":0,\"k\":[100,100]},\"r\":{\"a\":0,\"k\":0}," + + "\"o\":{\"a\":0,\"k\":100}", + shapes, 30); + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + SVGGroup layer = (SVGGroup) doc.getChildren().get(0); + com.codename1.svg.transcoder.model.SVGPath path = findFirst(layer, com.codename1.svg.transcoder.model.SVGPath.class); + assertNotNull("expected a path from the sh shape", path); + assertNotNull(path.getCommands()); + assertTrue("path must have at least move + 1 curve + close", + path.getCommands().size() >= 3); + assertEquals(com.codename1.svg.transcoder.parser.PathCommand.Type.MOVE, + path.getCommands().get(0).getType()); + // closed=true means last command is CLOSE + assertEquals(com.codename1.svg.transcoder.parser.PathCommand.Type.CLOSE, + path.getCommands().get(path.getCommands().size() - 1).getType()); + } + + @Test + public void multipleShapesShareOneFill() throws Exception { + // Lottie convention: a single "fl" within a shape group applies to + // every primitive in that group. The parser must propagate the + // fill to both primitives. + String shapes = "[{\"ty\":\"gr\",\"it\":[" + + "{\"ty\":\"rc\",\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[10,10]},\"r\":{\"a\":0,\"k\":0}}," + + "{\"ty\":\"el\",\"p\":{\"a\":0,\"k\":[5,5]},\"s\":{\"a\":0,\"k\":[8,8]}}," + + "{\"ty\":\"fl\",\"c\":{\"a\":0,\"k\":[1,0.5,0,1]},\"o\":{\"a\":0,\"k\":100}}" + + "]}]"; + String json = layer( + "\"a\":{\"a\":0,\"k\":[0,0]},\"p\":{\"a\":0,\"k\":[0,0]}," + + "\"s\":{\"a\":0,\"k\":[100,100]},\"r\":{\"a\":0,\"k\":0}," + + "\"o\":{\"a\":0,\"k\":100}", + shapes, 30); + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + SVGGroup layer = (SVGGroup) doc.getChildren().get(0); + SVGRect rect = findFirst(layer, SVGRect.class); + com.codename1.svg.transcoder.model.SVGEllipse ellipse = + findFirst(layer, com.codename1.svg.transcoder.model.SVGEllipse.class); + assertNotNull(rect); + assertNotNull(ellipse); + // Both shapes carry the same fill ARGB derived from (1, 0.5, 0, 1). + // 0.5 * 255 rounds to 128 (0x80). + int expected = 0xFFFF8000; + assertEquals(expected, rect.getStyle().getFill().getColor()); + assertEquals(expected, ellipse.getStyle().getFill().getColor()); + } + + @Test + public void extractsStrokeWidthAndColor() throws Exception { + String shapes = "[{\"ty\":\"rc\",\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[10,10]},\"r\":{\"a\":0,\"k\":0}}," + + "{\"ty\":\"st\",\"c\":{\"a\":0,\"k\":[0,1,1,1]},\"o\":{\"a\":0,\"k\":100},\"w\":{\"a\":0,\"k\":3}}]"; + String json = layer( + "\"a\":{\"a\":0,\"k\":[0,0]},\"p\":{\"a\":0,\"k\":[0,0]}," + + "\"s\":{\"a\":0,\"k\":[100,100]},\"r\":{\"a\":0,\"k\":0}," + + "\"o\":{\"a\":0,\"k\":100}", + shapes, 30); + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + SVGRect rect = findFirst((SVGGroup) doc.getChildren().get(0), SVGRect.class); + assertNotNull(rect); + assertNotNull("stroke must be set when an st entry is present", + rect.getStyle().getStroke()); + assertEquals(0xFF00FFFF, rect.getStyle().getStroke().getColor()); + assertNotNull(rect.getStyle().getStrokeWidth()); + assertEquals(3f, rect.getStyle().getStrokeWidth().floatValue(), 0.001f); + } + + // ------------------------------------------------------------------ + // Layer handling + // ------------------------------------------------------------------ + + @Test + public void multipleLayersPaintBackToFront() throws Exception { + // Lottie array order: top layer first, bottom layer last. SVG/CN1 + // paint in document order, so the parser reverses the list. The + // bottom layer (last in JSON) must appear FIRST in the document. + String json = "{\"v\":\"5.7.0\",\"fr\":30,\"ip\":0,\"op\":30,\"w\":100,\"h\":100,\"layers\":[" + + "{\"ty\":4,\"nm\":\"top\",\"ip\":0,\"op\":30," + + " \"ks\":{\"a\":{\"a\":0,\"k\":[0,0]},\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[100,100]},\"r\":{\"a\":0,\"k\":0},\"o\":{\"a\":0,\"k\":100}}," + + " \"shapes\":[{\"ty\":\"rc\",\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[20,20]},\"r\":{\"a\":0,\"k\":0}},{\"ty\":\"fl\",\"c\":{\"a\":0,\"k\":[1,0,0,1]},\"o\":{\"a\":0,\"k\":100}}]}," + + "{\"ty\":4,\"nm\":\"bot\",\"ip\":0,\"op\":30," + + " \"ks\":{\"a\":{\"a\":0,\"k\":[0,0]},\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[100,100]},\"r\":{\"a\":0,\"k\":0},\"o\":{\"a\":0,\"k\":100}}," + + " \"shapes\":[{\"ty\":\"el\",\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[40,40]}},{\"ty\":\"fl\",\"c\":{\"a\":0,\"k\":[0,1,0,1]},\"o\":{\"a\":0,\"k\":100}}]}" + + "]}"; + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + assertEquals(2, doc.getChildren().size()); + // First child = last layer in JSON = the ellipse (green) + SVGGroup first = (SVGGroup) doc.getChildren().get(0); + SVGGroup second = (SVGGroup) doc.getChildren().get(1); + assertNotNull(findFirst(first, com.codename1.svg.transcoder.model.SVGEllipse.class)); + assertNotNull(findFirst(second, SVGRect.class)); + } + + @Test + public void solidColorLayerEmitsRect() throws Exception { + // ty:1 (solid) with explicit sw/sh/sc should produce one filled rect. + String json = "{\"v\":\"5.7.0\",\"fr\":30,\"ip\":0,\"op\":30,\"w\":100,\"h\":100,\"layers\":[{" + + "\"ty\":1,\"nm\":\"bg\",\"ip\":0,\"op\":30,\"sw\":80,\"sh\":60,\"sc\":\"#33aaff\"," + + "\"ks\":{\"a\":{\"a\":0,\"k\":[0,0]},\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[100,100]},\"r\":{\"a\":0,\"k\":0},\"o\":{\"a\":0,\"k\":100}}" + + "}]}"; + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + SVGGroup layer = (SVGGroup) doc.getChildren().get(0); + SVGRect bg = findFirst(layer, SVGRect.class); + assertNotNull("solid layer should produce a rect", bg); + assertEquals(80f, bg.getWidth(), 0.001f); + assertEquals(60f, bg.getHeight(), 0.001f); + assertEquals(0xFF33AAFF, bg.getStyle().getFill().getColor()); + } + + @Test + public void unsupportedLayerTypeProducesEmptyGroup() throws Exception { + // Text (ty:5), image (ty:2), null (ty:3), precomp (ty:0) are not + // rendered but must not throw and must still produce a child node + // so the layer index/ordering stays stable. + String json = "{\"v\":\"5.7.0\",\"fr\":30,\"ip\":0,\"op\":30,\"w\":100,\"h\":100,\"layers\":[{" + + "\"ty\":5,\"nm\":\"txt\",\"ip\":0,\"op\":30," + + "\"ks\":{\"a\":{\"a\":0,\"k\":[0,0]},\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[100,100]},\"r\":{\"a\":0,\"k\":0},\"o\":{\"a\":0,\"k\":100}}" + + "}]}"; + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + assertEquals(1, doc.getChildren().size()); + SVGGroup g = (SVGGroup) doc.getChildren().get(0); + assertTrue("unsupported layer must produce an empty group", + g.getChildren().isEmpty()); + } + + @Test + public void opacityBelowFullValueIsBakedIntoStyle() throws Exception { + String json = layer( + "\"a\":{\"a\":0,\"k\":[0,0]},\"p\":{\"a\":0,\"k\":[0,0]}," + + "\"s\":{\"a\":0,\"k\":[100,100]},\"r\":{\"a\":0,\"k\":0}," + + "\"o\":{\"a\":0,\"k\":40}", + "[{\"ty\":\"rc\",\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[10,10]},\"r\":{\"a\":0,\"k\":0}}," + + "{\"ty\":\"fl\",\"c\":{\"a\":0,\"k\":[1,0,0,1]},\"o\":{\"a\":0,\"k\":100}}]", + 30); + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + SVGGroup layer = (SVGGroup) doc.getChildren().get(0); + assertNotNull(layer.getStyle().getOpacity()); + assertEquals(0.4f, layer.getStyle().getOpacity().floatValue(), 0.001f); + } + + // ------------------------------------------------------------------ + // Color normalization + // ------------------------------------------------------------------ + + @Test + public void normalizesRgbaZeroToOneIntoArgbInt() throws Exception { + // Edge cases: 0, 0.5, 1, plus alpha quarter -- verify the + // round(value * 255) conversion. + String shapes = "[{\"ty\":\"rc\",\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[10,10]},\"r\":{\"a\":0,\"k\":0}}," + + "{\"ty\":\"fl\",\"c\":{\"a\":0,\"k\":[0,0.5,1,0.25]},\"o\":{\"a\":0,\"k\":100}}]"; + String json = layer( + "\"a\":{\"a\":0,\"k\":[0,0]},\"p\":{\"a\":0,\"k\":[0,0]}," + + "\"s\":{\"a\":0,\"k\":[100,100]},\"r\":{\"a\":0,\"k\":0}," + + "\"o\":{\"a\":0,\"k\":100}", + shapes, 30); + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + SVGRect rect = findFirst((SVGGroup) doc.getChildren().get(0), SVGRect.class); + // alpha .25 -> 64 (0x40), R=0, G=128, B=255 -> 0x4000 80FF + assertEquals(0x400080FF, rect.getStyle().getFill().getColor()); + } + + // ------------------------------------------------------------------ + // Codegen + // ------------------------------------------------------------------ + + @Test + public void codegenForEllipseLayerProducesGeneralPathDraw() throws Exception { + // Ensure the codegen reaches an ellipse path even when the source + // is a Lottie "el" shape inside a gr/tr wrapping. + StringWriter w = new StringWriter(); + String shapes = "[{\"ty\":\"gr\",\"it\":[" + + "{\"ty\":\"el\",\"p\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[40,40]}}," + + "{\"ty\":\"fl\",\"c\":{\"a\":0,\"k\":[1,0,0,1]},\"o\":{\"a\":0,\"k\":100}}," + + "{\"ty\":\"tr\",\"p\":{\"a\":0,\"k\":[0,0]},\"a\":{\"a\":0,\"k\":[0,0]}," + + " \"s\":{\"a\":0,\"k\":[100,100]},\"r\":{\"a\":0,\"k\":0}," + + " \"o\":{\"a\":0,\"k\":100},\"sk\":{\"a\":0,\"k\":0},\"sa\":{\"a\":0,\"k\":0}}" + + "]}]"; + String json = layer( + "\"a\":{\"a\":0,\"k\":[0,0]},\"p\":{\"a\":0,\"k\":[60,60]}," + + "\"s\":{\"a\":0,\"k\":[100,100]},\"r\":{\"a\":0,\"k\":0}," + + "\"o\":{\"a\":0,\"k\":100}", + shapes, 60); + LottieTranscoder.transcode(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), + "com.example", "Ell", w); + String src = w.toString(); + assertTrue(src.contains("class Ell extends GeneratedSVGImage")); + // Ellipses lower to a drawArc/fillShape on a GeneralPath -- either + // way the rendered code references one of those APIs. + assertTrue("expected fillShape or drawArc in generated paint", + src.contains("fillShape") || src.contains("drawArc")); + } + + @Test + public void parseHandlesMissingTopLevelDimensions() throws Exception { + // Bodymovin always sets w/h; absence falls back to 100x100 so the + // generated subclass still compiles even on corrupt exports. + String json = "{\"v\":\"5.7.0\",\"fr\":30,\"ip\":0,\"op\":30,\"layers\":[]}"; + SVGDocument doc = LottieParser.parse(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + assertEquals(100f, doc.getWidth(), 0.001f); + assertEquals(100f, doc.getHeight(), 0.001f); + assertEquals(0, doc.getChildren().size()); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static SVGAnimation findAnimation(SVGGroup g, SVGAnimation.TransformType ty) { + for (SVGAnimation a : g.getAnimations()) { + if (a.getKind() == SVGAnimation.Kind.ANIMATE_TRANSFORM + && a.getTransformType() == ty) { + return a; + } + } + return null; + } + + @SuppressWarnings("unchecked") + private static T findFirst(SVGNode n, Class cls) { + if (cls.isInstance(n)) return (T) n; + if (n instanceof SVGGroup) { + for (SVGNode c : ((SVGGroup) n).getChildren()) { + T hit = findFirst(c, cls); + if (hit != null) return hit; + } + } + return null; + } +} diff --git a/maven/pom.xml b/maven/pom.xml index 228e9f32af..51fd5be6b7 100644 --- a/maven/pom.xml +++ b/maven/pom.xml @@ -62,6 +62,7 @@ factory css-compiler svg-transcoder + lottie-transcoder sqlite-jdbc javase javase-svg @@ -121,6 +122,11 @@ codenameone-svg-transcoder ${project.version} + + com.codenameone + codenameone-lottie-transcoder + ${project.version} + com.codenameone sqlite-jdbc diff --git a/scripts/android/screenshots/LottieAnimatedScreenshotTest.png b/scripts/android/screenshots/LottieAnimatedScreenshotTest.png new file mode 100644 index 0000000000..cf74a43956 Binary files /dev/null and b/scripts/android/screenshots/LottieAnimatedScreenshotTest.png differ diff --git a/scripts/hellocodenameone/common/src/main/css/lottie_pulse.json b/scripts/hellocodenameone/common/src/main/css/lottie_pulse.json new file mode 100644 index 0000000000..4bd21bd7aa --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/css/lottie_pulse.json @@ -0,0 +1,65 @@ +{ + "v": "5.7.0", + "fr": 30, + "ip": 0, + "op": 60, + "w": 120, + "h": 120, + "nm": "pulse", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "dot", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": {"a": 0, "k": [60, 60, 0], "ix": 2}, + "a": {"a": 0, "k": [0, 0, 0], "ix": 1}, + "s": {"a": 1, "k": [ + {"i": {"x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833]}, + "o": {"x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167]}, + "t": 0, "s": [60, 60, 100]}, + {"t": 60, "s": [140, 140, 100]} + ], "ix": 6} + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + {"ty": "el", "d": 1, + "s": {"a": 0, "k": [40, 40], "ix": 2}, + "p": {"a": 0, "k": [0, 0], "ix": 3}, + "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse", "hd": false}, + {"ty": "fl", + "c": {"a": 0, "k": [0.12, 0.54, 0.88, 1], "ix": 4}, + "o": {"a": 0, "k": 100, "ix": 5}, + "r": 1, "bm": 0, + "nm": "Fill 1", "mn": "ADBE Vector Graphic - Fill", "hd": false}, + {"ty": "tr", + "p": {"a": 0, "k": [0, 0], "ix": 2}, + "a": {"a": 0, "k": [0, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100], "ix": 3}, + "r": {"a": 0, "k": 0, "ix": 6}, + "o": {"a": 0, "k": 100, "ix": 7}, + "sk": {"a": 0, "k": 0, "ix": 4}, + "sa": {"a": 0, "k": 0, "ix": 5}, + "nm": "Transform"} + ], + "nm": "Ellipse 1", "np": 3, "cix": 2, "bm": 0, "ix": 1, + "mn": "ADBE Vector Group", "hd": false + } + ], + "ip": 0, + "op": 60, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} diff --git a/scripts/hellocodenameone/common/src/main/css/lottie_spinner.json b/scripts/hellocodenameone/common/src/main/css/lottie_spinner.json new file mode 100644 index 0000000000..32028c7bb2 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/css/lottie_spinner.json @@ -0,0 +1,64 @@ +{ + "v": "5.7.0", + "fr": 30, + "ip": 0, + "op": 30, + "w": 120, + "h": 120, + "nm": "spinner", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "sq", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 1, "k": [ + {"i": {"x": [0.833], "y": [0.833]}, "o": {"x": [0.167], "y": [0.167]}, "t": 0, "s": [0]}, + {"t": 30, "s": [360]} + ], "ix": 10}, + "p": {"a": 0, "k": [60, 60, 0], "ix": 2}, + "a": {"a": 0, "k": [0, 0, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100, 100], "ix": 6} + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + {"ty": "rc", "d": 1, + "s": {"a": 0, "k": [16, 32], "ix": 2}, + "p": {"a": 0, "k": [0, -32], "ix": 3}, + "r": {"a": 0, "k": 4, "ix": 4}, + "nm": "Rectangle Path 1", "mn": "ADBE Vector Shape - Rect", "hd": false}, + {"ty": "fl", + "c": {"a": 0, "k": [0.96, 0.26, 0.21, 1], "ix": 4}, + "o": {"a": 0, "k": 100, "ix": 5}, + "r": 1, "bm": 0, + "nm": "Fill 1", "mn": "ADBE Vector Graphic - Fill", "hd": false}, + {"ty": "tr", + "p": {"a": 0, "k": [0, 0], "ix": 2}, + "a": {"a": 0, "k": [0, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100], "ix": 3}, + "r": {"a": 0, "k": 0, "ix": 6}, + "o": {"a": 0, "k": 100, "ix": 7}, + "sk": {"a": 0, "k": 0, "ix": 4}, + "sa": {"a": 0, "k": 0, "ix": 5}, + "nm": "Transform"} + ], + "nm": "Rectangle 1", "np": 3, "cix": 2, "bm": 0, "ix": 1, + "mn": "ADBE Vector Group", "hd": false + } + ], + "ip": 0, + "op": 30, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} diff --git a/scripts/hellocodenameone/common/src/main/css/theme.css b/scripts/hellocodenameone/common/src/main/css/theme.css index 5e6fc687ca..94d443611d 100644 --- a/scripts/hellocodenameone/common/src/main/css/theme.css +++ b/scripts/hellocodenameone/common/src/main/css/theme.css @@ -292,3 +292,22 @@ SVGClippedBadgeStyle { bg-type: image_scaled_fit; padding: 2mm; } + +/* Build-time Lottie transcoder: Bodymovin JSON files are lowered into the + * same SVG model the SVG transcoder uses, so the cn1-svg-width / -height + * hints sized for SVG apply here too. */ +LottieSpinnerStyle { + background: url(lottie_spinner.json); + cn1-svg-width: 14mm; + cn1-svg-height: 14mm; + bg-type: image_scaled_fit; + padding: 2mm; +} + +LottiePulseStyle { + background: url(lottie_pulse.json); + cn1-svg-width: 14mm; + cn1-svg-height: 14mm; + bg-type: image_scaled_fit; + padding: 2mm; +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 2cd24f099d..18a0c2a2d2 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -220,6 +220,9 @@ private static int testTimeoutMs() { // AnimationTime so the captured frame is deterministic. new SVGStaticScreenshotTest(), new SVGAnimatedScreenshotTest(), + // Build-time Lottie transcoder -- same pipeline as SVG, lowers + // the Bodymovin JSON into the SVG model and reuses SVGRegistry. + new LottieAnimatedScreenshotTest(), // Keep this as the last screenshot test; orientation changes can leak into subsequent screenshots. new OrientationLockScreenshotTest(), new InPlaceEditViewTest(), @@ -385,6 +388,7 @@ private static boolean isJsSkippedScreenshotTest(String testName) { // on top of the current suite; revisit when that budget is bumped. || "SVGStaticScreenshotTest".equals(testName) || "SVGAnimatedScreenshotTest".equals(testName) + || "LottieAnimatedScreenshotTest".equals(testName) || "MainScreenScreenshotTest".equals(testName) || "SheetScreenshotTest".equals(testName) || "StatusBarTapDiagnosticScreenshotTest".equals(testName) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/LottieAnimatedScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/LottieAnimatedScreenshotTest.java new file mode 100644 index 0000000000..fe74ac033e --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/LottieAnimatedScreenshotTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + */ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Display; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.util.Resources; + +/// End-to-end test for the build-time Lottie transcoder. Mirrors +/// {@link SVGAnimatedScreenshotTest}: the source asset lands in +/// `src/main/css/`, the build-time transcoder lowers it into the SVG +/// pipeline, and the auto-generated SVGRegistry replaces the CSS-emitted +/// placeholder before this test runs. The clock is pinned by +/// {@link AbstractAnimationScreenshotTest} so the captured frame is +/// deterministic. +public class LottieAnimatedScreenshotTest extends AbstractAnimationScreenshotTest { + + private static final int ANIM_DURATION_MS = 1000; + + private Image spinner; + private Image pulse; + + @Override + public boolean shouldTakeScreenshot() { + return !"HTML5".equals(Display.getInstance().getPlatformName()); + } + + @Override + public boolean runTest() throws Exception { + if ("HTML5".equals(Display.getInstance().getPlatformName())) { + done(); + return true; + } + return super.runTest(); + } + + @Override + protected int getAnimationDurationMillis() { + return ANIM_DURATION_MS; + } + + @Override + protected void prepareCapture(int frameWidth, int frameHeight) { + super.prepareCapture(frameWidth, frameHeight); + Resources res = SVGStaticScreenshotTest.resolveGlobalResources(); + spinner = res == null ? null : res.getImage("lottie_spinner.json"); + pulse = res == null ? null : res.getImage("lottie_pulse.json"); + } + + @Override + protected void renderFrame(Graphics g, int width, int height, + double progress, int frameIndex) { + g.setColor(0xFFFFFF); + g.fillRect(0, 0, width, height); + + if (spinner == null || pulse == null) { + g.setColor(0xFF0000); + g.drawString("Lottie registry not installed", 10, 20); + return; + } + + int half = width / 2; + Image scaledSpinner = spinner.scaled(half, height); + Image scaledPulse = pulse.scaled(width - half, height); + g.drawImage(scaledSpinner, 0, 0); + g.drawImage(scaledPulse, half, 0); + } +} diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 9ca0667cea..9aee8d1d85 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -25,7 +25,7 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform - `references/java-api-subset.md` — How to inspect the supported Java API subset, IO (`Storage`, `FileSystemStorage`), networking (`ConnectionRequest`, `Rest`), concurrency, dates, SQLite. **Read this whenever the compliance check fails or when you reach for a `java.*` API.** - `references/ui-components.md` — Form, Toolbar, Container layouts (Border/Box/Flow/Grid/Layered), common components, navigation, dialogs. - `references/binding-and-validation.md` — `@Bindable` / `@Bind` annotation binding **and** annotation-driven validation (`@Required`, `@Length`, `@Regex`, `@Email`, `@Url`, `@Numeric`, `@ExistIn`, `@Validate`). Read this whenever you see one of those annotations, wire a model to a form, or need to gate a submit button on validation. -- `references/css.md` — CSS capabilities and (important) **limitations**. Selectors, supported properties, 9-patch borders, theme constants. +- `references/css.md` — CSS capabilities and (important) **limitations**. Selectors, supported properties, 9-patch borders, theme constants, and the build-time vector transcoder that compiles SVG and Lottie / Bodymovin JSON referenced via `url(...)` into `GeneratedSVGImage` subclasses. - `references/swing-comparison.md` — Mapping Swing concepts and code to Codename One. Read this when porting Swing code. - `references/html-css-cheatsheet.md` — Converting common HTML/CSS snippets to CN1 components + CSS. - `references/android-to-cn1.md` — Porting Android (XML + Kotlin/Java) screens to Codename One. diff --git a/scripts/initializr/common/src/main/resources/skill/references/css.md b/scripts/initializr/common/src/main/resources/skill/references/css.md index 3212364959..90786b6c9e 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/css.md +++ b/scripts/initializr/common/src/main/resources/skill/references/css.md @@ -313,6 +313,24 @@ Image home = Resources.getGlobalResources().getImage("home.svg"); For the full feature matrix and troubleshooting, point users to `docs/developer-guide/SVG-Transcoder.asciidoc`. +### Lottie — same pipeline, same URL syntax + +The `transcode-svg` goal also picks up Lottie / Bodymovin JSON (`.json`, `.lottie`). The file is lowered into the same SVG model and registered in the same `SVGRegistry`, so the developer-facing API is identical to the SVG path: + +```css +SpinnerStyle { background: url(spinner.json); cn1-svg-width: 12mm; cn1-svg-height: 12mm; bg-type: image_scaled_fit; } +``` + +```java +Image spin = Resources.getGlobalResources().getImage("spinner.json"); +``` + +Source directories: `common/src/main/lottie/` for Lottie, or drop next to `theme.css` like SVGs. + +**Lottie coverage**: shape layers (rect / ellipse / bezier path) with solid fills and strokes, layer transform (anchor / position / scale / rotation / opacity), animated rotation / position / scale collapsed to a first-to-last linear loop over the comp duration. Color / opacity animations, bezier easing, multi-keyframe paths (3+ keys), trim-path, gradients, text layers, image layers, expressions, and `.lottie` ZIP archives are **not** rendered — the parser drops them silently so a partially-supported file still produces a renderable class. + +For the full Lottie feature matrix and troubleshooting, point users to `docs/developer-guide/SVG-Transcoder.asciidoc`. + ### Custom TTF fonts Drop a `.ttf` (or `.otf`) under `common/src/main/css/fonts/`, then reference its **font name (not file name)** in `font-family`: diff --git a/scripts/ios/screenshots-metal/LottieAnimatedScreenshotTest.png b/scripts/ios/screenshots-metal/LottieAnimatedScreenshotTest.png new file mode 100644 index 0000000000..093bb36944 Binary files /dev/null and b/scripts/ios/screenshots-metal/LottieAnimatedScreenshotTest.png differ diff --git a/scripts/ios/screenshots/LottieAnimatedScreenshotTest.png b/scripts/ios/screenshots/LottieAnimatedScreenshotTest.png new file mode 100644 index 0000000000..68f26d03c2 Binary files /dev/null and b/scripts/ios/screenshots/LottieAnimatedScreenshotTest.png differ