Skip to content

Commit 38b519a

Browse files
Fix variable framerate video issues (#1293)
Co-authored-by: Copilot <[email protected]>
1 parent 9dc5f65 commit 38b519a

File tree

23 files changed

+1130
-79
lines changed

23 files changed

+1130
-79
lines changed

deploy/charts/impt/chart/values.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ jobs-execution-namespace:
134134
"FEATURE_FLAG_ASYNCHRONOUS_MEDIA_PREPROCESSING": "true"
135135
"FEATURE_FLAG_LABELS_REORDERING": "true"
136136
"FEATURE_FLAG_ANNOTATION_HOLE": "false"
137+
"FEATURE_FLAG_MIGRATE_VFR_TO_CFR_VIDEOS": "false"
137138

138139
configuration:
139140
feature_flags_data:
@@ -171,7 +172,7 @@ configuration:
171172
"FEATURE_FLAG_USER_ONBOARDING": "false"
172173
"FEATURE_FLAG_VISUAL_PROMPT_SERVICE": "true"
173174
"FEATURE_FLAG_WORKSPACE_ACTIONS": "false"
174-
175+
"FEATURE_FLAG_MIGRATE_VFR_TO_CFR_VIDEOS": "false"
175176
secrets:
176177
smtp:
177178
smtp_enabled: false
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"script": "convert_vfr_videos_to_cfr.py",
3+
"metadata": "convert_vfr_videos_to_cfr.json"
4+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"description": "Converts variable frame rate videos to constant frame rate",
3+
"supports_downgrade": true,
4+
"skip_on_project_import": false,
5+
"new_collections": [],
6+
"updated_collections": [],
7+
"deprecated_collections": [],
8+
"affects_binary_data": true
9+
}
Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
# Copyright (C) 2022-2025 Intel Corporation
2+
# LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
3+
import json
4+
import logging
5+
import os
6+
import shutil
7+
import subprocess
8+
import tempfile
9+
10+
import cv2
11+
from bson import ObjectId
12+
13+
from migration.utils import FeatureFlagProvider, IMigrationScript, MongoDBConnection
14+
from migration.utils.connection import MinioStorageClient
15+
16+
logger = logging.getLogger(__name__)
17+
18+
BUCKET_NAME_VIDEOS = os.environ.get("BUCKET_NAME_VIDEOS", "videos")
19+
20+
21+
class ConvertVFRVideosToCFR(IMigrationScript):
22+
"""
23+
Migration script that checks if any video has a variable frame rate. If this is the case,
24+
extract and save every frame, then reconstruct the video using the fps from get_fps method.
25+
The new constant frame rate video overwrites the old variable framerate video stored in the VideoBinaryRepo.
26+
27+
This migration needs to extract every frame to ensure existing annotations still align with the video.
28+
"""
29+
30+
@classmethod
31+
def upgrade_project(cls, organization_id: str, workspace_id: str, project_id: str) -> None:
32+
if not FeatureFlagProvider.is_enabled("FEATURE_FLAG_MIGRATE_VFR_TO_CFR_VIDEOS"):
33+
logger.warning("FEATURE_FLAG_MIGRATE_VFR_TO_CFR_VIDEOS not enabled, skipping migration.")
34+
return
35+
db = MongoDBConnection().geti_db
36+
storage_client = MinioStorageClient().client
37+
storage_prefix = f"organizations/{organization_id}/workspaces/{workspace_id}/projects/{project_id}"
38+
39+
video_collection = db.get_collection("video")
40+
dataset_storage_collection = db.get_collection("dataset_storage")
41+
42+
dataset_storages = list(dataset_storage_collection.find({"project_id": ObjectId(project_id)}))
43+
44+
for dataset_storage in dataset_storages:
45+
dataset_storage_id = dataset_storage["_id"]
46+
videos = list(video_collection.find({"dataset_storage_id": dataset_storage_id}))
47+
ds_storage_prefix = f"{storage_prefix}/dataset_storages/{dataset_storage_id}"
48+
49+
for video in videos:
50+
video_id = video["_id"]
51+
video_extension = video["extension"]
52+
video_path = f"{ds_storage_prefix}/{video_id}.{video_extension.lower()}"
53+
54+
try:
55+
# Get presigned URL for the video
56+
url = storage_client.presigned_get_object(BUCKET_NAME_VIDEOS, video_path)
57+
58+
# Check if video is VFR
59+
if cls._is_variable_frame_rate(url):
60+
logger.info(f"Converting VFR video {video_id} to CFR")
61+
62+
# Get the FPS for reconstruction
63+
fps = cls._get_video_fps(url)
64+
65+
# Convert VFR to CFR
66+
converted_file = cls._convert_vfr_to_cfr(url, fps)
67+
if converted_file:
68+
# Upload the converted video back to storage
69+
with open(converted_file, "rb") as f:
70+
storage_client.put_object(
71+
BUCKET_NAME_VIDEOS, video_path, f, length=os.path.getsize(converted_file)
72+
)
73+
74+
# Clean up temporary file
75+
os.unlink(converted_file)
76+
77+
logger.info(f"Successfully converted and replaced VFR video {video_id}")
78+
else:
79+
logger.error(f"Failed to convert VFR video {video_id}")
80+
else:
81+
logger.debug(f"Video {video_id} is already CFR, skipping")
82+
83+
except Exception as e:
84+
logger.error(f"Failed to process video {video_id}: {e}")
85+
continue
86+
87+
@classmethod
88+
def _get_video_fps(cls, video_path: str) -> float:
89+
"""
90+
Get video FPS using ffprobe. Copied logic from VideoDecoder.get_fps.
91+
92+
:param video_path: Local path or presigned S3 URL pointing to the video
93+
:return: Video FPS as float
94+
"""
95+
result = subprocess.run( # noqa: S603
96+
[ # noqa: S607
97+
"ffprobe",
98+
"-v",
99+
"error",
100+
"-select_streams",
101+
"v:0",
102+
"-show_entries",
103+
"stream=r_frame_rate",
104+
"-of",
105+
"json",
106+
video_path,
107+
],
108+
capture_output=True,
109+
check=False,
110+
)
111+
ffprobe_output = json.loads(result.stdout)
112+
r_frame_rate = ffprobe_output["streams"][0]["r_frame_rate"]
113+
num, denominator = map(int, r_frame_rate.split("/"))
114+
return num / denominator
115+
116+
@classmethod
117+
def _is_variable_frame_rate(cls, video_path: str) -> bool:
118+
"""
119+
Check if a video has variable frame rate. Copied logic from VideoDecoder.is_variable_frame_rate.
120+
121+
:param video_path: Local path or presigned S3 URL pointing to the video
122+
:return: True if the video has variable frame rate, False if constant frame rate
123+
"""
124+
# Get r_frame_rate
125+
r_frame_rate_result = subprocess.run( # noqa: S603
126+
[ # noqa: S607
127+
"ffprobe",
128+
"-v",
129+
"0",
130+
"-select_streams",
131+
"v:0",
132+
"-show_entries",
133+
"stream=r_frame_rate",
134+
"-of",
135+
"csv=p=0",
136+
video_path,
137+
],
138+
capture_output=True,
139+
check=False,
140+
text=True,
141+
)
142+
143+
# Get avg_frame_rate
144+
avg_frame_rate_result = subprocess.run( # noqa: S603
145+
[ # noqa: S607
146+
"ffprobe",
147+
"-v",
148+
"0",
149+
"-select_streams",
150+
"v:0",
151+
"-show_entries",
152+
"stream=avg_frame_rate",
153+
"-of",
154+
"csv=p=0",
155+
video_path,
156+
],
157+
capture_output=True,
158+
check=False,
159+
text=True,
160+
)
161+
162+
if r_frame_rate_result.returncode != 0 or avg_frame_rate_result.returncode != 0:
163+
logger.warning("ffprobe failed, could not determine frame rate.")
164+
return False
165+
166+
r_frame_rate = r_frame_rate_result.stdout.strip()
167+
avg_frame_rate = avg_frame_rate_result.stdout.strip()
168+
169+
# Convert rates to decimals for comparison (handle fractions like 30000/1001)
170+
def fraction_to_decimal(rate_str: str):
171+
if "/" in rate_str:
172+
numerator, denominator = map(int, rate_str.split("/"))
173+
return numerator / denominator if denominator != 0 else 0
174+
return float(rate_str)
175+
176+
r_frame_rate_decimal = fraction_to_decimal(r_frame_rate)
177+
avg_frame_rate_decimal = fraction_to_decimal(avg_frame_rate)
178+
179+
# If r_frame_rate and avg_frame_rate are not equal, it's VFR
180+
return r_frame_rate_decimal != avg_frame_rate_decimal
181+
182+
@classmethod
183+
def _convert_vfr_to_cfr(cls, input_path: str, target_fps: float) -> str | None:
184+
"""
185+
Convert a variable frame rate video to constant frame rate by extracting all frames
186+
and then reconstructing the video.
187+
188+
:param input_path: Path to the input VFR video (can be presigned URL)
189+
:param target_fps: Target frame rate for the output video
190+
:return: Path to temporary converted file if successful, None otherwise
191+
"""
192+
temp_dir = None
193+
temp_output_path = None
194+
195+
try:
196+
# Create temporary directory for frames
197+
temp_dir = tempfile.mkdtemp(prefix="vfr_conversion_")
198+
199+
# Create temporary file for output
200+
temp_fd, temp_output_path = tempfile.mkstemp(suffix=".mp4")
201+
os.close(temp_fd) # Close file descriptor, we only need the path
202+
203+
# Step 1: Extract all frames from the video
204+
logger.info("Extracting all frames from VFR video")
205+
if not cls._extract_all_frames(input_path, temp_dir):
206+
logger.error("Failed to extract frames")
207+
return None
208+
209+
# Step 2: Reconstruct video from extracted frames
210+
logger.info("Reconstructing CFR video from extracted frames")
211+
if not cls._stitch_frames_to_cfr_video(temp_dir, temp_output_path, target_fps):
212+
logger.error("Failed to reconstruct video from frames")
213+
return None
214+
215+
logger.info(f"Successfully converted VFR video to CFR: {temp_output_path}")
216+
return temp_output_path
217+
218+
except Exception as e:
219+
logger.error(f"Error during VFR to CFR conversion: {str(e)}")
220+
# Clean up failed temp file
221+
if temp_output_path and os.path.exists(temp_output_path):
222+
os.unlink(temp_output_path)
223+
return None
224+
finally:
225+
# Clean up temporary directory
226+
if temp_dir and os.path.exists(temp_dir):
227+
shutil.rmtree(temp_dir)
228+
229+
@classmethod
230+
def _extract_all_frames(cls, video_path: str, output_dir: str) -> bool:
231+
"""
232+
Extract all frames from a video file using the same logic as the opencv video decoder.
233+
Based on extract_all_frames from vfr_converter.py using VideoDecoder.
234+
235+
:param video_path: Path to the input video (can be presigned URL)
236+
:param output_dir: Directory to save extracted frames
237+
:return: True if successful, False otherwise
238+
"""
239+
try:
240+
# Create output directory if it doesn't exist
241+
os.makedirs(output_dir, exist_ok=True)
242+
243+
# Get video information
244+
video_reader = cv2.VideoCapture(video_path, cv2.CAP_FFMPEG)
245+
total_frames = int(video_reader.get(cv2.CAP_PROP_FRAME_COUNT))
246+
247+
logger.info(f"Extracting {total_frames} frames from {video_path}")
248+
249+
# Extract all frames
250+
for frame_index in range(total_frames):
251+
try:
252+
frame_filename = os.path.join(output_dir, f"frame_{frame_index:06d}.png")
253+
video_reader_frame_pos = int(video_reader.get(cv2.CAP_PROP_POS_FRAMES))
254+
if video_reader_frame_pos != frame_index:
255+
video_reader.set(cv2.CAP_PROP_POS_FRAMES, frame_index)
256+
read_success, frame_bgr = video_reader.read()
257+
if not read_success:
258+
if frame_index > 0:
259+
# Copy the last successfully saved frame and update its index
260+
last_frame_filename = os.path.join(output_dir, f"frame_{frame_index - 1:06d}.png")
261+
shutil.copy(last_frame_filename, frame_filename)
262+
logger.warning(
263+
f"Failed to read frame at index {frame_index}, copied previous frame instead"
264+
)
265+
continue
266+
raise RuntimeError(f"Failed to read frame at index {frame_index} and no previous frame to copy")
267+
268+
# Save frame
269+
cv2.imwrite(frame_filename, frame_bgr)
270+
271+
if (frame_index + 1) % 100 == 0:
272+
logger.info(f"Extracted {frame_index + 1}/{total_frames} frames")
273+
274+
except Exception as e:
275+
logger.error(f"Error extracting frame {frame_index}: {str(e)}")
276+
continue
277+
278+
logger.info(f"Successfully extracted all frames to {output_dir}")
279+
return True
280+
281+
except Exception as e:
282+
logger.error(f"Error during frame extraction: {str(e)}")
283+
return False
284+
285+
@classmethod
286+
def _stitch_frames_to_cfr_video(cls, frames_dir: str, output_path: str, fps: float) -> bool:
287+
"""
288+
Stitch extracted frames into a constant frame rate video using ffmpeg.
289+
Based on stitch_frames_to_cfr_video from vfr_converter.py.
290+
291+
:param frames_dir: Directory containing the extracted frames
292+
:param output_path: Path where the CFR video will be saved
293+
:param fps: Target frame rate for the output video
294+
:return: True if successful, False otherwise
295+
"""
296+
try:
297+
# Check if frames directory exists and has frames
298+
if not os.path.exists(frames_dir):
299+
logger.error(f"Frames directory '{frames_dir}' does not exist")
300+
return False
301+
302+
frame_files = sorted([f for f in os.listdir(frames_dir) if f.endswith(".png")])
303+
if not frame_files:
304+
logger.error(f"No PNG frames found in '{frames_dir}'")
305+
return False
306+
307+
logger.info(f"Stitching {len(frame_files)} frames into CFR video at {fps} fps")
308+
309+
# Use ffmpeg to create video from image sequence
310+
process = subprocess.run( # noqa: S603
311+
[ # noqa: S607
312+
"ffmpeg",
313+
"-y", # Overwrite output file
314+
"-framerate",
315+
str(fps), # Input framerate
316+
"-i",
317+
os.path.join(frames_dir, "frame_%06d.png"), # Input pattern
318+
"-c:v",
319+
"libx264", # Video codec
320+
"-preset",
321+
"slow", # Encoding preset for quality
322+
"-crf",
323+
"18", # Quality setting
324+
"-pix_fmt",
325+
"yuv420p", # Pixel format for compatibility
326+
"-r",
327+
str(fps), # Output framerate
328+
"-an", # Remove audio because video length might differ
329+
output_path,
330+
],
331+
capture_output=True,
332+
text=True,
333+
timeout=7200,
334+
check=False,
335+
)
336+
337+
if process.returncode == 0:
338+
logger.info(f"Successfully stitched frames into CFR video: {output_path}")
339+
return True
340+
logger.error(f"FFmpeg stitching failed with return code {process.returncode}")
341+
logger.error(f"FFmpeg stderr: {process.stderr}")
342+
return False
343+
344+
except subprocess.TimeoutExpired:
345+
logger.error(f"FFmpeg stitching timed out for {frames_dir}")
346+
return False
347+
except Exception as e:
348+
logger.error(f"Error during frame stitching: {str(e)}")
349+
return False
350+
351+
@classmethod
352+
def downgrade_project(cls, organization_id: str, workspace_id: str, project_id: str) -> None:
353+
"""
354+
Downgrade is not necessary.
355+
"""
356+
357+
@classmethod
358+
def downgrade_non_project_data(cls) -> None:
359+
pass
360+
361+
@classmethod
362+
def upgrade_non_project_data(cls) -> None:
363+
pass

0 commit comments

Comments
 (0)