initial commit
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user