From f6cac74de4c71a0dddf401341e6942d6e34e7e01 Mon Sep 17 00:00:00 2001 From: neoapps-dev Date: Wed, 22 Apr 2026 14:43:22 +0300 Subject: [PATCH] feat: SWF image viewer --- src/components/views/SwfView.tsx | 184 +++++++++++++++++++++++++++++++ src/hooks/useGameManager.ts | 2 +- src/pages/App.tsx | 39 ++++++- src/services/SwfService.ts | 166 ++++++++++++++++++++++++++++ 4 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 src/components/views/SwfView.tsx create mode 100644 src/services/SwfService.ts diff --git a/src/components/views/SwfView.tsx b/src/components/views/SwfView.tsx new file mode 100644 index 0000000..b73420a --- /dev/null +++ b/src/components/views/SwfView.tsx @@ -0,0 +1,184 @@ +import React, { useState, useCallback } from "react"; +import { motion } from "framer-motion"; +import { useConfig, useAudio, useUI } from "../../context/LauncherContext"; +import { SwfImage, SwfService } from "../../services/SwfService"; + +export default function SwfView() { + const { animationsEnabled } = useConfig(); + const { playBackSound } = useAudio(); + const { setActiveView } = useUI(); + + const [images, setImages] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [fileName, setFileName] = useState(null); + + const handleBack = useCallback(() => { + playBackSound(); + setActiveView("devtools"); + }, [playBackSound, setActiveView]); + + const processFile = async (file: File) => { + setLoading(true); + setError(null); + setFileName(file.name); + try { + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const extracted = await SwfService.extractImages(bytes); + setImages(extracted); + if (extracted.length === 0) { + setError("No supported images found in SWF."); + } + } catch (e: any) { + console.error(e); + setError(e.message || "Failed to process SWF"); + setImages([]); + } finally { + setLoading(false); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + processFile(e.target.files[0]); + } + }; + + 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) => { + let blob: Blob; + let ext: string; + if (img.type === "jpeg") { + blob = new Blob([new Uint8Array(img.data)], { 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 { + blob = new Blob([new Uint8Array(img.data)], { 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); + }; + + return ( + +
+

+ SWF Image Viewer +

+ + +
+ +
+
+
+ +
+ {fileName &&
{fileName}
} +
+ + {error && ( +
+ {error} +
+ )} + {loading && ( +
Loading...
+ )} + +
+ {images.length > 0 && ( +
+ Found {images.length} Image(s) +
+ )} +
+ {images.map((img, i) => ( +
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" + > +
+ {img.type === "jpeg" || img.type === "png" || img.type === "gif" ? ( + {`Tag + ) : ( + + Raw Bytes
{img.type}
({img.data.length} b) +
+ )} +
+
+
+ ID: {img.id} + {img.type} +
+ {img.name && ( + + {img.name} + + )} +
+
+ ))} +
+
+
+ +
+ ); +} diff --git a/src/hooks/useGameManager.ts b/src/hooks/useGameManager.ts index 548792f..dd43f90 100644 --- a/src/hooks/useGameManager.ts +++ b/src/hooks/useGameManager.ts @@ -88,7 +88,7 @@ export function useGameManager({ } else { setGameUpdateMessage(null); } - } catch(e) { + } catch (e) { console.error(e); } }, [profile, editions]); diff --git a/src/pages/App.tsx b/src/pages/App.tsx index ea0d6b1..43b774b 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -14,6 +14,7 @@ import GrfEditorView from "../components/views/GrfEditorView"; import ColEditorView from "../components/views/ColEditorView"; import OptionsEditorView from "../components/views/OptionsEditorView"; import ScreenshotsView from "../components/views/ScreenshotsView"; +import SwfView from "../components/views/SwfView"; import SkinViewer from "../components/common/SkinViewer"; import TeamModal from "../components/modals/TeamModal"; import PanoramaBackground from "../components/common/PanoramaBackground"; @@ -167,9 +168,9 @@ export default function App() { game.setGameUpdateMessage(null)} - onClick={() => { + onClick={() => { game.setGameUpdateMessage(null); - game.toggleInstall(config.profile); + game.toggleInstall(config.profile); }} title="Game Update Available!" variant="update" @@ -265,6 +266,37 @@ export default function App() { )} + + {isUiHidden && !displayIsDay && activeView == "devtools" && ( + + + + )} )} @@ -370,6 +402,9 @@ export default function App() { {activeView === "options-editor" && ( )} + {activeView === "swf-viewer" && ( + + )} {activeView === "skins" && } {activeView === "screenshots" && ( diff --git a/src/services/SwfService.ts b/src/services/SwfService.ts new file mode 100644 index 0000000..ff87336 --- /dev/null +++ b/src/services/SwfService.ts @@ -0,0 +1,166 @@ +import pako from "pako"; +export interface SwfImage { + id: number; + type: "jpeg" | "png" | "lossless" | "unknown" | "gif"; + name?: string; + data: Uint8Array; + alphaData?: Uint8Array; + metadata?: any; +} + +export class SwfService { + static extractImages(buffer: Uint8Array): SwfImage[] { + 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 + let data: Uint8Array; + if (sig === "FWS") { + data = buffer.slice(8); + } else if (sig === "CWS") { + try { + data = pako.inflate(buffer.slice(8)); + } catch (e) { + throw new Error("Failed to decompress CWS SWF"); + } + } else { + throw new Error(`Unsupported SWF signature: ${sig}`); + } + + 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 = {}; + 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); + }; + + while (offset < data.length) { + if (offset + 2 > data.length) break; + const tagCodeAndLength = 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; + length = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24); + offset += 4; + } + + if (tagType === 0) break; + if (offset + length > data.length) break; + + 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; + for (let i = 0; i < numAssets; i++) { + if (ptr + 2 > offset + length) break; + const charId = data[ptr] | (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; + 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 + } + + 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, + type: "lossless", + data: data.slice(offset + 2, offset + length) + }); + } + + offset += length; + } + + for (const img of images) { + if (nameMap[img.id]) { + img.name = nameMap[img.id]; + } + } + + return images; + } +}