Migrate clip/convert to V3 actions; add background clip support, redirect legacy route; update frontend guide

This commit is contained in:
Scott Idem
2026-03-11 14:51:08 -04:00
parent fbbc186af0
commit a20c436013
8 changed files with 283 additions and 64 deletions

View File

@@ -17,8 +17,7 @@ RUN apt-get update; \
# Install Python requirements # Install Python requirements
# This file is now located in the project root # This file is now located in the project root
COPY requirements.txt /tmp/requirements.txt COPY requirements.txt /tmp/requirements.txt
RUN --mount=type=cache,target=/root/.cache/pip \ RUN pip install -r /tmp/requirements.txt
pip install -r /tmp/requirements.txt
# Create a reference of actual installed versions # Create a reference of actual installed versions
RUN pip freeze >> /tmp/aether_fastapi_requirements_current.txt RUN pip freeze >> /tmp/aether_fastapi_requirements_current.txt

View File

@@ -34,6 +34,10 @@ async def clip_video_method(
Business logic for clipping a video using ffmpeg and saving as a new hosted_file. Business logic for clipping a video using ffmpeg and saving as a new hosted_file.
Returns the new hosted_file dict or False. Returns the new hosted_file dict or False.
""" """
# NOTE: This function is invoked by the hosted_file router at
# `/hosted_file/{hosted_file_id}/clip_video` and returns the created
# hosted_file metadata (or False) so the router can build the standard
# response body consumed by frontends.
hosted_file_obj = load_hosted_file_obj(hosted_file_id=hosted_file_id) hosted_file_obj = load_hosted_file_obj(hosted_file_id=hosted_file_id)
if not hosted_file_obj: return False if not hosted_file_obj: return False
@@ -46,6 +50,7 @@ async def clip_video_method(
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_video_file_clip: with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_video_file_clip:
tmp_video_file_clip_path = tmp_video_file_clip.name tmp_video_file_clip_path = tmp_video_file_clip.name
try:
if scale_down: if scale_down:
new_filename = f'{filename_no_ext}_[clip_scaled].{to_type}' new_filename = f'{filename_no_ext}_[clip_scaled].{to_type}'
cmd = f'ffmpeg -hide_banner -loglevel error -nostats -y -i {full_file_path} -ss {start_time} -to {end_time} -vf "scale=w=1920:h=1080:force_original_aspect_ratio=decrease" -c:v libx264 -crf 23 -maxrate 2M -bufsize 2M -c:a copy -movflags +faststart {tmp_video_file_clip_path}' cmd = f'ffmpeg -hide_banner -loglevel error -nostats -y -i {full_file_path} -ss {start_time} -to {end_time} -vf "scale=w=1920:h=1080:force_original_aspect_ratio=decrease" -c:v libx264 -crf 23 -maxrate 2M -bufsize 2M -c:a copy -movflags +faststart {tmp_video_file_clip_path}'
@@ -59,7 +64,8 @@ async def clip_video_method(
args = shlex.split(cmd) args = shlex.split(cmd)
try: try:
subprocess.run(args, check=True, capture_output=True, text=True) subprocess.run(args, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError: except subprocess.CalledProcessError as e:
log.exception(f'ffmpeg failed: returncode={e.returncode}; stdout={e.stdout}; stderr={e.stderr}')
return False return False
file_info = await save_file_to_hosted_file( file_info = await save_file_to_hosted_file(
@@ -79,6 +85,12 @@ async def clip_video_method(
new_obj = Hosted_File_Base(**file_info) new_obj = Hosted_File_Base(**file_info)
if res_id := create_hosted_file_obj(hosted_file_obj_new=new_obj): if res_id := create_hosted_file_obj(hosted_file_obj_new=new_obj):
return load_hosted_file_obj(hosted_file_id=res_id, model_as_dict=True) return load_hosted_file_obj(hosted_file_id=res_id, model_as_dict=True)
finally:
try:
if os.path.exists(tmp_video_file_clip_path):
os.unlink(tmp_video_file_clip_path)
except Exception:
log.exception('Failed to remove temporary video clip file')
return False return False
# ### END ### API Hosted File Methods ### clip_video_method() ### # ### END ### API Hosted File Methods ### clip_video_method() ###
@@ -95,23 +107,40 @@ async def convert_file_method(
): ):
from pdf2image import convert_from_path from pdf2image import convert_from_path
# NOTE: Invoked by the hosted_file router at
# `/hosted_file/{hosted_file_id}/convert_file`. This helper currently
# converts the first page of a PDF to an image (webp/png) and saves a
# new hosted_file record; it returns that record or False on failure.
hosted_file_obj = load_hosted_file_obj(hosted_file_id=hosted_file_id) hosted_file_obj = load_hosted_file_obj(hosted_file_id=hosted_file_id)
if not hosted_file_obj: return False if not hosted_file_obj: return False
# Ensure input is a PDF (pdf2image is designed for PDFs)
if (getattr(hosted_file_obj, 'extension', None) or '').lower() != 'pdf' and (getattr(hosted_file_obj, 'content_type', None) or '') != 'application/pdf':
log.warning('convert_file_method called on non-PDF file')
return False
full_file_path = os.path.join(settings.FILES_PATH['hosted_files_root'], hosted_file_obj.hash_sha256[0:2], f'{hosted_file_obj.hash_sha256}.file') full_file_path = os.path.join(settings.FILES_PATH['hosted_files_root'], hosted_file_obj.hash_sha256[0:2], f'{hosted_file_obj.hash_sha256}.file')
if not os.path.exists(full_file_path): return False if not os.path.exists(full_file_path): return False
if not os.path.exists(full_file_path): return False
save_path = os.path.join(settings.FILES_PATH['hosted_tmp_root'], 'convert_file', f'conv_{int(time.time())}.{to_type}') save_path = os.path.join(settings.FILES_PATH['hosted_tmp_root'], 'convert_file', f'conv_{int(time.time())}.{to_type}')
os.makedirs(os.path.dirname(save_path), exist_ok=True) os.makedirs(os.path.dirname(save_path), exist_ok=True)
images = convert_from_path(full_file_path, size=(3840, None)) try:
image = images[0] images = convert_from_path(full_file_path, size=(3840, None))
image = images[0]
if to_type == 'webp': if to_type == 'webp':
image.save(save_path, lossless=False, quality=90) image.save(save_path, lossless=False, quality=90)
elif to_type == 'png': elif to_type == 'png':
image.save(save_path, compress_level=9) image.save(save_path, compress_level=9)
else: return False else:
log.warning(f'Unsupported target type for convert_file_method: {to_type}')
return False
except Exception:
log.exception('Error converting file to image')
return False
file_info = await save_file_to_hosted_file( file_info = await save_file_to_hosted_file(
file_path = save_path, file_path = save_path,
@@ -129,6 +158,12 @@ async def convert_file_method(
else: else:
new_obj = Hosted_File_Base(**file_info) new_obj = Hosted_File_Base(**file_info)
if res_id := create_hosted_file_obj(hosted_file_obj_new=new_obj): if res_id := create_hosted_file_obj(hosted_file_obj_new=new_obj):
# cleanup tmp file
try:
if os.path.exists(save_path):
os.unlink(save_path)
except Exception:
log.exception('Failed to remove temporary converted file')
return load_hosted_file_obj(hosted_file_id=res_id, model_as_dict=True) return load_hosted_file_obj(hosted_file_id=res_id, model_as_dict=True)
return False return False
# ### END ### API Hosted File Methods ### convert_file_method() ### # ### END ### API Hosted File Methods ### convert_file_method() ###

View File

@@ -18,6 +18,7 @@ from app.methods.hosted_file_methods import (
create_hosted_file_link, delete_hosted_file_link, get_hosted_file_link_rec_list create_hosted_file_link, delete_hosted_file_link, get_hosted_file_link_rec_list
) )
from app.methods.lib_media import convert_file_method from app.methods.lib_media import convert_file_method
from app.methods.lib_media import clip_video_method
from app.lib_general_v3 import ( from app.lib_general_v3 import (
AccountContext, get_account_context, get_account_context_optional, AccountContext, get_account_context, get_account_context_optional,
SerializationParams, DelayParams SerializationParams, DelayParams
@@ -481,3 +482,55 @@ async def convert_file(
return mk_resp(data=result) return mk_resp(data=result)
return mk_resp(data=None, status_code=400, status_message="Conversion failed.") return mk_resp(data=None, status_code=400, status_message="Conversion failed.")
# ### END ### API V3 Hosted File Action ### convert_file() ### # ### END ### API V3 Hosted File Action ### convert_file() ###
@router.get('/{hosted_file_id}/clip_video', response_model=Resp_Body_Base)
async def clip_video(
hosted_file_id: str = Path(min_length=11, max_length=22),
link_to_type: str = Query(...),
link_to_id: str = Query(...),
start_time: str = Query(..., min_length=8, max_length=8),
end_time: str = Query(..., min_length=8, max_length=8),
filename_no_ext: str = Query('automated_hosted_file_clip_video'),
reencode: bool = Query(False),
scale_down: bool = Query(False),
background: bool = Query(False),
account: AccountContext = Depends(get_account_context),
):
"""
Clip a segment from a hosted video and save as a new hosted_file record.
Supports optional background scheduling returning `202 Accepted` when `background=true`.
"""
lid_int = redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type)
if not lid_int:
raise HTTPException(status_code=404, detail=f"Linked object not found: {link_to_type}:{link_to_id}")
async def _run_clip():
try:
return await clip_video_method(
hosted_file_id=hosted_file_id,
start_time=start_time,
end_time=end_time,
account_id=account.account_id,
account_id_random=account.account_id_random,
link_to_type=link_to_type,
link_to_id=lid_int,
filename_no_ext=filename_no_ext,
reencode=reencode,
scale_down=scale_down,
)
except Exception:
log.exception('Background clip task failed')
return None
if background:
# Schedule and return 202 Accepted
asyncio.create_task(_run_clip())
return mk_resp(data={'task': 'scheduled'}, status_code=202, status_message='Clip scheduled (background)')
result = await _run_clip()
if result:
return mk_resp(data=result)
return mk_resp(data=None, status_code=400, status_message="Clip failed.")
# ### END ### API V3 Hosted File Action ### clip_video() ###
# ### END ### API V3 Hosted File Action ### convert_file() ###

View File

@@ -1,6 +1,7 @@
import aiofiles, datetime, hashlib, mimetypes, os, pathlib, random, shutil, subprocess, shlex, tempfile, time import aiofiles, datetime, hashlib, mimetypes, os, pathlib, random, shutil, subprocess, shlex, tempfile, time
from fastapi import APIRouter, Body, Depends, File, Form, Header, HTTPException, Path, Query, Response, status, UploadFile from fastapi import APIRouter, Body, Depends, File, Form, Header, HTTPException, Path, Query, Response, status, UploadFile
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse, RedirectResponse
from urllib.parse import quote
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
from typing import Dict, List, Optional, Set, Union from typing import Dict, List, Optional, Set, Union
@@ -301,21 +302,23 @@ async def clip_video(
""" """
Modularized video clipping route. Modularized video clipping route.
""" """
if lid_int := redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type): # Deprecated legacy route — redirect clients to the V3 action endpoint
result = await clip_video_method( # New V3 action: /v3/action/hosted_file/{hosted_file_id}/clip_video
hosted_file_id = hosted_file_id, params = [
start_time = start_time, f"link_to_type={quote(link_to_type)}",
end_time = end_time, f"link_to_id={quote(link_to_id)}",
account_id = commons.x_account_id, f"start_time={quote(start_time)}",
account_id_random = commons.x_account_id_random, f"end_time={quote(end_time)}",
link_to_type = link_to_type, ]
link_to_id = lid_int, if filename_no_ext:
filename_no_ext = filename_no_ext, params.append(f"filename_no_ext={quote(filename_no_ext)}")
reencode = reencode, if reencode:
scale_down = scale_down params.append(f"reencode=true")
) if scale_down:
if result: return mk_resp(data=result, response=commons.response) params.append(f"scale_down=true")
return mk_resp(data=None, status_code=400, response=commons.response)
target = f"/v3/action/hosted_file/{hosted_file_id}/clip_video?" + "&".join(params)
return RedirectResponse(url=target, status_code=307)
# ### END ### API Hosted File ### clip_video() ### # ### END ### API Hosted File ### clip_video() ###

View File

@@ -123,6 +123,38 @@ Every Event File (`event_file`) **must** have a linked Hosted File (`hosted_file
--- ---
## 6. Hosted File Actions: Convert & Clip (Frontend Notes)
These helper endpoints let the frontend request small server-side transformations without uploading new blobs. They return a newly-created `hosted_file` metadata object on success.
- **Convert (PDF → Image)**
- Method: `GET`
- Path: `/v3/action/hosted_file/{hosted_file_id}/convert_file`
- Required query params: `link_to_type`, `link_to_id`
- Optional query params: `filename_no_ext` (defaults to `automated_hosted_file_conversion`), `to_type` (defaults to `webp`)
- Auth: standard V3 headers (`x-aether-api-key`, `x-account-id` / `x-no-account-id` / `?jwt=`)
- Behavior: converts the first page of a PDF to `webp` or `png`, saves a new `hosted_file`, and returns its metadata. Returns 400 on failure.
- **Clip Video**
- Method: `GET`
- Path: `/v3/action/hosted_file/{hosted_file_id}/clip_video`
- Required query params: `link_to_type`, `link_to_id`, `start_time`, `end_time` (format `HH:MM:SS`)
- Optional query params: `filename_no_ext` (defaults to `automated_hosted_file_clip_video`), `reencode` (bool), `scale_down` (bool)
- Auth: standard V3 headers
- Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`. Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize. Returns 400 on failure.
- Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`.
- Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize.
- For longer-running clips you can schedule the job in the background by adding `?background=true`. When scheduled the API returns `202 Accepted` and the clip runs asynchronously on the server; check the returned `hosted_file` record later via the standard V3 `hosted_file` endpoints.
- Returns 400 on synchronous failure; returns 202 when scheduled successfully.
Frontend guidance:
- Call these routes with the same `link_to_type` / `link_to_id` you plan to associate the resulting hosted_file with — the server resolves random IDs for you.
- After a successful response, use the V3 `hosted_file` action endpoints (download/delete) to manage or retrieve the new file.
- These endpoints run synchronously and can take time for large inputs; for heavy or batch workloads use a queued job pattern instead.
- These endpoints may take time for large inputs. Prefer using `?background=true` to schedule work and receive a `202 Accepted` response for async processing. For heavy or batch workloads use a queued job pattern instead.
## 5. Troubleshooting 403 Forbidden ## 5. Troubleshooting 403 Forbidden
If you receive a 403 on a valid ID: If you receive a 403 on a valid ID:

View File

@@ -8,7 +8,7 @@ ACCOUNT_ID = "Q8lR8Ai8hx2FjbQ3C_EH1Q" # OSIT
# IDs for testing ID Vision resolution # IDs for testing ID Vision resolution
HOSTED_FILE_ID = "PUwhbT2tMgU" HOSTED_FILE_ID = "PUwhbT2tMgU"
EVENT_FILE_ID = "cFyCd7FPZe9" EVENT_FILE_ID = "2e_MFBgY5eg"
ARCHIVE_CONTENT_ID = "UjKzrk-GKu5" ARCHIVE_CONTENT_ID = "UjKzrk-GKu5"
# Hash for testing content-addressable storage # Hash for testing content-addressable storage

View File

@@ -0,0 +1,97 @@
import sys
import os
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
# Add project root to path
sys.path.append(os.getcwd())
# Mock configuration and low-level deps BEFORE importing the target module
mock_config = MagicMock()
mock_settings = MagicMock()
mock_settings.FILES_PATH = {
'hosted_files_root': '/tmp',
'hosted_tmp_root': '/tmp'
}
mock_config.settings = mock_settings
sys.modules['app.config'] = mock_config
sys.modules['app.lib_general'] = MagicMock()
sys.modules['app.db_sql'] = MagicMock()
from app.methods import lib_media
def test_clip_video_method():
print('--- test_clip_video_method ---')
hosted_obj = SimpleNamespace(hash_sha256='aa11bb22cc33ddeeff00112233445566778899aa', extension='mp4')
# Prepare mocked behaviors
async def _async_save(*args, **kwargs):
return {'saved': True, 'hash_sha256': 'newhash'}
with patch.object(lib_media, 'load_hosted_file_obj', side_effect=[hosted_obj, {'id': 'EXIST_ID', 'hosted_file_id': 'EXIST_ID', 'filename': 'clip.mp4'}]) as mock_load, \
patch.object(lib_media, 'save_file_to_hosted_file', new=_async_save) as mock_save, \
patch('subprocess.run', return_value=None) as mock_run, \
patch('os.path.exists', return_value=True) as mock_exists, \
patch.object(lib_media, 'sql_select', return_value={'id': 'EXIST_ID'}) as mock_sql:
res = asyncio.run(lib_media.clip_video_method(
hosted_file_id='SOME_ID',
start_time='00:00:01',
end_time='00:00:05',
account_id=1,
link_to_type='archive_content',
link_to_id=1,
))
print('Result:', res)
assert isinstance(res, dict)
assert res.get('id') == 'EXIST_ID'
def test_convert_file_method():
print('--- test_convert_file_method ---')
hosted_obj = SimpleNamespace(hash_sha256='ff22ee33dd44cc55bb66aa77cc88dd99ee001122', extension='pdf', content_type='application/pdf')
class DummyImage:
def save(self, path, **kwargs):
with open(path, 'wb') as f:
f.write(b'PNGDATA')
async def _async_save2(*args, **kwargs):
return {'saved': True, 'hash_sha256': 'newhash2'}
# Provide a dummy pdf2image module to satisfy the runtime import inside the function
sys.modules['pdf2image'] = MagicMock()
sys.modules['pdf2image'].convert_from_path = lambda *a, **k: [DummyImage()]
with patch.object(lib_media, 'load_hosted_file_obj', side_effect=[hosted_obj, {'id': 'EXIST_ID2', 'hosted_file_id': 'EXIST_ID2', 'filename': 'conv.webp'}]) as mock_load, \
patch.object(lib_media, 'save_file_to_hosted_file', new=_async_save2) as mock_save, \
patch('os.path.exists', return_value=True) as mock_exists, \
patch.object(lib_media, 'sql_select', return_value={'id': 'EXIST_ID2'}) as mock_sql, \
patch.object(lib_media, 'create_hosted_file_obj', return_value='NEW_ID') as mock_create:
res = asyncio.run(lib_media.convert_file_method(
hosted_file_id='SOME_ID',
link_to_type='archive_content',
link_to_id=1,
account_id=1,
to_type='webp'
))
print('Result:', res)
assert isinstance(res, dict)
assert res.get('id') == 'EXIST_ID2'
if __name__ == '__main__':
try:
test_clip_video_method()
test_convert_file_method()
print('\n🎉 MEDIA METHOD TESTS PASSED')
except AssertionError as e:
print('\n❌ TEST FAILED:', e)
raise