feat: add AirShelf core implementation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import requests
|
||||
from django.db import transaction
|
||||
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.storage import TosStorage
|
||||
from apps.projects.models import ExportJob
|
||||
|
||||
|
||||
def _download_asset_primary_file(asset, target_path: Path) -> None:
|
||||
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
|
||||
if primary is None:
|
||||
raise ValueError(f"asset {asset.id} has no file")
|
||||
url = TosStorage().presigned_get_url(object_key=primary.object_key, expires_in=3600)
|
||||
response = requests.get(url, timeout=180)
|
||||
response.raise_for_status()
|
||||
target_path.write_bytes(response.content)
|
||||
|
||||
|
||||
def run_export_job(export_job_id: str) -> ExportJob:
|
||||
export_job = ExportJob.objects.select_related("timeline", "timeline__project").get(id=export_job_id)
|
||||
timeline = export_job.timeline
|
||||
project = timeline.project
|
||||
clips = list(timeline.clips.select_related("asset").order_by("sort_order"))
|
||||
if not clips:
|
||||
raise ValueError("timeline has no clips")
|
||||
|
||||
export_job.status = ExportJob.Status.RUNNING
|
||||
export_job.progress = 10
|
||||
export_job.save(update_fields=["status", "progress", "updated_at"])
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-export-") as tmp_dir:
|
||||
tmp = Path(tmp_dir)
|
||||
concat_file = tmp / "concat.txt"
|
||||
downloaded_files: list[Path] = []
|
||||
for index, clip in enumerate(clips):
|
||||
clip_path = tmp / f"clip-{index}.mp4"
|
||||
_download_asset_primary_file(clip.asset, clip_path)
|
||||
downloaded_files.append(clip_path)
|
||||
concat_file.write_text(
|
||||
"\n".join(f"file '{path.as_posix()}'" for path in downloaded_files),
|
||||
encoding="utf-8",
|
||||
)
|
||||
output_path = tmp / "output.mp4"
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(concat_file),
|
||||
"-vf",
|
||||
"scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2",
|
||||
"-r",
|
||||
"30",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
subprocess.run(command, check=True, capture_output=True)
|
||||
export_job.progress = 85
|
||||
export_job.save(update_fields=["progress", "updated_at"])
|
||||
|
||||
with output_path.open("rb") as fileobj:
|
||||
asset_id = export_job.id
|
||||
object_key = f"teams/{project.team_id}/projects/{project.id}/exports/{asset_id}.mp4"
|
||||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type="video/mp4")
|
||||
|
||||
with transaction.atomic():
|
||||
asset = Asset.objects.create(
|
||||
team=project.team,
|
||||
created_by=project.created_by,
|
||||
name=f"{project.name}-final.mp4",
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.EXPORTED,
|
||||
category=Asset.Category.FINAL_VIDEO,
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=stored.object_key,
|
||||
bucket=stored.bucket,
|
||||
content_type=stored.content_type,
|
||||
size_bytes=stored.size_bytes,
|
||||
is_primary=True,
|
||||
)
|
||||
export_job.output_asset = asset
|
||||
export_job.status = ExportJob.Status.SUCCEEDED
|
||||
export_job.progress = 100
|
||||
export_job.error_message = ""
|
||||
export_job.save(update_fields=["output_asset", "status", "progress", "error_message", "updated_at"])
|
||||
project.status = project.Status.COMPLETED
|
||||
project.save(update_fields=["status", "updated_at"])
|
||||
return export_job
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from apps.projects.models import ProjectStage
|
||||
|
||||
|
||||
STAGE_ORDER = [
|
||||
ProjectStage.Stage.SCRIPT,
|
||||
ProjectStage.Stage.BASE_ASSETS,
|
||||
ProjectStage.Stage.STORYBOARD,
|
||||
ProjectStage.Stage.VIDEO,
|
||||
ProjectStage.Stage.EXPORT,
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StageTransition:
|
||||
current: str
|
||||
target: str
|
||||
allowed: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def can_enter_stage(current_stage: str, target_stage: str, allow_skip_storyboard: bool = True) -> StageTransition:
|
||||
if target_stage not in STAGE_ORDER:
|
||||
return StageTransition(current_stage, target_stage, False, "unknown target stage")
|
||||
|
||||
current_index = STAGE_ORDER.index(current_stage) if current_stage in STAGE_ORDER else -1
|
||||
target_index = STAGE_ORDER.index(target_stage)
|
||||
|
||||
if target_index <= current_index + 1:
|
||||
return StageTransition(current_stage, target_stage, True)
|
||||
|
||||
if allow_skip_storyboard and current_stage == ProjectStage.Stage.BASE_ASSETS and target_stage == ProjectStage.Stage.VIDEO:
|
||||
return StageTransition(current_stage, target_stage, True)
|
||||
|
||||
return StageTransition(current_stage, target_stage, False, "stage prerequisite is not satisfied")
|
||||
|
||||
Reference in New Issue
Block a user