完成视频复刻和优化
This commit is contained in:
@@ -9,3 +9,14 @@ app = Celery("airshelf")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
app.autodiscover_tasks()
|
||||
|
||||
|
||||
from celery.signals import worker_ready # noqa: E402
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def _kick_asset_review_drain(**kwargs):
|
||||
"""Worker 起来就自动转人脸审核,不用人去后台点送审。"""
|
||||
from apps.ai.tasks import drain_asset_reviews_task
|
||||
|
||||
drain_asset_reviews_task.apply_async(countdown=8)
|
||||
|
||||
|
||||
@@ -328,11 +328,23 @@ class AdminAssetReviewTests(TestCase):
|
||||
mock_submit.assert_not_called()
|
||||
|
||||
def test_poll_only_processing(self):
|
||||
with patch("apps.adminpanel.views.poll_asset_review", return_value="active") as mock_poll:
|
||||
with patch("apps.assets.review.submit_asset_for_review", return_value=False), patch(
|
||||
"apps.assets.review.poll_asset_review", return_value="active"
|
||||
) as mock_poll:
|
||||
r = self.ac.post("/api/admin/asset-reviews/poll/", {}, format="json")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.data["polled"], 1) # 仅 a_proc 处于 processing
|
||||
mock_poll.assert_called_once()
|
||||
self.assertEqual(r.data.get("submitted", 0), 0)
|
||||
|
||||
def test_poll_auto_submits_unsubmitted(self):
|
||||
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as mock_submit, patch(
|
||||
"apps.assets.review.poll_asset_review", return_value="processing"
|
||||
):
|
||||
r = self.ac.post("/api/admin/asset-reviews/poll/", {}, format="json")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertGreaterEqual(r.data["submitted"], 1)
|
||||
mock_submit.assert_called()
|
||||
|
||||
|
||||
class AdminTaskMonitorTests(TestCase):
|
||||
|
||||
@@ -406,15 +406,20 @@ def admin_asset_reviews_submit(request):
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_asset_reviews_poll(request):
|
||||
"""轮询审核中(processing)资产的最新状态。给 asset_ids 则只轮询这些,否则轮询全平台 processing。"""
|
||||
"""自动送审未送审资产 + 轮询审核中状态。给 asset_ids 则只轮询这些,否则全平台兜底。"""
|
||||
from apps.assets.review import drain_platform_reviews
|
||||
|
||||
ids = request.data.get("asset_ids")
|
||||
qs = Asset.objects.filter(category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False)
|
||||
if ids:
|
||||
qs = qs.filter(id__in=ids)
|
||||
statuses = {}
|
||||
for asset in qs:
|
||||
statuses[str(asset.id)] = poll_asset_review(asset)
|
||||
return Response({"polled": len(statuses), "statuses": statuses})
|
||||
qs = Asset.objects.filter(
|
||||
category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False, id__in=ids
|
||||
)
|
||||
statuses = {}
|
||||
for asset in qs:
|
||||
statuses[str(asset.id)] = poll_asset_review(asset)
|
||||
return Response({"polled": len(statuses), "submitted": 0, "statuses": statuses})
|
||||
result = drain_platform_reviews()
|
||||
return Response(result)
|
||||
|
||||
|
||||
def _refresh_inflight_task(task: AITask, *, operator) -> AITask:
|
||||
|
||||
@@ -31,6 +31,7 @@ from apps.billing.services.ledger import charge_reserved_credit, release_credit,
|
||||
from .models import AITask, ModelConfig
|
||||
from .providers.volcano import VolcanoArkProvider
|
||||
from .generation_errors import classify_generation_error, public_error_for_task
|
||||
from .media_probe import REF_DURATION_MAX
|
||||
from .video_errors import parse_provider_error
|
||||
from .video_pricing import get_resolution
|
||||
|
||||
@@ -319,7 +320,7 @@ def build_content_items(*, team, prompt: str, mode: str, references: list) -> di
|
||||
raise ValueError(f"参考音频最多 3 条,当前 {audio_n} 条,请减少后重试")
|
||||
if audio_n > 0 and image_n + video_n == 0:
|
||||
raise ValueError("音频不能单独作为参考素材,请同时提供参考图片或视频")
|
||||
if video_duration_total > 15:
|
||||
if video_duration_total > REF_DURATION_MAX:
|
||||
raise ValueError("参考视频总时长不能超过 15 秒,请缩短后重试")
|
||||
|
||||
# @label 替换:按 label 长度降序,防子串吞噬
|
||||
@@ -409,6 +410,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
"""提交一条自由创作视频。校验/估价/预留在前(失败不留半套),火山调用在事务外。
|
||||
创建失败不抛:返回 FAILED 任务(带友好中文错误),前端渲染失败卡。校验类错误抛 ValueError → 400。"""
|
||||
prompt = str(params.get("prompt") or "").strip()
|
||||
feature = str(params.get("feature") or "free_video").strip() or "free_video"
|
||||
mode = str(params.get("mode") or "universal")
|
||||
model_name = str(params.get("model") or HIGH_RES_MODEL)
|
||||
aspect_ratio = str(params.get("aspect_ratio") or "16:9")
|
||||
@@ -486,7 +488,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
reserve_amount = video_reserve_amount(quote.points)
|
||||
|
||||
request_payload = {
|
||||
"feature": "free_video",
|
||||
"feature": feature,
|
||||
"mode": mode,
|
||||
"model": model_name,
|
||||
"endpoint": model_config.endpoint,
|
||||
@@ -505,6 +507,13 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
"references": built["snapshots"],
|
||||
"model_routing_v1": True,
|
||||
}
|
||||
extra = params.get("extra_payload")
|
||||
if isinstance(extra, dict):
|
||||
protected = set(request_payload)
|
||||
for key, value in extra.items():
|
||||
if key in protected or value in (None, ""):
|
||||
continue
|
||||
request_payload[key] = value
|
||||
|
||||
# 建任务 + 预留同一事务:余额不足/限额拦截时回滚任务行,不留半套
|
||||
with transaction.atomic():
|
||||
@@ -546,7 +555,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
generate_audio=generate_audio,
|
||||
seed=seed if seed != -1 else None,
|
||||
search_mode=search_mode,
|
||||
request_summary={"feature": "free_video", "mode": mode},
|
||||
request_summary={"feature": feature, "mode": mode},
|
||||
)
|
||||
response, provider_task_id = routed.value
|
||||
# execute_model_call 以原子 F 表达式累计实际尝试的平台成本;刷新内存对象,
|
||||
@@ -625,7 +634,7 @@ def _notify_failure(task: AITask, *, raw: str, hint: str) -> None:
|
||||
task=task,
|
||||
project=None,
|
||||
recipient=task.created_by,
|
||||
stage_label="自由创作视频",
|
||||
stage_label="视频复刻" if (task.request_payload or {}).get("feature") == "video_replace" else "自由创作视频",
|
||||
raw=raw,
|
||||
hint=hint,
|
||||
)
|
||||
@@ -643,19 +652,27 @@ def _store_free_video_media(*, task: AITask, media: str) -> Asset:
|
||||
asset_id = uuid.uuid4()
|
||||
object_key = f"teams/{task.team_id}/free-create/{asset_id}.mp4"
|
||||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
||||
prompt = (task.request_payload or {}).get("prompt") or ""
|
||||
payload = task.request_payload or {}
|
||||
prompt = payload.get("prompt") or ""
|
||||
feature = payload.get("feature") or "free_video"
|
||||
subject = str(payload.get("subject_name") or "").strip()
|
||||
if feature == "video_replace":
|
||||
mode_label = "角色复刻" if payload.get("replace_mode") == "character" else "商品复刻"
|
||||
asset_name = f"{subject}{mode_label}" if subject else mode_label
|
||||
else:
|
||||
asset_name = prompt[:255] or "自由创作视频"
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
team=task.team,
|
||||
created_by=task.created_by,
|
||||
# Asset.name 是通用资产字段,受 255 字符上限约束;完整显示标题由资产接口从
|
||||
# 关联任务的 request_payload.prompt 派生,避免再出现 50 字符业务截断。
|
||||
name=(prompt[:255] or "自由创作视频"),
|
||||
name=asset_name[:255],
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.FREE_CREATE,
|
||||
origin_task=task,
|
||||
metadata={"feature": "free_video"},
|
||||
metadata={"feature": feature},
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
@@ -892,6 +909,7 @@ def serialize_free_video_task(task: AITask, *, include_deleted_assets: bool = Fa
|
||||
"status": task.status,
|
||||
"mode": payload.get("mode") or "universal",
|
||||
"model": payload.get("model") or "",
|
||||
"feature": payload.get("feature") or "free_video",
|
||||
"prompt": payload.get("prompt") or "",
|
||||
"aspect_ratio": payload.get("aspect_ratio") or "16:9",
|
||||
"resolution": payload.get("resolution") or "720p",
|
||||
|
||||
@@ -8,6 +8,15 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# 参考视频/音频对外仍写「2-15 秒」。15 秒成片常被探测成 15.01–15.04(帧率取整 / AAC 对齐),给半秒容差。
|
||||
REF_DURATION_MIN = 1.95
|
||||
REF_DURATION_MAX = 15.5
|
||||
|
||||
|
||||
def duration_in_ref_range(seconds: float) -> bool:
|
||||
return REF_DURATION_MIN <= seconds <= REF_DURATION_MAX
|
||||
|
||||
|
||||
def probe_duration(file_path: str) -> float | None:
|
||||
"""ffprobe 取媒体时长(秒)。失败返回 None。"""
|
||||
try:
|
||||
|
||||
@@ -1571,7 +1571,10 @@ def stream_script_agent(
|
||||
target_index 非空 = 精准只改第 N 镜(读全脚本上下文,后端强制保留其余镜原样)。"""
|
||||
from apps.ai.services import create_ai_task, stream_routed_text_request
|
||||
|
||||
fmt, structure = coerce_combo(presentation_format, video_structure)
|
||||
# 极速成片与专业创作都只产出口播。历史项目、模板或请求里的短剧/Vlog
|
||||
# 仅作兼容读取,绝不能重新进入实际生成链路。
|
||||
del presentation_format
|
||||
fmt, structure = coerce_combo(DEFAULT_PRESENTATION_FORMAT, video_structure)
|
||||
|
||||
yield _sse({"type": "tool", "id": "skill", "label": f"加载套路:{PRESENTATION_FORMATS[fmt]} · {VIDEO_STRUCTURES[structure]}", "status": "running"})
|
||||
skill_loaded = bool(load_ecommerce_skill(fmt, structure))
|
||||
@@ -1898,7 +1901,8 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
|
||||
raise ValueError(f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)")
|
||||
total_duration = base_draft["total_duration"] # 已按各镜真实秒数加总,normalize 不会截掉用户增删后的镜
|
||||
# 改一镜要沿用原稿的套路,否则重写出来的那一镜镜头语言会跟其余镜打架
|
||||
fmt, structure = combo_keys(base_draft.get("presentation_format"), base_draft.get("video_structure"))
|
||||
# 单镜重写也必须遵守当前统一的口播规则,不能被旧稿的表现形式带回短剧/Vlog。
|
||||
fmt, structure = combo_keys(DEFAULT_PRESENTATION_FORMAT, base_draft.get("video_structure"))
|
||||
|
||||
product_image_urls = _script_product_reference_urls(project, model_config)
|
||||
messages = build_agent_messages(
|
||||
|
||||
@@ -31,6 +31,16 @@ def extract_entities_task(self, task_id: str) -> str:
|
||||
return task_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0)
|
||||
def run_video_digest_task(self, task_id: str) -> str:
|
||||
"""视频提炼页的慢活(抽帧 + Gemini,可达 1–2 分钟)在 worker 内跑,离开页面也不中断。
|
||||
幂等且失败自退费(见 run_team_digest_task),故 max_retries=0,不向上抛重试。"""
|
||||
from apps.ai.video_digest import run_team_digest_task
|
||||
|
||||
run_team_digest_task(task_id=task_id)
|
||||
return task_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0)
|
||||
def generate_base_asset_task(self, task_id: str) -> str:
|
||||
"""基础资产(商品/人物/场景立绘)的慢出图在 worker 内跑,Web 层不被占住。
|
||||
@@ -87,3 +97,27 @@ def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str:
|
||||
poll_free_video_task.apply_async(args=[task_id, attempt + 1], countdown=30)
|
||||
return task_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0)
|
||||
def drain_asset_reviews_task(self) -> str:
|
||||
"""全平台人脸审核兜底:未送审自动送火山、审核中自动拉绿/红盾。
|
||||
自重排(无 celery beat);eager 下不自转,避免单测死循环。"""
|
||||
from django.conf import settings as dj_settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from apps.assets.review import drain_platform_reviews
|
||||
|
||||
if cache.add("asset-review-drain-lock", "1", timeout=50):
|
||||
try:
|
||||
drain_platform_reviews()
|
||||
except Exception: # noqa: BLE001 — 单轮失败不终结循环
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning("drain_asset_reviews_task failed", exc_info=True)
|
||||
finally:
|
||||
cache.delete("asset-review-drain-lock")
|
||||
if getattr(dj_settings, "CELERY_TASK_ALWAYS_EAGER", False):
|
||||
return "ok"
|
||||
drain_asset_reviews_task.apply_async(countdown=45)
|
||||
return "ok"
|
||||
|
||||
|
||||
@@ -283,6 +283,7 @@ def _digest_with_duration(clip, duration: float):
|
||||
return frames_from_upload(clip)
|
||||
|
||||
|
||||
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
|
||||
class VideoDigestApiTests(TestCase):
|
||||
"""视频提炼页独立入口:不绑项目,缺文件直接 400。"""
|
||||
|
||||
@@ -300,42 +301,47 @@ class VideoDigestApiTests(TestCase):
|
||||
|
||||
def test_returns_digest_without_project(self):
|
||||
payload = {
|
||||
"text": "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3,
|
||||
"chars": 120,
|
||||
"frames": 4,
|
||||
"duration": 15.0,
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"task_id": "00000000-0000-0000-0000-000000000001",
|
||||
"status": "processing",
|
||||
"text": "",
|
||||
"prompt": "",
|
||||
"chars": 0,
|
||||
"duration": 15.0,
|
||||
"shots": 0,
|
||||
"file_name": "ref.mp4",
|
||||
"estimated_cost": "30",
|
||||
}
|
||||
with patch("apps.ai.video_digest.digest_team_video", return_value=payload):
|
||||
with patch("apps.ai.video_digest.submit_team_digest", return_value=payload):
|
||||
upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4")
|
||||
response = self.client.post("/api/ai/video-digest/", {"file": upload}, format="multipart")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertEqual(response.data["name"], "ref.mp4")
|
||||
self.assertIn("【第 1 镜】", response.data["text"])
|
||||
self.assertEqual(response.data["status"], "processing")
|
||||
self.assertEqual(response.data["task_id"], payload["task_id"])
|
||||
self.assertEqual(response.data["duration"], 15.0)
|
||||
|
||||
def test_forwards_model_config_id(self):
|
||||
payload = {
|
||||
"text": "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3,
|
||||
"chars": 120,
|
||||
"frames": 4,
|
||||
"duration": 15.0,
|
||||
"id": "00000000-0000-0000-0000-000000000002",
|
||||
"task_id": "00000000-0000-0000-0000-000000000002",
|
||||
"status": "processing",
|
||||
"text": "",
|
||||
"duration": 15.0,
|
||||
"estimated_cost": "30",
|
||||
}
|
||||
with patch("apps.ai.video_digest.digest_team_video", return_value=payload) as mocked:
|
||||
with patch("apps.ai.video_digest.submit_team_digest", return_value=payload) as mocked:
|
||||
upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4")
|
||||
response = self.client.post(
|
||||
"/api/ai/video-digest/",
|
||||
{"file": upload, "model_config_id": "abc-123"},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertEqual(mocked.call_args.kwargs["model_config_id"], "abc-123")
|
||||
|
||||
def test_provider_failure_returns_json_not_500(self):
|
||||
with patch("apps.ai.video_digest.digest_team_video", side_effect=RuntimeError("upstream 402")):
|
||||
with patch("apps.ai.video_digest.submit_team_digest", side_effect=RuntimeError("upstream 402")):
|
||||
upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4")
|
||||
response = self.client.post("/api/ai/video-digest/", {"file": upload}, format="multipart")
|
||||
self.assertEqual(response.status_code, 502)
|
||||
@@ -392,6 +398,7 @@ class VideoDigestApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["results"], [])
|
||||
self.assertEqual(response.data["total"], 0)
|
||||
self.assertIsNone(response.data["inflight"])
|
||||
|
||||
def test_history_lists_succeeded_team_digests(self):
|
||||
from apps.ai.models import AITask
|
||||
@@ -477,6 +484,25 @@ class VideoDigestApiTests(TestCase):
|
||||
)
|
||||
self.assertEqual(missing.status_code, 404)
|
||||
|
||||
def test_polls_inflight_job_and_exposes_it_on_list(self):
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = self._seed_digest_task(status=AITask.Status.RESERVED, extra_key="inflight")
|
||||
listed = self.client.get("/api/ai/video-digest/")
|
||||
self.assertEqual(listed.status_code, 200)
|
||||
self.assertEqual(listed.data["inflight"]["id"], str(task.id))
|
||||
self.assertEqual(listed.data["inflight"]["status"], "processing")
|
||||
|
||||
polled = self.client.get(f"/api/ai/video-digest/{task.id}/")
|
||||
self.assertEqual(polled.status_code, 200)
|
||||
self.assertEqual(polled.data["status"], "processing")
|
||||
self.assertEqual(polled.data["task_id"], str(task.id))
|
||||
self.assertEqual(polled.data["file_name"], "参考视频.mp4")
|
||||
|
||||
def test_poll_missing_job_returns_404(self):
|
||||
missing = self.client.get("/api/ai/video-digest/00000000-0000-0000-0000-000000000099/")
|
||||
self.assertEqual(missing.status_code, 404)
|
||||
|
||||
|
||||
@override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}})
|
||||
class VideoDigestBillingTests(TestCase):
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""视频复刻:专用提交/历史隔离/提示词后端写死。
|
||||
|
||||
运行:DB_ENGINE=sqlite python manage.py test apps.ai.test_video_replace --settings=airshelf.settings.test
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.free_video import submit_free_video
|
||||
from apps.ai.models import AITask, ModelConfig
|
||||
from apps.ai.test_free_video import STANDARD, _ark_create_response
|
||||
from apps.ai.video_replace import PRODUCT_PROMPT, submit_video_replace
|
||||
from apps.assets.models import Asset, AssetFile, Model
|
||||
from apps.billing.models import CreditAccount
|
||||
from apps.products.models import Product, ProductImage
|
||||
|
||||
|
||||
def _asset(team, user, *, kind=Asset.Type.IMAGE, name="素材", duration_ms=None, preview="http://tos/1.png"):
|
||||
asset = Asset.objects.create(
|
||||
team=team,
|
||||
created_by=user,
|
||||
name=name,
|
||||
asset_type=kind,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.UPLOAD,
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key="k/1",
|
||||
bucket="b",
|
||||
content_type="video/mp4" if kind == Asset.Type.VIDEO else "image/png",
|
||||
size_bytes=1,
|
||||
preview_url=preview,
|
||||
duration_ms=duration_ms,
|
||||
is_primary=True,
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
class SubmitVideoReplaceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="vrep", password="p")
|
||||
self.team = Team.objects.create(name="VR", owner=self.user)
|
||||
CreditAccount.objects.create(team=self.team, balance="10000.0000")
|
||||
self.provider = MagicMock()
|
||||
self.provider.create_video_task.return_value = _ark_create_response()
|
||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
|
||||
self.addCleanup(patch.stopall)
|
||||
self.video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="参考.mp4", duration_ms=8000, preview="http://tos/video.mp4")
|
||||
self.image = _asset(self.team, self.user, name="精华.png", preview="http://tos/product.png")
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华", cover_asset=self.image)
|
||||
ProductImage.objects.create(product=self.product, asset=self.image, is_primary=True)
|
||||
|
||||
def _submit(self, **over):
|
||||
params = {
|
||||
"replace_mode": "product",
|
||||
"video_asset_id": str(self.video.id),
|
||||
"product_id": str(self.product.id),
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
}
|
||||
params.update(over)
|
||||
return submit_video_replace(team=self.team, user=self.user, params=params)
|
||||
|
||||
def test_product_library_uses_backend_prompt_and_feature(self):
|
||||
task = self._submit()
|
||||
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
||||
payload = task.request_payload
|
||||
self.assertEqual(payload["feature"], "video_replace")
|
||||
self.assertEqual(payload["replace_mode"], "product")
|
||||
self.assertEqual(payload["subject_name"], "净颜精华")
|
||||
self.assertEqual(payload["subject_source"], "library")
|
||||
self.assertEqual(payload["prompt"], PRODUCT_PROMPT)
|
||||
labels = [item["label"] for item in payload["references"]]
|
||||
self.assertIn("参考视频", labels)
|
||||
self.assertIn("目标商品", labels)
|
||||
content = self.provider.create_video_task.call_args.kwargs.get("content_items") or []
|
||||
roles = [item.get("role") for item in content]
|
||||
self.assertIn("reference_video", roles)
|
||||
self.assertIn("reference_image", roles)
|
||||
|
||||
def test_character_library_and_temp_are_exclusive(self):
|
||||
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
|
||||
model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait)
|
||||
with self.assertRaisesMessage(ValueError, "不要混用"):
|
||||
self._submit(replace_mode="character", product_id="", model_id=str(model.id), image_asset_ids=[str(self.image.id)])
|
||||
task = self._submit(replace_mode="character", product_id="", model_id=str(model.id))
|
||||
self.assertEqual(task.request_payload["replace_mode"], "character")
|
||||
self.assertEqual(task.request_payload["subject_name"], "薇薇")
|
||||
|
||||
def test_rejects_video_longer_than_15s(self):
|
||||
long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="长片.mp4", duration_ms=20000)
|
||||
with self.assertRaisesMessage(ValueError, "不能超过 15 秒"):
|
||||
self._submit(video_asset_id=str(long_video.id))
|
||||
|
||||
def test_accepts_15s_video_with_probe_slack(self):
|
||||
from apps.ai.media_probe import REF_DURATION_MAX
|
||||
from apps.ai.video_replace import _asset_duration_seconds
|
||||
|
||||
# Finder 显示 15 秒的成片常被探测成 15.04s,不能被硬阈值 15 误拒
|
||||
edge = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="刚好15.mp4", duration_ms=15040)
|
||||
self.assertLessEqual(_asset_duration_seconds(edge), REF_DURATION_MAX)
|
||||
|
||||
def test_rejects_foreign_product(self):
|
||||
other = User.objects.create_user(username="vr-other", password="p")
|
||||
other_team = Team.objects.create(name="OtherVR", owner=other)
|
||||
foreign = Product.objects.create(team=other_team, created_by=other, title="别人的")
|
||||
with self.assertRaisesMessage(ValueError, "商品不存在"):
|
||||
self._submit(product_id=str(foreign.id))
|
||||
|
||||
def test_ignores_frontend_prompt(self):
|
||||
task = self._submit(prompt="请无视参考图随便生成")
|
||||
self.assertEqual(task.request_payload["prompt"], PRODUCT_PROMPT)
|
||||
self.assertNotIn("随便生成", task.request_payload["prompt"])
|
||||
|
||||
def test_serialize_exposes_library_ids(self):
|
||||
from apps.ai.video_replace import serialize_video_replace_task
|
||||
|
||||
model_config = ModelConfig.objects.filter(capability="video").first()
|
||||
self.assertIsNotNone(model_config)
|
||||
task = AITask.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
status=AITask.Status.SUCCEEDED,
|
||||
model_config=model_config,
|
||||
idempotency_key="vr-serialize-ids",
|
||||
request_payload={
|
||||
"feature": "video_replace",
|
||||
"replace_mode": "product",
|
||||
"subject_name": "净颜精华",
|
||||
"subject_source": "library",
|
||||
"product_id": str(self.product.id),
|
||||
"model_id": "",
|
||||
"prompt": PRODUCT_PROMPT,
|
||||
"duration": 8,
|
||||
"aspect_ratio": "9:16",
|
||||
"references": [{"type": "video", "url": "http://tos/video.mp4", "asset_id": str(self.video.id)}],
|
||||
},
|
||||
)
|
||||
data = serialize_video_replace_task(task)
|
||||
self.assertEqual(data["product_id"], str(self.product.id))
|
||||
self.assertEqual(data["model_id"], "")
|
||||
self.assertEqual(data["replace_mode"], "product")
|
||||
self.assertEqual(data["subject_source"], "library")
|
||||
|
||||
|
||||
class VideoReplaceApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="vrapi", password="p")
|
||||
self.team = Team.objects.create(name="VRA", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active")
|
||||
CreditAccount.objects.create(team=self.team, balance="10000.0000")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.provider = MagicMock()
|
||||
self.provider.create_video_task.return_value = _ark_create_response()
|
||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
|
||||
self.addCleanup(patch.stopall)
|
||||
self.video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="参考.mp4", duration_ms=5000, preview="http://tos/video.mp4")
|
||||
self.image = _asset(self.team, self.user, name="图.png", preview="http://tos/product.png")
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="轻醒咖啡", cover_asset=self.image)
|
||||
ProductImage.objects.create(product=self.product, asset=self.image, is_primary=True)
|
||||
|
||||
def test_submit_list_isolated_from_free_video(self):
|
||||
free = submit_free_video(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
params={
|
||||
"prompt": "一只猫",
|
||||
"mode": "universal",
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
"references": [],
|
||||
},
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/api/ai/video-replace/",
|
||||
{
|
||||
"replace_mode": "product",
|
||||
"video_asset_id": str(self.video.id),
|
||||
"product_id": str(self.product.id),
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
replace_id = resp.json()["task"]["id"]
|
||||
self.assertEqual(resp.json()["task"]["replace_mode"], "product")
|
||||
self.assertEqual(resp.json()["task"]["feature"], "video_replace")
|
||||
|
||||
free_list = self.client.get("/api/ai/free-video/").json()
|
||||
free_ids = {item["id"] for item in free_list["results"]}
|
||||
self.assertIn(str(free.id), free_ids)
|
||||
self.assertNotIn(replace_id, free_ids)
|
||||
|
||||
replace_list = self.client.get("/api/ai/video-replace/").json()
|
||||
replace_ids = {item["id"] for item in replace_list["results"]}
|
||||
self.assertIn(replace_id, replace_ids)
|
||||
self.assertNotIn(str(free.id), replace_ids)
|
||||
|
||||
def test_legacy_prompt_prefix_shows_in_replace_history(self):
|
||||
task = submit_free_video(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
params={
|
||||
"prompt": "[视频复刻] 商品:旧稿。保留镜头。",
|
||||
"mode": "universal",
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
"references": [],
|
||||
},
|
||||
)
|
||||
listing = self.client.get("/api/ai/video-replace/").json()
|
||||
self.assertEqual(listing["results"][0]["id"], str(task.id))
|
||||
self.assertEqual(listing["results"][0]["replace_mode"], "product")
|
||||
self.assertEqual(self.client.get("/api/ai/free-video/").json()["total"], 0)
|
||||
|
||||
def test_validation_error_returns_400(self):
|
||||
resp = self.client.post("/api/ai/video-replace/", {"replace_mode": "product"}, format="json")
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
@@ -16,6 +16,8 @@ from .views import (
|
||||
GenerateImageView,
|
||||
ImageConversationViewSet,
|
||||
ModelConfigViewSet,
|
||||
VideoReplacePollView,
|
||||
VideoReplaceView,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -35,4 +37,6 @@ urlpatterns = [
|
||||
path("free-video/<uuid:task_id>/restore/", FreeVideoRestoreView.as_view(), name="ai-free-video-restore"),
|
||||
path("free-video/<uuid:task_id>/purge/", FreeVideoPurgeView.as_view(), name="ai-free-video-purge"),
|
||||
path("free-video/<uuid:task_id>/", FreeVideoDetailView.as_view(), name="ai-free-video-detail"),
|
||||
path("video-replace/", VideoReplaceView.as_view(), name="ai-video-replace"),
|
||||
path("video-replace/<uuid:task_id>/poll/", VideoReplacePollView.as_view(), name="ai-video-replace-poll"),
|
||||
] + router.urls
|
||||
|
||||
@@ -700,7 +700,274 @@ def save_digest_prompt(*, team, task_id, prompt: str) -> dict | None:
|
||||
return serialize_digest_history(task)
|
||||
|
||||
|
||||
def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None) -> dict:
|
||||
class _FileFromPath:
|
||||
def __init__(self, path: str, name: str):
|
||||
self.name = name
|
||||
self.size = Path(path).stat().st_size
|
||||
self._path = path
|
||||
|
||||
def chunks(self, chunk_size=1024 * 1024):
|
||||
with Path(self._path).open("rb") as handle:
|
||||
while True:
|
||||
data = handle.read(chunk_size)
|
||||
if not data:
|
||||
break
|
||||
yield data
|
||||
|
||||
|
||||
def _store_raw_source(*, team, path: str, suffix: str) -> tuple[str, str]:
|
||||
ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}"
|
||||
mime = _SUFFIX_MIME.get(ext.lower(), "video/mp4")
|
||||
key = f"teams/{team.id}/video-digest/{uuid.uuid4()}{ext.lower()}"
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
storage = TosStorage()
|
||||
with Path(path).open("rb") as fileobj:
|
||||
stored = storage.upload_fileobj(fileobj=fileobj, object_key=key, content_type=mime)
|
||||
return stored.object_key, storage.public_url(object_key=stored.object_key)
|
||||
|
||||
|
||||
def _download_source_to_temp(object_key: str, suffix: str) -> str:
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}"
|
||||
storage = TosStorage()
|
||||
body = storage.client.get_object(Bucket=storage.bucket, Key=object_key)["Body"].read()
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=ext.lower(), delete=False)
|
||||
try:
|
||||
tmp.write(body)
|
||||
tmp.flush()
|
||||
finally:
|
||||
tmp.close()
|
||||
return tmp.name
|
||||
|
||||
|
||||
def serialize_digest_job(task) -> dict:
|
||||
from apps.ai.generation_errors import public_error_for_task
|
||||
from apps.ai.models import AITask
|
||||
|
||||
req = task.request_payload or {}
|
||||
resp = task.response_payload or {}
|
||||
prompt = str(resp.get("prompt") or resp.get("digest") or "").strip()
|
||||
inflight = task.status in {
|
||||
AITask.Status.CREATED,
|
||||
AITask.Status.RESERVED,
|
||||
AITask.Status.SUBMITTED,
|
||||
AITask.Status.POLLING,
|
||||
AITask.Status.POSTPROCESSING,
|
||||
}
|
||||
if inflight:
|
||||
job_status = "processing"
|
||||
elif task.status == AITask.Status.SUCCEEDED:
|
||||
job_status = "succeeded"
|
||||
else:
|
||||
job_status = "failed"
|
||||
duration = float(req.get("duration_seconds") or 0)
|
||||
file_name = str(req.get("file_name") or "") or "参考视频.mp4"
|
||||
public_error = public_error_for_task(task, operation="video_digest") if job_status == "failed" else None
|
||||
video_url = _media_url_from_payload(req, "video_url", "video_key", signed=True) or _media_url_from_payload(
|
||||
req, "source_url", "source_key", signed=True
|
||||
)
|
||||
return {
|
||||
"id": str(task.id),
|
||||
"task_id": str(task.id),
|
||||
"status": job_status,
|
||||
"text": prompt,
|
||||
"prompt": prompt,
|
||||
"chars": len(prompt),
|
||||
"duration": round(duration, 1) if duration else 0,
|
||||
"shots": int(req.get("shot_count") or 0) or shot_count(prompt),
|
||||
"file_name": file_name,
|
||||
"title": str(req.get("title") or "").strip() or title_from_filename(file_name),
|
||||
"ratio": str(req.get("ratio") or ""),
|
||||
"width": int(req.get("width") or 0),
|
||||
"height": int(req.get("height") or 0),
|
||||
"cover_url": _media_url_from_payload(req, "cover_url", "cover_key"),
|
||||
"video_url": video_url,
|
||||
"estimated_cost": str(task.estimated_cost),
|
||||
"error_message": (public_error.fallback_message if public_error else "") or str(task.error_message or ""),
|
||||
}
|
||||
|
||||
|
||||
def get_team_digest_job(*, team, task_id) -> dict | None:
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = AITask.objects.filter(
|
||||
id=task_id,
|
||||
team=team,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
project__isnull=True,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
).first()
|
||||
if task is None:
|
||||
return None
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
def get_inflight_team_digest(*, team) -> dict | None:
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = (
|
||||
AITask.objects.filter(
|
||||
team=team,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
project__isnull=True,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
status__in={
|
||||
AITask.Status.CREATED,
|
||||
AITask.Status.RESERVED,
|
||||
AITask.Status.SUBMITTED,
|
||||
AITask.Status.POLLING,
|
||||
AITask.Status.POSTPROCESSING,
|
||||
},
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if task is None:
|
||||
return None
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict:
|
||||
"""秒回任务 id。慢活(抽帧 + Gemini)交给 worker,离开页面也不中断。"""
|
||||
from django.db import transaction
|
||||
|
||||
from apps.ai.models import AITask
|
||||
from apps.ai.tasks import run_video_digest_task
|
||||
from apps.billing.pricing import quote_video_digest
|
||||
from apps.billing.services.ledger import reserve_credit
|
||||
|
||||
model_config = resolve_digest_model_config(preferred_id=model_config_id)
|
||||
if model_config is None:
|
||||
raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
|
||||
|
||||
path, suffix, size, duration = _materialize_upload(upload)
|
||||
file_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4"
|
||||
width = height = 0
|
||||
source_key = source_url = cover_key = cover_url = ""
|
||||
try:
|
||||
width, height = probe_video_size(path)
|
||||
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extract_cover_jpeg(path, duration))
|
||||
source_key, source_url = _store_raw_source(team=team, path=path, suffix=suffix)
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
if not source_key:
|
||||
raise VideoDigestError("参考视频上传失败,请重试")
|
||||
|
||||
quote = quote_video_digest(team=team, model_config=model_config)
|
||||
request_payload = {
|
||||
"model": model_config.name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"feature": "video_remix",
|
||||
"duration_seconds": round(duration, 2),
|
||||
"file_name": file_name,
|
||||
"title": title_from_filename(file_name),
|
||||
"file_size": size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"ratio": ratio_label(width, height),
|
||||
"suffix": suffix,
|
||||
"source_key": source_key,
|
||||
"source_url": source_url,
|
||||
"cover_key": cover_key,
|
||||
"cover_url": cover_url,
|
||||
}
|
||||
if quote.meta.get("rate"):
|
||||
request_payload["points_per_yuan_snapshot"] = quote.meta["rate"]
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
task = AITask.objects.create(
|
||||
team=team,
|
||||
created_by=user,
|
||||
project=None,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
status=AITask.Status.CREATED,
|
||||
model_config=model_config,
|
||||
idempotency_key=f"video_digest:{team.id}:{uuid.uuid4()}",
|
||||
request_payload=request_payload,
|
||||
estimated_cost=quote.points,
|
||||
base_cost=quote.base_cost_yuan,
|
||||
)
|
||||
reserve_credit(team=team, user=user, task=task, amount=quote.points)
|
||||
task.status = AITask.Status.RESERVED
|
||||
task.save(update_fields=["status", "updated_at"])
|
||||
except ValueError as exc:
|
||||
if "insufficient credit" in str(exc).lower():
|
||||
raise VideoDigestError("团队余额不足,请充值后重试") from exc
|
||||
raise VideoDigestError(str(exc)) from exc
|
||||
|
||||
run_video_digest_task.delay(str(task.id))
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
def run_team_digest_task(*, task_id: str) -> None:
|
||||
"""Worker:从 TOS 取参考视频,跑抽帧 + Gemini,失败退费。"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = AITask.objects.select_related("team", "created_by", "model_config", "credit_reservation").filter(
|
||||
id=task_id,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
project__isnull=True,
|
||||
).first()
|
||||
if task is None:
|
||||
return
|
||||
if task.status == AITask.Status.SUCCEEDED:
|
||||
return
|
||||
if task.status not in {AITask.Status.RESERVED, AITask.Status.SUBMITTED}:
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=task.id)
|
||||
if locked.status == AITask.Status.SUCCEEDED:
|
||||
return
|
||||
if locked.status not in {AITask.Status.RESERVED, AITask.Status.SUBMITTED}:
|
||||
return
|
||||
locked.status = AITask.Status.SUBMITTED
|
||||
locked.submitted_at = locked.submitted_at or timezone.now()
|
||||
locked.save(update_fields=["status", "submitted_at", "updated_at"])
|
||||
task = locked
|
||||
|
||||
req = task.request_payload or {}
|
||||
source_key = str(req.get("source_key") or "")
|
||||
suffix = str(req.get("suffix") or ".mp4")
|
||||
file_name = str(req.get("file_name") or "参考视频.mp4")
|
||||
if not source_key:
|
||||
reservation = getattr(task, "credit_reservation", None)
|
||||
_fail_digest_task(task, reservation, "参考视频丢失,请重新上传")
|
||||
return
|
||||
|
||||
local_path = ""
|
||||
try:
|
||||
local_path = _download_source_to_temp(source_key, suffix)
|
||||
upload = _FileFromPath(local_path, file_name)
|
||||
_digest_video(
|
||||
team=task.team,
|
||||
user=task.created_by,
|
||||
upload=upload,
|
||||
project=None,
|
||||
product_hint="",
|
||||
model_config_id=str(task.model_config_id) if task.model_config_id else None,
|
||||
existing_task=task,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("async video digest failed for %s", task.id)
|
||||
task.refresh_from_db()
|
||||
if task.status not in {AITask.Status.SUCCEEDED, AITask.Status.FAILED}:
|
||||
reservation = getattr(task, "credit_reservation", None)
|
||||
_fail_digest_task(task, reservation, str(exc))
|
||||
finally:
|
||||
if local_path:
|
||||
Path(local_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None, existing_task=None) -> dict:
|
||||
"""上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
@@ -720,7 +987,11 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
height = int(extras.get("height") or 0)
|
||||
file_size = int(extras.get("file_size") or getattr(upload, "size", 0) or 0)
|
||||
|
||||
model_config = resolve_digest_model_config(preferred_id=model_config_id)
|
||||
model_config = (
|
||||
existing_task.model_config
|
||||
if existing_task is not None and existing_task.model_config_id
|
||||
else resolve_digest_model_config(preferred_id=model_config_id)
|
||||
)
|
||||
if model_config is None:
|
||||
if source_path:
|
||||
Path(source_path).unlink(missing_ok=True)
|
||||
@@ -765,7 +1036,13 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
if quote.meta.get("rate"):
|
||||
request_payload = {**request_payload, "points_per_yuan_snapshot": quote.meta["rate"]}
|
||||
|
||||
if project is not None:
|
||||
if existing_task is not None:
|
||||
task = existing_task
|
||||
existing_req = dict(task.request_payload or {})
|
||||
request_payload = {**existing_req, **request_payload}
|
||||
task.request_payload = request_payload
|
||||
task.save(update_fields=["request_payload", "updated_at"])
|
||||
elif project is not None:
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
user=user,
|
||||
@@ -831,18 +1108,25 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
_fail_digest_task(task, reservation, str(exc))
|
||||
raise
|
||||
|
||||
cover_key, cover_url, video_key, video_url = "", "", "", ""
|
||||
existing_req = dict(task.request_payload or {})
|
||||
cover_key = str(existing_req.get("cover_key") or "")
|
||||
cover_url = str(existing_req.get("cover_url") or "")
|
||||
video_key = str(existing_req.get("video_key") or "")
|
||||
video_url = str(existing_req.get("video_url") or "")
|
||||
if project is None:
|
||||
if not cover_key:
|
||||
try:
|
||||
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extras.get("cover_jpeg") or b"")
|
||||
except Exception: # noqa: BLE001 — 封面失败不挡拆解结果
|
||||
logger.warning("video digest cover upload failed", exc_info=True)
|
||||
try:
|
||||
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extras.get("cover_jpeg") or b"")
|
||||
except Exception: # noqa: BLE001 — 封面失败不挡拆解结果
|
||||
logger.warning("video digest cover upload failed", exc_info=True)
|
||||
try:
|
||||
video_key, video_url = _store_digest_video(
|
||||
stored_key, stored_url = _store_digest_video(
|
||||
team=team,
|
||||
path=source_path,
|
||||
suffix=str(extras.get("suffix") or ".mp4"),
|
||||
)
|
||||
if stored_key:
|
||||
video_key, video_url = stored_key, stored_url
|
||||
except Exception: # noqa: BLE001 — 原片失败仍可看提示词,封面不能播
|
||||
logger.warning("video digest source upload failed", exc_info=True)
|
||||
if source_path:
|
||||
@@ -851,13 +1135,13 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
|
||||
shots = shot_count(digest)
|
||||
request_payload = {
|
||||
**(task.request_payload or {}),
|
||||
**existing_req,
|
||||
**request_payload,
|
||||
"shot_count": shots,
|
||||
"cover_key": cover_key,
|
||||
"cover_url": cover_url,
|
||||
"video_key": video_key,
|
||||
"video_url": video_url,
|
||||
"cover_key": cover_key or existing_req.get("cover_key") or "",
|
||||
"cover_url": cover_url or existing_req.get("cover_url") or "",
|
||||
"video_key": video_key or existing_req.get("video_key") or "",
|
||||
"video_url": video_url or existing_req.get("video_url") or "",
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""视频复刻:参考视频 + 商品图/人物图 → Seedance 换商品或换角色。
|
||||
|
||||
不新建任务类型、不接检测/抠图。提交仍走 submit_free_video,只把
|
||||
feature=video_replace 和 replace_mode 写进 payload,提示词由后端写死。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.assets.models import Asset, Model
|
||||
from apps.products.models import Product
|
||||
|
||||
from .free_video import HIGH_RES_MODEL, serialize_free_video_task, submit_free_video
|
||||
from .media_probe import REF_DURATION_MAX
|
||||
|
||||
FEATURE = "video_replace"
|
||||
REPLACE_MODES = {"product", "character"}
|
||||
MAX_IMAGES = 9
|
||||
LEGACY_PROMPT_PREFIX = "[视频复刻]"
|
||||
|
||||
PRODUCT_PROMPT = (
|
||||
"使用@参考视频作为镜头、节奏与口播氛围基准,"
|
||||
"将画面中需要替换的原商品完整替换为@目标商品的外观。"
|
||||
"保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围,"
|
||||
"商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。"
|
||||
)
|
||||
CHARACTER_PROMPT = (
|
||||
"使用@参考视频作为镜头、节奏与口播氛围基准,"
|
||||
"将画面中需要替换的原人物完整替换为@目标角色。"
|
||||
"保留参考视频的商品、场景、镜头运动、剪辑节奏与口播氛围,"
|
||||
"角色五官、发型、体态必须与参考图一致,不要改变原片构图和商品展示。"
|
||||
)
|
||||
|
||||
|
||||
def is_video_replace_task(task) -> bool:
|
||||
payload = task.request_payload or {}
|
||||
if payload.get("feature") == FEATURE:
|
||||
return True
|
||||
return str(payload.get("prompt") or "").startswith(LEGACY_PROMPT_PREFIX)
|
||||
|
||||
|
||||
def video_replace_q() -> Q:
|
||||
return Q(request_payload__feature=FEATURE) | Q(request_payload__prompt__startswith=LEGACY_PROMPT_PREFIX)
|
||||
|
||||
|
||||
def serialize_video_replace_task(task, *, include_deleted_assets: bool = False) -> dict:
|
||||
data = serialize_free_video_task(task, include_deleted_assets=include_deleted_assets)
|
||||
payload = task.request_payload or {}
|
||||
replace_mode = payload.get("replace_mode") or _legacy_replace_mode(payload.get("prompt") or "")
|
||||
data.update({
|
||||
"feature": FEATURE,
|
||||
"replace_mode": replace_mode,
|
||||
"subject_name": payload.get("subject_name") or "",
|
||||
"subject_source": payload.get("subject_source") or "",
|
||||
"product_id": payload.get("product_id") or "",
|
||||
"model_id": payload.get("model_id") or "",
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
def submit_video_replace(*, team, user, params: dict):
|
||||
"""校验素材 → 套提示词 → 复用 free_video 提交。失败抛 ValueError。"""
|
||||
replace_mode = str(params.get("replace_mode") or "").strip()
|
||||
if replace_mode not in REPLACE_MODES:
|
||||
raise ValueError("请选择替换商品或替换角色")
|
||||
|
||||
product_id = _optional_uuid(params.get("product_id"), "商品")
|
||||
model_id = _optional_uuid(params.get("model_id"), "角色")
|
||||
image_ids = _uuid_list(params.get("image_asset_ids"), "参考图")
|
||||
has_product = product_id is not None
|
||||
has_model = model_id is not None
|
||||
has_temp = bool(image_ids)
|
||||
|
||||
if replace_mode == "product":
|
||||
if has_model:
|
||||
raise ValueError("商品复刻请选择商品,不要同时选择角色")
|
||||
if has_product and has_temp:
|
||||
raise ValueError("请从商品库选择,或临时上传商品图,不要混用")
|
||||
if not has_product and not has_temp:
|
||||
raise ValueError("请选择商品或上传商品参考图")
|
||||
else:
|
||||
if has_product:
|
||||
raise ValueError("角色复刻请选择角色,不要同时选择商品")
|
||||
if has_model and has_temp:
|
||||
raise ValueError("请从人物库选择,或临时上传角色图,不要混用")
|
||||
if not has_model and not has_temp:
|
||||
raise ValueError("请选择角色或上传角色参考图")
|
||||
|
||||
video = _team_asset(team, params.get("video_asset_id"), kind=Asset.Type.VIDEO, label="参考视频")
|
||||
video_seconds = _asset_duration_seconds(video)
|
||||
if video_seconds > REF_DURATION_MAX:
|
||||
raise ValueError("参考视频不能超过 15 秒,请剪短后重试")
|
||||
|
||||
if has_product:
|
||||
subject_name, image_refs, subject_source = _product_library_refs(team, product_id)
|
||||
elif has_model:
|
||||
subject_name, image_refs, subject_source = _character_library_refs(team, model_id)
|
||||
else:
|
||||
noun = "商品" if replace_mode == "product" else "角色"
|
||||
subject_name, image_refs, subject_source = _temporary_image_refs(team, image_ids, noun=noun)
|
||||
|
||||
prompt = PRODUCT_PROMPT if replace_mode == "product" else CHARACTER_PROMPT
|
||||
duration = _output_duration(params.get("duration"), video_seconds)
|
||||
references = [
|
||||
_owned_ref(video, kind="video", role="reference_video", label="参考视频"),
|
||||
*image_refs,
|
||||
]
|
||||
return submit_free_video(
|
||||
team=team,
|
||||
user=user,
|
||||
params={
|
||||
"prompt": prompt,
|
||||
"mode": "universal",
|
||||
"model": str(params.get("model") or HIGH_RES_MODEL),
|
||||
"aspect_ratio": str(params.get("aspect_ratio") or "9:16"),
|
||||
"resolution": str(params.get("resolution") or "720p"),
|
||||
"duration": duration,
|
||||
"seed": params.get("seed", -1),
|
||||
"generate_audio": True,
|
||||
"references": references,
|
||||
"feature": FEATURE,
|
||||
"extra_payload": {
|
||||
"replace_mode": replace_mode,
|
||||
"subject_name": subject_name,
|
||||
"subject_source": subject_source,
|
||||
"product_id": str(product_id) if product_id else "",
|
||||
"model_id": str(model_id) if model_id else "",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _legacy_replace_mode(prompt: str) -> str:
|
||||
return "character" if prompt.startswith("[视频复刻·角色]") else "product"
|
||||
|
||||
|
||||
def _optional_uuid(value, label: str):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(text)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{label}无效") from exc
|
||||
|
||||
|
||||
def _uuid_list(value, label: str) -> list:
|
||||
if value in (None, ""):
|
||||
return []
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise ValueError(f"{label}格式无效")
|
||||
if len(value) > MAX_IMAGES:
|
||||
raise ValueError(f"{label}最多 {MAX_IMAGES} 张")
|
||||
seen = set()
|
||||
out = []
|
||||
for item in value:
|
||||
parsed = _optional_uuid(item, label)
|
||||
if parsed is None or parsed in seen:
|
||||
continue
|
||||
seen.add(parsed)
|
||||
out.append(parsed)
|
||||
return out
|
||||
|
||||
|
||||
def _team_asset(team, asset_id, *, kind: str, label: str) -> Asset:
|
||||
parsed = _optional_uuid(asset_id, label)
|
||||
if parsed is None:
|
||||
raise ValueError(f"请先上传{label}")
|
||||
asset = Asset.objects.filter(id=parsed, team=team, is_deleted=False, purged_at__isnull=True).first()
|
||||
if asset is None:
|
||||
raise ValueError(f"{label}不存在或已被删除")
|
||||
if asset.asset_type != kind:
|
||||
raise ValueError(f"{label}类型不正确")
|
||||
return asset
|
||||
|
||||
|
||||
def _asset_duration_seconds(asset: Asset) -> float:
|
||||
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
|
||||
if primary is None or not primary.duration_ms:
|
||||
return 0.0
|
||||
return primary.duration_ms / 1000.0
|
||||
|
||||
|
||||
def _output_duration(requested, video_seconds: float) -> int:
|
||||
try:
|
||||
value = int(requested) if requested not in (None, "") else 0
|
||||
except (TypeError, ValueError):
|
||||
value = 0
|
||||
if value:
|
||||
return min(15, max(4, value))
|
||||
if video_seconds:
|
||||
return min(15, max(4, int(round(video_seconds))))
|
||||
return 15
|
||||
|
||||
|
||||
def _owned_ref(asset: Asset, *, kind: str, role: str, label: str) -> dict:
|
||||
from .services import _asset_preview_url
|
||||
|
||||
url = _asset_preview_url(asset)
|
||||
if not url:
|
||||
raise ValueError(f"「{label}」没有可用文件")
|
||||
ref = {
|
||||
"url": url,
|
||||
"type": kind,
|
||||
"role": role,
|
||||
"label": label,
|
||||
"source": "upload",
|
||||
"asset_id": str(asset.id),
|
||||
}
|
||||
seconds = _asset_duration_seconds(asset)
|
||||
if seconds:
|
||||
ref["duration"] = seconds
|
||||
return ref
|
||||
|
||||
|
||||
def _library_image_ref(asset: Asset, *, team, label: str) -> dict:
|
||||
from .services import _asset_preview_url, _seedance_ref_url
|
||||
|
||||
if asset.team_id == team.id and not asset.is_deleted:
|
||||
return {
|
||||
"url": _asset_preview_url(asset) or "",
|
||||
"type": "image",
|
||||
"role": "reference_image",
|
||||
"label": label,
|
||||
"source": "asset",
|
||||
"asset_id": str(asset.id),
|
||||
}
|
||||
raw = _asset_preview_url(asset)
|
||||
url = _seedance_ref_url(raw, asset.review_status, asset.review_remote_id)
|
||||
if not url:
|
||||
raise ValueError(f"「{label}」没有可用文件")
|
||||
return {
|
||||
"url": url,
|
||||
"type": "image",
|
||||
"role": "reference_image",
|
||||
"label": label,
|
||||
"source": "upload",
|
||||
"asset_id": str(asset.id),
|
||||
}
|
||||
|
||||
|
||||
def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]:
|
||||
product = (
|
||||
Product.objects.filter(id=product_id, team=team, purged_at__isnull=True, status=Product.Status.ACTIVE)
|
||||
.select_related("cover_asset")
|
||||
.prefetch_related("images__asset")
|
||||
.first()
|
||||
)
|
||||
if product is None:
|
||||
raise ValueError("商品不存在或已被删除")
|
||||
assets = []
|
||||
seen = set()
|
||||
for image in product.images.all():
|
||||
asset = image.asset
|
||||
if asset is None or asset.id in seen or asset.is_deleted:
|
||||
continue
|
||||
seen.add(asset.id)
|
||||
assets.append(asset)
|
||||
if len(assets) >= MAX_IMAGES:
|
||||
break
|
||||
if not assets and product.cover_asset_id and not product.cover_asset.is_deleted:
|
||||
assets.append(product.cover_asset)
|
||||
if not assets:
|
||||
raise ValueError("这个商品还没有可用图片")
|
||||
refs = [_library_image_ref(asset, team=team, label="目标商品" if index == 0 else f"目标商品{index + 1}") for index, asset in enumerate(assets)]
|
||||
return product.title, refs, "library"
|
||||
|
||||
|
||||
def _character_library_refs(team, model_id: uuid.UUID) -> tuple[str, list, str]:
|
||||
model = (
|
||||
Model.objects.filter(Q(team=team) | Q(is_official=True), id=model_id, is_deleted=False, purged_at__isnull=True)
|
||||
.select_related("portrait_asset", "triview_asset")
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
raise ValueError("角色不存在或已被删除")
|
||||
assets = []
|
||||
seen = set()
|
||||
for asset in (model.portrait_asset, model.triview_asset):
|
||||
if asset is None or asset.id in seen or asset.is_deleted:
|
||||
continue
|
||||
seen.add(asset.id)
|
||||
assets.append(asset)
|
||||
if len(assets) >= MAX_IMAGES:
|
||||
break
|
||||
if not assets:
|
||||
raise ValueError("这个角色还没有可用图片")
|
||||
labels = ["目标角色", "目标角色三视图"]
|
||||
refs = [_library_image_ref(asset, team=team, label=labels[index] if index < len(labels) else f"目标角色{index + 1}") for index, asset in enumerate(assets)]
|
||||
return model.name, refs, "library"
|
||||
|
||||
|
||||
def _temporary_image_refs(team, image_ids: list, *, noun: str) -> tuple[str, list, str]:
|
||||
refs = []
|
||||
for index, asset_id in enumerate(image_ids):
|
||||
asset = _team_asset(team, asset_id, kind=Asset.Type.IMAGE, label=f"{noun}参考图")
|
||||
label = "目标商品" if noun == "商品" else "目标角色"
|
||||
if index > 0:
|
||||
label = f"{label}{index + 1}"
|
||||
refs.append(_owned_ref(asset, kind="image", role="reference_image", label=label))
|
||||
fallback = "临时商品素材" if noun == "商品" else "临时角色素材"
|
||||
name = Asset.objects.filter(id=image_ids[0]).values_list("name", flat=True).first() or fallback
|
||||
subject = name.rsplit(".", 1)[0] if name else fallback
|
||||
if len(refs) > 1:
|
||||
subject = f"{subject}({len(refs)}张参考图)"
|
||||
return subject, refs, "temporary"
|
||||
+110
-13
@@ -15,7 +15,7 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.serializers import AssetFileSerializer, AssetSerializer
|
||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||
from apps.common.celery_health import require_worker
|
||||
from apps.common.celery_health import require_worker, require_worker_task
|
||||
from apps.products.models import Product
|
||||
|
||||
from .generation_errors import classify_generation_error, public_error_for_task
|
||||
@@ -666,11 +666,13 @@ def _free_video_task_queryset(team):
|
||||
)
|
||||
|
||||
|
||||
def _free_video_list_queryset(team):
|
||||
def _free_video_list_queryset(team, *, include_replace=False):
|
||||
"""正常任务流隐藏已从资产库删除的成品,但保留生成中/失败及无落库资产的历史任务。"""
|
||||
from .video_replace import video_replace_q
|
||||
|
||||
video_assets = Asset.objects.filter(origin_task_id=OuterRef("pk"), asset_type=Asset.Type.VIDEO)
|
||||
active_video_assets = video_assets.filter(is_deleted=False, purged_at__isnull=True)
|
||||
return (
|
||||
qs = (
|
||||
_free_video_task_queryset(team)
|
||||
.annotate(
|
||||
_has_video_asset=Exists(video_assets),
|
||||
@@ -682,6 +684,9 @@ def _free_video_list_queryset(team):
|
||||
_has_active_video_asset=False,
|
||||
)
|
||||
)
|
||||
if include_replace:
|
||||
return qs.filter(video_replace_q())
|
||||
return qs.exclude(video_replace_q())
|
||||
|
||||
|
||||
def _free_video_trash_queryset(team):
|
||||
@@ -699,28 +704,33 @@ def _set_free_video_generated_assets_deleted(task, deleted):
|
||||
class VideoDigestView(APIView):
|
||||
"""视频提炼 · 上传参考视频提炼分镜稿(不绑项目)。
|
||||
|
||||
POST /api/ai/video-digest/ multipart file → 中文分镜稿。慢(30~60 秒),固定 30 积分/次,失败退还。
|
||||
POST /api/ai/video-digest/ multipart file → 秒回任务,worker 抽帧 + Gemini。失败退还。
|
||||
GET /api/ai/video-digest/ 本团队已完成的提炼历史(新→旧)。
|
||||
"""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
def get(self, request):
|
||||
from .video_digest import list_team_digest_history
|
||||
from .video_digest import get_inflight_team_digest, list_team_digest_history
|
||||
|
||||
team = get_current_team(request.user)
|
||||
results = list_team_digest_history(team=team)
|
||||
return Response({"results": results, "total": len(results)})
|
||||
return Response({
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
"inflight": get_inflight_team_digest(team=team),
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
from .video_digest import VideoDigestError, digest_team_video
|
||||
|
||||
upload = request.FILES.get("file") or request.data.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "请先上传参考视频"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
require_worker_task("apps.ai.tasks.run_video_digest_task")
|
||||
from .video_digest import VideoDigestError, submit_team_digest
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
result = digest_team_video(
|
||||
result = submit_team_digest(
|
||||
team=team,
|
||||
user=request.user,
|
||||
upload=upload,
|
||||
@@ -735,14 +745,25 @@ class VideoDigestView(APIView):
|
||||
{"detail": public_error.fallback_message, "error": public_error.as_dict()},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
return Response({"name": getattr(upload, "name", "") or "参考视频", **result})
|
||||
return Response({"name": getattr(upload, "name", "") or "参考视频", **result}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
class VideoDigestDetailView(APIView):
|
||||
"""PATCH /api/ai/video-digest/<id>/ 保存编辑后的提示词到这条历史。"""
|
||||
"""GET /api/ai/video-digest/<id>/ 轮询提炼任务。
|
||||
PATCH /api/ai/video-digest/<id>/ 保存编辑后的提示词到这条历史。
|
||||
"""
|
||||
|
||||
parser_classes = [JSONParser, FormParser]
|
||||
|
||||
def get(self, request, task_id):
|
||||
from .video_digest import get_team_digest_job
|
||||
|
||||
team = get_current_team(request.user)
|
||||
item = get_team_digest_job(team=team, task_id=task_id)
|
||||
if item is None:
|
||||
return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(item)
|
||||
|
||||
def patch(self, request, task_id):
|
||||
from .video_digest import VideoDigestError, save_digest_prompt
|
||||
|
||||
@@ -817,6 +838,82 @@ class FreeVideoView(APIView):
|
||||
)
|
||||
|
||||
|
||||
class VideoReplaceView(APIView):
|
||||
"""视频复刻:参考视频 + 商品图/人物图,走 Seedance 换商品或换角色。
|
||||
|
||||
POST /api/ai/video-replace/ 提交(提示词后端写死)
|
||||
GET /api/ai/video-replace/ 本页历史(不含自由创作)
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
require_worker()
|
||||
from .video_replace import serialize_video_replace_task, submit_video_replace
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
task = submit_video_replace(team=team, user=request.user, params=request.data or {})
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
internal_kind = (
|
||||
"user_credit_insufficient" if "余额不足" in message
|
||||
else "model_unavailable" if "模型未配置" in message
|
||||
else "provider_rate_limited" if "任务进行中" in message
|
||||
else "invalid_input"
|
||||
)
|
||||
public_error = classify_generation_error(
|
||||
exc, operation="video_generate", internal_kind=internal_kind
|
||||
)
|
||||
return Response(
|
||||
{"detail": public_error.fallback_message, "error": public_error.as_dict()},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
task = _free_video_task_queryset(team).get(id=task.id)
|
||||
return Response({"task": serialize_video_replace_task(task)}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
def get(self, request):
|
||||
from .video_replace import serialize_video_replace_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
offset = max(0, int(request.query_params.get("offset") or 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
try:
|
||||
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 20
|
||||
qs = _free_video_list_queryset(team, include_replace=True).order_by("-created_at")
|
||||
total = qs.count()
|
||||
tasks = list(qs[offset : offset + page_size])
|
||||
return Response(
|
||||
{
|
||||
"results": [serialize_video_replace_task(t) for t in tasks],
|
||||
"total": total,
|
||||
"has_more": offset + page_size < total,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class VideoReplacePollView(APIView):
|
||||
"""POST /api/ai/video-replace/<id>/poll/ —— 与自由创作共用 finalize,只认复刻任务。"""
|
||||
|
||||
def post(self, request, task_id):
|
||||
from .free_video import finalize_free_video
|
||||
from .video_replace import is_video_replace_task, serialize_video_replace_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
task = _free_video_task_queryset(team).filter(id=task_id).first()
|
||||
if task is None or not is_video_replace_task(task):
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
try:
|
||||
task = finalize_free_video(task=task)
|
||||
except Exception: # noqa: BLE001 — 单次轮询失败不终结任务
|
||||
logger.warning("video replace poll failed for %s", task_id, exc_info=True)
|
||||
task = _free_video_task_queryset(team).get(id=task.id)
|
||||
return Response({"task": serialize_video_replace_task(task)})
|
||||
|
||||
|
||||
class FreeVideoPollView(APIView):
|
||||
"""POST /api/ai/free-video/<id>/poll/ —— web 进程内单次轮询+终态化(幂等)。
|
||||
前端渐进轮询打这里;本地无 worker 也能全程收尾(与 pipeline poll-video-segment 同模式)。"""
|
||||
@@ -963,7 +1060,7 @@ class FreeVideoUploadView(APIView):
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
from .media_probe import extract_video_poster, probe_duration
|
||||
from .media_probe import duration_in_ref_range, extract_video_poster, probe_duration
|
||||
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
@@ -1015,7 +1112,7 @@ class FreeVideoUploadView(APIView):
|
||||
duration = probe_duration(str(tmp_path))
|
||||
if duration is None:
|
||||
return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not (2 <= duration <= 15):
|
||||
if not duration_in_ref_range(duration):
|
||||
label = "视频" if kind == "video" else "音频"
|
||||
return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if kind == "video":
|
||||
|
||||
@@ -123,7 +123,7 @@ def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only:
|
||||
file_id = uuid.uuid4()
|
||||
|
||||
if kind != "image":
|
||||
from apps.ai.media_probe import extract_video_poster, probe_duration
|
||||
from apps.ai.media_probe import duration_in_ref_range, extract_video_poster, probe_duration
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-fc-lib-") as tmp:
|
||||
tmp_path = Path(tmp) / f"in{suffix}"
|
||||
@@ -131,7 +131,7 @@ def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only:
|
||||
duration = probe_duration(str(tmp_path))
|
||||
if duration is None:
|
||||
return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not (2 <= duration <= 15):
|
||||
if not duration_in_ref_range(duration):
|
||||
label = "视频" if kind == "video" else "音频"
|
||||
return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if kind == "video":
|
||||
|
||||
@@ -116,6 +116,73 @@ def reference_review_state(asset: Asset) -> str:
|
||||
return "unsubmitted"
|
||||
|
||||
|
||||
def _unsubmitted_review_qs(*, team=None):
|
||||
qs = Asset.objects.filter(
|
||||
category__in=Asset.REVIEW_CATEGORIES,
|
||||
review_status="",
|
||||
is_deleted=False,
|
||||
)
|
||||
if team is not None:
|
||||
qs = qs.filter(team=team)
|
||||
return qs
|
||||
|
||||
|
||||
def _processing_review_qs(*, team=None):
|
||||
qs = Asset.objects.filter(
|
||||
category__in=Asset.REVIEW_CATEGORIES,
|
||||
review_status="processing",
|
||||
is_deleted=False,
|
||||
)
|
||||
if team is not None:
|
||||
qs = qs.filter(team=team)
|
||||
return qs
|
||||
|
||||
|
||||
def submit_unsubmitted_reviews(*, team=None, limit: int = 25) -> int:
|
||||
"""把未送审的人脸资产自动丢给火山,不用人去后台点「批量送审」。"""
|
||||
submitted = 0
|
||||
cap = max(1, min(int(limit), 50))
|
||||
for asset in _unsubmitted_review_qs(team=team).order_by("created_at")[:cap]:
|
||||
if submit_asset_for_review(asset):
|
||||
submitted += 1
|
||||
return submitted
|
||||
|
||||
|
||||
def poll_processing_reviews(*, team=None, limit: int = 80) -> dict[str, str]:
|
||||
cap = max(1, min(int(limit), 200))
|
||||
out: dict[str, str] = {}
|
||||
for asset in _processing_review_qs(team=team).order_by("updated_at")[:cap]:
|
||||
out[str(asset.id)] = poll_asset_review(asset)
|
||||
return out
|
||||
|
||||
|
||||
def drain_platform_reviews(*, submit_limit: int = 25, poll_limit: int = 80) -> dict:
|
||||
"""全平台:未送审自动送 + 审核中自动拉状态。worker 兜底循环,人不在后台页也会跑。"""
|
||||
submitted = submit_unsubmitted_reviews(limit=submit_limit)
|
||||
statuses = poll_processing_reviews(limit=poll_limit)
|
||||
return {"submitted": submitted, "polled": len(statuses), "statuses": statuses}
|
||||
|
||||
|
||||
def ensure_review_drain_loop() -> None:
|
||||
"""第一次有人轮询审核时,顺手把 worker 兜底循环拉起来(没有 beat 也能自转)。"""
|
||||
import sys
|
||||
|
||||
from django.conf import settings as dj_settings
|
||||
from django.core.cache import cache
|
||||
|
||||
if getattr(dj_settings, "CELERY_TASK_ALWAYS_EAGER", False) or "test" in sys.argv:
|
||||
return
|
||||
if not cache.add("asset-review-drain-kick", "1", timeout=300):
|
||||
return
|
||||
try:
|
||||
from apps.ai.tasks import drain_asset_reviews_task
|
||||
|
||||
drain_asset_reviews_task.delay()
|
||||
except Exception: # noqa: BLE001
|
||||
cache.delete("asset-review-drain-kick")
|
||||
logger.warning("kick asset review drain loop failed", exc_info=True)
|
||||
|
||||
|
||||
def poll_asset_review(asset: Asset) -> str:
|
||||
"""查单个真人资产审核状态并更新 review_status。返回最新状态。
|
||||
只在状态变化时落库(保留 updated_at 作为「进入 processing 的时刻」);processing 超时兜底为 failed。"""
|
||||
@@ -144,11 +211,8 @@ def poll_asset_review(asset: Asset) -> str:
|
||||
|
||||
|
||||
def poll_team_reviews(team) -> dict:
|
||||
"""轮询该团队所有「审核中」真人资产,更新状态。返回 {asset_id: status}。"""
|
||||
out: dict[str, str] = {}
|
||||
pending = Asset.objects.filter(
|
||||
team=team, category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False
|
||||
)
|
||||
for asset in pending:
|
||||
out[str(asset.id)] = poll_asset_review(asset)
|
||||
return out
|
||||
"""先把本团队未送审的人脸资产送出去,再轮询审核中状态。
|
||||
前端基础资产趴定时打这里:用户不用去后台点送审,也不用等管理员。"""
|
||||
ensure_review_drain_loop()
|
||||
submit_unsubmitted_reviews(team=team, limit=20)
|
||||
return poll_processing_reviews(team=team, limit=80)
|
||||
|
||||
@@ -890,3 +890,40 @@ class ReviewScopeTests(TestCase):
|
||||
with patch("apps.assets.review.assets_client.is_enabled", return_value=False):
|
||||
out = review.poll_team_reviews(team)
|
||||
self.assertEqual(set(out.keys()), set(keep.values()))
|
||||
|
||||
def test_poll_team_reviews_auto_submits_unsubmitted(self):
|
||||
from apps.assets import review
|
||||
|
||||
_, team = _mk_team("usub", "TeamUSub")
|
||||
empty = Asset.objects.create(
|
||||
team=team, name="待审角色", asset_type="image", source="ai_generated",
|
||||
category=Asset.Category.PERSON, review_status="",
|
||||
)
|
||||
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub, patch(
|
||||
"apps.assets.review.ensure_review_drain_loop"
|
||||
):
|
||||
review.poll_team_reviews(team)
|
||||
sub.assert_called()
|
||||
self.assertEqual(sub.call_args.args[0].id, empty.id)
|
||||
|
||||
def test_drain_platform_reviews_submits_then_polls(self):
|
||||
from apps.assets import review
|
||||
|
||||
_, team = _mk_team("drain", "TeamDrain")
|
||||
Asset.objects.create(
|
||||
team=team, name="未送", asset_type="image", source="ai_generated",
|
||||
category=Asset.Category.PERSON, review_status="",
|
||||
)
|
||||
proc = Asset.objects.create(
|
||||
team=team, name="审中", asset_type="image", source="ai_generated",
|
||||
category=Asset.Category.PERSON, review_status="processing",
|
||||
)
|
||||
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub, patch(
|
||||
"apps.assets.review.poll_asset_review", return_value="active"
|
||||
) as polled:
|
||||
out = review.drain_platform_reviews()
|
||||
self.assertEqual(out["submitted"], 1)
|
||||
self.assertEqual(out["polled"], 1)
|
||||
self.assertEqual(out["statuses"][str(proc.id)], "active")
|
||||
sub.assert_called_once()
|
||||
polled.assert_called_once()
|
||||
|
||||
@@ -142,5 +142,5 @@ def require_worker_task(task_name: str) -> None:
|
||||
_registered_task_cache[task_name] = (available, now + ttl)
|
||||
if available is False:
|
||||
exc = WorkerUnavailable()
|
||||
exc.detail = "极速成片后台任务尚未加载,请重启 Celery worker 后再试。"
|
||||
exc.detail = "一键成片后台任务尚未加载,请重启 Celery worker 后再试。"
|
||||
raise exc
|
||||
|
||||
@@ -170,14 +170,14 @@ def _public_error(raw: str) -> str:
|
||||
text = (raw or "").strip()
|
||||
lower = text.lower()
|
||||
if QUICK_SCRIPT_MODEL_NAME in lower:
|
||||
return "极速成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"
|
||||
return "一键成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"
|
||||
if "insufficient credit" in lower or "额度不足" in text:
|
||||
return "可用积分不足,极速成片已暂停"
|
||||
return "可用积分不足,一键成片已暂停"
|
||||
if "no active" in lower or "not configured" in lower or "没有可用" in text:
|
||||
return "当前缺少可用的生成模型,请联系管理员配置"
|
||||
if _looks_like_review_error(text):
|
||||
return REVIEW_FAIL_MESSAGE
|
||||
return "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
return "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
|
||||
|
||||
def _safe_error(exc: Exception) -> str:
|
||||
@@ -301,7 +301,7 @@ def fail_quick_create(job: QuickCreateJob, message: str, *, internal_error: str
|
||||
job.refresh_from_db(fields=["status", "metadata", "phase"])
|
||||
if job.status in {QuickCreateJob.Status.SUCCEEDED, QuickCreateJob.Status.CANCELLED}:
|
||||
return
|
||||
public_message = (message or "极速成片暂未完成,请稍后重试").strip()[:500]
|
||||
public_message = (message or "一键成片暂未完成,请稍后重试").strip()[:500]
|
||||
metadata = dict(job.metadata or {})
|
||||
if internal_error:
|
||||
metadata["internal_error"] = internal_error[:2000]
|
||||
@@ -365,7 +365,7 @@ def _consume_script_agent(job: QuickCreateJob) -> None:
|
||||
settings = _quick_settings(project)
|
||||
user = job.created_by or project.created_by
|
||||
if user is None:
|
||||
raise ValueError("极速成片任务缺少创建人")
|
||||
raise ValueError("一键成片任务缺少创建人")
|
||||
model_config = get_quick_script_model()
|
||||
if model_config is None:
|
||||
raise ValueError(f"{QUICK_SCRIPT_MODEL_NAME} is not configured")
|
||||
@@ -376,7 +376,8 @@ def _consume_script_agent(job: QuickCreateJob) -> None:
|
||||
getattr(product, "category", "") or "",
|
||||
getattr(product, "title", "") or "",
|
||||
)
|
||||
fmt = wizard.get("presentation_format") or rec["format"]
|
||||
# 极速成片与专业创作一致,脚本表现形式统一为口播。
|
||||
fmt = "oral"
|
||||
structure = wizard.get("video_structure") or rec["structure"]
|
||||
persona = wizard.get("persona") or rec["persona"]
|
||||
format_label = PRESENTATION_FORMATS.get(fmt, fmt)
|
||||
@@ -486,7 +487,7 @@ def _advance_product(job: QuickCreateJob) -> int:
|
||||
ProductSellingPoint.objects.create(
|
||||
product=product,
|
||||
title=product.title,
|
||||
detail="由极速成片根据商品名称自动填写",
|
||||
detail="由一键成片根据商品名称自动填写",
|
||||
sort_order=0,
|
||||
)
|
||||
_save_job(
|
||||
@@ -695,7 +696,7 @@ def _start_base_assets(job: QuickCreateJob) -> None:
|
||||
project = job.project
|
||||
user = job.created_by or project.created_by
|
||||
if user is None:
|
||||
raise ValueError("极速成片任务缺少创建人")
|
||||
raise ValueError("一键成片任务缺少创建人")
|
||||
with transaction.atomic():
|
||||
job = QuickCreateJob.objects.select_for_update().select_related("project").get(id=job.id)
|
||||
metadata = dict(job.metadata or {})
|
||||
@@ -939,7 +940,7 @@ def _start_videos(job: QuickCreateJob) -> None:
|
||||
submit_video_segment(
|
||||
video_segment=segment,
|
||||
user=job.created_by or job.project.created_by,
|
||||
prompt="极速成片自动生成,严格遵循本镜故事板与脚本。",
|
||||
prompt="一键成片自动生成,严格遵循本镜故事板与脚本。",
|
||||
model_config_id=settings["video_model_config_id"] or None,
|
||||
aspect_ratio=settings["aspect_ratio"],
|
||||
resolution=settings["resolution"],
|
||||
|
||||
@@ -307,14 +307,14 @@ class QuickCreateApiTests(TestCase):
|
||||
|
||||
def test_retry_api_resumes_failed_job(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="续跑商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="续跑商品 · 极速成片")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="续跑商品 · 一键成片")
|
||||
job = QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
project=project,
|
||||
status=QuickCreateJob.Status.FAILED,
|
||||
phase=QuickCreateJob.Phase.PRODUCTION,
|
||||
message="极速成片暂未完成,请稍后重试或进入专业模式查看",
|
||||
message="一键成片暂未完成,请稍后重试或进入专业模式查看",
|
||||
)
|
||||
with patch("apps.projects.tasks.advance_quick_create_task.apply_async") as enqueue:
|
||||
response = self.client.post(f"/api/projects/quick-create-retry/{job.id}/")
|
||||
@@ -325,7 +325,7 @@ class QuickCreateApiTests(TestCase):
|
||||
|
||||
def test_history_lists_completed_jobs_only(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="历史商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="历史商品 · 极速成片")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="历史商品 · 一键成片")
|
||||
QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
@@ -334,17 +334,17 @@ class QuickCreateApiTests(TestCase):
|
||||
phase=QuickCreateJob.Phase.COMPLETE,
|
||||
)
|
||||
failed_product = Product.objects.create(team=self.team, created_by=self.user, title="失败商品")
|
||||
failed_project = Project.objects.create(team=self.team, created_by=self.user, product=failed_product, name="失败商品 · 极速成片")
|
||||
failed_project = Project.objects.create(team=self.team, created_by=self.user, product=failed_product, name="失败商品 · 一键成片")
|
||||
QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
project=failed_project,
|
||||
status=QuickCreateJob.Status.FAILED,
|
||||
phase=QuickCreateJob.Phase.PRODUCTION,
|
||||
message="极速成片暂未完成,请稍后重试或进入专业模式查看",
|
||||
message="一键成片暂未完成,请稍后重试或进入专业模式查看",
|
||||
)
|
||||
running_product = Product.objects.create(team=self.team, created_by=self.user, title="进行中商品")
|
||||
running_project = Project.objects.create(team=self.team, created_by=self.user, product=running_product, name="进行中商品 · 极速成片")
|
||||
running_project = Project.objects.create(team=self.team, created_by=self.user, product=running_product, name="进行中商品 · 一键成片")
|
||||
QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
@@ -369,13 +369,13 @@ class QuickCreateApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
titles = [item["title"] for item in response.data["results"]]
|
||||
self.assertIn("历史商品 · 极速成片", titles)
|
||||
self.assertNotIn("失败商品 · 极速成片", titles)
|
||||
self.assertNotIn("进行中商品 · 极速成片", titles)
|
||||
self.assertIn("历史商品 · 一键成片", titles)
|
||||
self.assertNotIn("失败商品 · 一键成片", titles)
|
||||
self.assertNotIn("进行中商品 · 一键成片", titles)
|
||||
|
||||
def test_history_hides_deleted_projects(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="还在的商品")
|
||||
alive = Project.objects.create(team=self.team, created_by=self.user, product=product, name="还在的商品 · 极速成片")
|
||||
alive = Project.objects.create(team=self.team, created_by=self.user, product=product, name="还在的商品 · 一键成片")
|
||||
QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
@@ -388,7 +388,7 @@ class QuickCreateApiTests(TestCase):
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=deleted_product,
|
||||
name="已删商品 · 极速成片",
|
||||
name="已删商品 · 一键成片",
|
||||
is_deleted=True,
|
||||
)
|
||||
QuickCreateJob.objects.create(
|
||||
@@ -403,8 +403,8 @@ class QuickCreateApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
titles = [item["title"] for item in response.data["results"]]
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
self.assertIn("还在的商品 · 极速成片", titles)
|
||||
self.assertNotIn("已删商品 · 极速成片", titles)
|
||||
self.assertIn("还在的商品 · 一键成片", titles)
|
||||
self.assertNotIn("已删商品 · 一键成片", titles)
|
||||
|
||||
def test_list_serializer_flags_quick_create_projects(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="列表商品")
|
||||
@@ -412,7 +412,7 @@ class QuickCreateApiTests(TestCase):
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=product,
|
||||
name="列表商品 · 极速成片",
|
||||
name="列表商品 · 一键成片",
|
||||
metadata={"quick_create": True},
|
||||
)
|
||||
normal = Project.objects.create(
|
||||
@@ -436,7 +436,7 @@ class QuickCreateApiTests(TestCase):
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=product,
|
||||
name="锁单商品 · 极速成片",
|
||||
name="锁单商品 · 一键成片",
|
||||
metadata={"quick_create": True},
|
||||
)
|
||||
job = QuickCreateJob.objects.create(
|
||||
@@ -448,7 +448,7 @@ class QuickCreateApiTests(TestCase):
|
||||
|
||||
blocked = self.client.patch(f"/api/projects/{project.id}/", {"name": "不该改"}, format="json")
|
||||
self.assertEqual(blocked.status_code, 409)
|
||||
self.assertIn("极速成片", str(blocked.data))
|
||||
self.assertIn("一键成片", str(blocked.data))
|
||||
|
||||
allowed = self.client.get(f"/api/projects/{project.id}/")
|
||||
self.assertEqual(allowed.status_code, 200)
|
||||
@@ -473,7 +473,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=self.product,
|
||||
name="测试精华 · 极速成片",
|
||||
name="测试精华 · 一键成片",
|
||||
status=Project.Status.SCRIPTING,
|
||||
current_stage=ProjectStage.Stage.SCRIPT,
|
||||
)
|
||||
@@ -554,7 +554,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
QuickCreateJob.objects.filter(id=self.job.id).update(
|
||||
status=QuickCreateJob.Status.QUEUED,
|
||||
phase=QuickCreateJob.Phase.PRODUCT,
|
||||
message="等待开始极速成片",
|
||||
message="等待开始一键成片",
|
||||
updated_at=timezone.now() - timedelta(seconds=30),
|
||||
)
|
||||
self.job.refresh_from_db()
|
||||
@@ -748,7 +748,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
self.job.metadata = {"storyboard_started": True}
|
||||
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
|
||||
|
||||
fail_quick_create(self.job, "极速成片暂未完成,请稍后重试或进入专业模式查看", internal_error="Timeout reading from socket")
|
||||
fail_quick_create(self.job, "一键成片暂未完成,请稍后重试或进入专业模式查看", internal_error="Timeout reading from socket")
|
||||
self.job.refresh_from_db()
|
||||
self.project.refresh_from_db()
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.FAILED)
|
||||
@@ -772,7 +772,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
def test_recover_clears_false_failed_project_before_video_starts(self):
|
||||
self.project.status = Project.Status.FAILED
|
||||
self.project.current_stage = ProjectStage.Stage.VIDEO
|
||||
self.project.failure_reason = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.project.failure_reason = "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.project.save(update_fields=["status", "current_stage", "failure_reason", "updated_at"])
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
@@ -805,7 +805,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
def test_resume_failed_production_job_keeps_progress(self, enqueue):
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.metadata = {"storyboard_started": True, "transient_retries": 8, "video_fail_retries": 2}
|
||||
self.job.save(update_fields=["status", "phase", "error_message", "metadata", "updated_at"])
|
||||
|
||||
@@ -859,7 +859,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
def test_failed_job_serializer_rewrites_hidden_moderation_error(self):
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.metadata = {"internal_error": "400 moderation_blocked safety_violations=[sexual]"}
|
||||
self.job.save(update_fields=["status", "phase", "error_message", "metadata", "updated_at"])
|
||||
data = QuickCreateJobSerializer(self.job).data
|
||||
@@ -882,7 +882,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
segment.save(update_fields=["adopted_version", "status", "updated_at"])
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.save(update_fields=["status", "phase", "error_message", "updated_at"])
|
||||
|
||||
recover_quick_create(self.job)
|
||||
@@ -894,7 +894,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
"""专业模式完成后,极速任务的旧失败状态必须自动被回收。"""
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.save(update_fields=["status", "phase", "error_message", "updated_at"])
|
||||
self.project.status = Project.Status.COMPLETED
|
||||
self.project.current_stage = ProjectStage.Stage.VIDEO
|
||||
@@ -1143,7 +1143,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
base_ids = self._ready_asset_tasks()
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.ASSETS
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.metadata = {
|
||||
"base_asset_task_ids": base_ids,
|
||||
"assets_started": True,
|
||||
@@ -1194,7 +1194,7 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="极速成片",
|
||||
name="一键成片",
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.VIDEO_CLIP,
|
||||
|
||||
@@ -98,7 +98,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class QuickCreateInProgress(APIException):
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
default_detail = "该项目正在极速成片中,请到极速成片页查看进度"
|
||||
default_detail = "该项目正在一键成片中,请到一键成片页查看进度"
|
||||
default_code = "quick_create_running"
|
||||
|
||||
|
||||
@@ -553,7 +553,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
]
|
||||
if text_model is None:
|
||||
return Response(
|
||||
{"detail": "极速成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"},
|
||||
{"detail": "一键成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
if missing:
|
||||
@@ -653,7 +653,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
product=product,
|
||||
name=f"{name} · 极速成片",
|
||||
name=f"{name} · 一键成片",
|
||||
status=Project.Status.SCRIPTING,
|
||||
current_stage=ProjectStage.Stage.SCRIPT,
|
||||
metadata={
|
||||
@@ -665,7 +665,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"video_model_config_id": str(video_model.id),
|
||||
"video_model_name": video_model.name,
|
||||
"video_model_label": video_model.display_name,
|
||||
"presentation_format": rec["format"],
|
||||
"presentation_format": "oral",
|
||||
"video_structure": rec["structure"],
|
||||
"persona": rec["persona"],
|
||||
},
|
||||
@@ -676,7 +676,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
ProductSellingPoint.objects.create(
|
||||
product=product,
|
||||
title=name,
|
||||
detail="由极速成片根据商品名称自动填写",
|
||||
detail="由一键成片根据商品名称自动填写",
|
||||
sort_order=0,
|
||||
)
|
||||
job = QuickCreateJob.objects.create(
|
||||
@@ -686,7 +686,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
status=QuickCreateJob.Status.QUEUED,
|
||||
phase=QuickCreateJob.Phase.PRODUCT,
|
||||
progress=0,
|
||||
message="等待开始极速成片",
|
||||
message="等待开始一键成片",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -722,7 +722,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def quick_create_status(self, request, job_id=None):
|
||||
job = self._quick_job_queryset().filter(id=job_id).first()
|
||||
if job is None:
|
||||
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response({"detail": "一键成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
from .services.quick_create import recover_quick_create
|
||||
|
||||
try:
|
||||
@@ -736,7 +736,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def quick_create_cancel(self, request, job_id=None):
|
||||
job = self._quick_job_queryset().filter(id=job_id).first()
|
||||
if job is None:
|
||||
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response({"detail": "一键成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if job.status == QuickCreateJob.Status.SUCCEEDED:
|
||||
return Response({"detail": "已完成的任务不能取消"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
from .services.quick_create import cancel_quick_create
|
||||
@@ -749,7 +749,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def quick_create_retry(self, request, job_id=None):
|
||||
job = self._quick_job_queryset().filter(id=job_id).first()
|
||||
if job is None:
|
||||
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response({"detail": "一键成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if job.status == QuickCreateJob.Status.SUCCEEDED:
|
||||
return Response({"detail": "任务已完成"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if job.status == QuickCreateJob.Status.CANCELLED:
|
||||
@@ -808,14 +808,13 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"template_outline": render_outline_text({"outline": template.outline, "cta": template.cta}),
|
||||
}
|
||||
)
|
||||
# 套路参数只在模板真有值时覆盖,空值不要把设定卡的智能推荐顶掉。
|
||||
# 旧模板可能存了中文标签(「短剧」),这里归一成 key 再写入 wizard。
|
||||
# 表现形式已统一为口播;旧模板里的短剧/Vlog 只兼容读取结构,不能覆盖当前规则。
|
||||
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
|
||||
_fmt, structure = coerce_template_combo(raw_format, raw_structure)
|
||||
wizard["video_structure"] = structure
|
||||
wizard["presentation_format"] = "oral"
|
||||
persona = coerce_persona(getattr(template, "persona", "") or "")
|
||||
if persona:
|
||||
wizard["persona"] = persona
|
||||
@@ -860,7 +859,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。
|
||||
请求体:mode(auto|theme|revise)、prompt、model_config_id、selling_point_ids、persona、
|
||||
base_version_id(改稿)、aspect_ratio、total_duration(15/30/45/60)、
|
||||
presentation_format(oral|drama|vlog)、video_structure(pain|contrast|review|scene)。
|
||||
video_structure(pain|contrast|review|scene)。表现形式固定为口播。
|
||||
响应:text/event-stream,逐帧吐 tool/delta/draft/saved/done/error。"""
|
||||
project = self.get_object()
|
||||
mode = str(request.data.get("mode") or "auto")
|
||||
@@ -870,7 +869,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# 非法值一律由 agent 侧 coerce 兜底(夹区间/回落默认),这里不做 400,避免生成被参数噪声打断
|
||||
total_duration = coerce_total_duration(request.data.get("total_duration"))
|
||||
presentation_format, video_structure = combo_keys(
|
||||
request.data.get("presentation_format"),
|
||||
"oral",
|
||||
request.data.get("video_structure"),
|
||||
)
|
||||
wizard = ((project.metadata or {}).get("wizard") if isinstance(project.metadata, dict) else None) or {}
|
||||
|
||||
+22
-22
@@ -424,7 +424,7 @@ export function App() {
|
||||
const locked = lockedQuickCreateProject(listed, detailed);
|
||||
if (!locked) return;
|
||||
rememberQuickCreateJob(locked.quick_create_job_id);
|
||||
setNotice({ type: "info", text: "该项目正在极速成片中,请在极速成片页查看进度" });
|
||||
setNotice({ type: "info", text: "该项目正在一键成片中,请在一键成片页查看进度" });
|
||||
navigate("quickCreate", { productId: locked.product, replace: true });
|
||||
}, [authed, page, activeProjectId, projects, projectDetail]);
|
||||
|
||||
@@ -554,12 +554,12 @@ export function App() {
|
||||
const locked = canForce ? null : lockedQuickCreateProject(listed, detailed);
|
||||
if (locked) {
|
||||
rememberQuickCreateJob(locked.quick_create_job_id);
|
||||
setNotice({ type: "info", text: "该项目正在极速成片中,请在极速成片页查看进度" });
|
||||
setNotice({ type: "info", text: "该项目正在一键成片中,请在一键成片页查看进度" });
|
||||
next = "quickCreate";
|
||||
options = { ...options, productId: locked.product, replace: options.replace };
|
||||
}
|
||||
}
|
||||
// 图片创作 / 极速成片只有显式入口才携带商品;从工作台或视频创作进入时不能继承全局当前商品。
|
||||
// 图片创作 / 一键成片只有显式入口才携带商品;从工作台或视频创作进入时不能继承全局当前商品。
|
||||
const productId = next === "imageOptimize" || next === "quickCreate" ? options.productId : (options.productId ?? activeProductId);
|
||||
const projectId = options.projectId ?? activeProjectId;
|
||||
if (options.productId !== undefined) setActiveProductId(options.productId);
|
||||
@@ -951,7 +951,7 @@ export function App() {
|
||||
);
|
||||
case "productCreateUpload":
|
||||
// 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开
|
||||
// 创建成功后由 ProductsPage 弹「继续创建商品 / 极速成片 / 专业创作」选择弹窗,不再自动跳详情
|
||||
// 创建成功后由 ProductsPage 弹「继续创建商品 / 一键成片 / 专业创作」选择弹窗,不再自动跳详情
|
||||
return (
|
||||
<ProductsPage
|
||||
products={products}
|
||||
@@ -1284,26 +1284,26 @@ export function App() {
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} onOpenAdmin={() => navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} />
|
||||
<header className="topbar">
|
||||
<ModeTabs active={topModule} navigate={navigate} />
|
||||
<div className="right">
|
||||
<button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索">
|
||||
<IconKitSvg name="search" />
|
||||
<span>搜索</span>
|
||||
<span className="kbd">{searchKbd}</span>
|
||||
</button>
|
||||
<span className="balance-chip" onClick={() => navigate("account")}>
|
||||
<IconKitSvg name="creditCard" />
|
||||
余额 <strong>{money(billing?.account.balance)}</strong>
|
||||
</span>
|
||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
||||
<IconKitSvg name="bell" />
|
||||
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<Decorations />
|
||||
<header className="topbar">
|
||||
<ModeTabs active={topModule} navigate={navigate} />
|
||||
<div className="right">
|
||||
<button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索">
|
||||
<IconKitSvg name="search" />
|
||||
<span>搜索</span>
|
||||
<span className="kbd">{searchKbd}</span>
|
||||
</button>
|
||||
<span className="balance-chip" onClick={() => navigate("account")}>
|
||||
<IconKitSvg name="creditCard" />
|
||||
余额 <strong>{money(billing?.account.balance)}</strong>
|
||||
</span>
|
||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
||||
<IconKitSvg name="bell" />
|
||||
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="content" id="page-content" key={page}>
|
||||
<CornerMarks />
|
||||
{notice && <ToastLike notice={notice} />}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
.admin-app .sidebar .nav-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 116px);
|
||||
height: calc(100vh - var(--topbar-height));
|
||||
min-height: 0;
|
||||
padding: 10px 0 0;
|
||||
overflow: hidden;
|
||||
@@ -161,8 +161,8 @@
|
||||
.admin-logout:hover { color: var(--st-text); background: var(--st-hover); }
|
||||
|
||||
.admin-app .topbar {
|
||||
height: 116px;
|
||||
min-height: 116px;
|
||||
height: var(--topbar-height);
|
||||
min-height: var(--topbar-height);
|
||||
padding: 0 28px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
|
||||
|
||||
+29
-21
@@ -44,6 +44,7 @@ import type {
|
||||
User,
|
||||
UserPreference,
|
||||
VideoDigestHistory,
|
||||
VideoDigestJob,
|
||||
VoiceoverInfo
|
||||
} from "./types";
|
||||
import type { PresentationFormat, VideoStructure } from "./script-setup";
|
||||
@@ -411,32 +412,18 @@ export const api = {
|
||||
{ method: "POST", body: formData }
|
||||
);
|
||||
},
|
||||
// 视频提炼页:不绑项目,整段视频交给 Gemini 出中文分镜稿。慢(约 1–2 分钟),固定 30 积分,失败退还。
|
||||
// 视频提炼页:不绑项目。POST 秒回任务,worker 抽帧 + Gemini;离开页面也不中断。
|
||||
extractVideoDigest(formData: FormData) {
|
||||
return request<{
|
||||
name: string;
|
||||
chars: number;
|
||||
text: string;
|
||||
frames: number;
|
||||
duration: number;
|
||||
input?: string;
|
||||
estimated_cost?: string;
|
||||
task_id?: string;
|
||||
title?: string;
|
||||
file_name?: string;
|
||||
ratio?: string;
|
||||
shots?: number;
|
||||
cover_url?: string;
|
||||
video_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>(
|
||||
return request<VideoDigestJob>(
|
||||
"/api/ai/video-digest/",
|
||||
{ method: "POST", body: formData }
|
||||
);
|
||||
},
|
||||
listVideoDigests() {
|
||||
return request<{ results: VideoDigestHistory[]; total: number }>("/api/ai/video-digest/");
|
||||
return request<{ results: VideoDigestHistory[]; total: number; inflight?: VideoDigestJob | null }>("/api/ai/video-digest/");
|
||||
},
|
||||
getVideoDigest(id: string) {
|
||||
return request<VideoDigestJob>(`/api/ai/video-digest/${id}/`);
|
||||
},
|
||||
saveVideoDigest(id: string, prompt: string) {
|
||||
return request<VideoDigestHistory>(`/api/ai/video-digest/${id}/`, {
|
||||
@@ -915,6 +902,27 @@ export const api = {
|
||||
}) {
|
||||
return request<{ task: FreeVideoTask }>("/api/ai/free-video/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
submitVideoReplace(payload: {
|
||||
replace_mode: "product" | "character";
|
||||
video_asset_id: string;
|
||||
product_id?: string;
|
||||
model_id?: string;
|
||||
image_asset_ids?: string[];
|
||||
model?: string;
|
||||
aspect_ratio?: string;
|
||||
resolution?: string;
|
||||
duration?: number;
|
||||
}) {
|
||||
return request<{ task: FreeVideoTask }>("/api/ai/video-replace/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
videoReplaceTasks(offset = 0, pageSize = 20) {
|
||||
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>(
|
||||
`/api/ai/video-replace/?offset=${offset}&page_size=${pageSize}`
|
||||
);
|
||||
},
|
||||
pollVideoReplace(id: string) {
|
||||
return request<{ task: FreeVideoTask }>(`/api/ai/video-replace/${id}/poll/`, { method: "POST" });
|
||||
},
|
||||
freeVideoTasks(offset = 0, pageSize = 20) {
|
||||
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>(
|
||||
`/api/ai/free-video/?offset=${offset}&page_size=${pageSize}`
|
||||
@@ -1107,7 +1115,7 @@ export const adminApi = {
|
||||
);
|
||||
},
|
||||
pollReviews(assetIds?: string[]) {
|
||||
return request<{ polled: number; statuses: Record<string, string> }>(
|
||||
return request<{ polled: number; submitted?: number; statuses: Record<string, string> }>(
|
||||
"/api/admin/asset-reviews/poll/",
|
||||
{ method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) }
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ const SHELL_COMMANDS: Command[] = [
|
||||
{ id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
|
||||
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
|
||||
{ id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
|
||||
{ id: "quick-create", group: "导航", label: "极速成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" },
|
||||
{ id: "quick-create", group: "导航", label: "一键成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" },
|
||||
{ id: "video-remix", group: "导航", label: "提炼提示词", sub: "上传参考视频,提炼可编辑提示词", page: "videoRemix", icon: "scan", key: "R" },
|
||||
{ id: "video-replace", group: "导航", label: "视频复刻", sub: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", page: "videoReplace", icon: "replace", key: "E" },
|
||||
{ id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
|
||||
@@ -26,7 +26,7 @@ const SHELL_COMMANDS: Command[] = [
|
||||
{ id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" },
|
||||
{ id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" },
|
||||
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
|
||||
{ id: "quick-create-action", group: "常用动作", label: "极速成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" },
|
||||
{ id: "quick-create-action", group: "常用动作", label: "一键成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" },
|
||||
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
|
||||
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" },
|
||||
{ id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" }
|
||||
|
||||
@@ -33,6 +33,7 @@ export const MAX_IMAGES = 9;
|
||||
export const MAX_VIDEOS = 3;
|
||||
export const MAX_AUDIOS = 3;
|
||||
export const MAX_VIDEO_TOTAL_SECONDS = 15;
|
||||
export const VIDEO_DURATION_SLACK = 0.5;
|
||||
|
||||
// 任务在途状态(继续轮询);终态 = succeeded / failed / cancelled / compensating
|
||||
export const IN_FLIGHT_STATUSES = ["created", "reserved", "submitted", "polling", "postprocessing"];
|
||||
@@ -145,8 +146,9 @@ function probeMedia(file: File, kind: "video" | "audio"): Promise<FileCheck> {
|
||||
URL.revokeObjectURL(url);
|
||||
const duration = el.duration;
|
||||
if (!isFinite(duration)) resolve({ ok: true, type: kind });
|
||||
else if (duration < 2 || duration > 15) resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-15 秒之间` });
|
||||
else resolve({ ok: true, type: kind, duration: Math.round(duration * 10) / 10 });
|
||||
else if (duration < 2 - 0.05 || duration > MAX_VIDEO_TOTAL_SECONDS + VIDEO_DURATION_SLACK) {
|
||||
resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-15 秒之间` });
|
||||
} else resolve({ ok: true, type: kind, duration: Math.min(MAX_VIDEO_TOTAL_SECONDS, Math.round(duration * 10) / 10) });
|
||||
};
|
||||
el.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "媒体文件解析失败,请更换文件" }); };
|
||||
el.src = url;
|
||||
|
||||
@@ -2,6 +2,12 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { CheckCircle2, Inbox, Music, Shield, Trash2, X } from "lucide-react";
|
||||
|
||||
// 抽屉 / 弹窗 / 全屏播放器必须挂到 document.body。
|
||||
// 写在页面树里会被顶栏(sticky + z-index)盖住:搜索、余额、铃铛会浮在抽屉上。
|
||||
export function OverlayPortal({ children }: { children: ReactNode }) {
|
||||
return createPortal(children, document.body);
|
||||
}
|
||||
|
||||
// 浮层打开时锁住 body 滚动(否则滚轮会滚动遮罩后面的页面,体感"遮罩没盖住")。
|
||||
// 多浮层叠开用计数,最后一个关闭才解锁。
|
||||
let scrollLockCount = 0;
|
||||
@@ -64,7 +70,7 @@ export function MediaLightbox({ open, src, kind, name, close }: {
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, close]);
|
||||
if (!open || !src) return null;
|
||||
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
|
||||
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
|
||||
return createPortal(
|
||||
<div className="np-lightbox show" onClick={close}>
|
||||
<button className="lb-x" type="button" aria-label="关闭" onClick={close}><X /></button>
|
||||
@@ -114,7 +120,7 @@ export function TeamModal({ open, title, subtitle = "", icon, close, children, f
|
||||
useBodyScrollLock(open);
|
||||
const { mounted, show } = useOverlayTransition(open, close);
|
||||
if (!mounted) return null;
|
||||
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
|
||||
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
|
||||
return createPortal(
|
||||
<div className={`modal-bg${show ? " show" : ""}`} onClick={dismissable ? close : undefined}>
|
||||
<div className="modal invite-modal" onClick={(event) => event.stopPropagation()}>
|
||||
@@ -143,7 +149,7 @@ export function ConfirmModal({ open, title, detail, confirmText, subtitle = "",
|
||||
useBodyScrollLock(open);
|
||||
const { mounted, show } = useOverlayTransition(open, onCancel);
|
||||
if (!mounted) return null;
|
||||
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
|
||||
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
|
||||
return createPortal(
|
||||
<div className={`modal-bg${show ? " show" : ""}`} onClick={dismissable ? onCancel : undefined}>
|
||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||
@@ -185,7 +191,6 @@ export function Drawer({ title, open, close, children, className }: { title: str
|
||||
useBodyScrollLock(open);
|
||||
const { mounted, show } = useOverlayTransition(open, close);
|
||||
if (!mounted) return null;
|
||||
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={`drawer-bg${show ? " show" : ""}`} onClick={close} />
|
||||
|
||||
@@ -51,6 +51,10 @@
|
||||
:root {
|
||||
/* 比影擎 164px 略宽:我们子项带数量徽章,164 会挤 */
|
||||
--sidebar-width: 192px;
|
||||
--topbar-height: 116px;
|
||||
/* 壳层:顶栏/侧栏收窄键 < 页面浮层。浮层必须盖住搜索/余额/铃铛 */
|
||||
--z-topbar: 50;
|
||||
--z-overlay: 200;
|
||||
|
||||
/* ===== Backgrounds (冷灰 · YZ 影擎) ===== */
|
||||
--background-base: #f7f8fa;
|
||||
@@ -223,6 +227,7 @@ img, svg, video { display: block; max-width: 100%; }
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
||||
grid-template-rows: var(--topbar-height) minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
transition: grid-template-columns var(--t-base);
|
||||
}
|
||||
@@ -232,6 +237,7 @@ body.sidebar-collapsed .app { grid-template-columns: 96px minmax(0, 1fr); }
|
||||
aside.sidebar {
|
||||
--klein: #002fa7;
|
||||
--st-black: #101012;
|
||||
grid-row: 1 / -1;
|
||||
padding: 0;
|
||||
border-right: 1px solid rgba(27, 32, 40, 0.08);
|
||||
background: rgba(28, 34, 43, 0.035);
|
||||
@@ -252,8 +258,8 @@ aside.sidebar {
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 116px;
|
||||
min-height: 116px;
|
||||
height: var(--topbar-height);
|
||||
min-height: var(--topbar-height);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
@@ -357,7 +363,7 @@ aside.sidebar {
|
||||
font-weight: 500;
|
||||
/* 中文标签用 sans 字体,不用 mono + uppercase */
|
||||
}
|
||||
nav { display: flex; flex-direction: column; gap: 2px; }
|
||||
aside.sidebar nav { display: flex; flex-direction: column; gap: 2px; }
|
||||
nav a {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
padding: 9px 12px;
|
||||
@@ -414,7 +420,7 @@ nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); }
|
||||
}
|
||||
aside.sidebar .nav-panel {
|
||||
position: relative;
|
||||
height: calc(100vh - 116px);
|
||||
height: calc(100vh - var(--topbar-height));
|
||||
min-height: 0;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 82px;
|
||||
@@ -710,7 +716,7 @@ aside.sidebar .nav-panel > nav {
|
||||
}
|
||||
|
||||
body.sidebar-collapsed aside.sidebar { padding: 0; }
|
||||
body.sidebar-collapsed .sidebar-head { gap: 6px; margin: 0; padding: 0; height: 116px; min-height: 116px; }
|
||||
body.sidebar-collapsed .sidebar-head { gap: 6px; margin: 0; padding: 0; height: var(--topbar-height); min-height: var(--topbar-height); }
|
||||
body.sidebar-collapsed .brand-clip {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
@@ -760,13 +766,22 @@ body.sidebar-collapsed .user .em,
|
||||
body.sidebar-collapsed .user::after { display: none; }
|
||||
|
||||
/* ─── Main + grid background ─── */
|
||||
main { position: relative; background: #fff; min-width: 0; overflow-x: hidden; }
|
||||
.app > main {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-x: clip;
|
||||
overflow-y: visible;
|
||||
}
|
||||
.app:has(.pipeline-page) {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
main:has(.pipeline-page) {
|
||||
height: 100vh;
|
||||
.app > main:has(.pipeline-page) {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -774,6 +789,7 @@ main:has(.pipeline-page) {
|
||||
.grid-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 47, 167, 0.16) 1px, transparent 1px),
|
||||
@@ -828,13 +844,17 @@ main:has(.pipeline-page) {
|
||||
|
||||
/* ─── Topbar ─── */
|
||||
.topbar {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 0 clamp(36px, 3.2vw, 68px);
|
||||
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
|
||||
background: #fff;
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
height: 116px;
|
||||
min-height: 116px;
|
||||
box-shadow: 0 4px 16px rgba(20, 27, 38, 0.035);
|
||||
position: sticky; top: 0; z-index: var(--z-topbar);
|
||||
align-self: start;
|
||||
height: var(--topbar-height);
|
||||
min-height: var(--topbar-height);
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
flex-wrap: nowrap;
|
||||
@@ -1013,11 +1033,13 @@ main:has(.pipeline-page) {
|
||||
|
||||
/* ─── Content ─── */
|
||||
.content {
|
||||
/* 对齐 HTML 设计稿 #page-content(24px 28px 60px):原 48px 顶距使全站标题整体偏低 24px */
|
||||
/* 对齐 HTML 设计稿 #page-content(24px 28px 60px):原 48px 顶距使全站标题整体偏低 24px
|
||||
禁止设 z-index:会形成层叠上下文,页面里的 fixed 抽屉/弹窗再高也盖不住顶栏。
|
||||
网格底 .grid-bg 在前、本节点 position:relative,DOM 顺序即可压住网格。 */
|
||||
padding: 24px 28px 60px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: calc(100vh - 116px);
|
||||
z-index: auto;
|
||||
min-height: calc(100vh - var(--topbar-height));
|
||||
animation: yz-page-enter 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.content:has(.pipeline-page) {
|
||||
@@ -1029,8 +1051,8 @@ main:has(.pipeline-page) {
|
||||
animation: none;
|
||||
}
|
||||
@keyframes yz-page-enter {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.content > .corner-mark { display: none; }
|
||||
@@ -2637,7 +2659,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(21, 20, 15, .32);
|
||||
display: block;
|
||||
z-index: 90;
|
||||
z-index: var(--z-overlay);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
@@ -2649,7 +2671,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
width: 540px; max-width: 100vw;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border-faint);
|
||||
z-index: 95;
|
||||
z-index: calc(var(--z-overlay) + 5);
|
||||
transform: translateX(100%);
|
||||
transition: transform .25s cubic-bezier(.32, .72, 0, 1);
|
||||
display: flex; flex-direction: column;
|
||||
|
||||
@@ -281,7 +281,7 @@
|
||||
--pcd-line: rgba(34, 42, 54, 0.12);
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
z-index: var(--z-overlay);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
const QUICK_JOB_KEY = "airshelf:quick-create-job";
|
||||
const QUICK_CREATE_SUFFIX = / · (?:极速成片|一键成片)$/;
|
||||
|
||||
export function hasQuickCreateSuffix(name?: string) {
|
||||
return QUICK_CREATE_SUFFIX.test(name || "");
|
||||
}
|
||||
|
||||
export function stripQuickCreateSuffix(name: string) {
|
||||
return name.replace(QUICK_CREATE_SUFFIX, "");
|
||||
}
|
||||
|
||||
export function isQuickCreateBusy(project?: { quick_create_status?: string } | null) {
|
||||
return project?.quick_create_status === "queued" || project?.quick_create_status === "running";
|
||||
|
||||
@@ -137,13 +137,13 @@
|
||||
.quick-create-page .quick-history-open:hover { background: rgba(0,47,167,.05); }
|
||||
.quick-create-page .quick-history-open svg { width: 15px; height: 15px; }
|
||||
.quick-create-page .quick-history-empty { margin: 0; padding: 28px 8px; color: var(--quick-muted); font-size: 13px; }
|
||||
.quick-create-page .quick-player-bg { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 24px; background: rgba(22,23,26,.58); }
|
||||
.quick-create-page .quick-player { width: min(920px,100%); overflow: hidden; border-radius: 8px; background: #111216; }
|
||||
.quick-create-page .quick-player-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; color: #fff; }
|
||||
.quick-create-page .quick-player-bar strong { font-size: 13px; font-weight: 600; }
|
||||
.quick-create-page .quick-player-bar button { width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 8px; color: #fff; background: transparent; cursor: pointer; }
|
||||
.quick-create-page .quick-player-bar svg { width: 16px; height: 16px; }
|
||||
.quick-create-page .quick-player video { width: 100%; max-height: min(72vh, 620px); display: block; background: #000; }
|
||||
.quick-player-bg { position: fixed; inset: 0; z-index: var(--z-overlay); display: grid; place-items: center; padding: 24px; background: rgba(22,23,26,.58); }
|
||||
.quick-player { width: min(920px,100%); overflow: hidden; border-radius: 8px; background: #111216; }
|
||||
.quick-player-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; color: #fff; }
|
||||
.quick-player-bar strong { font-size: 13px; font-weight: 600; }
|
||||
.quick-player-bar button { width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 8px; color: #fff; background: transparent; cursor: pointer; }
|
||||
.quick-player-bar svg { width: 16px; height: 16px; }
|
||||
.quick-player video { width: 100%; max-height: min(72vh, 620px); display: block; background: #000; }
|
||||
@media (max-width: 980px) { .quick-create-page .quick-history-card { grid-template-columns: 96px minmax(0,1fr); }.quick-create-page .quick-history-open { grid-column: 1 / -1; justify-self: start; } }
|
||||
@media (min-width: 1753px) { .quick-create-page { position: relative; left: -21.5px; } }
|
||||
@media (max-width: 1400px) { .quick-create-page .quick-create-shell { grid-template-columns: minmax(0,.9fr) minmax(0,1.1fr); }.quick-create-page .quick-form-panel,.quick-create-page .quick-status-panel { padding: 24px; } }
|
||||
|
||||
@@ -162,17 +162,17 @@ export function AdminApp({ section, user, team, navigateAdmin, navigate, logout
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<header className="topbar">
|
||||
<div className="admin-crumb">
|
||||
<button type="button" onClick={() => navigateAdmin("")}>平台后台</button>
|
||||
{active.slug ? <span>{active.label}</span> : null}
|
||||
</div>
|
||||
<div className="right">
|
||||
<span className="admin-mode">超管模式</span>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<Decorations />
|
||||
<header className="topbar">
|
||||
<div className="admin-crumb">
|
||||
<button type="button" onClick={() => navigateAdmin("")}>平台后台</button>
|
||||
{active.slug ? <span>{active.label}</span> : null}
|
||||
</div>
|
||||
<div className="right">
|
||||
<span className="admin-mode">超管模式</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="content" id="page-content">
|
||||
<CornerMarks />
|
||||
{toast && <ToastLike notice={toast} />}
|
||||
|
||||
@@ -32,48 +32,54 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [tab, setTab] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setSelected(new Set());
|
||||
const load = useCallback(async ({ silent = false } = {}) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
const res = await adminApi.assetReviews({ review_status: tab || undefined, page, page_size: PAGE_SIZE });
|
||||
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
|
||||
setAssets(res.results);
|
||||
setCount(res.count);
|
||||
} catch {
|
||||
notify("error", "加载审核队列失败");
|
||||
if (!silent) notify("error", "加载审核队列失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, page]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
function toggleOne(id: string) {
|
||||
setSelected((s) => {
|
||||
const next = new Set(s);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function toggleAll() {
|
||||
setSelected((s) => (s.size === assets.length ? new Set() : new Set(assets.map((a) => a.id))));
|
||||
}
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
await adminApi.pollReviews();
|
||||
if (alive) await load({ silent: true });
|
||||
} catch {
|
||||
/* 自动送审失败不挡列表 */
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const timer = window.setInterval(tick, 12000);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
async function submit(ids: string[]) {
|
||||
if (busy || ids.length === 0) return;
|
||||
async function retry(id: string) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await adminApi.submitReviews(ids);
|
||||
notify("success", `已提交 ${res.submitted} 个资产送审`);
|
||||
await load();
|
||||
await adminApi.submitReviews([id]);
|
||||
notify("success", "已重新送审");
|
||||
await load({ silent: true });
|
||||
} catch {
|
||||
notify("error", "送审失败");
|
||||
notify("error", "重试失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -84,8 +90,17 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await adminApi.pollReviews();
|
||||
notify("success", res.polled > 0 ? `已刷新 ${res.polled} 个审核中资产` : "暂无审核中的资产");
|
||||
await load();
|
||||
const submitted = Number(res.submitted || 0);
|
||||
const polled = Number(res.polled || 0);
|
||||
if (submitted || polled) {
|
||||
notify("success", [
|
||||
submitted ? `自动送审 ${submitted} 个` : "",
|
||||
polled ? `刷新 ${polled} 个审核中` : "",
|
||||
].filter(Boolean).join(" · "));
|
||||
} else {
|
||||
notify("success", "暂无待处理审核");
|
||||
}
|
||||
await load({ silent: true });
|
||||
} catch {
|
||||
notify("error", "刷新失败");
|
||||
} finally {
|
||||
@@ -98,7 +113,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>资产审核</h1>
|
||||
<div className="sub"><span className="mono">{count} 个真人资产</span> · 火山人像素材库绿盾 / 红标</div>
|
||||
<div className="sub"><span className="mono">{count} 个真人资产</span> · 未送审会自动提交火山审核,无需手动点送审</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" disabled={busy} onClick={() => void poll()}>
|
||||
@@ -124,7 +139,6 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
<table className="t admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-check"><input type="checkbox" checked={selected.size === assets.length && assets.length > 0} onChange={toggleAll} aria-label="全选" /></th>
|
||||
<th>预览</th>
|
||||
<th>团队</th>
|
||||
<th>名称</th>
|
||||
@@ -135,7 +149,6 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
<tbody>
|
||||
{assets.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td className="col-check"><input type="checkbox" checked={selected.has(a.id)} onChange={() => toggleOne(a.id)} aria-label="选择" /></td>
|
||||
<td>
|
||||
{a.preview_url
|
||||
? (
|
||||
@@ -158,11 +171,11 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
{a.review_status === "failed" && a.review_error && <span className="admin-review-err mono" title={a.review_error}>!</span>}
|
||||
</td>
|
||||
<td className="col-actions">
|
||||
{(a.review_status === "failed" || a.review_status === "") && (
|
||||
<button className="btn btn-sm btn-ghost" type="button" disabled={busy} onClick={() => void submit([a.id])}>
|
||||
{a.review_status === "failed" ? "重试" : "送审"}
|
||||
{a.review_status === "failed" ? (
|
||||
<button className="btn btn-sm btn-ghost" type="button" disabled={busy} onClick={() => void retry(a.id)}>
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -172,14 +185,6 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="admin-bulk-bar" role="toolbar" aria-label="批量送审">
|
||||
<span className="admin-bulk-count">已选 {selected.size} 个</span>
|
||||
<button className="btn btn-sm" type="button" onClick={() => setSelected(new Set())}>取消</button>
|
||||
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void submit([...selected])}>批量送审</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { BillingSummary, Product, Project } from "../types";
|
||||
import type { NavigateFn, Page } from "./route-config";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
import { isQuickCreateBusy } from "../quick-create-lock";
|
||||
import { hasQuickCreateSuffix, isQuickCreateBusy } from "../quick-create-lock";
|
||||
|
||||
type DashTab = "all" | "wip" | "done";
|
||||
type EntryTone = "primary" | "subtle";
|
||||
@@ -31,7 +31,7 @@ const CREATE_GROUPS: Array<{
|
||||
label: "从商品开始",
|
||||
hint: "围绕商品卖点生成完整带货内容",
|
||||
cards: [
|
||||
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "quickCreate", icon: "wand" },
|
||||
{ title: "一键成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "quickCreate", icon: "wand" },
|
||||
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", tone: "primary", page: "projectWizard", icon: "folder" },
|
||||
],
|
||||
},
|
||||
@@ -78,12 +78,12 @@ function dashStageLabel(project: Project): string {
|
||||
function isQuickCreateProject(project: Project) {
|
||||
if (project.quick_create) return true;
|
||||
if (project.metadata?.quick_create) return true;
|
||||
return / · 极速成片$/.test(project.name || "");
|
||||
return hasQuickCreateSuffix(project.name);
|
||||
}
|
||||
|
||||
function dashCardMeta(project: Project, productTitle: string): string {
|
||||
const shots = project.video_segment_count ?? project.video_segments?.length ?? 0;
|
||||
const mode = isQuickCreateBusy(project) ? "极速成片生成中" : isQuickCreateProject(project) ? "极速成片" : "专业创作";
|
||||
const mode = isQuickCreateBusy(project) ? "一键成片生成中" : isQuickCreateProject(project) ? "一键成片" : "专业创作";
|
||||
return [mode, productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
MAX_IMAGES,
|
||||
MAX_VIDEOS,
|
||||
MAX_VIDEO_TOTAL_SECONDS,
|
||||
VIDEO_DURATION_SLACK,
|
||||
checkRefFile,
|
||||
isInFlight,
|
||||
type FreeMode,
|
||||
@@ -280,7 +281,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
|
||||
if (check.type === "image" && counts.image >= MAX_IMAGES) { notify("error", `参考图片最多 ${MAX_IMAGES} 张`); continue; }
|
||||
if (check.type === "video" && counts.video >= MAX_VIDEOS) { notify("error", `参考视频最多 ${MAX_VIDEOS} 条`); continue; }
|
||||
if (check.type === "audio" && counts.audio >= MAX_AUDIOS) { notify("error", `参考音频最多 ${MAX_AUDIOS} 条`); continue; }
|
||||
if (check.type === "video" && videoSeconds + (check.duration || 0) > MAX_VIDEO_TOTAL_SECONDS) {
|
||||
if (check.type === "video" && videoSeconds + (check.duration || 0) > MAX_VIDEO_TOTAL_SECONDS + VIDEO_DURATION_SLACK) {
|
||||
notify("error", `参考视频总时长不能超过 ${MAX_VIDEO_TOTAL_SECONDS} 秒`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const formatPoints = (value: string) => {
|
||||
};
|
||||
|
||||
// 模特详情弹窗:大图形象图 + 名字 + 官方模板/来源标签 + 三视图(16:9 单容器,无则占位)。
|
||||
// 复用 overlays.tsx 的 .modal 家族机制(useBodyScrollLock + useOverlayTransition + createPortal)。
|
||||
// 复用 overlays.tsx 的 .modal 家族机制(useBodyScrollLock + useOverlayTransition + OverlayPortal)。
|
||||
function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingChanged }: {
|
||||
model: ModelEntity | null;
|
||||
close: () => void;
|
||||
|
||||
@@ -13,23 +13,16 @@ import { DEFAULT_BILLING_RATES, estimateCost, FC_MODELS, modelLabel } from "../c
|
||||
import { ModelLibrary } from "../components/model-library";
|
||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||
import {
|
||||
allowedStructures,
|
||||
clampDuration,
|
||||
coercePresentationFormat,
|
||||
coerceVideoStructure,
|
||||
DURATION_OPTIONS,
|
||||
durationWarning,
|
||||
isForbidden,
|
||||
PRESENTATION_FORMATS,
|
||||
PRESENTATION_HINT,
|
||||
PRESENTATION_KEYS,
|
||||
recommendDuration,
|
||||
recommendSetup,
|
||||
SEGMENT_DURATION_MAX,
|
||||
STRUCTURE_HINT,
|
||||
TOTAL_DURATION_MIN,
|
||||
VIDEO_STRUCTURES,
|
||||
type PresentationFormat,
|
||||
type VideoStructure,
|
||||
} from "../script-setup";
|
||||
import { isLocalLife } from "../product-business";
|
||||
@@ -50,8 +43,7 @@ const VO_VOICES = [
|
||||
{ key: "BV102_streaming", label: "儒雅青年 · 解说男声" },
|
||||
{ key: "BV002_streaming", label: "通用男声" },
|
||||
];
|
||||
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_PERSONAS)
|
||||
// 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts
|
||||
const FIXED_PRESENTATION_FORMAT = "oral" as const;
|
||||
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
|
||||
const PERSONA_KEY_BY_LABEL: Record<string, string> = {
|
||||
...Object.fromEntries(Object.entries(WIZ_PERSONA_LABEL).map(([key, label]) => [label, key])),
|
||||
@@ -1442,10 +1434,10 @@ export function PipelinePage(props: {
|
||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
||||
const [chatMode, setChatMode] = useState<"ai" | "manual" | "video">("ai");
|
||||
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
|
||||
// ── Stage 1 · 生成前「表现形式 × 视频结构 × 人物 × 时长」设定(1.5–1.9):向导那边已删,这里补上 ──
|
||||
// setupOpen:两个入口之一被选中后,展示四栏选择 + 确认/重新推荐;确认后才真正发起生成。
|
||||
// ── Stage 1 · 生成前「视频结构 × 人物 × 时长」设定。表现形式固定为口播。 ──
|
||||
// setupOpen:入口被选中后展示三栏设定,确认后才真正发起生成。
|
||||
const SETUP_PERSONA_KEYS = Object.keys(WIZ_PERSONA_LABEL);
|
||||
// 2.3 按商品品类/人群推荐一组默认值;用户随时可改,推荐只是省掉「从零开始选」
|
||||
// 2.3 按商品品类推荐默认结构、人物与时长,用户仍可调整。
|
||||
const setupProduct = products.find((item) => item.id === project.product);
|
||||
const recommended = useMemo(
|
||||
() => recommendSetup({
|
||||
@@ -1457,9 +1449,6 @@ export function PipelinePage(props: {
|
||||
const wizard = project.metadata?.wizard;
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [setupSource, setSetupSource] = useState<"ai" | "manual" | "video">("ai");
|
||||
const [setupFormat, setSetupFormat] = useState<PresentationFormat>(
|
||||
coercePresentationFormat(wizard?.presentation_format, recommended.format)
|
||||
);
|
||||
const [setupStructure, setSetupStructure] = useState<VideoStructure>(
|
||||
coerceVideoStructure(wizard?.video_structure, recommended.structure)
|
||||
);
|
||||
@@ -1475,18 +1464,17 @@ export function PipelinePage(props: {
|
||||
// 商品是异步拉回来的,首帧 setupProduct 还是 undefined → 上面的初值只能拿到兜底组合。
|
||||
// 等商品到位后补一次推荐,但只在「用户没动过 + 项目也没存过设定」时才覆盖。
|
||||
const setupTouched = useRef(false);
|
||||
const wizardHasCombo = Boolean(wizard?.presentation_format && wizard?.video_structure);
|
||||
const wizardHasStructure = Boolean(wizard?.video_structure);
|
||||
const wizardHasDuration = typeof wizard?.total_duration === "number" || Boolean(wizard?.duration);
|
||||
const wizardHasPersona = Boolean(wizard?.persona);
|
||||
useEffect(() => {
|
||||
if (setupTouched.current) return;
|
||||
if (!wizardHasCombo) {
|
||||
setSetupFormat(recommended.format);
|
||||
if (!wizardHasStructure) {
|
||||
setSetupStructure(recommended.structure);
|
||||
if (!wizardHasDuration) setSetupDuration(recommended.duration);
|
||||
}
|
||||
if (!wizardHasPersona) setSetupPersona(recommended.persona);
|
||||
}, [recommended.format, recommended.structure, recommended.persona, recommended.duration, wizardHasCombo, wizardHasDuration, wizardHasPersona]);
|
||||
}, [recommended.structure, recommended.persona, recommended.duration, wizardHasStructure, wizardHasDuration, wizardHasPersona]);
|
||||
|
||||
// ── 5.2 换商品重跑 ── 新建向导选的套路模板由后端回填进 metadata.wizard,这里只读不写
|
||||
const templateName = typeof wizard?.template_name === "string" ? wizard.template_name : "";
|
||||
@@ -1497,9 +1485,8 @@ export function PipelinePage(props: {
|
||||
const [tplName, setTplName] = useState("");
|
||||
const [tplSaving, setTplSaving] = useState(false);
|
||||
function openSaveTemplate() {
|
||||
const formatLabel = PRESENTATION_FORMATS[setupFormat];
|
||||
const structureLabel = VIDEO_STRUCTURES[setupStructure];
|
||||
setTplName(`${formatLabel} · ${structureLabel} · ${shots.length} 镜`);
|
||||
setTplName(`口播 · ${structureLabel} · ${shots.length} 镜`);
|
||||
setTplOpen(true);
|
||||
}
|
||||
async function saveTemplate() {
|
||||
@@ -1517,16 +1504,10 @@ export function PipelinePage(props: {
|
||||
}
|
||||
}
|
||||
|
||||
// 1.8 组合联动:表现形式为主,视频结构按它筛;当前选中的若被筛掉就自动落到第一个合法项
|
||||
const structureOptions = useMemo(() => allowedStructures(setupFormat), [setupFormat]);
|
||||
function pickFormat(next: PresentationFormat) {
|
||||
setupTouched.current = true;
|
||||
setSetupFormat(next);
|
||||
if (isForbidden(next, setupStructure)) setSetupStructure(allowedStructures(next)[0]);
|
||||
}
|
||||
const structureOptions = useMemo(() => Object.keys(VIDEO_STRUCTURES) as VideoStructure[], []);
|
||||
const durationHint = durationWarning(setupStructure, setupDuration);
|
||||
// 建议时长跟着**当前选中**的组合走,不是跟着推荐组合走(否则选了短剧还提示口播的 30 秒)
|
||||
const durationSuggest = recommendDuration(setupFormat, setupStructure);
|
||||
// 建议时长跟着当前选中的口播结构走。
|
||||
const durationSuggest = recommendDuration(FIXED_PRESENTATION_FORMAT, setupStructure);
|
||||
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// 输入框随内容长高(封顶 260px 后内部滚动)。上传脚本 / 上传视频提炼灌进来的是整篇稿子,
|
||||
// 固定两行的框没法逐镜校对 —— 而「可人工逐镜编辑」正是这两个入口的硬要求。
|
||||
@@ -1760,7 +1741,7 @@ export function PipelinePage(props: {
|
||||
aspect_ratio: "9:16",
|
||||
// 设定卡参数一路传到后端:每镜固定 15 秒,总时长 15/30/45/60
|
||||
total_duration: setupDuration,
|
||||
presentation_format: setupFormat,
|
||||
presentation_format: FIXED_PRESENTATION_FORMAT,
|
||||
video_structure: setupStructure,
|
||||
target_index: targetIndex,
|
||||
source: source === "manual" || source === "video" ? source : undefined
|
||||
@@ -1847,27 +1828,22 @@ export function PipelinePage(props: {
|
||||
pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。");
|
||||
}
|
||||
}
|
||||
// 1.5 · 确认设定后真正发起生成。形式/结构/时长/人物/勾选卖点都走结构化参数,不再塞一句空主题。
|
||||
// 确认设定后真正发起生成。表现形式固定口播,人物、结构、时长与勾选卖点走结构化参数。
|
||||
async function runScriptWithSetup() {
|
||||
const format = coercePresentationFormat(setupFormat);
|
||||
const structure = coerceVideoStructure(setupStructure);
|
||||
const persona = coercePersona(setupPersona);
|
||||
const formatLabel = PRESENTATION_FORMATS[format];
|
||||
const structureLabel = VIDEO_STRUCTURES[structure];
|
||||
const personaLabel = WIZ_PERSONA_LABEL[persona] || persona;
|
||||
// 持久化到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
|
||||
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回入口菜单。
|
||||
await onSaveProjectMeta?.({
|
||||
wizard: {
|
||||
...(project.metadata?.wizard ?? {}),
|
||||
presentation_format: format,
|
||||
video_structure: structure,
|
||||
total_duration: setupDuration,
|
||||
persona,
|
||||
},
|
||||
});
|
||||
const nextWizard = { ...(project.metadata?.wizard ?? {}) };
|
||||
nextWizard.presentation_format = FIXED_PRESENTATION_FORMAT;
|
||||
nextWizard.video_structure = structure;
|
||||
nextWizard.total_duration = setupDuration;
|
||||
nextWizard.persona = persona;
|
||||
await onSaveProjectMeta?.({ wizard: nextWizard });
|
||||
setSetupOpen(false);
|
||||
const combo = `${formatLabel} · ${structureLabel} · ${setupDuration}s · ${personaLabel}`;
|
||||
const personaLabel = WIZ_PERSONA_LABEL[persona] || persona;
|
||||
const combo = `口播 · ${structureLabel} · ${setupDuration}s · ${personaLabel}`;
|
||||
if (setupSource === "video") {
|
||||
const base = chatText.trim();
|
||||
if (!base) {
|
||||
@@ -3122,7 +3098,6 @@ export function PipelinePage(props: {
|
||||
<div className="script-brief-summary" aria-label="当前创作方向">
|
||||
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),其余=设定卡确认时存进 metadata.wizard 的 */}
|
||||
<span className="pill neutral script-brief-pill"><span className="k">来源</span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "脚本辅助生成") : setupOpen ? SOURCE_LABEL[setupSource] : "未选择"}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">形式</span><span className="v" id="brief-format">{wizard?.presentation_format ? PRESENTATION_FORMATS[coercePresentationFormat(wizard.presentation_format)] : "待确认"}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">结构</span><span className="v" id="brief-structure">{wizard?.video_structure ? VIDEO_STRUCTURES[coerceVideoStructure(wizard.video_structure)] : "待确认"}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">时长</span><span className="v" id="brief-duration">{(() => {
|
||||
// 有脚本时按各镜真实秒数加总(镜可以不等长了);没脚本就显示设定卡里选的
|
||||
@@ -3302,7 +3277,7 @@ export function PipelinePage(props: {
|
||||
<time className="chat-time">{msg.time}</time>
|
||||
</div>
|
||||
))}
|
||||
{/* 1.5 · 选定生成方式后的「表现形式 × 视频结构 × 人物 × 时长」设定卡(确认后再生成) */}
|
||||
{/* 选定生成方式后的「视频结构 × 人物 × 时长」设定卡(确认后再生成) */}
|
||||
{setupOpen && (
|
||||
<div className="chat-msg ai">
|
||||
<div className="chat-bubble setup-card">
|
||||
@@ -3314,14 +3289,6 @@ export function PipelinePage(props: {
|
||||
沿用模板的镜数、每镜作用和节奏,文案、画面、模特与故事板都会按{setupProduct?.title || "当前商品"}重新生成 —— 不是把上一条片子换个商品名。
|
||||
</div>
|
||||
)}
|
||||
<label className="setup-field">
|
||||
<span className="sf-k">表现形式</span>
|
||||
<select className="setup-select" value={setupFormat} onChange={(e) => pickFormat(e.target.value as PresentationFormat)}>
|
||||
{PRESENTATION_KEYS.map((k) => <option key={k} value={k}>{PRESENTATION_FORMATS[k]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="setup-rec">{PRESENTATION_HINT[setupFormat]}</div>
|
||||
{/* 1.8 组合联动:短剧下拉里没有「测评验证」—— 演出来的实测没有可信度 */}
|
||||
<label className="setup-field">
|
||||
<span className="sf-k">视频结构</span>
|
||||
<select className="setup-select" value={setupStructure} onChange={(e) => { setupTouched.current = true; setSetupStructure(e.target.value as VideoStructure); }}>
|
||||
@@ -3343,14 +3310,13 @@ export function PipelinePage(props: {
|
||||
{DURATION_OPTIONS.map((s) => <option key={s} value={s}>{s} 秒</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className={`setup-rec${durationHint ? " warn" : ""}`}>{durationHint || `${PRESENTATION_FORMATS[setupFormat]} × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}</div>
|
||||
<div className={`setup-rec${durationHint ? " warn" : ""}`}>{durationHint || `口播 × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}</div>
|
||||
<div className="setup-foot">
|
||||
{/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setSetupOpen(false)}>← 返回</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" title={recommended.reason} onClick={() => {
|
||||
// 重新推荐:回到按商品品类算出的那一组(不是随机换,随机换等于没推荐)
|
||||
setupTouched.current = false;
|
||||
setSetupFormat(recommended.format);
|
||||
setSetupStructure(recommended.structure);
|
||||
setSetupPersona(recommended.persona);
|
||||
setSetupDuration(recommended.duration);
|
||||
@@ -5002,7 +4968,7 @@ export function PipelinePage(props: {
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">会存进模板的内容</label>
|
||||
<div className="tpl-capture">
|
||||
<div className="row"><span className="k">带货套路</span><span className="v">{PRESENTATION_FORMATS[setupFormat]} · {VIDEO_STRUCTURES[setupStructure]}</span></div>
|
||||
<div className="row"><span className="k">带货套路</span><span className="v">口播 · {VIDEO_STRUCTURES[setupStructure]}</span></div>
|
||||
<div className="row"><span className="k">脚本结构</span><span className="v">{shots.map((s) => s.role || "叙述").join(" → ") || "—"}</span></div>
|
||||
<div className="row"><span className="k">镜头节奏</span><span className="v">{shots.length} 镜 · {shots.map((s) => `${s.duration_seconds}s`).join(" / ") || "—"}</span></div>
|
||||
<div className="row"><span className="k">人物设定</span><span className="v">{WIZ_PERSONA_LABEL[setupPersona] || setupPersona}</span></div>
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
}
|
||||
};
|
||||
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
|
||||
// 创建成功弹窗:存刚创建的商品,非空即展示「继续创建 / 极速成片 / 专业创作」
|
||||
// 创建成功弹窗:存刚创建的商品,非空即展示「继续创建 / 一键成片 / 专业创作」
|
||||
const [createdProduct, setCreatedProduct] = useState<Product | null>(null);
|
||||
const [openChip, setOpenChip] = useState<"" | "cat" | "date" | "type">("");
|
||||
// 商品分类筛选改回多选:Set 存已选分类(空 = 全部),菜单含每项计数 + chip 上橙色计数徽标
|
||||
@@ -361,7 +361,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
<>
|
||||
<button className="btn" type="button" onClick={() => { setCreatedProduct(null); setDrawer(true); }}>继续创建商品</button>
|
||||
<button className="btn" type="button" onClick={() => { const productId = createdProduct?.id; setCreatedProduct(null); navigate("projectWizard", { productId }); }}>专业创作</button>
|
||||
<button className="btn btn-primary" type="button" onClick={() => { const productId = createdProduct?.id; setCreatedProduct(null); navigate("quickCreate", { productId }); }}>极速成片</button>
|
||||
<button className="btn btn-primary" type="button" onClick={() => { const productId = createdProduct?.id; setCreatedProduct(null); navigate("quickCreate", { productId }); }}>一键成片</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -372,7 +372,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
close={() => setDrawer(false)}
|
||||
onCreate={onCreate}
|
||||
onUploadImage={onUploadImage}
|
||||
// 创建成功 → 弹「继续创建商品 / 极速成片 / 专业创作」选择弹窗(替代纯 toast)
|
||||
// 创建成功 → 弹「继续创建商品 / 一键成片 / 专业创作」选择弹窗(替代纯 toast)
|
||||
onCreated={(product) => setCreatedProduct(product)}
|
||||
/>
|
||||
</section>
|
||||
@@ -664,7 +664,7 @@ function pdAssetTypeLabel(asset: Asset): string {
|
||||
// 项目状态 → 分桶 / 友好标签 / pill 类(对齐 projects.tsx 语义,组件内自洽)
|
||||
function pdProjBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; }
|
||||
function pdProjStatusLabel(project: Project) {
|
||||
if (isQuickCreateBusy(project)) return "极速成片生成中";
|
||||
if (isQuickCreateBusy(project)) return "一键成片生成中";
|
||||
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
|
||||
}
|
||||
function pdProjPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; }
|
||||
@@ -1203,7 +1203,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
<div className="qa-row-2">
|
||||
<div className="qa-item primary" data-go="quick-create" role="button" tabIndex={0} onClick={() => navigate("quickCreate", { productId: product.id })}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m21.64 3-1.28 1.28a5.5 5.5 0 0 0-7.78 7.78l-8.5 8.5a2.12 2.12 0 0 0 3 3l8.5-8.5a5.5 5.5 0 0 0 7.78-7.78Z"/><path d="m14 7 3 3"/></svg></span>
|
||||
极速成片
|
||||
一键成片
|
||||
</div>
|
||||
<div className="qa-item" data-go="projects-new" role="button" tabIndex={0} onClick={() => navigate("projectWizard", { productId: product.id })}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg></span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isLocalLife } from "../product-business";
|
||||
import { Pager } from "../components/pager";
|
||||
import { SkeletonRows } from "../components/loading";
|
||||
import { useViewMode } from "../components/use-view-mode";
|
||||
import { isQuickCreateBusy } from "../quick-create-lock";
|
||||
import { hasQuickCreateSuffix, isQuickCreateBusy } from "../quick-create-lock";
|
||||
import "../project-wizard-page.css";
|
||||
|
||||
const PROJ_PAGE_SIZE = 8; // 4 列网格两行
|
||||
@@ -436,7 +436,7 @@ const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; labe
|
||||
type ProjTab = (typeof PROJ_TABS)[number]["filter"];
|
||||
|
||||
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string; primary?: boolean }> = [
|
||||
{ title: "极速成片", desc: "输入商品名称并上传图片,自动完成脚本、场景、故事板和视频生成。", page: "quickCreate", image: "/assets/yz/video-quick.jpg", primary: true },
|
||||
{ title: "一键成片", desc: "输入商品名称并上传图片,自动完成脚本、场景、故事板和视频生成。", page: "quickCreate", image: "/assets/yz/video-quick.jpg", primary: true },
|
||||
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产、故事板和最终视频。", page: "projectWizard", image: "/assets/yz/video-pro.jpg", primary: true },
|
||||
];
|
||||
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
|
||||
@@ -448,12 +448,12 @@ const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: strin
|
||||
function isQuickCreateProject(project: Project) {
|
||||
if (project.quick_create) return true;
|
||||
if (project.metadata?.quick_create) return true;
|
||||
return / · 极速成片$/.test(project.name || "");
|
||||
return hasQuickCreateSuffix(project.name);
|
||||
}
|
||||
|
||||
function projectModeLabel(project: Project) {
|
||||
if (isQuickCreateBusy(project)) return "极速成片生成中";
|
||||
return isQuickCreateProject(project) ? "极速成片" : "专业创作";
|
||||
if (isQuickCreateBusy(project)) return "一键成片生成中";
|
||||
return isQuickCreateProject(project) ? "一键成片" : "专业创作";
|
||||
}
|
||||
|
||||
function projCardSub(project: Project, productTitle: string): string {
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError, type QuickCreateJob } from "../api";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob } from "../quick-create-lock";
|
||||
import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays";
|
||||
import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock";
|
||||
import type { ModelConfig } from "../types";
|
||||
import {
|
||||
DEFAULT_BILLING_RATES,
|
||||
@@ -75,7 +75,7 @@ function formatClock(seconds: number) {
|
||||
}
|
||||
|
||||
function historyTitle(item: QuickCreateJob) {
|
||||
return (item.title || item.product_name || "极速成片").replace(/ · 极速成片$/, "");
|
||||
return stripQuickCreateSuffix(item.title || item.product_name || "一键成片");
|
||||
}
|
||||
|
||||
function jobIsComplete(item: QuickCreateJob) {
|
||||
@@ -140,6 +140,7 @@ export function QuickCreatePage({
|
||||
const [pollEpoch, setPollEpoch] = useState(0);
|
||||
const [history, setHistory] = useState<QuickCreateJob[]>([]);
|
||||
const [playing, setPlaying] = useState<{ url: string; title: string } | null>(null);
|
||||
useBodyScrollLock(Boolean(playing));
|
||||
const videoConfigs = useMemo(
|
||||
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
|
||||
[modelConfigs],
|
||||
@@ -261,7 +262,7 @@ export function QuickCreatePage({
|
||||
if (next.status === "succeeded") {
|
||||
if (watchedGeneratingRef.current && completedNoticeRef.current !== next.id) {
|
||||
completedNoticeRef.current = next.id;
|
||||
notifyRef.current?.("success", "极速成片已生成");
|
||||
notifyRef.current?.("success", "一键成片已生成");
|
||||
projectCreatedRef.current?.();
|
||||
}
|
||||
if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status);
|
||||
@@ -274,8 +275,8 @@ export function QuickCreatePage({
|
||||
if (terminalNoticeRef.current !== next.id) {
|
||||
terminalNoticeRef.current = next.id;
|
||||
const message = next.status === "cancelled"
|
||||
? "极速成片已取消"
|
||||
: next.error_message || "极速成片未完成,已完成的步骤会保留,可重试继续";
|
||||
? "一键成片已取消"
|
||||
: next.error_message || "一键成片未完成,已完成的步骤会保留,可重试继续";
|
||||
notifyRef.current?.(next.status === "cancelled" ? "info" : "error", message);
|
||||
}
|
||||
if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status);
|
||||
@@ -293,10 +294,10 @@ export function QuickCreatePage({
|
||||
setJobId("");
|
||||
clearDraft();
|
||||
forgetQuickCreateJob();
|
||||
notifyRef.current?.("info", "已清除其他账号的极速成片记录");
|
||||
notifyRef.current?.("info", "已清除其他账号的一键成片记录");
|
||||
return;
|
||||
}
|
||||
notifyRef.current?.("error", error instanceof Error ? error.message : "读取极速成片进度失败");
|
||||
notifyRef.current?.("error", error instanceof Error ? error.message : "读取一键成片进度失败");
|
||||
timer = window.setTimeout(poll, 8000);
|
||||
}
|
||||
};
|
||||
@@ -369,7 +370,7 @@ export function QuickCreatePage({
|
||||
if (next.status === "queued" || next.status === "running") {
|
||||
onNotify?.("success", "已从上次进度继续生成");
|
||||
} else if (next.status === "succeeded") {
|
||||
onNotify?.("success", "极速成片已生成");
|
||||
onNotify?.("success", "一键成片已生成");
|
||||
} else {
|
||||
onNotify?.("error", next.error_message || "继续生成失败,已完成的步骤会保留");
|
||||
}
|
||||
@@ -429,15 +430,15 @@ export function QuickCreatePage({
|
||||
setSavedImages(created.product_images || []);
|
||||
setSourceProductId(created.product_id || "");
|
||||
rememberQuickCreateJob(created.id);
|
||||
onNotify?.("success", "极速成片任务已启动");
|
||||
onNotify?.("success", "一键成片任务已启动");
|
||||
onProjectCreated?.();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 503) {
|
||||
setServiceUnavailable(true);
|
||||
setUnavailableMessage(error.message || "极速成片服务暂不可用,请稍后再试");
|
||||
onNotify?.("info", error.message || "极速成片服务暂不可用,请稍后再试");
|
||||
setUnavailableMessage(error.message || "一键成片服务暂不可用,请稍后再试");
|
||||
onNotify?.("info", error.message || "一键成片服务暂不可用,请稍后再试");
|
||||
} else {
|
||||
onNotify?.("error", error instanceof Error ? error.message : "极速成片启动失败");
|
||||
onNotify?.("error", error instanceof Error ? error.message : "一键成片启动失败");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -556,7 +557,7 @@ export function QuickCreatePage({
|
||||
<header className="project-builder-header quick-create-header">
|
||||
<div className="project-builder-title">
|
||||
<button type="button" className="image-back-button" onClick={onBack} aria-label={backLabel}><ArrowLeft /></button>
|
||||
<div><h1>极速成片</h1></div>
|
||||
<div><h1>一键成片</h1></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -760,7 +761,7 @@ export function QuickCreatePage({
|
||||
<span className="quick-failed-icon"><AlertCircle /></span>
|
||||
<h2>
|
||||
{serviceUnavailable
|
||||
? "极速成片暂不可用"
|
||||
? "一键成片暂不可用"
|
||||
: isCancelled
|
||||
? "已取消本次生成"
|
||||
: reviewBlocked
|
||||
@@ -798,9 +799,9 @@ export function QuickCreatePage({
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="quick-history" aria-label="过往极速成片项目">
|
||||
<section className="quick-history" aria-label="过往一键成片项目">
|
||||
<div className="quick-history-head">
|
||||
<h2>过往极速成片项目</h2>
|
||||
<h2>过往一键成片项目</h2>
|
||||
<span>{history.length}个项目</span>
|
||||
</div>
|
||||
{history.length ? (
|
||||
@@ -828,7 +829,7 @@ export function QuickCreatePage({
|
||||
<div className="quick-history-copy">
|
||||
<span className={`quick-history-badge${badge === "已完成" ? "" : " is-wait"}`}>{badge}</span>
|
||||
<h3>{historyTitle(item)}</h3>
|
||||
<p>极速成片 · {scenes}场 · {item.result?.aspect_ratio || item.settings?.aspect_ratio || "9:16"} · {(item.result?.resolution || item.settings?.resolution || "720p").toUpperCase()} · {item.result?.video_model || item.settings?.video_model_label || "智能模型"}</p>
|
||||
<p>一键成片 · {scenes}场 · {item.result?.aspect_ratio || item.settings?.aspect_ratio || "9:16"} · {(item.result?.resolution || item.settings?.resolution || "720p").toUpperCase()} · {item.result?.video_model || item.settings?.video_model_label || "智能模型"}</p>
|
||||
</div>
|
||||
<button type="button" className="quick-history-open" onClick={() => openProfessional(item.project_id, item.status)}>
|
||||
<ArrowUpRight />查看项目
|
||||
@@ -838,7 +839,7 @@ export function QuickCreatePage({
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="quick-history-empty">还没有完成的极速成片,生成成功后会出现在这里。</p>
|
||||
<p className="quick-history-empty">还没有完成的一键成片,生成成功后会出现在这里。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -846,7 +847,7 @@ export function QuickCreatePage({
|
||||
open={confirmCancel}
|
||||
title="确认取消生成?"
|
||||
subtitle="当前任务将停止"
|
||||
detail="取消后,本次极速成片不会继续生成;已完成的商品和素材会保留在专业创作项目中。"
|
||||
detail="取消后,本次一键成片不会继续生成;已完成的商品和素材会保留在专业创作项目中。"
|
||||
confirmText="确认取消"
|
||||
icon={<AlertCircle size={16} />}
|
||||
dismissable={!cancelling}
|
||||
@@ -855,6 +856,7 @@ export function QuickCreatePage({
|
||||
/>
|
||||
|
||||
{playing ? (
|
||||
<OverlayPortal>
|
||||
<div className="quick-player-bg" onClick={() => setPlaying(null)} role="presentation">
|
||||
<div className="quick-player" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={playing.title}>
|
||||
<div className="quick-player-bar">
|
||||
@@ -864,6 +866,7 @@ export function QuickCreatePage({
|
||||
<video src={playing.url} controls autoPlay playsInline controlsList="nodownload" />
|
||||
</div>
|
||||
</div>
|
||||
</OverlayPortal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -59,9 +59,9 @@ export type NavigateOptions = {
|
||||
hash?: string;
|
||||
// 视频项目页初始 tab(all/wip/done/fail):仅经 route state 透传给 ProjectsPage,不进 URL path。
|
||||
tab?: string;
|
||||
// 极速成片已结束时仍要进专业模式,跳过「生成中」锁。
|
||||
// 一键成片已结束时仍要进专业模式,跳过「生成中」锁。
|
||||
forcePipeline?: boolean;
|
||||
// 与 forcePipeline 一起用:立刻把列表里的极速成片状态改成非生成中,避免被旧 running 弹回。
|
||||
// 与 forcePipeline 一起用:立刻把列表里的一键成片状态改成非生成中,避免被旧 running 弹回。
|
||||
quickCreateStatus?: string;
|
||||
};
|
||||
export type NavigateFn = (page: Page, options?: NavigateOptions) => void;
|
||||
@@ -108,7 +108,7 @@ export const routeLabels: Record<Page, string> = {
|
||||
messages: "消息",
|
||||
assetFactory: "图片工具",
|
||||
freeCreate: "自由创作",
|
||||
quickCreate: "极速成片",
|
||||
quickCreate: "一键成片",
|
||||
videoRemix: "提炼提示词",
|
||||
videoReplace: "视频复刻",
|
||||
imageOptimize: "图片创作",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
BadgeCheck,
|
||||
Clock3,
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
FileVideo,
|
||||
FileVideo2,
|
||||
@@ -17,13 +18,14 @@ import {
|
||||
ScanSearch,
|
||||
TextCursorInput,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { api, ApiError } from "../api";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import type { ModelConfig, VideoDigestHistory } from "../types";
|
||||
import type { ModelConfig, VideoDigestHistory, VideoDigestJob } from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
export const REMIX_PROMPT_KEY = "fc-remix-prompt";
|
||||
const REMIX_DRAFT_KEY = "vr-digest-draft";
|
||||
const JOB_KEY = "airshelf:video-remix-job";
|
||||
const VIDEO_DIGEST_POINTS = 30;
|
||||
|
||||
type ProgressStage = "upload" | "analyze" | "prompt";
|
||||
@@ -103,6 +105,34 @@ function historySummary(item: VideoDigestHistory) {
|
||||
return bits.join(" · ");
|
||||
}
|
||||
|
||||
function readJobId() {
|
||||
try {
|
||||
return localStorage.getItem(JOB_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function rememberJob(id: string) {
|
||||
try {
|
||||
localStorage.setItem(JOB_KEY, id);
|
||||
} catch {
|
||||
/* 无痕模式忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
function forgetJob() {
|
||||
try {
|
||||
localStorage.removeItem(JOB_KEY);
|
||||
} catch {
|
||||
/* 无痕模式忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
function jobIdOf(job: Pick<VideoDigestJob, "id" | "task_id">) {
|
||||
return job.task_id || job.id || "";
|
||||
}
|
||||
|
||||
export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: {
|
||||
textModels?: ModelConfig[];
|
||||
onNotify: (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -110,7 +140,8 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
navigate: NavigateFn;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [jobId, setJobId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [shots, setShots] = useState(0);
|
||||
@@ -122,11 +153,15 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
const [height, setHeight] = useState(0);
|
||||
const [taskId, setTaskId] = useState("");
|
||||
const [hasResult, setHasResult] = useState(false);
|
||||
const [remoteVideoUrl, setRemoteVideoUrl] = useState("");
|
||||
const [history, setHistory] = useState<VideoDigestHistory[]>([]);
|
||||
const [openHistoryId, setOpenHistoryId] = useState("");
|
||||
const [playing, setPlaying] = useState<VideoDigestHistory | null>(null);
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
|
||||
const completedNoticeRef = useRef("");
|
||||
const blobPreviewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
|
||||
const previewUrl = blobPreviewUrl || remoteVideoUrl;
|
||||
const analyzing = submitting || Boolean(jobId);
|
||||
|
||||
const digestModels = useMemo(
|
||||
() => textModels.filter((model) => model.status === "active" && isGemini31(model)),
|
||||
@@ -145,23 +180,59 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
try {
|
||||
const data = await api.listVideoDigests();
|
||||
setHistory(data.results || []);
|
||||
return data;
|
||||
} catch {
|
||||
/* 历史失败不挡当前拆解 */
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const applyJobMeta = (job: VideoDigestJob) => {
|
||||
if (job.duration) setDuration(job.duration);
|
||||
if (job.file_name) {
|
||||
setFileName(job.file_name);
|
||||
setKind(fileKind(null, job.file_name));
|
||||
}
|
||||
if (job.ratio) setRatio(job.ratio);
|
||||
if (job.width) setWidth(job.width);
|
||||
if (job.height) setHeight(job.height);
|
||||
if (job.video_url) setRemoteVideoUrl(job.video_url);
|
||||
};
|
||||
|
||||
const applySucceededJob = (job: VideoDigestJob) => {
|
||||
const text = (job.text || job.prompt || "").trim();
|
||||
applyJobMeta(job);
|
||||
setPrompt(text);
|
||||
setShots(job.shots || shotCount(text, 0));
|
||||
setTaskId(jobIdOf(job));
|
||||
setHasResult(Boolean(text));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.removeItem(REMIX_DRAFT_KEY);
|
||||
} catch {
|
||||
/* 无痕模式忽略 */
|
||||
}
|
||||
void loadHistory();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const data = await loadHistory();
|
||||
if (cancelled) return;
|
||||
const stored = readJobId();
|
||||
const inflightId = data?.inflight ? jobIdOf(data.inflight) : "";
|
||||
const next = stored || inflightId;
|
||||
if (!next) return;
|
||||
rememberJob(next);
|
||||
if (data?.inflight && jobIdOf(data.inflight) === next) applyJobMeta(data.inflight);
|
||||
setJobId(next);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
}, [previewUrl]);
|
||||
if (blobPreviewUrl) URL.revokeObjectURL(blobPreviewUrl);
|
||||
}, [blobPreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = promptRef.current;
|
||||
@@ -170,8 +241,50 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
el.style.height = `${Math.max(132, el.scrollHeight)}px`;
|
||||
}, [prompt, hasResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const job = await api.getVideoDigest(jobId);
|
||||
if (cancelled) return;
|
||||
applyJobMeta(job);
|
||||
if (job.status === "processing") {
|
||||
timer = window.setTimeout(poll, 2500);
|
||||
return;
|
||||
}
|
||||
if (job.status === "succeeded") {
|
||||
applySucceededJob(job);
|
||||
if (completedNoticeRef.current !== jobIdOf(job)) {
|
||||
completedNoticeRef.current = jobIdOf(job);
|
||||
onNotify("success", "视频拆解完成,已生成提示词");
|
||||
}
|
||||
void loadHistory();
|
||||
} else {
|
||||
onNotify("error", job.error_message || "视频拆解失败,请重试");
|
||||
}
|
||||
setJobId("");
|
||||
forgetJob();
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setJobId("");
|
||||
forgetJob();
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(poll, 8000);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [jobId, onNotify]);
|
||||
|
||||
const pickFile = async (next: File | null) => {
|
||||
if (!next) return;
|
||||
if (!next || analyzing) return;
|
||||
if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) {
|
||||
onNotify("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
|
||||
return;
|
||||
@@ -191,33 +304,38 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
if (meta.duration) setDuration(Math.round(meta.duration));
|
||||
setHasResult(false);
|
||||
setTaskId("");
|
||||
setRemoteVideoUrl("");
|
||||
setPrompt("");
|
||||
};
|
||||
|
||||
const analyze = async () => {
|
||||
if (!file || busy) return;
|
||||
setBusy(true);
|
||||
if (!file || analyzing) return;
|
||||
setSubmitting(true);
|
||||
setHasResult(false);
|
||||
setPrompt("");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (activeModel?.id) fd.append("model_config_id", activeModel.id);
|
||||
const digest = await api.extractVideoDigest(fd);
|
||||
const text = digest.text.trim();
|
||||
setPrompt(text);
|
||||
setDuration(digest.duration || duration);
|
||||
setShots(digest.shots || shotCount(text, digest.frames));
|
||||
setFileName(digest.file_name || file.name);
|
||||
setFileSize(file.size);
|
||||
if (digest.ratio) setRatio(digest.ratio);
|
||||
if (digest.width) setWidth(digest.width);
|
||||
if (digest.height) setHeight(digest.height);
|
||||
setTaskId(digest.task_id || "");
|
||||
setHasResult(true);
|
||||
onNotify("success", "视频拆解完成,已生成提示词");
|
||||
void loadHistory();
|
||||
const job = await api.extractVideoDigest(fd);
|
||||
const id = jobIdOf(job);
|
||||
applyJobMeta(job);
|
||||
if (job.status === "succeeded") {
|
||||
applySucceededJob(job);
|
||||
onNotify("success", "视频拆解完成,已生成提示词");
|
||||
void loadHistory();
|
||||
return;
|
||||
}
|
||||
if (!id) {
|
||||
onNotify("error", "视频拆解失败,请重试");
|
||||
return;
|
||||
}
|
||||
rememberJob(id);
|
||||
setJobId(id);
|
||||
} catch (error) {
|
||||
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -242,6 +360,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
}
|
||||
};
|
||||
|
||||
const downloadVideo = () => {
|
||||
if (!remoteVideoUrl) {
|
||||
onNotify("info", "这条没有保存原片,重新上传拆一次就能下载");
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.href = remoteVideoUrl;
|
||||
link.download = `${(fileName || "参考视频").replace(/\.[^.]+$/, "") || "参考视频"}.mp4`;
|
||||
link.rel = "noopener";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
onNotify("success", "已开始下载参考视频");
|
||||
};
|
||||
|
||||
const continueGenerate = () => {
|
||||
const text = prompt.trim();
|
||||
if (!text) return;
|
||||
@@ -258,11 +391,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
}
|
||||
};
|
||||
|
||||
const analyzeLabel = busy
|
||||
const analyzeLabel = analyzing
|
||||
? "正在拆解…"
|
||||
: `生成这个需要 ${estimatedPoints} 积分`;
|
||||
|
||||
const stage: ProgressStage = busy ? "analyze" : hasResult ? "prompt" : file ? "analyze" : "upload";
|
||||
const stage: ProgressStage = analyzing ? "analyze" : hasResult ? "prompt" : file || remoteVideoUrl ? "analyze" : "upload";
|
||||
const panelClass = [
|
||||
"video-result-panel remix-information-panel",
|
||||
hasResult ? "has-result" : "",
|
||||
analyzing ? "is-analyzing" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className="vr-page">
|
||||
@@ -303,14 +441,15 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<strong>参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 60 秒</span>
|
||||
</div>
|
||||
{file && previewUrl ? (
|
||||
{previewUrl ? (
|
||||
<div className="video-upload-field has-file has-preview">
|
||||
<video src={previewUrl} controls playsInline preload="metadata" />
|
||||
<label className="remix-replace-video">
|
||||
<label className={`remix-replace-video${analyzing ? " is-disabled" : ""}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
hidden
|
||||
disabled={analyzing}
|
||||
onChange={(event) => {
|
||||
void pickFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
@@ -325,6 +464,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
hidden
|
||||
disabled={analyzing}
|
||||
onChange={(event) => {
|
||||
void pickFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
@@ -339,20 +479,31 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
)}
|
||||
</div>
|
||||
<div className="video-flow-actions remix-analyze-actions">
|
||||
<button type="button" className="primary-action" disabled={!file || busy} onClick={() => void analyze()}>
|
||||
<button type="button" className="primary-action" disabled={!file || analyzing} onClick={() => void analyze()}>
|
||||
<ScanSearch />
|
||||
<span>{analyzeLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className={`video-result-panel remix-information-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
|
||||
<aside className={panelClass} aria-live="polite">
|
||||
<div className="video-result-placeholder">
|
||||
<div>
|
||||
<span className="remix-placeholder-icon"><ScanLine /></span>
|
||||
<strong>等待视频解析</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="remix-generating-state" role="status">
|
||||
<div className="remix-generating-content">
|
||||
<div className="remix-generating-visual">
|
||||
<span className="remix-generating-frame"><ScanSearch /></span>
|
||||
<span className="remix-generating-badge"><FileVideo2 /></span>
|
||||
</div>
|
||||
<strong>正在提炼提示词</strong>
|
||||
<span>离开页面也不会中断,稍后回来即可查看结果</span>
|
||||
<div className="remix-generating-bar" aria-hidden="true"><span /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-analysis-result">
|
||||
<div className="remix-info-heading">
|
||||
<span className="remix-status-icon"><BadgeCheck /></span>
|
||||
@@ -387,13 +538,23 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<section className={`remix-prompt-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
|
||||
<div className="remix-prompt-placeholder">
|
||||
<span><TextCursorInput /></span>
|
||||
<div><strong>等待生成提示词</strong></div>
|
||||
<div><strong>{analyzing ? "正在生成提示词" : "等待生成提示词"}</strong></div>
|
||||
</div>
|
||||
<div className="remix-prompt-result">
|
||||
<div className="remix-prompt-head">
|
||||
<div className="remix-prompt-title">
|
||||
<div><h2>提示词内容</h2></div>
|
||||
</div>
|
||||
<div className="remix-prompt-head-actions">
|
||||
<button type="button" className="secondary-action remix-prompt-head-btn" onClick={downloadVideo} disabled={!remoteVideoUrl}>
|
||||
<Download />
|
||||
下载视频
|
||||
</button>
|
||||
<button type="button" className="secondary-action remix-prompt-head-btn" onClick={() => void savePrompt()}>
|
||||
<Save />
|
||||
保存提示词
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={promptRef}
|
||||
@@ -403,10 +564,6 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
<div className="video-flow-actions remix-prompt-actions">
|
||||
<button type="button" className="secondary-action" onClick={() => void savePrompt()}>
|
||||
<Save />
|
||||
保存提示词
|
||||
</button>
|
||||
<button type="button" className="primary-action" onClick={continueGenerate}>
|
||||
<span>生成视频</span>
|
||||
<ArrowRight />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clapperboard,
|
||||
Download,
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
RefreshCw,
|
||||
Replace,
|
||||
Upload,
|
||||
UserRound,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import { OverlayPortal, useBodyScrollLock } from "../components/overlays";
|
||||
import {
|
||||
DEFAULT_BILLING_RATES,
|
||||
FC_MODELS,
|
||||
@@ -27,14 +28,64 @@ import {
|
||||
isInFlight,
|
||||
type BillingRates,
|
||||
} from "../components/free-create/constants";
|
||||
import type { FreeVideoRef, FreeVideoTask, ModelConfig, Product } from "../types";
|
||||
import type { FreeVideoRef, FreeVideoTask, ModelConfig, ModelEntity, Product } from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
const JOB_KEY = "airshelf:video-replace-job";
|
||||
const REMIX_MARK = "[视频复刻]";
|
||||
const CHARACTER_MARK = "[视频复刻·角色]";
|
||||
const VIDEO_ACCEPT = "video/mp4,video/quicktime";
|
||||
|
||||
type ProductSource = "library" | "temporary" | "";
|
||||
type ReplaceMode = "product" | "character";
|
||||
|
||||
const REPLACE_MODE_COPY = {
|
||||
product: {
|
||||
modeLabel: "商品复刻",
|
||||
targetLabel: "商品",
|
||||
targetStep: "2. 选择自己的商品",
|
||||
videoEmpty: "系统将自动识别需要替换的商品区域",
|
||||
videoReady: "视频已就绪,将自动识别原商品区域",
|
||||
libraryTitle: "从商品库选择",
|
||||
libraryEmpty: "选择已创建的商品",
|
||||
temporaryTitle: "临时上传商品",
|
||||
temporaryEmpty: "仅用于本次任务 · 最多 9 张",
|
||||
temporaryNoun: "商品图",
|
||||
temporaryFallback: "临时商品素材",
|
||||
generatingTitle: "正在进行商品复刻",
|
||||
generatingCopy: "正在匹配商品外观与原片镜头",
|
||||
resultTitle: "商品复刻已完成",
|
||||
resultPreview: "商品复刻预览",
|
||||
consistency: "商品一致性检查通过",
|
||||
drawerTitle: "选择商品",
|
||||
drawerDescription: "从已经创建的商品中选择一个用于本次视频复刻。",
|
||||
drawerEmpty: "还没有商品,先去商品库创建一个",
|
||||
historyKind: "商品",
|
||||
pickToast: "已选择商品",
|
||||
},
|
||||
character: {
|
||||
modeLabel: "角色复刻",
|
||||
targetLabel: "角色",
|
||||
targetStep: "2. 选择自己的角色",
|
||||
videoEmpty: "系统将自动识别需要替换的原片角色",
|
||||
videoReady: "视频已就绪,将自动识别原片角色",
|
||||
libraryTitle: "从人物库选择",
|
||||
libraryEmpty: "选择已创建的人物",
|
||||
temporaryTitle: "临时上传角色",
|
||||
temporaryEmpty: "仅用于本次任务 · 最多 9 张参考图",
|
||||
temporaryNoun: "角色参考图",
|
||||
temporaryFallback: "临时角色素材",
|
||||
generatingTitle: "正在进行角色复刻",
|
||||
generatingCopy: "正在匹配角色外观、表情与原片动作",
|
||||
resultTitle: "角色复刻已完成",
|
||||
resultPreview: "角色复刻预览",
|
||||
consistency: "角色一致性检查通过",
|
||||
drawerTitle: "选择模特",
|
||||
drawerDescription: "从人物库中选择一个角色用于本次视频复刻。",
|
||||
drawerEmpty: "还没有人物,先去模特库添加",
|
||||
historyKind: "角色",
|
||||
pickToast: "已选择角色",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function readJobId() {
|
||||
try {
|
||||
@@ -94,27 +145,59 @@ function clampDuration(seconds: number) {
|
||||
return Math.min(15, Math.max(4, rounded || 15));
|
||||
}
|
||||
|
||||
function isRemixTask(task: FreeVideoTask) {
|
||||
return (task.prompt || "").startsWith(REMIX_MARK);
|
||||
function isCharacterRemix(task?: Partial<FreeVideoTask> | null) {
|
||||
if (task?.replace_mode === "character") return true;
|
||||
if (task?.replace_mode === "product") return false;
|
||||
return (task?.prompt || "").startsWith(CHARACTER_MARK);
|
||||
}
|
||||
|
||||
function productNameFromPrompt(prompt?: string) {
|
||||
const match = (prompt || "").match(/商品:([^\n。]+)/);
|
||||
function modeFromTask(task?: Partial<FreeVideoTask> | null): ReplaceMode {
|
||||
return isCharacterRemix(task) ? "character" : "product";
|
||||
}
|
||||
|
||||
function subjectNameFromTask(task?: Partial<FreeVideoTask> | null) {
|
||||
const named = (task?.subject_name || "").trim();
|
||||
if (named) return named;
|
||||
const match = (task?.prompt || "").match(/(?:商品|角色):([^\n。]+)/);
|
||||
return (match?.[1] || "").trim();
|
||||
}
|
||||
|
||||
function remixTitle(task?: Partial<FreeVideoTask> | null) {
|
||||
const name = productNameFromPrompt(task?.prompt);
|
||||
return name ? `${name}视频复刻` : "视频复刻预览";
|
||||
const copy = REPLACE_MODE_COPY[modeFromTask(task)];
|
||||
const name = subjectNameFromTask(task);
|
||||
if (name) return `${name}${copy.modeLabel}`;
|
||||
return copy.resultPreview;
|
||||
}
|
||||
|
||||
function remixSourceLabel(task?: Partial<FreeVideoTask> | null) {
|
||||
const prompt = task?.prompt || "";
|
||||
const name = productNameFromPrompt(prompt);
|
||||
if (/商品库/.test(prompt) && name) return `商品库:${name}`;
|
||||
const name = subjectNameFromTask(task);
|
||||
const source = task?.subject_source;
|
||||
if (modeFromTask(task) === "character") {
|
||||
if ((source === "library" || /人物库/.test(prompt)) && name) return `人物库:${name}`;
|
||||
return "临时角色素材";
|
||||
}
|
||||
if ((source === "library" || /商品库/.test(prompt)) && name) return `商品库:${name}`;
|
||||
return "临时商品素材";
|
||||
}
|
||||
|
||||
function videoRefFromTask(task?: Partial<FreeVideoTask> | null) {
|
||||
return (task?.references || []).find((item) => item.type === "video") || null;
|
||||
}
|
||||
|
||||
function imageRefsFromTask(task?: Partial<FreeVideoTask> | null) {
|
||||
return (task?.references || []).filter((item) => item.type === "image" && (item.url || item.asset_id));
|
||||
}
|
||||
|
||||
function sizeFromRatio(ratio: string) {
|
||||
if (ratio === "16:9") return { width: 1280, height: 720 };
|
||||
if (ratio === "1:1") return { width: 1080, height: 1080 };
|
||||
if (ratio === "3:4") return { width: 834, height: 1112 };
|
||||
if (ratio === "4:3") return { width: 1112, height: 834 };
|
||||
if (ratio === "21:9") return { width: 1470, height: 630 };
|
||||
return { width: 720, height: 1280 };
|
||||
}
|
||||
|
||||
function productCover(product: Product) {
|
||||
return product.cover_preview_url || product.images?.find((image) => image.is_primary)?.preview_url || product.images?.[0]?.preview_url || "";
|
||||
}
|
||||
@@ -123,9 +206,12 @@ function productImageCount(product: Product) {
|
||||
return product.images?.filter((image) => image.asset || image.preview_url).length || 0;
|
||||
}
|
||||
|
||||
function buildPrompt(productName: string, fromLibrary: boolean) {
|
||||
const source = fromLibrary ? "商品库中的" : "本次上传的";
|
||||
return `${REMIX_MARK} 商品:${productName}。使用${source}商品参考图,保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围,将画面中的原商品完整替换为该商品。商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。`;
|
||||
function modelCover(model: ModelEntity) {
|
||||
return model.portrait || model.triview || "";
|
||||
}
|
||||
|
||||
function modelImageCount(model: ModelEntity) {
|
||||
return [model.portrait, model.triview].filter(Boolean).length;
|
||||
}
|
||||
|
||||
export function VideoReplacePage({
|
||||
@@ -143,22 +229,28 @@ export function VideoReplacePage({
|
||||
navigate?: NavigateFn;
|
||||
}) {
|
||||
const [products, setProducts] = useState(initialProducts);
|
||||
const [models, setModels] = useState<ModelEntity[]>([]);
|
||||
const [replaceMode, setReplaceMode] = useState<ReplaceMode>("product");
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoRef, setVideoRef] = useState<FreeVideoRef | null>(null);
|
||||
const [videoUploading, setVideoUploading] = useState(false);
|
||||
const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 });
|
||||
const [source, setSource] = useState<ProductSource>("");
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<ModelEntity | null>(null);
|
||||
const [tempFiles, setTempFiles] = useState<File[]>([]);
|
||||
const [tempPreviews, setTempPreviews] = useState<string[]>([]);
|
||||
const [tempAssetRefs, setTempAssetRefs] = useState<FreeVideoRef[]>([]);
|
||||
const [filledSubjectName, setFilledSubjectName] = useState("");
|
||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||
const [pendingProductId, setPendingProductId] = useState("");
|
||||
const [pendingModelId, setPendingModelId] = useState("");
|
||||
const [jobId, setJobId] = useState(readJobId);
|
||||
const [job, setJob] = useState<FreeVideoTask | null>(null);
|
||||
const [history, setHistory] = useState<FreeVideoTask[]>([]);
|
||||
const [expandedHistoryId, setExpandedHistoryId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [billingRates, setBillingRates] = useState<BillingRates>(DEFAULT_BILLING_RATES);
|
||||
const [playing, setPlaying] = useState<{ url: string; title: string } | null>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
const tempInputRef = useRef<HTMLInputElement>(null);
|
||||
const completedNoticeRef = useRef("");
|
||||
@@ -172,15 +264,33 @@ export function VideoReplacePage({
|
||||
[videoConfigs],
|
||||
);
|
||||
|
||||
const copy = REPLACE_MODE_COPY[replaceMode];
|
||||
const videoReady = Boolean(videoRef?.asset_id);
|
||||
const productName = source === "library"
|
||||
? (selectedProduct?.title || "")
|
||||
: tempFiles[0]
|
||||
? (tempFiles.length > 1
|
||||
? `${tempFiles[0].name.replace(/\.[^.]+$/, "") || "临时商品素材"}(${tempFiles.length}张参考图)`
|
||||
: (tempFiles[0].name.replace(/\.[^.]+$/, "") || "临时商品素材"))
|
||||
? (replaceMode === "character" ? (selectedModel?.name || "") : (selectedProduct?.title || ""))
|
||||
: source === "temporary"
|
||||
? (tempFiles[0]
|
||||
? (tempFiles.length > 1
|
||||
? `${tempFiles[0].name.replace(/\.[^.]+$/, "") || copy.temporaryFallback}(${tempFiles.length}张参考图)`
|
||||
: (tempFiles[0].name.replace(/\.[^.]+$/, "") || copy.temporaryFallback))
|
||||
: (filledSubjectName || copy.temporaryFallback))
|
||||
: "";
|
||||
const productReady = source === "library" ? Boolean(selectedProduct) : tempFiles.length > 0;
|
||||
const libraryPreview = selectedProduct ? productCover(selectedProduct) : "";
|
||||
const productReady = source === "library"
|
||||
? (replaceMode === "character" ? Boolean(selectedModel) : Boolean(selectedProduct))
|
||||
: (tempFiles.length > 0 || tempAssetRefs.length > 0);
|
||||
const libraryPreview = replaceMode === "character"
|
||||
? (selectedModel ? modelCover(selectedModel) : "")
|
||||
: (selectedProduct ? productCover(selectedProduct) : "");
|
||||
const libraryImageCount = replaceMode === "character"
|
||||
? (selectedModel ? modelImageCount(selectedModel) : 0)
|
||||
: (selectedProduct ? productImageCount(selectedProduct) : 0);
|
||||
const tempDisplay = tempFiles.length
|
||||
? tempFiles.map((file, index) => ({ key: fileKey(file), src: tempPreviews[index], name: file.name }))
|
||||
: tempAssetRefs.map((item, index) => ({
|
||||
key: item.asset_id || `${item.url}-${index}`,
|
||||
src: item.url || item.thumb_url || "",
|
||||
name: item.label || `${copy.temporaryNoun}${index + 1}`,
|
||||
}));
|
||||
const aspectRatio = ratioFromSize(videoMeta.width, videoMeta.height);
|
||||
const outputDuration = clampDuration(videoMeta.duration || 15);
|
||||
const estimated = estimateCost(preferredModel, {
|
||||
@@ -189,12 +299,16 @@ export function VideoReplacePage({
|
||||
duration: outputDuration,
|
||||
refs: [
|
||||
...(videoRef ? [{ type: "video", duration: videoRef.duration || videoMeta.duration }] : []),
|
||||
...((source === "library" ? (selectedProduct?.images || []).slice(0, MAX_IMAGES) : tempFiles).map(() => ({ type: "image" }))),
|
||||
...((source === "library"
|
||||
? Array.from({ length: Math.max(1, libraryImageCount) }, () => ({ type: "image" as const }))
|
||||
: (tempFiles.length ? tempFiles : tempAssetRefs)
|
||||
).map(() => ({ type: "image" }))),
|
||||
],
|
||||
}, billingRates);
|
||||
const points = estimated.points || 220;
|
||||
const generating = Boolean(job && isInFlight(job.status)) || submitting || videoUploading;
|
||||
const hasResult = Boolean(job && job.status === "succeeded" && job.video_url);
|
||||
const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy;
|
||||
const panelClass = [
|
||||
"video-result-panel replace-result-panel",
|
||||
generating ? "is-generating" : "",
|
||||
@@ -203,13 +317,13 @@ export function VideoReplacePage({
|
||||
const generateLabel = generating
|
||||
? "正在复刻…"
|
||||
: hasResult
|
||||
? `再次复刻 · 消耗 ${points} 积分`
|
||||
: `开始复刻 · 消耗 ${points} 积分`;
|
||||
? `再次${copy.modeLabel} · 消耗 ${points} 积分`
|
||||
: `开始${copy.modeLabel} · 消耗 ${points} 积分`;
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const data = await api.freeVideoTasks(0, 50);
|
||||
setHistory((data.results || []).filter((item) => isRemixTask(item) && item.status === "succeeded"));
|
||||
const data = await api.videoReplaceTasks(0, 50);
|
||||
setHistory((data.results || []).filter((item) => item.status === "succeeded"));
|
||||
} catch {
|
||||
/* 历史失败不挡当前复刻 */
|
||||
}
|
||||
@@ -217,6 +331,7 @@ export function VideoReplacePage({
|
||||
|
||||
useEffect(() => {
|
||||
void api.products(100).then((payload) => setProducts(payload.results || [])).catch(() => undefined);
|
||||
void api.listModels({ pageSize: 200 }).then((payload) => setModels((payload.results || []).filter((item) => !item.is_deleted))).catch(() => undefined);
|
||||
void api.billingConfig()
|
||||
.then((config) => setBillingRates({
|
||||
margin: Number(config.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin,
|
||||
@@ -238,14 +353,7 @@ export function VideoReplacePage({
|
||||
return () => urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
}, [tempFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryOpen) return;
|
||||
const previous = document.body.classList.contains("asset-library-open");
|
||||
document.body.classList.add("asset-library-open");
|
||||
return () => {
|
||||
if (!previous) document.body.classList.remove("asset-library-open");
|
||||
};
|
||||
}, [libraryOpen]);
|
||||
useBodyScrollLock(libraryOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
@@ -253,7 +361,7 @@ export function VideoReplacePage({
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await api.pollFreeVideo(jobId);
|
||||
const data = await api.pollVideoReplace(jobId);
|
||||
if (cancelled) return;
|
||||
setJob(data.task);
|
||||
if (isInFlight(data.task.status)) {
|
||||
@@ -349,7 +457,7 @@ export function VideoReplacePage({
|
||||
});
|
||||
const room = Math.max(0, MAX_IMAGES - current.length);
|
||||
if (room === 0) {
|
||||
onNotify("info", "最多上传9张商品图片");
|
||||
onNotify("info", `最多上传9张${copy.temporaryNoun}`);
|
||||
return current;
|
||||
}
|
||||
if (!unique.length) {
|
||||
@@ -361,92 +469,107 @@ export function VideoReplacePage({
|
||||
});
|
||||
setSource("temporary");
|
||||
setSelectedProduct(null);
|
||||
setSelectedModel(null);
|
||||
setTempAssetRefs([]);
|
||||
setFilledSubjectName("");
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
};
|
||||
|
||||
const confirmLibraryProduct = () => {
|
||||
const switchReplaceMode = (next: ReplaceMode) => {
|
||||
if (next === replaceMode || generating) return;
|
||||
setReplaceMode(next);
|
||||
setSource("");
|
||||
setSelectedProduct(null);
|
||||
setSelectedModel(null);
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs([]);
|
||||
setFilledSubjectName("");
|
||||
setPendingProductId("");
|
||||
setPendingModelId("");
|
||||
setLibraryOpen(false);
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
onNotify("info", `已切换为${REPLACE_MODE_COPY[next].modeLabel}`);
|
||||
};
|
||||
|
||||
const confirmLibrarySelection = () => {
|
||||
if (replaceMode === "character") {
|
||||
const model = models.find((item) => item.id === pendingModelId);
|
||||
if (!model) {
|
||||
onNotify("info", "请先选择一个角色");
|
||||
return;
|
||||
}
|
||||
if (!modelCover(model)) {
|
||||
onNotify("error", "这个角色还没有可用图片");
|
||||
return;
|
||||
}
|
||||
setSelectedModel(model);
|
||||
setSelectedProduct(null);
|
||||
setSource("library");
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs([]);
|
||||
setFilledSubjectName("");
|
||||
setLibraryOpen(false);
|
||||
setPendingModelId("");
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
onNotify("success", `${copy.pickToast}:${model.name}`);
|
||||
return;
|
||||
}
|
||||
const product = products.find((item) => item.id === pendingProductId);
|
||||
if (!product) {
|
||||
onNotify("info", "请先选择或上传商品素材");
|
||||
return;
|
||||
}
|
||||
setSelectedProduct(product);
|
||||
setSelectedModel(null);
|
||||
setSource("library");
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs([]);
|
||||
setFilledSubjectName("");
|
||||
setLibraryOpen(false);
|
||||
setPendingProductId("");
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
onNotify("success", `已选择商品:${product.title}`);
|
||||
onNotify("success", `${copy.pickToast}:${product.title}`);
|
||||
};
|
||||
|
||||
const startGeneration = async () => {
|
||||
if (!videoFile || !productReady || generating) return;
|
||||
if (!videoReady || !productReady || generating) return;
|
||||
if (!preferredModel) {
|
||||
onNotify("error", "暂无可用视频模型");
|
||||
return;
|
||||
}
|
||||
if (!videoRef?.asset_id) {
|
||||
onNotify("error", "请先上传参考视频");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let imageRefs: FreeVideoRef[] = [];
|
||||
if (source === "library" && selectedProduct) {
|
||||
imageRefs = (selectedProduct.images || [])
|
||||
.filter((image) => image.asset || image.preview_url)
|
||||
.slice(0, MAX_IMAGES)
|
||||
.map((image, index) => ({
|
||||
url: image.preview_url || "",
|
||||
type: "image" as const,
|
||||
role: "reference_image",
|
||||
label: `${selectedProduct.title}${index + 1}`,
|
||||
asset_id: image.asset,
|
||||
source: "asset" as const,
|
||||
}));
|
||||
if (!imageRefs.length && (selectedProduct.cover_asset || productCover(selectedProduct))) {
|
||||
imageRefs = [{
|
||||
url: productCover(selectedProduct),
|
||||
type: "image",
|
||||
role: "reference_image",
|
||||
label: selectedProduct.title,
|
||||
asset_id: selectedProduct.cover_asset || undefined,
|
||||
source: selectedProduct.cover_asset ? "asset" : "upload",
|
||||
}];
|
||||
let imageAssetIds: string[] = [];
|
||||
if (source === "temporary") {
|
||||
if (tempFiles.length) {
|
||||
for (const file of tempFiles.slice(0, MAX_IMAGES)) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const data = await api.uploadFreeVideoRef(form);
|
||||
if (data.asset_id) imageAssetIds.push(data.asset_id);
|
||||
}
|
||||
} else {
|
||||
imageAssetIds = tempAssetRefs.map((item) => item.asset_id || "").filter(Boolean);
|
||||
}
|
||||
if (!imageRefs.length) {
|
||||
onNotify("error", "这个商品还没有可用图片");
|
||||
if (!imageAssetIds.length) {
|
||||
onNotify("error", replaceMode === "character" ? "请上传角色参考图" : "请上传商品参考图");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const uploaded: FreeVideoRef[] = [];
|
||||
for (const file of tempFiles.slice(0, MAX_IMAGES)) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const data = await api.uploadFreeVideoRef(form);
|
||||
uploaded.push({
|
||||
url: data.url,
|
||||
type: "image",
|
||||
role: "reference_image",
|
||||
label: data.name || file.name,
|
||||
thumb_url: data.thumb_url || data.url,
|
||||
asset_id: data.asset_id,
|
||||
source: "upload",
|
||||
});
|
||||
}
|
||||
imageRefs = uploaded;
|
||||
}
|
||||
if (!videoRef) {
|
||||
onNotify("error", "请先上传参考视频");
|
||||
return;
|
||||
}
|
||||
const prompt = buildPrompt(productName.replace(/(\d+张参考图)$/, ""), source === "library");
|
||||
const data = await api.submitFreeVideo({
|
||||
prompt,
|
||||
mode: "universal",
|
||||
const data = await api.submitVideoReplace({
|
||||
replace_mode: replaceMode,
|
||||
video_asset_id: videoRef.asset_id,
|
||||
product_id: source === "library" && replaceMode === "product" ? selectedProduct?.id : undefined,
|
||||
model_id: source === "library" && replaceMode === "character" ? selectedModel?.id : undefined,
|
||||
image_asset_ids: source === "temporary" ? imageAssetIds : undefined,
|
||||
model: preferredModel.name,
|
||||
aspect_ratio: aspectRatio,
|
||||
resolution: "720p",
|
||||
duration: outputDuration,
|
||||
seed: -1,
|
||||
generate_audio: true,
|
||||
references: [videoRef, ...imageRefs],
|
||||
});
|
||||
setJob(data.task);
|
||||
setJobId(data.task.id);
|
||||
@@ -465,6 +588,68 @@ export function VideoReplacePage({
|
||||
}
|
||||
};
|
||||
|
||||
const fillFormFromTask = (task: FreeVideoTask) => {
|
||||
if (generating) return;
|
||||
const mode = modeFromTask(task);
|
||||
const video = videoRefFromTask(task);
|
||||
if (!video?.asset_id) {
|
||||
onNotify("error", "这条记录没有可用的参考视频,请重新上传");
|
||||
return;
|
||||
}
|
||||
const images = imageRefsFromTask(task);
|
||||
const subject = subjectNameFromTask(task);
|
||||
setReplaceMode(mode);
|
||||
setVideoFile(null);
|
||||
setVideoRef({
|
||||
...video,
|
||||
type: "video",
|
||||
role: "reference_video",
|
||||
label: video.label || "参考视频",
|
||||
});
|
||||
setVideoMeta({
|
||||
duration: Number(video.duration || task.duration || 0),
|
||||
...sizeFromRatio(task.aspect_ratio || "9:16"),
|
||||
});
|
||||
setFilledSubjectName(subject);
|
||||
if (mode === "character") {
|
||||
const model = models.find((item) => item.id === task.model_id)
|
||||
|| models.find((item) => item.name === subject);
|
||||
if (model) {
|
||||
setSelectedModel(model);
|
||||
setSelectedProduct(null);
|
||||
setSource("library");
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs([]);
|
||||
} else {
|
||||
setSelectedModel(null);
|
||||
setSelectedProduct(null);
|
||||
setSource(images.length ? "temporary" : "");
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs(images);
|
||||
if (!model && task.subject_source === "library") onNotify("info", "原角色已不在人物库,请重新选择");
|
||||
}
|
||||
} else {
|
||||
const product = products.find((item) => item.id === task.product_id)
|
||||
|| products.find((item) => item.title === subject);
|
||||
if (product) {
|
||||
setSelectedProduct(product);
|
||||
setSelectedModel(null);
|
||||
setSource("library");
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs([]);
|
||||
} else {
|
||||
setSelectedProduct(null);
|
||||
setSelectedModel(null);
|
||||
setSource(images.length ? "temporary" : "");
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs(images);
|
||||
if (!product && task.subject_source === "library") onNotify("info", "原商品已不在商品库,请重新选择");
|
||||
}
|
||||
}
|
||||
onNotify("success", "已填入上次素材,确认后可再次生成");
|
||||
document.querySelector(".replace-flow-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
const downloadVideo = (url: string, title: string) => {
|
||||
if (!url) return;
|
||||
const link = document.createElement("a");
|
||||
@@ -477,13 +662,11 @@ export function VideoReplacePage({
|
||||
onNotify("success", "已开始下载视频复刻成片");
|
||||
};
|
||||
|
||||
const openHistory = (item: FreeVideoTask) => {
|
||||
setJob(item);
|
||||
setJobId("");
|
||||
forgetJob();
|
||||
const toggleHistory = (id: string) => {
|
||||
setExpandedHistoryId((current) => (current === id ? "" : id));
|
||||
};
|
||||
|
||||
const cells = Array.from({ length: 9 }, (_, index) => tempFiles[index] || null);
|
||||
const cells = Array.from({ length: 9 }, (_, index) => tempDisplay[index] || null);
|
||||
|
||||
return (
|
||||
<div className="vrep-page">
|
||||
@@ -503,12 +686,38 @@ export function VideoReplacePage({
|
||||
<div className="video-flow-grid">
|
||||
<section className="video-flow-panel replace-flow-panel">
|
||||
<h2>准备复刻素材</h2>
|
||||
<div className="replace-mode-switch" role="tablist" aria-label="选择视频复刻功能">
|
||||
<button
|
||||
type="button"
|
||||
className={`replace-mode-button${replaceMode === "product" ? " active" : ""}`}
|
||||
data-replace-mode="product"
|
||||
role="tab"
|
||||
aria-selected={replaceMode === "product"}
|
||||
disabled={generating}
|
||||
onClick={() => switchReplaceMode("product")}
|
||||
>
|
||||
<Package />
|
||||
<span>替换商品</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`replace-mode-button${replaceMode === "character" ? " active" : ""}`}
|
||||
data-replace-mode="character"
|
||||
role="tab"
|
||||
aria-selected={replaceMode === "character"}
|
||||
disabled={generating}
|
||||
onClick={() => switchReplaceMode("character")}
|
||||
>
|
||||
<UserRound />
|
||||
<span>替换角色</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="video-flow-step">
|
||||
<div className="video-flow-step-head">
|
||||
<strong>1. 参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 60 秒</span>
|
||||
<span>MP4 / MOV · 最长 15 秒</span>
|
||||
</div>
|
||||
<label className={`video-upload-field${videoFile ? " has-file" : ""}`}>
|
||||
<label className={`video-upload-field${videoReady ? " has-file" : ""}`}>
|
||||
<input
|
||||
ref={videoInputRef}
|
||||
type="file"
|
||||
@@ -521,15 +730,15 @@ export function VideoReplacePage({
|
||||
/>
|
||||
<span>
|
||||
<FileVideo2 />
|
||||
<strong>{videoFile ? videoFile.name : "点击上传参考视频"}</strong>
|
||||
<small>{videoFile ? "视频已就绪,将自动识别原商品区域" : "系统将自动识别需要替换的商品区域"}</small>
|
||||
<strong>{videoFile?.name || (videoReady ? "参考视频已就绪" : "点击上传参考视频")}</strong>
|
||||
<small>{videoReady ? copy.videoReady : copy.videoEmpty}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="video-flow-step">
|
||||
<div className="video-flow-step-head">
|
||||
<strong>2. 选择自己的商品</strong>
|
||||
<strong>{copy.targetStep}</strong>
|
||||
<span>请选择一种方式</span>
|
||||
</div>
|
||||
<div className="product-replace-options">
|
||||
@@ -537,46 +746,65 @@ export function VideoReplacePage({
|
||||
type="button"
|
||||
className={`replace-product-method${source === "library" ? " active" : ""}${libraryPreview ? " has-product-preview" : ""}`}
|
||||
onClick={() => {
|
||||
setPendingProductId(selectedProduct?.id || "");
|
||||
if (replaceMode === "character") setPendingModelId(selectedModel?.id || "");
|
||||
else setPendingProductId(selectedProduct?.id || "");
|
||||
setLibraryOpen(true);
|
||||
}}
|
||||
>
|
||||
{libraryPreview ? <img className="replace-product-method-background" src={libraryPreview} alt="" aria-hidden="true" /> : <img className="replace-product-method-background" alt="" aria-hidden="true" />}
|
||||
<span className="replace-product-method-icon"><LibraryBig /></span>
|
||||
<span className="replace-product-method-copy">
|
||||
<strong>从商品库选择</strong>
|
||||
<small>{source === "library" && selectedProduct ? `已选择 · ${selectedProduct.title}` : "选择已创建的商品"}</small>
|
||||
<strong>{copy.libraryTitle}</strong>
|
||||
<small>
|
||||
{source === "library" && (replaceMode === "character" ? selectedModel : selectedProduct)
|
||||
? `已选择 · ${replaceMode === "character" ? selectedModel?.name : selectedProduct?.title}`
|
||||
: copy.libraryEmpty}
|
||||
</small>
|
||||
</span>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
|
||||
<label
|
||||
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempFiles.length ? " has-images" : ""}`}
|
||||
<div
|
||||
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempDisplay.length ? " has-images" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => tempInputRef.current?.click()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
tempInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img className="replace-product-method-background" alt="" aria-hidden="true" />
|
||||
<span className="replace-product-method-icon"><Upload /></span>
|
||||
<span className="replace-product-method-copy">
|
||||
<strong>临时上传</strong>
|
||||
<small>{tempFiles.length ? `已上传 ${tempFiles.length} 张商品图` : "仅用于本次任务 · 最多 9 张"}</small>
|
||||
<strong>{copy.temporaryTitle}</strong>
|
||||
<small>{tempDisplay.length ? `已上传 ${tempDisplay.length} 张${copy.temporaryNoun}` : copy.temporaryEmpty}</small>
|
||||
</span>
|
||||
<ImagePlus />
|
||||
{tempDisplay.length ? (
|
||||
<span className="replace-temporary-preview">
|
||||
<span className="replace-temporary-grid" aria-label="临时上传的商品图片">
|
||||
{cells.map((file, index) => (
|
||||
<span className={`replace-temporary-cell${file ? "" : " empty"}`} key={`temp-${index}`}>
|
||||
{file ? (
|
||||
<span className="replace-temporary-grid" aria-label={`临时上传的${copy.temporaryNoun}`}>
|
||||
{cells.map((item, index) => (
|
||||
<span className={`replace-temporary-cell${item ? "" : " empty"}`} key={item?.key || `temp-${index}`}>
|
||||
{item ? (
|
||||
<>
|
||||
<img src={tempPreviews[index]} alt={file.name} />
|
||||
<img src={item.src} alt={item.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="replace-temporary-remove"
|
||||
aria-label={`删除第${index + 1}张临时商品图片`}
|
||||
aria-label={`删除第${index + 1}张临时${copy.temporaryNoun}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setTempFiles((current) => current.filter((_, itemIndex) => itemIndex !== index));
|
||||
if (tempFiles.length) {
|
||||
setTempFiles((current) => current.filter((_, itemIndex) => itemIndex !== index));
|
||||
} else {
|
||||
setTempAssetRefs((current) => current.filter((_, itemIndex) => itemIndex !== index));
|
||||
}
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
onNotify("info", "已删除临时商品图片");
|
||||
onNotify("info", `已删除临时${copy.temporaryNoun}`);
|
||||
}}
|
||||
>
|
||||
×
|
||||
@@ -589,35 +817,40 @@ export function VideoReplacePage({
|
||||
<span className="replace-temporary-more">
|
||||
<span><ImagePlus /></span>
|
||||
<strong>继续上传</strong>
|
||||
<span className="replace-temporary-count">已上传 {tempFiles.length} / 9</span>
|
||||
<span className="replace-temporary-count">已上传 {tempDisplay.length} / 9</span>
|
||||
<button
|
||||
type="button"
|
||||
className="replace-temporary-clear"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!tempFiles.length && !tempAssetRefs.length) return;
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs([]);
|
||||
setFilledSubjectName("");
|
||||
if (source === "temporary") setSource("");
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
onNotify("info", "已清空临时商品图片");
|
||||
onNotify("info", `已清空临时${copy.temporaryNoun}`);
|
||||
}}
|
||||
>
|
||||
清空全部
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<input
|
||||
ref={tempInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
hidden
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => {
|
||||
addTempImages(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -625,7 +858,7 @@ export function VideoReplacePage({
|
||||
<button
|
||||
type="button"
|
||||
className="primary-action"
|
||||
disabled={!videoFile || !productReady || generating}
|
||||
disabled={!videoReady || !productReady || generating}
|
||||
onClick={() => void startGeneration()}
|
||||
>
|
||||
<Replace />
|
||||
@@ -647,28 +880,30 @@ export function VideoReplacePage({
|
||||
<div className="replace-generating-content">
|
||||
<div className="replace-generating-visual">
|
||||
<span className="replace-generating-frame"><Clapperboard /></span>
|
||||
<span className="replace-generating-product"><Package /></span>
|
||||
<span className="replace-generating-product">{replaceMode === "character" ? <UserRound /> : <Package />}</span>
|
||||
</div>
|
||||
<strong>正在进行视频复刻</strong>
|
||||
<span>正在匹配商品外观与原片镜头</span>
|
||||
<strong>{copy.generatingTitle}</strong>
|
||||
<span>{copy.generatingCopy}</span>
|
||||
<div className="replace-generating-bar" aria-hidden="true"><span /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-analysis-result">
|
||||
<h2>复刻任务已完成</h2>
|
||||
<h2>{resultCopy.resultTitle}</h2>
|
||||
<div className="replace-preview">
|
||||
{job?.video_url ? <video src={job.video_url} poster={job.thumbnail_url || undefined} muted playsInline /> : null}
|
||||
<div className="replace-preview-copy">
|
||||
<strong>{productName ? `${productName}视频复刻预览` : remixTitle(job)}</strong>
|
||||
<span>{outputDuration} 秒 · {ratioCopy(job?.aspect_ratio || aspectRatio)} · 商品一致性检查通过</span>
|
||||
</div>
|
||||
{job?.video_url ? (
|
||||
<video src={job.video_url} poster={job.thumbnail_url || undefined} controls playsInline />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="replace-preview-meta">
|
||||
<strong>{productName ? `${productName}${resultCopy.modeLabel}` : remixTitle(job)}</strong>
|
||||
<span>{(job?.duration || outputDuration)} 秒 · {ratioCopy(job?.aspect_ratio || aspectRatio)} · {job?.resolution || "720p"}</span>
|
||||
</div>
|
||||
<div className="video-flow-actions replace-result-actions">
|
||||
<button type="button" className="secondary-action" onClick={() => void startGeneration()}>
|
||||
<button type="button" className="secondary-action" onClick={() => job && fillFormFromTask(job)}>
|
||||
<RefreshCw />
|
||||
重新生成
|
||||
</button>
|
||||
<button type="button" className="primary-action" onClick={() => downloadVideo(job?.video_url || "", productName || "视频复刻")}>
|
||||
<button type="button" className="primary-action" onClick={() => downloadVideo(job?.video_url || "", productName || remixTitle(job) || "视频复刻")}>
|
||||
<Download />
|
||||
下载视频
|
||||
</button>
|
||||
@@ -686,50 +921,92 @@ export function VideoReplacePage({
|
||||
<div className="replace-history-empty">还没有完成的视频复刻项目</div>
|
||||
) : (
|
||||
<div className="replace-history-list">
|
||||
{history.map((item) => (
|
||||
<article className="replace-history-card" key={item.id}>
|
||||
<div
|
||||
className="replace-history-cover"
|
||||
onClick={() => {
|
||||
if (!item.video_url) return;
|
||||
setPlaying({ url: item.video_url, title: remixTitle(item) });
|
||||
}}
|
||||
>
|
||||
{item.thumbnail_url ? <img src={item.thumbnail_url} alt={`${remixTitle(item)}封面`} /> : null}
|
||||
<span>{formatClock(item.duration)}</span>
|
||||
</div>
|
||||
<div className="replace-history-copy">
|
||||
<span>已完成</span>
|
||||
<h3>{remixTitle(item)}</h3>
|
||||
<p>参考视频 {item.duration} 秒 · {remixSourceLabel(item)} · {item.aspect_ratio} · {item.resolution}</p>
|
||||
</div>
|
||||
<button type="button" className="replace-history-open" onClick={() => openHistory(item)}>
|
||||
<ArrowUpRight />
|
||||
查看项目
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
{history.map((item) => {
|
||||
const open = expandedHistoryId === item.id;
|
||||
const sourceVideo = videoRefFromTask(item);
|
||||
return (
|
||||
<article className={`replace-history-card${open ? " is-open" : ""}`} key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="replace-history-summary"
|
||||
aria-expanded={open}
|
||||
onClick={() => toggleHistory(item.id)}
|
||||
>
|
||||
<span className="replace-history-cover">
|
||||
{item.thumbnail_url ? <img src={item.thumbnail_url} alt="" /> : null}
|
||||
<span>{formatClock(item.duration)}</span>
|
||||
</span>
|
||||
<span className="replace-history-copy">
|
||||
<span>已完成 · {REPLACE_MODE_COPY[modeFromTask(item)].historyKind}</span>
|
||||
<strong>{remixTitle(item)}</strong>
|
||||
<small>参考视频 {item.duration} 秒 · {remixSourceLabel(item)} · {item.resolution}</small>
|
||||
</span>
|
||||
<span className="replace-history-toggle">
|
||||
<ChevronDown />
|
||||
{open ? "收起" : "展开对比"}
|
||||
</span>
|
||||
</button>
|
||||
<div className="replace-history-compare" hidden={!open}>
|
||||
<div className="replace-history-compare-pane">
|
||||
<span>原视频</span>
|
||||
{sourceVideo?.url ? (
|
||||
<video src={sourceVideo.url} poster={sourceVideo.thumb_url || undefined} controls playsInline preload="metadata" />
|
||||
) : (
|
||||
<div className="replace-history-compare-empty">原视频暂不可用</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="replace-history-compare-pane">
|
||||
<span>生成后</span>
|
||||
{item.video_url ? (
|
||||
<video src={item.video_url} poster={item.thumbnail_url || undefined} controls playsInline preload="metadata" />
|
||||
) : (
|
||||
<div className="replace-history-compare-empty">成片暂不可用</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<OverlayPortal>
|
||||
<div className={`asset-library-layer${libraryOpen ? " open" : ""}`} aria-hidden={!libraryOpen}>
|
||||
<button type="button" className="asset-library-scrim" aria-label="关闭资产库" onClick={() => setLibraryOpen(false)} />
|
||||
<aside className="asset-library-drawer" role="dialog" aria-modal="true" aria-labelledby="assetLibraryTitle">
|
||||
<header className="asset-library-head">
|
||||
<div>
|
||||
<h2 id="assetLibraryTitle">选择商品</h2>
|
||||
<p>从已经创建的商品中选择一个用于本次视频复刻。</p>
|
||||
<h2 id="assetLibraryTitle">{copy.drawerTitle}</h2>
|
||||
<p>{copy.drawerDescription}</p>
|
||||
</div>
|
||||
<button type="button" className="product-drawer-close" aria-label="关闭" onClick={() => setLibraryOpen(false)}>
|
||||
<X />
|
||||
</button>
|
||||
</header>
|
||||
<div className="asset-library-grid">
|
||||
{products.length === 0 ? (
|
||||
<div className="asset-library-empty">还没有商品,先去商品库创建一个</div>
|
||||
{replaceMode === "character" ? (
|
||||
models.length === 0 ? (
|
||||
<div className="asset-library-empty">{copy.drawerEmpty}</div>
|
||||
) : models.map((model) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`asset-library-choice${pendingModelId === model.id ? " selected" : ""}`}
|
||||
key={model.id}
|
||||
onClick={() => setPendingModelId(model.id)}
|
||||
>
|
||||
<span className="asset-choice-check"><Check /></span>
|
||||
{modelCover(model) ? <img src={modelCover(model)} alt={model.name} /> : <img alt={model.name} />}
|
||||
<span>
|
||||
<strong>{model.name}</strong>
|
||||
<small>{model.is_official ? "官方模板" : "我的模特"} · {modelImageCount(model)} 张素材</small>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : products.length === 0 ? (
|
||||
<div className="asset-library-empty">{copy.drawerEmpty}</div>
|
||||
) : products.map((product) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -748,21 +1025,14 @@ export function VideoReplacePage({
|
||||
</div>
|
||||
<footer className="asset-library-footer">
|
||||
<button type="button" className="secondary-action" onClick={() => setLibraryOpen(false)}>取消</button>
|
||||
<button type="button" className="primary-action" onClick={confirmLibraryProduct}>
|
||||
<button type="button" className="primary-action" onClick={confirmLibrarySelection}>
|
||||
<Check />
|
||||
<span>确定使用</span>
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<MediaLightbox
|
||||
open={Boolean(playing?.url)}
|
||||
src={playing?.url || ""}
|
||||
kind="video"
|
||||
name={playing?.title}
|
||||
close={() => setPlaying(null)}
|
||||
/>
|
||||
</OverlayPortal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ aside.sidebar {
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
.nav-section { font-size: 12px; color: var(--ink-3); padding: 14px 12px 6px; letter-spacing: .08em; text-transform: uppercase; font-weight: 600; }
|
||||
nav { display: flex; flex-direction: column; gap: 1px; }
|
||||
aside.sidebar nav { display: flex; flex-direction: column; gap: 1px; }
|
||||
nav a {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
padding: 8px 12px;
|
||||
@@ -177,7 +177,7 @@ nav a.disabled:hover { background: transparent; color: var(--ink-4); }
|
||||
.user .em { font-size: 13px; }
|
||||
|
||||
/* ─── Main + grid background ─── */
|
||||
main { position: relative; overflow: hidden; background: #fff; }
|
||||
main { position: relative; background: #fff; }
|
||||
.grid-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -264,7 +264,7 @@ main { position: relative; overflow: hidden; background: #fff; }
|
||||
|
||||
/* ─── Content ─── */
|
||||
/* 内容区始终紧贴侧栏铺满整个剩余宽度(与生产管线全屏页一致),不限宽、不居中 —— 否则宽屏会右侧留白或内容居中变窄 */
|
||||
.content { padding: 36px 48px 60px; position: relative; z-index: 1; }
|
||||
.content { padding: 36px 48px 60px; position: relative; }
|
||||
.page-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 28px; gap: 16px; flex-wrap: wrap; }
|
||||
.page-head h1 { font-size: 26px; font-weight: 600; letter-spacing: -.018em; line-height: 1.2; }
|
||||
.page-head .sub { font-size: 14px; color: var(--ink-2); margin-top: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
@@ -512,7 +512,7 @@ export type Project = {
|
||||
video_segment_count?: number;
|
||||
// 合成成片地址(最新一次成功拼接):列表播放按钮 / 视频阶段「播放成片」直接用它;没合成过为空串
|
||||
final_video_url?: string;
|
||||
// 是否由极速成片入口创建;列表页据此显示「极速成片」而不是「专业创作」
|
||||
// 是否由一键成片入口创建;列表页据此显示「一键成片」而不是「专业创作」
|
||||
quick_create?: boolean;
|
||||
quick_create_status?: string;
|
||||
quick_create_job_id?: string;
|
||||
@@ -656,6 +656,12 @@ export type FreeVideoTask = {
|
||||
seed: number;
|
||||
seed_used?: number | null;
|
||||
generate_audio: boolean;
|
||||
feature?: string;
|
||||
replace_mode?: "product" | "character" | "";
|
||||
subject_name?: string;
|
||||
subject_source?: "library" | "temporary" | "";
|
||||
product_id?: string;
|
||||
model_id?: string;
|
||||
references: FreeVideoRef[];
|
||||
estimated_tokens: number;
|
||||
actual_tokens: number;
|
||||
@@ -673,6 +679,27 @@ export type FreeVideoTask = {
|
||||
completed_at: string | null;
|
||||
};
|
||||
|
||||
export type VideoDigestJob = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
status: "processing" | "succeeded" | "failed" | string;
|
||||
text: string;
|
||||
prompt: string;
|
||||
chars: number;
|
||||
duration: number;
|
||||
shots: number;
|
||||
file_name: string;
|
||||
title: string;
|
||||
ratio: string;
|
||||
width: number;
|
||||
height: number;
|
||||
cover_url: string;
|
||||
video_url: string;
|
||||
estimated_cost?: string;
|
||||
error_message: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type VideoDigestHistory = {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -148,7 +148,8 @@
|
||||
}
|
||||
|
||||
.vr-page .remix-information-panel .video-result-placeholder,
|
||||
.vr-page .remix-information-panel .video-analysis-result {
|
||||
.vr-page .remix-information-panel .video-analysis-result,
|
||||
.vr-page .remix-information-panel .remix-generating-state {
|
||||
min-height: 286px;
|
||||
}
|
||||
|
||||
@@ -340,11 +341,38 @@
|
||||
|
||||
.vr-page .remix-prompt-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-head-actions .remix-prompt-head-btn {
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-head-actions .remix-prompt-head-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.vr-page .secondary-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -560,6 +588,127 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-state {
|
||||
display: none;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vr-page .remix-information-panel.is-analyzing .video-result-placeholder,
|
||||
.vr-page .remix-information-panel.is-analyzing .video-analysis-result {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vr-page .remix-information-panel.is-analyzing .remix-generating-state {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-content {
|
||||
width: min(280px, 100%);
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-visual {
|
||||
position: relative;
|
||||
width: 136px;
|
||||
height: 104px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-frame {
|
||||
position: absolute;
|
||||
inset: 4px 18px 18px 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 47, 167, 0.14);
|
||||
border-radius: 16px;
|
||||
color: var(--klein);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-frame svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-frame::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(110deg, transparent 28%, rgba(0, 47, 167, 0.13) 50%, transparent 72%);
|
||||
transform: translateX(-130%);
|
||||
animation: remix-frame-scan 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-badge {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
background: var(--klein);
|
||||
animation: remix-badge-pulse 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-badge svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-content > strong {
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-content > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 47, 167, 0.1);
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-bar span {
|
||||
display: block;
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--klein);
|
||||
animation: remix-progress-slide 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes remix-frame-scan {
|
||||
0% { transform: translateX(-130%); }
|
||||
100% { transform: translateX(130%); }
|
||||
}
|
||||
|
||||
@keyframes remix-badge-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.07); }
|
||||
}
|
||||
|
||||
@keyframes remix-progress-slide {
|
||||
0% { transform: translateX(-115%); }
|
||||
100% { transform: translateX(260%); }
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -737,6 +886,11 @@
|
||||
background: rgba(0, 47, 167, 0.92);
|
||||
}
|
||||
|
||||
.vr-page .remix-replace-video.is-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field.has-preview:hover,
|
||||
.vr-page .remix-page .video-upload-field.has-preview:focus-within {
|
||||
background: #0f1728;
|
||||
@@ -753,7 +907,8 @@
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-information-panel .video-result-placeholder,
|
||||
.vr-page .remix-page .remix-information-panel .video-analysis-result {
|
||||
.vr-page .remix-page .remix-information-panel .video-analysis-result,
|
||||
.vr-page .remix-page .remix-information-panel .remix-generating-state {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -805,6 +960,11 @@
|
||||
.vr-page .remix-page .remix-prompt-panel.has-result .remix-prompt-result {
|
||||
animation: none;
|
||||
}
|
||||
.vr-page .remix-generating-frame::after,
|
||||
.vr-page .remix-generating-badge,
|
||||
.vr-page .remix-generating-bar span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.vr-page .remix-history-section {
|
||||
@@ -1100,7 +1260,7 @@
|
||||
@media (max-width: 1120px) {
|
||||
.vr-page .video-flow-grid,
|
||||
.vr-page .remix-flow-grid { grid-template-columns: 1fr; }
|
||||
.vr-page .remix-prompt-head { flex-direction: column; }
|
||||
.vr-page .remix-prompt-head { flex-wrap: wrap; }
|
||||
.vr-page .video-flow-actions { flex-wrap: wrap; }
|
||||
.vr-page .remix-progress-step:not(:last-child)::after {
|
||||
left: calc(50% + 42px);
|
||||
|
||||
@@ -136,6 +136,50 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.vrep-page .replace-mode-switch {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin: 16px 0 22px;
|
||||
padding: 5px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.09);
|
||||
border-radius: 14px;
|
||||
background: rgba(241, 244, 249, 0.88);
|
||||
}
|
||||
|
||||
.vrep-page .replace-mode-button {
|
||||
min-height: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
color: #69758a;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, background 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.vrep-page .replace-mode-button svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.vrep-page .replace-mode-button.active {
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
box-shadow: 0 7px 18px rgba(22, 45, 92, 0.09), inset 0 0 0 1px rgba(0, 47, 167, 0.11);
|
||||
}
|
||||
|
||||
.vrep-page .replace-mode-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.vrep-page .video-flow-step {
|
||||
margin-top: 22px;
|
||||
}
|
||||
@@ -216,7 +260,9 @@
|
||||
}
|
||||
|
||||
.vrep-page .primary-action,
|
||||
.vrep-page .secondary-action {
|
||||
.vrep-page .secondary-action,
|
||||
.asset-library-layer .primary-action,
|
||||
.asset-library-layer .secondary-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -227,7 +273,8 @@
|
||||
transition: transform 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.vrep-page .primary-action {
|
||||
.vrep-page .primary-action,
|
||||
.asset-library-layer .primary-action {
|
||||
min-width: 158px;
|
||||
height: 50px;
|
||||
padding: 0 22px;
|
||||
@@ -237,19 +284,22 @@
|
||||
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
|
||||
}
|
||||
|
||||
.vrep-page .primary-action:hover:not(:disabled) {
|
||||
.vrep-page .primary-action:hover:not(:disabled),
|
||||
.asset-library-layer .primary-action:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
background: var(--klein-hover);
|
||||
}
|
||||
|
||||
.vrep-page .primary-action:disabled {
|
||||
.vrep-page .primary-action:disabled,
|
||||
.asset-library-layer .primary-action:disabled {
|
||||
opacity: 0.38;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vrep-page .secondary-action {
|
||||
.vrep-page .secondary-action,
|
||||
.asset-library-layer .secondary-action {
|
||||
min-width: 132px;
|
||||
height: 46px;
|
||||
padding: 0 18px;
|
||||
@@ -258,12 +308,15 @@
|
||||
background: rgba(34, 42, 54, 0.05);
|
||||
}
|
||||
|
||||
.vrep-page .secondary-action:hover:not(:disabled) {
|
||||
.vrep-page .secondary-action:hover:not(:disabled),
|
||||
.asset-library-layer .secondary-action:hover:not(:disabled) {
|
||||
background: rgba(34, 42, 54, 0.10);
|
||||
}
|
||||
|
||||
.vrep-page .primary-action svg,
|
||||
.vrep-page .secondary-action svg {
|
||||
.vrep-page .secondary-action svg,
|
||||
.asset-library-layer .primary-action svg,
|
||||
.asset-library-layer .secondary-action svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
@@ -541,6 +594,7 @@
|
||||
}
|
||||
|
||||
.vrep-page .replace-result-panel.has-result .replace-preview,
|
||||
.vrep-page .replace-result-panel.has-result .replace-preview-meta,
|
||||
.vrep-page .replace-result-panel.has-result .replace-result-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -616,7 +670,8 @@
|
||||
}
|
||||
|
||||
.vrep-page .video-result-panel.has-result .video-analysis-result {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vrep-page .replace-generating-state {
|
||||
@@ -752,47 +807,37 @@
|
||||
|
||||
.vrep-page .replace-preview {
|
||||
position: relative;
|
||||
min-height: 250px;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-top: 18px;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
border-radius: 13px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(12, 17, 27, 0.08), rgba(12, 17, 27, 0.62)),
|
||||
#101012;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
background: #101012;
|
||||
}
|
||||
|
||||
.vrep-page .replace-preview video,
|
||||
.vrep-page .replace-preview img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 420px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
background: #101012;
|
||||
}
|
||||
|
||||
.vrep-page .replace-preview-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: end;
|
||||
width: 100%;
|
||||
padding: 26px;
|
||||
background: linear-gradient(180deg, transparent, rgba(12, 17, 27, 0.72));
|
||||
.vrep-page .replace-preview-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.vrep-page .replace-preview-copy strong {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
.vrep-page .replace-preview-meta strong {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.vrep-page .replace-preview-copy span {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
.vrep-page .replace-preview-meta span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -822,13 +867,13 @@
|
||||
|
||||
.vrep-page .replace-history-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-empty {
|
||||
padding: 28px 16px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||
border-radius: 15px;
|
||||
border-radius: 12px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
font-size: 13px;
|
||||
@@ -837,16 +882,11 @@
|
||||
|
||||
.vrep-page .replace-history-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 184px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 12px 30px rgba(22, 45, 92, 0.055);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 8px 20px rgba(22, 45, 92, 0.05);
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-card::before {
|
||||
@@ -857,13 +897,31 @@
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-summary {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 148px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 14px 12px 16px;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-summary:hover {
|
||||
background: rgba(0, 47, 167, 0.035);
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-cover {
|
||||
position: relative;
|
||||
height: 108px;
|
||||
height: 84px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
background: #e9eef8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-cover img,
|
||||
@@ -878,76 +936,143 @@
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
padding: 4px 7px;
|
||||
border-radius: 6px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
background: rgba(16, 16, 18, 0.76);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-copy > span {
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
padding: 4px 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--klein);
|
||||
background: #eef3ff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-copy h3 {
|
||||
margin: 9px 0 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1d2940;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-copy p {
|
||||
margin: 7px 0 0;
|
||||
.vrep-page .replace-history-copy strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-copy small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-open {
|
||||
min-height: 38px;
|
||||
.vrep-page .replace-history-toggle {
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.22);
|
||||
border-radius: 9px;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.18);
|
||||
border-radius: 8px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-open svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
.vrep-page .replace-history-toggle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-layer {
|
||||
.vrep-page .replace-history-card.is-open .replace-history-toggle svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-compare {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 0 14px 14px 16px;
|
||||
border-top: 1px solid rgba(34, 42, 54, 0.08);
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-compare[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-compare-pane {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-compare-pane > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-compare-pane video,
|
||||
.vrep-page .replace-history-compare-empty {
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 360px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #101012;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-compare-pane video {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.vrep-page .replace-history-compare-empty {
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 挂到 body,盖住顶栏。变量写在本层,不依赖 .vrep-page */
|
||||
.asset-library-layer {
|
||||
--klein: #002fa7;
|
||||
--klein-hover: #002680;
|
||||
--muted: #6f747c;
|
||||
--text: #17181a;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
z-index: var(--z-overlay);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: visibility 0s linear 260ms, opacity 220ms ease;
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-layer.open {
|
||||
.asset-library-layer.open {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-scrim {
|
||||
.asset-library-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
@@ -955,7 +1080,7 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-drawer {
|
||||
.asset-library-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
@@ -970,11 +1095,11 @@
|
||||
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-layer.open .asset-library-drawer {
|
||||
.asset-library-layer.open .asset-library-drawer {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-head {
|
||||
.asset-library-head {
|
||||
min-height: 82px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -984,18 +1109,18 @@
|
||||
border-bottom: 1px solid rgba(34, 42, 54, 0.09);
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-head h2 {
|
||||
.asset-library-head h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-head p {
|
||||
.asset-library-head p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.vrep-page .product-drawer-close {
|
||||
.asset-library-layer .product-drawer-close {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
@@ -1008,17 +1133,17 @@
|
||||
transition: color 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
.vrep-page .product-drawer-close:hover {
|
||||
.asset-library-layer .product-drawer-close:hover {
|
||||
color: var(--text);
|
||||
background: rgba(34, 42, 54, 0.09);
|
||||
}
|
||||
|
||||
.vrep-page .product-drawer-close svg {
|
||||
.asset-library-layer .product-drawer-close svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-grid {
|
||||
.asset-library-grid {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -1029,7 +1154,7 @@
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-choice {
|
||||
.asset-library-choice {
|
||||
position: relative;
|
||||
min-height: 214px;
|
||||
overflow: hidden;
|
||||
@@ -1042,18 +1167,18 @@
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-choice:hover {
|
||||
.asset-library-choice:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(0, 47, 167, 0.32);
|
||||
box-shadow: 0 10px 24px rgba(20, 27, 38, 0.08);
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-choice.selected {
|
||||
.asset-library-choice.selected {
|
||||
border-color: var(--klein);
|
||||
box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.1);
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-choice img {
|
||||
.asset-library-choice img {
|
||||
width: 100%;
|
||||
height: 164px;
|
||||
display: block;
|
||||
@@ -1061,17 +1186,17 @@
|
||||
background: #e9eef8;
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-choice > span:not(.asset-choice-check) {
|
||||
.asset-library-choice > span:not(.asset-choice-check) {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-choice strong { font-size: 11px; }
|
||||
.vrep-page .asset-library-choice small { color: var(--muted); font-size: 9px; }
|
||||
.asset-library-choice strong { font-size: 11px; }
|
||||
.asset-library-choice small { color: var(--muted); font-size: 9px; }
|
||||
|
||||
.vrep-page .asset-choice-check {
|
||||
.asset-choice-check {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
@@ -1084,10 +1209,10 @@
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-choice.selected .asset-choice-check { display: grid; }
|
||||
.vrep-page .asset-choice-check svg { width: 13px; height: 13px; }
|
||||
.asset-library-choice.selected .asset-choice-check { display: grid; }
|
||||
.asset-choice-check svg { width: 13px; height: 13px; }
|
||||
|
||||
.vrep-page .asset-library-footer {
|
||||
.asset-library-footer {
|
||||
min-height: 82px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1099,7 +1224,7 @@
|
||||
box-shadow: 0 -10px 24px rgba(20, 27, 38, 0.045);
|
||||
}
|
||||
|
||||
.vrep-page .asset-library-empty {
|
||||
.asset-library-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 36px 12px;
|
||||
color: var(--muted);
|
||||
@@ -1110,16 +1235,19 @@
|
||||
@media (max-width: 1120px) {
|
||||
.vrep-page .video-flow-grid { grid-template-columns: 1fr; }
|
||||
.vrep-page .product-replace-options { grid-template-columns: 1fr; }
|
||||
.vrep-page .replace-history-card {
|
||||
.vrep-page .replace-history-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.vrep-page .replace-history-open {
|
||||
.vrep-page .replace-history-toggle {
|
||||
justify-self: start;
|
||||
}
|
||||
.vrep-page .asset-library-drawer {
|
||||
.vrep-page .replace-history-compare {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.asset-library-drawer {
|
||||
width: min(650px, 100vw);
|
||||
}
|
||||
.vrep-page .asset-library-grid {
|
||||
.asset-library-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user