Skip to content

Commit c14e105

Browse files
committed
added true iframe deletion datamoshing, fixed composite argument order
1 parent cac57e7 commit c14e105

7 files changed

Lines changed: 538 additions & 123 deletions

File tree

dithertools/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22
33
Core classes: :class:`Image`, :class:`Video`, :class:`FrameGenerator`.
44
Compositing: :func:`composite`, :func:`composite_color`.
5-
Datamoshing: :func:`iframe_delete`, :func:`pframe_dup`.
5+
Datamoshing: :func:`iframe_delete`, :func:`iframe_delete_optflow`,
6+
:func:`pframe_dup`.
67
I/O shortcut: :func:`load` (returns ``Image`` or ``Video`` based on file type).
78
Mask generation: :func:`random_matrix`, :func:`random_matrix_color`.
89
Animated masks: :class:`MaskGenerator`.
910
"""
1011

1112
__version__ = "0.1.0"
1213

13-
from dithertools.datamosh import iframe_delete, pframe_dup
14+
from dithertools.datamosh import iframe_delete, iframe_delete_optflow, pframe_dup
1415
from dithertools.dither import composite, composite_color
1516
from dithertools.generators import FrameGenerator, MaskGenerator
1617
from dithertools.image import Image
@@ -26,6 +27,7 @@
2627
"composite",
2728
"composite_color",
2829
"iframe_delete",
30+
"iframe_delete_optflow",
2931
"load",
3032
"pframe_dup",
3133
"random_matrix",

dithertools/datamosh.py

Lines changed: 255 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
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
813
warped by the accumulated motion of subsequent frames, producing pixel
914
streaking 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

1617
from __future__ import annotations
1718

1819
import itertools
20+
import json
21+
import os
22+
import subprocess
1923
import sys
24+
import tempfile
2025
from typing import TYPE_CHECKING, Iterator
2126

2227
import 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+
304546
def pframe_dup(
305547
source: "Image | Video | FrameGenerator",
306548
transition_frame: int | None = None,

0 commit comments

Comments
 (0)