完善二期清单
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.1.15 on 2026-08-18 03:07
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0009_team_price_multiplier'),
|
||||
('projects', '0007_project_soft_delete'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ScriptTemplate',
|
||||
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=128)),
|
||||
('presentation_format', models.CharField(blank=True, max_length=32)),
|
||||
('video_structure', models.CharField(blank=True, max_length=32)),
|
||||
('persona', models.CharField(blank=True, max_length=32)),
|
||||
('total_duration', models.PositiveIntegerField(default=0)),
|
||||
('outline', models.JSONField(blank=True, default=list)),
|
||||
('cta', models.TextField(blank=True)),
|
||||
('scenes', models.JSONField(blank=True, default=list)),
|
||||
('usage_count', models.PositiveIntegerField(default=0)),
|
||||
('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)),
|
||||
('source_project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='script_templates', to='projects.project')),
|
||||
('source_script', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='templates', to='projects.scriptversion')),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='accounts.team')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['team', '-created_at'], name='projects_sc_team_id_da7bc5_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('team', 'name'), name='uniq_script_template_name_per_team')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -100,6 +100,43 @@ class ScriptSegment(TimeStampedModel):
|
||||
ordering = ["sort_order", "created_at"]
|
||||
|
||||
|
||||
class ScriptTemplate(TeamOwnedModel):
|
||||
"""「套路模板」:把一版脚本里可复用的部分抽出来存团队库,换个商品重开项目时套用。
|
||||
|
||||
只存**套路**不存**文案**:outline 里每镜只留作用/时长/商品露出/说话方式,
|
||||
旧商品的口播词一律不进模板,避免换商品重跑时把上一个品牌漏出去。
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=128)
|
||||
presentation_format = models.CharField(max_length=32, blank=True) # oral|drama|vlog
|
||||
video_structure = models.CharField(max_length=32, blank=True) # pain|contrast|review|scene
|
||||
persona = models.CharField(max_length=32, blank=True) # 人物设定 key(对齐向导 WIZ_PERSONAS)
|
||||
total_duration = models.PositiveIntegerField(default=0)
|
||||
# [{index, role, duration, product_exposure, delivery}] · 镜头节奏 + 脚本结构
|
||||
outline = models.JSONField(default=list, blank=True)
|
||||
# 结尾转化方式的**写法参考**(可能带旧品牌词,套用时提示模型换成新商品)
|
||||
cta = models.TextField(blank=True)
|
||||
# 场景风格名(人物场景风格的场景侧;人物侧由 persona 承载)
|
||||
scenes = models.JSONField(default=list, blank=True)
|
||||
source_project = models.ForeignKey(
|
||||
Project, on_delete=models.SET_NULL, null=True, blank=True, related_name="script_templates"
|
||||
)
|
||||
source_script = models.ForeignKey(
|
||||
"ScriptVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="templates"
|
||||
)
|
||||
usage_count = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["team", "name"], name="uniq_script_template_name_per_team"),
|
||||
]
|
||||
indexes = [models.Index(fields=["team", "-created_at"])]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class BaseAssetGroup(TimeStampedModel):
|
||||
class Kind(models.TextChoices):
|
||||
PRODUCT = "product", "Product"
|
||||
|
||||
@@ -11,6 +11,7 @@ from .models import (
|
||||
Project,
|
||||
ProjectStage,
|
||||
ScriptSegment,
|
||||
ScriptTemplate,
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardShot,
|
||||
@@ -34,6 +35,41 @@ def _asset_preview_url(asset) -> str:
|
||||
return AssetFileSerializer().get_preview_url(primary) if primary else ""
|
||||
|
||||
|
||||
_VIDEO_SUFFIXES = (".mp4", ".mov", ".webm", ".m4v")
|
||||
|
||||
|
||||
def is_playable_video(asset) -> bool:
|
||||
"""主文件是不是真能塞进 <video> 播的视频。演示种子数据里有「导出任务标成功、挂的却是一张 PNG 海报」
|
||||
的成片,直接下发会让播放按钮打开一个放不出来的图。"""
|
||||
if asset is None:
|
||||
return False
|
||||
files = list(asset.files.all())
|
||||
primary = next((f for f in files if f.is_primary), files[0] if files else None)
|
||||
if primary is None:
|
||||
return False
|
||||
if (primary.content_type or "").startswith("video/"):
|
||||
return True
|
||||
return (primary.object_key or "").lower().endswith(_VIDEO_SUFFIXES)
|
||||
|
||||
|
||||
def _final_video_url(project) -> str:
|
||||
"""合成成片(最新一次成功的拼接导出)的可播放 URL;没合成过返回空串。
|
||||
在 Python 侧筛选而不再查库:列表页用 Prefetch 预取(见 ProjectViewSet.get_queryset),
|
||||
详情页 timeline 只有一条、export_jobs 数量极少,直接遍历不会放大查询。"""
|
||||
timeline = getattr(project, "timeline", None)
|
||||
if timeline is None:
|
||||
return ""
|
||||
succeeded = [
|
||||
job for job in timeline.export_jobs.all()
|
||||
if job.status == ExportJob.Status.SUCCEEDED and is_playable_video(job.output_asset)
|
||||
]
|
||||
if not succeeded:
|
||||
return ""
|
||||
dated = [job for job in succeeded if job.created_at is not None]
|
||||
latest = max(dated, key=lambda job: job.created_at) if dated else succeeded[-1]
|
||||
return _asset_preview_url(latest.output_asset)
|
||||
|
||||
|
||||
class ProjectStageSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ProjectStage
|
||||
@@ -321,18 +357,24 @@ class ProjectListSerializer(serializers.ModelSerializer):
|
||||
cover_preview_url = serializers.SerializerMethodField()
|
||||
script_version_count = serializers.IntegerField(read_only=True, default=0)
|
||||
video_segment_count = serializers.IntegerField(read_only=True, default=0)
|
||||
# 合成成片地址:项目列表的播放按钮据此直接播成片(没合成过为空 → 退回进流水线)
|
||||
final_video_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = [
|
||||
"id", "name", "product", "product_title", "cover_preview_url",
|
||||
"status", "current_stage", "script_version_count", "video_segment_count",
|
||||
"final_video_url",
|
||||
"is_deleted", "purged_at", "created_at", "updated_at",
|
||||
]
|
||||
|
||||
def get_cover_preview_url(self, obj) -> str:
|
||||
return _asset_preview_url(getattr(obj.product, "cover_asset", None)) if obj.product_id else ""
|
||||
|
||||
def get_final_video_url(self, obj) -> str:
|
||||
return _final_video_url(obj)
|
||||
|
||||
|
||||
class ProjectSerializer(serializers.ModelSerializer):
|
||||
stages = ProjectStageSerializer(many=True, read_only=True)
|
||||
@@ -342,6 +384,8 @@ class ProjectSerializer(serializers.ModelSerializer):
|
||||
storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True) # 过渡期保留(旧整版,新生成不再写)
|
||||
storyboard_shots = StoryboardShotSerializer(many=True, read_only=True)
|
||||
timeline = TimelineSerializer(read_only=True)
|
||||
# 合成成片地址(最新一次成功拼接):视频阶段的「播放成片 / 下载成片」直接用它
|
||||
final_video_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
@@ -363,7 +407,64 @@ class ProjectSerializer(serializers.ModelSerializer):
|
||||
"storyboard_shots",
|
||||
"video_segments",
|
||||
"timeline",
|
||||
"final_video_url",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "status", "current_stage", "failure_reason", "is_deleted", "purged_at", "created_at", "updated_at"]
|
||||
|
||||
def get_final_video_url(self, obj) -> str:
|
||||
return _final_video_url(obj)
|
||||
|
||||
|
||||
class ScriptTemplateSerializer(serializers.ModelSerializer):
|
||||
"""套路模板 · 列表与详情共用。写入只开放 name(其余字段由存模板端点从脚本抽)。"""
|
||||
|
||||
outline_text = serializers.SerializerMethodField()
|
||||
source_project_name = serializers.CharField(source="source_project.name", default="", read_only=True)
|
||||
shot_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ScriptTemplate
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"presentation_format",
|
||||
"video_structure",
|
||||
"persona",
|
||||
"total_duration",
|
||||
"outline",
|
||||
"outline_text",
|
||||
"shot_count",
|
||||
"cta",
|
||||
"scenes",
|
||||
"source_project",
|
||||
"source_project_name",
|
||||
"usage_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"presentation_format",
|
||||
"video_structure",
|
||||
"persona",
|
||||
"total_duration",
|
||||
"outline",
|
||||
"cta",
|
||||
"scenes",
|
||||
"source_project",
|
||||
"usage_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_outline_text(self, obj) -> str:
|
||||
from .services.templates import render_outline_text
|
||||
|
||||
return render_outline_text(
|
||||
{"outline": obj.outline, "cta": obj.cta}
|
||||
)
|
||||
|
||||
def get_shot_count(self, obj) -> int:
|
||||
return len(obj.outline or [])
|
||||
|
||||
@@ -442,6 +442,10 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
suffix = Path(primary.object_key).suffix or ".mp3" if primary else ".mp3"
|
||||
bgm_name = f"bgm{suffix}"
|
||||
_download_asset_primary_file(bgm_track.asset, tmp / bgm_name)
|
||||
# BGM 文件里没有音频流(上传串了张图 / 演示种子数据)→ 丢掉 BGM 照常拼片,
|
||||
# 而不是让滤镜里的 [n:a] 匹配不到流、把整条合成炸成一句看不懂的 ffmpeg 报错。
|
||||
if not _has_audio_stream(tmp / bgm_name):
|
||||
bgm_name = None
|
||||
|
||||
cues = _subtitle_cues(timeline, project, specs, starts, total)
|
||||
sub_overlays: list[tuple[str, float, float]] = []
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""套路模板:从一版脚本抽出可复用的结构,以及把模板还原成给脚本 agent 的指令。
|
||||
|
||||
设计前提:模板只承载**怎么讲**(镜数、每镜作用、节奏、商品怎么露、口播还是对白、转化写法),
|
||||
不承载**讲什么**(旧商品的口播词)。换商品重跑时才不会把上一个品牌带出去。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
ROLE_FALLBACK = "叙述"
|
||||
DELIVERY_DIALOGUE = "角色对白"
|
||||
DELIVERY_NARRATION = "口播/旁白"
|
||||
|
||||
# 脚本 agent 落库存中文标签(「短剧」),设定卡 / 模板表用 ASCII key(drama)。两边都认。
|
||||
# 人物同理:一期向导偶尔把中文写进 wizard.persona。
|
||||
_PERSONA_KEYS = {"urban", "bestie", "ceo", "reviewer", "mom", "genz"}
|
||||
_PERSONA_KEY_BY_LABEL = {
|
||||
"都市白领女性": "urban",
|
||||
"闺蜜种草": "bestie",
|
||||
"总裁亲选": "ceo",
|
||||
"专业测评师": "reviewer",
|
||||
"专业测评": "reviewer",
|
||||
"实用宝妈": "mom",
|
||||
"学生党": "genz",
|
||||
}
|
||||
|
||||
|
||||
def coerce_template_combo(presentation_format: str, video_structure: str) -> tuple[str, str]:
|
||||
from apps.ai.script_agent import combo_keys
|
||||
|
||||
return combo_keys(presentation_format or None, video_structure or None)
|
||||
|
||||
|
||||
def coerce_persona(value: str) -> str:
|
||||
raw = (value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if raw in _PERSONA_KEYS:
|
||||
return raw
|
||||
return _PERSONA_KEY_BY_LABEL.get(raw, raw)
|
||||
|
||||
|
||||
def build_template_fields(*, project, script) -> dict:
|
||||
"""从 ScriptVersion 抽出模板字段。不落库,调用方决定怎么存。"""
|
||||
meta = script.metadata or {}
|
||||
wizard = (project.metadata or {}).get("wizard") or {}
|
||||
segments = list(script.segments.all())
|
||||
|
||||
outline = []
|
||||
cta = ""
|
||||
for index, segment in enumerate(segments):
|
||||
role = (segment.role or "").strip() or ROLE_FALLBACK
|
||||
outline.append(
|
||||
{
|
||||
"index": index,
|
||||
"role": role,
|
||||
"duration": int(segment.duration_seconds or 0),
|
||||
"product_exposure": (segment.product_exposure or "").strip(),
|
||||
"delivery": DELIVERY_DIALOGUE if segment.dialogue else DELIVERY_NARRATION,
|
||||
}
|
||||
)
|
||||
if role == "CTA":
|
||||
cta = (segment.narration or "").strip()
|
||||
if not cta and segments:
|
||||
cta = (segments[-1].narration or "").strip()
|
||||
|
||||
total = meta.get("total_duration")
|
||||
if not isinstance(total, int) or total <= 0:
|
||||
total = sum(item["duration"] for item in outline)
|
||||
|
||||
raw_format = str(meta.get("presentation_format") or wizard.get("presentation_format") or "")
|
||||
raw_structure = str(meta.get("video_structure") or wizard.get("video_structure") or "")
|
||||
fmt, structure = coerce_template_combo(raw_format, raw_structure) if (raw_format or raw_structure) else ("", "")
|
||||
|
||||
return {
|
||||
"presentation_format": fmt,
|
||||
"video_structure": structure,
|
||||
"persona": coerce_persona(str(wizard.get("persona") or "")),
|
||||
"total_duration": total,
|
||||
"outline": outline,
|
||||
"cta": cta[:500],
|
||||
"scenes": [s for s in ((project.metadata or {}).get("scenes") or []) if isinstance(s, str)][:6],
|
||||
}
|
||||
|
||||
|
||||
def render_outline_text(template_fields: dict) -> str:
|
||||
"""把模板渲染成人能读、模型也能照着做的一段中文,供前端预览与生成时拼 prompt 复用。"""
|
||||
outline = template_fields.get("outline") or []
|
||||
lines = []
|
||||
for item in outline:
|
||||
parts = [f"第 {int(item.get('index', 0)) + 1} 镜", f"{item.get('duration', 0)}s", str(item.get("role") or ROLE_FALLBACK)]
|
||||
exposure = str(item.get("product_exposure") or "").strip()
|
||||
if exposure:
|
||||
parts.append(f"商品{exposure}")
|
||||
parts.append(str(item.get("delivery") or DELIVERY_NARRATION))
|
||||
lines.append(" · ".join(parts))
|
||||
body = "\n".join(lines)
|
||||
cta = str(template_fields.get("cta") or "").strip()
|
||||
if cta:
|
||||
body += f"\n结尾转化写法参考:{cta}"
|
||||
return body
|
||||
@@ -0,0 +1,8 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import ScriptTemplateViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", ScriptTemplateViewSet, basename="script-template")
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,235 @@
|
||||
"""5.1 存模板 / 5.2 换商品重跑 · 端到端契约测试。
|
||||
|
||||
关注三件事:模板只抽套路不抽文案、团队之间互不可见、新建项目时套路参数按库里的模板回填。
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.products.models import Product
|
||||
from apps.projects.models import Project, ScriptSegment, ScriptTemplate, ScriptVersion
|
||||
|
||||
|
||||
def _make_team(username: str, team_name: str):
|
||||
user = User.objects.create_user(username=username, password="pass")
|
||||
team = Team.objects.create(name=team_name, owner=user)
|
||||
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
|
||||
return user, team
|
||||
|
||||
|
||||
class ScriptTemplateTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user, self.team = _make_team("tpl-owner", "Tpl Team")
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="旧商品 · 面膜")
|
||||
self.project = Project.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="面膜 · v1",
|
||||
product=self.product,
|
||||
metadata={"wizard": {"persona": "urban"}, "scenes": ["出租屋卫生间", "梳妆台"]},
|
||||
)
|
||||
self.script = ScriptVersion.objects.create(
|
||||
project=self.project,
|
||||
title="熬夜脸急救",
|
||||
content="{}",
|
||||
source="ai",
|
||||
is_adopted=True,
|
||||
metadata={"presentation_format": "oral", "video_structure": "pain", "total_duration": 30},
|
||||
)
|
||||
specs = [
|
||||
(0, 5, "钩子", "未出现", "熬夜脸还有救吗"),
|
||||
(1, 8, "痛点", "背景陈列", "第二天开会脸垮得像纸"),
|
||||
(2, 12, "卖点", "手持展示", "这盒面膜敷 10 分钟就回弹"),
|
||||
(3, 5, "CTA", "特写", "点下方小黄车,今晚买二送一"),
|
||||
]
|
||||
for order, duration, role, exposure, narration in specs:
|
||||
ScriptSegment.objects.create(
|
||||
script_version=self.script,
|
||||
sort_order=order,
|
||||
duration_seconds=duration,
|
||||
role=role,
|
||||
product_exposure=exposure,
|
||||
narration=narration,
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
# ── 5.1 存模板 ──
|
||||
def test_save_as_template_captures_structure_not_copy(self):
|
||||
response = self.client.post(
|
||||
f"/api/projects/{self.project.id}/save-as-template/", {"name": "口播 · 痛点前置"}, format="json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
template = ScriptTemplate.objects.get(id=response.data["id"])
|
||||
self.assertEqual(template.team, self.team)
|
||||
self.assertEqual(template.presentation_format, "oral")
|
||||
self.assertEqual(template.video_structure, "pain")
|
||||
self.assertEqual(template.persona, "urban")
|
||||
self.assertEqual(template.total_duration, 30)
|
||||
self.assertEqual([item["role"] for item in template.outline], ["钩子", "痛点", "卖点", "CTA"])
|
||||
self.assertEqual([item["duration"] for item in template.outline], [5, 8, 12, 5])
|
||||
self.assertEqual(template.scenes, ["出租屋卫生间", "梳妆台"])
|
||||
# 转化写法作为参考留一句,其余镜的口播文案一律不进模板
|
||||
self.assertEqual(template.cta, "点下方小黄车,今晚买二送一")
|
||||
blob = "".join(str(item) for item in template.outline)
|
||||
for copy in ("熬夜脸还有救吗", "脸垮得像纸", "敷 10 分钟就回弹"):
|
||||
self.assertNotIn(copy, blob)
|
||||
|
||||
def test_chinese_labels_from_script_are_normalized_to_keys(self):
|
||||
"""脚本 agent 落库存的是中文标签;模板必须转成 key,否则设定卡会显示 undefined。"""
|
||||
self.script.metadata = {
|
||||
**self.script.metadata,
|
||||
"presentation_format": "短剧",
|
||||
"video_structure": "痛点解决",
|
||||
}
|
||||
self.script.save(update_fields=["metadata"])
|
||||
self.project.metadata = {"wizard": {"persona": "专业测评"}}
|
||||
self.project.save(update_fields=["metadata"])
|
||||
|
||||
created = self.client.post(
|
||||
f"/api/projects/{self.project.id}/save-as-template/", {"name": "短剧 · 痛点解决"}, format="json"
|
||||
)
|
||||
self.assertEqual(created.status_code, 201, created.data)
|
||||
template = ScriptTemplate.objects.get(id=created.data["id"])
|
||||
self.assertEqual(template.presentation_format, "drama")
|
||||
self.assertEqual(template.video_structure, "pain")
|
||||
self.assertEqual(template.persona, "reviewer")
|
||||
|
||||
new_product = Product.objects.create(team=self.team, created_by=self.user, title="新商品 · T恤")
|
||||
response = self.client.post(
|
||||
"/api/projects/",
|
||||
{
|
||||
"name": "T恤 · 同套路",
|
||||
"product": str(new_product.id),
|
||||
"metadata": {"wizard": {"template_id": created.data["id"]}},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
wizard = Project.objects.get(id=response.data["id"]).metadata["wizard"]
|
||||
self.assertEqual(wizard["presentation_format"], "drama")
|
||||
self.assertEqual(wizard["video_structure"], "pain")
|
||||
self.assertEqual(wizard["persona"], "reviewer")
|
||||
|
||||
def test_legacy_chinese_template_is_coerced_on_apply(self):
|
||||
"""已经存进库的旧模板若带着中文标签,新建项目时也要转成 key。"""
|
||||
template = ScriptTemplate.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="旧短剧模板",
|
||||
presentation_format="短剧",
|
||||
video_structure="痛点解决",
|
||||
persona="专业测评",
|
||||
total_duration=50,
|
||||
outline=[{"index": 0, "role": "钩子", "duration": 5, "product_exposure": "", "delivery": "口播/旁白"}],
|
||||
)
|
||||
new_product = Product.objects.create(team=self.team, created_by=self.user, title="新商品 · 精华")
|
||||
response = self.client.post(
|
||||
"/api/projects/",
|
||||
{
|
||||
"name": "精华 · 同套路",
|
||||
"product": str(new_product.id),
|
||||
"metadata": {"wizard": {"template_id": str(template.id)}},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
wizard = Project.objects.get(id=response.data["id"]).metadata["wizard"]
|
||||
self.assertEqual(wizard["presentation_format"], "drama")
|
||||
self.assertEqual(wizard["video_structure"], "pain")
|
||||
self.assertEqual(wizard["persona"], "reviewer")
|
||||
self.assertEqual(wizard["total_duration"], 50)
|
||||
|
||||
def test_outline_text_is_readable(self):
|
||||
self.client.post(f"/api/projects/{self.project.id}/save-as-template/", {"name": "套路 A"}, format="json")
|
||||
listed = self.client.get("/api/script-templates/")
|
||||
|
||||
text = listed.data["results"][0]["outline_text"]
|
||||
self.assertIn("第 1 镜 · 5s · 钩子", text)
|
||||
self.assertIn("商品手持展示", text)
|
||||
self.assertIn("结尾转化写法参考:", text)
|
||||
self.assertEqual(listed.data["results"][0]["shot_count"], 4)
|
||||
|
||||
def test_save_rejects_short_name_duplicate_and_scriptless_project(self):
|
||||
short = self.client.post(f"/api/projects/{self.project.id}/save-as-template/", {"name": "A"}, format="json")
|
||||
self.assertEqual(short.status_code, 400)
|
||||
|
||||
self.client.post(f"/api/projects/{self.project.id}/save-as-template/", {"name": "套路 A"}, format="json")
|
||||
dupe = self.client.post(f"/api/projects/{self.project.id}/save-as-template/", {"name": "套路 A"}, format="json")
|
||||
self.assertEqual(dupe.status_code, 400)
|
||||
self.assertEqual(ScriptTemplate.objects.filter(team=self.team).count(), 1)
|
||||
|
||||
empty = Project.objects.create(team=self.team, created_by=self.user, name="空项目", product=self.product)
|
||||
blank = self.client.post(f"/api/projects/{empty.id}/save-as-template/", {"name": "套路 B"}, format="json")
|
||||
self.assertEqual(blank.status_code, 400)
|
||||
|
||||
def test_templates_are_team_scoped(self):
|
||||
self.client.post(f"/api/projects/{self.project.id}/save-as-template/", {"name": "套路 A"}, format="json")
|
||||
other_user, _ = _make_team("outsider", "Other Team")
|
||||
other = APIClient()
|
||||
other.force_authenticate(other_user)
|
||||
|
||||
self.assertEqual(other.get("/api/script-templates/").data["count"], 0)
|
||||
|
||||
# ── 5.2 换商品重跑 ──
|
||||
def test_new_project_with_template_backfills_wizard(self):
|
||||
created = self.client.post(
|
||||
f"/api/projects/{self.project.id}/save-as-template/", {"name": "口播 · 痛点前置"}, format="json"
|
||||
)
|
||||
template_id = created.data["id"]
|
||||
new_product = Product.objects.create(team=self.team, created_by=self.user, title="新商品 · 精华水")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/projects/",
|
||||
{
|
||||
"name": "精华水 · v1",
|
||||
"product": str(new_product.id),
|
||||
"metadata": {"wizard": {"template_id": template_id, "selling_point_ids": []}},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
project = Project.objects.get(id=response.data["id"])
|
||||
wizard = project.metadata["wizard"]
|
||||
self.assertEqual(wizard["template_id"], template_id)
|
||||
self.assertEqual(wizard["template_name"], "口播 · 痛点前置")
|
||||
self.assertEqual(wizard["presentation_format"], "oral")
|
||||
self.assertEqual(wizard["video_structure"], "pain")
|
||||
self.assertEqual(wizard["persona"], "urban")
|
||||
self.assertEqual(wizard["total_duration"], 30)
|
||||
self.assertIn("第 4 镜 · 5s · CTA", wizard["template_outline"])
|
||||
# 新项目仍走完整流水线初始化,不是「复制旧项目」
|
||||
self.assertEqual(project.stages.count(), 5)
|
||||
self.assertEqual(project.script_versions.count(), 0)
|
||||
self.assertEqual(ScriptTemplate.objects.get(id=template_id).usage_count, 1)
|
||||
|
||||
def test_unknown_or_cross_team_template_id_is_dropped_not_fatal(self):
|
||||
other_user, other_team = _make_team("stranger", "Stranger Team")
|
||||
foreign = ScriptTemplate.objects.create(team=other_team, created_by=other_user, name="别人的套路")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/projects/",
|
||||
{"name": "借用", "product": str(self.product.id), "metadata": {"wizard": {"template_id": str(foreign.id)}}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
project = Project.objects.get(id=response.data["id"])
|
||||
self.assertNotIn("template_id", project.metadata["wizard"])
|
||||
self.assertNotIn("template_outline", project.metadata["wizard"])
|
||||
self.assertEqual(ScriptTemplate.objects.get(id=foreign.id).usage_count, 0)
|
||||
|
||||
def test_rename_and_delete_template(self):
|
||||
created = self.client.post(f"/api/projects/{self.project.id}/save-as-template/", {"name": "套路 A"}, format="json")
|
||||
template_id = created.data["id"]
|
||||
|
||||
renamed = self.client.patch(f"/api/script-templates/{template_id}/", {"name": "套路 A2"}, format="json")
|
||||
self.assertEqual(renamed.status_code, 200, renamed.data)
|
||||
self.assertEqual(ScriptTemplate.objects.get(id=template_id).name, "套路 A2")
|
||||
|
||||
removed = self.client.delete(f"/api/script-templates/{template_id}/")
|
||||
self.assertEqual(removed.status_code, 204)
|
||||
self.assertFalse(ScriptTemplate.objects.filter(id=template_id).exists())
|
||||
@@ -9,6 +9,7 @@ from apps.assets.models import Asset, AssetFile
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
from apps.products.models import Product
|
||||
from apps.projects.models import (
|
||||
ExportJob,
|
||||
Project,
|
||||
ProjectStage,
|
||||
ScriptSegment,
|
||||
@@ -981,6 +982,176 @@ class ExportClipsTests(TestCase):
|
||||
self.assertEqual(res.status_code, 400)
|
||||
|
||||
|
||||
class MergeFinalVideoTests(TestCase):
|
||||
"""合成成片(submit-export):按当前采用片段重建时间线 · 在跑的任务复用 · 成片地址下发。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="mrg", password="p")
|
||||
self.team = Team.objects.create(name="MrgT", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="P")
|
||||
self.project = Project.objects.create(
|
||||
team=self.team, name="项目甲", product=self.product, created_by=self.user,
|
||||
current_stage=ProjectStage.Stage.VIDEO, status=Project.Status.COMPLETED,
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
def _video_asset(self, tag):
|
||||
asset = Asset.objects.create(
|
||||
team=self.team, name=f"clip-{tag}", asset_type="video", source="ai_generated", category="video_clip",
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset, object_key=f"{tag}.mp4", bucket="b", content_type="video/mp4",
|
||||
is_primary=True, preview_url=f"http://x/{tag}.mp4",
|
||||
)
|
||||
return asset
|
||||
|
||||
def _segment(self, order, tag):
|
||||
asset = self._video_asset(tag)
|
||||
seg = VideoSegment.objects.create(
|
||||
project=self.project, sort_order=order, target_duration_seconds=10,
|
||||
status=VideoSegment.Status.SUCCEEDED,
|
||||
)
|
||||
version = VideoSegmentVersion.objects.create(video_segment=seg, asset=asset, is_adopted=True)
|
||||
seg.adopted_version = version
|
||||
seg.save(update_fields=["adopted_version"])
|
||||
return seg, asset
|
||||
|
||||
def _submit(self):
|
||||
with patch("apps.projects.views.run_export_job_in_thread") as runner:
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
res = self.client.post(f"/api/projects/{self.project.id}/submit-export/")
|
||||
return res, runner
|
||||
|
||||
def test_submit_builds_timeline_and_keeps_video_stage(self):
|
||||
self._segment(0, "a")
|
||||
self._segment(1, "b")
|
||||
res, runner = self._submit()
|
||||
self.assertEqual(res.status_code, 202)
|
||||
runner.assert_called_once()
|
||||
timeline = Timeline.objects.get(project=self.project)
|
||||
clips = list(timeline.clips.order_by("sort_order"))
|
||||
self.assertEqual(len(clips), 2)
|
||||
self.assertEqual([c.start_ms for c in clips], [0, 10000])
|
||||
# V1 视频是末阶段:合成不能把项目推到被雪藏的第 5 阶段(否则流水线页落到看不见的页面)
|
||||
self.project.refresh_from_db()
|
||||
self.assertEqual(self.project.current_stage, ProjectStage.Stage.VIDEO)
|
||||
|
||||
def test_submit_reuses_running_job(self):
|
||||
self._segment(0, "a")
|
||||
first, _ = self._submit()
|
||||
job_id = first.data["id"]
|
||||
ExportJob.objects.filter(id=job_id).update(status=ExportJob.Status.RUNNING)
|
||||
second, runner = self._submit()
|
||||
self.assertEqual(second.status_code, 202)
|
||||
self.assertEqual(str(second.data["id"]), str(job_id))
|
||||
self.assertEqual(ExportJob.objects.filter(timeline__project=self.project).count(), 1)
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_resubmit_rebuilds_clips_after_segment_rerun(self):
|
||||
seg, _old = self._segment(0, "old")
|
||||
self._submit()
|
||||
# 上一次合成已收工(否则「重新合成」会复用在跑的任务,见 test_submit_reuses_running_job)
|
||||
ExportJob.objects.filter(timeline__project=self.project).update(status=ExportJob.Status.SUCCEEDED)
|
||||
new_asset = self._video_asset("new")
|
||||
version = VideoSegmentVersion.objects.create(video_segment=seg, asset=new_asset, is_adopted=True)
|
||||
seg.adopted_version = version
|
||||
seg.save(update_fields=["adopted_version"])
|
||||
self._submit()
|
||||
clips = list(Timeline.objects.get(project=self.project).clips.all())
|
||||
self.assertEqual([c.asset_id for c in clips], [new_asset.id])
|
||||
|
||||
def test_final_video_url_exposed_after_success(self):
|
||||
self._segment(0, "a")
|
||||
self._submit()
|
||||
timeline = Timeline.objects.get(project=self.project)
|
||||
final = Asset.objects.create(
|
||||
team=self.team, name="final.mp4", asset_type="video", source="exported", category="final_video",
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=final, object_key="final.mp4", bucket="b", content_type="video/mp4",
|
||||
is_primary=True, preview_url="http://x/final.mp4",
|
||||
)
|
||||
job = timeline.export_jobs.order_by("-created_at").first()
|
||||
job.status = "succeeded"
|
||||
job.output_asset = final
|
||||
job.progress = 100
|
||||
job.save(update_fields=["status", "output_asset", "progress"])
|
||||
|
||||
detail = self.client.get(f"/api/projects/{self.project.id}/")
|
||||
self.assertEqual(detail.data["final_video_url"], "http://x/final.mp4")
|
||||
listed = self.client.get("/api/projects/")
|
||||
row = next(p for p in listed.data["results"] if str(p["id"]) == str(self.project.id))
|
||||
self.assertEqual(row["final_video_url"], "http://x/final.mp4")
|
||||
|
||||
def test_final_video_url_empty_before_merge(self):
|
||||
self._segment(0, "a")
|
||||
detail = self.client.get(f"/api/projects/{self.project.id}/")
|
||||
self.assertEqual(detail.data["final_video_url"], "")
|
||||
|
||||
def test_merge_drops_bgm_without_audio_stream(self):
|
||||
"""BGM 文件里没有音频流(上传串了张图 / 演示种子数据)→ 丢掉 BGM 照常拼片,
|
||||
不能让滤镜里的 [n:a] 匹配不到流,把整条合成炸掉。"""
|
||||
from pathlib import Path
|
||||
|
||||
from apps.projects.models import BgmTrack
|
||||
from apps.projects.services import export as export_mod
|
||||
|
||||
seg, _asset = self._segment(0, "a")
|
||||
timeline = Timeline.objects.create(project=self.project, name="T", duration_seconds=10)
|
||||
TimelineClip.objects.create(
|
||||
timeline=timeline, asset=seg.adopted_version.asset, sort_order=0, start_ms=0, duration_ms=10000,
|
||||
)
|
||||
fake_bgm = Asset.objects.create(team=self.team, name="BGM", asset_type="audio", source="upload")
|
||||
AssetFile.objects.create(
|
||||
asset=fake_bgm, object_key="cover.png", bucket="b", content_type="image/png", is_primary=True,
|
||||
)
|
||||
BgmTrack.objects.create(timeline=timeline, asset=fake_bgm, volume=60, start_ms=0)
|
||||
job = ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED)
|
||||
|
||||
commands = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
commands.append(cmd)
|
||||
# ffprobe:一律回「无音频流 / 无帧率」,模拟素材探测不出音轨
|
||||
if cmd[0] == "ffprobe":
|
||||
return type("P", (), {"returncode": 0, "stdout": b"", "stderr": b""})()
|
||||
(Path(kwargs["cwd"]) / "output.mp4").write_bytes(b"MP4")
|
||||
return type("P", (), {"returncode": 0, "stdout": b"", "stderr": b""})()
|
||||
|
||||
with patch.object(export_mod, "_download_asset_primary_file", lambda asset, path: path.write_bytes(b"X")), \
|
||||
patch.object(export_mod.subprocess, "run", side_effect=fake_run), \
|
||||
patch.object(export_mod, "TosStorage") as Tos:
|
||||
Tos.return_value.upload_fileobj.return_value = type(
|
||||
"S", (), {"object_key": "exports/x.mp4", "bucket": "b", "content_type": "video/mp4", "size_bytes": 3},
|
||||
)()
|
||||
export_mod.run_export_job(str(job.id))
|
||||
|
||||
job.refresh_from_db()
|
||||
self.assertEqual(job.status, ExportJob.Status.SUCCEEDED)
|
||||
ffmpeg_cmd = next(c for c in commands if c[0] == "ffmpeg")
|
||||
self.assertNotIn("-stream_loop", ffmpeg_cmd)
|
||||
self.assertNotIn("[abgm]", ffmpeg_cmd[ffmpeg_cmd.index("-filter_complex") + 1])
|
||||
|
||||
def test_final_video_url_skips_non_video_output(self):
|
||||
"""演示种子数据里有「导出成功但挂了张 PNG 海报」的成片:不能下发,否则播放键打开放不出的图。"""
|
||||
self._segment(0, "a")
|
||||
self._submit()
|
||||
poster = Asset.objects.create(
|
||||
team=self.team, name="成片海报", asset_type="video", source="exported", category="final_video",
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=poster, object_key="poster.png", bucket="b", content_type="image/png",
|
||||
is_primary=True, preview_url="http://x/poster.png",
|
||||
)
|
||||
ExportJob.objects.filter(timeline__project=self.project).update(
|
||||
status=ExportJob.Status.SUCCEEDED, output_asset=poster,
|
||||
)
|
||||
detail = self.client.get(f"/api/projects/{self.project.id}/")
|
||||
self.assertEqual(detail.data["final_video_url"], "")
|
||||
|
||||
|
||||
class BaseAssetAdoptDeleteTests(TestCase):
|
||||
"""显性采用/未采用 + 删除角色组。"""
|
||||
|
||||
|
||||
@@ -6,4 +6,3 @@ router = DefaultRouter()
|
||||
router.register("", ProjectViewSet, basename="project")
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count, F, Prefetch, Q
|
||||
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.renderers import BaseRenderer
|
||||
from rest_framework.response import Response
|
||||
@@ -18,7 +19,7 @@ from apps.ai.providers import TtsNotConfigured
|
||||
from apps.ai.script_agent import (
|
||||
SEGMENT_DURATION_MAX,
|
||||
SEGMENT_DURATION_MIN,
|
||||
coerce_combo,
|
||||
combo_keys,
|
||||
coerce_total_duration,
|
||||
stream_script_agent,
|
||||
)
|
||||
@@ -54,6 +55,7 @@ from .models import (
|
||||
Project,
|
||||
ProjectStage,
|
||||
ScriptSegment,
|
||||
ScriptTemplate,
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardShot,
|
||||
@@ -70,13 +72,16 @@ from .serializers import (
|
||||
ExportJobSerializer,
|
||||
ProjectListSerializer,
|
||||
ProjectSerializer,
|
||||
ScriptTemplateSerializer,
|
||||
ScriptVersionSerializer,
|
||||
StoryboardVersionSerializer,
|
||||
VideoSegmentVersionSerializer,
|
||||
is_playable_video,
|
||||
)
|
||||
from .services.export import run_export_job_in_thread
|
||||
from .services.pipeline import STAGE_ORDER
|
||||
from .services.script_import import ScriptFileError, extract_script_text
|
||||
from .services.templates import build_template_fields, coerce_persona, coerce_template_combo, render_outline_text
|
||||
from .tasks import poll_video_segment_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -315,8 +320,18 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# ——原列表把每个项目的 阶段/片段/故事板/时间线/资产文件全拉出,20 个项目实测 ~2s。
|
||||
if self.action == "list":
|
||||
qs = (
|
||||
Project.objects.select_related("product", "product__cover_asset")
|
||||
.prefetch_related("product__cover_asset__files")
|
||||
Project.objects.select_related("product", "product__cover_asset", "timeline")
|
||||
.prefetch_related(
|
||||
"product__cover_asset__files",
|
||||
# 成片地址(final_video_url)只需要「成功的导出任务」,预取到位后列表不再逐项目查库
|
||||
Prefetch(
|
||||
"timeline__export_jobs",
|
||||
queryset=ExportJob.objects.filter(status=ExportJob.Status.SUCCEEDED)
|
||||
.select_related("output_asset")
|
||||
.prefetch_related("output_asset__files")
|
||||
.order_by("-created_at"),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
script_version_count=Count("script_versions", distinct=True),
|
||||
video_segment_count=Count("video_segments", distinct=True),
|
||||
@@ -423,6 +438,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
@transaction.atomic
|
||||
def perform_create(self, serializer):
|
||||
project = serializer.save(team=self.get_team(), created_by=self.request.user)
|
||||
self._apply_wizard_template(project)
|
||||
for stage in STAGE_ORDER:
|
||||
ProjectStage.objects.create(project=project, stage=stage)
|
||||
# 先铺 4 段占位;真实段数与每段时长在采用脚本时由 _sync_video_segments_to_script 收口
|
||||
@@ -431,6 +447,74 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX
|
||||
)
|
||||
|
||||
def _apply_wizard_template(self, project) -> None:
|
||||
"""新建向导选了套路模板 → 后端按模板真值回填 metadata.wizard,并计一次使用。
|
||||
前端只传 template_id,套路参数一律以库里的模板为准,避免前后端两份真相。"""
|
||||
wizard = (project.metadata or {}).get("wizard") or {}
|
||||
template_id = wizard.get("template_id")
|
||||
if not template_id:
|
||||
return
|
||||
template = ScriptTemplate.objects.filter(id=template_id, team=project.team).first()
|
||||
if template is None:
|
||||
# 模板被删/跨团队 → 静默丢弃这个字段,项目照常建,不阻断创建
|
||||
wizard.pop("template_id", None)
|
||||
project.metadata = {**(project.metadata or {}), "wizard": wizard}
|
||||
project.save(update_fields=["metadata", "updated_at"])
|
||||
return
|
||||
wizard.update(
|
||||
{
|
||||
"template_id": str(template.id),
|
||||
"template_name": template.name,
|
||||
"template_outline": render_outline_text({"outline": template.outline, "cta": template.cta}),
|
||||
}
|
||||
)
|
||||
# 套路参数只在模板真有值时覆盖,空值不要把设定卡的智能推荐顶掉。
|
||||
# 旧模板可能存了中文标签(「短剧」),这里归一成 key 再写入 wizard。
|
||||
raw_format = getattr(template, "presentation_format", "") or ""
|
||||
raw_structure = getattr(template, "video_structure", "") or ""
|
||||
if raw_format or raw_structure:
|
||||
fmt, structure = coerce_template_combo(raw_format, raw_structure)
|
||||
wizard["presentation_format"] = fmt
|
||||
wizard["video_structure"] = structure
|
||||
persona = coerce_persona(getattr(template, "persona", "") or "")
|
||||
if persona:
|
||||
wizard["persona"] = persona
|
||||
if template.total_duration:
|
||||
wizard["total_duration"] = template.total_duration
|
||||
project.metadata = {**(project.metadata or {}), "wizard": wizard}
|
||||
project.save(update_fields=["metadata", "updated_at"])
|
||||
ScriptTemplate.objects.filter(id=template.id).update(usage_count=F("usage_count") + 1)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="save-as-template")
|
||||
def save_as_template(self, request, pk=None):
|
||||
"""把当前脚本存成团队套路模板(5.1)。只抽套路不抽文案,见 services/templates.py。"""
|
||||
project = self.get_object()
|
||||
name = str(request.data.get("name") or "").strip()
|
||||
if len(name) < 2:
|
||||
return Response({"detail": "模板名至少 2 个字"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
script_id = request.data.get("script_version_id")
|
||||
scripts = ScriptVersion.objects.filter(project=project).prefetch_related("segments")
|
||||
script = (
|
||||
scripts.filter(id=script_id).first()
|
||||
if script_id
|
||||
else (scripts.filter(is_adopted=True).order_by("-created_at").first() or scripts.order_by("-created_at").first())
|
||||
)
|
||||
if script is None:
|
||||
return Response({"detail": "这个项目还没有脚本,先生成一版再存模板"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not script.segments.exists():
|
||||
return Response({"detail": "这版脚本没有分镜,存不出套路"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if ScriptTemplate.objects.filter(team=project.team, name=name).exists():
|
||||
return Response({"detail": "模板名已存在,换一个"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
template = ScriptTemplate.objects.create(
|
||||
team=project.team,
|
||||
created_by=request.user,
|
||||
name=name,
|
||||
source_project=project,
|
||||
source_script=script,
|
||||
**build_template_fields(project=project, script=script),
|
||||
)
|
||||
return Response(ScriptTemplateSerializer(template).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer])
|
||||
def script_agent_stream(self, request, pk=None):
|
||||
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。
|
||||
@@ -446,7 +530,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
|
||||
# 非法值一律由 agent 侧 coerce 兜底(夹区间/回落默认),这里不做 400,避免生成被参数噪声打断
|
||||
total_duration = coerce_total_duration(request.data.get("total_duration"))
|
||||
presentation_format, video_structure = coerce_combo(
|
||||
presentation_format, video_structure = combo_keys(
|
||||
request.data.get("presentation_format"),
|
||||
request.data.get("video_structure"),
|
||||
)
|
||||
@@ -1135,6 +1219,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
@action(detail=True, methods=["post"], url_path="submit-export")
|
||||
@transaction.atomic
|
||||
def submit_export(self, request, pk=None):
|
||||
"""把已出片的各场视频用 ffmpeg 拼成一条成片。HTTP 秒回,拼接在后台线程跑,
|
||||
前端轮询 poll-export 取进度 / 成片地址。"""
|
||||
project = self.get_object()
|
||||
missing_segments = project.video_segments.filter(adopted_version__isnull=True).count()
|
||||
if missing_segments:
|
||||
@@ -1146,6 +1232,27 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
project=project,
|
||||
defaults={"name": f"{project.name} Timeline", "duration_seconds": 60},
|
||||
)
|
||||
# 清理僵尸任务:后台线程跑导出时若 Pod 被重启/OOM 杀掉,旧任务会永远停在 RUNNING、
|
||||
# 前端无限轮询。新建前把本时间线超过 5 分钟仍未结束的旧任务标失败,避免界面一直转圈。
|
||||
from datetime import timedelta
|
||||
|
||||
ExportJob.objects.filter(
|
||||
timeline=timeline,
|
||||
status__in=[ExportJob.Status.QUEUED, ExportJob.Status.RUNNING],
|
||||
updated_at__lt=timezone.now() - timedelta(minutes=5),
|
||||
).update(status=ExportJob.Status.FAILED, error_message="任务中断(服务重启或超时),已自动失败,请重新导出")
|
||||
# 防重复合成:仍在跑的任务直接复用,不再新建 —— 刷新页面 / 连点两次 / 开两个标签页
|
||||
# 都只会有一条 ffmpeg 在跑(拼接吃 CPU,跑两遍纯属浪费,且两条任务会互相覆盖成片)。
|
||||
inflight = ExportJob.objects.filter(
|
||||
timeline=timeline,
|
||||
status__in=[ExportJob.Status.QUEUED, ExportJob.Status.RUNNING],
|
||||
).order_by("-created_at").first()
|
||||
if inflight is not None:
|
||||
return Response(ExportJobSerializer(inflight).data, status=status.HTTP_202_ACCEPTED)
|
||||
if VIDEO_IS_FINAL_STAGE:
|
||||
# V1 没有剪辑台(第 5 阶段雪藏),时间线纯由后端按「当前采用的片段」生成。
|
||||
# 每次合成都重建:某场重跑/换采用版之后再合成,成片必须跟着换,不能沿用上次的旧片段。
|
||||
timeline.clips.all().delete()
|
||||
if not timeline.clips.exists():
|
||||
start_ms = 0
|
||||
for segment in project.video_segments.select_related("adopted_version__asset").order_by("sort_order"):
|
||||
@@ -1158,22 +1265,17 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
duration_ms=segment.target_duration_seconds * 1000,
|
||||
)
|
||||
start_ms += segment.target_duration_seconds * 1000
|
||||
# 清理僵尸任务:后台线程跑导出时若 Pod 被重启/OOM 杀掉,旧任务会永远停在 RUNNING、
|
||||
# 前端无限轮询。新建前把本时间线超过 5 分钟仍未结束的旧任务标失败,避免界面一直转圈。
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
ExportJob.objects.filter(
|
||||
timeline=timeline,
|
||||
status__in=[ExportJob.Status.QUEUED, ExportJob.Status.RUNNING],
|
||||
updated_at__lt=timezone.now() - timedelta(minutes=5),
|
||||
).update(status=ExportJob.Status.FAILED, error_message="任务中断(服务重启或超时),已自动失败,请重新导出")
|
||||
export_job = create_export_job(timeline=timeline, user=request.user)
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT)
|
||||
stage.status = ProjectStage.Status.RUNNING
|
||||
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"])
|
||||
if not VIDEO_IS_FINAL_STAGE:
|
||||
# V1 视频是末阶段:一旦把 current_stage 推到 export,流水线页会落到被雪藏的第 5 阶段
|
||||
# (前端 STAGE_STEPS 只有 4 格)→ 用户点完合成就掉进一个看不见的页面。故 V1 留在视频阶段,
|
||||
# 合成进度由 poll-export 内联展示。V2 恢复剪辑台后此处照旧推进。
|
||||
project.current_stage = ProjectStage.Stage.EXPORT
|
||||
project.status = Project.Status.EXPORTING
|
||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
# 后台线程跑真实 ffmpeg 拼接(无需 Celery worker);前端轮询 poll-export 取进度/成片。
|
||||
transaction.on_commit(lambda: run_export_job_in_thread(str(export_job.id)))
|
||||
return Response(ExportJobSerializer(export_job).data, status=status.HTTP_202_ACCEPTED)
|
||||
@@ -1189,7 +1291,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
output_url = ""
|
||||
output = export_job.output_asset
|
||||
if output is not None:
|
||||
# 必须是真能播的视频文件才下发:演示种子数据里有「导出标成功、挂的却是张 PNG 海报」的成片,
|
||||
# 直接下发会让「播放成片」打开一个放不出来的图(与 serializers._final_video_url 同一把闸)。
|
||||
if output is not None and is_playable_video(output):
|
||||
primary = output.files.filter(is_primary=True).first() or output.files.first()
|
||||
if primary is not None:
|
||||
output_url = AssetFileSerializer().get_preview_url(primary)
|
||||
@@ -1412,3 +1516,19 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
timeline.metadata = metadata
|
||||
timeline.save(update_fields=["metadata", "duration_seconds", "updated_at"])
|
||||
return Response(ProjectSerializer(self.get_object()).data)
|
||||
|
||||
|
||||
class ScriptTemplateViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""套路模板库(团队隔离)。建模板走 projects/{id}/save-as-template/,这里只做 列 / 改名 / 删。"""
|
||||
|
||||
queryset = ScriptTemplate.objects.select_related("source_project").all()
|
||||
serializer_class = ScriptTemplateSerializer
|
||||
http_method_names = ["get", "patch", "delete", "head", "options"]
|
||||
|
||||
def perform_update(self, serializer):
|
||||
name = str(serializer.validated_data.get("name") or "").strip()
|
||||
if len(name) < 2:
|
||||
raise ValidationError({"detail": "模板名至少 2 个字"})
|
||||
if ScriptTemplate.objects.filter(team=self.get_team(), name=name).exclude(id=serializer.instance.id).exists():
|
||||
raise ValidationError({"detail": "模板名已存在,换一个"})
|
||||
serializer.save(name=name)
|
||||
|
||||
Reference in New Issue
Block a user