Skip to content

Commit d5c66a0

Browse files
morethanwordsclaude
andcommitted
Update tlottie and ship its no-SIMD build
Repin the vendored renderer to dkaraush/tlottie cbfaf4fa, which now builds the same sources twice - with and without `-C target-feature=simd128`. Both artifacts are vendored, and lottieLoader hands the scalar one to browsers that fail IS_WEB_ASSEMBLY_SIMD_SUPPORTED, so stickers stay animated on Chrome 75-90, Firefox 79-88 and Safari 15-16.3 instead of freezing on their static thumbnail. Which build runs is now separate from whether lottie runs at all: apart from simd128 both need only bulk-memory, sign-extension and non-trapping float-to-int, so the NO_WASM gates moved to a new probe for exactly those (the shared validate() guard is factored out of both environment flags). The vendoring script takes the upstream examples/web directory, verifies each variant against its own source and stripped-output checksum, and refuses a binary whose target_features contradict the expected variant. Both builds render frame-identical output - the test asserts it across every bundled asset and all six Fitz tones, so the golden hashes are unchanged by the renderer update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 173f3c6 commit d5c66a0

9 files changed

Lines changed: 282 additions & 136 deletions

File tree

‎scripts/vendor-tlottie.mjs‎

Lines changed: 105 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,29 @@ import {readFileSync, writeFileSync} from 'node:fs';
33
import {dirname, resolve} from 'node:path';
44
import {fileURLToPath} from 'node:url';
55

6-
const SOURCE_COMMIT = '8efaf11d2113e5d2d0ef0a8b0b710703d296c153';
7-
const SOURCE_SHA256 = '60f37ce619fac19905b760efce96195c7f4c683b2bca7f85777a8825ab613e19';
8-
const OUTPUT_SHA256 = '1d959e0e5efccd470c1a1ce79bcacc066cfa38499237955b0ec382d00245f8ec';
9-
10-
const sourcePath = process.argv[2];
11-
if(!sourcePath) {
12-
throw new Error('Usage: node scripts/vendor-tlottie.mjs /path/to/tlottie/examples/web/tlottie.wasm');
6+
const SOURCE_COMMIT = 'cbfaf4fa180a74aec826ac2662e83d3ae0bbc560';
7+
8+
// Upstream builds the same sources twice: with and without `-C target-feature=simd128`.
9+
// Both variants are vendored so browsers without WebAssembly SIMD still get a renderer.
10+
const VARIANTS = [{
11+
name: 'tlottie.wasm',
12+
sourceSha256: '48e7ad6025cdae153214ea32ec4393c446475df7a3d5939d8cc286f7b9979248',
13+
outputSha256: '0cb9c73e2e184d3c3d2762d4f7c23ed1c993a76a9f0c10f1ea84883a4dc41801',
14+
simd: true
15+
}, {
16+
name: 'tlottie.nosimd.wasm',
17+
sourceSha256: '17bebc9128dcc3351a405a47192af7ee2c5d4653869fd76ef504c75681d242d7',
18+
outputSha256: '01e0d8359073259cb6aed1a61e97ceee964419cdfecb2c8849a8deae6b6b934c',
19+
simd: false
20+
}];
21+
22+
const sourceDir = process.argv[2];
23+
if(!sourceDir) {
24+
throw new Error('Usage: node scripts/vendor-tlottie.mjs /path/to/tlottie/examples/web');
1325
}
1426

1527
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
1628

17-
const source = readFileSync(sourcePath);
18-
const sourceHash = sha256(source);
19-
if(sourceHash !== SOURCE_SHA256) {
20-
throw new Error(`Expected tlottie ${SOURCE_COMMIT} (${SOURCE_SHA256}), got ${sourceHash}`);
21-
}
22-
2329
const readVarUint32 = (bytes, start) => {
2430
let offset = start;
2531
let value = 0;
@@ -38,50 +44,99 @@ const readVarUint32 = (bytes, start) => {
3844
throw new Error('Invalid WebAssembly varuint32');
3945
};
4046

41-
// Drop only debug/name custom sections from the upstream web artifact;
42-
// executable sections and target metadata remain byte-for-byte.
43-
const chunks = [source.subarray(0, 8)];
44-
let offset = 8;
45-
while(offset < source.length) {
46-
const sectionStart = offset;
47-
const sectionId = source[offset++];
48-
const length = readVarUint32(source, offset);
49-
const payloadStart = length.offset;
50-
const sectionEnd = payloadStart + length.value;
51-
if(sectionEnd > source.length) {
52-
throw new Error('Invalid WebAssembly section length');
53-
}
47+
const readSections = (bytes) => {
48+
const sections = [];
49+
let offset = 8;
50+
while(offset < bytes.length) {
51+
const sectionStart = offset;
52+
const sectionId = bytes[offset++];
53+
const length = readVarUint32(bytes, offset);
54+
const payloadStart = length.offset;
55+
const sectionEnd = payloadStart + length.value;
56+
if(sectionEnd > bytes.length) {
57+
throw new Error('Invalid WebAssembly section length');
58+
}
59+
60+
let name;
61+
if(sectionId === 0) {
62+
const nameLength = readVarUint32(bytes, payloadStart);
63+
const nameEnd = nameLength.offset + nameLength.value;
64+
if(nameEnd > sectionEnd) {
65+
throw new Error('Invalid WebAssembly custom section name');
66+
}
5467

55-
let keep = true;
56-
if(sectionId === 0) {
57-
const nameLength = readVarUint32(source, payloadStart);
58-
const nameEnd = nameLength.offset + nameLength.value;
59-
if(nameEnd > sectionEnd) {
60-
throw new Error('Invalid WebAssembly custom section name');
68+
name = bytes.subarray(nameLength.offset, nameEnd).toString();
6169
}
6270

63-
const name = source.subarray(nameLength.offset, nameEnd).toString();
64-
keep = !name.startsWith('.debug_') && name !== 'name';
71+
sections.push({sectionId, name, payloadStart, sectionStart, sectionEnd});
72+
offset = sectionEnd;
6573
}
6674

67-
if(keep) {
68-
chunks.push(source.subarray(sectionStart, sectionEnd));
69-
}
75+
return sections;
76+
};
7077

71-
offset = sectionEnd;
72-
}
78+
// rustc records the enabled target features; it is the only place the two variants
79+
// are told apart, so the vendored binary has to keep it and match the requested build.
80+
const readTargetFeatures = (bytes, section) => {
81+
const features = [];
82+
let offset = section.payloadStart;
83+
const name = readVarUint32(bytes, offset);
84+
offset = name.offset + name.value;
85+
86+
const count = readVarUint32(bytes, offset);
87+
offset = count.offset;
88+
for(let i = 0; i < count.value; ++i) {
89+
const prefix = String.fromCharCode(bytes[offset++]);
90+
const length = readVarUint32(bytes, offset);
91+
features.push(prefix + bytes.subarray(length.offset, length.offset + length.value).toString());
92+
offset = length.offset + length.value;
93+
}
7394

74-
const output = Buffer.concat(chunks);
75-
new WebAssembly.Module(output);
76-
const outputHash = sha256(output);
77-
if(outputHash !== OUTPUT_SHA256) {
78-
throw new Error(`Expected vendored tlottie ${OUTPUT_SHA256}, got ${outputHash}`);
79-
}
95+
return features;
96+
};
8097

8198
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
82-
const outputPath = resolve(rootDir, 'src/vendor/tlottie/tlottie.wasm');
83-
writeFileSync(outputPath, output);
8499

85-
console.log(`Vendored tlottie ${SOURCE_COMMIT}`);
86-
console.log(`source: ${source.length} bytes, ${sourceHash}`);
87-
console.log(`output: ${output.length} bytes, ${outputHash}`);
100+
for(const variant of VARIANTS) {
101+
const sourcePath = resolve(sourceDir, variant.name);
102+
const source = readFileSync(sourcePath);
103+
const sourceHash = sha256(source);
104+
if(sourceHash !== variant.sourceSha256) {
105+
throw new Error(`Expected ${variant.name} of tlottie ${SOURCE_COMMIT} (${variant.sourceSha256}), got ${sourceHash}`);
106+
}
107+
108+
const sections = readSections(source);
109+
const targetFeatures = sections.find((section) => section.name === 'target_features');
110+
if(!targetFeatures) {
111+
throw new Error(`${variant.name} has no target_features section`);
112+
}
113+
114+
const hasSimd = readTargetFeatures(source, targetFeatures).includes('+simd128');
115+
if(hasSimd !== variant.simd) {
116+
throw new Error(`${variant.name} ${hasSimd ? 'requires' : 'does not require'} simd128, which contradicts the expected variant`);
117+
}
118+
119+
// Drop only debug/name custom sections from the upstream web artifact;
120+
// executable sections and target metadata remain byte-for-byte.
121+
const chunks = [source.subarray(0, 8)];
122+
for(const section of sections) {
123+
const keep = section.sectionId !== 0 ||
124+
(!section.name.startsWith('.debug_') && section.name !== 'name');
125+
if(keep) {
126+
chunks.push(source.subarray(section.sectionStart, section.sectionEnd));
127+
}
128+
}
129+
130+
const output = Buffer.concat(chunks);
131+
new WebAssembly.Module(output);
132+
const outputHash = sha256(output);
133+
if(outputHash !== variant.outputSha256) {
134+
throw new Error(`Expected vendored ${variant.name} ${variant.outputSha256}, got ${outputHash}`);
135+
}
136+
137+
writeFileSync(resolve(rootDir, 'src/vendor/tlottie', variant.name), output);
138+
139+
console.log(`Vendored ${variant.name} of tlottie ${SOURCE_COMMIT}`);
140+
console.log(`source: ${source.length} bytes, ${sourceHash}`);
141+
console.log(`output: ${output.length} bytes, ${outputHash}`);
142+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import isWebAssemblyFeatureSupported from '@environment/webAssemblyFeatureSupport';
2+
3+
// A minimal module using every post-MVP feature the vendored tlottie builds need:
4+
// bulk-memory (memory.copy), non-trapping float-to-int (i32.trunc_sat_f32_s) and
5+
// sign-extension (i32.extend8_s). Both the SIMD and the no-SIMD build need exactly
6+
// these, so a browser failing here cannot run either one.
7+
const BASELINE_TEST_MODULE = new Uint8Array([
8+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
9+
0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f,
10+
0x03, 0x02, 0x01, 0x00,
11+
0x05, 0x03, 0x01, 0x00, 0x01,
12+
0x0a, 0x16, 0x01, 0x14, 0x00, 0x41, 0x00, 0x41, 0x00, 0x41, 0x00, 0xfc, 0x0a, 0x00, 0x00,
13+
0x43, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x00, 0xc0, 0x0b
14+
]);
15+
16+
const IS_WEB_ASSEMBLY_BASELINE_SUPPORTED = isWebAssemblyFeatureSupported(BASELINE_TEST_MODULE);
17+
18+
export default IS_WEB_ASSEMBLY_BASELINE_SUPPORTED;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import IS_WEB_ASSEMBLY_SUPPORTED from '@environment/webAssemblySupport';
2+
3+
// Feature probes are minimal modules built from the opcodes they test for.
4+
// validate() stays synchronous and does not compile or execute application code.
5+
export default function isWebAssemblyFeatureSupported(testModule: BufferSource) {
6+
return IS_WEB_ASSEMBLY_SUPPORTED &&
7+
typeof(WebAssembly.validate) === 'function' &&
8+
WebAssembly.validate(testModule);
9+
}
Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
1-
import IS_WEB_ASSEMBLY_SUPPORTED from '@environment/webAssemblySupport';
1+
import isWebAssemblyFeatureSupported from '@environment/webAssemblyFeatureSupport';
22

3-
// A minimal module returning v128 and using SIMD opcodes. validate() stays
4-
// synchronous and does not compile or execute application code.
3+
// A minimal module returning v128 and using SIMD opcodes.
54
const SIMD_TEST_MODULE = new Uint8Array([
65
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
76
0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7b,
87
0x03, 0x02, 0x01, 0x00,
98
0x0a, 0x0a, 0x01, 0x08, 0x00, 0x41, 0x00, 0xfd, 0x0f, 0xfd, 0x62, 0x0b
109
]);
1110

12-
const IS_WEB_ASSEMBLY_SIMD_SUPPORTED = IS_WEB_ASSEMBLY_SUPPORTED &&
13-
typeof(WebAssembly.validate) === 'function' &&
14-
WebAssembly.validate(SIMD_TEST_MODULE);
11+
const IS_WEB_ASSEMBLY_SIMD_SUPPORTED = isWebAssemblyFeatureSupported(SIMD_TEST_MODULE);
1512

1613
export default IS_WEB_ASSEMBLY_SIMD_SUPPORTED;

‎src/lib/lottie/lottieLoader.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,22 @@ import LottiePlayer, {LottieOptions} from '@lib/lottie/lottiePlayer';
88
import blobConstruct from '@helpers/blob/blobConstruct';
99
import apiManagerProxy from '@lib/apiManagerProxy';
1010
import IS_WEB_ASSEMBLY_SIMD_SUPPORTED from '@environment/webAssemblySimdSupport';
11+
import IS_WEB_ASSEMBLY_BASELINE_SUPPORTED from '@environment/webAssemblyBaselineSupport';
1112
import makeError from '@helpers/makeError';
1213
import rootScope from '@lib/rootScope';
1314
import toArray from '@helpers/array/toArray';
1415
import lottieMessagePort from '@lib/lottie/lottieMessagePort';
1516
import SHOULD_RENDER_OFFSCREEN from '@lib/lottie/shouldRenderOffscreen';
1617
import tlottieWasmAssetUrl from '@vendor/tlottie/tlottie.wasm?url';
18+
import tlottieNoSimdWasmAssetUrl from '@vendor/tlottie/tlottie.nosimd.wasm?url';
1719

18-
const TLOTTIE_WASM_URL = new URL(tlottieWasmAssetUrl, location.href).href;
20+
// tlottie builds the same renderer twice - the SIMD build is the fast path, the scalar
21+
// one keeps stickers animated on browsers without WebAssembly SIMD (Chrome 75-90,
22+
// Firefox 79-88, Safari 15-16.3). Both render frame-identical output.
23+
const TLOTTIE_WASM_URL = new URL(
24+
IS_WEB_ASSEMBLY_SIMD_SUPPORTED ? tlottieWasmAssetUrl : tlottieNoSimdWasmAssetUrl,
25+
location.href
26+
).href;
1927

2028
export type LottieAssetName =
2129
| 'EmptyFolder'
@@ -116,7 +124,7 @@ export class LottieLoader {
116124
}
117125

118126
public loadLottieWorkers() {
119-
if(!IS_WEB_ASSEMBLY_SIMD_SUPPORTED) {
127+
if(!IS_WEB_ASSEMBLY_BASELINE_SUPPORTED) {
120128
// This method is also used as a fire-and-forget preload. Unsupported
121129
// browsers should stay on their static fallback without an unhandled
122130
// rejection; actual animation loads still reject with NO_WASM below.
@@ -164,7 +172,7 @@ export class LottieLoader {
164172
public loadAnimationDataFromURL(url: string, method: 'json'): Promise<any>;
165173
public loadAnimationDataFromURL(url: string, method?: 'blob'): Promise<Blob>;
166174
public loadAnimationDataFromURL(url: string, method: 'json' | 'blob' = 'blob'): Promise<Blob | any> {
167-
if(!IS_WEB_ASSEMBLY_SIMD_SUPPORTED) {
175+
if(!IS_WEB_ASSEMBLY_BASELINE_SUPPORTED) {
168176
return Promise.reject(makeError('NO_WASM'));
169177
}
170178

@@ -223,7 +231,7 @@ export class LottieLoader {
223231
}
224232

225233
public async loadAnimationWorker(params: LottieOptions): Promise<LottiePlayer> {
226-
if(!IS_WEB_ASSEMBLY_SIMD_SUPPORTED) {
234+
if(!IS_WEB_ASSEMBLY_BASELINE_SUPPORTED) {
227235
throw makeError('NO_WASM');
228236
}
229237

@@ -309,7 +317,7 @@ export class LottieLoader {
309317
}
310318

311319
public destroyWorkers() {
312-
if(!IS_WEB_ASSEMBLY_SIMD_SUPPORTED) {
320+
if(!IS_WEB_ASSEMBLY_BASELINE_SUPPORTED) {
313321
return;
314322
}
315323

0 commit comments

Comments
 (0)