@flighthq/swf 0.3.0 → 0.3.1-next.320.fe56fc2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract.d.ts +1 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +1 -0
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/swfDocument.d.ts +5 -5
- package/dist/swfDocument.d.ts.map +1 -1
- package/dist/swfDocument.js +359 -102
- package/dist/swfDocument.js.map +1 -1
- package/dist/swfFilter.d.ts +2 -2
- package/dist/swfFilter.d.ts.map +1 -1
- package/dist/swfFilter.js +24 -2
- package/dist/swfFilter.js.map +1 -1
- package/dist/swfFrameAction.d.ts +2 -2
- package/dist/swfFrameAction.d.ts.map +1 -1
- package/dist/swfFrameAction.js +13 -6
- package/dist/swfFrameAction.js.map +1 -1
- package/dist/swfFrameActionTestHelper.d.ts +2 -0
- package/dist/swfFrameActionTestHelper.d.ts.map +1 -0
- package/dist/swfFrameActionTestHelper.js +99 -0
- package/dist/swfFrameActionTestHelper.js.map +1 -0
- package/dist/swfImageDecoder.d.ts +4 -0
- package/dist/swfImageDecoder.d.ts.map +1 -0
- package/dist/swfImageDecoder.js +21 -0
- package/dist/swfImageDecoder.js.map +1 -0
- package/dist/swfMorphShape.d.ts +2 -1
- package/dist/swfMorphShape.d.ts.map +1 -1
- package/dist/swfMorphShape.js +18 -7
- package/dist/swfMorphShape.js.map +1 -1
- package/dist/swfText.d.ts +2 -2
- package/dist/swfText.d.ts.map +1 -1
- package/dist/swfText.js +15 -2
- package/dist/swfText.js.map +1 -1
- package/package.json +24 -22
- package/src/swfDocument.test.ts +1527 -163
- package/src/swfFilter.test.ts +53 -0
- package/src/swfFrameAction.test.ts +26 -97
- package/src/swfImageDecoder.test.ts +53 -0
- package/src/swfImageResources.test.ts +14 -17
- package/src/swfMorphShape.test.ts +18 -0
package/dist/swfDocument.js
CHANGED
|
@@ -4,17 +4,18 @@ import { createClipRegionFromContours, createClipRegionFromPath } from '@flighth
|
|
|
4
4
|
import { getDecompressor } from '@flighthq/compression/contract';
|
|
5
5
|
import { createMatrix, inverseMatrix, matrixTransformPointXY, multiplyMatrix } from '@flighthq/geometry/contract';
|
|
6
6
|
import { createEmbeddedImageResourceReference } from '@flighthq/image/contract';
|
|
7
|
+
import { reportImportDiagnostic } from '@flighthq/importdiagnostics/contract';
|
|
7
8
|
import { addMovieClipFrameScript, createMovieClip, setMovieClipSource } from '@flighthq/movieclip/contract';
|
|
8
9
|
import { addNodeChild, addNodeOrderListEntry, applyNodeOrderList, clearNodeOrderList, createNodeOrderList, getNodeRuntime, invalidateNodeAppearance, invalidateNodeLocalBounds, removeNodeChild, setNodeColorAdjustments, setNodeLocalMatrix, } from '@flighthq/node/contract';
|
|
9
10
|
import { createScene2DDocument, createScene2DSlotReference, registerScene2DDocumentImporter, } from '@flighthq/scene2d-resources/contract';
|
|
10
11
|
import { createDisplayObject, createSprite, setNode2DClip } from '@flighthq/scene2d/contract';
|
|
11
12
|
import { copyShapeCommands, createScale9Shape, createShape, getShapeFillRegions, setMorphShapeProgress, } from '@flighthq/shape/contract';
|
|
12
|
-
import { createSampler, createTexture
|
|
13
|
-
import { AdvancedBlendMode, BlendMode, Compression, MorphShapeKind, TimelineAudioCueKind, TimelineStreamAudioCueKind, } from '@flighthq/types/contract';
|
|
14
|
-
import { createSwfLosslessBitmap } from './swfBitmap';
|
|
13
|
+
import { createSampler, createTexture } from '@flighthq/texture/contract';
|
|
14
|
+
import { AdvancedBlendMode, BlendMode, Compression, ImportDiagnosticSeverity, MorphShapeKind, TimelineAudioCueKind, TimelineStreamAudioCueKind, } from '@flighthq/types/contract';
|
|
15
15
|
import { readSwfEditTextFactory } from './swfEditText';
|
|
16
16
|
import { readSwfFilterList } from './swfFilter';
|
|
17
17
|
import { readSwfAbcFrameScripts, readSwfFrameActions } from './swfFrameAction';
|
|
18
|
+
import { SWF_LOSSLESS_ALPHA_MIME_TYPE, SWF_LOSSLESS_MIME_TYPE } from './swfImageDecoder';
|
|
18
19
|
import { createSwfMorphShape } from './swfMorphShape';
|
|
19
20
|
import { SwfReader } from './swfReader';
|
|
20
21
|
import { createSwfShape } from './swfShape';
|
|
@@ -22,23 +23,23 @@ import { createSwfTextShape, readSwfFontGlyphOutlineSource } from './swfText';
|
|
|
22
23
|
// Recovers every embedded DefineFont/2/3 as the generic, glyph-index-keyed outline seam. The map key
|
|
23
24
|
// is the SWF character id used by DefineText and DefineEditText. This is a separate parse entry from
|
|
24
25
|
// Scene2D construction so callers that only need embedded fonts do not have to retain a document.
|
|
25
|
-
export function createGlyphOutlineSourcesFromSwf(source) {
|
|
26
|
-
const file = readSwfFile(source);
|
|
26
|
+
export function createGlyphOutlineSourcesFromSwf(source, diagnostics) {
|
|
27
|
+
const file = readSwfFile(source, diagnostics);
|
|
27
28
|
return file === null ? null : new Map(file.parsed.fontOutlineSources);
|
|
28
29
|
}
|
|
29
30
|
// The document alone, for a caller that wants the graph and nothing else — the importer registry among
|
|
30
31
|
// them. A file whose placements carry an advanced blend or a filter list still imports fully here; what
|
|
31
32
|
// it loses is the report of them, which is what createScene2DImportFromSwf returns.
|
|
32
|
-
export function createScene2DFromSwf(source) {
|
|
33
|
-
return createScene2DImportFromSwf(source)?.document ?? null;
|
|
33
|
+
export function createScene2DFromSwf(source, diagnostics) {
|
|
34
|
+
return createScene2DImportFromSwf(source, diagnostics)?.document ?? null;
|
|
34
35
|
}
|
|
35
36
|
// The full import: the document, plus the placement appearance no node can carry. SWF puts a blend mode
|
|
36
37
|
// and a filter list on the same record as the matrix, and Flight expresses neither on a node — an
|
|
37
38
|
// advanced blend needs a BlendEffect and an effect is a descriptor a caller runs explicitly, since
|
|
38
39
|
// `displayObject.filters` is an anti-goal. Both therefore travel beside the document instead of being
|
|
39
40
|
// dropped at the seam or silently flattened onto a node.
|
|
40
|
-
export function createScene2DImportFromSwf(source) {
|
|
41
|
-
const file = readSwfFile(source);
|
|
41
|
+
export function createScene2DImportFromSwf(source, diagnostics) {
|
|
42
|
+
const file = readSwfFile(source, diagnostics);
|
|
42
43
|
if (file === null)
|
|
43
44
|
return null;
|
|
44
45
|
const { frameRate, parsed, stageBounds } = file;
|
|
@@ -46,14 +47,16 @@ export function createScene2DImportFromSwf(source) {
|
|
|
46
47
|
const instantiation = {
|
|
47
48
|
activeSymbols: new Set(),
|
|
48
49
|
appearances: [],
|
|
50
|
+
diagnostics,
|
|
49
51
|
frameRate: frameRate > 0 ? frameRate : null,
|
|
50
52
|
resolvingBounds: new Set(),
|
|
51
53
|
resolvedBounds: new Map(),
|
|
52
54
|
};
|
|
53
55
|
const root = createSwfTimelineNode(parsed.timeline, stageBounds, parsed, slots, instantiation, 0);
|
|
54
|
-
if (root === null)
|
|
56
|
+
if (root === null) {
|
|
57
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.timeline-instantiation-failed', 'createScene2DImportFromSwf');
|
|
55
58
|
return null;
|
|
56
|
-
|
|
59
|
+
}
|
|
57
60
|
return {
|
|
58
61
|
appearances: instantiation.appearances,
|
|
59
62
|
document: createScene2DDocument(root, slots, 'swf', parsed.backgroundColor, createSwfImageResources(parsed), createSwfAudioResources(parsed)),
|
|
@@ -70,8 +73,8 @@ export function createScene2DImportFromSwf(source) {
|
|
|
70
73
|
// Anything less would hand back artwork whose bitmaps could never be paired with their pixels, since
|
|
71
74
|
// each call parses afresh and its Textures are its own. `backgroundColor` stays null: the stage colour
|
|
72
75
|
// belongs to the stage, and a symbol instantiated into someone else's scene is not it.
|
|
73
|
-
export function createScene2DSymbolFromSwf(source, linkageName) {
|
|
74
|
-
const file = readSwfFile(source);
|
|
76
|
+
export function createScene2DSymbolFromSwf(source, linkageName, diagnostics) {
|
|
77
|
+
const file = readSwfFile(source, diagnostics);
|
|
75
78
|
if (file === null)
|
|
76
79
|
return null;
|
|
77
80
|
const { frameRate, parsed } = file;
|
|
@@ -80,12 +83,19 @@ export function createScene2DSymbolFromSwf(source, linkageName) {
|
|
|
80
83
|
if (name === linkageName)
|
|
81
84
|
characterId = id;
|
|
82
85
|
}
|
|
83
|
-
|
|
86
|
+
// A linkage name nothing exports is the caller naming a symbol this file does not have — worth telling
|
|
87
|
+
// them apart from a file that failed to parse, since both come back null.
|
|
88
|
+
if (characterId < 0) {
|
|
89
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.unknown-linkage-name', 'createScene2DSymbolFromSwf', {
|
|
90
|
+
linkageName,
|
|
91
|
+
});
|
|
84
92
|
return null;
|
|
93
|
+
}
|
|
85
94
|
const slots = [];
|
|
86
95
|
const instantiation = {
|
|
87
96
|
activeSymbols: new Set(),
|
|
88
97
|
appearances: [],
|
|
98
|
+
diagnostics,
|
|
89
99
|
frameRate: frameRate > 0 ? frameRate : null,
|
|
90
100
|
resolvingBounds: new Set(),
|
|
91
101
|
resolvedBounds: new Map(),
|
|
@@ -93,7 +103,6 @@ export function createScene2DSymbolFromSwf(source, linkageName) {
|
|
|
93
103
|
const root = createSwfSymbolNode(parsed, characterId, slots, instantiation);
|
|
94
104
|
if (root === null)
|
|
95
105
|
return null;
|
|
96
|
-
fillSwfLosslessBitmapTextures(parsed);
|
|
97
106
|
return createScene2DDocument(root, slots, 'swf', null, createSwfImageResources(parsed), createSwfAudioResources(parsed));
|
|
98
107
|
}
|
|
99
108
|
// Every linkage name the file exported, whether or not the symbol was ever placed. Pair with
|
|
@@ -105,58 +114,51 @@ export function readSwfExportedSymbolNames(source) {
|
|
|
105
114
|
export function registerSwfScene2DDocumentImporter(registry) {
|
|
106
115
|
registerScene2DDocumentImporter(registry, 'swf', matchesSwfDocument, (source) => createScene2DFromSwf(source));
|
|
107
116
|
}
|
|
108
|
-
function readSwfFile(source) {
|
|
109
|
-
const uncompressed = uncompressSwfSource(source);
|
|
117
|
+
function readSwfFile(source, diagnostics) {
|
|
118
|
+
const uncompressed = uncompressSwfSource(source, diagnostics);
|
|
110
119
|
if (uncompressed === null)
|
|
111
120
|
return null;
|
|
121
|
+
// Every rejection below loses the WHOLE document, and each has a distinct cause. Without a report per
|
|
122
|
+
// cause the caller receives one null from `createScene2DFromSwf` and cannot tell an unregistered
|
|
123
|
+
// decompressor — which IS reported, upstream — from a truncated header, which was not.
|
|
112
124
|
const header = new SwfReader(uncompressed, 0, uncompressed.length);
|
|
113
125
|
const signature = header.readUint8();
|
|
114
126
|
if (signature !== FWS_SIGNATURE || header.readUint8() !== W_SIGNATURE || header.readUint8() !== S_SIGNATURE) {
|
|
127
|
+
// Distinct from the container's `swf.invalid-signature`: that one reads the ORIGINAL bytes, this one
|
|
128
|
+
// reads the decompressed body, whose header the decompressor rewrote. NOTHING CAN REACH THIS TODAY —
|
|
129
|
+
// an FWS container is returned unchanged and every compressed path writes `FWS_SIGNATURE` into byte 0,
|
|
130
|
+
// while bytes 1 and 2 were validated before either. The report stays because unreachable-by-
|
|
131
|
+
// construction is a property of today's two container paths, not of the format: a third path, or a
|
|
132
|
+
// decompressor that stops rewriting the header, makes it reachable, and silence would return with it.
|
|
133
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.uncompressed-signature-invalid', 'readSwfFile');
|
|
115
134
|
return null;
|
|
116
135
|
}
|
|
117
136
|
const version = header.readUint8();
|
|
118
137
|
const fileLength = header.readUint32();
|
|
119
|
-
if (!header.valid || version === 0 || fileLength < MIN_SWF_LENGTH || fileLength > uncompressed.length)
|
|
138
|
+
if (!header.valid || version === 0 || fileLength < MIN_SWF_LENGTH || fileLength > uncompressed.length) {
|
|
139
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.header-fields-invalid', 'readSwfFile', {
|
|
140
|
+
declaredLength: fileLength,
|
|
141
|
+
available: uncompressed.length,
|
|
142
|
+
version,
|
|
143
|
+
});
|
|
120
144
|
return null;
|
|
145
|
+
}
|
|
121
146
|
const body = new SwfReader(uncompressed, SWF_PREFIX_LENGTH, fileLength);
|
|
122
147
|
const stageBounds = readSwfRectangle(body);
|
|
123
|
-
if (stageBounds === null)
|
|
148
|
+
if (stageBounds === null) {
|
|
149
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.stage-bounds-unreadable', 'readSwfFile');
|
|
124
150
|
return null;
|
|
151
|
+
}
|
|
125
152
|
// Header FrameRate is 8.8 fixed and governs every timeline in the file; the authored FrameCount that
|
|
126
153
|
// follows it is advisory, so the real root frame count comes from the ShowFrame tags themselves.
|
|
127
154
|
const frameRate = body.readUint16() / FIXED_8_8_ONE;
|
|
128
155
|
body.readUint16();
|
|
129
|
-
if (!body.valid)
|
|
156
|
+
if (!body.valid) {
|
|
157
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.header-truncated', 'readSwfFile');
|
|
130
158
|
return null;
|
|
131
|
-
const parsed = readSwfTags(body);
|
|
132
|
-
return parsed === null ? null : { frameRate, parsed, stageBounds };
|
|
133
|
-
}
|
|
134
|
-
// Fills in the pixels this package can unpack on its own. A lossless definition is not an image file — it
|
|
135
|
-
// is a raw raster plus zlib — so no generic decoder reaches it, but both halves needed to read it are
|
|
136
|
-
// already here: the shared decompressor the caller registered, and the layout knowledge in swfBitmap. Its
|
|
137
|
-
// pixels therefore resolve at import, and it earns no image resource because there is nothing left to
|
|
138
|
-
// load. The encoded formats leave as references and decode through @flighthq/image later.
|
|
139
|
-
//
|
|
140
|
-
// Only characters something actually samples are unpacked, so a library full of unused bitmaps costs
|
|
141
|
-
// nothing. That is the whole reason this walks the texture map rather than the payload map.
|
|
142
|
-
function fillSwfLosslessBitmapTextures(parsed) {
|
|
143
|
-
for (const [characterId, variants] of parsed.imageTextures) {
|
|
144
|
-
if (!isSwfLosslessImage(parsed.images.get(characterId)))
|
|
145
|
-
continue;
|
|
146
|
-
const image = parsed.images.get(characterId);
|
|
147
|
-
if (image === undefined)
|
|
148
|
-
continue;
|
|
149
|
-
const bitmap = createSwfLosslessBitmap(image.bytes, image.mimeType === SWF_LOSSLESS_ALPHA_MIME_TYPE);
|
|
150
|
-
if (bitmap === null)
|
|
151
|
-
continue;
|
|
152
|
-
for (const texture of variants.values())
|
|
153
|
-
setTextureSource(texture, bitmap);
|
|
154
159
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (image === undefined)
|
|
158
|
-
return false;
|
|
159
|
-
return image.mimeType === SWF_LOSSLESS_MIME_TYPE || image.mimeType === SWF_LOSSLESS_ALPHA_MIME_TYPE;
|
|
160
|
+
const parsed = readSwfTags(body, diagnostics);
|
|
161
|
+
return parsed === null ? null : { frameRate, parsed, stageBounds };
|
|
160
162
|
}
|
|
161
163
|
// Presents any container form as the uncompressed bytes the rest of the importer reads. `FWS` is already
|
|
162
164
|
// that and is returned as-is, with no copy. `CWS` and `ZWS` compress everything after the 8-byte header,
|
|
@@ -164,32 +166,60 @@ function isSwfLosslessImage(image) {
|
|
|
164
166
|
// to `FWS` — the declared length already counts uncompressed bytes, so it carries over untouched.
|
|
165
167
|
// Compression the caller has not registered a decompressor for is reported as the document's null
|
|
166
168
|
// sentinel, exactly like a malformed file: the bytes are unreadable either way.
|
|
167
|
-
function uncompressSwfSource(source) {
|
|
168
|
-
if (source.length < SWF_PREFIX_LENGTH || source[1] !== W_SIGNATURE || source[2] !== S_SIGNATURE)
|
|
169
|
+
function uncompressSwfSource(source, diagnostics) {
|
|
170
|
+
if (source.length < SWF_PREFIX_LENGTH || source[1] !== W_SIGNATURE || source[2] !== S_SIGNATURE) {
|
|
171
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.invalid-signature', 'uncompressSwfSource', {
|
|
172
|
+
length: source.length,
|
|
173
|
+
});
|
|
169
174
|
return null;
|
|
175
|
+
}
|
|
170
176
|
const signature = source[0];
|
|
171
177
|
if (signature === FWS_SIGNATURE)
|
|
172
178
|
return source;
|
|
173
179
|
const compression = signature === CWS_SIGNATURE ? Compression.Deflate : signature === ZWS_SIGNATURE ? Compression.Lzma : null;
|
|
174
|
-
if (compression === null)
|
|
180
|
+
if (compression === null) {
|
|
181
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.unknown-container', 'uncompressSwfSource', {
|
|
182
|
+
signature,
|
|
183
|
+
});
|
|
175
184
|
return null;
|
|
185
|
+
}
|
|
176
186
|
const decompress = getDecompressor(compression);
|
|
177
|
-
|
|
187
|
+
// Distinct from a malformed body on purpose. Both return the same null sentinel, but a caller that
|
|
188
|
+
// never registered a decompressor has a file it could read after one registration, while a corrupt
|
|
189
|
+
// stream is unreadable however the caller is configured — and only the crumb can tell them apart.
|
|
190
|
+
if (decompress === null) {
|
|
191
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.no-decompressor-registered', 'uncompressSwfSource', { compression });
|
|
178
192
|
return null;
|
|
193
|
+
}
|
|
179
194
|
const header = new SwfReader(source, 0, SWF_PREFIX_LENGTH);
|
|
180
195
|
header.readUint32();
|
|
181
196
|
const fileLength = header.readUint32();
|
|
182
|
-
if (fileLength < MIN_SWF_LENGTH)
|
|
197
|
+
if (fileLength < MIN_SWF_LENGTH) {
|
|
198
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.declared-length-too-small', 'uncompressSwfSource', {
|
|
199
|
+
fileLength,
|
|
200
|
+
});
|
|
183
201
|
return null;
|
|
202
|
+
}
|
|
184
203
|
// LZMA puts a compressed length and the 5 property bytes between the header and its stream; zlib starts
|
|
185
204
|
// its stream immediately. Either way the decompressor receives the stream itself.
|
|
186
205
|
const bodyLength = fileLength - SWF_PREFIX_LENGTH;
|
|
187
206
|
const streamStart = compression === Compression.Lzma ? SWF_LZMA_PREFIX_LENGTH : SWF_PREFIX_LENGTH;
|
|
188
|
-
if (streamStart > source.length)
|
|
207
|
+
if (streamStart > source.length) {
|
|
208
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.truncated-container', 'uncompressSwfSource', {
|
|
209
|
+
length: source.length,
|
|
210
|
+
streamStart,
|
|
211
|
+
});
|
|
189
212
|
return null;
|
|
213
|
+
}
|
|
190
214
|
const body = decompress(source.subarray(streamStart), bodyLength);
|
|
191
|
-
if (body === null || body.length < bodyLength)
|
|
215
|
+
if (body === null || body.length < bodyLength) {
|
|
216
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.decompression-failed', 'uncompressSwfSource', {
|
|
217
|
+
compression,
|
|
218
|
+
expected: bodyLength,
|
|
219
|
+
received: body === null ? -1 : body.length,
|
|
220
|
+
});
|
|
192
221
|
return null;
|
|
222
|
+
}
|
|
193
223
|
const uncompressed = new Uint8Array(SWF_PREFIX_LENGTH + bodyLength);
|
|
194
224
|
uncompressed.set(source.subarray(0, SWF_PREFIX_LENGTH));
|
|
195
225
|
uncompressed[0] = FWS_SIGNATURE;
|
|
@@ -229,7 +259,7 @@ function populateSwfTimelineNode(clip, timeline, parsed, slots, state, depth) {
|
|
|
229
259
|
const clips = new Map();
|
|
230
260
|
for (const frame of timeline.frames) {
|
|
231
261
|
const ordered = [...frame.values()].sort(compareSwfPlacementDepth);
|
|
232
|
-
frames.push(buildSwfFrameEntries(ordered, parsed, clips));
|
|
262
|
+
frames.push(buildSwfFrameEntries(ordered, parsed, clips, state.diagnostics));
|
|
233
263
|
for (const placement of ordered) {
|
|
234
264
|
const key = createSwfInstanceKey(placement);
|
|
235
265
|
if (nodes.has(key))
|
|
@@ -268,7 +298,7 @@ function populateSwfTimelineNode(clip, timeline, parsed, slots, state, depth) {
|
|
|
268
298
|
const target = scale9 !== null
|
|
269
299
|
? scale9
|
|
270
300
|
: editText !== undefined
|
|
271
|
-
? createSwfEditTextTarget(editText, parsed, targetBounds)
|
|
301
|
+
? createSwfEditTextTarget(editText, parsed, targetBounds, state.diagnostics)
|
|
272
302
|
: image !== undefined
|
|
273
303
|
? createSwfTexturedSprite(acquireSwfImageTexture(parsed, placement.characterId, false, true), targetBounds)
|
|
274
304
|
: video !== undefined
|
|
@@ -291,7 +321,7 @@ function populateSwfTimelineNode(clip, timeline, parsed, slots, state, depth) {
|
|
|
291
321
|
}
|
|
292
322
|
}
|
|
293
323
|
}
|
|
294
|
-
collectSwfNodeAppearances(frames, nodes, state.appearances);
|
|
324
|
+
collectSwfNodeAppearances(frames, nodes, state.appearances, state.diagnostics);
|
|
295
325
|
setMovieClipSource(clip, createSwfTimelineSource(frames, nodes, timeline.labels, timeline.cues, state.frameRate));
|
|
296
326
|
// Frame scripts attach after the source, so the clip already knows how many frames it has when a
|
|
297
327
|
// recognized command addresses one.
|
|
@@ -307,15 +337,24 @@ function populateSwfTimelineNode(clip, timeline, parsed, slots, state, depth) {
|
|
|
307
337
|
//
|
|
308
338
|
// One entry per (instance, frame) that carries either, so a filter that changes across frames reads as
|
|
309
339
|
// the per-frame data it is. Frames are 1-based, matching gotoAndStopMovieClip.
|
|
310
|
-
function collectSwfNodeAppearances(frames, nodes, out) {
|
|
340
|
+
function collectSwfNodeAppearances(frames, nodes, out, diagnostics) {
|
|
311
341
|
for (let frame = 0; frame < frames.length; frame++) {
|
|
312
342
|
for (const entry of frames[frame]) {
|
|
313
343
|
const { advancedBlendMode, effects } = entry.placement;
|
|
314
344
|
if (advancedBlendMode === null && effects.length === 0)
|
|
315
345
|
continue;
|
|
316
346
|
const node = nodes.get(createSwfInstanceKey(entry.placement));
|
|
317
|
-
if (node === undefined)
|
|
347
|
+
if (node === undefined) {
|
|
348
|
+
// The appearance report is the only carrier for these two channels, so a placement whose node was
|
|
349
|
+
// never allocated loses them outright. Reaching here means the placement declared one or both,
|
|
350
|
+
// which is why the earlier `continue` is not a loss and this one is. A masking placement cannot
|
|
351
|
+
// reach here despite also earning no node: masks are excluded from frame entries upstream.
|
|
352
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'swf.appearance-without-node', 'collectSwfNodeAppearances', {
|
|
353
|
+
capability: advancedBlendMode !== null ? 'swf.placement.blend-mode' : 'swf.placement.filter-list',
|
|
354
|
+
frame: frame + 1,
|
|
355
|
+
});
|
|
318
356
|
continue;
|
|
357
|
+
}
|
|
319
358
|
out.push({ advancedBlendMode, effects: [...effects], frame: frame + 1, node });
|
|
320
359
|
}
|
|
321
360
|
}
|
|
@@ -424,28 +463,51 @@ function createSwfTimelineSource(frames, nodes, labels, cues, frameRate) {
|
|
|
424
463
|
// placement with a clip depth covers every depth above its own through that clip depth — while Flight
|
|
425
464
|
// clips a node and its subtree. Applying one region to each covered sibling is equivalent to grouping
|
|
426
465
|
// them under a clipped container, and it leaves the attach/detach/reorder path untouched.
|
|
427
|
-
function buildSwfFrameEntries(ordered, parsed, clips) {
|
|
466
|
+
function buildSwfFrameEntries(ordered, parsed, clips, diagnostics) {
|
|
428
467
|
const entries = [];
|
|
429
468
|
for (const placement of ordered) {
|
|
430
469
|
if (placement.clipDepth > 0)
|
|
431
470
|
continue;
|
|
432
|
-
const mask = resolveSwfPlacementMask(ordered, placement);
|
|
433
|
-
|
|
471
|
+
const mask = resolveSwfPlacementMask(ordered, placement, diagnostics);
|
|
472
|
+
const clip = mask === null ? null : resolveSwfMaskClip(mask, placement, parsed, clips);
|
|
473
|
+
// A mask that resolves to no region leaves its covered instance UNCLIPPED, which is a visible
|
|
474
|
+
// difference and not a no-op: the mask character had no decoded geometry, so imposing nothing was
|
|
475
|
+
// the honest choice over imposing a wrong clip. Recover rather than Drop — the instance still draws.
|
|
476
|
+
if (mask !== null && clip === null) {
|
|
477
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'swf.mask-without-geometry', 'buildSwfFrameEntries', {
|
|
478
|
+
capability: 'swf.placement.clip-depth',
|
|
479
|
+
depth: placement.depth,
|
|
480
|
+
maskCharacterId: mask.characterId,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
entries.push({ clip, placement });
|
|
434
484
|
}
|
|
435
485
|
return entries;
|
|
436
486
|
}
|
|
437
487
|
// The innermost mask covering a depth. Flight carries one clip per node, so where masks nest, the
|
|
438
488
|
// deepest one wins rather than intersecting them.
|
|
439
|
-
function resolveSwfPlacementMask(ordered, placement) {
|
|
489
|
+
function resolveSwfPlacementMask(ordered, placement, diagnostics) {
|
|
440
490
|
let mask = null;
|
|
491
|
+
let covering = 0;
|
|
441
492
|
for (const candidate of ordered) {
|
|
442
493
|
if (candidate.clipDepth <= 0 || candidate.depth >= placement.depth)
|
|
443
494
|
continue;
|
|
444
495
|
if (placement.depth > candidate.clipDepth)
|
|
445
496
|
continue;
|
|
497
|
+
covering++;
|
|
446
498
|
if (mask === null || candidate.depth > mask.depth)
|
|
447
499
|
mask = candidate;
|
|
448
500
|
}
|
|
501
|
+
// Two masks over one instance means the outer one is not applied at all, so the instance shows more
|
|
502
|
+
// than the file said it should. Skip rather than Recover: a node carries one clip, so this is a
|
|
503
|
+
// vocabulary gap in the clip subject rather than geometry this decoder failed to read.
|
|
504
|
+
if (covering > 1) {
|
|
505
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'swf.nested-mask-collapsed', 'resolveSwfPlacementMask', {
|
|
506
|
+
capability: 'swf.placement.clip-depth',
|
|
507
|
+
covering,
|
|
508
|
+
depth: placement.depth,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
449
511
|
return mask;
|
|
450
512
|
}
|
|
451
513
|
function resolveSwfMaskClip(mask, placement, parsed, clips) {
|
|
@@ -584,11 +646,14 @@ function createSwfImageResources(parsed) {
|
|
|
584
646
|
const resources = [];
|
|
585
647
|
for (const [characterId, variants] of parsed.imageTextures) {
|
|
586
648
|
const image = parsed.images.get(characterId);
|
|
587
|
-
|
|
588
|
-
// work that does not exist and would fail if anything tried to decode it as an image file.
|
|
589
|
-
if (image === undefined || isSwfLosslessImage(image))
|
|
649
|
+
if (image === undefined)
|
|
590
650
|
continue;
|
|
591
|
-
const
|
|
651
|
+
const alphaType = image.mimeType === SWF_LOSSLESS_ALPHA_MIME_TYPE
|
|
652
|
+
? 'premultiplied'
|
|
653
|
+
: image.mimeType === SWF_LOSSLESS_MIME_TYPE
|
|
654
|
+
? 'opaque'
|
|
655
|
+
: 'straight';
|
|
656
|
+
const reference = createEmbeddedImageResourceReference(image.bytes, image.mimeType, alphaType);
|
|
592
657
|
reference.textures = [...variants.values()];
|
|
593
658
|
resources.push(reference);
|
|
594
659
|
}
|
|
@@ -624,8 +689,21 @@ function createSwfMovieClip(bounds) {
|
|
|
624
689
|
// A field becomes its own node per placement, because its text is per-instance state rather than shared
|
|
625
690
|
// artwork. The authored RECT still sizes it, so a field reports the box the tool drew even before any
|
|
626
691
|
// layout has run.
|
|
627
|
-
function createSwfEditTextTarget(create, parsed, bounds) {
|
|
628
|
-
|
|
692
|
+
function createSwfEditTextTarget(create, parsed, bounds, diagnostics) {
|
|
693
|
+
// The field survives with its size, box and colour and simply has no font family, which is the
|
|
694
|
+
// diminished case: it exists, it is smaller than authored, and nothing else says so. Reported once per
|
|
695
|
+
// unresolved id rather than per call, since the resolver runs for every run of text.
|
|
696
|
+
const unresolved = new Set();
|
|
697
|
+
const node = create((fontId) => {
|
|
698
|
+
const name = parsed.fontNames.get(fontId);
|
|
699
|
+
if (name !== undefined)
|
|
700
|
+
return name;
|
|
701
|
+
if (!unresolved.has(fontId)) {
|
|
702
|
+
unresolved.add(fontId);
|
|
703
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'swf.edit-text-font-name-unresolved', 'createSwfEditTextTarget', { capability: 'swf.text.define-edit-text', fontId });
|
|
704
|
+
}
|
|
705
|
+
return '';
|
|
706
|
+
});
|
|
629
707
|
if (bounds !== null) {
|
|
630
708
|
node.data.authoredBounds = { ...bounds };
|
|
631
709
|
getNodeRuntime(node).computeLocalBoundsRectangle = computeSwfLocalBoundsRectangle;
|
|
@@ -726,7 +804,7 @@ function createSwfSymbolNode(parsed, characterId, slots, state) {
|
|
|
726
804
|
const bounds = resolveSwfCharacterBounds(parsed, characterId, state, 0);
|
|
727
805
|
const editText = parsed.editTexts.get(characterId);
|
|
728
806
|
if (editText !== undefined)
|
|
729
|
-
return createSwfEditTextTarget(editText, parsed, bounds);
|
|
807
|
+
return createSwfEditTextTarget(editText, parsed, bounds, state.diagnostics);
|
|
730
808
|
if (parsed.images.has(characterId)) {
|
|
731
809
|
return createSwfTexturedSprite(acquireSwfImageTexture(parsed, characterId, false, true), bounds);
|
|
732
810
|
}
|
|
@@ -759,16 +837,29 @@ function resolveSwfCharacterBounds(parsed, characterId, state, depth) {
|
|
|
759
837
|
// A symbol's authored extent covers everything it can show, so this unions every frame's placements
|
|
760
838
|
// rather than only the first frame's: the node's local bounds do not change as its playhead moves.
|
|
761
839
|
let bounds = null;
|
|
840
|
+
let missingChildren = 0;
|
|
762
841
|
for (const frame of sprite.frames) {
|
|
763
842
|
for (const placement of frame.values()) {
|
|
764
843
|
const childBounds = resolveSwfCharacterBounds(parsed, placement.characterId, state, depth + 1);
|
|
765
|
-
if (childBounds === null)
|
|
844
|
+
if (childBounds === null) {
|
|
845
|
+
// Two different things reach here and only one is a loss. A sprite that resolved to an empty
|
|
846
|
+
// union has no extent to contribute and is correct; a character with neither authored bounds nor
|
|
847
|
+
// a sprite body was never imported at all, and the union it should have widened is short.
|
|
848
|
+
if (!parsed.characterBounds.has(placement.characterId) && !parsed.sprites.has(placement.characterId)) {
|
|
849
|
+
missingChildren++;
|
|
850
|
+
}
|
|
766
851
|
continue;
|
|
852
|
+
}
|
|
767
853
|
const transformed = transformSwfRectangle(childBounds, placement.matrix);
|
|
768
854
|
bounds = bounds === null ? transformed : mergeSwfRectangles(bounds, transformed);
|
|
769
855
|
}
|
|
770
856
|
}
|
|
771
857
|
state.resolvingBounds.delete(characterId);
|
|
858
|
+
if (missingChildren > 0) {
|
|
859
|
+
// A count rather than a flag: the box survives and is simply smaller than the sprite's contents, and
|
|
860
|
+
// a count is the only thing that separates a full union from a short one.
|
|
861
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.sprite-bounds-short', 'resolveSwfCharacterBounds', { capability: 'swf.timeline.define-sprite', characterId, missingChildren });
|
|
862
|
+
}
|
|
772
863
|
state.resolvedBounds.set(characterId, bounds);
|
|
773
864
|
return bounds;
|
|
774
865
|
}
|
|
@@ -787,7 +878,7 @@ function transformSwfRectangle(bounds, matrix) {
|
|
|
787
878
|
const maxY = Math.max(y0, y1, y2, y3);
|
|
788
879
|
return { height: maxY - y, width: maxX - x, x, y };
|
|
789
880
|
}
|
|
790
|
-
function readPlaceObject(body, placements, hasExtendedFlags) {
|
|
881
|
+
function readPlaceObject(body, placements, hasExtendedFlags, diagnostics) {
|
|
791
882
|
const flags = body.readUint8();
|
|
792
883
|
const extendedFlags = hasExtendedFlags ? body.readUint8() : 0;
|
|
793
884
|
const depth = body.readUint16();
|
|
@@ -811,8 +902,15 @@ function readPlaceObject(body, placements, hasExtendedFlags) {
|
|
|
811
902
|
const hasFilterList = (extendedFlags & 0x01) !== 0;
|
|
812
903
|
const readEffects = [];
|
|
813
904
|
const readFilterAdjustments = [];
|
|
814
|
-
const filterListComplete = !hasFilterList || readSwfFilterList(body, readEffects, readFilterAdjustments);
|
|
815
|
-
const
|
|
905
|
+
const filterListComplete = !hasFilterList || readSwfFilterList(body, readEffects, readFilterAdjustments, diagnostics);
|
|
906
|
+
const declaresBlendMode = (extendedFlags & 0x02) !== 0;
|
|
907
|
+
const hasBlendMode = declaresBlendMode && filterListComplete;
|
|
908
|
+
if (declaresBlendMode && !filterListComplete) {
|
|
909
|
+
// The record declared a blend mode and the byte carrying it is unreachable behind a list that did
|
|
910
|
+
// not finish. The placement keeps the filters read so far, so nothing about the result says a
|
|
911
|
+
// declared channel went unread.
|
|
912
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'swf.blend-mode-behind-unread-filters', 'readPlaceObject', { capability: 'swf.placement.blend-mode' });
|
|
913
|
+
}
|
|
816
914
|
const blendModeValue = hasBlendMode ? body.readUint8() : 0;
|
|
817
915
|
if (!body.valid || (isMove && existing === undefined) || (characterId === 0 && directLinkage === null))
|
|
818
916
|
return;
|
|
@@ -1006,10 +1104,11 @@ function readSwfRectangle(reader) {
|
|
|
1006
1104
|
y: yMin / TWIPS_PER_PIXEL,
|
|
1007
1105
|
};
|
|
1008
1106
|
}
|
|
1009
|
-
function readSwfTags(reader) {
|
|
1107
|
+
function readSwfTags(reader, diagnostics) {
|
|
1010
1108
|
const state = {
|
|
1011
1109
|
abcBlobs: [],
|
|
1012
1110
|
backgroundColor: null,
|
|
1111
|
+
diagnostics,
|
|
1013
1112
|
pendingInitActions: [],
|
|
1014
1113
|
editTexts: new Map(),
|
|
1015
1114
|
fontNames: new Map(),
|
|
@@ -1098,9 +1197,11 @@ function appendSwfAbcFrameScripts(state, timeline) {
|
|
|
1098
1197
|
for (const [characterId, className] of state.linkages)
|
|
1099
1198
|
charactersByClass.set(className, characterId);
|
|
1100
1199
|
for (const blob of state.abcBlobs) {
|
|
1101
|
-
const byClass = readSwfAbcFrameScripts(blob);
|
|
1102
|
-
if (byClass === null)
|
|
1200
|
+
const byClass = readSwfAbcFrameScripts(blob.bytes, state.diagnostics);
|
|
1201
|
+
if (byClass === null) {
|
|
1202
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.abc-frame-scripts-unreadable', 'appendSwfAbcFrameScripts', { capability: blob.named ? 'swf.script.do-abc' : 'swf.script.do-abc-anonymous' });
|
|
1103
1203
|
continue;
|
|
1204
|
+
}
|
|
1104
1205
|
for (const [className, frames] of byClass) {
|
|
1105
1206
|
const characterId = charactersByClass.get(className);
|
|
1106
1207
|
if (characterId === undefined)
|
|
@@ -1116,8 +1217,17 @@ function appendSwfAbcFrameScripts(state, timeline) {
|
|
|
1116
1217
|
function appendSwfPendingTextShapes(reader, state) {
|
|
1117
1218
|
for (const pending of state.pendingTexts) {
|
|
1118
1219
|
const shape = createSwfTextShape(new SwfReader(reader.source, pending.start, pending.end), pending.version, state.fontOutlineSources);
|
|
1119
|
-
if (shape
|
|
1120
|
-
|
|
1220
|
+
if (shape === null) {
|
|
1221
|
+
// Composition is deferred because the records address glyphs by index into a font that may not
|
|
1222
|
+
// have been read yet, so the failure lands here rather than at the tag. Without a report the
|
|
1223
|
+
// character is simply absent and every placement of it resolves to nothing.
|
|
1224
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.text-shape-uncomposable', 'appendSwfPendingTextShapes', {
|
|
1225
|
+
capability: pending.version === 2 ? 'swf.text.define-text-2' : 'swf.text.define-text',
|
|
1226
|
+
characterId: pending.characterId,
|
|
1227
|
+
});
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
state.shapes.set(pending.characterId, shape);
|
|
1121
1231
|
}
|
|
1122
1232
|
}
|
|
1123
1233
|
function readSwfTimeline(reader, state) {
|
|
@@ -1148,27 +1258,43 @@ function readSwfTimeline(reader, state) {
|
|
|
1148
1258
|
frames.push(new Map(placements));
|
|
1149
1259
|
}
|
|
1150
1260
|
else if (code === TAG_DO_ABC || code === TAG_DO_ABC_ANONYMOUS) {
|
|
1151
|
-
state.abcBlobs.push(readSwfAbcPayload(body, code === TAG_DO_ABC));
|
|
1261
|
+
state.abcBlobs.push({ bytes: readSwfAbcPayload(body, code === TAG_DO_ABC), named: code === TAG_DO_ABC });
|
|
1152
1262
|
}
|
|
1153
1263
|
else if (code === TAG_DO_INIT_ACTION) {
|
|
1154
1264
|
// An init action runs once for the sprite it names, before that sprite's own first frame, so a
|
|
1155
1265
|
// recognized block belongs to frame 1 of that sprite rather than to the timeline reading the tag.
|
|
1156
1266
|
const spriteId = body.readUint16();
|
|
1157
1267
|
const script = readSwfFrameActions(new SwfReader(body.source, body.pos, body.end));
|
|
1158
|
-
if (script
|
|
1268
|
+
if (script === null) {
|
|
1269
|
+
// Same decline as DoAction below and the same Skip reasoning: the bytecode is outside this
|
|
1270
|
+
// importer's scope by charter rather than data it failed to read. It reports the sprite it names
|
|
1271
|
+
// rather than a frame, because an init action runs before that sprite's first frame.
|
|
1272
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Skip, 'swf.frame-script-declined', 'readSwfTimeline', { capability: 'swf.script.do-init-action', characterId: spriteId });
|
|
1273
|
+
}
|
|
1274
|
+
else {
|
|
1159
1275
|
state.pendingInitActions.push({ characterId: spriteId, script });
|
|
1276
|
+
}
|
|
1160
1277
|
}
|
|
1161
1278
|
else if (code === TAG_DO_ACTION) {
|
|
1162
1279
|
// A DoAction belongs to the frame being assembled — the one the next ShowFrame closes.
|
|
1163
1280
|
const script = readSwfFrameActions(new SwfReader(body.source, body.pos, body.end));
|
|
1164
1281
|
if (script !== null)
|
|
1165
1282
|
actions.set(frames.length + 1, script);
|
|
1283
|
+
// A block is recognized only when EVERY action in it is a playback command, because honouring the
|
|
1284
|
+
// legible half would misrepresent what the frame does. Skip rather than Drop: the bytecode is
|
|
1285
|
+
// outside this importer's scope by charter, not data it failed to read.
|
|
1286
|
+
else {
|
|
1287
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Skip, 'swf.frame-script-declined', 'readSwfTimeline', {
|
|
1288
|
+
capability: 'swf.script.do-action',
|
|
1289
|
+
frame: frames.length + 1,
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1166
1292
|
}
|
|
1167
1293
|
else if (code === TAG_FRAME_LABEL) {
|
|
1168
1294
|
addSwfTimelineLabel(labels, frames.length + 1, body.readString());
|
|
1169
1295
|
}
|
|
1170
1296
|
else if (code === TAG_DEFINE_SCENE_AND_FRAME_LABEL_DATA) {
|
|
1171
|
-
readSwfSceneAndFrameLabelData(body, labels);
|
|
1297
|
+
readSwfSceneAndFrameLabelData(body, labels, state.diagnostics);
|
|
1172
1298
|
}
|
|
1173
1299
|
else if (code === TAG_DEFINE_SCALING_GRID) {
|
|
1174
1300
|
readSwfScalingGrid(body, state);
|
|
@@ -1177,7 +1303,7 @@ function readSwfTimeline(reader, state) {
|
|
|
1177
1303
|
readLegacyPlaceObject(body, placements);
|
|
1178
1304
|
}
|
|
1179
1305
|
else if (code === TAG_PLACE_OBJECT_2) {
|
|
1180
|
-
readPlaceObject(body, placements, false);
|
|
1306
|
+
readPlaceObject(body, placements, false, state.diagnostics);
|
|
1181
1307
|
}
|
|
1182
1308
|
else if (code === TAG_REMOVE_OBJECT) {
|
|
1183
1309
|
readLegacyRemoveObject(body, placements);
|
|
@@ -1238,7 +1364,7 @@ function readSwfTimeline(reader, state) {
|
|
|
1238
1364
|
state.sprites.set(spriteId, spriteTimeline);
|
|
1239
1365
|
}
|
|
1240
1366
|
else if (code === TAG_PLACE_OBJECT_3 || code === TAG_PLACE_OBJECT_4) {
|
|
1241
|
-
readPlaceObject(body, placements, true);
|
|
1367
|
+
readPlaceObject(body, placements, true, state.diagnostics);
|
|
1242
1368
|
}
|
|
1243
1369
|
else if (code === TAG_START_SOUND) {
|
|
1244
1370
|
readSwfStartSound(body, state, cues, frames.length + 1);
|
|
@@ -1263,6 +1389,15 @@ function readSwfTimeline(reader, state) {
|
|
|
1263
1389
|
if (body.valid && body.pos < body.end)
|
|
1264
1390
|
streamChunks.push(body.source.subarray(body.pos, body.end));
|
|
1265
1391
|
}
|
|
1392
|
+
else if (streamFormat >= 0) {
|
|
1393
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Skip, 'swf.stream-sound-format', 'readSwfTimeline', {
|
|
1394
|
+
capability: 'swf.axis.sound-format-non-mp3',
|
|
1395
|
+
format: streamFormat,
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
else {
|
|
1400
|
+
reportSwfDeclinedTag(state.diagnostics, code);
|
|
1266
1401
|
}
|
|
1267
1402
|
if (!body.valid)
|
|
1268
1403
|
return null;
|
|
@@ -1273,15 +1408,46 @@ function readSwfTimeline(reader, state) {
|
|
|
1273
1408
|
if (frames.length === 0)
|
|
1274
1409
|
frames.push(placements);
|
|
1275
1410
|
appendSwfStreamSoundCue(state, cues, streamChunks, streamStartFrame);
|
|
1411
|
+
// A label or cue authored after the last ShowFrame names a frame the timeline never reaches, so it is
|
|
1412
|
+
// dropped rather than bound to a frame that does not exist. Found by auditing the claim rather than the
|
|
1413
|
+
// wire: `swf.scene-names` covered only the scene table, while THIS is the other way that tag's data is
|
|
1414
|
+
// lost, and silence about it was being reported as trustworthy.
|
|
1415
|
+
const reachableCues = cues.filter((cue) => cue.frame <= frames.length);
|
|
1416
|
+
const reachableLabels = labels.filter((label) => label.frame <= frames.length);
|
|
1417
|
+
if (reachableLabels.length !== labels.length) {
|
|
1418
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.label-past-last-frame', 'readSwfTimeline', {
|
|
1419
|
+
capability: 'swf.timeline.frame-label',
|
|
1420
|
+
dropped: labels.length - reachableLabels.length,
|
|
1421
|
+
frames: frames.length,
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
if (reachableCues.length !== cues.length) {
|
|
1425
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.cue-past-last-frame', 'readSwfTimeline', {
|
|
1426
|
+
dropped: cues.length - reachableCues.length,
|
|
1427
|
+
frames: frames.length,
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1276
1430
|
return {
|
|
1277
1431
|
actions,
|
|
1278
|
-
|
|
1279
|
-
cues: cues.filter((cue) => cue.frame <= frames.length),
|
|
1432
|
+
cues: reachableCues,
|
|
1280
1433
|
frames,
|
|
1281
|
-
|
|
1282
|
-
labels: labels.filter((label) => label.frame <= frames.length).sort(compareSwfTimelineLabelFrame),
|
|
1434
|
+
labels: reachableLabels.sort(compareSwfTimelineLabelFrame),
|
|
1283
1435
|
};
|
|
1284
1436
|
}
|
|
1437
|
+
// Reports the tags this importer deliberately reads past that cost a real capability, and stays silent on
|
|
1438
|
+
// the ones that cost nothing. The line matters: a document is not worse off for having skipped
|
|
1439
|
+
// `FileAttributes` or a font's hinting table, so reporting those would bury the entries that do mean
|
|
1440
|
+
// something under noise a caller has to filter. Every code below is a decision recorded in
|
|
1441
|
+
// `agents/packages/swf/tag-coverage.md`, not an unrecognized tag.
|
|
1442
|
+
function reportSwfDeclinedTag(diagnostics, code) {
|
|
1443
|
+
if (diagnostics === undefined)
|
|
1444
|
+
return;
|
|
1445
|
+
const kind = SWF_DECLINED_TAG_KINDS.get(code);
|
|
1446
|
+
if (kind === undefined)
|
|
1447
|
+
return;
|
|
1448
|
+
const capability = SWF_DECLINED_TAG_CAPABILITIES.get(code);
|
|
1449
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, kind, 'readSwfTimeline', capability === undefined ? { tag: code } : { capability, tag: code });
|
|
1450
|
+
}
|
|
1285
1451
|
// The stage colour, as an RGB record. SWF gives it no alpha, and a stage is opaque, so it packs to fully
|
|
1286
1452
|
// opaque RGBA. Last declaration wins, matching how a player applies the most recent one it has read.
|
|
1287
1453
|
// A button is a display character whose content is a small display list per interaction state. A document
|
|
@@ -1312,6 +1478,14 @@ function readSwfButtonDefinition(body, state, version) {
|
|
|
1312
1478
|
const colorTransform = version === 2 ? readSwfColorTransform(reader) : null;
|
|
1313
1479
|
if (!reader.valid)
|
|
1314
1480
|
return;
|
|
1481
|
+
if ((flags & BUTTON_STATE_UP) === 0 && characterId !== 0) {
|
|
1482
|
+
// A document is a still scene, so only the up state is held. The other states are a real capability
|
|
1483
|
+
// gap rather than data this decoder could not read, which is why they Skip rather than Drop.
|
|
1484
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Skip, 'swf.button-interaction-state', 'readSwfButtonDefinition', {
|
|
1485
|
+
characterId,
|
|
1486
|
+
flags,
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1315
1489
|
if ((flags & BUTTON_STATE_UP) !== 0 && characterId !== 0) {
|
|
1316
1490
|
placements.set(depth, {
|
|
1317
1491
|
advancedBlendMode: null,
|
|
@@ -1348,9 +1522,28 @@ function readSwfFontDefinition(body, state, code) {
|
|
|
1348
1522
|
const version = code === TAG_DEFINE_FONT ? 1 : code === TAG_DEFINE_FONT_2 ? 2 : 3;
|
|
1349
1523
|
const reader = new SwfReader(body.source, body.pos, body.end);
|
|
1350
1524
|
const fontId = reader.source[body.pos] + reader.source[body.pos + 1] * 0x100;
|
|
1351
|
-
const source = readSwfFontGlyphOutlineSource(reader, version);
|
|
1352
|
-
|
|
1525
|
+
const source = readSwfFontGlyphOutlineSource(reader, version, state.diagnostics, fontId);
|
|
1526
|
+
// A glyph table this decoder cannot read at all costs the WHOLE font, not one glyph, so it is a
|
|
1527
|
+
// separate loss from `swf.font-glyph-outline` and must report separately: without this, a font that
|
|
1528
|
+
// vanished entirely and a font that imported cleanly both produce no crumb.
|
|
1529
|
+
if (source === null) {
|
|
1530
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.font-glyph-table', 'readSwfFontDefinition', {
|
|
1531
|
+
capability: version === 1 ? 'swf.font.define-font' : `swf.font.define-font-${version}`,
|
|
1532
|
+
characterId: fontId,
|
|
1533
|
+
});
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
if (fontId === 0)
|
|
1353
1537
|
return;
|
|
1538
|
+
if (state.fontOutlineSources.has(fontId)) {
|
|
1539
|
+
// Every other definition kind rejects the document on a repeated character id; fonts do not, so the
|
|
1540
|
+
// second table silently replaces the first. The document imports, the font exists, and it is the
|
|
1541
|
+
// wrong font — no existence check and no count can see that, which is why it is reported here.
|
|
1542
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.font-character-id-reused', 'readSwfFontDefinition', {
|
|
1543
|
+
capability: version === 1 ? 'swf.font.define-font' : `swf.font.define-font-${version}`,
|
|
1544
|
+
characterId: fontId,
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1354
1547
|
state.fontOutlineSources.set(fontId, source);
|
|
1355
1548
|
const fontName = readSwfFontName(body, version);
|
|
1356
1549
|
if (fontName !== '')
|
|
@@ -1438,12 +1631,21 @@ function readSwfScalingGrid(body, state) {
|
|
|
1438
1631
|
return;
|
|
1439
1632
|
state.scalingGrids.set(characterId, splitter);
|
|
1440
1633
|
}
|
|
1441
|
-
function readSwfSceneAndFrameLabelData(body, labels) {
|
|
1634
|
+
function readSwfSceneAndFrameLabelData(body, labels, diagnostics) {
|
|
1442
1635
|
const sceneCount = body.readEncodedUint32();
|
|
1443
1636
|
for (let i = 0; i < sceneCount && body.valid; i++) {
|
|
1444
1637
|
body.readEncodedUint32();
|
|
1445
1638
|
body.readString();
|
|
1446
1639
|
}
|
|
1640
|
+
// Scene names are read past to reach the label table behind them. Skip rather than Drop: Flight has
|
|
1641
|
+
// frame labels but no subject for a named frame range, so this is a capability gap rather than data
|
|
1642
|
+
// this decoder failed to read.
|
|
1643
|
+
if (sceneCount > 0) {
|
|
1644
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'swf.scene-names', 'readSwfSceneAndFrameLabelData', {
|
|
1645
|
+
capability: 'swf.timeline.define-scene-and-frame-label-data',
|
|
1646
|
+
sceneCount,
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1447
1649
|
const labelCount = body.readEncodedUint32();
|
|
1448
1650
|
for (let i = 0; i < labelCount && body.valid; i++) {
|
|
1449
1651
|
const frame = body.readEncodedUint32();
|
|
@@ -1494,8 +1696,12 @@ function readSwfBoundedDefinition(body, state, code) {
|
|
|
1494
1696
|
const reader = new SwfReader(body.source, body.pos, body.end);
|
|
1495
1697
|
const bounds = state.characterBounds.get(characterId);
|
|
1496
1698
|
const factory = readSwfEditTextFactory(reader, bounds?.width ?? 0, bounds?.height ?? 0);
|
|
1497
|
-
if (factory
|
|
1699
|
+
if (factory === null) {
|
|
1700
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.edit-text-unparseable', 'readSwfBoundedDefinition', { capability: 'swf.text.define-edit-text', characterId });
|
|
1701
|
+
}
|
|
1702
|
+
else {
|
|
1498
1703
|
state.editTexts.set(characterId, factory);
|
|
1704
|
+
}
|
|
1499
1705
|
}
|
|
1500
1706
|
if (code === TAG_DEFINE_TEXT || code === TAG_DEFINE_TEXT_2) {
|
|
1501
1707
|
const reader = new SwfReader(body.source, body.pos, body.end);
|
|
@@ -1526,8 +1732,17 @@ function readSwfShapeBody(body, state, characterId, version) {
|
|
|
1526
1732
|
// The fill's character is not this shape's, hence the distinct name: a bitmap fill names whatever
|
|
1527
1733
|
// character carries its pixels, which may be defined later in the tag stream.
|
|
1528
1734
|
const shape = createSwfShape(reader, version, (fillCharacterId, repeat, smoothed) => acquireSwfImageTexture(state, fillCharacterId, repeat, smoothed));
|
|
1529
|
-
if (shape
|
|
1530
|
-
|
|
1735
|
+
if (shape === null) {
|
|
1736
|
+
// Recover rather than Drop: the character survives as the bounded placeholder it was before any
|
|
1737
|
+
// geometry existed, so the document still places and sizes it — only the drawing is missing.
|
|
1738
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Recover, 'swf.shape-body-unreadable', 'readSwfShapeDefinition', {
|
|
1739
|
+
capability: version === 1 ? 'swf.shape.define-shape' : `swf.shape.define-shape-${version}`,
|
|
1740
|
+
characterId,
|
|
1741
|
+
version,
|
|
1742
|
+
});
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
state.shapes.set(characterId, shape);
|
|
1531
1746
|
}
|
|
1532
1747
|
// Decodes a morph definition's geometry and paint on a reader of its own, so a body this decoder cannot
|
|
1533
1748
|
// read costs only that body. The definition keeps its authored bounds and contributes no drawing, which
|
|
@@ -1539,9 +1754,17 @@ function readSwfMorphShapeBody(body, state, characterId, version) {
|
|
|
1539
1754
|
const source = body.source;
|
|
1540
1755
|
const start = body.pos;
|
|
1541
1756
|
const end = body.end;
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1757
|
+
// Only the parse-time validation call carries the sink. The stored closure runs again per placement,
|
|
1758
|
+
// potentially long after import returned, and a sink written to then would append to a collection
|
|
1759
|
+
// whose consumer has already read it.
|
|
1760
|
+
const decode = (diagnostics) => createSwfMorphShape(new SwfReader(source, start, end), version, (fillCharacterId, repeat, smoothed) => acquireSwfImageTexture(state, fillCharacterId, repeat, smoothed), diagnostics);
|
|
1761
|
+
if (decode(state.diagnostics) === null) {
|
|
1762
|
+
// The character is simply absent from the document afterwards, so without this the loss has no
|
|
1763
|
+
// signal at all: a placement of it resolves to nothing and the import still reports success.
|
|
1764
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.morph-shape-undecodable', 'readSwfMorphShapeBody', { capability: version === 2 ? 'swf.morph.define-morph-shape-2' : 'swf.morph.define-morph-shape', characterId });
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
state.morphShapes.set(characterId, decode);
|
|
1545
1768
|
}
|
|
1546
1769
|
function resolveSwfMorphShapeVersion(code) {
|
|
1547
1770
|
if (code === TAG_DEFINE_MORPH_SHAPE)
|
|
@@ -1569,8 +1792,13 @@ function readSwfLegacyImageDefinition(body, state) {
|
|
|
1569
1792
|
if (!body.valid || characterId === 0 || state.definedCharacters.has(characterId))
|
|
1570
1793
|
return;
|
|
1571
1794
|
const tables = state.jpegTables;
|
|
1572
|
-
if (tables === null)
|
|
1795
|
+
if (tables === null) {
|
|
1796
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.jpeg-tables-missing', 'readSwfLegacyImageDefinition', {
|
|
1797
|
+
capability: 'swf.bitmap.define-bits-jpeg-tables',
|
|
1798
|
+
characterId,
|
|
1799
|
+
});
|
|
1573
1800
|
return;
|
|
1801
|
+
}
|
|
1574
1802
|
const tablesEnd = tables.length >= 2 && tables[tables.length - 2] === 0xff && tables[tables.length - 1] === JPEG_END_OF_IMAGE
|
|
1575
1803
|
? tables.length - 2
|
|
1576
1804
|
: tables.length;
|
|
@@ -1579,8 +1807,13 @@ function readSwfLegacyImageDefinition(body, state) {
|
|
|
1579
1807
|
spliced.set(tables.subarray(0, tablesEnd));
|
|
1580
1808
|
spliced.set(body.source.subarray(imageStart, body.end), tablesEnd);
|
|
1581
1809
|
const image = readSwfEmbeddedImage(spliced, 0, spliced.length);
|
|
1582
|
-
if (image === null)
|
|
1810
|
+
if (image === null) {
|
|
1811
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.jpeg-tables-unsplittable', 'readSwfLegacyImageDefinition', {
|
|
1812
|
+
capability: 'swf.bitmap.define-bits-jpeg-tables',
|
|
1813
|
+
characterId,
|
|
1814
|
+
});
|
|
1583
1815
|
return;
|
|
1816
|
+
}
|
|
1584
1817
|
state.definedCharacters.add(characterId);
|
|
1585
1818
|
state.characterBounds.set(characterId, image.bounds);
|
|
1586
1819
|
state.images.set(characterId, { bytes: spliced, mimeType: image.mimeType });
|
|
@@ -1599,6 +1832,16 @@ function readSwfEmbeddedImageDefinition(body, state, code) {
|
|
|
1599
1832
|
body.readUint16();
|
|
1600
1833
|
imageStart = body.pos;
|
|
1601
1834
|
imageEnd = alphaOffsetBase + alphaDataOffset;
|
|
1835
|
+
// The colour stream ends at the alpha offset and the zlib-compressed alpha block after it is
|
|
1836
|
+
// discarded, so a transparent JPEG imports fully opaque. Drop rather than Skip: this is authored
|
|
1837
|
+
// data lost, not a feature declined — the bytes are present and go unread.
|
|
1838
|
+
if (alphaDataOffset > 0 && imageEnd < body.end) {
|
|
1839
|
+
reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.jpeg-alpha-stream', 'readSwfEmbeddedImageDefinition', {
|
|
1840
|
+
capability: code === TAG_DEFINE_BITS_JPEG_3 ? 'swf.bitmap.define-bits-jpeg-3' : 'swf.bitmap.define-bits-jpeg-4',
|
|
1841
|
+
characterId,
|
|
1842
|
+
discardedBytes: body.end - imageEnd,
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1602
1845
|
}
|
|
1603
1846
|
if (!body.valid ||
|
|
1604
1847
|
characterId === 0 ||
|
|
@@ -2030,8 +2273,6 @@ const S_SIGNATURE = 0x53;
|
|
|
2030
2273
|
const MORPH_RATIO_ONE = 0xffff;
|
|
2031
2274
|
const SWF_INSTANCE_KEY_SCALE = 0x10000;
|
|
2032
2275
|
const SWF_LZMA_PREFIX_LENGTH = 17;
|
|
2033
|
-
const SWF_LOSSLESS_ALPHA_MIME_TYPE = 'image/x-swf-lossless-alpha';
|
|
2034
|
-
const SWF_LOSSLESS_MIME_TYPE = 'image/x-swf-lossless';
|
|
2035
2276
|
const SWF_MIME_TYPE = 'application/x-shockwave-flash';
|
|
2036
2277
|
const SWF_PREFIX_LENGTH = 8;
|
|
2037
2278
|
const SOUND_ENVELOPE_LEVEL_ONE = 32768;
|
|
@@ -2065,6 +2306,22 @@ const TAG_START_SOUND_2 = 89;
|
|
|
2065
2306
|
const TAG_SOUND_STREAM_BLOCK = 19;
|
|
2066
2307
|
const TAG_SOUND_STREAM_HEAD = 18;
|
|
2067
2308
|
const TAG_SOUND_STREAM_HEAD_2 = 45;
|
|
2309
|
+
// Only tags whose absence loses something a caller could want. Metadata tags — `FileAttributes`,
|
|
2310
|
+
// `Metadata`, `ProductInfo`, `ScriptLimits`, `DebugID`, `EnableDebugger2`, `EnableTelemetry`, `Protect`,
|
|
2311
|
+
// `SetTabIndex`, `DefineButtonCxform`, and the font hinting/naming tables — are deliberately absent: they
|
|
2312
|
+
// carry no scene content, so skipping one is not a loss to report.
|
|
2313
|
+
// The declared capability a declined tag costs, where one exists. Deliberately partial: `DefineFont4`,
|
|
2314
|
+
// `DefineBinaryData`, `ImportAssets` and `DefineButtonSound` name no declared capability, and inventing
|
|
2315
|
+
// one so every crumb could carry a join key would put entries in the denominator that nothing measures.
|
|
2316
|
+
const SWF_DECLINED_TAG_CAPABILITIES = new Map([[61, 'swf.video.video-frame']]);
|
|
2317
|
+
const SWF_DECLINED_TAG_KINDS = new Map([
|
|
2318
|
+
[17, 'swf.define-button-sound'],
|
|
2319
|
+
[57, 'swf.import-assets'],
|
|
2320
|
+
[61, 'swf.video-frame-payload'],
|
|
2321
|
+
[71, 'swf.import-assets'],
|
|
2322
|
+
[87, 'swf.define-binary-data'],
|
|
2323
|
+
[91, 'swf.define-font-4'],
|
|
2324
|
+
]);
|
|
2068
2325
|
const TAG_DEFINE_SCALING_GRID = 78;
|
|
2069
2326
|
const TAG_DEFINE_SCENE_AND_FRAME_LABEL_DATA = 86;
|
|
2070
2327
|
const TAG_DEFINE_BITS_JPEG_2 = 21;
|