mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-18 00:07:09 +00:00
feat: SWF image viewer
This commit is contained in:
parent
1febea2f68
commit
f6cac74de4
184
src/components/views/SwfView.tsx
Normal file
184
src/components/views/SwfView.tsx
Normal file
|
|
@ -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<SwfImage[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fileName, setFileName] = useState<string | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
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"
|
||||
>
|
||||
<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
|
||||
</h2>
|
||||
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="text-[#A0A0A0] hover:text-white transition-colors mc-text-shadow uppercase tracking-widest text-sm"
|
||||
>
|
||||
Return {"->"}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="w-full text-center text-white mc-text-shadow mb-4">Loading...</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>
|
||||
{img.name && (
|
||||
<span className="text-[#FFFF55] truncate w-full" title={img.name}>
|
||||
{img.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
.pattern-checkerboard {
|
||||
background-color: #2a2a2a;
|
||||
background-image: linear-gradient(45deg, #1a1a1a 25%, transparent 25%, transparent 75%, #1a1a1a 75%, #1a1a1a),
|
||||
linear-gradient(45deg, #1a1a1a 25%, transparent 25%, transparent 75%, #1a1a1a 75%, #1a1a1a);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 8px 8px;
|
||||
}
|
||||
`}</style>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ export function useGameManager({
|
|||
} else {
|
||||
setGameUpdateMessage(null);
|
||||
}
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [profile, editions]);
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<AchievementToast
|
||||
message={game.gameUpdateMessage}
|
||||
onClose={() => 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() {
|
|||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{isUiHidden && !displayIsDay && activeView == "devtools" && (
|
||||
<motion.div
|
||||
key="secret-swf-btn"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="absolute inset-0 z-[100] flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
audio.playPressSound();
|
||||
setIsUiHidden(false);
|
||||
setActiveView("swf-viewer");
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<img
|
||||
src="/images/tools/pck.png"
|
||||
className="w-16 h-16 cursor-pointer object-contain opacity-50 group-hover:opacity-100 drop-shadow-[0_4px_4px_rgba(0,0,0,1)] grayscale group-hover:grayscale-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = "/images/Button_Background.png";
|
||||
}}
|
||||
/>
|
||||
<span className="text-[#FFFF55] text-sm mc-text-shadow opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
SWF Viewer
|
||||
</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
@ -370,6 +402,9 @@ export default function App() {
|
|||
{activeView === "options-editor" && (
|
||||
<OptionsEditorView key="options-editor-view" />
|
||||
)}
|
||||
{activeView === "swf-viewer" && (
|
||||
<SwfView key="swf-viewer-view" />
|
||||
)}
|
||||
{activeView === "skins" && <SkinsView key="skins-view" />}
|
||||
{activeView === "screenshots" && (
|
||||
<ScreenshotsView key="screenshots-view" />
|
||||
|
|
|
|||
166
src/services/SwfService.ts
Normal file
166
src/services/SwfService.ts
Normal file
|
|
@ -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<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);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue