feat: add AirShelf core implementation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import ProjectViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", ProjectViewSet, basename="project")
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user