"""fla extractor: view what's inside Flash/Animate .fla files without Adobe Animate.

For every folder (or set of .fla files) given, this writes under OUT/<folder>/:
  images/     embedded bitmaps (PNG/JPEG/GIF)
  audio/      embedded sounds (WAV, or MP3 when stored that way)
  animatics/  embedded video clips (e.g. a leica/animatic), muxed with the
              sound of the same name, as .flv -- open these with VLC
  drawings/   the vector artwork of each file (old binary .fla only), drawn as
              SVG with a gallery page per file; animated symbols play in a browser
  shots/      for scene files (those with an animatic), the finished shot as an
              animated SVG instead of a gallery of every library item
  index.html  a page for browsing all of the above
Files already extracted are skipped on later runs (use --force to redo them).

Usage:
  python fla_extract.py                    # the current folder (and its subfolders)
  python fla_extract.py PATH [PATH ...]    # these folders and/or .fla files
  python fla_extract.py --preview OUT.png FILE [FILE ...]
                                           # screenshot SVG/PNG files as a grid
Options:
  --out DIR   where to write (default: ./extracted)
  --force     redo files that were already extracted
  --jobs N    files processed in parallel (default: CPU count)
  --drawings auto|all|shots|none
              auto (default): scene files get an animated shot, other files a
              gallery of all their drawings; all: galleries and shots for every
              file; shots: only the animated shots; none: skip the artwork
  --width N   width of the --preview screenshot in pixels (default 1200)
  --browser PATH
              Chrome/Edge/Chromium to use for --preview (default: found automatically)

Needs only Python 3.8+ (no extra packages). --preview also needs a
Chromium-based browser (Chrome, Edge or Chromium).

The binary .fla vector layout follows the reverse-engineering notes of the
fla-decoder project (https://github.com/eddiemoore/fla-decoder, MIT licence),
extended here with symbol instances, layer masks, frame timing, tweens, colour
effects and corrections for CS3/CS4-era files.
"""
import argparse
import html
import json
import math
import os
import random
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import zipfile
import zlib
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path

RATES = (5512, 11025, 22050, 44100)


# ---------------------------------------------------------------------------
# OLE compound files (.fla from Flash 8 / CS3 / CS4)

class CompoundFile:
    def __init__(self, path):
        with open(path, 'rb') as f:
            self.data = f.read()
        h = self.data[:512]
        if h[:8] != bytes.fromhex('d0cf11e0a1b11ae1'):
            raise ValueError('not an OLE compound file')
        self.ssz = 1 << struct.unpack_from('<H', h, 30)[0]
        self.mssz = 1 << struct.unpack_from('<H', h, 32)[0]
        nfat = struct.unpack_from('<I', h, 44)[0]
        dir_start = struct.unpack_from('<I', h, 48)[0]
        self.cutoff = struct.unpack_from('<I', h, 56)[0]
        minifat_start = struct.unpack_from('<I', h, 60)[0]
        difat_start, ndifat = struct.unpack_from('<II', h, 68)
        difat = list(struct.unpack_from('<109I', h, 76))
        s = difat_start
        for _ in range(ndifat):
            if s >= 0xFFFFFFFE:
                break
            vals = struct.unpack_from('<%dI' % (self.ssz // 4), self._sector(s))
            difat += vals[:-1]
            s = vals[-1]
        difat = [d for d in difat if d < 0xFFFFFFFE][:nfat]
        fat = b''.join(self._sector(d) for d in difat)
        self.fat = struct.unpack('<%dI' % (len(fat) // 4), fat)
        dirdata = self._chain(dir_start, self.fat, self._sector)
        self.entries = []
        for i in range(len(dirdata) // 128):
            e = dirdata[i * 128:(i + 1) * 128]
            nl = struct.unpack_from('<H', e, 64)[0]
            start, size = struct.unpack_from('<IQ', e, 116)
            if self.ssz == 512:
                size &= 0xFFFFFFFF
            self.entries.append((e[:max(nl - 2, 0)].decode('utf-16le', 'replace'), e[66], start, size))
        _, _, root_start, root_size = self.entries[0]
        self.ministream = self._chain(root_start, self.fat, self._sector)[:root_size]
        mf = self._chain(minifat_start, self.fat, self._sector)
        self.minifat = struct.unpack('<%dI' % (len(mf) // 4), mf)

    def _sector(self, n):
        o = (n + 1) * self.ssz
        return self.data[o:o + self.ssz]

    def _minisector(self, n):
        o = n * self.mssz
        return self.ministream[o:o + self.mssz]

    @staticmethod
    def _chain(start, table, read):
        out, s, seen = [], start, set()
        while s < 0xFFFFFFFE and s < len(table) and s not in seen:
            seen.add(s)
            out.append(read(s))
            s = table[s]
        return b''.join(out)

    def streams(self):
        out = {}
        for name, typ, start, size in self.entries:
            if typ != 2:
                continue
            if size < self.cutoff:
                d = self._chain(start, self.minifat, self._minisector)
            else:
                d = self._chain(start, self.fat, self._sector)
            out[name] = d[:size]
        return out


# ---------------------------------------------------------------------------
# Bitmaps

def decode_lossless(d):
    """Flash's stored lossless bitmap (03 05 header) -> (w, h, RGBA bytes)."""
    rowbytes, w, h = struct.unpack_from('<HHH', d, 2)
    has_alpha, compressed = d[24], d[25]
    if compressed:
        pos, comp = 26, bytearray()
        while pos + 2 <= len(d):
            n = struct.unpack_from('<H', d, pos)[0]
            pos += 2
            if n == 0:
                break
            comp += d[pos:pos + n]
            pos += n
        px = zlib.decompress(bytes(comp))
    else:
        px = d[26:]
    if len(px) < rowbytes * h:
        raise ValueError('bitmap data too short')
    px = b''.join(px[y * rowbytes:y * rowbytes + w * 4] for y in range(h))
    out = bytearray(len(px))  # premultiplied ARGB -> straight RGBA
    out[0::4], out[1::4], out[2::4], out[3::4] = px[1::4], px[2::4], px[3::4], px[0::4]
    if has_alpha:
        for i, a in enumerate(px[0::4]):
            if 0 < a < 255:
                j = i * 4
                out[j] = min(255, out[j] * 255 // a)
                out[j + 1] = min(255, out[j + 1] * 255 // a)
                out[j + 2] = min(255, out[j + 2] * 255 // a)
    else:
        out[3::4] = b'\xff' * (w * h)
    return w, h, bytes(out)


def png_bytes(w, h, rgba):
    def chunk(t, b):
        return struct.pack('>I', len(b)) + t + b + struct.pack('>I', zlib.crc32(t + b) & 0xffffffff)
    stride = w * 4
    raw = b''.join(b'\x00' + rgba[y * stride:(y + 1) * stride] for y in range(h))
    return (b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 6, 0, 0, 0))
            + chunk(b'IDAT', zlib.compress(raw, 6)) + chunk(b'IEND', b''))


def as_image(d):
    """(extension, file bytes) if d is a stored bitmap, else None."""
    if d[:2] == b'\xff\xd8':
        return '.jpg', d
    if d[:8] == b'\x89PNG\r\n\x1a\n':
        return '.png', d
    if d[:3] == b'GIF':
        return '.gif', d
    if d[:2] == b'\x03\x05':
        return '.png', png_bytes(*decode_lossless(d))
    return None


# ---------------------------------------------------------------------------
# Sounds

def looks_like_mp3(d):
    if d[:3] == b'ID3':
        return True
    if len(d) < 4 or d[0] != 0xFF or d[1] & 0xE0 != 0xE0:
        return False
    version, layer = (d[1] >> 3) & 3, (d[1] >> 1) & 3
    bitrate, rate = d[2] >> 4, (d[2] >> 2) & 3
    return version != 1 and layer != 0 and bitrate not in (0, 15) and rate != 3


def wav_bytes(pcm, rate, bits, channels):
    align = channels * bits // 8
    return (b'RIFF' + struct.pack('<I', 36 + len(pcm)) + b'WAVEfmt '
            + struct.pack('<IHHIIHH', 16, 1, channels, rate, rate * align, align, bits)
            + b'data' + struct.pack('<I', len(pcm)) + pcm)


def pcm_params(fmt):
    """Flash sound format byte -> (rate, bits, channels)."""
    return RATES[(fmt >> 2) & 3], 16 if fmt & 2 else 8, 2 if fmt & 1 else 1


# ---------------------------------------------------------------------------
# Video (embedded VP6 / Sorenson clips, stored with a frame table)

def parse_video(d):
    """-> dict(width, height, frames=[bytes], keys=[bool], codec) or None."""
    if d[:2] != b'\x03\x04' or len(d) < 80:
        return None
    w, h = struct.unpack_from('<II', d, 29)
    p = None
    for q in range(41, min(len(d) - 17, 400)):
        if d[q] == 1:
            off, size, one, _ = struct.unpack_from('<IIII', d, q + 1)
            if off == 0 and one == 1 and 0 < size < len(d):
                p = q
                break
    if p is None:
        return None
    recs = []
    while p + 17 <= len(d) and d[p] == 1:
        off, size, _, kind = struct.unpack_from('<IIII', d, p + 1)
        recs.append((off, size, kind))
        p += 17
    total = struct.unpack_from('<I', d, p + 1)[0]
    base = p + 5
    if total != recs[-1][0] + recs[-1][1] or base + total > len(d):
        return None
    frames = [d[base + off:base + off + size] for off, size, _ in recs]
    f0 = frames[0]
    if len(f0) > 2 and f0[:2] == b'\x00\x00' and f0[2] & 0x80:
        codec, keys = 2, [(f[4] >> 1) & 3 == 0 if len(f) > 4 else False for f in frames]  # Sorenson H.263
    else:
        codec, keys = 4, [len(f) > 1 and f[1] & 0x80 == 0 for f in frames]  # VP6 (adjust byte + frame)
    return {'width': w, 'height': h, 'frames': frames, 'keys': keys, 'codec': codec}


def amf_metadata(meta):
    out = bytearray(b'\x02' + struct.pack('>H', 10) + b'onMetaData')
    out += b'\x08' + struct.pack('>I', len(meta))
    for k, v in meta.items():
        out += struct.pack('>H', len(k)) + k.encode()
        if isinstance(v, bool):
            out += b'\x01' + bytes([v])
        else:
            out += b'\x00' + struct.pack('>d', float(v))
    return bytes(out + b'\x00\x00\x09')


def flv_bytes(video, fps, pcm=None, fmt=0x0f):
    frames, keys = video['frames'], video['keys']
    out = bytearray(b'FLV\x01' + bytes([0x05 if pcm else 0x01]) + struct.pack('>I', 9) + b'\x00' * 4)

    def tag(kind, ms, payload):
        out.extend(bytes([kind]) + struct.pack('>I', len(payload))[1:] + struct.pack('>I', ms)[1:]
                   + bytes([(ms >> 24) & 0xFF]) + b'\x00\x00\x00' + payload)
        out.extend(struct.pack('>I', 11 + len(payload)))

    meta = {'duration': len(frames) / fps, 'width': video['width'], 'height': video['height'],
            'framerate': fps, 'videocodecid': video['codec']}
    if pcm:
        rate, bits, ch = pcm_params(fmt)
        bpf = bits // 8 * ch
        nsamples = len(pcm) // bpf
        meta.update(audiocodecid=3, audiosamplerate=rate, audiosamplesize=bits, stereo=ch == 2)
        meta['duration'] = max(meta['duration'], nsamples / rate)
        aflag = bytes([0x30 | ((fmt >> 2) & 3) << 2 | (fmt & 3)])
    tag(18, 0, amf_metadata(meta))
    i = 0
    while True:
        ms = round(i * 1000 / fps)
        more = False
        if pcm:
            a, b = round(i * rate / fps), round((i + 1) * rate / fps)
            if a < nsamples:
                tag(8, ms, aflag + pcm[a * bpf:min(b, nsamples) * bpf])
                more = True
        if i < len(frames):
            tag(9, ms, bytes([(0x10 if keys[i] else 0x20) | video['codec']]) + frames[i])
            more = True
        if not more:
            break
        i += 1
    return bytes(out)


# ---------------------------------------------------------------------------
# Reading the two kinds of .fla

def utf16_strings(b):
    return [(m.start(), m.group().decode('utf-16le')) for m in re.finditer(rb'(?:[\x20-\x7e]\x00){3,}', b)]


def read_ole_fla(streams):
    """-> (fps, [asset dicts]) for an old binary .fla's streams."""
    contents = streams.get('Contents', b'')
    strs = utf16_strings(contents)
    names = {}
    for i, (_, s) in enumerate(strs):
        if re.fullmatch(r'Media \d+', s) and i + 1 < len(strs):
            names.setdefault(s, strs[i + 1][1])
    fps = 24.0
    j = contents.find(b'\xff\xfe\xff\x11' + 'legacyLineSpacing'.encode('utf-16le'))
    if j > 13:
        v = struct.unpack_from('<I', contents, j - 13)[0] / 65536
        if 1 <= v <= 120:
            fps = v
    assets = []
    for sname, d in streams.items():
        if not sname.startswith('Media'):
            continue
        label = names.get(sname, sname)
        img = None if d[:2] == b'\x03\x04' else as_image(d)
        if img:
            assets.append({'kind': 'image', 'name': label, 'id': sname, 'ext': img[0], 'data': img[1]})
            continue
        if d[:2] == b'\x03\x04':
            assets.append({'kind': 'video', 'name': label, 'id': sname, 'data': d})
            continue
        # a sound: find its format byte by the sample count stored next to it
        fmt = None
        i = contents.find(sname.encode('utf-16le') + b'\xff\xfe\xff')
        win = contents[i:i + 800] if i >= 0 else b''
        for m in re.finditer(rb'(?=([\x00-\x3f])\x00(.{4}))', win, re.S):
            f, n = m.group(1)[0], struct.unpack('<I', m.group(2))[0]
            if n and n * (2 if f & 2 else 1) * (2 if f & 1 else 1) == len(d):
                fmt = f
                break
        if fmt is None and d[:1] == b'\x03':
            assets.append({'kind': 'unknown', 'name': label, 'id': sname, 'data': d})
            continue
        assets.append({'kind': 'sound', 'name': label, 'id': sname, 'data': d,
                       'fmt': 0x0f if fmt is None else fmt,
                       'mp3': (fmt is None or fmt >> 4 == 2) and looks_like_mp3(d)})
    return fps, assets


SOUND_FMT = re.compile(r'(5|11|22|44)kHz (8|16)bit (Mono|Stereo)', re.I)


def read_zip_fla(path):
    """-> (fps, [asset dicts]) for a CS5+ (XFL-in-zip) .fla."""
    z = zipfile.ZipFile(path)
    doc = z.read('DOMDocument.xml').decode('utf-8', 'replace')
    m = re.search(r'<DOMDocument\b[^>]*\bframeRate="([\d.]+)"', doc)
    fps = float(m.group(1)) if m else 24.0
    attrs = lambda tag: [dict(re.findall(r'(\w+)="([^"]*)"', t)) for t in re.findall(r'<%s\b[^>]*>' % tag, doc)]
    binfiles = {n.lower(): n for n in z.namelist()}

    def load(href):
        n = binfiles.get(('bin/' + html.unescape(href)).lower())
        return z.read(n) if n else None

    assets = []
    for a in attrs('DOMBitmapItem'):
        d = load(a.get('bitmapDataHRef', ''))
        img = as_image(d) if d else None
        if img:
            assets.append({'kind': 'image', 'name': html.unescape(a.get('name', 'bitmap')), 'id': a.get('bitmapDataHRef'),
                           'ext': img[0], 'data': img[1]})
    for a in attrs('DOMSoundItem'):
        d = load(a.get('soundDataHRef', ''))
        if not d:
            continue
        fm = SOUND_FMT.search(a.get('format', ''))
        fmt = 0x0f
        if fm:
            fmt = ({'5': 0, '11': 1, '22': 2, '44': 3}[fm.group(1)] << 2 | (2 if fm.group(2) == '16' else 0)
                   | (1 if fm.group(3).lower() == 'stereo' else 0))
        assets.append({'kind': 'sound', 'name': html.unescape(a.get('name', 'sound')), 'id': a.get('soundDataHRef'),
                       'data': d, 'fmt': fmt, 'mp3': looks_like_mp3(d)})
    for a in attrs('DOMVideoItem'):
        d = load(a.get('videoDataHRef', ''))
        if d:
            assets.append({'kind': 'flv' if d[:3] == b'FLV' else 'video', 'name': html.unescape(a.get('name', 'video')),
                           'id': a.get('videoDataHRef'), 'data': d})
    return fps, assets


# ---------------------------------------------------------------------------
# Writing

def safe(s, limit=80):
    s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', s).strip(' .')
    return s[:limit] or 'unnamed'


def stem(name):
    return os.path.splitext(os.path.basename(name.replace('\\', '/')))[0].lower()


def process_fla(path, prefix, outdir, drawings='auto'):
    """Extract one .fla. Returns (counts, notes)."""
    counts = {'images': 0, 'audio': 0, 'animatics': 0, 'drawings': 0, 'shots': 0}
    notes = []
    streams = None
    with open(path, 'rb') as f:
        magic = f.read(4)
    if magic == b'PK\x03\x04':
        fps, assets = read_zip_fla(path)
    elif magic == b'\xd0\xcf\x11\xe0':
        streams = CompoundFile(path).streams()
        fps, assets = read_ole_fla(streams)
    else:
        return counts, ['not a .fla this script understands']
    used = set()

    def target(sub, label, ext):
        os.makedirs(os.path.join(outdir, sub), exist_ok=True)
        base = '%s__%s' % (prefix, safe(os.path.splitext(label)[0]))
        p, n = base + ext, 2
        while p.lower() in used:
            p, n = '%s_%d%s' % (base, n, ext), n + 1
        used.add(p.lower())
        return os.path.join(outdir, sub, p)

    def write(p, data):
        with open(p, 'wb') as f:
            f.write(data)

    sounds = [a for a in assets if a['kind'] == 'sound']
    for a in assets:
        try:
            if a['kind'] == 'image':
                write(target('images', a['name'], a['ext']), a['data'])
                counts['images'] += 1
            elif a['kind'] == 'sound':
                if a['mp3']:
                    write(target('audio', a['name'], '.mp3'), a['data'])
                else:
                    write(target('audio', a['name'], '.wav'), wav_bytes(a['data'], *pcm_params(a['fmt'])))
                counts['audio'] += 1
            elif a['kind'] == 'flv':
                write(target('animatics', a['name'], '.flv'), a['data'])
                counts['animatics'] += 1
            elif a['kind'] == 'video':
                v = parse_video(a['data'])
                if not v:
                    notes.append('%s: video in an unknown layout, skipped' % a['name'])
                    continue
                match = [s for s in sounds if stem(s['name']) == stem(a['name']) and not s['mp3']]
                if not match and len(sounds) == 1 and not sounds[0]['mp3']:
                    match = sounds
                snd = match[0] if match else None
                write(target('animatics', a['name'], '.flv'),
                      flv_bytes(v, fps, snd['data'] if snd else None, snd['fmt'] if snd else 0x0f))
                counts['animatics'] += 1
            else:
                notes.append('%s: unrecognised media, skipped' % a['name'])
        except Exception as e:  # keep going with the other assets
            notes.append('%s: %s' % (a['name'], e))
    if streams is not None and drawings != 'none':
        is_scene = any(a['kind'] == 'video' for a in assets)
        try:
            if drawings == 'all' or (drawings == 'auto' and not is_scene):
                n, vnotes = export_vectors(streams, os.path.join(outdir, 'drawings', prefix), prefix)
                counts['drawings'] += n
                notes += ['drawings: %s' % v for v in vnotes if v != 'no vector artwork found']
            if drawings == 'shots' or (is_scene and drawings in ('auto', 'all')):
                counts['shots'] += export_stage(streams, os.path.join(outdir, 'shots'), prefix)
        except Exception as e:
            notes.append('drawings: %s' % e)
    return counts, notes


def write_index(outdir, title):
    def files(sub):
        d = os.path.join(outdir, sub)
        return sorted(os.listdir(d)) if os.path.isdir(d) else []

    imgs, auds, vids = files('images'), files('audio'), files('animatics')
    shots = [f for f in files('shots') if f.endswith('.svg')]
    galleries = [d for d in files('drawings') if os.path.isfile(os.path.join(outdir, 'drawings', d, 'index.html'))]
    q = lambda sub, f: html.escape(sub + '/' + f, quote=True).replace(' ', '%20').replace('#', '%23')
    parts = ['''<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s assets</title><style>
:root{--bg:#f6f6f4;--card:#fff;--ink:#1d1d1b;--mute:#6b6b66;--line:#e2e2dc;--chk:#e9e9e4}
@media (prefers-color-scheme:dark){:root{--bg:#161615;--card:#1f1f1d;--ink:#ecece8;--mute:#9a9a93;--line:#2e2e2b;--chk:#2a2a27}}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 system-ui,sans-serif}
main{max-width:1200px;margin:0 auto;padding:24px 16px}h1{font-size:22px;margin:0 0 4px}
h2{font-size:16px;margin:32px 0 12px;padding-bottom:6px;border-bottom:1px solid var(--line)}
.mute{color:var(--mute)}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
figure{margin:0;background:var(--card);border:1px solid var(--line);border-radius:8px;overflow:hidden}
figure a{display:block;background:repeating-conic-gradient(var(--chk) 0 25%%,transparent 0 50%%) 0 0/16px 16px}
figure img{display:block;width:100%%;height:150px;object-fit:contain}
figcaption{padding:6px 8px;font-size:12px;word-break:break-all;color:var(--mute)}
.list{display:grid;gap:6px}.row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;background:var(--card);
border:1px solid var(--line);border-radius:8px;padding:6px 10px}.row span{flex:1 1 240px;word-break:break-all}
audio{height:32px;max-width:100%%}a{color:inherit}
</style></head><body><main><h1>%s</h1>
<p class="mute">%d shots · %d drawing galleries · %d images · %d sounds · %d animatics.
Shots and drawings play in the browser; animatics are .flv files: open them in VLC.</p>'''
             % (html.escape(title), html.escape(title), len(shots), len(galleries), len(imgs), len(auds), len(vids))]
    if shots:
        parts.append('<h2>Shots</h2><div class="grid">')
        parts += ['<figure><a href="%s" target="_blank"><img loading="lazy" src="%s" alt=""></a><figcaption>%s</figcaption></figure>'
                  % (q('shots', f), q('shots', f), html.escape(f[:-4])) for f in shots]
        parts.append('</div>')
    if galleries:
        parts.append('<h2>Drawings</h2><div class="list">')
        for d in galleries:
            n = sum(1 for f in os.listdir(os.path.join(outdir, 'drawings', d)) if f.endswith('.svg'))
            parts.append('<div class="row"><span><a href="%s">%s</a></span><span class="mute">%d drawings</span></div>'
                         % (q('drawings', d + '/index.html'), html.escape(d), n))
        parts.append('</div>')
    if imgs:
        parts.append('<h2>Images</h2><div class="grid">')
        parts += ['<figure><a href="%s" target="_blank"><img loading="lazy" src="%s" alt=""></a><figcaption>%s</figcaption></figure>'
                  % (q('images', f), q('images', f), html.escape(f)) for f in imgs]
        parts.append('</div>')
    if auds:
        parts.append('<h2>Sounds</h2><div class="list">')
        parts += ['<div class="row"><span>%s</span><audio controls preload="none" src="%s"></audio></div>'
                  % (html.escape(f), q('audio', f)) for f in auds]
        parts.append('</div>')
    if vids:
        parts.append('<h2>Animatics</h2><div class="list">')
        parts += ['<div class="row"><span><a href="%s">%s</a></span></div>' % (q('animatics', f), html.escape(f)) for f in vids]
        parts.append('</div>')
    parts.append('</main></body></html>')
    with open(os.path.join(outdir, 'index.html'), 'w', encoding='utf-8') as f:
        f.write('\n'.join(parts))


def fla_files(folder, skip=()):
    """All .fla files under folder, leaving out hidden folders and the paths in skip."""
    skip = {os.path.normcase(os.path.abspath(p)) for p in skip}
    out = []
    for dirpath, dirnames, filenames in os.walk(folder):
        dirnames[:] = sorted(d for d in dirnames if not d.startswith('.')
                             and os.path.normcase(os.path.abspath(os.path.join(dirpath, d))) not in skip)
        out += [os.path.join(dirpath, f) for f in sorted(filenames) if f.lower().endswith('.fla')]
    return out


# ---------------------------------------------------------------------------
# Vector artwork (old binary .fla): parsing and SVG rendering

UNIT = 5120.0           # shape coordinates: 1 px = 5120 units (twips << 8)
GRAD_HALF = 819.2       # gradient square is +-16384 twips = +-819.2 px
MAX_ANIM_FRAMES = 240   # longer symbol timelines are written as a still of frame 1
MAX_STAGE_FRAMES = 5000 # a whole scene (shot) is animated up to this length
MAX_DEPTH = 40
GRADIENT_EXTRA = 8      # bytes of gradient settings in CS3/CS4-era files
EASE_CURVES = 6         # custom ease curves stored with a motion tween


class ParseError(Exception):
    pass


# ---------------------------------------------------------------------------
# MFC CArchive object reader

class Reader:
    def __init__(self, buf):
        self.buf, self.pos = buf, 0

    def take(self, n):
        if self.pos + n > len(self.buf):
            raise ParseError('unexpected end at 0x%x' % self.pos)
        v = self.buf[self.pos:self.pos + n]
        self.pos += n
        return v

    def u8(self):
        return self.take(1)[0]

    def u16(self):
        return struct.unpack('<H', self.take(2))[0]

    def s16(self):
        return struct.unpack('<h', self.take(2))[0]

    def u32(self):
        return struct.unpack('<I', self.take(4))[0]

    def s32(self):
        return struct.unpack('<i', self.take(4))[0]

    def cstring(self):
        n = self.u8()
        if n < 0xFF:
            return self.take(n).decode('latin1')
        w = self.u16()
        if w == 0xFFFE:
            n = self.u8()
            if n == 0xFF:
                n = self.u16()
                if n == 0xFFFF:
                    n = self.u32()
            return self.take(2 * n).decode('utf-16le', 'replace')
        if w == 0xFFFF:
            w = self.u32()
        return self.take(w).decode('latin1')

    def matrix(self):
        a, b, c, d, tx, ty = struct.unpack('<iiiiii', self.take(24))
        return (a / 65536, b / 65536, c / 65536, d / 65536, tx / 20, ty / 20)


class Archive:
    def __init__(self, buf):
        self.r = Reader(buf)
        self.load = [None]  # MFC load table; index 0 is NULL

    def read_object(self, attach=None):
        r = self.r
        tag = r.u16()
        if tag == 0:
            return None
        if tag == 0x7FFF:
            big = r.u32()
            if not big & 0x80000000:
                return {'cls': 'REF', 'obj': self.load[big]}
            cls = self.load[big & 0x7FFFFFFF]
        elif tag == 0xFFFF:
            r.u16()
            name = r.take(r.u16()).decode('ascii', 'replace')
            cls = ('class', name)
            self.load.append(cls)
        elif tag & 0x8000:
            cls = self.load[tag & 0x7FFF] if (tag & 0x7FFF) < len(self.load) else None
            if not (isinstance(cls, tuple) and cls[0] == 'class'):
                raise ParseError('bad class reference at 0x%x' % r.pos)
        else:
            if tag >= len(self.load):
                raise ParseError('bad object reference at 0x%x' % r.pos)
            return {'cls': 'REF', 'obj': self.load[tag]}
        obj = {'cls': cls[1]}
        self.load.append(obj)
        if attach is not None:
            attach(obj)
        reader = READERS.get(cls[1])
        if reader is None:
            raise ParseError('unsupported object %s' % cls[1])
        reader(self, obj)
        obj['done'] = True
        return obj


def read_picobj(ar, o):
    r = ar.r
    s = r.u8()
    r.u8()
    kids = o['children'] = []
    while ar.read_object(attach=kids.append) is not None:
        pass
    kids[:] = [k for k in kids if k.get('cls') != 'REF']
    if s >= 1:
        r.take(8)
    if s >= 3:
        r.u8()
    if s >= 4:
        r.u8()


def read_fill(r, caps):
    f = {'color': r.u32()}
    sub = f['sub'] = r.u8()
    r.u8()
    if sub & 0x10:
        f['kind'] = 'gradient'
        f['matrix'] = r.matrix()
        n = r.u8()
        if caps:
            r.take(GRADIENT_EXTRA)  # spread / interpolation / focal point
        f['stops'] = [(r.u8(), r.u32()) for _ in range(n)]
    elif sub & 0x40:
        f['kind'] = 'bitmap'
        f['matrix'] = r.matrix()
        f['bitmap'] = r.u32()
    elif sub & 0x20:
        f['kind'] = 'other'
        r.matrix()
        r.take(12)
    else:
        f['kind'] = 'solid'
    return f


def read_line(r, caps):
    l = {'color': r.u32(), 'width': r.u16()}
    r.take(4)
    if caps:
        l['caps'] = (r.u8(), r.u8(), r.u8(), r.u8())
        r.u16()
        l['fill'] = read_fill(r, caps)
    return l


def read_delta(r, t):
    if t == 0:
        return 0, 0
    if t == 1:
        return r.s16(), r.s16()
    if t == 2:
        return r.s32(), r.s32()
    return r.s16() << 7, r.s16() << 7


def read_shape_data(r, caps):
    schema = r.u8()
    r.u32()
    nf = r.u16()
    if schema < 3:
        fills = []
        for _ in range(nf):
            fills.append({'color': r.u32(), 'kind': 'solid'})
            r.u16()
    else:
        fills = [read_fill(r, caps) for _ in range(nf)]
    lines = [read_line(r, caps) for _ in range(r.u16())]
    edges = []
    if schema >= 2:
        x = y = f0 = f1 = ln = 0
        while True:
            fl = r.u8()
            if fl == 0:
                break
            if fl & 0x40:  # style change: line style, then the fills on either side
                if fl & 0x80:
                    ln, f0, f1 = r.u8(), r.u8(), r.u8()
                else:  # the top bit is a flag, not part of the style number
                    ln, f0, f1 = r.u16() & 0x7FFF, r.u16() & 0x7FFF, r.u16() & 0x7FFF
            d1 = read_delta(r, fl & 3)
            d2 = read_delta(r, (fl >> 2) & 3)
            d3 = read_delta(r, (fl >> 4) & 3)
            fx, fy = x + d1[0], y + d1[1]
            cx, cy = fx + d2[0], fy + d2[1]
            tx, ty = fx + d3[0], fy + d3[1]
            edges.append((fx, fy, cx, cy, tx, ty, (fl >> 2) & 3 != 0, f0, f1, ln))
            x, y = tx, ty
    if schema > 4:
        n = r.s32()
        r.take(32 * n)  # original cubic curves; the quadratic edges above are what Flash draws
    return {'fills': fills, 'lines': lines, 'edges': edges}


def read_shape(ar, o):
    read_picobj(ar, o)
    r = ar.r
    caps = r.u8() > 2
    o['matrix'] = r.matrix()
    o['shape'] = read_shape_data(r, caps)


def read_page(ar, o):
    read_picobj(ar, o)
    r = ar.r
    ps = r.u8()
    if ps != 4:
        r.u16()
    if ps >= 5:
        r.u16()
    if ps >= 7:
        r.u32()
    if ps >= 3:
        r.take(8 * r.u32())


def read_layer(ar, o):
    read_picobj(ar, o)
    r = ar.r
    ls = r.u8()
    o['name'] = r.cstring()
    if ls <= 3:
        r.u8()
    if ls >= 4:
        r.take(3)
    if ls >= 5:
        r.u32()
    if ls >= 6:
        r.take(8)
    if ls >= 8:
        r.u32()
    o['kind'] = r.u8()  # 0 normal, 1 guide, 4 mask (others: folders etc.)
    p = ar.read_object()
    if p is not None and p.get('cls') == 'REF':
        p = p['obj']
    o['parent'] = p
    if 7 <= ls < 9:
        ar.read_object()
    if 2 <= ls < 6:
        r.u8()
    if 3 <= ls < 9:
        r.u8()
    if ls >= 9:
        r.u8()
    if ls >= 10:
        r.u8()


def read_timeline_sub(r):
    typ, fmt = r.u32(), r.u32()
    if typ >= 1:
        r.u32()
        r.take(4 * r.u32())
    if fmt == 1 and typ >= 4:
        r.cstring()
    elif fmt == 0:
        r.u32()
        r.take(4 * r.u32())


def read_frame(ar, o):
    read_shape(ar, o)
    r = ar.r
    fs = r.u8()
    o['duration'] = r.u16()
    o['tween'] = r.u16() if fs > 2 else r.u8()
    if fs > 1:
        o['ease'] = r.s16()
    if fs > 4:
        r.u16()
    if fs > 5:
        r.take(8 * r.u16())
    if fs > 6:
        r.take(11)
    if fs > 7:
        r.u16()
    if fs > 8:
        if fs >= 23:
            r.cstring()
        if fs >= 19:
            read_timeline_sub(r)
        else:
            raise ParseError('frame layout %d not supported' % fs)
    if fs > 10:
        r.take(8)
    if fs > 11:
        r.u32()
    if fs > 12:
        ar.read_object()
    if fs > 13:
        r.u32()
    if fs > 14:
        ar.read_object()
    if fs > 15 and fs >= 23:
        r.cstring()
    if fs > 19:
        r.u32()
    if fs > 20:
        r.u32()
    if fs >= 22:
        r.u32()
    if fs >= 24:
        r.u32()
        if r.u32():
            # custom ease curves of a motion tween: per property, a point
            # count then (control, anchor, control) points as doubles
            o['ease_curves'] = curves = []
            for _ in range(EASE_CURVES):
                n = r.u32()
                if n > 1000:
                    raise ParseError('bad ease curve')
                curves.append(struct.unpack('<%dd' % (6 * n), r.take(48 * n)))


def read_symbol(ar, o):
    read_picobj(ar, o)
    r = ar.r
    r.u8()
    o['matrix'] = r.matrix()
    o['first_frame'] = r.u16()
    o['loop'] = r.u16()  # 0 loop, 1 play once, 2 single frame
    r.u8()
    cx = [struct.unpack('<h', r.take(2))[0] for _ in range(8)]
    o['cxform'] = cx  # (mult, add) per channel A, R, G, B; mult 256 = 1.0
    r.u32()
    r.s32()
    o['inst_name'] = r.cstring()
    o['lib'] = r.u32()
    r.take(7)


def read_sprite(ar, o):
    read_symbol(ar, o)
    r = ar.r
    ss = r.u8()
    if ss != 8:
        raise ParseError('movie clip instance layout %d not supported' % ss)
    read_timeline_sub(r)
    r.cstring()
    r.take(22)
    r.cstring()  # component metadata (XML) or empty


def read_text(ar, o):
    """Static text box (only the single-style layout seen so far is supported)."""
    read_picobj(ar, o)
    r = ar.r
    ts = r.u8()
    o['matrix'] = r.matrix()
    o['bounds'] = struct.unpack('<iiii', r.take(16))  # left, right, top, bottom (twips)
    r.take(2)
    flags = r.u32()
    r.u16()
    r.cstring()
    if ts != 13 or flags & 0x6000:
        raise ParseError('text layout not supported')
    n = r.u16()
    if r.u8() != 12:
        raise ParseError('text style layout not supported')
    o['size'] = r.u16() / 20
    o['font'] = r.take(2 * r.u8()).decode('utf-16le', 'replace')
    r.take(39)
    o['text'] = r.take(2 * n).decode('utf-16le', 'replace')
    r.u16()
    r.cstring()
    r.take(8)
    r.cstring()
    r.cstring()
    r.take(3)


def read_bitmap(ar, o):
    read_picobj(ar, o)
    r = ar.r
    bs = r.u8()
    o['matrix'] = r.matrix()
    o['media'] = r.u16()
    if bs >= 2 and r.u8():
        raise ParseError('bitmap filters not supported')


def read_video(ar, o):
    """An embedded video placed on the stage (the animatic); not drawn."""
    read_picobj(ar, o)
    r = ar.r
    r.u8()
    r.matrix()
    r.take(17)
    r.cstring()
    r.cstring()
    r.u32()
    r.u16()


def read_morph(ar, o):
    """Shape-tween data. The tween's starting keyframe is drawn as is."""
    r = ar.r
    r.u8()
    r.matrix()
    r.matrix()
    r.take(8)
    for _ in range(r.u16()):
        ar.read_object()
    if r.u16():
        raise ParseError('shape tween hints not supported')
    for _ in range(r.u16()):
        read_fill(r, True)
    for _ in range(r.u16()):
        read_line(r, False)


def read_morph_segment(ar, o):
    r = ar.r
    r.take(32)
    for _ in range(r.u16()):
        ar.read_object()


def read_morph_curve(ar, o):
    ar.r.take(36)


READERS = {
    'CPicPage': read_page,
    'CPicLayer': read_layer,
    'CPicFrame': read_frame,
    'CPicShape': read_shape,
    'CPicSymbol': read_symbol,
    'CPicSprite': read_sprite,
    'CPicBitmap': read_bitmap,
    'CPicText': read_text,
    'CPicVideoStream': read_video,
    'CPicMorphShape': read_morph,
    'CMorphSegment': read_morph_segment,
    'CMorphCurve': read_morph_curve,
}


def parse_timeline(buf):
    """Parse a Symbol/Page stream. Returns (root, error): on a parse error the
    objects read so far are kept, so most of the drawing can still render."""
    ar = Archive(buf)
    holder = []
    try:
        if ar.r.u8() != 1:
            raise ParseError('not a timeline stream')
        ar.read_object(attach=holder.append)
        err = None if ar.r.pos == len(buf) else 'trailing data'
    except (ParseError, IndexError, struct.error, UnicodeDecodeError) as e:
        err = str(e)
    return (holder[0] if holder else None), err


# ---------------------------------------------------------------------------
# Document

def library_names(contents):
    """Symbol number -> library name, read from the Contents stream."""
    names = {}
    for m in re.finditer(rb'(?:S\x00y\x00m\x00b\x00o\x00l\x00 \x00)((?:[0-9]\x00)+)', contents):
        num = int(m.group(1).decode('utf-16le'))
        p = m.end()
        if contents[p:p + 3] == b'\xff\xfe\xff':
            n = contents[p + 3]
            names.setdefault(num, contents[p + 4:p + 4 + 2 * n].decode('utf-16le', 'replace'))
    return names


def document_settings(contents):
    """(fps, stage (left, top, right, bottom) in px, background colour) from Contents."""
    fps, stage, bg = 24.0, (0, 0, 550, 400), 0xFFFFFFFF
    j = contents.find(b'\xff\xfe\xff\x11' + 'legacyLineSpacing'.encode('utf-16le'))
    if j > 74:
        v = struct.unpack_from('<I', contents, j - 13)[0] / 65536
        if 1 <= v <= 120:
            fps = v
        left, right, top, bottom = struct.unpack_from('<iiii', contents, j - 74)
        if left < right and top < bottom and right - left < 400000:
            stage = (left / 20, top / 20, right / 20, bottom / 20)
            bg = struct.unpack_from('<I', contents, j - 21)[0]
    return fps, stage, bg


class Document:
    def __init__(self, streams):
        self.streams = streams
        contents = streams.get('Contents', b'')
        self.names = library_names(contents)
        self.fps, self.stage, self.background = document_settings(contents)
        self._cache = {}
        self.errors = {}

    def symbol_ids(self):
        return sorted(int(n.split()[1]) for n in self.streams if re.fullmatch(r'Symbol \d+', n))

    def timeline(self, key):
        if key not in self._cache:
            data = self.streams.get(key)
            root, err = parse_timeline(data) if data else (None, 'missing')
            if err:
                self.errors[key] = err
            self._cache[key] = root
        return self._cache[key]

    def symbol(self, num):
        return self.timeline('Symbol %d' % num)


def layers_of(root):
    return [c for c in (root or {}).get('children', []) if c.get('cls') == 'CPicLayer']


def frames_of(layer):
    return [c for c in layer.get('children', []) if c.get('cls') == 'CPicFrame']


def timeline_length(root):
    n = 0
    for L in layers_of(root):
        if L.get('kind') == 1:
            continue
        n = max(n, sum(max(1, f.get('duration', 1)) if f.get('duration', 1) else 0 for f in frames_of(L)))
    return n


def frame_at(layer, f):
    """(frame, frame index where it starts, following keyframe or None) at frame f, or None."""
    start = 0
    frs = frames_of(layer)
    for i, fr in enumerate(frs):
        dur = fr.get('duration', 1)
        if dur and start <= f < start + dur:
            return fr, start, (frs[i + 1] if i + 1 < len(frs) else None)
        start += dur
    return None


# ---------------------------------------------------------------------------
# Motion tweens

def bezier_ease(curve, t):
    """Custom ease: curve holds (ctrl, anchor, ctrl) points per anchor, x/y pairs."""
    pts = [(curve[i], curve[i + 1]) for i in range(0, len(curve), 2)]
    anchors = pts[1::3]
    for k in range(len(anchors) - 1):
        p0, p1, p2, p3 = anchors[k], pts[3 * k + 2], pts[3 * k + 3], anchors[k + 1]
        if p0[0] <= t <= p3[0] or k == len(anchors) - 2:
            lo, hi = 0.0, 1.0
            for _ in range(30):  # solve x(u) = t
                u = (lo + hi) / 2
                x = (1 - u) ** 3 * p0[0] + 3 * (1 - u) ** 2 * u * p1[0] + 3 * (1 - u) * u * u * p2[0] + u ** 3 * p3[0]
                lo, hi = (u, hi) if x < t else (lo, u)
            u = (lo + hi) / 2
            return (1 - u) ** 3 * p0[1] + 3 * (1 - u) ** 2 * u * p1[1] + 3 * (1 - u) * u * u * p2[1] + u ** 3 * p3[1]
    return t


def tween_progress(fr, f_off):
    t = f_off / max(1, fr.get('duration', 1))
    curves = fr.get('ease_curves')
    if curves and len(curves[0]) >= 12:
        return bezier_ease(curves[0], t)
    e = max(-100, min(100, fr.get('ease', 0))) / 100
    return t + e * t * (1 - t)  # Flash's classic ease: + eases out, - eases in


def decompose(m):
    a, b, c, d, tx, ty = m
    return math.hypot(a, b), math.hypot(c, d), math.atan2(b, a), math.atan2(-c, d), tx, ty


def lerp_angle(x, y, t):
    while y - x > math.pi:
        y -= 2 * math.pi
    while x - y > math.pi:
        y += 2 * math.pi
    return x + (y - x) * t


def lerp_matrix(m1, m2, t):
    sx1, sy1, ry1, rx1, tx1, ty1 = decompose(m1)
    sx2, sy2, ry2, rx2, tx2, ty2 = decompose(m2)
    sx, sy = sx1 + (sx2 - sx1) * t, sy1 + (sy2 - sy1) * t
    ry, rx = lerp_angle(ry1, ry2, t), lerp_angle(rx1, rx2, t)
    return (sx * math.cos(ry), sx * math.sin(ry), -sy * math.sin(rx), sy * math.cos(rx),
            tx1 + (tx2 - tx1) * t, ty1 + (ty2 - ty1) * t)


def tweened_children(fr, nxt, f_off):
    """Children of a motion-tweened keyframe, moved part way towards the next keyframe."""
    kids = fr.get('children', [])
    if not (fr.get('tween', 0) & 1) or nxt is None or f_off <= 0:
        return kids
    t = tween_progress(fr, f_off)
    targets = [k for k in nxt.get('children', []) if k.get('cls') in ('CPicSymbol', 'CPicSprite') and k.get('done')]
    out = []
    for k in kids:
        if k.get('cls') in ('CPicSymbol', 'CPicSprite') and k.get('done'):
            match = next((n for n in targets if n['lib'] == k['lib']), None)
            if match is not None:
                targets.remove(match)
                k = dict(k, matrix=lerp_matrix(k['matrix'], match['matrix'], t),
                         cxform=[round(x + (y - x) * t) for x, y in zip(k['cxform'], match['cxform'])])
        out.append(k)
    return out


# ---------------------------------------------------------------------------
# SVG rendering

def fmt(v):
    s = '%.2f' % v
    s = s.rstrip('0').rstrip('.')
    return '0' if s in ('-0', '') else s


def css_color(u32):
    r, g, b, a = u32 & 0xFF, (u32 >> 8) & 0xFF, (u32 >> 16) & 0xFF, u32 >> 24
    return '#%02x%02x%02x' % (r, g, b), a / 255


def mat_mul(m, n):
    a, b, c, d, tx, ty = m
    a2, b2, c2, d2, tx2, ty2 = n
    return (a * a2 + c * b2, b * a2 + d * b2, a * c2 + c * d2, b * c2 + d * d2,
            a * tx2 + c * ty2 + tx, b * tx2 + d * ty2 + ty)


def bbox_transform(bb, m):
    if bb is None:
        return None
    a, b, c, d, tx, ty = m
    xs, ys = [], []
    for x, y in ((bb[0], bb[1]), (bb[2], bb[1]), (bb[0], bb[3]), (bb[2], bb[3])):
        xs.append(a * x + c * y + tx)
        ys.append(b * x + d * y + ty)
    return (min(xs), min(ys), max(xs), max(ys))


def bbox_union(a, b):
    if a is None:
        return b
    if b is None:
        return a
    return (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]))


def bbox_intersect(a, b):
    if a is None or b is None:
        return None
    r = (max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3]))
    return r if r[0] < r[2] and r[1] < r[3] else None


def segs_bbox(segs, pad):
    xs = [v for s in segs for v in (s[0], s[2], s[4])]
    ys = [v for s in segs for v in (s[1], s[3], s[5])]
    return (min(xs) / UNIT - pad, min(ys) / UNIT - pad, max(xs) / UNIT + pad, max(ys) / UNIT + pad)


def is_identity(m):
    return all(abs(x - y) < 1e-4 for x, y in zip(m, (1, 0, 0, 1, 0, 0)))


def svg_matrix(m):
    return 'matrix(%s)' % ' '.join(fmt(v) if i >= 4 else '%.5g' % v for i, v in enumerate(m))


def chain_path(segs, close):
    """Join (x0, y0, cx, cy, x1, y1, curve) segments end-to-start into an SVG path."""
    starts = defaultdict(list)
    for i, s in enumerate(segs):
        starts[(s[0], s[1])].append(i)
    used = [False] * len(segs)
    out = []

    def emit(s):
        if s[6]:
            out.append('Q%s %s %s %s' % (fmt(s[2] / UNIT), fmt(s[3] / UNIT), fmt(s[4] / UNIT), fmt(s[5] / UNIT)))
        else:
            out.append('L%s %s' % (fmt(s[4] / UNIT), fmt(s[5] / UNIT)))

    for i, s in enumerate(segs):
        if used[i]:
            continue
        used[i] = True
        out.append('M%s %s' % (fmt(s[0] / UNIT), fmt(s[1] / UNIT)))
        emit(s)
        first, cur = (s[0], s[1]), (s[4], s[5])
        while cur != first:
            nxt = next((j for j in starts.get(cur, ()) if not used[j]), None)
            if nxt is None:
                break
            used[nxt] = True
            emit(segs[nxt])
            cur = (segs[nxt][4], segs[nxt][5])
        if close and cur == first:
            out.append('Z')
    return ''.join(out)


class Renderer:
    def __init__(self, doc):
        self.doc = doc
        self.defs = []
        self.done = {}      # (symbol, frame, mask_mode) -> (id, bbox) or None
        self.masks = {}
        self.shared_ids = {}  # markup -> id, so identical artwork is stored once
        self.ids = 0

    def shared(self, body, min_len=400):
        """Store a large piece of markup once in <defs> and reference it."""
        if len(body) < min_len:
            return body
        sid = self.shared_ids.get(body)
        if sid is None:
            sid = self.shared_ids[body] = self.new_id('d')
            self.defs.append('<g id="%s">%s</g>' % (sid, body))
        return '<use href="#%s"/>' % sid

    def new_id(self, prefix):
        self.ids += 1
        return '%s%d' % (prefix, self.ids)

    # -- shapes -------------------------------------------------------------
    def shape(self, sd, mask_mode):
        parts, bb = [], None
        fills, lines = sd['fills'], sd['lines']
        by_fill, by_line = defaultdict(list), defaultdict(list)
        for fx, fy, cx, cy, tx, ty, curve, f0, f1, ln in sd['edges']:
            if f0:
                by_fill[f0].append((fx, fy, cx, cy, tx, ty, curve))
            if f1:
                by_fill[f1].append((tx, ty, cx, cy, fx, fy, curve))
            if ln and not mask_mode:
                by_line[ln].append((fx, fy, cx, cy, tx, ty, curve))
        for k in sorted(by_fill):
            if not 0 < k <= len(fills):
                continue
            paint = 'fill="#fff"' if mask_mode else self.paint(fills[k - 1])
            if paint is None:
                continue
            parts.append('<path d="%s" %s fill-rule="evenodd"/>' % (chain_path(by_fill[k], True), paint))
            bb = bbox_union(bb, segs_bbox(by_fill[k], 0))
        for k in sorted(by_line):
            if not 0 < k <= len(lines):
                continue
            l = lines[k - 1]
            col, alpha = css_color(l['color'])
            if alpha == 0:
                continue
            w = l['width'] / 20
            attrs = 'fill="none" stroke="%s" stroke-linecap="round" stroke-linejoin="round"' % col
            attrs += ' stroke-width="%s"' % (fmt(w) if w > 0 else '1" vector-effect="non-scaling-stroke')
            if alpha < 1:
                attrs += ' stroke-opacity="%s"' % fmt(alpha)
            parts.append('<path d="%s" %s/>' % (chain_path(by_line[k], False), attrs))
            bb = bbox_union(bb, segs_bbox(by_line[k], max(w, 1) / 2))
        return ''.join(parts), bb

    def paint(self, f):
        if f['kind'] == 'gradient' and f.get('stops'):
            gid = self.new_id('g')
            a, b, c, d, tx, ty = f['matrix']
            stops = ''
            for pos, col in f['stops']:
                hexcol, alpha = css_color(col)
                stops += '<stop offset="%s" stop-color="%s"%s/>' % (
                    fmt(pos / 255), hexcol, '' if alpha >= 1 else ' stop-opacity="%s"' % fmt(alpha))
            tf = 'gradientUnits="userSpaceOnUse" gradientTransform="%s"' % svg_matrix((a, b, c, d, tx, ty))
            if f['sub'] & 0x03:
                self.defs.append('<radialGradient id="%s" %s cx="0" cy="0" r="%s">%s</radialGradient>'
                                 % (gid, tf, GRAD_HALF, stops))
            else:
                self.defs.append('<linearGradient id="%s" %s x1="-%s" y1="0" x2="%s" y2="0">%s</linearGradient>'
                                 % (gid, tf, GRAD_HALF, GRAD_HALF, stops))
            return 'fill="url(#%s)"' % gid
        col, alpha = css_color(f['color'])
        if alpha == 0:
            return None
        return 'fill="%s"%s' % (col, '' if alpha >= 1 else ' fill-opacity="%s"' % fmt(alpha))

    # -- timelines ----------------------------------------------------------
    def element(self, o, f_off, mask_mode, depth):
        cls = o.get('cls')
        if cls == 'CPicShape':
            body, bb = self.shape(o['shape'], mask_mode) if 'shape' in o else ('', None)
            m = o.get('matrix', (1, 0, 0, 1, 0, 0))
            if body and not is_identity(m):
                body, bb = '<g transform="%s">%s</g>' % (svg_matrix(m), body), bbox_transform(bb, m)
            # members of a group carry their own placement in the same space as the group
            for k in o.get('children', []):
                b2, kb = self.element(k, f_off, mask_mode, depth)
                body += b2
                bb = bbox_union(bb, kb)
            return body, bb
        if cls == 'CPicText' and o.get('done') and not mask_mode:
            left, right, top, bottom = (v / 20 for v in o['bounds'])
            m = o['matrix']
            txt = '<text x="%s" y="%s" font-family="%s" font-size="%s" fill="#000">%s</text>' % (
                fmt(left + 2), fmt(top + o['size']), html.escape(o['font'], quote=True), fmt(o['size']),
                html.escape(o['text']))
            if not is_identity(m):
                txt = '<g transform="%s">%s</g>' % (svg_matrix(m), txt)
            return txt, bbox_transform((left, top, right, bottom), m)
        if cls in ('CPicSymbol', 'CPicSprite') and o.get('done'):
            if cls == 'CPicSprite':
                o = dict(o, loop=2, first_frame=0)  # a movie clip is shown at its first frame
            ff, loop = o['first_frame'], o['loop']
            child = self.doc.symbol(o['lib'])
            n = timeline_length(child)
            if not n:
                return '', None
            if loop == 2:
                fr = ff
            elif loop == 1:
                fr = min(ff + f_off, n - 1)
            else:
                fr = (ff + f_off) % n
            ref = self.symbol_frame(o['lib'], min(fr, n - 1), mask_mode, depth + 1)
            if ref is None:
                return '', None
            sid, bb = ref
            m = o['matrix']
            out = '<use href="#%s"%s/>' % (sid, '' if is_identity(m) else ' transform="%s"' % svg_matrix(m))
            if not mask_mode:
                out = self.cxform(o['cxform'], out)
            return out, bbox_transform(bb, m)
        return '', None

    def cxform(self, cx, inner):
        am, aa, rm, ra, gm, ga, bm, ba = cx
        if (rm, ra, gm, ga, bm, ba) == (256, 0, 256, 0, 256, 0):
            if (am, aa) == (256, 0):
                return inner
            if aa == 0:
                return '<g opacity="%s">%s</g>' % (fmt(max(0, min(1, am / 256))), inner)
        fid = self.shared_ids.get(('cx', tuple(cx)))
        if fid is None:
            fid = self.shared_ids[('cx', tuple(cx))] = self.new_id('c')
            vals = '%s 0 0 0 %s 0 %s 0 0 %s 0 0 %s 0 %s 0 0 0 %s %s' % (
                fmt(rm / 256), fmt(ra / 255), fmt(gm / 256), fmt(ga / 255),
                fmt(bm / 256), fmt(ba / 255), fmt(am / 256), fmt(aa / 255))
            # Flash recolours only the drawing itself, so keep the result inside its outline
            self.defs.append('<filter id="%s" x="-50%%" y="-50%%" width="200%%" height="200%%" '
                             'color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="%s"/>'
                             '<feComposite operator="in" in2="SourceAlpha"/></filter>' % (fid, vals))
        return '<g filter="url(#%s)">%s</g>' % (fid, inner)

    def layer_content(self, layer, f, mask_mode, depth):
        hit = frame_at(layer, f)
        if not hit:
            return '', None
        fr, start, nxt = hit
        body, bb = self.shape(fr['shape'], mask_mode) if 'shape' in fr else ('', None)
        for k in tweened_children(fr, nxt, f - start):
            b2, kb = self.element(k, f - start, mask_mode, depth)
            body += b2
            bb = bbox_union(bb, kb)
        return self.shared(body), bb

    def timeline_frame(self, root, f, mask_mode, depth):
        out, bb = [], None
        for L in layers_of(root):
            if L.get('kind') == 1:
                continue
            body, lbb = self.layer_content(L, f, mask_mode, depth)
            if not body:
                continue
            mask = L.get('parent')
            if mask is not None and mask.get('cls') == 'CPicLayer' and mask.get('kind') == 4:
                key = (id(mask), f)
                if key not in self.masks:
                    mbody, mbb = self.layer_content(mask, f, True, depth)
                    mid = self.shared_ids.get(('mask', mbody))
                    if mid is None:
                        mid = self.shared_ids[('mask', mbody)] = self.new_id('m')
                        self.defs.append('<mask id="%s" maskUnits="userSpaceOnUse" x="-100000" y="-100000" '
                                         'width="200000" height="200000">%s</mask>' % (mid, mbody))
                    self.masks[key] = (mid, mbb)
                mid, mbb = self.masks[key]
                body = '<g mask="url(#%s)">%s</g>' % (mid, body)
                lbb = bbox_intersect(lbb, mbb)
            out.append(body)
            bb = bbox_union(bb, lbb)
        return ''.join(out), bb

    def symbol_frame(self, num, f, mask_mode, depth):
        key = (num, f, mask_mode)
        if key in self.done:
            return self.done[key]
        self.done[key] = None  # guards against self-reference
        if depth > MAX_DEPTH:
            return None
        root = self.doc.symbol(num)
        body, bb = self.timeline_frame(root, f, mask_mode, depth) if root else ('', None)
        if not body or bb is None:
            return None
        sid = self.shared_ids.get(body)
        if sid is None:
            sid = self.shared_ids[body] = self.new_id('s')
            self.defs.append('<g id="%s">%s</g>' % (sid, body))
        self.done[key] = (sid, bb)
        return self.done[key]


def svg_document(defs, body, bb, title, background=None, pad=True):
    margin = max(2.0, 0.03 * max(bb[2] - bb[0], bb[3] - bb[1])) if pad else 0
    x, y = bb[0] - margin, bb[1] - margin
    w, h = bb[2] - bb[0] + 2 * margin, bb[3] - bb[1] + 2 * margin
    bgrect = ''
    if background is not None:
        col, alpha = css_color(background)
        bgrect = '<rect x="%s" y="%s" width="%s" height="%s" fill="%s"/>' % (fmt(x), fmt(y), fmt(w), fmt(h), col)
    return ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="%s %s %s %s" width="%s" height="%s">'
            '<title>%s</title><defs>%s</defs>%s%s</svg>'
            % (fmt(x), fmt(y), fmt(w), fmt(h), fmt(w), fmt(h), html.escape(title), ''.join(defs), bgrect, body))


def render_timeline_svg(doc, root, title, max_frames=MAX_ANIM_FRAMES, stage=False):
    """SVG text for a whole timeline (animated when it has several frames), or None.
    With stage=True the picture is cropped to the document's stage, like the published movie."""
    rnd = Renderer(doc)
    n = timeline_length(root)
    if n == 0:
        return None, 0
    frames = n if n <= max_frames else 1
    groups, bb = [], None
    for f in range(frames):
        body, fbb = rnd.timeline_frame(root, f, False, 0)
        groups.append(body)
        bb = bbox_union(bb, fbb)
    if bb is None or not any(groups):
        return None, 0
    extra = {'background': doc.background, 'pad': False} if stage else {}
    if stage:
        bb = doc.stage
    if frames == 1:
        return svg_document(rnd.defs, groups[0], bb, title, **extra), 1
    runs = []  # (first frame, last frame, body): identical neighbouring frames are merged
    for f, body in enumerate(groups):
        if runs and runs[-1][2] == body:
            runs[-1][1] = f
        else:
            runs.append([f, f, body])
    if len(runs) == 1:
        return svg_document(rnd.defs, runs[0][2], bb, title, **extra), frames
    dur = frames / doc.fps
    parts = []
    for a, b, body in runs:
        t0, t1 = a / frames, (b + 1) / frames
        if a == 0:
            anim = 'values="visible;hidden" keyTimes="0;%.5f"' % t1
        elif b == frames - 1:
            anim = 'values="hidden;visible" keyTimes="0;%.5f"' % t0
        else:
            anim = 'values="hidden;visible;hidden" keyTimes="0;%.5f;%.5f"' % (t0, t1)
        parts.append('<g visibility="%s">%s<animate attributeName="visibility" %s dur="%ss" '
                     'calcMode="discrete" repeatCount="indefinite"/></g>'
                     % ('visible' if a == 0 else 'hidden', body, anim, fmt(dur)))
    return svg_document(rnd.defs, ''.join(parts), bb, title, **extra), frames


def safe_name(s, limit=80):
    s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', s).strip(' .')
    return s[:limit] or 'unnamed'


def export_stage(streams, outdir, label):
    """Write each scene of the file as an animated SVG cropped to the stage
    (<label>.svg, then <label>_scene2.svg ...). Returns how many were written."""
    doc = Document(streams)
    written = 0
    for key in sorted((k for k in streams if re.fullmatch(r'Page \d+', k)), key=lambda k: int(k.split()[1])):
        svg, frames = render_timeline_svg(doc, doc.timeline(key), label, MAX_STAGE_FRAMES, stage=True)
        if not svg:
            continue
        os.makedirs(outdir, exist_ok=True)
        name = label if key == 'Page 1' else '%s_scene%s' % (label, key.split()[1])
        with open(os.path.join(outdir, safe_name(name, 120) + '.svg'), 'w', encoding='utf-8') as f:
            f.write(svg)
        written += 1
    return written


def export_vectors(streams, outdir, label):
    """Write an SVG per library symbol plus the stage, and a gallery page.
    Returns (count, notes)."""
    doc = Document(streams)
    items = []   # (kind, title, filename, frames)
    used = set()
    for key in sorted(k for k in streams if re.fullmatch(r'Page \d+', k)):
        root = doc.timeline(key)
        svg, frames = render_timeline_svg(doc, root, '%s %s' % (label, key), MAX_STAGE_FRAMES, stage=True)
        if svg:
            fn = 'Stage_%s.svg' % key.split()[1]
            items.append(('stage', 'Stage' + ('' if key == 'Page 1' else ' ' + key.split()[1]), fn, frames, svg))
    referenced = set()
    for num in doc.symbol_ids():
        root = doc.symbol(num)
        stack = [root] if root else []
        while stack:
            o = stack.pop()
            if o.get('cls') in ('CPicSymbol', 'CPicSprite') and 'lib' in o:
                referenced.add(o['lib'])
            stack += o.get('children', [])
            if o.get('cls') == 'CPicLayer' and isinstance(o.get('parent'), dict):
                stack.append(o['parent'])
    for num in doc.symbol_ids():
        root = doc.symbol(num)
        name = doc.names.get(num, 'Symbol %d' % num)
        svg, frames = render_timeline_svg(doc, root, name)
        if not svg:
            continue
        base = safe_name(name)
        fn = base + '.svg'
        n = 2
        while fn.lower() in used:
            fn = '%s_%d.svg' % (base, n)
            n += 1
        used.add(fn.lower())
        items.append(('part' if num in referenced else 'main', name, fn, frames, svg))
    if not items:
        return 0, ['no vector artwork found']
    os.makedirs(outdir, exist_ok=True)
    for _, _, fn, _, svg in items:
        with open(os.path.join(outdir, fn), 'w', encoding='utf-8') as f:
            f.write(svg)
    write_gallery(outdir, label, items)
    notes = []
    bad = [k for k in doc.errors if doc.errors[k] != 'missing']
    if bad:
        notes.append('%d of %d timelines only partly decoded' % (len(bad), len(streams)))
    return len(items), notes


def write_gallery(outdir, label, items):
    order = {'stage': 0, 'main': 1, 'part': 2}
    items = sorted(items, key=lambda it: (order[it[0]], it[1].lower()))
    sections = [('stage', 'Stage'), ('main', 'Complete symbols (not used inside other symbols)'),
                ('part', 'Parts (used inside other symbols)')]
    body = []
    for kind, heading in sections:
        group = [it for it in items if it[0] == kind]
        if not group:
            continue
        body.append('<h2>%s <span class="mute">(%d)</span></h2><div class="grid">' % (heading, len(group)))
        for _, title, fn, frames, _ in group:
            url = html.escape(fn, quote=True).replace(' ', '%20').replace('#', '%23')
            tag = ' <b>%d frames</b>' % frames if frames > 1 else ''
            body.append('<figure data-n="%s"><a href="%s" target="_blank"><img loading="lazy" src="%s" alt=""></a>'
                        '<figcaption>%s%s</figcaption></figure>'
                        % (html.escape(title.lower(), quote=True), url, url, html.escape(title), tag))
        body.append('</div>')
    page = '''<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%(t)s artwork</title><style>
:root{--bg:#f6f6f4;--card:#fff;--ink:#1d1d1b;--mute:#6b6b66;--line:#e2e2dc;--chk:#ededea}
@media (prefers-color-scheme:dark){:root{--bg:#161615;--card:#1f1f1d;--ink:#ecece8;--mute:#9a9a93;--line:#2e2e2b;--chk:#2a2a27}}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 system-ui,sans-serif}
main{max-width:1400px;margin:0 auto;padding:24px 16px}h1{font-size:22px;margin:0 0 4px}
h2{font-size:16px;margin:28px 0 12px;padding-bottom:6px;border-bottom:1px solid var(--line)}
.mute{color:var(--mute);font-weight:400}input{font:inherit;padding:8px 10px;border:1px solid var(--line);border-radius:8px;
background:var(--card);color:var(--ink);width:100%%;max-width:360px;box-sizing:border-box}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
figure{margin:0;background:var(--card);border:1px solid var(--line);border-radius:8px;overflow:hidden}
figure a{display:block;background:repeating-conic-gradient(var(--chk) 0 25%%,transparent 0 50%%) 0 0/16px 16px}
figure img{display:block;width:100%%;height:180px;object-fit:contain;padding:6px;box-sizing:border-box}
figcaption{padding:6px 8px;font-size:12px;word-break:break-word;color:var(--mute)}figcaption b{color:var(--ink);font-weight:600}
</style></head><body><main><h1>%(t)s</h1>
<p class="mute">%(n)d drawings rendered from the .fla. Items marked with a frame count are animated. Click one to open it full size.</p>
<input id="q" type="search" placeholder="Filter by name" autocomplete="off">
%(body)s</main><script>
document.getElementById('q').addEventListener('input',function(e){var q=e.target.value.toLowerCase();
document.querySelectorAll('figure').forEach(function(f){f.style.display=f.dataset.n.indexOf(q)<0?'none':''});});
</script></body></html>''' % {'t': html.escape(label), 'n': len(items), 'body': '\n'.join(body)}
    with open(os.path.join(outdir, 'index.html'), 'w', encoding='utf-8') as f:
        f.write(page)


# ---------------------------------------------------------------------------
# Previews

def find_browser():
    """A Chromium-based browser (Edge, Chrome or Chromium) for headless screenshots."""
    for name in ('msedge', 'microsoft-edge', 'microsoft-edge-stable', 'google-chrome', 'google-chrome-stable',
                 'chromium', 'chromium-browser', 'chrome'):
        found = shutil.which(name)
        if found:
            return found
    candidates = []
    for env in ('PROGRAMFILES(X86)', 'PROGRAMFILES', 'LOCALAPPDATA'):
        base = os.environ.get(env)
        if base:
            candidates += [os.path.join(base, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
                           os.path.join(base, 'Google', 'Chrome', 'Application', 'chrome.exe')]
    candidates += ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
                   '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
                   '/Applications/Chromium.app/Contents/MacOS/Chromium']
    return next((c for c in candidates if os.path.isfile(c)), None)


def preview(out, files, size=1200, browser=None):
    """Screenshot SVG/PNG files as a labelled grid (up to four per row) with a
    headless browser, to check renders without opening a browser window."""
    browser = browser or find_browser()
    if not browser:
        sys.exit('No Chrome, Edge or Chromium found for --preview; pass one with --browser PATH.')
    out, files = os.path.abspath(out), [os.path.abspath(f) for f in files]
    cols = min(4, len(files))
    cell = size // cols
    rows = (len(files) + cols - 1) // cols
    cells = ''.join(
        '<div style="border:1px solid #ddd;position:relative"><img src="%s?%d" '
        'style="width:100%%;height:100%%;object-fit:contain"><span style="position:absolute;left:4px;top:2px;'
        'font:11px sans-serif;color:#888">%s</span></div>'
        % (Path(f).as_uri(), random.randint(0, 10 ** 9), html.escape(os.path.basename(f)[:48])) for f in files)
    work = tempfile.mkdtemp(prefix='fla_preview_')
    try:
        page = os.path.join(work, 'view.html')
        with open(page, 'w', encoding='utf-8') as f:
            f.write('<!doctype html><html><body style="margin:0;background:#fff"><div style="display:grid;'
                    'grid-template-columns:repeat(%d,1fr);grid-auto-rows:%dpx">%s</div></body></html>'
                    % (cols, cell, cells))
        subprocess.run([browser, '--headless=new', '--disable-gpu', '--hide-scrollbars', '--no-first-run',
                        '--allow-file-access-from-files', '--user-data-dir=' + os.path.join(work, 'profile'),
                        '--window-size=%d,%d' % (size, cell * rows), '--screenshot=' + out, Path(page).as_uri()],
                       capture_output=True, timeout=120)
    finally:
        shutil.rmtree(work, ignore_errors=True)
    print(out if os.path.exists(out) else 'screenshot failed')


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument('paths', nargs='*', help='folders and/or .fla files (default: the current folder)')
    ap.add_argument('--out', default='extracted')
    ap.add_argument('--force', action='store_true')
    ap.add_argument('--jobs', type=int, default=os.cpu_count() or 2)
    ap.add_argument('--drawings', choices=('auto', 'all', 'shots', 'none'), default='auto')
    ap.add_argument('--preview', metavar='OUT.png')
    ap.add_argument('--width', type=int, default=1200)
    ap.add_argument('--browser', metavar='PATH')
    args = ap.parse_args()
    if args.preview:
        if not args.paths:
            ap.error('--preview needs the files to screenshot')
        preview(args.preview, args.paths, args.width, args.browser)
        return

    # group the inputs: a folder is one group; loose .fla files are grouped by their folder
    groups = {}  # folder -> list of .fla files
    for p in args.paths or ['.']:
        if os.path.isdir(p):
            groups.setdefault(os.path.abspath(p), []).extend(fla_files(p, skip=[args.out]))
        elif os.path.isfile(p) and p.lower().endswith('.fla'):
            groups.setdefault(os.path.dirname(os.path.abspath(p)), []).append(p)
        else:
            print('Skipping %s: not a folder or .fla file' % p)
    jobs = [(os.path.basename(folder) or 'files', folder, sorted(set(files)))
            for folder, files in groups.items() if files]
    if not jobs:
        print('No .fla files found.')
        return

    with ProcessPoolExecutor(max_workers=max(1, args.jobs)) as pool:
        for label, folder, files in jobs:
            outdir = os.path.join(args.out, label)
            os.makedirs(outdir, exist_ok=True)
            done_path = os.path.join(outdir, '.done.json')
            try:
                with open(done_path, encoding='utf-8') as f:
                    done = json.load(f)
            except (OSError, ValueError):
                done = {}
            todo = []
            for p in files:
                rel = os.path.relpath(os.path.abspath(p), folder)
                st = os.stat(p)
                if args.force or done.get(rel) != [st.st_size, int(st.st_mtime)]:
                    prefix = safe(os.path.splitext(rel)[0].replace(os.sep, '_'), 120)
                    todo.append((rel, [st.st_size, int(st.st_mtime)], pool.submit(process_fla, p, prefix, outdir, args.drawings)))
            print('%s: %d .fla file(s) to extract' % (label, len(todo)), flush=True)
            totals = {'images': 0, 'audio': 0, 'animatics': 0, 'drawings': 0, 'shots': 0}
            for n, (rel, sig, fut) in enumerate(todo, 1):
                try:
                    counts, notes = fut.result()
                except Exception as e:
                    counts, notes = {}, ['failed: %s' % e]
                for k, v in counts.items():
                    totals[k] += v
                for note in notes:
                    print('  %s: %s' % (rel, note))
                if not any(n.startswith('failed') for n in notes):
                    done[rel] = sig
                if n % 25 == 0 or n == len(todo):
                    print('  %d/%d done' % (n, len(todo)), flush=True)
                    with open(done_path, 'w', encoding='utf-8') as f:
                        json.dump(done, f, indent=0)
            write_index(outdir, label)
            print('  new: %d images, %d sounds, %d animatics, %d shots, %d drawings -> %s' % (
                totals['images'], totals['audio'], totals['animatics'], totals['shots'], totals['drawings'], outdir),
                flush=True)


if __name__ == '__main__':
    main()
