Files
2026-07-13 12:31:40 +08:00

1074 lines
44 KiB
Python

# Adopted from https://github.com/guandeh17/Self-Forcing
# SPDX-License-Identifier: Apache-2.0
from torch.utils.data import Dataset
import numpy as np
import torch
import random
import json
from pathlib import Path
from PIL import Image
import os
import subprocess
import time
import warnings
import torchvision.transforms as transforms
import torchvision.transforms.functional as F
try:
import decord
except ModuleNotFoundError:
decord = None
DEFAULT_SCENE_CUT_PREFIX = "The scene transitions. "
class TextDataset(Dataset):
def __init__(self, prompt_path, extended_prompt_path=None):
with open(prompt_path, encoding="utf-8") as f:
self.prompt_list = [line.rstrip() for line in f]
if extended_prompt_path is not None:
with open(extended_prompt_path, encoding="utf-8") as f:
self.extended_prompt_list = [line.rstrip() for line in f]
assert len(self.extended_prompt_list) == len(self.prompt_list)
else:
self.extended_prompt_list = None
def __len__(self):
return len(self.prompt_list)
def __getitem__(self, idx):
batch = {
"prompts": self.prompt_list[idx],
"idx": idx,
}
if self.extended_prompt_list is not None:
batch["extended_prompts"] = self.extended_prompt_list[idx]
return batch
class MultiTextDataset(Dataset):
"""Dataset for multi-segment prompts stored in a JSONL file.
Each line is a JSON object, e.g.
{"prompts": ["a cat", "a dog", "a bird"]}
Args
----
prompt_path : str
Path to the JSONL file
field : str
Name of the list-of-strings field, default "prompts"
cache_dir : str | None
``cache_dir`` passed to HF Datasets (optional)
"""
def __init__(self, prompt_path: str, field: str = "prompts", cache_dir: str | None = None):
try:
import datasets
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
"The 'datasets' package is required for MultiTextDataset. "
"Use MultiTextConcatDataset for plain txt/json-caption directories "
"or install datasets."
) from exc
self.ds = datasets.load_dataset(
"json",
data_files=prompt_path,
split="train",
cache_dir=cache_dir,
streaming=False,
)
assert len(self.ds) > 0, "JSONL is empty"
assert field in self.ds.column_names, f"Missing field '{field}'"
seg_len = len(self.ds[0][field])
for i, ex in enumerate(self.ds):
val = ex[field]
assert isinstance(val, list), f"Line {i} field '{field}' is not a list"
assert len(val) == seg_len, f"Line {i} list length mismatch"
self.field = field
def __len__(self):
return len(self.ds)
def __getitem__(self, idx: int):
return {
"idx": idx,
"prompts_list": self.ds[idx][self.field], # List[str]
}
class MultiTextConcatDataset(Dataset):
"""Text-only dataset for multi-shot training and inference.
Supports two input modes:
**txt file** — each line is one caption. Each sample uses the caption at
index ``idx``, repeated ``num_blocks`` times (single-shot, no scene cut
prefix).
**directory** — reads ``caption/<subfolder>/*.json`` files (no video dir
needed). Shot durations are resolved with a three-level fallback:
1. ``shot_durations.txt`` in the caption subfolder (per-sample override)
2. ``chunks_per_shot`` from config (global fixed repeat)
3. Even distribution across all available captions
Scene cut prefix is prepended at shot boundaries (first block of each
shot except shot 0). Output is always exactly ``num_blocks`` prompts:
truncated if too many, padded with the last caption if too few.
"""
def __init__(
self,
data_path: str,
num_blocks: int,
chunks_per_shot: int = 0,
scene_cut_prefix: str = DEFAULT_SCENE_CUT_PREFIX,
caption_field: str = "caption",
deterministic: bool = False,
):
self.num_blocks = num_blocks
self.chunks_per_shot = chunks_per_shot
self.scene_cut_prefix = scene_cut_prefix
self.caption_field = caption_field
self.deterministic = deterministic
path = Path(data_path)
if data_path.endswith(".txt") or path.is_file():
self._mode = "txt"
with open(data_path, encoding="utf-8") as f:
self._prompts = [line.rstrip() for line in f if line.strip()]
assert len(self._prompts) > 0, f"No prompts found in {data_path}"
else:
self._mode = "dir"
self._caption_dir = path / "caption" if (path / "caption").is_dir() else path
self._folders = sorted([d for d in self._caption_dir.iterdir() if d.is_dir()])
assert len(self._folders) > 0, (
f"No caption subfolders found in {self._caption_dir}"
)
def __len__(self):
if self._mode == "txt":
return len(self._prompts)
return len(self._folders)
def __getitem__(self, idx):
if self._mode == "txt":
return self._get_txt_item(idx)
return self._get_dir_item(idx)
# ------------------------------------------------------------------
# txt mode
# ------------------------------------------------------------------
def _get_txt_item(self, idx):
caption = self._prompts[idx % len(self._prompts)]
return {
"prompts": [caption] * self.num_blocks,
"idx": idx,
}
# ------------------------------------------------------------------
# directory mode
# ------------------------------------------------------------------
def _get_dir_item(self, idx):
folder = self._folders[idx % len(self._folders)]
raw_captions = self._load_captions_from_folder(folder)
if not raw_captions:
raw_captions = [""]
shot_durations = self._resolve_shot_durations(folder, len(raw_captions))
prompts = self._apply_shot_durations(raw_captions, shot_durations)
# Ensure exactly num_blocks prompts
if len(prompts) > self.num_blocks:
prompts = prompts[: self.num_blocks]
elif len(prompts) < self.num_blocks:
last = prompts[-1] if prompts else ""
prompts.extend([last] * (self.num_blocks - len(prompts)))
return {
"prompts": prompts,
"idx": idx,
}
def _load_captions_from_folder(self, folder: Path):
json_files = sorted(
[f for f in folder.glob("*.json") if f.name != "global.json"],
key=lambda p: (p.stem.isdigit(), int(p.stem) if p.stem.isdigit() else 0, p.stem),
)
captions = []
for jf in json_files:
try:
with open(jf, "r", encoding="utf-8") as f:
data = json.load(f)
captions.append(data.get(self.caption_field, ""))
except Exception:
captions.append("")
return captions
# ------------------------------------------------------------------
# shot duration helpers
# ------------------------------------------------------------------
@staticmethod
def _load_shot_durations(folder: Path):
txt_path = folder / "shot_durations.txt"
if not txt_path.exists():
return None
try:
with open(txt_path, "r") as f:
content = f.read().strip()
parts = content.replace(",", " ").split()
durations = [int(x) for x in parts if x.strip()]
return durations if durations else None
except Exception:
return None
def _resolve_shot_durations(self, folder: Path, num_captions: int):
durations = self._load_shot_durations(folder)
if durations is not None:
return durations[:num_captions]
if self.chunks_per_shot > 0:
return [self.chunks_per_shot] * num_captions
return self._even_durations(num_captions)
def _even_durations(self, num_shots: int):
total = self.num_blocks
base, extra = divmod(total, num_shots)
return [base + (1 if i < extra else 0) for i in range(num_shots)]
def _apply_shot_durations(self, raw_captions, shot_durations):
target = self.num_blocks
clamped: list[int] = []
remaining = target
for d in shot_durations:
if remaining <= 0:
break
take = min(d, remaining)
clamped.append(take)
remaining -= take
if remaining > 0 and clamped:
clamped[-1] += remaining
prompts: list[str] = []
for shot_idx, (caption, duration) in enumerate(zip(raw_captions, clamped)):
for block_in_shot in range(duration):
if shot_idx > 0 and block_in_shot == 0 and self.scene_cut_prefix:
prompts.append(self.scene_cut_prefix + caption)
else:
prompts.append(caption)
return prompts
class MultiVideoConcatDataset(Dataset):
"""Dataset that concatenates multiple videos from a folder into a fixed-length video.
Each item consists of multiple video segments concatenated together:
- First segment: first_chunk_frames frames (chunk)
- Subsequent segments: subsequent_chunk_frames frames each (chunk)
- Total: total_frames frames (first_chunk_frames + subsequent_chunk_frames*num_subsequent_segments = total_frames)
Videos are sampled preserving original duration, and if a video doesn't have enough frames,
it moves to the next video. If a video has enough frames, it can be sampled repeatedly.
"""
def __init__(
self,
data_dir,
video_size,
total_frames,
target_fps=16,
video_extensions=('.mp4', '.avi', '.mov', '.mkv', '.webm'),
caption_field='caption',
filter_invalid_folders=False,
deterministic: bool = False,
num_frame_per_block=8,
temporal_compression_ratio=4,
allow_padding: bool = False,
min_latent_frames: int = 0,
single_video_only: bool = False,
independent_first_frame: bool = False,
return_image: bool = False,
max_chunks_per_shot: int = 0,
scene_cut_prefix: str = DEFAULT_SCENE_CUT_PREFIX,
sample_warning_seconds: float = 60.0,
sample_warning_interval_seconds: float = 60.0,
):
self.root_dir = Path(data_dir)
self.data_dir = self.root_dir / "video"
self.caption_dir = self.root_dir / "caption"
self.video_size = video_size
self.total_frames = total_frames
total_latent_frames = 1 + (total_frames - 1) // temporal_compression_ratio
separate_first_latent = (
independent_first_frame
and total_latent_frames % num_frame_per_block != 0
)
if separate_first_latent:
assert (total_latent_frames - 1) % num_frame_per_block == 0, (
f"total latent frames ({total_latent_frames}) must be divisible by "
f"num_frame_per_block ({num_frame_per_block}) or equal to "
f"1 + N * num_frame_per_block when independent_first_frame=True"
)
first_chunk_latent_frames = (
num_frame_per_block + 1 if separate_first_latent else num_frame_per_block
)
first_chunk_frames = 1 + (first_chunk_latent_frames - 1) * temporal_compression_ratio
subsequent_chunk_frames = num_frame_per_block * temporal_compression_ratio
self.first_chunk_frames = first_chunk_frames
self.subsequent_chunk_frames = subsequent_chunk_frames
self.target_fps = target_fps
self.caption_field = caption_field
self.video_extensions = video_extensions
self.filter_invalid_folders = filter_invalid_folders
self.deterministic = deterministic
self.allow_padding = allow_padding
self.num_frame_per_block = num_frame_per_block
self.independent_first_frame = independent_first_frame
self.first_chunk_latent_frames = first_chunk_latent_frames
self.return_image = return_image
if min_latent_frames > 0:
assert min_latent_frames % num_frame_per_block == 0, (
f"min_latent_frames ({min_latent_frames}) must be a multiple of "
f"num_frame_per_block ({num_frame_per_block})"
)
self.min_latent_frames = min_latent_frames
self.single_video_only = single_video_only
self.max_chunks_per_shot = max_chunks_per_shot
self.scene_cut_prefix = scene_cut_prefix
self.sample_warning_seconds = float(sample_warning_seconds or 0.0)
self.sample_warning_interval_seconds = float(sample_warning_interval_seconds or 0.0)
remaining_frames = total_frames - first_chunk_frames
self.num_subsequent_segments = remaining_frames // subsequent_chunk_frames
self.total_segments = 1 + self.num_subsequent_segments
assert total_frames == first_chunk_frames + self.num_subsequent_segments * subsequent_chunk_frames, \
f"Total frames ({total_frames}) must equal first_chunk_frames ({first_chunk_frames}) + " \
f"num_subsequent_segments ({self.num_subsequent_segments}) * subsequent_chunk_frames ({subsequent_chunk_frames})"
if not self.data_dir.exists():
raise ValueError(f"Video directory not found: {self.data_dir}")
if not self.caption_dir.exists():
raise ValueError(f"Caption directory not found: {self.caption_dir}")
self.folders = [d for d in self.data_dir.iterdir() if d.is_dir()]
if len(self.folders) == 0:
raise ValueError(f"No subdirectories found in {self.data_dir}")
# Optionally pre-filter folders with insufficient frames
# Note: This can be slow for large datasets due to IO operations
if self.filter_invalid_folders:
print(f"[MultiVideoConcatDataset] Pre-filtering {len(self.folders)} folders for sufficient frames...")
valid_folders = []
skipped_folders = []
for folder in self.folders:
if self._check_folder_has_enough_frames(folder):
valid_folders.append(folder)
else:
skipped_folders.append(folder.name)
if len(skipped_folders) > 0:
print(f"[MultiVideoConcatDataset] Skipped {len(skipped_folders)} folders due to insufficient frames: {skipped_folders}")
self.folders = valid_folders
if len(self.folders) == 0:
raise ValueError(f"No folders with sufficient frames found in {self.data_dir}")
# Setup transforms
self.resize_transform = transforms.Resize(
self.video_size,
antialias=True
)
self.normalize = transforms.Normalize(
mean=[0.5, 0.5, 0.5],
std=[0.5, 0.5, 0.5]
)
# Lazy caches: avoid repeated decord.VideoReader / filesystem IO
self._video_info_cache = {} # video_path -> (total_frames, fps)
self._folder_files_cache = {} # folder_path -> list of video paths
if decord is not None:
decord.bridge.set_bridge('torch')
def _get_caption_folder(self, folder_name):
"""Return the caption directory path for a given folder (sample)."""
return self.caption_dir / folder_name
def _load_caption(self, video_path, folder_name):
"""Load caption for a video file."""
video_stem = video_path.stem
caption_folder = self._get_caption_folder(folder_name)
caption_path = caption_folder / f"{video_stem}.json"
if caption_path.exists():
try:
with open(caption_path, 'r', encoding='utf-8') as f:
caption_data = json.load(f)
return caption_data.get(self.caption_field, "")
except Exception:
return ""
return ""
def _get_video_files_in_folder(self, folder_path):
"""Get sorted video files in a folder, keeping only those with a per-video caption (cached)."""
key = str(folder_path)
if key in self._folder_files_cache:
return self._folder_files_cache[key]
video_files = []
for ext in self.video_extensions:
video_files.extend(list(folder_path.glob(f'*{ext}')))
video_files.extend(list(folder_path.glob(f'*{ext.upper()}')))
def get_numeric_key(path):
try:
return int(path.stem)
except ValueError:
return float('inf')
video_files.sort(key=get_numeric_key)
folder_name = folder_path.name
filtered_videos = []
for video_path in video_files:
caption = self._load_caption(video_path, folder_name)
if caption is not None and caption != "":
filtered_videos.append(video_path)
self._folder_files_cache[key] = filtered_videos
return filtered_videos
def _check_folder_has_enough_frames(self, folder_path):
"""Check if a folder has enough total frames across all videos to complete all segments.
This is a lenient check: we verify that the total available frames across all videos
is sufficient for the required segments, assuming ideal sampling.
"""
video_files = self._get_video_files_in_folder(folder_path)
if len(video_files) == 0:
return False
# Calculate total available frames (in target_fps timebase)
total_available_frames = 0
for video_path in video_files:
try:
total_frames, original_fps = self._get_video_info(video_path)
# Convert to target_fps timebase
available_in_target_fps = total_frames * self.target_fps / original_fps
total_available_frames += available_in_target_fps
except Exception:
# If we can't read a video, be conservative and skip this folder
return False
# Check if we have enough frames for all segments
required_frames = self.total_frames
return total_available_frames >= required_frames
def _get_video_info(self, video_path):
"""Get video information without loading frames (cached)."""
key = str(video_path)
if key in self._video_info_cache:
return self._video_info_cache[key]
if decord is None:
info = self._get_video_info_ffprobe(video_path)
self._video_info_cache[key] = info
return info
try:
vr = decord.VideoReader(key, width=self.video_size[1], height=self.video_size[0])
except:
vr = decord.VideoReader(key)
info = (len(vr), vr.get_avg_fps())
self._video_info_cache[key] = info
return info
@staticmethod
def _parse_fps(value):
if not value or value == "0/0":
return 0.0
if "/" in value:
num, den = value.split("/", 1)
den_f = float(den)
return float(num) / den_f if den_f != 0 else 0.0
return float(value)
def _get_video_info_ffprobe(self, video_path):
cmd = [
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-count_frames",
"-show_entries",
"stream=nb_read_frames,nb_frames,avg_frame_rate,r_frame_rate,duration",
"-of",
"json",
str(video_path),
]
try:
proc = subprocess.run(cmd, check=True, capture_output=True, text=True)
stream = json.loads(proc.stdout)["streams"][0]
fps = self._parse_fps(stream.get("avg_frame_rate")) or self._parse_fps(stream.get("r_frame_rate"))
frames_raw = stream.get("nb_read_frames") or stream.get("nb_frames")
if frames_raw and str(frames_raw).isdigit():
total_frames = int(frames_raw)
else:
total_frames = int(round(float(stream.get("duration", 0.0)) * fps))
if total_frames <= 0 or fps <= 0:
raise ValueError(f"Could not infer frames/fps from ffprobe output for {video_path}")
return total_frames, fps
except (subprocess.CalledProcessError, KeyError, IndexError, ValueError, json.JSONDecodeError) as exc:
raise RuntimeError(f"ffprobe failed to read video metadata for {video_path}: {exc}") from exc
def _can_sample_from_position(self, total_frames, original_fps, num_frames, start_frame):
"""Check if we can sample num_frames starting from start_frame.
Returns True if we can sample num_frames without exceeding video bounds.
"""
if start_frame >= total_frames:
return False
# Calculate sampling interval: original_fps / target_fps
sampling_interval = original_fps / self.target_fps
# Calculate the last frame index we need (with rounding)
# We need to check if we can get num_frames frames
last_frame_needed = start_frame + (num_frames - 1) * sampling_interval
# Account for rounding: the actual last frame index will be rounded
# So we need some margin to ensure we don't exceed bounds
return int(np.round(last_frame_needed)) < total_frames
def _can_complete_all_segments_without_wrap(self, video_files, start_video_idx, start_frame):
"""Check if from (start_video_idx, start_frame) we can sample all segments
without ever wrapping to the beginning (i.e. only use this video and later ones).
When single_video_only is True, all segments must come from the same video.
"""
vidx = start_video_idx
start = start_frame
# First segment
total_frames, original_fps = self._get_video_info(video_files[vidx])
if not self._can_sample_from_position(
total_frames, original_fps, self.first_chunk_frames, start
):
return False
sampling_interval = original_fps / self.target_fps
next_start = start + self.first_chunk_frames * sampling_interval
if int(np.round(next_start)) >= total_frames:
if self.single_video_only:
return self.num_subsequent_segments == 0
vidx += 1
start = 0
else:
start = int(np.round(next_start))
if vidx >= len(video_files):
return False
# Subsequent segments
for seg_i in range(self.num_subsequent_segments):
while vidx < len(video_files):
total_frames, original_fps = self._get_video_info(video_files[vidx])
if self._can_sample_from_position(
total_frames, original_fps, self.subsequent_chunk_frames, start
):
break
if self.single_video_only:
return False
vidx += 1
start = 0
if vidx >= len(video_files):
return False
sampling_interval = original_fps / self.target_fps
next_start = start + self.subsequent_chunk_frames * sampling_interval
if int(np.round(next_start)) >= total_frames:
if self.single_video_only and seg_i < self.num_subsequent_segments - 1:
return False
vidx += 1
start = 0
else:
start = int(np.round(next_start))
return True
def _sample_random_start(self, video_files):
"""Sample a random (video_idx, start_frame) valid for the first segment,
and from which we can complete ALL segments without wrapping to the start.
Returns (video_idx, start_frame); falls back to (0, 0) if no valid start found.
"""
candidates = []
for video_idx, video_path in enumerate(video_files):
total_frames, original_fps = self._get_video_info(video_path)
sampling_interval = original_fps / self.target_fps
last_needed = (self.first_chunk_frames - 1) * sampling_interval
max_start = int(np.floor(total_frames - 1 - last_needed))
if max_start < 0:
continue
step = max(1, max_start // 50)
for start in range(0, max_start + 1, step):
if not self._can_sample_from_position(
total_frames, original_fps, self.first_chunk_frames, start
):
continue
if self._can_complete_all_segments_without_wrap(video_files, video_idx, start):
candidates.append((video_idx, start))
if candidates:
chosen = random.choice(candidates)
return chosen
return (0, 0)
def _sample_frames_from_video(self, video_path, num_frames, start_frame=0):
"""Sample frames from a video preserving original duration.
Args:
video_path: Path to video file
num_frames: Number of frames to sample
start_frame: Starting frame index (for repeated sampling)
Returns:
tuple: (frames_tensor, total_frames_in_video, original_fps)
"""
if decord is None:
return self._sample_frames_from_video_ffmpeg(video_path, num_frames, start_frame)
try:
vr = decord.VideoReader(str(video_path), width=self.video_size[1], height=self.video_size[0])
except:
vr = decord.VideoReader(str(video_path))
total_frames = len(vr)
original_fps = vr.get_avg_fps()
if total_frames == 0:
raise ValueError(f"Video {video_path} has no frames")
# Calculate frame sampling based on fps to preserve duration
# Calculate sampling interval: original_fps / target_fps
sampling_interval = original_fps / self.target_fps
# Generate frame indices starting from start_frame
indices = []
current_frame = float(start_frame)
for _ in range(num_frames):
frame_idx = int(np.round(current_frame))
frame_idx = min(frame_idx, total_frames - 1)
indices.append(frame_idx)
current_frame += sampling_interval
if current_frame >= total_frames:
# If we run out of frames, pad with the last frame
remaining = num_frames - len(indices)
indices.extend([total_frames - 1] * remaining)
break
indices = np.array(indices[:num_frames], dtype=np.int32)
# Get video frames: shape (num_frames, height, width, 3)
video_frames = vr.get_batch(indices).numpy()
# Convert to tensor and permute to (num_frames, 3, height, width)
video_tensor = torch.from_numpy(video_frames).permute(0, 3, 1, 2).contiguous()
# Convert to float and normalize pixel values from [0, 255] to [0, 1]
video_tensor = video_tensor.float() / 255.0
# Resize if needed
if video_tensor.shape[2] != self.video_size[0] or video_tensor.shape[3] != self.video_size[1]:
resized_frames = []
for i in range(video_tensor.shape[0]):
resized_frames.append(self.resize_transform(video_tensor[i]))
video_tensor = torch.stack(resized_frames, dim=0)
# Apply normalization: (x - 0.5) / 0.5 -> range [-1, 1]
video_tensor = self.normalize(video_tensor)
video_tensor = video_tensor.to(torch.float16)
return video_tensor, total_frames, original_fps
def _sample_frames_from_video_ffmpeg(self, video_path, num_frames, start_frame=0):
total_frames, original_fps = self._get_video_info(video_path)
start_time = max(0.0, float(start_frame) / max(original_fps, 1e-6))
height, width = self.video_size
cmd = [
"ffmpeg",
"-v",
"error",
"-ss",
f"{start_time:.6f}",
"-i",
str(video_path),
"-vf",
f"fps={self.target_fps},scale={width}:{height}",
"-frames:v",
str(num_frames),
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"pipe:1",
]
try:
proc = subprocess.run(cmd, check=True, capture_output=True)
except subprocess.CalledProcessError as exc:
stderr = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else ""
raise RuntimeError(f"ffmpeg failed to sample {video_path}: {stderr}") from exc
frame_bytes = height * width * 3
decoded_frames = len(proc.stdout) // frame_bytes
if decoded_frames <= 0:
raise RuntimeError(f"ffmpeg produced no frames for {video_path}")
video_frames = np.frombuffer(proc.stdout[: decoded_frames * frame_bytes], dtype=np.uint8)
video_frames = video_frames.reshape(decoded_frames, height, width, 3)
video_tensor = torch.from_numpy(video_frames.copy()).permute(0, 3, 1, 2).contiguous()
video_tensor = video_tensor.float() / 255.0
if decoded_frames < num_frames:
pad = video_tensor[-1:].repeat(num_frames - decoded_frames, 1, 1, 1)
video_tensor = torch.cat([video_tensor, pad], dim=0)
elif decoded_frames > num_frames:
video_tensor = video_tensor[:num_frames]
video_tensor = self.normalize(video_tensor)
video_tensor = video_tensor.to(torch.float16)
return video_tensor, total_frames, original_fps
def __len__(self):
return len(self.folders)
@staticmethod
def _sample_failure(reason):
return False, reason
def _try_get_item_from_folder(self, folder_idx, deterministic: bool = False):
"""Try to get item from a specific folder.
Returns (True, result) on success and (False, reason) on failure.
The reason is used by __getitem__ warnings so a long scan does not
look like a silent hang when most folders are too short.
"""
folder_path = self.folders[folder_idx]
folder_name = folder_path.name
video_files = self._get_video_files_in_folder(folder_path)
if len(video_files) == 0:
return self._sample_failure(f"{folder_name}: no videos with captions")
# Collect all segments
all_segments = []
prompts_list = []
try:
# Start position
if deterministic:
current_video_idx, current_start_frame = 0, 0
else:
# Random start: don't always start from the first video, so we get more diversity
current_video_idx, current_start_frame = self._sample_random_start(video_files)
# Sample first segment (9 frames)
video_path = video_files[current_video_idx]
prev_seg_video_idx = current_video_idx
total_frames, original_fps = self._get_video_info(video_path)
# Ensure the current video is long enough for the first segment
while not self._can_sample_from_position(
total_frames, original_fps, self.first_chunk_frames, current_start_frame
):
if self.single_video_only:
return self._sample_failure(
f"{folder_name}: first video is too short for the first chunk"
)
current_video_idx += 1
current_start_frame = 0
if current_video_idx >= len(video_files):
return self._sample_failure(
f"{folder_name}: no video can provide the first chunk"
)
video_path = video_files[current_video_idx]
prev_seg_video_idx = current_video_idx
total_frames, original_fps = self._get_video_info(video_path)
# Sample first segment
segment_frames, total_frames, original_fps = self._sample_frames_from_video(
video_path, self.first_chunk_frames, current_start_frame
)
all_segments.append(segment_frames)
prompt = self._load_caption(video_path, folder_name)
prompts_list.append(prompt)
# Update position for next sampling
sampling_interval = original_fps / self.target_fps
next_start_frame = current_start_frame + self.first_chunk_frames * sampling_interval
# If we've exhausted this video, move to next
if int(np.round(next_start_frame)) >= total_frames:
if self.single_video_only:
if self.num_subsequent_segments > 0:
return self._sample_failure(
f"{folder_name}: single-video sample ends after first chunk"
)
else:
current_video_idx += 1
current_start_frame = 0
else:
current_start_frame = int(np.round(next_start_frame))
chunks_from_current_video = 1 if current_video_idx == prev_seg_video_idx else 0
# Sample subsequent segments (12 frames each). No wrap: we only use videos from start onward.
for seg_idx in range(self.num_subsequent_segments):
if current_video_idx >= len(video_files):
if self.allow_padding:
break
return self._sample_failure(
f"{folder_name}: ran out of videos before all chunks were filled"
)
# Force virtual scene cut if max_shot_chunks reached:
# skip 1 second of video and treat the remainder as a new shot.
forced_scene_cut = False
if (self.max_chunks_per_shot > 0
and chunks_from_current_video >= self.max_chunks_per_shot):
vp = video_files[current_video_idx]
_, ofps = self._get_video_info(vp)
current_start_frame += int(np.round(ofps))
chunks_from_current_video = 0
forced_scene_cut = True
video_path = video_files[current_video_idx]
total_frames, original_fps = self._get_video_info(video_path)
can_sample = True
while not self._can_sample_from_position(
total_frames, original_fps, self.subsequent_chunk_frames, current_start_frame
):
if self.single_video_only:
can_sample = False
break
current_video_idx += 1
current_start_frame = 0
chunks_from_current_video = 0
if current_video_idx >= len(video_files):
can_sample = False
break
video_path = video_files[current_video_idx]
total_frames, original_fps = self._get_video_info(video_path)
if not can_sample:
if self.allow_padding:
break
return self._sample_failure(
f"{folder_name}: remaining videos are too short for the next chunk"
)
is_scene_cut = (current_video_idx != prev_seg_video_idx) or forced_scene_cut
# Sample segment
segment_frames, total_frames, original_fps = self._sample_frames_from_video(
video_path, self.subsequent_chunk_frames, current_start_frame
)
all_segments.append(segment_frames)
prompt = self._load_caption(video_path, folder_name)
if is_scene_cut and self.scene_cut_prefix:
prompt = self.scene_cut_prefix + prompt
prompts_list.append(prompt)
prev_seg_video_idx = current_video_idx
chunks_from_current_video += 1
# Update position for next sampling
sampling_interval = original_fps / self.target_fps
next_start_frame = current_start_frame + self.subsequent_chunk_frames * sampling_interval
# If we've exhausted this video, move to next
if int(np.round(next_start_frame)) >= total_frames:
if self.single_video_only:
if seg_idx < self.num_subsequent_segments - 1:
return self._sample_failure(
f"{folder_name}: single-video sample is too short"
)
else:
current_video_idx += 1
current_start_frame = 0
chunks_from_current_video = 0
else:
current_start_frame = int(np.round(next_start_frame))
num_filled_segments = len(all_segments)
if num_filled_segments == 0:
num_valid_latent_frames = 0
else:
num_valid_latent_frames = (
self.first_chunk_latent_frames
+ (num_filled_segments - 1) * self.num_frame_per_block
)
# Reject if below minimum latent frame threshold
if self.allow_padding and self.min_latent_frames > 0:
if num_valid_latent_frames < self.min_latent_frames:
return self._sample_failure(
f"{folder_name}: only {num_valid_latent_frames} valid latent frames, "
f"below min_latent_frames={self.min_latent_frames}"
)
if num_filled_segments < self.total_segments:
last_prompt = prompts_list[-1] if prompts_list else ""
prompts_list.extend([last_prompt] * (self.total_segments - num_filled_segments))
# Concatenate all segments: (total_frames, 3, height, width)
concatenated_video = torch.cat(all_segments, dim=0)
# Ensure we have exactly total_frames
if concatenated_video.shape[0] != self.total_frames:
# Pad or trim if necessary
if concatenated_video.shape[0] < self.total_frames:
# Pad with last frame
last_frame = concatenated_video[-1:].repeat(self.total_frames - concatenated_video.shape[0], 1, 1, 1)
concatenated_video = torch.cat([concatenated_video, last_frame], dim=0)
else:
# Trim
concatenated_video = concatenated_video[:self.total_frames]
result = {
'frames': concatenated_video.permute(1, 0, 2, 3),
'prompts': prompts_list,
'idx': folder_idx
}
if self.return_image:
result['image'] = concatenated_video[0]
if self.allow_padding:
result['num_valid_latent_frames'] = num_valid_latent_frames
return True, result
except Exception as exc:
return self._sample_failure(f"{folder_name}: {type(exc).__name__}: {exc}")
def __getitem__(self, idx):
start_time = time.monotonic()
last_warning_time = start_time
attempts = 0
last_failure = None
def maybe_warn(folder_idx, failure_reason):
nonlocal last_warning_time
if self.sample_warning_seconds <= 0:
return
elapsed = time.monotonic() - start_time
if elapsed < self.sample_warning_seconds:
return
if (
self.sample_warning_interval_seconds > 0
and time.monotonic() - last_warning_time < self.sample_warning_interval_seconds
):
return
last_warning_time = time.monotonic()
folder_name = self.folders[folder_idx % len(self.folders)].name
warnings.warn(
"[MultiVideoConcatDataset] Still searching for a valid sample "
f"after {elapsed:.1f}s and {attempts} folder attempts "
f"(requested_idx={idx}, current_folder={folder_name}, "
f"last_failure={failure_reason}). This usually means the dataset "
f"does not contain enough video duration for total_frames={self.total_frames} "
f"at target_fps={self.target_fps}. Consider reducing the training "
"window, enabling allow_padding, lowering min_latent_frames, or "
"pre-filtering invalid folders.",
RuntimeWarning,
stacklevel=2,
)
# First try the requested folder
attempts += 1
success, result = self._try_get_item_from_folder(idx, deterministic=self.deterministic)
if success:
return result
last_failure = result
maybe_warn(idx, last_failure)
# If the requested folder fails, try other folders.
# If any valid folder exists in the dataset, try to return data from it:
# scan every other folder starting from idx + 1 and return the first
# successful sample.
num_folders = len(self.folders)
for i in range(1, num_folders):
alt_idx = (idx + i) % num_folders
attempts += 1
success, result = self._try_get_item_from_folder(
alt_idx,
deterministic=self.deterministic,
)
if success:
return result
last_failure = result
maybe_warn(alt_idx, last_failure)
# If all attempts fail, raise an error
elapsed = time.monotonic() - start_time
if self.sample_warning_seconds > 0 and elapsed >= self.sample_warning_seconds:
warnings.warn(
"[MultiVideoConcatDataset] No valid sample was found after "
f"{elapsed:.1f}s and {attempts} folder attempts "
f"(requested_idx={idx}, last_failure={last_failure}).",
RuntimeWarning,
stacklevel=2,
)
raise ValueError(
f"Failed to sample valid data from folder {idx} and nearby folders. "
f"This may indicate insufficient valid videos in the dataset. "
f"Tried {attempts} folders in {elapsed:.1f}s. Last failure: {last_failure}"
)
def cycle(dl):
while True:
for data in dl:
yield data
def multi_video_collate_fn(batch):
# batch is a length-B list of dictionaries returned by __getitem__.
frames = torch.stack([b["frames"] for b in batch], dim=0) # (B, T, C, H, W)
# Keep prompts as one list per sample:
# [[p0_seg0, p0_seg1, ...], [p1_seg0, ...], ...].
prompts_list = [b["prompts"] for b in batch] # List[List[str]]
idx = torch.tensor([b["idx"] for b in batch], dtype=torch.long)
result = {
"frames": frames,
"prompts": prompts_list,
"idx": idx,
}
if "image" in batch[0]:
result["image"] = torch.stack([b["image"] for b in batch], dim=0)
if "num_valid_latent_frames" in batch[0]:
result["num_valid_latent_frames"] = torch.tensor(
[b["num_valid_latent_frames"] for b in batch], dtype=torch.long
)
return result
def eval_collate_fn(batch):
"""Collate for text-only datasets (no frames)."""
prompts_list = [b["prompts"] for b in batch]
idx = torch.tensor([b["idx"] for b in batch], dtype=torch.long)
result = {
"prompts": prompts_list,
"idx": idx,
}
if "shot_durations" in batch[0]:
result["shot_durations"] = [b["shot_durations"] for b in batch]
return result