Guest User

DV video convertor

a guest
Feb 28th, 2026
85
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 7.14 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3.  
  4. import argparse
  5. import json
  6. import math
  7. import struct
  8. import subprocess
  9. import sys
  10. from pathlib import Path
  11. from typing import List, Tuple
  12.  
  13. MAGIC_DV = 0x21215644  # "DV!!" little-endian
  14.  
  15. def run(cmd: List[str], *, check: bool = True) -> subprocess.CompletedProcess:
  16.     p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
  17.     if check and p.returncode != 0:
  18.         raise RuntimeError(
  19.             "Command failed:\n  {}\n\nstdout:\n{}\n\nstderr:\n{}".format(
  20.                 " ".join(cmd), p.stdout, p.stderr
  21.             )
  22.         )
  23.     return p
  24.  
  25.  
  26. def ffprobe_json(path: Path) -> dict:
  27.     p = run(
  28.         [
  29.             "ffprobe",
  30.             "-hide_banner",
  31.             "-loglevel",
  32.             "error",
  33.             "-print_format",
  34.             "json",
  35.             "-show_format",
  36.             "-show_streams",
  37.             "-show_packets",
  38.             "-select_streams",
  39.             "v:0",
  40.             str(path),
  41.         ],
  42.         check=True,
  43.     )
  44.     return json.loads(p.stdout)
  45.  
  46.  
  47. def ffprobe_format_duration(path: Path) -> float:
  48.     p = run(
  49.         [
  50.             "ffprobe",
  51.             "-hide_banner",
  52.             "-loglevel",
  53.             "error",
  54.             "-show_entries",
  55.             "format=duration",
  56.             "-of",
  57.             "default=nw=1:nk=1",
  58.             str(path),
  59.         ],
  60.         check=True,
  61.     )
  62.     return float(p.stdout.strip())
  63.  
  64.  
  65. def parse_header_and_table(dv_bytes: bytes) -> Tuple[Tuple[int, ...], List[Tuple[int, int, int, int, int]]]:
  66.     if len(dv_bytes) < 76:
  67.         raise ValueError("File too small to be DV!!")
  68.  
  69.     hdr = struct.unpack_from("<19I", dv_bytes, 0)
  70.     if hdr[0] != MAGIC_DV:
  71.         raise ValueError("Bad magic: not DV!!")
  72.  
  73.     n = hdr[7]
  74.     table_off = 76
  75.     table_len = 20 * n
  76.     if len(dv_bytes) < table_off + table_len:
  77.         raise ValueError("Truncated: packet table incomplete")
  78.  
  79.     entries = [struct.unpack_from("<5I", dv_bytes, table_off + 20 * i) for i in range(n)]
  80.     return hdr, entries
  81.  
  82.  
  83. def extract_streams(dv_path: Path, out_dir: Path) -> Tuple[Path, Path, int]:
  84.     """
  85.    Extract:
  86.      - video: raw MPEG4 elementary stream (.m4v)
  87.      - audio: Ogg/Vorbis (.ogg) = preroll + concatenated audio tails
  88.    """
  89.     dv_bytes = dv_path.read_bytes()
  90.     hdr, entries = parse_header_and_table(dv_bytes)
  91.  
  92.     n = hdr[7]
  93.     preroll_len = hdr[9]
  94.  
  95.     table_off = 76
  96.     data_off = table_off + 20 * n
  97.  
  98.     if len(dv_bytes) < data_off + preroll_len:
  99.         raise ValueError("Truncated: missing audio preroll")
  100.  
  101.     preroll = dv_bytes[data_off : data_off + preroll_len]
  102.     packet_region_off = data_off + preroll_len
  103.  
  104.     sizes = [(e[0] & 0x7FFFFFFF) for e in entries]
  105.     total_packets = sum(sizes)
  106.  
  107.     if len(dv_bytes) < packet_region_off + total_packets:
  108.         raise ValueError("Truncated: packet region shorter than expected")
  109.  
  110.     video = bytearray()
  111.     audio = bytearray(preroll)
  112.  
  113.     pos = packet_region_off
  114.     for i in range(n):
  115.         sz = sizes[i]
  116.         audio_bytes = entries[i][3]
  117.         chunk = dv_bytes[pos : pos + sz]
  118.         if len(chunk) != sz:
  119.             raise ValueError(f"Truncated packet {i}: expected {sz}, got {len(chunk)}")
  120.         if audio_bytes:
  121.             if audio_bytes > sz:
  122.                 raise ValueError(f"Packet {i}: audio_bytes ({audio_bytes}) > size ({sz})")
  123.             vlen = sz - audio_bytes
  124.             video += chunk[:vlen]
  125.             audio += chunk[vlen:]
  126.         else:
  127.             video += chunk
  128.         pos += sz
  129.  
  130.     out_dir.mkdir(parents=True, exist_ok=True)
  131.     video_path = out_dir / f"{dv_path.stem}.video.m4v"
  132.     audio_path = out_dir / f"{dv_path.stem}.audio.ogg"
  133.     video_path.write_bytes(video)
  134.     audio_path.write_bytes(audio)
  135.  
  136.     return video_path, audio_path, n
  137.  
  138.  
  139. def mux_mkv_with_fixed_timestamps(
  140.     video_path: Path,
  141.     audio_path: Path,
  142.     out_path: Path,
  143.     *,
  144.     fps: int,
  145. ) -> None:
  146.     """
  147.    Stream-copy mux, but inject correct timestamps into the raw MPEG4 video
  148.    using setts bitstream filter.
  149.  
  150.    We use time_base=1/1000 and 25fps => 40ms per frame:
  151.      pts = 40*N, dts = 40*N, duration = 40
  152.    """
  153.     frame_ms = int(round(1000 / fps))  # 25 -> 40
  154.     bsf = f"setts=pts={frame_ms}*N:dts={frame_ms}*N:duration={frame_ms}:time_base=1/1000"
  155.  
  156.     run(
  157.         [
  158.             "ffmpeg",
  159.             "-hide_banner",
  160.             "-y",
  161.             "-loglevel",
  162.             "error",
  163.             "-i",
  164.             str(video_path),
  165.             "-i",
  166.             str(audio_path),
  167.             "-map",
  168.             "0:v:0",
  169.             "-map",
  170.             "1:a:0",
  171.             "-c",
  172.             "copy",
  173.             "-bsf:v",
  174.             bsf,
  175.             str(out_path),
  176.         ],
  177.         check=True,
  178.     )
  179.  
  180.  
  181. def validate_output(out_path: Path, *, expected_frames: int, audio_path: Path, fps: int) -> None:
  182.     # 1) Ensure output is decodable end-to-end (fatal errors => non-zero)
  183.     run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(out_path), "-f", "null", "-"], check=True)
  184.  
  185.     # 2) Video packet count == expected frame count
  186.     j = ffprobe_json(out_path)
  187.     packets = j.get("packets", [])
  188.     if len(packets) != expected_frames:
  189.         raise RuntimeError(f"Bad mux: video packet count {len(packets)} != expected {expected_frames}")
  190.  
  191.     # 3) Duration match (audio duration is the reliable one here)
  192.     out_dur = ffprobe_format_duration(out_path)
  193.     aud_dur = ffprobe_format_duration(audio_path)
  194.  
  195.     # Allow small container rounding error (Matroska timebase granularity)
  196.     if abs(out_dur - aud_dur) > 0.10:
  197.         raise RuntimeError(f"Bad mux: duration mismatch. out={out_dur:.3f}s audio={aud_dur:.3f}s")
  198.  
  199.     # 4) Sanity: expected duration from frames/fps roughly matches too
  200.     expected_dur = expected_frames / fps
  201.     if abs(out_dur - expected_dur) > 0.25:
  202.         raise RuntimeError(
  203.             f"Bad mux: duration vs frames/fps mismatch. out={out_dur:.3f}s expected~{expected_dur:.3f}s"
  204.         )
  205.  
  206.  
  207. def main() -> int:
  208.     ap = argparse.ArgumentParser(description="Convert DV!! .dv to a playable MKV without re-encoding.")
  209.     ap.add_argument("input", type=Path, help="Input DV!! file (e.g. kleo19.dv)")
  210.     ap.add_argument("--out-dir", type=Path, default=Path("out"), help="Output directory")
  211.     ap.add_argument("--fps", type=int, default=25, help="Video FPS (kleo19.dv is 25)")
  212.     args = ap.parse_args()
  213.  
  214.     dv_path: Path = args.input
  215.     if not dv_path.exists():
  216.         print(f"Not found: {dv_path}", file=sys.stderr)
  217.         return 2
  218.  
  219.     out_dir: Path = args.out_dir
  220.     video_path, audio_path, frames = extract_streams(dv_path, out_dir)
  221.  
  222.     out_mkv = out_dir / f"{dv_path.stem}.mkv"
  223.     mux_mkv_with_fixed_timestamps(video_path, audio_path, out_mkv, fps=args.fps)
  224.  
  225.     validate_output(out_mkv, expected_frames=frames, audio_path=audio_path, fps=args.fps)
  226.  
  227.     print("OK")
  228.     print(f"  video: {video_path}")
  229.     print(f"  audio: {audio_path}")
  230.     print(f"  mkv:   {out_mkv}")
  231.     print(f"  frames: {frames}, fps: {args.fps}")
  232.     return 0
  233.  
  234.  
  235. if __name__ == "__main__":
  236.     raise SystemExit(main())
Advertisement
Add Comment
Please, Sign In to add comment