Source code for data_juicer_agents.tools.media.scan_media_folder.logic
# -*- coding: utf-8 -*-
"""Pure logic for scan_media_folder â runtime-agnostic."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Sequence, Set
# ---- Suffix constants (mirrored from data_juicer.format.media_folder_formatter) ----
IMAGE_SUFFIXES: frozenset = frozenset({
".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".tif", ".svg",
})
VIDEO_SUFFIXES: frozenset = frozenset({
".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv", ".webm", ".m4v", ".ts",
})
# DJ special tokens (defaults; kept literal to avoid importing DJ at tool-logic time)
_IMAGE_TOKEN = "<__dj__image>"
_VIDEO_TOKEN = "<__dj__video>"
_EOC_TOKEN = "<|__dj__eoc|>"
def _normalize_suffixes(suffixes: Optional[Sequence[str]]) -> Set[str]:
if suffixes is None:
return set()
result = set()
for s in suffixes:
s = s.strip().lower()
if not s:
continue
if not s.startswith("."):
s = f".{s}"
result.add(s)
return result
def _iter_media_files(
folder_path: str,
suffixes: Set[str],
recursive: bool,
) -> Iterator[str]:
"""Yield absolute paths under *folder_path* whose suffix is in *suffixes*.
Streams results so peak memory stays O(files-per-directory) instead of
holding every matched path at once â important for the large FUSE-mounted
folders this tool targets. Entries are sorted within each directory (and
directories traversed in sorted order) for deterministic output.
"""
if recursive:
walker = os.walk(folder_path)
else:
try:
_root, _dirs, files = next(os.walk(folder_path))
except StopIteration:
return
walker = [(_root, [], files)]
for root, dirs, files in walker:
dirs.sort()
for fname in sorted(files):
ext = os.path.splitext(fname)[1].lower()
if ext in suffixes:
yield os.path.join(root, fname)
[docs]
def scan_media_folder(
*,
folder_path: str,
media_type: str = "auto",
recursive: bool = True,
output_path: str,
suffixes: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Scan *folder_path* and write a DJ-Format JSONL to *output_path*.
Returns a summary dict.
"""
# Resolve paths
folder = Path(folder_path).expanduser().resolve()
if not folder.is_dir():
return {
"ok": False,
"error_type": "not_a_directory",
"message": f"folder_path does not exist or is not a directory: {folder}",
}
out = Path(output_path).expanduser().resolve()
# Determine suffixes to scan
custom_suffixes = _normalize_suffixes(suffixes)
if custom_suffixes:
all_suffixes = custom_suffixes
elif media_type == "image":
all_suffixes = set(IMAGE_SUFFIXES)
elif media_type == "video":
all_suffixes = set(VIDEO_SUFFIXES)
else: # auto
all_suffixes = set(IMAGE_SUFFIXES) | set(VIDEO_SUFFIXES)
# Stream: classify and write one record per file so peak memory stays O(1)
# regardless of folder size (only counts and up to 3 samples are retained).
# The output file is opened lazily on the first match, so an empty result
# never creates a stray file (and never needs a delete on a FUSE mount).
image_count = 0
video_count = 0
sample_records: List[Dict[str, Any]] = []
fh = None
try:
for p in _iter_media_files(str(folder), all_suffixes, recursive):
ext = os.path.splitext(p)[1].lower()
# A file reaches this loop only if its suffix was scanned for, so an
# extension outside the standard sets must be a user-supplied custom
# suffix. Classify those by media_type instead of dropping them
# silently: 'video' -> video record, 'image'/'auto' -> image record.
is_video = ext in VIDEO_SUFFIXES or (
ext not in IMAGE_SUFFIXES and media_type == "video"
)
if is_video:
rec = {"text": f"{_VIDEO_TOKEN} {_EOC_TOKEN}", "videos": [p]}
video_count += 1
else:
rec = {"text": f"{_IMAGE_TOKEN} {_EOC_TOKEN}", "images": [p]}
image_count += 1
if fh is None:
out.parent.mkdir(parents=True, exist_ok=True)
fh = open(out, "w", encoding="utf-8")
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
if len(sample_records) < 3:
sample_records.append(rec)
except Exception as exc:
return {
"ok": False,
"error_type": "write_failed",
"message": f"Failed to write JSONL to {out}: {exc}",
}
finally:
if fh is not None:
fh.close()
record_count = image_count + video_count
if record_count == 0:
return {
"ok": False,
"error_type": "no_files_found",
"message": f"No media files found under {folder} (suffixes={sorted(all_suffixes)})",
}
# Determine detected type label
if image_count > 0 and video_count > 0:
detected = "mixed"
elif video_count > 0:
detected = "video"
else:
detected = "image"
return {
"ok": True,
"record_count": record_count,
"output_path": str(out),
"sample_records": sample_records,
"media_type_detected": detected,
"image_count": image_count,
"video_count": video_count,
"message": (
f"Scanned {record_count} media file(s) ({image_count} image(s), "
f"{video_count} video(s)) â {out}"
),
}