1- """Datamoshing effects via optical-flow-driven frame warping .
1+ """Datamoshing effects.
22
3- :func:`iframe_delete` simulates keyframe deletion at a scene cut: the last
4- frame of scene A is warped forward by scene B's motion vectors, producing the
5- characteristic "melting between scenes" look.
3+ :func:`iframe_delete` performs true MPEG-4 bitstream datamoshing by binary-
4+ patching encoded I-frames so the decoder treats them as P-frames, producing
5+ authentic block artefacts and colour smearing at the codec level. Requires
6+ ``ffmpeg`` and ``ffprobe`` in ``PATH``.
7+
8+ :func:`iframe_delete_optflow` simulates I-frame deletion in decoded pixel
9+ space: the last frame of scene A is warped forward by scene B's motion
10+ vectors using OpenCV dense optical flow, no compressed video files required.
611
712:func:`pframe_dup` simulates P-frame duplication: a frozen reference frame is
813warped by the accumulated motion of subsequent frames, producing pixel
914streaking and blooming in the direction of movement.
10-
11- Both functions work entirely in decoded pixel space (``uint8`` RGB NumPy
12- arrays) using OpenCV dense optical flow — no compressed video files are
13- required.
1415"""
1516
1617from __future__ import annotations
1718
1819import itertools
20+ import json
21+ import os
22+ import subprocess
1923import sys
24+ import tempfile
2025from typing import TYPE_CHECKING , Iterator
2126
2227import cv2
@@ -138,7 +143,7 @@ def _frame_count(source: "Image | Video | FrameGenerator") -> int | None:
138143 return getattr (source , "frame_count" , None )
139144
140145
141- def iframe_delete (
146+ def iframe_delete_optflow (
142147 scene_a : "Image | Video | FrameGenerator" ,
143148 scene_b : "Image | Video | FrameGenerator | None" = None ,
144149 * ,
@@ -148,7 +153,7 @@ def iframe_delete(
148153 dis : bool = False ,
149154 progress : bool = True ,
150155) -> "Image | Video" :
151- """Simulate I-frame deletion at a scene cut.
156+ """Simulate I-frame deletion at a scene cut using optical flow .
152157
153158 The last frame of ``scene_a`` (or the frame just before
154159 ``transition_frame`` in single-source mode) acts as the decoder's frozen
@@ -157,6 +162,9 @@ def iframe_delete(
157162 from the new scene's own motion — the visual equivalent of removing the
158163 keyframe that would otherwise reset the image to the new scene content.
159164
165+ Works entirely in decoded pixel space (``uint8`` RGB NumPy arrays) using
166+ OpenCV dense optical flow — no compressed video files are required.
167+
160168 **Output frames** — only the warped frames derived from ``scene_a``'s
161169 reference are returned. ``scene_a``'s original frames are consumed solely
162170 to extract the reference and are *not* included in the output. In
@@ -212,14 +220,14 @@ def iframe_delete(
212220 Example::
213221
214222 from dithertools import Video
215- from dithertools.datamosh import iframe_delete
223+ from dithertools.datamosh import iframe_delete_optflow
216224
217225 # Two-source: apply scene_b's motion to scene_a's last frame
218- result = iframe_delete (Video("scene_a.mp4"), Video("scene_b.mp4"))
226+ result = iframe_delete_optflow (Video("scene_a.mp4"), Video("scene_b.mp4"))
219227 result.save("moshed.mp4")
220228
221229 # Single-source: delete the keyframe at frame 60
222- result = iframe_delete (Video("clip.mp4"), transition_frame=60)
230+ result = iframe_delete_optflow (Video("clip.mp4"), transition_frame=60)
223231 result.save("moshed.mp4")
224232 """
225233 from dithertools .image import Image
@@ -301,6 +309,240 @@ def _single_source() -> Iterator[np.ndarray]:
301309 return Video .from_frames (_progress_iter (_single_source (), total , progress ), fps = fps )
302310
303311
312+ def _aviglitch (data : bytearray , kf_positions : list [tuple [int , int ]]) -> None :
313+ """Zero non-first keyframe payloads and clear AVIIF_KEYFRAME in idx1.
314+
315+ kf_positions: list of (abs_file_pos, payload_size) for each video keyframe
316+ in stream order, as returned by ffprobe show_packets. The first entry is
317+ kept intact; all subsequent ones are zeroed (triggering decoder error
318+ concealment — the decoder copies the last decoded frame instead of
319+ resetting to the intra content) and their AVIIF_KEYFRAME flag in the AVI
320+ idx1 index is cleared (preventing decoders from flushing their reference
321+ buffer at the transition point).
322+ """
323+ AVIIF_KEYFRAME = 0x10
324+
325+ for pos , size in kf_positions [1 :]:
326+ data [pos : pos + size ] = bytes (size )
327+
328+ idx1_off = bytes (data ).find (b"idx1" )
329+ if idx1_off == - 1 :
330+ return
331+
332+ idx1_size = int .from_bytes (data [idx1_off + 4 : idx1_off + 8 ], "little" )
333+ idx1_start = idx1_off + 8
334+ n_entries = idx1_size // 16
335+ kf_count = 0
336+
337+ for i in range (n_entries ):
338+ e = idx1_start + i * 16
339+ if e + 16 > len (data ):
340+ break
341+ fcc = bytes (data [e : e + 4 ])
342+ flags = int .from_bytes (data [e + 4 : e + 8 ], "little" )
343+ if fcc [2 :4 ] not in (b"dc" , b"db" ) or not (flags & AVIIF_KEYFRAME ):
344+ continue
345+ kf_count += 1
346+ if kf_count > 1 :
347+ data [e + 4 : e + 8 ] = (flags & ~ AVIIF_KEYFRAME ).to_bytes (4 , "little" )
348+
349+
350+ def iframe_delete (
351+ scene_a : "Image | Video | FrameGenerator" ,
352+ scene_b : "Image | Video | FrameGenerator | None" = None ,
353+ * ,
354+ transition_frame : int | None = None ,
355+ quality : int = 3 ,
356+ progress : bool = True ,
357+ ) -> "Video" :
358+ """True MPEG-4 bitstream datamosh at a scene cut.
359+
360+ Encodes all input frames to a temporary MPEG-4 AVI, forces I-frames only
361+ at frame 0 and the transition point, then applies the aviglitch-style
362+ effect to the transition I-frame: its payload is zeroed (the decoder
363+ applies error concealment — copying scene A's last frame — instead of
364+ resetting to scene B's intra content) and its ``AVIIF_KEYFRAME`` flag in
365+ the AVI ``idx1`` index is cleared (preventing decoders from flushing
366+ their reference buffer at the cut). Scene B's subsequent P-frames then
367+ apply their motion vectors to scene A's pixel state, producing the
368+ characteristic datamosh: block artefacts, colour smearing, and
369+ macroblocks at the codec level.
370+
371+ Requires ``ffmpeg`` and ``ffprobe`` in ``PATH``.
372+
373+ **Mode selection** — supply exactly one of ``scene_b`` or
374+ ``transition_frame``:
375+
376+ - *Two-source mode* (``scene_b`` provided): all frames from ``scene_a``
377+ followed by all frames from ``scene_b`` are returned. The transition
378+ artefact appears at the scene cut; ``scene_a``'s frames provide the
379+ visual anchor the effect needs.
380+ - *Single-source mode* (``transition_frame`` provided): frames before the
381+ cut pass through unchanged; frames from ``transition_frame`` onward
382+ carry the datamosh artefacts.
383+
384+ Args:
385+ scene_a: The donor scene. In two-source mode, its last frame becomes
386+ the motion reference for ``scene_b``. In single-source mode,
387+ this is the full source. May be an
388+ :class:`~dithertools.image.Image`,
389+ :class:`~dithertools.video.Video`, or
390+ :class:`~dithertools.generators.FrameGenerator`.
391+ scene_b: The motion-donor scene (two-source mode). ``None`` when
392+ using single-source mode.
393+ transition_frame: Index at which the I-frame deletion occurs
394+ (single-source mode). ``None`` when ``scene_b`` is provided.
395+ quality: MPEG-4 ``qscale:v`` value. ``1`` = best quality / least
396+ artefacts; ``31`` = worst quality / most macroblocks. Defaults
397+ to ``3``.
398+ progress: Display a progress spinner on stderr while encoding frames.
399+ Only active when stderr is a TTY. Defaults to ``True``.
400+
401+ Returns:
402+ A memory-backed :class:`~dithertools.video.Video` containing all
403+ output frames — scene_a + moshed scene_b in two-source mode, or the
404+ full source with post-transition artefacts in single-source mode.
405+
406+ Raises:
407+ ValueError: If both ``scene_b`` and ``transition_frame`` are given,
408+ or if neither is given.
409+ RuntimeError: If ``ffmpeg`` or ``ffprobe`` is not found in ``PATH``,
410+ or if encoding or probing fails.
411+
412+ Example::
413+
414+ from dithertools import Video
415+ from dithertools.datamosh import iframe_delete
416+
417+ # Two-source: authentic datamosh at the cut between two scenes
418+ result = iframe_delete(Video("scene_a.mp4"), Video("scene_b.mp4"))
419+ result.save("moshed.mp4")
420+
421+ # Single-source: delete the keyframe at frame 60
422+ result = iframe_delete(Video("clip.mp4"), transition_frame=60)
423+ result.save("moshed.mp4")
424+ """
425+ from dithertools .image import Image
426+ from dithertools .video import Video
427+
428+ if scene_b is not None and transition_frame is not None :
429+ raise ValueError (
430+ "Provide either scene_b (two-source mode) or transition_frame "
431+ "(single-source mode), not both."
432+ )
433+ if scene_b is None and transition_frame is None :
434+ raise ValueError (
435+ "Provide either scene_b (two-source mode) or transition_frame "
436+ "(single-source mode)."
437+ )
438+
439+ def _collect (src : "Image | Video | FrameGenerator" ) -> list [np .ndarray ]:
440+ if isinstance (src , Image ):
441+ return [src .data ]
442+ return list (src )
443+
444+ if scene_b is not None :
445+ a_frames = _collect (scene_a )
446+ b_frames = _collect (scene_b )
447+ all_frames = a_frames + b_frames
448+ transition = len (a_frames )
449+ fps = _fps (scene_b ) or _fps (scene_a ) or 24.0
450+ else :
451+ all_frames = _collect (scene_a )
452+ transition = transition_frame # type: ignore[assignment]
453+ fps = _fps (scene_a ) or 24.0
454+
455+ if not all_frames :
456+ raise ValueError ("source yielded no frames" )
457+
458+ h , w = all_frames [0 ].shape [:2 ]
459+ total = len (all_frames )
460+
461+ tmp_path : str | None = None
462+ try :
463+ fd , tmp_path = tempfile .mkstemp (suffix = ".avi" )
464+ os .close (fd )
465+
466+ ffmpeg_cmd = [
467+ "ffmpeg" , "-y" ,
468+ "-f" , "rawvideo" ,
469+ "-pix_fmt" , "rgb24" ,
470+ "-s" , f"{ w } x{ h } " ,
471+ "-r" , str (fps ),
472+ "-i" , "pipe:0" ,
473+ "-c:v" , "mpeg4" ,
474+ "-qscale:v" , str (quality ),
475+ "-g" , "99999" ,
476+ "-sc_threshold" , "0" ,
477+ "-force_key_frames" , f"expr:eq(n,0)+eq(n,{ transition } )" ,
478+ tmp_path ,
479+ ]
480+ proc = subprocess .Popen (
481+ ffmpeg_cmd ,
482+ stdin = subprocess .PIPE ,
483+ stderr = subprocess .DEVNULL ,
484+ )
485+ assert proc .stdin is not None
486+ for frame in _progress_iter (iter (all_frames ), total , progress ):
487+ proc .stdin .write (frame .tobytes ())
488+ proc .stdin .close ()
489+ proc .wait ()
490+ if proc .returncode != 0 :
491+ raise RuntimeError (f"ffmpeg failed with exit code { proc .returncode } " )
492+
493+ probe_cmd = [
494+ "ffprobe" , "-v" , "error" ,
495+ "-select_streams" , "v:0" ,
496+ "-show_packets" ,
497+ "-show_entries" , "packet=flags,pos,size" ,
498+ "-of" , "json" ,
499+ tmp_path ,
500+ ]
501+ probe = subprocess .run (probe_cmd , capture_output = True , text = True , check = True )
502+ pkts = json .loads (probe .stdout ).get ("packets" , [])
503+
504+ kf_positions = [
505+ (int (p ["pos" ]), int (p ["size" ]))
506+ for p in pkts
507+ if "K" in p .get ("flags" , "" )
508+ ]
509+
510+ with open (tmp_path , "r+b" ) as f :
511+ avi_data = bytearray (f .read ())
512+
513+ _aviglitch (avi_data , kf_positions )
514+
515+ with open (tmp_path , "wb" ) as f :
516+ f .write (avi_data )
517+
518+ # Decode via ffmpeg, which applies error concealment on the zeroed
519+ # I-frame (copying scene A's last decoded frame into the reference
520+ # buffer) before processing scene B's P-frames. OpenCV's
521+ # VideoCapture treats the decode error as fatal and stops reading,
522+ # which would truncate the output.
523+ decode_cmd = [
524+ "ffmpeg" , "-v" , "error" ,
525+ "-i" , tmp_path ,
526+ "-f" , "rawvideo" , "-pix_fmt" , "rgb24" ,
527+ "pipe:1" ,
528+ ]
529+ decode_proc = subprocess .run (decode_cmd , capture_output = True )
530+ raw = decode_proc .stdout
531+ frame_size = h * w * 3
532+ n_frames = len (raw ) // frame_size
533+ frames = [
534+ np .frombuffer (
535+ raw [i * frame_size : (i + 1 ) * frame_size ], dtype = np .uint8
536+ ).reshape (h , w , 3 ).copy ()
537+ for i in range (n_frames )
538+ ]
539+ return Video .from_frames (iter (frames ), fps = fps )
540+
541+ finally :
542+ if tmp_path and os .path .exists (tmp_path ):
543+ os .unlink (tmp_path )
544+
545+
304546def pframe_dup (
305547 source : "Image | Video | FrameGenerator" ,
306548 transition_frame : int | None = None ,
0 commit comments