feat(core/backend): pipeline continuity + threaded ffmpeg burn-in export + upload/save-timeline

Video pipeline (script→assets→storyboard→video→stitch):
- robust split_script_into_segments (4 non-empty scenes), scene-aware storyboard/video prompts
- link VideoSegment→ScriptSegment + storyboard-frame reference image (graceful text fallback)
- idempotent poll_video_segment (no double-charge on repeated polling)
- threaded export (no Celery worker needed) + poll-export endpoint
- run_export_job rewritten to filter_complex: per-clip trim, xfade transitions,
  subtitle burn-in (Pillow PNG overlay; this ffmpeg lacks libass), BGM mix
- upload-video-segment / upload-bgm / save-timeline endpoints
- serializers embed asset preview URLs (beat assets pagination); Pillow added to requirements

Also includes prior uncommitted backend work: account preferences/sessions,
billing trend, product/asset endpoints, accounts 0002 migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-09 14:46:16 +08:00
co-authored by Claude Opus 4.8
parent 8959946241
commit 92826dec14
13 changed files with 1229 additions and 97 deletions
@@ -0,0 +1,94 @@
# Generated by Django 5.1.15 on 2026-06-08 09:48
import apps.accounts.models
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="LoginSession",
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)),
("user_agent", models.CharField(blank=True, max_length=400)),
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
("last_seen_at", models.DateTimeField(auto_now=True)),
("revoked_at", models.DateTimeField(blank=True, null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="login_sessions",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-last_seen_at"],
},
),
migrations.CreateModel(
name="UserPreference",
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)),
(
"notify",
models.JSONField(
blank=True, default=apps.accounts.models._default_notify
),
),
("two_factor_enabled", models.BooleanField(default=False)),
(
"creation_defaults",
models.JSONField(
blank=True, default=apps.accounts.models._default_creation
),
),
(
"display",
models.JSONField(
blank=True, default=apps.accounts.models._default_display
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="preference",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
]
+38
View File
@@ -34,6 +34,44 @@ class Team(TimeStampedModel):
return self.name
def _default_notify() -> dict:
return {"n-export": True, "n-fail": True, "n-quota": True, "n-login": True}
def _default_creation() -> dict:
return {"template": "pain", "duration": "60", "subtitle": "big-variety", "bgm": "kapian", "transition": "fade"}
def _default_display() -> dict:
return {"appearance": "system", "language": "zh", "density": "standard"}
class UserPreference(TimeStampedModel):
"""用户设置:通知策略 / 两步验证 / 创作默认 / 显示偏好。服务端持久化(替代前端 localStorage)。"""
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="preference")
notify = models.JSONField(default=_default_notify, blank=True)
two_factor_enabled = models.BooleanField(default=False)
creation_defaults = models.JSONField(default=_default_creation, blank=True)
display = models.JSONField(default=_default_display, blank=True)
def __str__(self) -> str:
return f"prefs/{self.user}"
class LoginSession(TimeStampedModel):
"""登录会话记录:每次登录写一条(设备 UA / IP / 时间),供设置页「在用设备」展示与下线。"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="login_sessions")
user_agent = models.CharField(max_length=400, blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
last_seen_at = models.DateTimeField(auto_now=True)
revoked_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-last_seen_at"]
class TeamMember(TimeStampedModel):
class Role(models.TextChoices):
OWNER = "owner", "Owner"
+21 -1
View File
@@ -2,7 +2,7 @@ from rest_framework import serializers
from apps.billing.models import CreditAccount
from .models import Team, TeamMember, User
from .models import LoginSession, Team, TeamMember, User, UserPreference
class UserSerializer(serializers.ModelSerializer):
@@ -12,6 +12,26 @@ class UserSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "status"]
class UserPreferenceSerializer(serializers.ModelSerializer):
class Meta:
model = UserPreference
fields = ["notify", "two_factor_enabled", "creation_defaults", "display", "updated_at"]
read_only_fields = ["updated_at"]
class LoginSessionSerializer(serializers.ModelSerializer):
is_current = serializers.SerializerMethodField()
class Meta:
model = LoginSession
fields = ["id", "user_agent", "ip_address", "last_seen_at", "created_at", "is_current"]
read_only_fields = fields
def get_is_current(self, obj) -> bool:
ctx = self.context or {}
return bool(obj.ip_address and obj.ip_address == ctx.get("current_ip") and obj.user_agent == ctx.get("current_ua"))
class TeamSerializer(serializers.ModelSerializer):
class Meta:
model = Team
+8
View File
@@ -3,9 +3,13 @@ from django.urls import path
from .views import (
change_password,
login,
login_sessions,
logout,
me,
preferences,
register,
revoke_login_session,
revoke_other_sessions,
team_member_detail,
team_member_password,
team_members,
@@ -20,6 +24,10 @@ urlpatterns = [
path("me/", me, name="auth-me"),
path("me/password/", change_password, name="auth-change-password"),
path("me/avatar/", update_avatar, name="auth-avatar"),
path("me/preferences/", preferences, name="auth-preferences"),
path("me/sessions/", login_sessions, name="auth-sessions"),
path("me/sessions/revoke-others/", revoke_other_sessions, name="auth-sessions-revoke-others"),
path("me/sessions/<uuid:session_id>/revoke/", revoke_login_session, name="auth-session-revoke"),
path("team/members/", team_members, name="team-members"),
path("team/members/<uuid:member_id>/", team_member_detail, name="team-member-detail"),
path("team/members/<uuid:member_id>/password/", team_member_password, name="team-member-password"),
+88 -3
View File
@@ -12,8 +12,16 @@ from rest_framework.response import Response
from apps.common.api import get_current_team
from .models import TeamMember, User
from .serializers import LoginSerializer, RegisterSerializer, TeamMemberSerializer, TeamSerializer, UserSerializer
from .models import LoginSession, TeamMember, User, UserPreference
from .serializers import (
LoginSerializer,
LoginSessionSerializer,
RegisterSerializer,
TeamMemberSerializer,
TeamSerializer,
UserPreferenceSerializer,
UserSerializer,
)
def auth_payload(user, team, token):
@@ -24,6 +32,25 @@ def auth_payload(user, team, token):
}
def _client_ip(request):
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.META.get("REMOTE_ADDR") or None
def record_login_session(request, user):
"""登录成功后记录一条会话(设备 UA / IP),供设置页「在用设备」展示。"""
try:
LoginSession.objects.create(
user=user,
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:400],
ip_address=_client_ip(request),
)
except Exception: # noqa: BLE001 — 会话记录失败不应阻断登录
pass
@api_view(["POST"])
@permission_classes([])
def register(request):
@@ -31,6 +58,7 @@ def register(request):
serializer.is_valid(raise_exception=True)
data = serializer.save()
token, _ = Token.objects.get_or_create(user=data["user"])
record_login_session(request, data["user"])
return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED)
@@ -48,6 +76,7 @@ def login(request):
return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST)
team = get_current_team(user)
token, _ = Token.objects.get_or_create(user=user)
record_login_session(request, user)
return Response(auth_payload(user, team, token))
@@ -97,12 +126,19 @@ def change_password(request):
return Response({"token": token.key})
@api_view(["POST"])
@api_view(["POST", "DELETE"])
@parser_classes([MultiPartParser, FormParser])
@permission_classes([IsAuthenticated])
def update_avatar(request):
from apps.assets.storage import TosStorage
# DELETE = 恢复默认头像(清空 avatar_url,前端回退到首字母占位)
if request.method == "DELETE":
user = request.user
user.avatar_url = ""
user.save(update_fields=["avatar_url"])
return Response(UserSerializer(user).data)
upload = request.FILES.get("file")
if upload is None:
return Response({"detail": "no file"}, status=status.HTTP_400_BAD_REQUEST)
@@ -223,3 +259,52 @@ def team_member_password(request, member_id):
member.user.save(update_fields=["password"])
Token.objects.filter(user=member.user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(["GET", "PUT", "PATCH"])
@permission_classes([IsAuthenticated])
def preferences(request):
"""用户设置:通知策略 / 两步验证 / 创作默认 / 显示偏好。服务端持久化。"""
pref, _ = UserPreference.objects.get_or_create(user=request.user)
if request.method in ("PUT", "PATCH"):
serializer = UserPreferenceSerializer(pref, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
pref.refresh_from_db()
return Response(UserPreferenceSerializer(pref).data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def login_sessions(request):
"""在用设备:返回未下线的登录会话(最近 20 条)。"""
sessions = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True)[:20]
current_ip = _client_ip(request)
current_ua = (request.META.get("HTTP_USER_AGENT") or "")[:400]
data = LoginSessionSerializer(sessions, many=True, context={"current_ip": current_ip, "current_ua": current_ua}).data
return Response(data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def revoke_login_session(request, session_id):
"""下线单个设备会话。"""
from django.utils import timezone
updated = LoginSession.objects.filter(user=request.user, id=session_id, revoked_at__isnull=True).update(
revoked_at=timezone.now()
)
return Response({"revoked": updated})
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def revoke_other_sessions(request):
"""下线除当前外的所有其他设备:旋转 token(令其他端 token 失效)+ 标记会话已下线。"""
from django.utils import timezone
LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).update(revoked_at=timezone.now())
Token.objects.filter(user=request.user).delete()
token, _ = Token.objects.get_or_create(user=request.user)
record_login_session(request, request.user)
return Response({"token": token.key})