Guest User

DV to OGM video converter

a guest
Feb 28th, 2026
57
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 14.29 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3.  
  4. import argparse
  5. import random
  6. import struct
  7. import subprocess
  8. import sys
  9. from pathlib import Path
  10. from typing import Dict, Iterable, List, Tuple
  11.  
  12. MAGIC_DV = 0x21215644  # "DV!!" little-endian
  13.  
  14.  
  15. # -----------------------------
  16. # Basic helpers
  17. # -----------------------------
  18. def eprint(msg: str) -> None:
  19.     print(msg, file=sys.stderr)
  20.  
  21.  
  22. def run(cmd: List[str]) -> int:
  23.     p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
  24.     if p.returncode != 0:
  25.         eprint("Command failed:\n  " + " ".join(cmd))
  26.         if p.stdout.strip():
  27.             eprint("stdout:\n" + p.stdout)
  28.         if p.stderr.strip():
  29.             eprint("stderr:\n" + p.stderr)
  30.     return p.returncode
  31.  
  32.  
  33. # -----------------------------
  34. # DV!! extraction (same structure you already validated)
  35. # -----------------------------
  36. def parse_dvbang(dv_bytes: bytes) -> Tuple[Tuple[int, ...], bytes, bytes]:
  37.     if len(dv_bytes) < 76:
  38.         raise ValueError("File too small")
  39.  
  40.     hdr = struct.unpack_from("<19I", dv_bytes, 0)
  41.     if hdr[0] != MAGIC_DV:
  42.         raise ValueError("Not a DV!! file (bad magic)")
  43.  
  44.     n = hdr[7]
  45.     preroll_len = hdr[9]
  46.  
  47.     table_off = 76
  48.     table_len = 20 * n
  49.     if len(dv_bytes) < table_off + table_len:
  50.         raise ValueError("Truncated: packet table")
  51.  
  52.     entries = [struct.unpack_from("<5I", dv_bytes, table_off + 20 * i) for i in range(n)]
  53.  
  54.     data_off = table_off + table_len
  55.     if len(dv_bytes) < data_off + preroll_len:
  56.         raise ValueError("Truncated: preroll")
  57.  
  58.     preroll = dv_bytes[data_off : data_off + preroll_len]
  59.     pos = data_off + preroll_len
  60.  
  61.     video = bytearray()
  62.     audio = bytearray(preroll)
  63.  
  64.     for i, e in enumerate(entries):
  65.         size = e[0] & 0x7FFFFFFF
  66.         audio_bytes = e[3]
  67.  
  68.         chunk = dv_bytes[pos : pos + size]
  69.         if len(chunk) != size:
  70.             raise ValueError(f"Truncated packet {i}: expected {size}, got {len(chunk)}")
  71.         pos += size
  72.  
  73.         if audio_bytes:
  74.             if audio_bytes > size:
  75.                 raise ValueError(f"Packet {i}: audio_bytes > size")
  76.             vlen = size - audio_bytes
  77.             video += chunk[:vlen]
  78.             audio += chunk[vlen:]
  79.         else:
  80.             video += chunk
  81.  
  82.     return hdr, bytes(video), bytes(audio)
  83.  
  84.  
  85. # -----------------------------
  86. # Ogg primitives (page builder + CRC)
  87. # -----------------------------
  88. def _ogg_crc_table() -> List[int]:
  89.     poly = 0x04C11DB7
  90.     table: List[int] = []
  91.     for i in range(256):
  92.         r = i << 24
  93.         for _ in range(8):
  94.             if r & 0x80000000:
  95.                 r = ((r << 1) & 0xFFFFFFFF) ^ poly
  96.             else:
  97.                 r = (r << 1) & 0xFFFFFFFF
  98.         table.append(r)
  99.     return table
  100.  
  101.  
  102. CRC_TABLE = _ogg_crc_table()
  103.  
  104.  
  105. def ogg_crc(page_bytes: bytes) -> int:
  106.     crc = 0
  107.     for b in page_bytes:
  108.         crc = ((crc << 8) & 0xFFFFFFFF) ^ CRC_TABLE[((crc >> 24) & 0xFF) ^ b]
  109.     return crc
  110.  
  111.  
  112. def build_ogg_page(
  113.     packets: List[bytes],
  114.     serial: int,
  115.     seq: int,
  116.     granule: int,
  117.     header_type: int,
  118. ) -> bytes:
  119.     segs: List[int] = []
  120.     body = b""
  121.  
  122.     for pkt in packets:
  123.         body += pkt
  124.         n = len(pkt)
  125.         while n >= 255:
  126.             segs.append(255)
  127.             n -= 255
  128.         segs.append(n)
  129.  
  130.     nseg = len(segs)
  131.     if nseg > 255:
  132.         raise ValueError("Too many segments in one page (split packets/pages)")
  133.  
  134.     header = bytearray()
  135.     header += b"OggS"
  136.     header += bytes([0])  # version
  137.     header += bytes([header_type])
  138.     header += struct.pack("<Q", granule)
  139.     header += struct.pack("<I", serial)
  140.     header += struct.pack("<I", seq)
  141.     header += struct.pack("<I", 0)  # CRC placeholder
  142.     header += bytes([nseg])
  143.     header += bytes(segs)
  144.  
  145.     page = bytes(header) + body
  146.     crc = ogg_crc(page)
  147.     page = page[:22] + struct.pack("<I", crc) + page[26:]
  148.     return page
  149.  
  150.  
  151. # -----------------------------
  152. # Parse existing Vorbis Ogg pages so we can copy them exactly
  153. # -----------------------------
  154. def ogg_iter_pages(data: bytes) -> Iterable[Dict]:
  155.     i = 0
  156.     while True:
  157.         j = data.find(b"OggS", i)
  158.         if j < 0:
  159.             return
  160.         if j + 27 > len(data):
  161.             return
  162.         header_type = data[j + 5]
  163.         granule = struct.unpack_from("<Q", data, j + 6)[0]
  164.         serial = struct.unpack_from("<I", data, j + 14)[0]
  165.         seq = struct.unpack_from("<I", data, j + 18)[0]
  166.         nseg = data[j + 26]
  167.         segtable = data[j + 27 : j + 27 + nseg]
  168.         body_len = sum(segtable)
  169.         body_start = j + 27 + nseg
  170.         body = data[body_start : body_start + body_len]
  171.         page_bytes = data[j : body_start + body_len]
  172.         yield {
  173.             "header_type": header_type,
  174.             "granule": granule,
  175.             "serial": serial,
  176.             "seq": seq,
  177.             "segtable": segtable,
  178.             "body": body,
  179.             "bytes": page_bytes,
  180.         }
  181.         i = body_start + body_len
  182.  
  183.  
  184. def vorbis_sample_rate_from_ogg_pages(pages: List[Dict]) -> int:
  185.     # Reconstruct first packet (Vorbis identification): 0x01 "vorbis" ...
  186.     cur = bytearray()
  187.     for p in pages:
  188.         off = 0
  189.         for s in p["segtable"]:
  190.             cur += p["body"][off : off + s]
  191.             off += s
  192.             if s < 255:
  193.                 pkt = bytes(cur)
  194.                 # Vorbis ID packet begins with 0x01 + "vorbis"
  195.                 if len(pkt) >= 16 and pkt[0] == 0x01 and pkt[1:7] == b"vorbis":
  196.                     # version(4) at 7..10, channels(1) at 11, sample_rate(4) at 12..15
  197.                     return struct.unpack_from("<I", pkt, 12)[0]
  198.                 return 0
  199.     return 0
  200.  
  201.  
  202. def vorbis_header_end_page_index(pages: List[Dict]) -> int:
  203.     # Count packets across pages until we have the first 3 Vorbis packets (id/comment/setup)
  204.     pkt_count = 0
  205.     cur = bytearray()
  206.     for idx, p in enumerate(pages):
  207.         off = 0
  208.         for s in p["segtable"]:
  209.             cur += p["body"][off : off + s]
  210.             off += s
  211.             if s < 255:
  212.                 pkt_count += 1
  213.                 cur = bytearray()
  214.                 if pkt_count >= 3:
  215.                     return idx
  216.     return len(pages) - 1
  217.  
  218.  
  219. # -----------------------------
  220. # MPEG-4 Part2 elementary stream framing (split at VOP startcode)
  221. # -----------------------------
  222. def split_mpeg4_vop_frames(es: bytes) -> List[bytes]:
  223.     vop = b"\x00\x00\x01\xb6"
  224.     idxs: List[int] = []
  225.     i = 0
  226.     while True:
  227.         j = es.find(vop, i)
  228.         if j < 0:
  229.             break
  230.         idxs.append(j)
  231.         i = j + 4
  232.  
  233.     if not idxs:
  234.         raise ValueError("No MPEG-4 VOP start codes found (0x000001B6)")
  235.  
  236.     header = es[: idxs[0]]
  237.     frames: List[bytes] = []
  238.     for k, start in enumerate(idxs):
  239.         end = idxs[k + 1] if k + 1 < len(idxs) else len(es)
  240.         frames.append(es[start:end])
  241.  
  242.     # Put VOL/headers in the first frame packet
  243.     frames[0] = header + frames[0]
  244.     return frames
  245.  
  246.  
  247. # -----------------------------
  248. # OGM (OggDS) stream headers and packets
  249. # Spec layout referenced in ogmstreams.h :contentReference[oaicite:1]{index=1}
  250. # -----------------------------
  251. def build_ogm_stream_header_video(width: int, height: int, fps: int, fourcc: bytes = b"XVID") -> bytes:
  252.     # stream_header structure (new header format) + video union fields.
  253.     # Layout (little-endian), size=52 bytes (see ogmstreams.h) :contentReference[oaicite:2]{index=2}
  254.     streamtype = (b"video" + b"\x00" * 3)  # 8 bytes
  255.     subtype = fourcc[:4].ljust(4, b"\x00")
  256.  
  257.     size = 52
  258.     time_unit = int(round(10_000_000 / fps))  # "reference time" units
  259.     samples_per_unit = 1
  260.     default_len = 1
  261.     buffersize = 0
  262.     bits_per_sample = 0
  263.     padding = 0
  264.  
  265.     sh_video = struct.pack("<ii", width, height)
  266.  
  267.     base = struct.pack(
  268.         "<8s4siqqiih",
  269.         streamtype,
  270.         subtype,
  271.         size,
  272.         time_unit,
  273.         samples_per_unit,
  274.         default_len,
  275.         buffersize,
  276.         bits_per_sample,
  277.     )
  278.     return base + sh_video + struct.pack("<h", padding)
  279.  
  280.  
  281. def build_vorbis_comment(vendor: bytes = b"dvbang") -> bytes:
  282.     # Vorbis-style comment body: vendor_len + vendor + user_comment_list_len
  283.     return struct.pack("<I", len(vendor)) + vendor + struct.pack("<I", 0)
  284.  
  285.  
  286. def ogm_video_header_packet(width: int, height: int, fps: int) -> bytes:
  287.     # First packet: 0x01 + stream_header :contentReference[oaicite:3]{index=3}
  288.     return b"\x01" + build_ogm_stream_header_video(width, height, fps)
  289.  
  290.  
  291. def ogm_video_comment_packet() -> bytes:
  292.     # Second packet: 0x03 + vorbis comment payload :contentReference[oaicite:4]{index=4}
  293.     return b"\x03" + build_vorbis_comment()
  294.  
  295.  
  296. def ogm_video_data_packet(frame_bytes: bytes, samples: int = 1, keyframe: bool = True, lenbytes: int = 2) -> bytes:
  297.     """
  298.    Data packets:
  299.      - byte0: flags, includes lenbytes encoding bits and keyframe bit :contentReference[oaicite:5]{index=5}
  300.      - next lenbytes: packet duration in samples (frames for video)
  301.      - then payload
  302.    """
  303.     lb = lenbytes
  304.     if not (0 <= lb <= 7):
  305.         raise ValueError("lenbytes must be 0..7")
  306.  
  307.     bit0 = (lb & 0x1)
  308.     bit1 = (lb & 0x2) >> 1
  309.     bit2 = (lb & 0x4) >> 2
  310.  
  311.     flags = 0
  312.     if keyframe:
  313.         flags |= 0x08
  314.     # lenbytes mapping per ogmstreams.h bit description :contentReference[oaicite:6]{index=6}
  315.     flags |= (bit0 << 6) | (bit1 << 7) | (bit2 << 1)
  316.  
  317.     return bytes([flags]) + samples.to_bytes(lb, "little") + frame_bytes
  318.  
  319.  
  320. # -----------------------------
  321. # Build multiplexed OGM (.ogv) without ffmpeg muxing
  322. # -----------------------------
  323. def mux_to_ogv(
  324.     out_path: Path,
  325.     *,
  326.     width: int,
  327.     height: int,
  328.     fps: int,
  329.     frames: List[bytes],
  330.     audio_pages: List[Dict],
  331.     audio_sample_rate: int,
  332. ) -> None:
  333.     if not audio_pages:
  334.         raise ValueError("No audio Ogg pages")
  335.  
  336.     audio_serial = audio_pages[0]["serial"]
  337.     video_serial = random.randint(1, 0xFFFFFFFF)
  338.     while video_serial == audio_serial:
  339.         video_serial = random.randint(1, 0xFFFFFFFF)
  340.  
  341.     # Find where Vorbis headers end (first 3 packets)
  342.     hdr_end = vorbis_header_end_page_index(audio_pages)
  343.  
  344.     # Required by many demuxers: all BOS pages first.
  345.     # We output:
  346.     #   - audio BOS (copied)
  347.     #   - video BOS (our header packet)
  348.     # then the rest of audio header pages, then video comment, then interleaved data.
  349.     v_header = ogm_video_header_packet(width, height, fps)
  350.     v_comment = ogm_video_comment_packet()
  351.  
  352.     with out_path.open("wb") as f:
  353.         # 1) BOS pages
  354.         f.write(audio_pages[0]["bytes"])  # audio BOS page (unchanged)
  355.         f.write(build_ogg_page([v_header], video_serial, 0, 0, 0x02))  # video BOS
  356.  
  357.         vseq = 1
  358.  
  359.         # 2) video comment header page
  360.         f.write(build_ogg_page([v_comment], video_serial, vseq, 0, 0x00))
  361.         vseq += 1
  362.  
  363.         # 3) remaining audio header pages (excluding BOS page already written)
  364.         for p in audio_pages[1 : hdr_end + 1]:
  365.             f.write(p["bytes"])
  366.  
  367.         # 4) interleave data pages by time
  368.         v_i = 0
  369.  
  370.         for ap in audio_pages[hdr_end + 1 :]:
  371.             at = (ap["granule"] / audio_sample_rate) if audio_sample_rate else 0.0
  372.  
  373.             # Emit video frames up to this audio timestamp (with a small half-frame slack)
  374.             while v_i < len(frames) and ((v_i + 1) / fps) <= at + (0.5 / fps):
  375.                 pkt = ogm_video_data_packet(frames[v_i], samples=1, keyframe=True, lenbytes=2)
  376.                 gran = v_i + 1  # granulepos = frames decoded so far
  377.                 header_type = 0x04 if v_i == (len(frames) - 1) else 0x00
  378.                 f.write(build_ogg_page([pkt], video_serial, vseq, gran, header_type))
  379.                 vseq += 1
  380.                 v_i += 1
  381.  
  382.             # Then copy this audio page unchanged
  383.             f.write(ap["bytes"])
  384.  
  385.         # If audio ended early, flush remaining video
  386.         while v_i < len(frames):
  387.             pkt = ogm_video_data_packet(frames[v_i], samples=1, keyframe=True, lenbytes=2)
  388.             gran = v_i + 1
  389.             header_type = 0x04 if v_i == (len(frames) - 1) else 0x00
  390.             f.write(build_ogg_page([pkt], video_serial, vseq, gran, header_type))
  391.             vseq += 1
  392.             v_i += 1
  393.  
  394.  
  395. # -----------------------------
  396. # Main
  397. # -----------------------------
  398. def main() -> int:
  399.     ap = argparse.ArgumentParser(description="Convert DV!! to .ogv (OGM/OggDS) in pure Python (no ffmpeg muxing).")
  400.     ap.add_argument("input", type=Path, help="Input DV!! file (e.g. kleo19.dv)")
  401.     ap.add_argument("--out", type=Path, default=None, help="Output .ogv path (default: <input>.ogv)")
  402.     ap.add_argument("--fps", type=int, default=25, help="FPS (kleo19.dv: 25)")
  403.     ap.add_argument("--validate", action="store_true", help="Do ffmpeg decode validation step")
  404.     args = ap.parse_args()
  405.  
  406.     dv_path: Path = args.input
  407.     if not dv_path.exists():
  408.         eprint(f"Not found: {dv_path}")
  409.         return 2
  410.  
  411.     out_path = args.out or dv_path.with_suffix(".ogv")
  412.  
  413.     hdr, video_es, audio_ogg = parse_dvbang(dv_path.read_bytes())
  414.     width, height = hdr[4], hdr[5]
  415.  
  416.     # Parse audio ogg pages and get sample rate
  417.     audio_pages = list(ogg_iter_pages(audio_ogg))
  418.     sr = vorbis_sample_rate_from_ogg_pages(audio_pages)
  419.     if sr <= 0:
  420.         eprint("WARNING: could not detect Vorbis sample rate; interleaving may be less accurate.")
  421.         sr = 44100  # safe fallback for typical files
  422.  
  423.     # Split MPEG-4 Part2 elementary stream into frames
  424.     frames = split_mpeg4_vop_frames(video_es)
  425.  
  426.     mux_to_ogv(
  427.         out_path,
  428.         width=width,
  429.         height=height,
  430.         fps=args.fps,
  431.         frames=frames,
  432.         audio_pages=audio_pages,
  433.         audio_sample_rate=sr,
  434.     )
  435.  
  436.     print(f"ogv: {out_path}")
  437.     print(f"video: MPEG-4 Part 2 (frames={len(frames)} {width}x{height} fps={args.fps})")
  438.     print(f"audio: Vorbis (sr={sr} pages={len(audio_pages)})")
  439.  
  440.     # Validation (decode test only; not muxing)
  441.     if args.validate:
  442.         rc = run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(out_path), "-f", "null", "-"])
  443.         if rc != 0:
  444.             eprint("WARNING: ffmpeg decode test reported errors (file was still written).")
  445.  
  446.     return 0
  447.  
  448.  
  449. if __name__ == "__main__":
  450.     raise SystemExit(main())
Advertisement
Add Comment
Please, Sign In to add comment