feat: SWF editor enhancements

This commit is contained in:
neoapps-dev 2026-04-25 18:32:56 +03:00
parent 29f4fdff47
commit c25bb7cbaf
3 changed files with 504 additions and 233 deletions

View file

@ -1,41 +1,90 @@
import React, { useState, useCallback } from "react";
import { motion } from "framer-motion";
import React, { useState, useCallback, useMemo, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useConfig, useAudio, useUI } from "../../context/LauncherContext";
import { SwfImage, SwfService } from "../../services/SwfService";
import { SwfImage, SwfService, SwfTag } from "../../services/SwfService";
export default function SwfView() {
const { animationsEnabled } = useConfig();
const { playBackSound } = useAudio();
const { playBackSound, playPressSound } = useAudio();
const { setActiveView } = useUI();
const [swfData, setSwfData] = useState<{ version: number, compressed: boolean, frameHeader: Uint8Array, tags: SwfTag[] } | null>(null);
const [images, setImages] = useState<SwfImage[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [selectedImageId, setSelectedImageId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [fileName, setFileName] = useState<string | null>(null);
const [imageUrls, setImageUrls] = useState<Record<number, string>>({});
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const replaceInputRef = useRef<HTMLInputElement>(null);
const showNotification = (message: string, type: "success" | "error" = "success") => {
setNotification({ message, type });
setTimeout(() => setNotification(null), 3000);
};
const filteredImages = useMemo(() => {
return images.filter(img =>
img.id.toString().includes(searchTerm) ||
(img.name && img.name.toLowerCase().includes(searchTerm.toLowerCase()))
);
}, [images, searchTerm]);
const selectedImage = useMemo(() => {
return images.find(img => img.id === selectedImageId) || null;
}, [images, selectedImageId]);
const handleBack = useCallback(() => {
playBackSound();
setActiveView("devtools");
}, [playBackSound, setActiveView]);
const loadUrl = async (img: SwfImage) => {
if (imageUrls[img.id]) return imageUrls[img.id];
let url = "";
if (img.type === "jpeg") {
const blob = new Blob([img.data as any], { type: "image/jpeg" });
url = URL.createObjectURL(blob);
} else if (img.type === "lossless") {
const rgba = await SwfService.decodeLosslessToRGBA(img);
const canvas = document.createElement("canvas");
canvas.width = img.width!;
canvas.height = img.height!;
const ctx = canvas.getContext("2d");
if (ctx) {
const imageData = new ImageData(new Uint8ClampedArray(rgba), img.width!, img.height!);
ctx.putImageData(imageData, 0, 0);
url = canvas.toDataURL();
}
}
if (url) {
setImageUrls(prev => ({ ...prev, [img.id]: url }));
}
return url;
};
useEffect(() => {
if (selectedImage) {
loadUrl(selectedImage);
}
}, [selectedImage]);
const processFile = async (file: File) => {
setLoading(true);
setError(null);
setFileName(file.name);
setImageUrls({});
try {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const extracted = await SwfService.extractImages(bytes);
const swf = SwfService.parse(bytes);
setSwfData(swf);
const extracted = SwfService.extractImages(swf.tags);
setImages(extracted);
if (extracted.length === 0) {
setError("No supported images found in SWF.");
if (extracted.length > 0) {
setSelectedImageId(extracted[0].id);
}
showNotification(`Loaded ${file.name}`);
} catch (e: any) {
console.error(e);
setError(e.message || "Failed to process SWF");
showNotification("Failed to process SWF", "error");
setImages([]);
} finally {
setLoading(false);
setSwfData(null);
}
};
@ -45,37 +94,82 @@ export default function SwfView() {
}
};
const getImageSrc = (img: SwfImage) => {
if (img.type === "jpeg" || img.type === "png" || img.type === "gif") {
const mime = img.type === "jpeg" ? "image/jpeg" : `image/${img.type}`;
const blob = new Blob([new Uint8Array(img.data)], { type: mime });
return URL.createObjectURL(blob);
}
return "";
};
const handleDownload = (img: SwfImage) => {
const handleDownload = async (img: SwfImage) => {
playPressSound();
let blob: Blob;
let ext: string;
let ext = "png";
if (img.type === "jpeg") {
blob = new Blob([new Uint8Array(img.data)], { type: "image/jpeg" });
blob = new Blob([img.data as any], { type: "image/jpeg" });
ext = "jpg";
} else if (img.type === "png" || img.type === "gif") {
blob = new Blob([new Uint8Array(img.data)], { type: `image/${img.type}` });
ext = img.type;
} else if (img.type === "lossless") {
const rgba = await SwfService.decodeLosslessToRGBA(img);
const canvas = document.createElement("canvas");
canvas.width = img.width!;
canvas.height = img.height!;
const ctx = canvas.getContext("2d");
if (ctx) {
const imageData = new ImageData(new Uint8ClampedArray(rgba), img.width!, img.height!);
ctx.putImageData(imageData, 0, 0);
const dataUrl = canvas.toDataURL("image/png");
const res = await fetch(dataUrl);
blob = await res.blob();
} else {
blob = new Blob([rgba as any], { type: "application/octet-stream" });
ext = "bin";
}
} else {
blob = new Blob([new Uint8Array(img.data)], { type: "application/octet-stream" });
blob = new Blob([img.data as any], { type: "application/octet-stream" });
ext = "bin";
}
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${img.name || `image_${img.id}`}.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification(`Exported: ${img.name || img.id}`);
};
const handleReplace = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!swfData || !selectedImageId || !e.target.files?.[0]) return;
const file = e.target.files[0];
playPressSound();
const buffer = await file.arrayBuffer();
const newData = new Uint8Array(buffer);
const newTags = SwfService.updateImageTag(
swfData.tags,
selectedImageId,
newData,
selectedImage?.type || "unknown"
);
const newSwfData = { ...swfData, tags: newTags };
setSwfData(newSwfData);
const extracted = SwfService.extractImages(newTags);
setImages(extracted);
setImageUrls(prev => {
const next = { ...prev };
delete next[selectedImageId];
return next;
});
showNotification("Image Replaced", "success");
e.target.value = "";
};
const handleSaveSwf = () => {
if (!swfData) return;
playPressSound();
const result = SwfService.serialize(swfData.version, swfData.compressed, swfData.frameHeader, swfData.tags);
const blob = new Blob([result as any], { type: "application/x-shockwave-flash" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName || "output.swf";
a.click();
URL.revokeObjectURL(url);
showNotification("SWF Saved Successfully");
};
return (
@ -84,92 +178,158 @@ export default function SwfView() {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-4xl outline-none"
className="flex flex-col items-center w-full max-w-6xl h-[85vh] outline-none"
>
<div className="w-full flex justify-between items-end mb-4 border-b-2 border-[#373737] pb-2">
<h2 className="text-2xl text-white mc-text-shadow tracking-widest uppercase opacity-80 font-bold">
SWF Image Viewer
<div className="w-full flex justify-between items-center mb-4 px-8">
<h2 className="text-2xl text-white mc-text-shadow border-b-2 border-[#373737] pb-1 tracking-widest uppercase font-bold">
SWF Editor
</h2>
<button
onClick={handleBack}
className="text-[#A0A0A0] hover:text-white transition-colors mc-text-shadow uppercase tracking-widest text-sm"
>
Return {"->"}
</button>
<div className="flex gap-4">
<button
onClick={() => fileInputRef.current?.click()}
className="px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Open SWF
</button>
<button
onClick={handleSaveSwf}
disabled={!swfData}
className={`px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none ${!swfData ? "opacity-50 grayscale" : ""}`}
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Save SWF
</button>
</div>
</div>
<div
className="w-full h-110 p-6 flex flex-col shadow-2xl relative"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<div className="w-full flex items-center justify-between gap-4 bg-black/40 p-4 border-2 border-[#373737] mb-4">
<div className="flex-1">
<label className="block w-full text-center p-3 border-2 border-dashed border-[#A0A0A0] hover:border-white hover:bg-white/5 cursor-pointer transition-colors text-[#A0A0A0] hover:text-white uppercase mc-text-shadow">
Select SWF File
<input
type="file"
accept=".swf"
onChange={handleFileChange}
className="hidden"
/>
</label>
</div>
{fileName && <div className="text-white mc-text-shadow truncate w-1/3 text-right">{fileName}</div>}
</div>
<input type="file" ref={fileInputRef} onChange={handleFileChange} className="hidden" accept=".swf" />
<input type="file" ref={replaceInputRef} onChange={handleReplace} className="hidden" />
{error && (
<div className="w-full p-3 bg-red-900/50 border-2 border-red-500 text-red-200 mc-text-shadow text-center mb-4">
{error}
{!swfData ? (
<div className="flex-1 w-full flex flex-col items-center justify-center p-12"
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<img src="/images/tools/arc.png" className="w-32 h-32 mb-8 opacity-20 grayscale" style={{ imageRendering: "pixelated" }} />
<h3 className="text-2xl text-white/40 mc-text-shadow italic">Open an SWF file to begin editing</h3>
</div>
) : (
<div className="flex-1 w-full flex gap-4 overflow-hidden">
<div className="w-1/3 flex flex-col p-4" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<div className="mb-4">
<input
type="text"
placeholder="Search assets..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
/>
</div>
)}
{loading && (
<div className="w-full text-center text-white mc-text-shadow mb-4">Loading...</div>
)}
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar space-y-1">
{filteredImages.map(img => (
<div
key={img.id}
onClick={() => { playPressSound(); setSelectedImageId(img.id); }}
className={`flex items-center gap-3 p-3 cursor-pointer transition-all border-l-4 ${selectedImageId === img.id
? "bg-[#FFFF55]/10 border-[#FFFF55] text-[#FFFF55]"
: "border-transparent hover:bg-white/5 text-white/60 hover:text-white"
}`}
>
<img src="/images/tools/arc.png" className={`w-5 h-5 object-contain ${selectedImageId === img.id ? "" : "grayscale"}`} style={{ imageRendering: "pixelated" }} />
<div className="flex flex-col min-w-0">
<span className="text-sm font-bold truncate">ID: {img.id} {img.name ? `- ${img.name}` : ""}</span>
<span className="text-[10px] uppercase opacity-60">{img.type} {img.width ? `(${img.width}x${img.height})` : ""}</span>
</div>
</div>
))}
</div>
</div>
<div className="flex-1 w-full overflow-y-auto pr-2 custom-scrollbar">
{images.length > 0 && (
<div className="text-[#A0A0A0] mc-text-shadow mb-2 text-sm uppercase px-1">
Found {images.length} Image(s)
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{images.map((img, i) => (
<div
key={`${img.id}-${i}`}
onClick={() => handleDownload(img)}
className="cursor-pointer bg-black/60 border-2 border-[#373737] flex flex-col p-2 relative group hover:border-white transition-colors"
title="Click to download"
>
<div className="w-full aspect-square bg-[#202020] flex items-center justify-center overflow-hidden mb-2 pattern-checkerboard">
{img.type === "jpeg" || img.type === "png" || img.type === "gif" ? (
<img src={getImageSrc(img)} className="w-full h-full object-contain" alt={`Tag ${img.id}`} />
) : (
<span className="text-[#A0A0A0] text-xs px-2 text-center">
Raw Bytes<br/>{img.type}<br/>({img.data.length} b)
</span>
)}
</div>
<div className="text-white mc-text-shadow flex flex-col uppercase text-xs truncate w-full">
<div className="flex justify-between w-full">
<span>ID: {img.id}</span>
<span className="text-[#A0A0A0] opacity-80">{img.type}</span>
<div className="w-2/3 flex flex-col p-6 overflow-y-auto" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<AnimatePresence mode="wait">
{!selectedImage ? (
<div className="flex-1 flex flex-col items-center justify-center text-white/20 italic gap-4">
<span>Select an image to view details</span>
</div>
) : (
<motion.div
key={selectedImage.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="flex flex-col h-full"
>
<div className="flex justify-between items-start mb-6 border-b border-[#373737] pb-4">
<div className="flex flex-col gap-1">
<h3 className="text-[#FFFF55] text-2xl mc-text-shadow">
{selectedImage.name || `Bitmap ${selectedImage.id}`}
</h3>
<div className="flex gap-4 text-xs uppercase tracking-widest text-white/40">
<span>Character ID: <span className="text-white/80">{selectedImage.id}</span></span>
<span>Type: <span className="text-white/80">{selectedImage.type}</span></span>
{selectedImage.width && <span>Size: <span className="text-white/80">{selectedImage.width}x{selectedImage.height}</span></span>}
</div>
</div>
{img.name && (
<span className="text-[#FFFF55] truncate w-full" title={img.name}>
{img.name}
</span>
</div>
<div className="flex-1 bg-black/40 border-2 border-[#373737] mb-6 flex items-center justify-center overflow-hidden relative group pattern-checkerboard">
{imageUrls[selectedImage.id] ? (
<img
src={imageUrls[selectedImage.id]}
className="max-w-full max-h-full object-contain"
style={{ imageRendering: "pixelated" }}
alt={`Bitmap ${selectedImage.id}`}
/>
) : (
<div className="text-white/20 italic">Loading Preview...</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
<div className="flex gap-4 mt-auto">
<button
onClick={() => handleDownload(selectedImage)}
className="flex-1 py-3 text-white mc-text-shadow text-lg transition-all hover:text-[#FFFF55]"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Export Bitmap
</button>
<button
onClick={() => replaceInputRef.current?.click()}
className="flex-1 py-3 text-white mc-text-shadow text-lg transition-all hover:text-[#FFFF55]"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Replace Bitmap
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)}
<button
onClick={handleBack}
className="w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-6 outline-none border-none hover:text-[#FFFF55] text-white"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
>
Back
</button>
<AnimatePresence>
{notification && (
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 50, scale: 0.9 }}
className="fixed top-12 right-12 z-[110] p-6 flex flex-col items-center justify-center min-w-[240px]"
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
>
<span className="text-white text-lg mc-text-shadow font-bold tracking-widest uppercase">
{notification.message}
</span>
</motion.div>
)}
</AnimatePresence>
<style>{`
.pattern-checkerboard {
background-color: #2a2a2a;

View file

@ -159,7 +159,7 @@ export default function App() {
message={updateMessage}
onClose={clearUpdateMessage}
onClick={() =>
TauriService.openUrl("https://lce-hub.github.io/")
TauriService.openUrl("https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest")
}
title="Update Available!"
variant="update"
@ -279,7 +279,7 @@ export default function App() {
onClick={() => {
audio.playPressSound();
setIsUiHidden(false);
setActiveView("swf-viewer");
setActiveView("swf");
}}
className="pointer-events-auto hover:scale-110 active:scale-95 transition-transform outline-none bg-transparent border-none flex flex-col items-center gap-2 group"
>
@ -292,7 +292,7 @@ export default function App() {
}}
/>
<span className="text-[#FFFF55] text-sm mc-text-shadow opacity-0 group-hover:opacity-100 transition-opacity">
SWF Viewer
SWF Editor
</span>
</button>
</motion.div>
@ -403,8 +403,8 @@ export default function App() {
{activeView === "options-editor" && (
<OptionsEditorView key="options-editor-view" />
)}
{activeView === "swf-viewer" && (
<SwfView key="swf-viewer-view" />
{activeView === "swf-editor" && (
<SwfView key="swf-editor-view" />
)}
{activeView === "skins" && <SkinsView key="skins-view" />}
{activeView === "screenshots" && (

View file

@ -1,22 +1,26 @@
import pako from "pako";
export interface SwfTag {
code: number;
data: Uint8Array;
}
export interface SwfImage {
id: number;
type: "jpeg" | "png" | "lossless" | "unknown" | "gif";
name?: string;
data: Uint8Array;
alphaData?: Uint8Array;
metadata?: any;
width?: number;
height?: number;
format?: number;
hasAlpha?: boolean;
}
export class SwfService {
static extractImages(buffer: Uint8Array): SwfImage[] {
if (buffer.length < 8) {
throw new Error("Invalid SWF: file too small");
}
static parse(buffer: Uint8Array): { version: number; compressed: boolean; frameHeader: Uint8Array; tags: SwfTag[] } {
if (buffer.length < 8) throw new Error("Invalid SWF: file too small");
const sig = String.fromCharCode(buffer[0], buffer[1], buffer[2]);
// const version = buffer[3];
// const fileLength = buffer[4] | (buffer[5] << 8) | (buffer[6] << 16) | (buffer[7] << 24); //neo: small endian
const version = buffer[3];
let data: Uint8Array;
if (sig === "FWS") {
data = buffer.slice(8);
@ -31,136 +35,243 @@ export class SwfService {
}
let offset = 0;
if (data.length === 0) return [];
const nbits = data[0] >> 3;
const rectBytes = Math.ceil((5 + nbits * 4) / 8);
offset += rectBytes;
offset += 4;
const images: SwfImage[] = [];
const nameMap: Record<number, string> = {};
let jpegTables: Uint8Array | null = null;
const getRealType = (imgData: Uint8Array): "jpeg" | "png" | "gif" | "lossless" | "unknown" => {
if (imgData.length >= 4) {
if (imgData[0] === 0x89 && imgData[1] === 0x50 && imgData[2] === 0x4e && imgData[3] === 0x47) return "png";
if (imgData[0] === 0x47 && imgData[1] === 0x49 && imgData[2] === 0x46 && imgData[3] === 0x38) return "gif";
if (imgData[0] === 0xff && imgData[1] === 0xd8) return "jpeg";
}
return "unknown";
};
const fixJpeg = (imgData: Uint8Array, jtt: Uint8Array | null = null): Uint8Array => {
let combined = imgData;
if (jtt && jtt.length > 0) {
combined = new Uint8Array(jtt.length + imgData.length);
combined.set(jtt, 0);
combined.set(imgData, jtt.length);
}
const out: number[] = [];
for (let i = 0; i < combined.length; i++) {
if (i + 3 < combined.length &&
combined[i] === 0xff && combined[i + 1] === 0xd9 &&
combined[i + 2] === 0xff && combined[i + 3] === 0xd8) {
i += 3;
continue;
}
out.push(combined[i]);
}
return new Uint8Array(out);
};
const frameHeaderLength = rectBytes + 4;
const frameHeader = data.slice(0, frameHeaderLength);
offset = frameHeaderLength;
const tags: SwfTag[] = [];
while (offset < data.length) {
if (offset + 2 > data.length) break;
const tagCodeAndLength = data[offset] | (data[offset + 1] << 8);
const tagHeader = data[offset] | (data[offset + 1] << 8);
offset += 2;
let tagType = tagCodeAndLength >> 6;
let length = tagCodeAndLength & 0x3F;
if (length === 0x3F) {
if (offset + 4 > data.length) break;
const code = tagHeader >> 6;
let length = tagHeader & 0x3f;
if (length === 0x3f) {
length = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24);
offset += 4;
}
const tagData = data.slice(offset, offset + length);
tags.push({ code, data: tagData });
offset += length;
if (code === 0) break;
}
if (tagType === 0) break;
if (offset + length > data.length) break;
return { version, compressed: sig === "CWS", frameHeader, tags };
}
if (tagType === 8) {
//neo: JPEGTables
jpegTables = data.slice(offset, offset + length);
} else if (tagType === 56 || tagType === 76) {
let ptr = offset;
const numAssets = data[ptr] | (data[ptr + 1] << 8);
ptr += 2;
static serialize(version: number, compressed: boolean, frameHeader: Uint8Array, tags: SwfTag[]): Uint8Array {
const payloads: Uint8Array[] = [frameHeader];
let bodyLength = frameHeader.length;
for (const tag of tags) {
const isLong = tag.data.length >= 0x3f;
const headerLen = isLong ? 6 : 2;
const header = new Uint8Array(headerLen);
const h = (tag.code << 6) | (isLong ? 0x3f : tag.data.length);
header[0] = h & 0xff;
header[1] = (h >> 8) & 0xff;
if (isLong) {
header[2] = tag.data.length & 0xff;
header[3] = (tag.data.length >> 8) & 0xff;
header[4] = (tag.data.length >> 16) & 0xff;
header[5] = (tag.data.length >> 24) & 0xff;
}
payloads.push(header, tag.data);
bodyLength += headerLen + tag.data.length;
}
const uncompressedBody = new Uint8Array(bodyLength);
let offset = 0;
for (const p of payloads) {
uncompressedBody.set(p, offset);
offset += p.length;
}
const fileLength = 8 + uncompressedBody.length;
const finalBody = compressed ? pako.deflate(uncompressedBody) : uncompressedBody;
const result = new Uint8Array(8 + finalBody.length);
result.set(new TextEncoder().encode(compressed ? "CWS" : "FWS"), 0);
result[3] = version;
result[4] = fileLength & 0xff;
result[5] = (fileLength >> 8) & 0xff;
result[6] = (fileLength >> 16) & 0xff;
result[7] = (fileLength >> 24) & 0xff;
result.set(finalBody, 8);
return result;
}
static extractImages(tags: SwfTag[]): SwfImage[] {
const images: SwfImage[] = [];
const nameMap: Record<number, string> = {};
let jpegTables: Uint8Array | null = null;
for (const tag of tags) {
if (tag.code === 8) {
jpegTables = tag.data;
} else if (tag.code === 56 || tag.code === 76) {
const numAssets = tag.data[0] | (tag.data[1] << 8);
let ptr = 2;
for (let i = 0; i < numAssets; i++) {
if (ptr + 2 > offset + length) break;
const charId = data[ptr] | (data[ptr + 1] << 8);
const charId = tag.data[ptr] | (tag.data[ptr + 1] << 8);
ptr += 2;
let end = ptr;
while (end < offset + length && data[end] !== 0) {
end++;
}
const nameStr = new TextDecoder().decode(data.slice(ptr, end));
nameMap[charId] = nameStr;
while (end < tag.data.length && tag.data[end] !== 0) end++;
nameMap[charId] = new TextDecoder().decode(tag.data.slice(ptr, end));
ptr = end + 1;
}
} else if (tagType === 6) {
//neo: DefineBits
const characterId = data[offset] | (data[offset + 1] << 8);
const rawData = data.slice(offset + 2, offset + length);
const fixedData = fixJpeg(rawData, jpegTables);
images.push({
id: characterId,
type: getRealType(fixedData) as any,
data: fixedData
});
} else if (tagType === 21) {
//neo: this is DefineBitsJPEG2
const characterId = data[offset] | (data[offset + 1] << 8);
const rawData = data.slice(offset + 2, offset + length);
const fixedData = fixJpeg(rawData);
images.push({
id: characterId,
type: getRealType(fixedData) as any,
data: fixedData
});
} else if (tagType === 35) {
//neo: this is DefineBitsJPEG3
const characterId = data[offset] | (data[offset + 1] << 8);
const alphaOffset = data[offset + 2] | (data[offset + 3] << 8) | (data[offset + 4] << 16) | (data[offset + 5] << 24);
const imgRawData = data.slice(offset + 6, offset + 6 + alphaOffset);
const alphaDataRaw = data.slice(offset + 6 + alphaOffset, offset + length);
let alphaData;
try {
alphaData = pako.inflate(alphaDataRaw);
} catch (e) {
//neo: idfk what to do here, it should have been zlib compressed
}
} else if (tag.code === 6 || tag.code === 21) {
const charId = tag.data[0] | (tag.data[1] << 8);
const imgData = tag.data.slice(2);
const fixed = this.fixJpeg(imgData, tag.code === 6 ? jpegTables : null);
images.push({ id: charId, type: "jpeg", data: fixed, name: nameMap[charId] });
} else if (tag.code === 35) {
const charId = tag.data[0] | (tag.data[1] << 8);
const alphaOffset = tag.data[2] | (tag.data[3] << 8) | (tag.data[4] << 16) | (tag.data[5] << 24);
const imgData = tag.data.slice(6, 6 + alphaOffset);
const alphaRaw = tag.data.slice(6 + alphaOffset);
let alpha = undefined;
try { alpha = pako.inflate(alphaRaw); } catch (e) { }
images.push({ id: charId, type: "jpeg", data: this.fixJpeg(imgData), alphaData: alpha, name: nameMap[charId] });
} else if (tag.code === 20 || tag.code === 36) {
const charId = tag.data[0] | (tag.data[1] << 8);
const format = tag.data[2];
const width = tag.data[3] | (tag.data[4] << 8);
const height = tag.data[5] | (tag.data[6] << 8);
const hasAlpha = tag.code === 36;
const raw = tag.data.slice(tag.code === 20 ? 7 : 7);
const fixedData = fixJpeg(imgRawData);
images.push({
id: characterId,
type: getRealType(fixedData) as any,
data: fixedData,
alphaData: alphaData
});
} else if (tagType === 20 || tagType === 36) {
//neo: this is either DefineBitsLossless or DefineBitsLossless2
const characterId = data[offset] | (data[offset + 1] << 8);
images.push({
id: characterId,
id: charId,
type: "lossless",
data: data.slice(offset + 2, offset + length)
data: raw,
width,
height,
format,
hasAlpha,
name: nameMap[charId]
});
}
offset += length;
}
for (const img of images) {
if (nameMap[img.id]) {
img.name = nameMap[img.id];
}
}
return images;
}
private static fixJpeg(data: Uint8Array, jtt: Uint8Array | null = null): Uint8Array {
let combined = data;
if (jtt && jtt.length > 0) {
let tLen = jtt.length;
if (jtt[tLen - 2] === 0xff && jtt[tLen - 1] === 0xd9) tLen -= 2;
let start = 0;
if (data[0] === 0xff && data[1] === 0xd8) start = 2;
combined = new Uint8Array(tLen + data.length - start);
combined.set(jtt.slice(0, tLen), 0);
combined.set(data.slice(start), tLen);
}
return combined;
}
static async decodeLosslessToRGBA(img: SwfImage): Promise<Uint8Array> {
if (img.type !== "lossless" || !img.width || !img.height) return new Uint8Array(0);
const decoded = pako.inflate(img.data);
const rgba = new Uint8Array(img.width * img.height * 4);
if (img.format === 3) {
const colorTableSize = decoded[0] + 1;
let ptr = 1;
const palette: number[][] = [];
for (let i = 0; i < colorTableSize; i++) {
if (img.hasAlpha) {
const a = decoded[ptr++];
const r_pm = decoded[ptr++];
const g_pm = decoded[ptr++];
const b_pm = decoded[ptr++];
palette.push([
a > 0 ? Math.min(255, (r_pm * 255) / a) : 0,
a > 0 ? Math.min(255, (g_pm * 255) / a) : 0,
a > 0 ? Math.min(255, (b_pm * 255) / a) : 0,
a
]);
} else {
palette.push([decoded[ptr++], decoded[ptr++], decoded[ptr++], 255]);
}
}
const rowStride = Math.ceil(img.width / 4) * 4;
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
const idx = decoded[ptr + y * rowStride + x];
const color = palette[idx] || [0, 0, 0, 0];
const offset = (y * img.width + x) * 4;
rgba[offset] = color[0];
rgba[offset + 1] = color[1];
rgba[offset + 2] = color[2];
rgba[offset + 3] = color[3];
}
}
} else if (img.format === 4) {
const rowStride = Math.ceil((img.width * 2) / 4) * 4;
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
const p = decoded[y * rowStride + x * 2] | (decoded[y * rowStride + x * 2 + 1] << 8);
const r = ((p >> 10) & 0x1f) << 3;
const g = ((p >> 5) & 0x1f) << 3;
const b = (p & 0x1f) << 3;
const offset = (y * img.width + x) * 4;
rgba[offset] = r;
rgba[offset + 1] = g;
rgba[offset + 2] = b;
rgba[offset + 3] = 255;
}
}
} else if (img.format === 5) {
let ptr = 0;
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
const a = decoded[ptr++];
const r_pm = decoded[ptr++];
const g_pm = decoded[ptr++];
const b_pm = decoded[ptr++];
const offset = (y * img.width + x) * 4;
if (img.hasAlpha) {
rgba[offset] = a > 0 ? Math.min(255, (r_pm * 255) / a) : 0;
rgba[offset + 1] = a > 0 ? Math.min(255, (g_pm * 255) / a) : 0;
rgba[offset + 2] = a > 0 ? Math.min(255, (b_pm * 255) / a) : 0;
rgba[offset + 3] = a;
} else {
rgba[offset] = r_pm;
rgba[offset + 1] = g_pm;
rgba[offset + 2] = b_pm;
rgba[offset + 3] = 255;
}
}
}
}
return rgba;
}
static updateImageTag(tags: SwfTag[], charId: number, newData: Uint8Array, type: SwfImage["type"]): SwfTag[] {
return tags.map(tag => {
if (tag.code === 6 || tag.code === 21 || tag.code === 35 || tag.code === 20 || tag.code === 36) {
const id = tag.data[0] | (tag.data[1] << 8);
if (id === charId) {
let payload: Uint8Array;
if (type === "jpeg") {
const header = new Uint8Array(tag.code === 35 ? 6 : 2);
header[0] = id & 0xff;
header[1] = (id >> 8) & 0xff;
if (tag.code === 35) {
payload = newData;
} else {
payload = new Uint8Array(2 + newData.length);
payload.set(header, 0);
payload.set(newData, 2);
}
} else {
payload = newData;
}
return { ...tag, data: payload };
}
}
return tag;
});
}
}