Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 104 additions & 37 deletions videohelpersuite/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import os
import subprocess
import re

import asyncio
import glob

from .utils import is_url, get_sorted_dir_files_from_directory, ffmpeg_path, \
validate_sequence, is_safe_path, strip_path, try_download_video, ENCODE_ARGS
Expand Down Expand Up @@ -136,53 +136,120 @@ async def view_video(request):
async def query_video(request):
query = request.rel_url.query
filepath = await resolve_path(query)
#TODO: cache lookup

if isinstance(filepath, web.Response):
return filepath
filepath = filepath[0]
format_type = query.get('format', 'video')

# Handle folder paths when format is "folder"
if format_type == 'folder' and os.path.isdir(filepath):
# Check for image files in the folder
image_extensions = FolderOfImages.IMG_EXTENSIONS # Use same extensions as FolderOfImages
image_files = []
for ext in image_extensions:
image_files.extend(glob.glob(os.path.join(filepath, ext.replace('.', '*.').lower())))
image_files.extend(glob.glob(os.path.join(filepath, ext.replace('.', '*.').upper())))

if not image_files:
# No images found, return empty response to avoid breaking UI
return web.json_response({})

# Gather info for image sequence
frame_rate = float(query.get('force_rate', 8)) # Default to 8 FPS, as in view_video
skip_first_images = int(query.get('skip_first_images', 0))
select_every_nth = int(query.get('select_every_nth', 1)) or 1

valid_images = get_sorted_dir_files_from_directory(filepath, skip_first_images, select_every_nth, FolderOfImages.IMG_EXTENSIONS)
# No valid images after filtering, return empty response
if not valid_images:
return web.json_response({})

source = {
'fps': frame_rate / select_every_nth,
'frames': len(valid_images),
'duration': len(valid_images) / (frame_rate / select_every_nth),
'size': None # Size could be derived from first image, but not critical
}
query_cache[filepath] = (os.stat(filepath).st_mtime, source)
loaded = {
'duration': source['duration'],
'fps': source['fps'],
'frames': source['frames']
}
loaded['duration'] -= float(query.get('start_time', 0))
loaded['duration'] -= int(query.get('skip_first_frames', 0)) / loaded['fps']
return web.json_response({'source': source, 'loaded': loaded})

if os.path.isdir(filepath):
# Look for video files
video_extensions = ['*.mp4', '*.avi', '*.mkv', '*.mov']
video_files = []
for ext in video_extensions:
video_files.extend(glob.glob(os.path.join(filepath, ext)))

if not video_files:
# No video files found, return empty response
return web.json_response({})

filepath = video_files[0]
print(f"Selected video file: {filepath}")

if filepath.endswith(".webp"):
# ffmpeg doesn't support decoding animated WebP https://trac.ffmpeg.org/ticket/4907
return web.json_response({})
if filepath in query_cache and query_cache[filepath][0] == os.stat(filepath).st_mtime:
source = query_cache[filepath][1]
else:
args_dummy = [ffmpeg_path, "-i", filepath, '-c', 'copy', '-frames:v', '1', "-f", "null", "-"]
try:
dummy_res = subprocess.run(args_dummy, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, check=True)
except subprocess.CalledProcessError as e:
raise Exception("An error occurred in the ffmpeg subprocess:\n" \
+ e.stderr.decode(*ENCODE_ARGS))
lines = dummy_res.stderr.decode(*ENCODE_ARGS)
source = {}

for line in lines.split('\n'):
match = re.search("^ *Stream .* Video.*, ([1-9]|\\d{2,})x(\\d+)", line)
if match is not None:
source['size'] = [int(match.group(1)), int(match.group(2))]
fps_match = re.search(", ([\\d\\.]+) fps", line)
if not fps_match:
return web.json_response({})
source['fps'] = float(fps_match.group(1))
if re.search("(yuva|rgba)", line):
source['alpha'] = True
break

# Validate if filepath is a file or image sequence
if not os.path.isfile(filepath) and not validate_sequence(filepath):
# Path is neither a file nor a valid sequence, return empty response
return web.json_response({})

# Cache and process video file with FFmpeg
try:
if filepath in query_cache and query_cache[filepath][0] == os.stat(filepath).st_mtime:
source = query_cache[filepath][1]
else:
raise Exception("Failed to parse video/image information. FFMPEG output:\n" + lines)
args_dummy = [ffmpeg_path, "-i", filepath, '-c', 'copy', '-frames:v', '1', "-f", "null", "-"]
try:
dummy_res = subprocess.run(args_dummy, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, check=True)
except subprocess.CalledProcessError as e:
raise Exception("An error occurred in the ffmpeg subprocess:\n" \
+ e.stderr.decode(*ENCODE_ARGS))
lines = dummy_res.stderr.decode(*ENCODE_ARGS)
source = {}

durs_match = re.search("Duration: (\\d+:\\d+:\\d+\\.\\d+),", lines)
if not (durs_match and 'fps' in source):
return web.json_response({})
durs = durs_match.group(1).split(':')
duration = int(durs[0])*360 + int(durs[1])*60 + float(durs[2])
source['duration'] = duration
source['frames'] = int(duration*source['fps'])
query_cache[filepath] = (os.stat(filepath).st_mtime, source)
for line in lines.split('\n'):
match = re.search("^ *Stream .* Video.*, ([1-9]|\\d{2,})x(\\d+)", line)
if match is not None:
source['size'] = [int(match.group(1)), int(match.group(2))]
fps_match = re.search(", ([\\d\\.]+) fps", line)
if not fps_match:
return web.json_response({})
source['fps'] = float(fps_match.group(1))
if re.search("(yuva|rgba)", line):
source['alpha'] = True
break
else:
raise Exception("Failed to parse video/image information. FFMPEG output:\n" + lines)

durs_match = re.search("Duration: (\\d+:\\d+:\\d+\\.\\d+),", lines)
if not (durs_match and 'fps' in source):
return web.json_response({})
durs = durs_match.group(1).split(':')
duration = int(durs[0])*360 + int(durs[1])*60 + float(durs[2])
source['duration'] = duration
source['frames'] = int(duration*source['fps'])
query_cache[filepath] = (os.stat(filepath).st_mtime, source)
except (FileNotFoundError, PermissionError) as e:
# Handle cases where file is missing or inaccessible (e.g., stale cache entry)
return web.json_response({})

loaded = {}
if 'duration' not in source:
return web.json_response({})
loaded['duration'] = source['duration']
loaded['duration'] -= float(query.get('start_time',0))
loaded['duration'] -= float(query.get('start_time', 0))
loaded['fps'] = float(query.get('force_rate', 0)) or source['fps']
loaded['duration'] -= int(query.get('skip_first_frames', 0)) / loaded['fps']
loaded['fps'] /= int(query.get('select_every_nth', 1)) or 1
Expand Down Expand Up @@ -256,4 +323,4 @@ async def get_path(request):
#Broken symlinks can throw a very unhelpful "Invalid argument"
pass
valid_items.sort(key=lambda f: os.stat(os.path.join(path,f)).st_mtime)
return web.json_response(valid_items)
return web.json_response(valid_items)