feat: add AirShelf core implementation

This commit is contained in:
zyc
2026-06-05 10:21:40 +08:00
parent 2ba1058329
commit cfdcd84a30
252 changed files with 70828 additions and 0 deletions
@@ -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")