195 lines
6.6 KiB
Python
195 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
SWF Inspector - Reads SWF binary headers to extract metadata without
|
||
running the file.
|
||
|
||
Parses: SWF version, frame rate, frame count, dimensions, compression type.
|
||
Reference: https://open-flash.github.io/mirrors/swf-spec-19.pdf
|
||
"""
|
||
|
||
import logging
|
||
import struct
|
||
import zlib
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
log = logging.getLogger("swf_inspector")
|
||
|
||
# SWF signatures
|
||
SIG_UNCOMPRESSED = b"FWS"
|
||
SIG_ZLIB = b"CWS"
|
||
SIG_LZMA = b"ZWS"
|
||
|
||
|
||
class SWFInspector:
|
||
"""
|
||
Reads SWF file headers and returns useful metadata.
|
||
|
||
Usage
|
||
-----
|
||
info = SWFInspector().inspect(Path("my.swf"))
|
||
print(info)
|
||
# {'version': 8, 'compression': 'none', 'fps': 24.0,
|
||
# 'frame_count': 300, 'width': 550, 'height': 400}
|
||
"""
|
||
|
||
def inspect(self, swf_path: Path) -> dict:
|
||
"""Return a dict of metadata about the SWF file."""
|
||
result = {
|
||
"path": str(swf_path),
|
||
"size_bytes": swf_path.stat().st_size if swf_path.exists() else None,
|
||
"version": None,
|
||
"compression": None,
|
||
"fps": None,
|
||
"frame_count": None,
|
||
"width": None,
|
||
"height": None,
|
||
"estimated_duration_seconds": None,
|
||
"error": None,
|
||
}
|
||
|
||
if not swf_path.exists():
|
||
result["error"] = "File not found"
|
||
return result
|
||
|
||
try:
|
||
with open(swf_path, "rb") as f:
|
||
header = f.read(8)
|
||
|
||
if len(header) < 8:
|
||
result["error"] = "File too small to be a valid SWF"
|
||
return result
|
||
|
||
sig = header[:3]
|
||
version = header[3]
|
||
file_length = struct.unpack_from("<I", header, 4)[0]
|
||
|
||
result["version"] = version
|
||
result["file_length"] = file_length
|
||
|
||
if sig == SIG_UNCOMPRESSED:
|
||
result["compression"] = "none"
|
||
self._parse_body(swf_path, result, offset=8)
|
||
elif sig == SIG_ZLIB:
|
||
result["compression"] = "zlib"
|
||
self._parse_zlib_body(swf_path, result)
|
||
elif sig == SIG_LZMA:
|
||
result["compression"] = "lzma"
|
||
result["error"] = "LZMA-compressed SWF: metadata extraction limited (version 13+)"
|
||
# Still try to get basic info from what's available
|
||
else:
|
||
result["error"] = f"Unknown SWF signature: {sig!r}"
|
||
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
log.debug(f"SWF inspection error for {swf_path}: {e}", exc_info=True)
|
||
|
||
# Compute estimated duration
|
||
if result["fps"] and result["frame_count"]:
|
||
result["estimated_duration_seconds"] = round(
|
||
result["frame_count"] / result["fps"], 2
|
||
)
|
||
|
||
return result
|
||
|
||
def _parse_body(self, swf_path: Path, result: dict, offset: int, data: bytes = None):
|
||
"""Parse the uncompressed body of a SWF starting at `offset`."""
|
||
try:
|
||
if data is None:
|
||
with open(swf_path, "rb") as f:
|
||
f.seek(offset)
|
||
data = f.read(64) # Only need first ~32 bytes
|
||
|
||
pos = 0
|
||
# RECT structure: Nbits (5 bits) then 4 values each Nbits wide
|
||
if len(data) < 4:
|
||
return
|
||
|
||
nbits = (data[pos] >> 3) & 0x1F # top 5 bits of first byte
|
||
rect_bits = 5 + 4 * nbits
|
||
rect_bytes = (rect_bits + 7) // 8
|
||
|
||
if len(data) < rect_bytes + 4:
|
||
return
|
||
|
||
# Parse RECT fields (Xmin, Xmax, Ymin, Ymax) in twips (1/20 px)
|
||
bits = int.from_bytes(data[pos : pos + rect_bytes], "big")
|
||
total_bits = rect_bytes * 8
|
||
shift = total_bits - 5 - nbits
|
||
xmin = self._signed_bits(bits >> shift, nbits)
|
||
shift -= nbits
|
||
xmax = self._signed_bits(bits >> shift, nbits)
|
||
shift -= nbits
|
||
ymin = self._signed_bits(bits >> shift, nbits)
|
||
shift -= nbits
|
||
ymax = self._signed_bits(bits >> shift, nbits)
|
||
|
||
result["width"] = round((xmax - xmin) / 20)
|
||
result["height"] = round((ymax - ymin) / 20)
|
||
|
||
pos += rect_bytes
|
||
|
||
# FrameRate: FIXED8 (8.8 fixed point, little-endian)
|
||
if pos + 2 > len(data):
|
||
return
|
||
frame_rate_raw = struct.unpack_from("<H", data, pos)[0]
|
||
result["fps"] = round(frame_rate_raw / 256.0, 2)
|
||
pos += 2
|
||
|
||
# FrameCount: UI16
|
||
if pos + 2 > len(data):
|
||
return
|
||
result["frame_count"] = struct.unpack_from("<H", data, pos)[0]
|
||
|
||
except Exception as e:
|
||
log.debug(f"Body parse error: {e}")
|
||
|
||
def _parse_zlib_body(self, swf_path: Path, result: dict):
|
||
"""Decompress zlib SWF body and parse it."""
|
||
try:
|
||
with open(swf_path, "rb") as f:
|
||
f.seek(8) # skip signature + version + file_length
|
||
compressed = f.read()
|
||
decompressed = zlib.decompress(compressed)
|
||
self._parse_body(swf_path, result, offset=0, data=decompressed)
|
||
except zlib.error as e:
|
||
result["error"] = f"zlib decompression failed: {e}"
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
|
||
@staticmethod
|
||
def _signed_bits(value: int, nbits: int) -> int:
|
||
"""Convert an unsigned integer to signed given bit width."""
|
||
value &= (1 << nbits) - 1
|
||
if value >= (1 << (nbits - 1)):
|
||
value -= (1 << nbits)
|
||
return value
|
||
|
||
def inspect_many(self, paths: list) -> dict:
|
||
"""Inspect multiple SWF files, returning {filename: info} dict."""
|
||
return {p.name: self.inspect(p) for p in paths}
|
||
|
||
def print_report(self, swf_path: Path):
|
||
"""Pretty-print inspection results for a single SWF."""
|
||
info = self.inspect(swf_path)
|
||
print(f"\n{'='*50}")
|
||
print(f"SWF: {info['path']}")
|
||
print(f"{'='*50}")
|
||
if info.get("error"):
|
||
print(f" ⚠ Error: {info['error']}")
|
||
print(f" Version : {info['version']}")
|
||
print(f" Compression: {info['compression']}")
|
||
print(f" Dimensions : {info['width']} × {info['height']} px")
|
||
print(f" FPS : {info['fps']}")
|
||
print(f" Frames : {info['frame_count']}")
|
||
print(f" Est. length: {info['estimated_duration_seconds']}s")
|
||
print(f" File size : {info['size_bytes']:,} bytes" if info['size_bytes'] else " File size : N/A")
|
||
print()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
inspector = SWFInspector()
|
||
for path in sys.argv[1:]:
|
||
inspector.print_report(Path(path))
|