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
+1
View File
@@ -0,0 +1 @@
+46
View File
@@ -0,0 +1,46 @@
from django.contrib import admin
from .models import (
BaseAssetGroup,
BgmTrack,
ExportJob,
Project,
ProjectStage,
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardVersion,
SubtitleTrack,
Timeline,
TimelineClip,
VideoSegment,
VideoSegmentVersion,
)
class ProjectStageInline(admin.TabularInline):
model = ProjectStage
extra = 0
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ("name", "team", "product", "status", "current_stage", "updated_at")
search_fields = ("name", "team__name", "product__title")
list_filter = ("status", "current_stage")
inlines = [ProjectStageInline]
admin.site.register(ScriptVersion)
admin.site.register(ScriptSegment)
admin.site.register(BaseAssetGroup)
admin.site.register(StoryboardVersion)
admin.site.register(StoryboardFrame)
admin.site.register(VideoSegment)
admin.site.register(VideoSegmentVersion)
admin.site.register(Timeline)
admin.site.register(TimelineClip)
admin.site.register(SubtitleTrack)
admin.site.register(BgmTrack)
admin.site.register(ExportJob)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ProjectsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.projects"
@@ -0,0 +1,721 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("accounts", "0001_initial"),
("ai", "0001_initial"),
("assets", "0001_initial"),
("products", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Project",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
(
"status",
models.CharField(
choices=[
("draft", "Draft"),
("scripting", "Scripting"),
("asseting", "Asseting"),
("storyboarding", "Storyboarding"),
("videoing", "Videoing"),
("exporting", "Exporting"),
("completed", "Completed"),
("failed", "Failed"),
],
default="draft",
max_length=32,
),
),
("current_stage", models.CharField(default="script", max_length=32)),
(
"budget_limit",
models.DecimalField(
blank=True, decimal_places=2, max_digits=12, null=True
),
),
("failure_reason", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="projects",
to="products.product",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
],
),
migrations.CreateModel(
name="BaseAssetGroup",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"kind",
models.CharField(
choices=[
("product", "Product"),
("person", "Person"),
("scene", "Scene"),
],
max_length=32,
),
),
("prompt", models.TextField(blank=True)),
("version", models.PositiveIntegerField(default=1)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"adopted_asset",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="adopted_base_groups",
to="assets.asset",
),
),
(
"candidate_assets",
models.ManyToManyField(
blank=True,
related_name="candidate_base_groups",
to="assets.asset",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="base_asset_groups",
to="ai.aitask",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="base_asset_groups",
to="projects.project",
),
),
],
),
migrations.CreateModel(
name="ProjectStage",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"stage",
models.CharField(
choices=[
("script", "Script"),
("base_assets", "Base Assets"),
("storyboard", "Storyboard"),
("video", "Video"),
("export", "Export"),
],
max_length=32,
),
),
(
"status",
models.CharField(
choices=[
("not_started", "Not Started"),
("draft", "Draft"),
("queued", "Queued"),
("running", "Running"),
("succeeded", "Succeeded"),
("failed", "Failed"),
("skipped", "Skipped"),
("needs_review", "Needs Review"),
],
default="not_started",
max_length=32,
),
),
("started_at", models.DateTimeField(blank=True, null=True)),
("completed_at", models.DateTimeField(blank=True, null=True)),
("error_message", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="stages",
to="projects.project",
),
),
],
options={
"ordering": ["created_at"],
},
),
migrations.CreateModel(
name="ScriptVersion",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(blank=True, max_length=128)),
("content", models.TextField()),
("source", models.CharField(default="ai", max_length=32)),
("is_adopted", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="script_versions",
to="projects.project",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="script_versions",
to="ai.aitask",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="ScriptSegment",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("duration_seconds", models.PositiveIntegerField(default=15)),
("narration", models.TextField(blank=True)),
("visual_prompt", models.TextField(blank=True)),
("product_points", models.JSONField(blank=True, default=list)),
(
"script_version",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="segments",
to="projects.scriptversion",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="StoryboardVersion",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("prompt", models.TextField(blank=True)),
("is_adopted", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="storyboard_versions",
to="projects.project",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="storyboard_versions",
to="ai.aitask",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="StoryboardFrame",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("prompt", models.TextField(blank=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="storyboard_frames",
to="assets.asset",
),
),
(
"script_segment",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="storyboard_frames",
to="projects.scriptsegment",
),
),
(
"storyboard",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="frames",
to="projects.storyboardversion",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="Timeline",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(blank=True, max_length=255)),
("aspect_ratio", models.CharField(default="9:16", max_length=16)),
("resolution", models.CharField(default="1080x1920", max_length=32)),
("duration_seconds", models.PositiveIntegerField(default=60)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="timeline",
to="projects.project",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="SubtitleTrack",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("content", models.JSONField(blank=True, default=list)),
("style", models.JSONField(blank=True, default=dict)),
("enabled", models.BooleanField(default=True)),
(
"timeline",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="subtitle_tracks",
to="projects.timeline",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="ExportJob",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"status",
models.CharField(
choices=[
("draft", "Draft"),
("queued", "Queued"),
("running", "Running"),
("succeeded", "Succeeded"),
("failed", "Failed"),
],
default="draft",
max_length=32,
),
),
("progress", models.PositiveIntegerField(default=0)),
("error_message", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"output_asset",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="export_jobs",
to="assets.asset",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="export_jobs",
to="ai.aitask",
),
),
(
"timeline",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="export_jobs",
to="projects.timeline",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="BgmTrack",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("volume", models.PositiveIntegerField(default=60)),
("start_ms", models.PositiveIntegerField(default=0)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="bgm_tracks",
to="assets.asset",
),
),
(
"timeline",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bgm_tracks",
to="projects.timeline",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TimelineClip",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("start_ms", models.PositiveIntegerField(default=0)),
("duration_ms", models.PositiveIntegerField(default=15000)),
("trim_start_ms", models.PositiveIntegerField(default=0)),
("trim_end_ms", models.PositiveIntegerField(blank=True, null=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="timeline_clips",
to="assets.asset",
),
),
(
"timeline",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="clips",
to="projects.timeline",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="VideoSegment",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("target_duration_seconds", models.PositiveIntegerField(default=15)),
(
"status",
models.CharField(
choices=[
("not_started", "Not Started"),
("queued", "Queued"),
("running", "Running"),
("succeeded", "Succeeded"),
("failed", "Failed"),
],
default="not_started",
max_length=32,
),
),
("error_message", models.TextField(blank=True)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="video_segments",
to="projects.project",
),
),
(
"script_segment",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="video_segments",
to="projects.scriptsegment",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="VideoSegmentVersion",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("prompt", models.TextField(blank=True)),
("is_adopted", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="video_segment_versions",
to="assets.asset",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="video_versions",
to="ai.aitask",
),
),
(
"video_segment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="projects.videosegment",
),
),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="videosegment",
name="adopted_version",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="adopted_by_segments",
to="projects.videosegmentversion",
),
),
migrations.AddIndex(
model_name="project",
index=models.Index(
fields=["team", "status"], name="projects_pr_team_id_4a0091_idx"
),
),
migrations.AddIndex(
model_name="project",
index=models.Index(
fields=["team", "current_stage"], name="projects_pr_team_id_a3c9ff_idx"
),
),
migrations.AddIndex(
model_name="baseassetgroup",
index=models.Index(
fields=["project", "kind"], name="projects_ba_project_8fb70a_idx"
),
),
migrations.AlterUniqueTogether(
name="projectstage",
unique_together={("project", "stage")},
),
migrations.AlterUniqueTogether(
name="videosegment",
unique_together={("project", "sort_order")},
),
]
@@ -0,0 +1 @@
+237
View File
@@ -0,0 +1,237 @@
from django.db import models
from apps.common.models import TeamOwnedModel, TimeStampedModel
class Project(TeamOwnedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
SCRIPTING = "scripting", "Scripting"
ASSETING = "asseting", "Asseting"
STORYBOARDING = "storyboarding", "Storyboarding"
VIDEOING = "videoing", "Videoing"
EXPORTING = "exporting", "Exporting"
COMPLETED = "completed", "Completed"
FAILED = "failed", "Failed"
name = models.CharField(max_length=255)
product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="projects")
status = models.CharField(max_length=32, choices=Status.choices, default=Status.DRAFT)
current_stage = models.CharField(max_length=32, default="script")
budget_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
failure_reason = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
indexes = [
models.Index(fields=["team", "status"]),
models.Index(fields=["team", "current_stage"]),
]
def __str__(self) -> str:
return self.name
class ProjectStage(TimeStampedModel):
class Stage(models.TextChoices):
SCRIPT = "script", "Script"
BASE_ASSETS = "base_assets", "Base Assets"
STORYBOARD = "storyboard", "Storyboard"
VIDEO = "video", "Video"
EXPORT = "export", "Export"
class Status(models.TextChoices):
NOT_STARTED = "not_started", "Not Started"
DRAFT = "draft", "Draft"
QUEUED = "queued", "Queued"
RUNNING = "running", "Running"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
SKIPPED = "skipped", "Skipped"
NEEDS_REVIEW = "needs_review", "Needs Review"
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="stages")
stage = models.CharField(max_length=32, choices=Stage.choices)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.NOT_STARTED)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
error_message = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
unique_together = [("project", "stage")]
ordering = ["created_at"]
class ScriptVersion(TimeStampedModel):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="script_versions")
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="script_versions")
title = models.CharField(max_length=128, blank=True)
content = models.TextField()
source = models.CharField(max_length=32, default="ai")
is_adopted = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
class ScriptSegment(TimeStampedModel):
script_version = models.ForeignKey(ScriptVersion, on_delete=models.CASCADE, related_name="segments")
sort_order = models.PositiveIntegerField(default=0)
duration_seconds = models.PositiveIntegerField(default=15)
narration = models.TextField(blank=True)
visual_prompt = models.TextField(blank=True)
product_points = models.JSONField(default=list, blank=True)
class Meta:
ordering = ["sort_order", "created_at"]
class BaseAssetGroup(TimeStampedModel):
class Kind(models.TextChoices):
PRODUCT = "product", "Product"
PERSON = "person", "Person"
SCENE = "scene", "Scene"
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="base_asset_groups")
kind = models.CharField(max_length=32, choices=Kind.choices)
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="base_asset_groups")
prompt = models.TextField(blank=True)
adopted_asset = models.ForeignKey(
"assets.Asset",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="adopted_base_groups",
)
candidate_assets = models.ManyToManyField("assets.Asset", blank=True, related_name="candidate_base_groups")
version = models.PositiveIntegerField(default=1)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
indexes = [models.Index(fields=["project", "kind"])]
class StoryboardVersion(TimeStampedModel):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="storyboard_versions")
task = models.ForeignKey(
"ai.AITask",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="storyboard_versions",
)
prompt = models.TextField(blank=True)
is_adopted = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
class StoryboardFrame(TimeStampedModel):
storyboard = models.ForeignKey(StoryboardVersion, on_delete=models.CASCADE, related_name="frames")
script_segment = models.ForeignKey(
ScriptSegment,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="storyboard_frames",
)
asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="storyboard_frames")
sort_order = models.PositiveIntegerField(default=0)
prompt = models.TextField(blank=True)
class Meta:
ordering = ["sort_order", "created_at"]
class VideoSegment(TimeStampedModel):
class Status(models.TextChoices):
NOT_STARTED = "not_started", "Not Started"
QUEUED = "queued", "Queued"
RUNNING = "running", "Running"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="video_segments")
script_segment = models.ForeignKey(
ScriptSegment,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="video_segments",
)
sort_order = models.PositiveIntegerField(default=0)
target_duration_seconds = models.PositiveIntegerField(default=15)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.NOT_STARTED)
adopted_version = models.ForeignKey(
"projects.VideoSegmentVersion",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="adopted_by_segments",
)
error_message = models.TextField(blank=True)
class Meta:
unique_together = [("project", "sort_order")]
ordering = ["sort_order", "created_at"]
class VideoSegmentVersion(TimeStampedModel):
video_segment = models.ForeignKey(VideoSegment, on_delete=models.CASCADE, related_name="versions")
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="video_versions")
asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="video_segment_versions")
prompt = models.TextField(blank=True)
is_adopted = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
class Timeline(TimeStampedModel):
project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="timeline")
name = models.CharField(max_length=255, blank=True)
aspect_ratio = models.CharField(max_length=16, default="9:16")
resolution = models.CharField(max_length=32, default="1080x1920")
duration_seconds = models.PositiveIntegerField(default=60)
metadata = models.JSONField(default=dict, blank=True)
class TimelineClip(TimeStampedModel):
timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="clips")
asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="timeline_clips")
sort_order = models.PositiveIntegerField(default=0)
start_ms = models.PositiveIntegerField(default=0)
duration_ms = models.PositiveIntegerField(default=15000)
trim_start_ms = models.PositiveIntegerField(default=0)
trim_end_ms = models.PositiveIntegerField(null=True, blank=True)
class Meta:
ordering = ["sort_order", "created_at"]
class SubtitleTrack(TimeStampedModel):
timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="subtitle_tracks")
content = models.JSONField(default=list, blank=True)
style = models.JSONField(default=dict, blank=True)
enabled = models.BooleanField(default=True)
class BgmTrack(TimeStampedModel):
timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="bgm_tracks")
asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="bgm_tracks")
volume = models.PositiveIntegerField(default=60)
start_ms = models.PositiveIntegerField(default=0)
class ExportJob(TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
QUEUED = "queued", "Queued"
RUNNING = "running", "Running"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="export_jobs")
status = models.CharField(max_length=32, choices=Status.choices, default=Status.DRAFT)
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="export_jobs")
output_asset = models.ForeignKey("assets.Asset", on_delete=models.SET_NULL, null=True, blank=True, related_name="export_jobs")
progress = models.PositiveIntegerField(default=0)
error_message = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
+140
View File
@@ -0,0 +1,140 @@
from rest_framework import serializers
from .models import (
BaseAssetGroup,
ExportJob,
Project,
ProjectStage,
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardVersion,
Timeline,
TimelineClip,
VideoSegment,
VideoSegmentVersion,
)
class ProjectStageSerializer(serializers.ModelSerializer):
class Meta:
model = ProjectStage
fields = ["id", "stage", "status", "started_at", "completed_at", "error_message", "metadata"]
read_only_fields = fields
class VideoSegmentSerializer(serializers.ModelSerializer):
class Meta:
model = VideoSegment
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version"]
read_only_fields = fields
class BaseAssetGroupSerializer(serializers.ModelSerializer):
candidate_assets = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
class Meta:
model = BaseAssetGroup
fields = ["id", "kind", "prompt", "adopted_asset", "candidate_assets", "version", "metadata", "created_at"]
read_only_fields = fields
class StoryboardFrameSerializer(serializers.ModelSerializer):
class Meta:
model = StoryboardFrame
fields = ["id", "script_segment", "asset", "sort_order", "prompt"]
read_only_fields = fields
class StoryboardVersionSerializer(serializers.ModelSerializer):
frames = StoryboardFrameSerializer(many=True, read_only=True)
class Meta:
model = StoryboardVersion
fields = ["id", "prompt", "is_adopted", "frames", "created_at", "updated_at"]
read_only_fields = fields
class VideoSegmentVersionSerializer(serializers.ModelSerializer):
class Meta:
model = VideoSegmentVersion
fields = ["id", "video_segment", "asset", "prompt", "is_adopted", "metadata", "created_at"]
read_only_fields = fields
class TimelineClipSerializer(serializers.ModelSerializer):
class Meta:
model = TimelineClip
fields = ["id", "asset", "sort_order", "start_ms", "duration_ms", "trim_start_ms", "trim_end_ms"]
read_only_fields = ["id"]
class TimelineExportJobSerializer(serializers.ModelSerializer):
class Meta:
model = ExportJob
fields = ["id", "status", "output_asset", "progress", "error_message", "created_at", "updated_at"]
read_only_fields = fields
class TimelineSerializer(serializers.ModelSerializer):
clips = TimelineClipSerializer(many=True, read_only=True)
export_jobs = TimelineExportJobSerializer(many=True, read_only=True)
class Meta:
model = Timeline
fields = ["id", "name", "aspect_ratio", "resolution", "duration_seconds", "metadata", "clips", "export_jobs"]
read_only_fields = ["id", "clips", "export_jobs"]
class ExportJobSerializer(serializers.ModelSerializer):
class Meta:
model = ExportJob
fields = ["id", "status", "output_asset", "progress", "error_message", "metadata", "created_at", "updated_at"]
read_only_fields = fields
class ScriptSegmentSerializer(serializers.ModelSerializer):
class Meta:
model = ScriptSegment
fields = ["id", "sort_order", "duration_seconds", "narration", "visual_prompt", "product_points"]
read_only_fields = fields
class ScriptVersionSerializer(serializers.ModelSerializer):
segments = ScriptSegmentSerializer(many=True, read_only=True)
class Meta:
model = ScriptVersion
fields = ["id", "title", "content", "source", "is_adopted", "segments", "created_at", "updated_at"]
read_only_fields = fields
class ProjectSerializer(serializers.ModelSerializer):
stages = ProjectStageSerializer(many=True, read_only=True)
video_segments = VideoSegmentSerializer(many=True, read_only=True)
script_versions = ScriptVersionSerializer(many=True, read_only=True)
base_asset_groups = BaseAssetGroupSerializer(many=True, read_only=True)
storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True)
timeline = TimelineSerializer(read_only=True)
class Meta:
model = Project
fields = [
"id",
"name",
"product",
"status",
"current_stage",
"budget_limit",
"failure_reason",
"metadata",
"stages",
"script_versions",
"base_asset_groups",
"storyboard_versions",
"video_segments",
"timeline",
"created_at",
"updated_at",
]
read_only_fields = ["id", "status", "current_stage", "failure_reason", "created_at", "updated_at"]
@@ -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")
+44
View File
@@ -0,0 +1,44 @@
from airshelf.celery import app
from apps.ai.models import AITask
from apps.ai.services import poll_video_segment
from apps.projects.models import ExportJob
from apps.projects.models import VideoSegment
from apps.projects.services.export import run_export_job
@app.task(bind=True, max_retries=120)
def poll_video_segment_task(self, video_segment_id: str) -> str:
segment = VideoSegment.objects.select_related("project", "project__created_by").get(id=video_segment_id)
ai_task = (
segment.project.ai_tasks.filter(
task_type=AITask.Type.VIDEO_SEGMENT,
request_payload__video_segment_id=str(segment.id),
status__in=[AITask.Status.SUBMITTED, AITask.Status.POLLING],
)
.select_related("created_by")
.order_by("-created_at")
.first()
)
if ai_task is None:
return video_segment_id
user = ai_task.created_by or segment.project.created_by
version = poll_video_segment(video_segment=segment, user=user)
if version is None and segment.status in [VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED]:
raise self.retry(countdown=30)
return video_segment_id
@app.task(bind=True, max_retries=2)
def run_export_job_task(self, export_job_id: str) -> str:
try:
run_export_job(export_job_id)
except Exception as exc:
export_job = ExportJob.objects.filter(id=export_job_id).first()
if export_job:
export_job.status = ExportJob.Status.FAILED
export_job.error_message = str(exc)
export_job.save(update_fields=["status", "error_message", "updated_at"])
raise
return export_job_id
+102
View File
@@ -0,0 +1,102 @@
from django.test import TestCase
from unittest.mock import patch
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import ModelConfig, ModelProvider
from apps.billing.models import CreditAccount, CreditLedger
from apps.products.models import Product
from apps.projects.models import Project, ProjectStage, ScriptVersion, VideoSegment
class ProjectApiTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="owner", password="pass")
self.team = Team.objects.create(name="E2E Team", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(team=self.team, balance="100.0000")
self.product = Product.objects.create(team=self.team, created_by=self.user, title="Test Product")
self.provider = ModelProvider.objects.create(
name="volcengine",
display_name="Volcano",
base_url="https://ark.cn-beijing.volces.com/api/v3",
)
self.model = ModelConfig.objects.create(
provider=self.provider,
name="doubao-seed-2-0-pro-260215",
display_name="Doubao",
capability=ModelConfig.Capability.TEXT,
endpoint="chat/completions",
unit_price="2.0000",
)
self.client = APIClient()
self.client.force_authenticate(self.user)
def test_create_project_initializes_pipeline(self):
response = self.client.post(
"/api/projects/",
{"name": "Launch Video", "product": str(self.product.id)},
format="json",
)
self.assertEqual(response.status_code, 201)
project = Project.objects.get(id=response.data["id"])
self.assertEqual(project.team, self.team)
self.assertEqual(project.created_by, self.user)
self.assertEqual(project.stages.count(), 5)
self.assertEqual(project.video_segments.count(), 4)
self.assertEqual(
list(project.stages.values_list("stage", flat=True)),
[
ProjectStage.Stage.SCRIPT,
ProjectStage.Stage.BASE_ASSETS,
ProjectStage.Stage.STORYBOARD,
ProjectStage.Stage.VIDEO,
ProjectStage.Stage.EXPORT,
],
)
self.assertEqual(
list(project.video_segments.values_list("target_duration_seconds", flat=True)),
[15, 15, 15, 15],
)
self.assertEqual(
list(project.video_segments.values_list("sort_order", flat=True)),
[0, 1, 2, 3],
)
self.assertTrue(VideoSegment.objects.filter(project=project).exists())
@patch("apps.ai.services.VolcanoArkProvider")
def test_generate_script_creates_script_segments_and_charges_credit(self, provider_cls):
provider = provider_cls.return_value
provider.chat_completion.return_value = {
"choices": [
{
"message": {
"content": "1. 开场吸引\n2. 展示卖点\n3. 使用场景\n4. 促单转化",
}
}
]
}
provider.extract_text.return_value = "1. 开场吸引\n2. 展示卖点\n3. 使用场景\n4. 促单转化"
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="Launch Video")
for stage in [
ProjectStage.Stage.SCRIPT,
ProjectStage.Stage.BASE_ASSETS,
ProjectStage.Stage.STORYBOARD,
ProjectStage.Stage.VIDEO,
ProjectStage.Stage.EXPORT,
]:
ProjectStage.objects.create(project=project, stage=stage)
response = self.client.post(
f"/api/projects/{project.id}/generate-script/",
{"prompt": "突出高转化"},
format="json",
)
self.assertEqual(response.status_code, 201)
script = ScriptVersion.objects.get(project=project)
self.assertEqual(script.segments.count(), 4)
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1)
+9
View File
@@ -0,0 +1,9 @@
from rest_framework.routers import DefaultRouter
from .views import ProjectViewSet
router = DefaultRouter()
router.register("", ProjectViewSet, basename="project")
urlpatterns = router.urls
+198
View File
@@ -0,0 +1,198 @@
from django.db import transaction
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.ai.services import (
create_export_job,
generate_base_asset,
generate_project_script,
generate_storyboard,
poll_video_segment,
submit_video_segment,
)
from apps.common.api import TeamScopedViewSetMixin
from .models import BaseAssetGroup, Project, ProjectStage, ScriptVersion, Timeline, TimelineClip, VideoSegment
from .serializers import (
BaseAssetGroupSerializer,
ExportJobSerializer,
ProjectSerializer,
ScriptVersionSerializer,
StoryboardVersionSerializer,
VideoSegmentVersionSerializer,
)
from .services.pipeline import STAGE_ORDER
from .tasks import poll_video_segment_task, run_export_job_task
def promote_base_asset_stage_if_ready(project: Project) -> bool:
adopted_kind_count = (
project.base_asset_groups.filter(adopted_asset__isnull=False).values("kind").distinct().count()
)
if adopted_kind_count < 3:
return False
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS)
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.STORYBOARD
project.status = Project.Status.STORYBOARDING
project.save(update_fields=["current_stage", "status", "updated_at"])
return True
class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
queryset = Project.objects.select_related("product", "timeline").prefetch_related(
"stages",
"video_segments",
"script_versions",
"script_versions__segments",
"base_asset_groups",
"base_asset_groups__candidate_assets",
"storyboard_versions",
"storyboard_versions__frames",
"timeline__clips",
).all()
serializer_class = ProjectSerializer
search_fields = ["name", "product__title"]
ordering_fields = ["created_at", "updated_at", "name"]
@transaction.atomic
def perform_create(self, serializer):
project = serializer.save(team=self.get_team(), created_by=self.request.user)
for stage in STAGE_ORDER:
ProjectStage.objects.create(project=project, stage=stage)
for index in range(4):
VideoSegment.objects.create(project=project, sort_order=index, target_duration_seconds=15)
@action(detail=True, methods=["post"], url_path="generate-script")
def generate_script(self, request, pk=None):
project = self.get_object()
script = generate_project_script(
project=project,
user=request.user,
user_prompt=request.data.get("prompt", ""),
selling_point_ids=request.data.get("selling_point_ids") or [],
)
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="adopt-script")
@transaction.atomic
def adopt_script(self, request, pk=None):
project = self.get_object()
script_id = request.data.get("script_version_id")
script = ScriptVersion.objects.select_for_update().get(project=project, id=script_id)
ScriptVersion.objects.filter(project=project).update(is_adopted=False)
script.is_adopted = True
script.save(update_fields=["is_adopted", "updated_at"])
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.BASE_ASSETS
project.status = Project.Status.ASSETING
project.save(update_fields=["current_stage", "status", "updated_at"])
return Response(ScriptVersionSerializer(script).data)
@action(detail=True, methods=["post"], url_path="generate-base-asset")
def generate_base_asset_action(self, request, pk=None):
project = self.get_object()
kind = request.data.get("kind")
if kind not in BaseAssetGroup.Kind.values:
return Response({"detail": "invalid base asset kind"}, status=status.HTTP_400_BAD_REQUEST)
group = generate_base_asset(project=project, user=request.user, kind=kind, prompt=request.data.get("prompt", ""))
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS)
stage.status = ProjectStage.Status.NEEDS_REVIEW
stage.save(update_fields=["status", "updated_at"])
promote_base_asset_stage_if_ready(project)
return Response(BaseAssetGroupSerializer(group).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="adopt-base-asset")
@transaction.atomic
def adopt_base_asset(self, request, pk=None):
project = self.get_object()
group = BaseAssetGroup.objects.select_for_update().get(project=project, id=request.data.get("group_id"))
asset_id = request.data.get("asset_id")
if not group.candidate_assets.filter(id=asset_id).exists():
return Response({"detail": "asset is not a candidate"}, status=status.HTTP_400_BAD_REQUEST)
group.adopted_asset_id = asset_id
group.save(update_fields=["adopted_asset", "updated_at"])
promote_base_asset_stage_if_ready(project)
return Response(BaseAssetGroupSerializer(group).data)
@action(detail=True, methods=["post"], url_path="generate-storyboard")
def generate_storyboard_action(self, request, pk=None):
project = self.get_object()
storyboard = generate_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""))
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
return Response(StoryboardVersionSerializer(storyboard).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="skip-storyboard")
@transaction.atomic
def skip_storyboard(self, request, pk=None):
project = self.get_object()
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SKIPPED
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
return Response(ProjectSerializer(project).data)
@action(detail=True, methods=["post"], url_path="submit-video-segment")
def submit_video_segment_action(self, request, pk=None):
project = self.get_object()
segment = VideoSegment.objects.get(project=project, id=request.data.get("video_segment_id"))
submit_video_segment(video_segment=segment, user=request.user, prompt=request.data.get("prompt", ""))
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
return Response(ProjectSerializer(project).data, status=status.HTTP_202_ACCEPTED)
@action(detail=True, methods=["post"], url_path="poll-video-segment")
def poll_video_segment_action(self, request, pk=None):
project = self.get_object()
segment = VideoSegment.objects.get(project=project, id=request.data.get("video_segment_id"))
version = poll_video_segment(video_segment=segment, user=request.user)
if version is None:
return Response({"status": segment.status}, status=status.HTTP_202_ACCEPTED)
return Response(VideoSegmentVersionSerializer(version).data)
@action(detail=True, methods=["post"], url_path="submit-export")
@transaction.atomic
def submit_export(self, request, pk=None):
project = self.get_object()
missing_segments = project.video_segments.filter(adopted_version__isnull=True).count()
if missing_segments:
return Response(
{"detail": f"{missing_segments} video segments are not ready for export"},
status=status.HTTP_400_BAD_REQUEST,
)
timeline, _ = Timeline.objects.get_or_create(
project=project,
defaults={"name": f"{project.name} Timeline", "duration_seconds": 60},
)
if not timeline.clips.exists():
start_ms = 0
for segment in project.video_segments.select_related("adopted_version__asset").order_by("sort_order"):
if segment.adopted_version_id:
TimelineClip.objects.create(
timeline=timeline,
asset=segment.adopted_version.asset,
sort_order=segment.sort_order,
start_ms=start_ms,
duration_ms=segment.target_duration_seconds * 1000,
)
start_ms += segment.target_duration_seconds * 1000
export_job = create_export_job(timeline=timeline, user=request.user)
run_export_job_task.delay(str(export_job.id))
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT)
stage.status = ProjectStage.Status.QUEUED
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.EXPORT
project.status = Project.Status.EXPORTING
project.save(update_fields=["current_stage", "status", "updated_at"])
return Response(ExportJobSerializer(export_job).data, status=status.HTTP_202_ACCEPTED)