feat(core): notification inbox infinite scroll + command palette fix (+ pending WIP)
消息中心:全量渲染 → 真·后端分页滚动加载 - backend(ops/views): NotificationPagination(10/页,page_size 可覆盖)+ 响应回 type_counts(按收件人绝对计数,不受分页/搜索影响) - frontend(messages): 自管分页,滚到底加载下一批;tab/搜索走服务端并重置到第1页; 代号作废在途旧请求防切换卡空白;乐观标已读;「已加载 X / Y」分母用当前筛选总数 - api/App/types: listNotifications 支持 page/page_size/search;allNotifications 携带 type_counts 命令面板(侧边栏搜索):修复点开后 UI 错位 - app-shell: 遮罩 className 漏了基类 shell-command-bg(只有 .show)致无定位塌到左下; 补回基类 + header 类名对齐 .shell-command-h - messages-page.css: 工作台收进视口高度,收件箱在面板内滚动 本次提交一并带入此前若干未提交 WIP(account/ai-tools/library/pipeline/products/settings + accounts/ai/assets/billing/projects 后端),按用户要求整体推 dev。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,12 +41,18 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
|
||||
class TeamMemberSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
# 本月已消费(自然月,按 CreditLedger CHARGE 流水按人聚合);由 view 经 context 注入 charged_map
|
||||
month_charged = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TeamMember
|
||||
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit"]
|
||||
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit", "month_charged"]
|
||||
read_only_fields = ["id", "team", "user", "status"]
|
||||
|
||||
def get_month_charged(self, obj):
|
||||
charged_map = self.context.get("charged_map") or {}
|
||||
return str(charged_map.get(obj.user_id, 0))
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.Serializer):
|
||||
username = serializers.CharField(max_length=150)
|
||||
|
||||
@@ -40,13 +40,22 @@ def _client_ip(request):
|
||||
|
||||
|
||||
def record_login_session(request, user):
|
||||
"""登录成功后记录一条会话(设备 UA / IP),供设置页「在用设备」展示。"""
|
||||
"""登录成功后记录设备会话(UA / IP)。去重:同一台电脑(UA)+ 同一 IP 视为同一台设备,
|
||||
已存在未下线的同设备会话则只刷新 last_seen_at,不再新增一行(避免「在用设备」列表里同设备重复堆叠)。"""
|
||||
try:
|
||||
LoginSession.objects.create(
|
||||
user=user,
|
||||
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:400],
|
||||
ip_address=_client_ip(request),
|
||||
user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:400]
|
||||
ip_address = _client_ip(request)
|
||||
existing = (
|
||||
LoginSession.objects.filter(
|
||||
user=user, user_agent=user_agent, ip_address=ip_address, revoked_at__isnull=True
|
||||
)
|
||||
.order_by("-last_seen_at")
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
existing.save(update_fields=["last_seen_at"]) # auto_now 刷新最近活跃时间
|
||||
else:
|
||||
LoginSession.objects.create(user=user, user_agent=user_agent, ip_address=ip_address)
|
||||
except Exception: # noqa: BLE001 — 会话记录失败不应阻断登录
|
||||
pass
|
||||
|
||||
@@ -170,13 +179,36 @@ def can_manage_team(user, team):
|
||||
return bool(member and member.role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN})
|
||||
|
||||
|
||||
def _month_charged_by_user(team):
|
||||
"""本团队当前自然月每个成员的消费(CHARGE 流水)合计:{user_id: Decimal}。"""
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.billing.models import CreditLedger
|
||||
|
||||
month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
rows = (
|
||||
CreditLedger.objects.filter(
|
||||
team=team,
|
||||
ledger_type=CreditLedger.Type.CHARGE,
|
||||
created_at__gte=month_start,
|
||||
)
|
||||
.values("user_id")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
return {row["user_id"]: row["total"] for row in rows if row["user_id"] is not None}
|
||||
|
||||
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def team_members(request):
|
||||
team = get_current_team(request.user)
|
||||
if request.method == "GET":
|
||||
members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at")
|
||||
return Response(TeamMemberSerializer(members, many=True).data)
|
||||
charged_map = _month_charged_by_user(team)
|
||||
return Response(
|
||||
TeamMemberSerializer(members, many=True, context={"charged_map": charged_map}).data
|
||||
)
|
||||
|
||||
if not can_manage_team(request.user, team):
|
||||
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
|
||||
@@ -277,23 +309,41 @@ def preferences(request):
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def login_sessions(request):
|
||||
"""在用设备:返回未下线的登录会话(最近 20 条)。"""
|
||||
sessions = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True)[:20]
|
||||
"""在用设备:返回未下线的登录会话(去重后最近 20 台)。
|
||||
去重规则:同一台电脑(UA)+ 同一 IP 只算一台,取该设备最近一次会话展示(兼容历史已堆叠的重复行)。"""
|
||||
queryset = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).order_by("-last_seen_at")
|
||||
seen: set = set()
|
||||
unique = []
|
||||
for session in queryset:
|
||||
key = (session.user_agent, session.ip_address)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(session)
|
||||
if len(unique) >= 20:
|
||||
break
|
||||
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
|
||||
data = LoginSessionSerializer(unique, 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):
|
||||
"""下线单个设备会话。"""
|
||||
"""下线单个设备:把同一台设备(UA + IP)下的所有未下线会话一并下线,
|
||||
否则去重展示的一台设备点「下线」后,底层其它重复会话仍存活会再次冒出来。"""
|
||||
from django.utils import timezone
|
||||
|
||||
updated = LoginSession.objects.filter(user=request.user, id=session_id, revoked_at__isnull=True).update(
|
||||
revoked_at=timezone.now()
|
||||
)
|
||||
target = LoginSession.objects.filter(user=request.user, id=session_id).first()
|
||||
if not target:
|
||||
return Response({"revoked": 0})
|
||||
updated = LoginSession.objects.filter(
|
||||
user=request.user,
|
||||
user_agent=target.user_agent,
|
||||
ip_address=target.ip_address,
|
||||
revoked_at__isnull=True,
|
||||
).update(revoked_at=timezone.now())
|
||||
return Response({"revoked": updated})
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -184,6 +188,34 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id
|
||||
raise
|
||||
|
||||
|
||||
def _generate_video_poster(*, video_bytes: bytes, team, project, asset_id) -> "StoredObject | None":
|
||||
"""用 ffmpeg 抽视频首帧作为封面(poster)并上传 TOS。best-effort:任何失败都返回 None,不影响视频资产落地。"""
|
||||
if not video_bytes:
|
||||
return None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-poster-") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
video_path = tmp_dir / "in.mp4"
|
||||
poster_path = tmp_dir / "poster.jpg"
|
||||
video_path.write_bytes(video_bytes)
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-y", "-ss", "0", "-i", str(video_path), "-frames:v", "1", "-q:v", "3", str(poster_path)],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
if proc.returncode != 0 or not poster_path.exists():
|
||||
return None
|
||||
poster_bytes = poster_path.read_bytes()
|
||||
if not poster_bytes:
|
||||
return None
|
||||
object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}-poster.jpg"
|
||||
return TosStorage().upload_fileobj(
|
||||
fileobj=BytesIO(poster_bytes), object_key=object_key, content_type="image/jpeg"
|
||||
)
|
||||
except Exception: # noqa: BLE001 — poster 仅用于展示,失败不阻断
|
||||
return None
|
||||
|
||||
|
||||
def _store_generated_media(*, team, user, project, task, media: str, name: str, category: str, asset_type: str) -> Asset:
|
||||
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
|
||||
suffix = ".png"
|
||||
@@ -214,6 +246,22 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str,
|
||||
size_bytes=stored.size_bytes,
|
||||
is_primary=True,
|
||||
)
|
||||
# 视频资产:额外抽首帧作为封面图,挂成同一 Asset 下的 image 文件,供任务中心/列表显示缩略图
|
||||
if "video" in content_type:
|
||||
try:
|
||||
video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
|
||||
except Exception: # noqa: BLE001
|
||||
video_bytes = b""
|
||||
poster = _generate_video_poster(video_bytes=video_bytes, team=team, project=project, asset_id=asset_id)
|
||||
if poster:
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=poster.object_key,
|
||||
bucket=poster.bucket,
|
||||
content_type=poster.content_type,
|
||||
size_bytes=poster.size_bytes,
|
||||
is_primary=False,
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ class AssetSerializer(serializers.ModelSerializer):
|
||||
"description",
|
||||
"metadata",
|
||||
"is_deleted",
|
||||
"origin_task",
|
||||
"files",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
|
||||
@@ -11,11 +11,15 @@ class CreditAccountSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class CreditLedgerSerializer(serializers.ModelSerializer):
|
||||
# 成员展示名:优先真实姓名 → 用户名 → 邮箱;系统流水(无 user)留空
|
||||
user_label = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = CreditLedger
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"user_label",
|
||||
"project",
|
||||
"task",
|
||||
"ledger_type",
|
||||
@@ -27,6 +31,12 @@ class CreditLedgerSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_user_label(self, obj):
|
||||
user = obj.user
|
||||
if user is None:
|
||||
return ""
|
||||
return user.first_name or user.username or user.email or ""
|
||||
|
||||
|
||||
class CreditReservationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -29,7 +29,7 @@ def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
|
||||
ledger_type=CreditLedger.Type.RESERVE,
|
||||
amount=amount,
|
||||
balance_after=account.balance,
|
||||
reason="reserve ai task credit",
|
||||
reason="AI 任务预扣额度",
|
||||
)
|
||||
return reservation
|
||||
|
||||
@@ -52,7 +52,7 @@ def release_credit(*, reservation: CreditReservation, reason: str = "") -> None:
|
||||
ledger_type=CreditLedger.Type.RELEASE,
|
||||
amount=reservation.amount,
|
||||
balance_after=account.balance,
|
||||
reason=reason or "release reserved credit",
|
||||
reason=reason or "释放预留额度",
|
||||
)
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Dec
|
||||
ledger_type=CreditLedger.Type.CHARGE,
|
||||
amount=actual_amount,
|
||||
balance_after=account.balance,
|
||||
reason="charge ai task credit",
|
||||
reason="AI 任务扣费",
|
||||
)
|
||||
if reservation.amount > actual_amount:
|
||||
CreditLedger.objects.create(
|
||||
@@ -88,6 +88,6 @@ def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Dec
|
||||
ledger_type=CreditLedger.Type.RELEASE,
|
||||
amount=reservation.amount - actual_amount,
|
||||
balance_after=account.balance,
|
||||
reason="release unused reserved credit",
|
||||
reason="释放未用预留额度",
|
||||
)
|
||||
|
||||
|
||||
@@ -56,7 +56,27 @@ def ledgers(request):
|
||||
queryset = queryset.filter(project_id=project_id)
|
||||
if user_id:
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
return Response(CreditLedgerSerializer(queryset[:100], many=True).data)
|
||||
# 服务端分页:总数随流水增长(原先写死 [:100] 导致永远 100 条)
|
||||
try:
|
||||
page = max(1, int(request.query_params.get("page", 1)))
|
||||
except (TypeError, ValueError):
|
||||
page = 1
|
||||
try:
|
||||
page_size = int(request.query_params.get("page_size", 10))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 10
|
||||
page_size = max(1, min(page_size, 100))
|
||||
total = queryset.count()
|
||||
start = (page - 1) * page_size
|
||||
rows = queryset[start:start + page_size]
|
||||
return Response(
|
||||
{
|
||||
"count": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"results": CreditLedgerSerializer(rows, many=True).data,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
|
||||
@@ -2,9 +2,17 @@ from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
|
||||
class NotificationPagination(PageNumberPagination):
|
||||
# 收件箱滚动加载:每批 10 条,前端可用 ?page_size= 覆盖(上限 100)
|
||||
page_size = 10
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
from apps.assets.models import Asset
|
||||
from apps.billing.models import CreditAccount
|
||||
from apps.common.api import TeamScopedViewSetMixin
|
||||
@@ -109,14 +117,19 @@ def ensure_team_notifications(team, user):
|
||||
class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
serializer_class = NotificationSerializer
|
||||
queryset = Notification.objects.select_related("team", "recipient", "project").all()
|
||||
pagination_class = NotificationPagination
|
||||
search_fields = ["title", "brief", "body", "source", "stage"]
|
||||
ordering_fields = ["created_at", "updated_at"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
# 团队 + 收件人 + 未归档:分类计数的基准集(不含 tab/未读/搜索过滤)
|
||||
def _recipient_scope(self):
|
||||
queryset = super().get_queryset().filter(archived_at__isnull=True)
|
||||
user = self.request.user
|
||||
queryset = queryset.filter(Q(recipient=user) | Q(recipient__isnull=True))
|
||||
return queryset.filter(Q(recipient=user) | Q(recipient__isnull=True))
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self._recipient_scope()
|
||||
notification_type = self.request.query_params.get("type")
|
||||
if notification_type and notification_type not in {"all", "unread"}:
|
||||
queryset = queryset.filter(notification_type=notification_type)
|
||||
@@ -128,9 +141,19 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
ensure_team_notifications(self.get_team(), request.user)
|
||||
response = super().list(request, *args, **kwargs)
|
||||
data = response.data
|
||||
unread_count = self.get_queryset().filter(is_read=False).count()
|
||||
if isinstance(data, dict):
|
||||
# 分类 chip 计数取绝对总数(忽略当前 tab/搜索),与设计稿一致
|
||||
base = self._recipient_scope()
|
||||
unread_count = base.filter(is_read=False).count()
|
||||
data["unread_count"] = unread_count
|
||||
data["type_counts"] = {
|
||||
"all": base.count(),
|
||||
"unread": unread_count,
|
||||
"task": base.filter(notification_type="task").count(),
|
||||
"team": base.filter(notification_type="team").count(),
|
||||
"billing": base.filter(notification_type="billing").count(),
|
||||
"system": base.filter(notification_type="system").count(),
|
||||
}
|
||||
return response
|
||||
|
||||
def perform_create(self, serializer):
|
||||
|
||||
@@ -44,6 +44,19 @@ def _download_asset_primary_file(asset, target_path: Path) -> None:
|
||||
target_path.write_bytes(response.content)
|
||||
|
||||
|
||||
def _has_audio_stream(path: Path) -> bool:
|
||||
"""探测视频文件是否含音频流(决定是否保留该片段的原声/人声)。ffprobe 失败时保守按无声处理。"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries",
|
||||
"stream=index", "-of", "csv=p=0", str(path)],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
return bool(proc.stdout.strip())
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _load_font(size: int):
|
||||
from PIL import ImageFont
|
||||
|
||||
@@ -123,9 +136,14 @@ def _output_starts(specs: list[dict], xfade: float) -> tuple[list[float], float]
|
||||
return starts, max(0.1, total)
|
||||
|
||||
|
||||
_AFMT = "aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo"
|
||||
|
||||
|
||||
def _build_export_command(*, n: int, specs: list[dict], starts: list[float], total: float,
|
||||
transition: str, sub_overlays: list[tuple[str, float, float]],
|
||||
bgm_name: str | None, bgm_volume: float) -> list[str]:
|
||||
bgm_name: str | None, bgm_volume: float,
|
||||
has_audio: list[bool] | None = None) -> list[str]:
|
||||
has_audio = has_audio or [False] * n
|
||||
parts: list[str] = []
|
||||
for i, s in enumerate(specs):
|
||||
parts.append(
|
||||
@@ -153,8 +171,30 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
|
||||
)
|
||||
vlabel = out
|
||||
if bgm_name:
|
||||
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS[aout]")
|
||||
|
||||
# 音频:片段自带的人声/原声必须保留(有声片段取原音轨,无声片段补等长静音,否则 concat 会缺流);
|
||||
# 若另挂了 BGM,则把 BGM 混到原声之上(amix,normalize=0 不自动衰减原声音量)。
|
||||
# 三种片段全无声且无 BGM 时,保持旧行为=纯视频不带音轨。
|
||||
want_audio = any(has_audio) or bool(bgm_name)
|
||||
audio_label: str | None = None
|
||||
if want_audio:
|
||||
for i, s in enumerate(specs):
|
||||
if has_audio[i]:
|
||||
parts.append(
|
||||
f"[{i}:a]atrim=start={s['ts']:.3f}:end={s['te']:.3f},asetpts=PTS-STARTPTS,{_AFMT}[a{i}]"
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
f"anullsrc=channel_layout=stereo:sample_rate=44100,atrim=0:{s['dur']:.3f},"
|
||||
f"asetpts=PTS-STARTPTS,{_AFMT}[a{i}]"
|
||||
)
|
||||
parts.append("".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[avoice0]")
|
||||
parts.append(f"[avoice0]atrim=0:{total:.3f},asetpts=PTS-STARTPTS[avoice]")
|
||||
audio_label = "avoice"
|
||||
if bgm_name:
|
||||
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS,{_AFMT}[abgm]")
|
||||
parts.append("[avoice][abgm]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0[aout]")
|
||||
audio_label = "aout"
|
||||
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
for i in range(n):
|
||||
@@ -164,10 +204,10 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
for png, _s, _e in sub_overlays:
|
||||
cmd += ["-loop", "1", "-i", png]
|
||||
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"]
|
||||
if bgm_name:
|
||||
cmd += ["-map", "[aout]"]
|
||||
if audio_label:
|
||||
cmd += ["-map", f"[{audio_label}]"]
|
||||
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", "-preset", "veryfast"]
|
||||
if bgm_name:
|
||||
if audio_label:
|
||||
cmd += ["-c:a", "aac", "-b:a", "192k"]
|
||||
cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"]
|
||||
return cmd
|
||||
@@ -237,6 +277,8 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
tmp = Path(tmp_dir)
|
||||
for index, clip in enumerate(clips):
|
||||
_download_asset_primary_file(clip.asset, tmp / f"clip{index}.mp4")
|
||||
# 逐片段探测是否自带音轨:有声→保留原声,无声→补静音(见 _build_export_command)
|
||||
has_audio = [_has_audio_stream(tmp / f"clip{index}.mp4") for index in range(len(clips))]
|
||||
|
||||
bgm_name = None
|
||||
if bgm_track is not None and bgm_track.asset_id:
|
||||
@@ -258,6 +300,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
command = _build_export_command(
|
||||
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
|
||||
sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0,
|
||||
has_audio=has_audio,
|
||||
)
|
||||
proc = subprocess.run(command, cwd=str(tmp), capture_output=True)
|
||||
if proc.returncode != 0:
|
||||
|
||||
Reference in New Issue
Block a user