完善视频提炼和视频复刻完善提交测试

This commit is contained in:
Azmat@qq.com
2026-08-28 10:15:15 +08:00
parent 52c5cdae1d
commit 849b14a0f6
33 changed files with 1475 additions and 208 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "airshelf-frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "core/frontend",
"port": 5173
}
]
}
+47
View File
@@ -523,6 +523,53 @@ class VideoDigestApiTests(TestCase):
self.assertEqual(polled.data["task_id"], str(task.id)) self.assertEqual(polled.data["task_id"], str(task.id))
self.assertEqual(polled.data["file_name"], "参考视频.mp4") self.assertEqual(polled.data["file_name"], "参考视频.mp4")
def test_inflight_without_preview_urls_is_still_restorable(self):
"""封面/原片直链拿不到(TOS 慢、签名失败)不代表任务死了。
前端曾拿「有没有预览图」当判活依据,退出再进来时把正在跑的任务直接取消掉,
用户看到的就是「任务凭空消失」。这里锁住:没有任何预览 URL 也照样回 processing。
"""
from apps.ai.models import AITask
task = self._seed_digest_task(status=AITask.Status.SUBMITTED, extra_key="nopreview")
task.request_payload = {
**task.request_payload,
"video_url": "", "cover_url": "", "source_url": "", "source_key": "", "cover_key": "",
}
task.save(update_fields=["request_payload"])
listed = self.client.get("/api/ai/video-digest/")
self.assertEqual(listed.data["inflight"]["id"], str(task.id))
self.assertEqual(listed.data["inflight"]["status"], "processing")
self.assertEqual(listed.data["inflight"]["video_url"], "")
self.assertEqual(listed.data["inflight"]["cover_url"], "")
polled = self.client.get(f"/api/ai/video-digest/{task.id}/")
self.assertEqual(polled.data["status"], "processing")
task.refresh_from_db()
self.assertEqual(task.status, AITask.Status.SUBMITTED) # 只是查看,不该被动到
def test_second_submit_is_blocked_while_one_is_running(self):
"""已有提炼在跑时再提交要 409,并把在跑的那条带回去让前端接上。"""
from apps.ai.models import AITask
running = self._seed_digest_task(status=AITask.Status.SUBMITTED, extra_key="busy")
resp = self.client.post(
"/api/ai/video-digest/",
{"reuse_task_id": str(running.id)},
format="multipart",
)
self.assertEqual(resp.status_code, 409)
self.assertIn("完成或取消后", resp.json()["detail"])
self.assertEqual(resp.json()["inflight"]["id"], str(running.id))
# 收掉之后放行(这里只验闸门,不验后面的真实提炼)
running.status = AITask.Status.CANCELLED
running.save(update_fields=["status"])
from apps.ai.video_digest import get_inflight_team_digest
self.assertIsNone(get_inflight_team_digest(team=self.team))
def test_poll_missing_job_returns_404(self): def test_poll_missing_job_returns_404(self):
missing = self.client.get("/api/ai/video-digest/00000000-0000-0000-0000-000000000099/") missing = self.client.get("/api/ai/video-digest/00000000-0000-0000-0000-000000000099/")
self.assertEqual(missing.status_code, 404) self.assertEqual(missing.status_code, 404)
+148 -3
View File
@@ -172,6 +172,12 @@ class SubmitVideoReplaceTests(TestCase):
self.assertNotIn("reference_video", roles) # 参考视频不再发给火山 self.assertNotIn("reference_video", roles) # 参考视频不再发给火山
self.assertNotIn("video_url", [item.get("type") for item in content]) self.assertNotIn("video_url", [item.get("type") for item in content])
def _retire(self, task):
"""把任务收成终态,好让单飞闸放行下一次提交。"""
task.status = AITask.Status.CANCELLED
task.save(update_fields=["status", "updated_at"])
return task
def _fake_source_file(self): def _fake_source_file(self):
"""_FileFromPath 会 stat 真实文件,给个空的临时 mp4 顶替 TOS 下载结果。""" """_FileFromPath 会 stat 真实文件,给个空的临时 mp4 顶替 TOS 下载结果。"""
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
@@ -240,6 +246,38 @@ class SubmitVideoReplaceTests(TestCase):
# 提炼阶段绝不能掺进商品名,否则拆出来的就不是「这条参考视频原本的样子」 # 提炼阶段绝不能掺进商品名,否则拆出来的就不是「这条参考视频原本的样子」
self.assertNotIn("净颜精华", str(seen["messages"])) self.assertNotIn("净颜精华", str(seen["messages"]))
def test_digest_call_is_recorded_for_admin_task_monitor(self):
"""复刻里的提炼要在运营后台任务详情的尝试链里看得到,否则出问题无从查起。"""
from apps.ai.models import AIModelAttempt
task = self._run_digest(self._submit())
attempts = list(AIModelAttempt.objects.filter(task=task, operation="video_digest").order_by("sequence"))
self.assertEqual(len(attempts), 1)
self.assertEqual(attempts[0].status, AIModelAttempt.Status.SUCCEEDED)
self.assertEqual(attempts[0].capability, "text")
self.assertEqual(attempts[0].request_summary.get("for"), "video_replace")
self.assertTrue(attempts[0].response_summary.get("shots"))
self.assertIsNotNone(attempts[0].finished_at)
def test_failed_digest_attempts_are_recorded_too(self):
"""废稿重试也要各留一条,才能看出是「模型抽风」而不是「没跑」。"""
from apps.ai import video_digest
from apps.ai.models import AIModelAttempt
task = self._submit()
text_model = ModelConfig.objects.filter(capability="text", status="active").first()
with patch.object(video_digest, "_download_source_to_temp", return_value=self._fake_source_file()), \
patch.object(
video_digest, "digest_input_from_upload",
return_value=(None, [], 8.0, {"width": 720, "height": 1280, "file_name": "参考.mp4"}),
), \
patch.object(video_digest, "resolve_digest_model_config", return_value=text_model), \
patch("apps.ai.services._collect_extract_text", side_effect=[("", {}), (DIGEST_SAMPLE, {})]):
run_replace_digest(task)
attempts = list(AIModelAttempt.objects.filter(task=task, operation="video_digest").order_by("sequence"))
self.assertEqual([a.status for a in attempts], ["failed", "succeeded"])
self.assertTrue(attempts[1].is_retry)
def test_digest_retries_once_before_giving_up(self): def test_digest_retries_once_before_giving_up(self):
"""Gemini 偶尔吐废稿。复刻失败要用户从头重选素材,所以同模型再试一次。""" """Gemini 偶尔吐废稿。复刻失败要用户从头重选素材,所以同模型再试一次。"""
from apps.ai import video_digest from apps.ai import video_digest
@@ -278,6 +316,52 @@ class SubmitVideoReplaceTests(TestCase):
self.assertFalse(self.provider.create_video_task.called) self.assertFalse(self.provider.create_video_task.called)
self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0) self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0)
def test_second_submit_is_blocked_while_one_is_running(self):
"""刷新页面时前端拉状态有空档,用户容易以为「没任务」再点一次。
后端必须拦住,否则重复建任务、重复扣费。"""
from apps.ai.video_replace import VideoReplaceInProgress, get_inflight_video_replace
first = self._submit()
with self.assertRaises(VideoReplaceInProgress) as ctx:
self._submit()
self.assertEqual(ctx.exception.task.id, first.id) # 409 要带上在跑的那条
self.assertIn("完成或取消后", str(ctx.exception))
self.assertEqual(get_inflight_video_replace(self.team).id, first.id)
# 收掉之后放行
self._retire(first)
self.assertIsNone(get_inflight_video_replace(self.team))
self.assertEqual(self._submit().status, AITask.Status.CREATED)
def test_replace_api_returns_409_with_inflight(self):
"""前端拿这个 409 直接切到「进行中」,而不是弹个错就完了。"""
from rest_framework.test import APIClient
from apps.accounts.models import TeamMember
TeamMember.objects.get_or_create(
team=self.team, user=self.user, defaults={"role": "owner", "status": "active"}
)
client = APIClient()
client.force_authenticate(self.user)
payload = {
"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,
}
first = client.post("/api/ai/video-replace/", payload, format="json")
self.assertEqual(first.status_code, 202)
again = client.post("/api/ai/video-replace/", payload, format="json")
self.assertEqual(again.status_code, 409)
self.assertEqual(again.json()["inflight"]["id"], first.json()["task"]["id"])
listed = client.get("/api/ai/video-replace/").json()
self.assertEqual(listed["inflight"]["id"], first.json()["task"]["id"])
def test_missing_digest_model_is_rejected_at_submit(self): def test_missing_digest_model_is_rejected_at_submit(self):
"""提炼模型没配好要当场 400,不能建了任务、等一轮提炼才告诉用户。""" """提炼模型没配好要当场 400,不能建了任务、等一轮提炼才告诉用户。"""
@@ -340,6 +424,26 @@ class SubmitVideoReplaceTests(TestCase):
self.assertNotIn("三视图", prompt) self.assertNotIn("三视图", prompt)
self.assertIn("@目标商品", prompt) self.assertIn("@目标商品", prompt)
def test_triview_only_product_still_fills_target_slot(self):
"""商品只有三视图、没有实拍图时,三视图要顶上来当 @目标商品 ——
否则提示词里的 @目标商品 找不到同名 label,火山收到的是一句没有指代的字面量。"""
bare = Product.objects.create(team=self.team, created_by=self.user, title="只有三视图")
tri = _asset(self.team, self.user, name="三视图.png", preview="http://tos/only-tri.png")
tri.metadata = {"product_id": str(bare.id), "view": "three_view"}
tri.save(update_fields=["metadata"])
task = self._submit(product_id=str(bare.id))
labels = [ref["label"] for ref in task.request_payload["references"]]
self.assertEqual(labels, ["目标商品"])
task = self._run_digest(task)
self.assertEqual(task.status, AITask.Status.SUBMITTED)
prompt = task.request_payload["prompt"]
self.assertIn("@目标商品", prompt)
self.assertNotIn("@目标商品三视图", prompt) # 没有第二张图就别点名它
api_prompt = task.request_payload.get("api_prompt") or ""
self.assertNotIn("@目标商品", api_prompt) # 已被换成火山认的「图片N」
def test_triview_yields_budget_so_refs_never_exceed_nine(self): def test_triview_yields_budget_so_refs_never_exceed_nine(self):
"""三视图占一张名额:商品图要让位,否则火山那边超 9 张直接拒。""" """三视图占一张名额:商品图要让位,否则火山那边超 9 张直接拒。"""
from apps.ai.video_replace import MAX_IMAGES from apps.ai.video_replace import MAX_IMAGES
@@ -369,10 +473,51 @@ class SubmitVideoReplaceTests(TestCase):
self.assertEqual(task.request_payload["replace_mode"], "character") self.assertEqual(task.request_payload["replace_mode"], "character")
self.assertEqual(task.request_payload["subject_name"], "薇薇") self.assertEqual(task.request_payload["subject_name"], "薇薇")
def test_rejects_video_longer_than_15s(self): def test_product_accepts_up_to_60s_but_character_still_caps_at_15s(self):
long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="长片.mp4", duration_ms=20000) """商品复刻的参考视频只喂提炼模型,不发火山 → 放宽到 60 秒;
角色复刻仍直传火山,15 秒是火山硬限制,不能跟着放宽。"""
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait)
mid = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="半分钟.mp4", duration_ms=30000)
long_ok = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="一分钟.mp4", duration_ms=60000)
too_long = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="超长.mp4", duration_ms=75000)
# 商品复刻:30s / 60s 都放行。单飞闸只允许一条在跑,所以每次提交后收掉再试下一条。
self.assertEqual(self._retire(self._submit(video_asset_id=str(mid.id))).status, AITask.Status.CANCELLED)
self.assertEqual(self._retire(self._submit(video_asset_id=str(long_ok.id))).status, AITask.Status.CANCELLED)
with self.assertRaisesMessage(ValueError, "不能超过 60 秒"):
self._submit(video_asset_id=str(too_long.id))
# 角色复刻:超过 15 秒仍然拒
with self.assertRaisesMessage(ValueError, "不能超过 15 秒"): with self.assertRaisesMessage(ValueError, "不能超过 15 秒"):
self._submit(video_asset_id=str(long_video.id)) self._submit(
replace_mode="character", product_id="", model_id=str(model.id),
video_asset_id=str(mid.id),
)
def test_long_source_asks_model_to_condense(self):
"""60 秒参考稿 + 15 秒成片:必须让模型压缩改编,不然会照着拍到一半戛然而止。"""
long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="一分钟.mp4", duration_ms=58000)
# duration=None → 按参考视频推导成片时长(火山单次上限 15 秒),与线上一致
task = self._run_digest(self._submit(video_asset_id=str(long_video.id), duration=None))
prompt = task.request_payload["prompt"]
self.assertIn("压缩改编", prompt)
self.assertIn("58 秒", prompt)
self.assertIn("15 秒", prompt)
def test_short_source_skips_condense_note(self):
"""参考视频和成片时长差不多时别啰嗦。"""
task = self._run_digest(self._submit(duration=None)) # setUp 的参考视频 8 秒 → 成片也 8 秒
self.assertNotIn("压缩改编", task.request_payload["prompt"])
def test_prompt_always_pins_character_consistency(self):
"""上传图里可能有人物(服饰模特图/真人手持图),要锁住人物一致;
没有人物时也要说清楚「别从商品图凭空套脸」。"""
prompt = self._run_digest(self._submit()).request_payload["prompt"]
self.assertIn("自始至终是同一个人", prompt)
self.assertIn("不要中途换脸", prompt)
self.assertIn("参考图里如果有人物", prompt)
self.assertIn("不要从商品图上凭空套一张脸", prompt)
def test_accepts_15s_video_with_probe_slack(self): def test_accepts_15s_video_with_probe_slack(self):
from apps.ai.media_probe import REF_DURATION_MAX from apps.ai.media_probe import REF_DURATION_MAX
+103 -2
View File
@@ -59,6 +59,14 @@ class VideoDigestError(ValueError):
"""用户可见的失败(文件不合格 / ffmpeg 读不动),一律 400。""" """用户可见的失败(文件不合格 / ffmpeg 读不动),一律 400。"""
class VideoDigestInProgress(VideoDigestError):
"""本团队已有提炼在跑。视图转 409,并把在跑的那条带回去让前端接上。"""
def __init__(self, job: dict):
super().__init__("已有一个视频正在提炼中,完成或取消后才能再提交")
self.job = job
@dataclass(frozen=True) @dataclass(frozen=True)
class VideoFrame: class VideoFrame:
at_seconds: int at_seconds: int
@@ -923,6 +931,14 @@ def submit_team_digest(*, team, user, upload=None, reuse_task_id=None, model_con
from apps.billing.pricing import quote_video_digest from apps.billing.pricing import quote_video_digest
from apps.billing.services.ledger import reserve_credit from apps.billing.services.ledger import reserve_credit
# 单飞闸:一个团队同时只允许一条提炼在跑。
# 刷新页面时前端要异步拉状态,这个空档用户很容易以为「没任务」再传一次 —— 前端加载态
# 只是治标,真正兜底在这里。先跑过期回收,死任务不会把人永久锁住。
expire_stale_team_digests(team=team)
running = get_inflight_team_digest(team=team)
if running:
raise VideoDigestInProgress(running)
model_config = resolve_digest_model_config(preferred_id=model_config_id) model_config = resolve_digest_model_config(preferred_id=model_config_id)
if model_config is None: if model_config is None:
raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员") raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
@@ -1359,6 +1375,9 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
复刻本来就是一次收费,拆解是它的内部工序。模型调用的审计仍记在传入的复刻 复刻本来就是一次收费,拆解是它的内部工序。模型调用的审计仍记在传入的复刻
task 上(AIModelAttempt),出问题查得到。 task 上(AIModelAttempt),出问题查得到。
""" """
import time
from apps.ai.models import AIModelAttempt
from apps.ai.services import _collect_extract_text, get_text_provider from apps.ai.services import _collect_extract_text, get_text_provider
primary = asset.files.filter(is_primary=True).first() or asset.files.first() primary = asset.files.filter(is_primary=True).first() or asset.files.first()
@@ -1408,7 +1427,12 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
expected_frames = len(frames) or (24 if video else 0) expected_frames = len(frames) or (24 if video else 0)
digest = "" digest = ""
last_error: Exception | None = None last_error: Exception | None = None
for attempt in range(1, DIGEST_MAX_ATTEMPTS + 1): for attempt_no in range(1, DIGEST_MAX_ATTEMPTS + 1):
# 这一步不走 execute_model_call(它要求 task.model_config 就是本次主模型,
# 而复刻任务的主模型是 Seedance),所以尝试记录得自己落 —— 不落的话
# 运营后台的任务详情看不到这次 Gemini 调用,出问题无从查起。
record = _open_digest_attempt(task, model_config, attempt_no, duration, expected_frames, video)
started = time.monotonic()
try: try:
text, _payload = _collect_extract_text( text, _payload = _collect_extract_text(
provider, provider,
@@ -1418,12 +1442,19 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
extra_body={"max_tokens": DIGEST_MAX_TOKENS}, extra_body={"max_tokens": DIGEST_MAX_TOKENS},
) )
digest = validate_digest_text(text, duration=duration, frame_count=expected_frames) digest = validate_digest_text(text, duration=duration, frame_count=expected_frames)
_close_digest_attempt(
record, AIModelAttempt.Status.SUCCEEDED, started,
summary={"chars": len(digest), "shots": shot_count(digest)},
)
break break
except Exception as exc: # noqa: BLE001 — 空文/废稿/网络抖动都值得再来一次 except Exception as exc: # noqa: BLE001 — 空文/废稿/网络抖动都值得再来一次
last_error = exc last_error = exc
_close_digest_attempt(
record, AIModelAttempt.Status.FAILED, started, error=str(exc),
)
logger.warning( logger.warning(
"replace digest attempt %s/%s failed for task %s: %s", "replace digest attempt %s/%s failed for task %s: %s",
attempt, DIGEST_MAX_ATTEMPTS, task.id, exc, attempt_no, DIGEST_MAX_ATTEMPTS, task.id, exc,
) )
if not digest: if not digest:
raise VideoDigestError(f"参考视频拆解失败:{last_error}") raise VideoDigestError(f"参考视频拆解失败:{last_error}")
@@ -1439,3 +1470,73 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
finally: finally:
if local_path: if local_path:
Path(local_path).unlink(missing_ok=True) Path(local_path).unlink(missing_ok=True)
def _open_digest_attempt(task, model_config, attempt_no: int, duration: float, frames: int, video):
"""给复刻任务补一条「提炼」尝试记录,让运营后台的尝试链能看到这次 Gemini 调用。
审计失败绝不能拖垮拆解本身,所以整段吞异常、返回 None。
"""
from django.db import transaction
from django.db.models import Max
from django.utils import timezone
from apps.ai.models import AIModelAttempt, AITask
try:
with transaction.atomic():
AITask.objects.select_for_update().only("id").get(pk=task.pk)
sequence = (
AIModelAttempt.objects.filter(task_id=task.pk).aggregate(m=Max("sequence"))["m"] or 0
) + 1
provider = model_config.provider
return AIModelAttempt.objects.create(
task_id=task.pk,
sequence=sequence,
provider=provider,
model_config=model_config,
provider_name=provider.name,
provider_display_name=provider.display_name,
model_name=model_config.name,
model_display_name=model_config.display_name,
public_model_name=model_config.display_name or model_config.name,
capability="text",
operation="video_digest",
status=AIModelAttempt.Status.STARTED,
is_retry=attempt_no > 1,
started_at=timezone.now(),
request_summary={
"for": "video_replace",
"duration_seconds": round(float(duration or 0), 2),
"frame_count": frames,
"input": "native_video" if video is not None else "frames",
},
)
except Exception: # noqa: BLE001 — 审计写失败不影响出片
logger.warning("video replace digest attempt record failed", exc_info=True)
return None
def _close_digest_attempt(record, status, started_at: float, *, summary: dict | None = None, error: str = ""):
import time
from django.utils import timezone
if record is None:
return
try:
record.status = status
record.finished_at = timezone.now()
record.duration_ms = max(0, round((time.monotonic() - started_at) * 1000))
if summary:
record.response_summary = summary
if error:
record.error_type = "processing_failed"
record.raw_error = error[:2000]
record.safe_error_summary = "参考视频拆解未通过校验"
record.save(update_fields=[
"status", "finished_at", "duration_ms", "response_summary",
"error_type", "raw_error", "safe_error_summary", "updated_at",
])
except Exception: # noqa: BLE001
logger.warning("video replace digest attempt close failed", exc_info=True)
+84 -6
View File
@@ -43,10 +43,38 @@ LEGACY_PROMPT_PREFIX = "[视频复刻]"
# 商品三视图在商品库是 standalone Asset(metadata.view=three_view + product_id), # 商品三视图在商品库是 standalone Asset(metadata.view=three_view + product_id),
# 和项目内的 BaseAssetGroup 是两套存储,这里直接取商品库那份。 # 和项目内的 BaseAssetGroup 是两套存储,这里直接取商品库那份。
TRIVIEW_LABEL = "目标商品三视图" TRIVIEW_LABEL = "目标商品三视图"
# 商品复刻的参考视频不进火山、只喂提炼模型,所以不受火山 15 秒参考视频限制。
# 半秒容差同 media_probe 的老规矩(60 秒成片常被探成 60.02)。角色复刻仍是 15 秒。
PRODUCT_REF_DURATION_MAX = 60.5
REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试" REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试"
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试" REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试" REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试"
class VideoReplaceInProgress(ValueError):
"""本团队已有复刻在跑。视图转 409,并把在跑的那条带回去让前端接上。"""
def __init__(self, task):
super().__init__("已有一个视频正在复刻中,完成或取消后才能再提交")
self.task = task
def get_inflight_video_replace(team):
"""本团队正在跑的复刻任务(含审核中 / 提炼中)。没有返回 None。"""
_reap_stale_free_video_tasks(team=team)
return (
AITask.objects.filter(
team=team,
task_type=AITask.Type.FREE_VIDEO,
status__in=IN_FLIGHT_STATUSES,
is_deleted=False,
purged_at__isnull=True,
)
.filter(video_replace_q())
.order_by("-created_at")
.first()
)
# 商品复刻不再把参考视频直接丢给火山(实测换不动商品,出片跑偏),改走两步: # 商品复刻不再把参考视频直接丢给火山(实测换不动商品,出片跑偏),改走两步:
# 先用「提炼提示词」那一整套(同一份 SKILL.md + 同一个 Gemini 3.1 Pro)把参考视频拆成 # 先用「提炼提示词」那一整套(同一份 SKILL.md + 同一个 Gemini 3.1 Pro)把参考视频拆成
# 中文分镜稿,再把分镜稿当导演脚本、连同商品图一起交给 Seedance。 # 中文分镜稿,再把分镜稿当导演脚本、连同商品图一起交给 Seedance。
@@ -62,8 +90,22 @@ PRODUCT_DIGEST_TAIL = (
"不要改造型、不要改配色、不要凭空补细节。\n" "不要改造型、不要改配色、不要凭空补细节。\n"
"{triview}" "{triview}"
"3. 人物、场景、光线、色调、动作和表情按分镜稿保持不变,只换商品。\n" "3. 人物、场景、光线、色调、动作和表情按分镜稿保持不变,只换商品。\n"
" · 全片的人物必须自始至终是同一个人:五官、发型、体态和服装在所有镜头里保持一致,"
"不要中途换脸、换人或换装。\n"
" · 参考图里如果有人物(例如服饰类的模特图、真人手持商品的图),画面中的人物就以那张参考图为准,"
"五官、发型、体态和服装必须与之一致;参考图里只有商品、没有人物时,就按分镜稿描述的人物去演,"
"不要从商品图上凭空套一张脸。\n"
"4. 台词/旁白按分镜稿逐字念出;原文提到旧商品名称的地方,改说「{subject}」。\n" "4. 台词/旁白按分镜稿逐字念出;原文提到旧商品名称的地方,改说「{subject}」。\n"
"5. 分镜稿里的「字幕」栏只是对原片的记录,不要把这些文字画到画面上。" "5. 分镜稿里的「字幕」栏只是对原片的记录,不要把这些文字画到画面上。"
"{condense}"
)
# 参考视频比成片长时才追加(火山单次出片上限 15 秒,60 秒参考稿必须压缩改编,
# 否则模型会照着分镜稿从头拍、拍到 15 秒戛然而止)。
PRODUCT_CONDENSE_NOTE = (
"\n6. 参考视频约 {source} 秒,而本次成片只有 {output} 秒:请按分镜稿的结构和节奏**压缩改编**,"
"保留主线、最关键的卖点镜头和结尾收束,可以合并或省略次要镜头;"
"不要只拍开头几镜就停,也不要把画面加速成快放。"
) )
# 商品库里有三视图时才追加这一条(没有就不提,免得模型去找一张不存在的图)。 # 商品库里有三视图时才追加这一条(没有就不提,免得模型去找一张不存在的图)。
PRODUCT_TRIVIEW_NOTE = ( PRODUCT_TRIVIEW_NOTE = (
@@ -107,7 +149,14 @@ def compact_digest_for_video(digest_text: str) -> str:
return "\n".join(line for line in lines if line.strip()).strip() return "\n".join(line for line in lines if line.strip()).strip()
def build_product_replace_prompt(digest_text: str, subject_name: str, *, has_triview: bool = False) -> str: def build_product_replace_prompt(
digest_text: str,
subject_name: str,
*,
has_triview: bool = False,
source_seconds: float = 0,
output_seconds: int = 0,
) -> str:
"""分镜稿 + 商品替换要求 → 交给 Seedance 的完整提示词。 """分镜稿 + 商品替换要求 → 交给 Seedance 的完整提示词。
@目标商品 必须与 references 里第一张商品图的 label 对上,否则 build_content_items @目标商品 必须与 references 里第一张商品图的 label 对上,否则 build_content_items
@@ -119,9 +168,18 @@ def build_product_replace_prompt(digest_text: str, subject_name: str, *, has_tri
if not body: if not body:
raise ValueError("参考视频拆解结果为空,请重试") raise ValueError("参考视频拆解结果为空,请重试")
subject = (subject_name or "").strip() or "目标商品" subject = (subject_name or "").strip() or "目标商品"
source = int(round(float(source_seconds or 0)))
output = int(output_seconds or 0)
# 差 3 秒以内不啰嗦,模型自己会收;差得多才明确要求压缩改编。
condense = (
PRODUCT_CONDENSE_NOTE.format(source=source, output=output)
if source and output and source - output >= 3
else ""
)
tail = PRODUCT_DIGEST_TAIL.format( tail = PRODUCT_DIGEST_TAIL.format(
subject=subject, subject=subject,
triview=PRODUCT_TRIVIEW_NOTE if has_triview else "", triview=PRODUCT_TRIVIEW_NOTE if has_triview else "",
condense=condense,
) )
return enforce_no_embedded_captions(f"{PRODUCT_DIGEST_HEAD}\n\n{body}\n\n{tail}") return enforce_no_embedded_captions(f"{PRODUCT_DIGEST_HEAD}\n\n{body}\n\n{tail}")
@@ -190,9 +248,20 @@ def submit_video_replace(*, team, user, params: dict):
if not has_model and not has_temp: if not has_model and not has_temp:
raise ValueError("请选择角色或上传角色参考图") raise ValueError("请选择角色或上传角色参考图")
# 单飞闸:一个团队同时只允许一条复刻在跑。刷新页面时前端拉状态有空档,
# 用户容易以为「没任务」再点一次,重复扣费。_reap_stale_free_video_tasks 会先回收
# 死任务(CREATED 超 16 分钟等),所以不会被永久锁住。
running = get_inflight_video_replace(team)
if running is not None:
raise VideoReplaceInProgress(running)
video = _team_asset(team, params.get("video_asset_id"), kind=Asset.Type.VIDEO, label="参考视频") video = _team_asset(team, params.get("video_asset_id"), kind=Asset.Type.VIDEO, label="参考视频")
video_seconds = _asset_duration_seconds(video) video_seconds = _asset_duration_seconds(video)
if video_seconds > REF_DURATION_MAX: if replace_mode == "product":
if video_seconds > PRODUCT_REF_DURATION_MAX:
raise ValueError("参考视频不能超过 60 秒,请剪短后重试")
elif video_seconds > REF_DURATION_MAX:
# 角色复刻把参考视频直传火山,15 秒是火山那边的硬限制,不能跟着放宽。
raise ValueError("参考视频不能超过 15 秒,请剪短后重试") raise ValueError("参考视频不能超过 15 秒,请剪短后重试")
if has_product: if has_product:
@@ -345,7 +414,11 @@ def run_replace_digest(task) -> None:
(ref or {}).get("label") == TRIVIEW_LABEL for ref in (payload.get("references") or []) (ref or {}).get("label") == TRIVIEW_LABEL for ref in (payload.get("references") or [])
) )
prompt = build_product_replace_prompt( prompt = build_product_replace_prompt(
digest, str(payload.get("subject_name") or ""), has_triview=has_triview digest,
str(payload.get("subject_name") or ""),
has_triview=has_triview,
source_seconds=float(payload.get("digest_source_duration") or 0),
output_seconds=int(payload.get("duration") or 0),
) )
except (VideoDigestError, ValueError) as exc: except (VideoDigestError, ValueError) as exc:
_fail_reviewing_task(task, str(exc), error_code="processing_failed") _fail_reviewing_task(task, str(exc), error_code="processing_failed")
@@ -776,10 +849,15 @@ def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]:
assets.append(asset) assets.append(asset)
if len(assets) >= image_budget: if len(assets) >= image_budget:
break break
if not assets and product.cover_asset_id and not product.cover_asset.is_deleted: if not assets and product.cover_asset_id and product.cover_asset_id not in seen and not product.cover_asset.is_deleted:
assets.append(product.cover_asset) assets.append(product.cover_asset)
if not assets and triview is None: if not assets:
raise ValueError("这个商品还没有可用图片") # 只有三视图、没有任何实拍图时,把三视图顶上来当 @目标商品。
# 否则提示词里的 @目标商品 找不到同名 label,火山拿到的是一句没有指代的字面量。
if triview is None:
raise ValueError("这个商品还没有可用图片")
assets = [triview]
triview = None
refs = [_library_image_ref(asset, team=team, label="目标商品" if index == 0 else f"目标商品{index + 1}") for index, asset in enumerate(assets)] refs = [_library_image_ref(asset, team=team, label="目标商品" if index == 0 else f"目标商品{index + 1}") for index, asset in enumerate(assets)]
if triview is not None: if triview is not None:
# 放最后:@目标商品 仍指向第一张实拍图,三视图作为「各面长什么样」的补充证据。 # 放最后:@目标商品 仍指向第一张实拍图,三视图作为「各面长什么样」的补充证据。
+44 -7
View File
@@ -728,7 +728,7 @@ class VideoDigestView(APIView):
if upload is None and not reuse_task_id: if upload is None and not reuse_task_id:
return Response({"detail": "请先上传参考视频"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "请先上传参考视频"}, status=status.HTTP_400_BAD_REQUEST)
require_worker_task("apps.ai.tasks.run_video_digest_task") require_worker_task("apps.ai.tasks.run_video_digest_task")
from .video_digest import VideoDigestError, submit_team_digest from .video_digest import VideoDigestError, VideoDigestInProgress, submit_team_digest
team = get_current_team(request.user) team = get_current_team(request.user)
try: try:
@@ -739,6 +739,9 @@ class VideoDigestView(APIView):
reuse_task_id=reuse_task_id, reuse_task_id=reuse_task_id,
model_config_id=request.data.get("model_config_id") or None, model_config_id=request.data.get("model_config_id") or None,
) )
except VideoDigestInProgress as exc:
# 409 + 在跑的那条:前端据此直接切到进行中状态,而不是弹个错就完了
return Response({"detail": str(exc), "inflight": exc.job}, status=status.HTTP_409_CONFLICT)
except VideoDigestError as exc: except VideoDigestError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案 except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案
@@ -866,11 +869,21 @@ class VideoReplaceView(APIView):
# 商品复刻的拆解在 worker 里跑;worker 还是旧镜像的话消息会被静默丢弃 → 永久「提炼中」。 # 商品复刻的拆解在 worker 里跑;worker 还是旧镜像的话消息会被静默丢弃 → 永久「提炼中」。
if str((request.data or {}).get("replace_mode") or "") == "product": if str((request.data or {}).get("replace_mode") or "") == "product":
require_worker_task("apps.ai.tasks.run_video_replace_digest_task") require_worker_task("apps.ai.tasks.run_video_replace_digest_task")
from .video_replace import serialize_video_replace_task, submit_video_replace from .video_replace import (
VideoReplaceInProgress,
serialize_video_replace_task,
submit_video_replace,
)
team = get_current_team(request.user) team = get_current_team(request.user)
try: try:
task = submit_video_replace(team=team, user=request.user, params=request.data or {}) task = submit_video_replace(team=team, user=request.user, params=request.data or {})
except VideoReplaceInProgress as exc:
# 409 + 在跑的那条:前端据此直接切到进行中状态,而不是弹个错就完了
return Response(
{"detail": str(exc), "inflight": serialize_video_replace_task(exc.task)},
status=status.HTTP_409_CONFLICT,
)
except ValueError as exc: except ValueError as exc:
message = str(exc) message = str(exc)
internal_kind = ( internal_kind = (
@@ -906,11 +919,16 @@ class VideoReplaceView(APIView):
qs = _free_video_list_queryset(team, include_replace=True).order_by("-created_at") qs = _free_video_list_queryset(team, include_replace=True).order_by("-created_at")
total = qs.count() total = qs.count()
tasks = list(qs[offset : offset + page_size]) tasks = list(qs[offset : offset + page_size])
from .video_replace import get_inflight_video_replace
running = get_inflight_video_replace(team) if offset == 0 else None
return Response( return Response(
{ {
"results": [serialize_video_replace_task(t) for t in tasks], "results": [serialize_video_replace_task(t) for t in tasks],
"total": total, "total": total,
"has_more": offset + page_size < total, "has_more": offset + page_size < total,
# 首页才带:前端刷新后据此恢复「进行中」,不必自己在列表里翻状态
"inflight": serialize_video_replace_task(running) if running is not None else None,
} }
) )
@@ -1059,6 +1077,13 @@ _FREE_REF_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
_FREE_REF_AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"} _FREE_REF_AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"}
_FREE_REF_IMAGE_MAX = 30 * 1024 * 1024 _FREE_REF_IMAGE_MAX = 30 * 1024 * 1024
_FREE_REF_VIDEO_MAX = 50 * 1024 * 1024 _FREE_REF_VIDEO_MAX = 50 * 1024 * 1024
# 视频复刻·商品:参考视频只喂给提炼模型(不发火山),没有 15 秒的硬限制,
# 按「提炼提示词」页那套放宽到 60 秒 / 200MB。自由创作和角色复刻仍走上面的 15 秒。
# 拿这个 purpose 传上来的长视频,若被拿去自由创作/角色复刻,下游 build_content_items
# 与 submit_video_replace 各自还有 15 秒校验兜底,越不过去。
_REPLACE_SOURCE_PURPOSE = "video_replace_product"
_REPLACE_SOURCE_VIDEO_MAX = 200 * 1024 * 1024
_REPLACE_SOURCE_DURATION_MAX = 60.5
_FREE_REF_AUDIO_MAX = 15 * 1024 * 1024 _FREE_REF_AUDIO_MAX = 15 * 1024 * 1024
@@ -1088,6 +1113,7 @@ class FreeVideoUploadView(APIView):
team = get_current_team(request.user) team = get_current_team(request.user)
content_type = (upload.content_type or "").lower() content_type = (upload.content_type or "").lower()
size = upload.size or 0 size = upload.size or 0
long_source = str(request.data.get("purpose") or "").strip() == _REPLACE_SOURCE_PURPOSE
if content_type in _FREE_REF_IMAGE_TYPES: if content_type in _FREE_REF_IMAGE_TYPES:
kind, asset_type, suffix = "image", Asset.Type.IMAGE, {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[content_type] kind, asset_type, suffix = "image", Asset.Type.IMAGE, {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[content_type]
@@ -1095,8 +1121,12 @@ class FreeVideoUploadView(APIView):
return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST)
elif content_type in _FREE_REF_VIDEO_TYPES: elif content_type in _FREE_REF_VIDEO_TYPES:
kind, asset_type, suffix = "video", Asset.Type.VIDEO, ".mp4" if content_type == "video/mp4" else ".mov" kind, asset_type, suffix = "video", Asset.Type.VIDEO, ".mp4" if content_type == "video/mp4" else ".mov"
if size > _FREE_REF_VIDEO_MAX: video_max = _REPLACE_SOURCE_VIDEO_MAX if long_source else _FREE_REF_VIDEO_MAX
return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST) if size > video_max:
return Response(
{"detail": f"视频大小不能超过 {video_max // 1024 // 1024}MB"},
status=status.HTTP_400_BAD_REQUEST,
)
elif content_type in _FREE_REF_AUDIO_TYPES: elif content_type in _FREE_REF_AUDIO_TYPES:
kind, asset_type, suffix = "audio", Asset.Type.AUDIO, ".mp3" if content_type == "audio/mpeg" else ".wav" kind, asset_type, suffix = "audio", Asset.Type.AUDIO, ".mp3" if content_type == "audio/mpeg" else ".wav"
if size > _FREE_REF_AUDIO_MAX: if size > _FREE_REF_AUDIO_MAX:
@@ -1132,9 +1162,16 @@ class FreeVideoUploadView(APIView):
duration = probe_duration(str(tmp_path)) duration = probe_duration(str(tmp_path))
if duration is None: if duration is None:
return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
if not duration_in_ref_range(duration): from .media_probe import REF_DURATION_MIN
label = "视频" if kind == "video" else "音频"
return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST) if long_source and kind == "video":
in_range = REF_DURATION_MIN <= duration <= _REPLACE_SOURCE_DURATION_MAX
range_hint = "视频时长需在 2-60 秒之间"
else:
in_range = duration_in_ref_range(duration)
range_hint = f"{'视频' if kind == 'video' else '音频'}时长需在 2-15 秒之间"
if not in_range:
return Response({"detail": range_hint}, status=status.HTTP_400_BAD_REQUEST)
if kind == "video": if kind == "video":
poster_bytes = extract_video_poster(str(tmp_path)) poster_bytes = extract_video_poster(str(tmp_path))
+20
View File
@@ -36,6 +36,7 @@ class ProductSerializer(serializers.ModelSerializer):
images = ProductImageSerializer(many=True, required=False) images = ProductImageSerializer(many=True, required=False)
selling_points = ProductSellingPointSerializer(many=True, required=False) selling_points = ProductSellingPointSerializer(many=True, required=False)
cover_preview_url = serializers.SerializerMethodField() cover_preview_url = serializers.SerializerMethodField()
triview_preview_url = serializers.SerializerMethodField()
class Meta: class Meta:
model = Product model = Product
@@ -51,6 +52,7 @@ class ProductSerializer(serializers.ModelSerializer):
"status", "status",
"cover_asset", "cover_asset",
"cover_preview_url", "cover_preview_url",
"triview_preview_url",
"images", "images",
"selling_points", "selling_points",
"created_at", "created_at",
@@ -61,6 +63,24 @@ class ProductSerializer(serializers.ModelSerializer):
def get_cover_preview_url(self, obj) -> str: def get_cover_preview_url(self, obj) -> str:
return _asset_preview_url(obj.cover_asset) return _asset_preview_url(obj.cover_asset)
def get_triview_preview_url(self, obj) -> str:
"""商品三视图(白底多角度单张 16:9)。商品库存的是 standalone Asset,不在 images 里,
视频复刻要靠它锁商品各面,前端也要能直接告诉用户这次带了三视图"""
from apps.assets.models import Asset
asset = (
Asset.objects.filter(
team_id=obj.team_id,
is_deleted=False,
purged_at__isnull=True,
metadata__product_id=str(obj.id),
metadata__view="three_view",
)
.order_by("-created_at")
.first()
)
return _asset_preview_url(asset) if asset else ""
def validate(self, attrs): def validate(self, attrs):
# 创建商品必须至少有一张图:无图商品会让下游"商品参考图"彻底落空,故事板/视频退化纯文生图。 # 创建商品必须至少有一张图:无图商品会让下游"商品参考图"彻底落空,故事板/视频退化纯文生图。
# 例外:本地生活团购是虚拟商品(无实物),允许不传主图,沿用主流程 SOP。 # 例外:本地生活团购是虚拟商品(无实物),允许不传主图,沿用主流程 SOP。
+40
View File
@@ -242,3 +242,43 @@ class ProductBusinessTypeTests(TestCase):
self.assertIn("火锅套餐", local) self.assertIn("火锅套餐", local)
self.assertNotIn("面膜", local) self.assertNotIn("面膜", local)
class ProductTriviewFieldTests(TestCase):
"""商品三视图是 standalone Asset(metadata.view=three_view + product_id),不在 images 里。
视频复刻靠它锁商品各面,前端也要能直接告诉用户这次带了三视图"""
def setUp(self):
self.user = User.objects.create_user(username="ptri", password="p")
self.team = Team.objects.create(name="PT", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华")
def _asset(self, name, preview, metadata=None):
asset = Asset.objects.create(
team=self.team, created_by=self.user, name=name,
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED,
category=Asset.Category.UPLOAD, metadata=metadata or {},
)
AssetFile.objects.create(
asset=asset, object_key="k/1", bucket="b", content_type="image/png",
size_bytes=1, preview_url=preview, is_primary=True,
)
return asset
def test_triview_url_exposed_when_present(self):
self._asset("三视图", "http://tos/tri.png", {"product_id": str(self.product.id), "view": "three_view"})
data = self.client.get(f"/api/products/{self.product.id}/").json()
self.assertEqual(data["triview_preview_url"], "http://tos/tri.png")
def test_blank_when_no_triview(self):
data = self.client.get(f"/api/products/{self.product.id}/").json()
self.assertEqual(data["triview_preview_url"], "")
def test_other_products_triview_does_not_leak(self):
other = Product.objects.create(team=self.team, created_by=self.user, title="别的商品")
self._asset("三视图", "http://tos/other.png", {"product_id": str(other.id), "view": "three_view"})
data = self.client.get(f"/api/products/{self.product.id}/").json()
self.assertEqual(data["triview_preview_url"], "")
@@ -259,6 +259,29 @@ def _should_mark_project_failed(job: QuickCreateJob) -> bool:
return True return True
# 编排器活着时会不断推进并保存 job(updated_at 跟着动)。超过这个时长没动过,
# 基本就是 worker 挂了 —— 不当它在跑,否则一次抽风会把团队永久锁在"有任务进行中"。
QUICK_CREATE_STALE_AFTER = timedelta(minutes=30)
def get_inflight_quick_create(team):
"""本团队正在跑的一键成片。没有 / 已经僵死的返回 None。"""
from django.utils import timezone
return (
QuickCreateJob.objects.select_related("project")
.filter(
team=team,
status__in=(QuickCreateJob.Status.QUEUED, QuickCreateJob.Status.RUNNING),
project__is_deleted=False,
project__purged_at__isnull=True,
updated_at__gte=timezone.now() - QUICK_CREATE_STALE_AFTER,
)
.order_by("-created_at")
.first()
)
def restore_false_failed_quick_creates(team) -> None: def restore_false_failed_quick_creates(team) -> None:
jobs = ( jobs = (
QuickCreateJob.objects.select_related("project") QuickCreateJob.objects.select_related("project")
@@ -111,6 +111,55 @@ class QuickCreateApiTests(TestCase):
{str(image.asset_id) for image in product.images.order_by("sort_order")}, {str(image.asset_id) for image in product.images.order_by("sort_order")},
) )
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
@patch("apps.projects.views.get_default_model")
@patch("apps.projects.tasks.advance_quick_create_task.apply_async")
@patch("apps.projects.views.require_worker_task")
@patch("apps.projects.views._store_uploaded_asset")
def test_second_submit_is_blocked_while_one_is_running(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model):
"""刷新页面时前端拉状态有空档,用户容易以为「没任务」再提交一次 ——
那会重复建商品重复扣费后端必须拦住,并把在跑的那条带回去"""
store_asset.side_effect = self._uploaded_asset
video_model = SimpleNamespace(
id=uuid.uuid4(),
name="doubao-seedance-2-0-fast-260128",
display_name="Seedance 2.0 Fast",
metadata={"capabilities": {"resolutions": ["480p", "720p"], "aspect_ratios": ["9:16"], "durations": [15]}},
)
get_model.side_effect = [object(), video_model, object(), video_model]
def _post():
return self.client.post(
"/api/projects/quick-create/",
{
"name": "轻醒咖啡",
"images": [SimpleUploadedFile("front.png", b"png-one", content_type="image/png")],
"aspect_ratio": "9:16",
"resolution": "720p",
"total_duration": "15",
"category": "食品饮料",
},
)
first = _post()
self.assertEqual(first.status_code, 202)
job_id = str(QuickCreateJob.objects.get(project__product__title="轻醒咖啡").id)
again = _post()
self.assertEqual(again.status_code, 409)
self.assertIn("完成或取消后", again.json()["detail"])
self.assertEqual(again.json()["inflight"]["id"], job_id)
# 历史接口也要把在跑的那条带出来,前端刷新后才能恢复进度
listed = self.client.get("/api/projects/quick-create-history/").json()
self.assertEqual(listed["inflight"]["id"], job_id)
# 收成终态后放行
QuickCreateJob.objects.filter(id=job_id).update(status=QuickCreateJob.Status.CANCELLED)
from apps.projects.services.quick_create import get_inflight_quick_create
self.assertIsNone(get_inflight_quick_create(self.team))
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object()) @patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
@patch("apps.projects.views.get_default_model") @patch("apps.projects.views.get_default_model")
@patch("apps.projects.tasks.advance_quick_create_task.apply_async") @patch("apps.projects.tasks.advance_quick_create_task.apply_async")
+19
View File
@@ -485,6 +485,20 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
def quick_create(self, request): def quick_create(self, request):
"""商品名称 + 1–9 张图 → 新建商品与项目,并启动完整自动生产流水线。""" """商品名称 + 1–9 张图 → 新建商品与项目,并启动完整自动生产流水线。"""
require_worker_task("apps.projects.tasks.advance_quick_create_task") require_worker_task("apps.projects.tasks.advance_quick_create_task")
# 单飞闸:一个团队同时只允许一条一键成片。刷新页面时前端拉状态有空档,
# 用户容易以为「没任务」再提交一次 —— 那会重复建商品、重复扣费。
# get_inflight_quick_create 会跳过 30 分钟没动过的僵尸 job,不至于把人永久锁住。
from .services.quick_create import get_inflight_quick_create
running_job = get_inflight_quick_create(self.get_team())
if running_job is not None:
return Response(
{
"detail": "已有一个一键成片正在进行中,完成或取消后才能再提交",
"inflight": QuickCreateJobSerializer(running_job).data,
},
status=status.HTTP_409_CONFLICT,
)
name = str(request.data.get("name") or "").strip() name = str(request.data.get("name") or "").strip()
uploads = request.FILES.getlist("images") or request.FILES.getlist("images[]") uploads = request.FILES.getlist("images") or request.FILES.getlist("images[]")
source_product_id = str(request.data.get("source_product_id") or "").strip() source_product_id = str(request.data.get("source_product_id") or "").strip()
@@ -775,9 +789,14 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
) )
.order_by("-created_at") .order_by("-created_at")
) )
from .services.quick_create import get_inflight_quick_create
running = get_inflight_quick_create(self.get_team())
return Response({ return Response({
"count": jobs.count(), "count": jobs.count(),
"results": QuickCreateJobSerializer(jobs[:30], many=True).data, "results": QuickCreateJobSerializer(jobs[:30], many=True).data,
# 进行中的那条:前端刷新/重进页面据此恢复进度,不再显示成空表单
"inflight": QuickCreateJobSerializer(running).data if running is not None else None,
}) })
@transaction.atomic @transaction.atomic
+7
View File
@@ -3456,3 +3456,10 @@
.yz-image .result-panel { height: auto; min-height: 420px; } .yz-image .result-panel { height: auto; min-height: 420px; }
.yz-image .studio-layout { grid-template-columns: 1fr; height: auto; } .yz-image .studio-layout { grid-template-columns: 1fr; height: auto; }
} }
/* 参考图可直接拖进输入区 */
.yz-image .image-composer.is-dragover {
outline: 1.5px dashed var(--heat);
outline-offset: 4px;
border-radius: var(--r-md, 8px);
}
+13 -4
View File
@@ -127,11 +127,14 @@ export function setRemember(username: string | null, remember: boolean) {
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
/** 解析成功的响应体。409 这类「冲突」要把在跑的任务带回给调用方接上,只有 message 不够用。 */
payload?: Record<string, unknown>;
constructor(status: number, message: string) { constructor(status: number, message: string, payload?: Record<string, unknown>) {
super(message); super(message);
this.name = "ApiError"; this.name = "ApiError";
this.status = status; this.status = status;
this.payload = payload;
} }
} }
@@ -169,8 +172,10 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const text = await response.text(); const text = await response.text();
// DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上 // DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上
let message = text || `${response.status} ${response.statusText}`; let message = text || `${response.status} ${response.statusText}`;
let payload: Record<string, unknown> | undefined;
try { try {
const data = JSON.parse(text) as Record<string, unknown>; const data = JSON.parse(text) as Record<string, unknown>;
payload = data;
const first = data.detail ?? data.error ?? data.message ?? Object.values(data)[0]; const first = data.detail ?? data.error ?? data.message ?? Object.values(data)[0];
if (typeof first === "string") message = first; if (typeof first === "string") message = first;
else if (Array.isArray(first) && typeof first[0] === "string") message = first[0]; else if (Array.isArray(first) && typeof first[0] === "string") message = first[0];
@@ -191,7 +196,7 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
if (response.status === 413) { if (response.status === 413) {
message = "文件太大,服务器没接收。视频请压到 200MB 以内再传"; message = "文件太大,服务器没接收。视频请压到 200MB 以内再传";
} }
throw new ApiError(response.status, message); throw new ApiError(response.status, message, payload);
} }
if (response.status === 204) return undefined as T; if (response.status === 204) return undefined as T;
return response.json() as Promise<T>; return response.json() as Promise<T>;
@@ -379,7 +384,10 @@ export const api = {
return request<QuickCreateJob>(`/api/projects/quick-create-retry/${jobId}/`, { method: "POST" }); return request<QuickCreateJob>(`/api/projects/quick-create-retry/${jobId}/`, { method: "POST" });
}, },
quickCreateHistory() { quickCreateHistory() {
return request<{ count: number; results: QuickCreateJob[] }>("/api/projects/quick-create-history/"); // inflight = 正在跑的那条:刷新/重进页面据此恢复进度,不再显示成空表单
return request<{ count: number; results: QuickCreateJob[]; inflight?: QuickCreateJob | null }>(
"/api/projects/quick-create-history/"
);
}, },
// 整体替换 metadata —— 调用方务必先展开现有 project.metadata 再合并,别把别的 key 冲掉 // 整体替换 metadata —— 调用方务必先展开现有 project.metadata 再合并,别把别的 key 冲掉
updateProject(id: string, payload: { name?: string; metadata?: Record<string, unknown> }) { updateProject(id: string, payload: { name?: string; metadata?: Record<string, unknown> }) {
@@ -932,7 +940,8 @@ export const api = {
return request<{ task: FreeVideoTask }>("/api/ai/video-replace/", { method: "POST", body: JSON.stringify(payload) }); return request<{ task: FreeVideoTask }>("/api/ai/video-replace/", { method: "POST", body: JSON.stringify(payload) });
}, },
videoReplaceTasks(offset = 0, pageSize = 20) { videoReplaceTasks(offset = 0, pageSize = 20) {
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>( // inflight 只在首页返回:刷新/重进页面据此恢复「进行中」,不用自己在列表里翻状态
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean; inflight?: FreeVideoTask | null }>(
`/api/ai/video-replace/?offset=${offset}&page_size=${pageSize}` `/api/ai/video-replace/?offset=${offset}&page_size=${pageSize}`
); );
}, },
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Check, ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react"; import { Check, ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react";
import { api } from "../../api"; import { api } from "../../api";
import { useFileDrop } from "../use-file-drop";
import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types"; import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types";
import { useBodyScrollLock } from "../overlays"; import { useBodyScrollLock } from "../overlays";
@@ -131,6 +132,17 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
} }
}; };
// 素材库弹窗:整块 body 都能接住拖进来的文件。在组内传素材,在组外快速建图。
const libDrop = useFileDrop(
(files) => {
const file = files[0];
if (!file) return;
if (activeGroup) void uploadAsset(file);
else void quickUploadImage(file);
},
{ disabled: uploading }
);
const quickUploadImage = async (file: File) => { const quickUploadImage = async (file: File) => {
setQuickUploading(true); setQuickUploading(true);
try { try {
@@ -278,7 +290,11 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
</div> </div>
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button> <button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
</div> </div>
<div className="modal-b fc-lib-body" onClick={() => { setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); }}> <div
className={`modal-b fc-lib-body${libDrop.dragging ? " is-dragover" : ""}`}
{...libDrop.dropProps}
onClick={() => { setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); }}
>
{!activeGroup ? ( {!activeGroup ? (
<> <>
<div className="fc-lib-toolbar"> <div className="fc-lib-toolbar">
@@ -137,7 +137,7 @@ function probeImage(file: File): Promise<FileCheck> {
}); });
} }
function probeMedia(file: File, kind: "video" | "audio"): Promise<FileCheck> { function probeMedia(file: File, kind: "video" | "audio", maxSeconds = MAX_VIDEO_TOTAL_SECONDS): Promise<FileCheck> {
return new Promise((resolve) => { return new Promise((resolve) => {
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
const el = document.createElement(kind); const el = document.createElement(kind);
@@ -146,24 +146,34 @@ function probeMedia(file: File, kind: "video" | "audio"): Promise<FileCheck> {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
const duration = el.duration; const duration = el.duration;
if (!isFinite(duration)) resolve({ ok: true, type: kind }); if (!isFinite(duration)) resolve({ ok: true, type: kind });
else if (duration < 2 - 0.05 || duration > MAX_VIDEO_TOTAL_SECONDS + VIDEO_DURATION_SLACK) { else if (duration < 2 - 0.05 || duration > maxSeconds + VIDEO_DURATION_SLACK) {
resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-15 秒之间` }); resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-${maxSeconds} 秒之间` });
} else resolve({ ok: true, type: kind, duration: Math.min(MAX_VIDEO_TOTAL_SECONDS, Math.round(duration * 10) / 10) }); } else resolve({ ok: true, type: kind, duration: Math.min(maxSeconds, Math.round(duration * 10) / 10) });
}; };
el.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "媒体文件解析失败,请更换文件" }); }; el.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "媒体文件解析失败,请更换文件" }); };
el.src = url; el.src = url;
}); });
} }
export async function checkRefFile(file: File): Promise<FileCheck> { /**
* (15 / 50MB),
* ·, options */
export async function checkRefFile(
file: File,
options?: { maxSeconds?: number; maxVideoBytes?: number }
): Promise<FileCheck> {
const type = (file.type || "").toLowerCase(); const type = (file.type || "").toLowerCase();
const maxSeconds = options?.maxSeconds ?? MAX_VIDEO_TOTAL_SECONDS;
const maxVideoBytes = options?.maxVideoBytes ?? VIDEO_MAX_BYTES;
if (IMAGE_TYPES.includes(type)) { if (IMAGE_TYPES.includes(type)) {
if (file.size > IMAGE_MAX_BYTES) return { ok: false, error: "图片大小不能超过 30MB" }; if (file.size > IMAGE_MAX_BYTES) return { ok: false, error: "图片大小不能超过 30MB" };
return probeImage(file); return probeImage(file);
} }
if (VIDEO_TYPES.includes(type)) { if (VIDEO_TYPES.includes(type)) {
if (file.size > VIDEO_MAX_BYTES) return { ok: false, error: "视频大小不能超过 50MB" }; if (file.size > maxVideoBytes) {
return probeMedia(file, "video"); return { ok: false, error: `视频大小不能超过 ${Math.round(maxVideoBytes / 1024 / 1024)}MB` };
}
return probeMedia(file, "video", maxSeconds);
} }
if (AUDIO_TYPES.includes(type)) { if (AUDIO_TYPES.includes(type)) {
if (file.size > AUDIO_MAX_BYTES) return { ok: false, error: "音频大小不能超过 15MB" }; if (file.size > AUDIO_MAX_BYTES) return { ok: false, error: "音频大小不能超过 15MB" };
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { api } from "../api"; import { api } from "../api";
import { useFileDrop } from "./use-file-drop";
import type { Asset, ModelEntity } from "../types"; import type { Asset, ModelEntity } from "../types";
import { useBodyScrollLock, MediaLightbox } from "./overlays"; import { useBodyScrollLock, MediaLightbox } from "./overlays";
@@ -94,6 +95,11 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
} finally { setBusy(false); } } finally { setBusy(false); }
} }
// 本地上传 → 直接作为选中立绘进右侧栏 // 本地上传 → 直接作为选中立绘进右侧栏
const candidateDrop = useFileDrop(
(files) => { void uploadCandidate(files[0]); },
{ disabled: busy, accept: (f) => f.type.startsWith("image/") }
);
async function uploadCandidate(file?: File | null) { async function uploadCandidate(file?: File | null) {
if (!file) return; if (!file) return;
setBusy(true); setBusy(true);
@@ -156,9 +162,9 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
) : ( ) : (
<div className="actorlib-studio-upload"> <div className="actorlib-studio-upload">
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>1 </div> <div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>1 </div>
<div className="actorlib-drop" role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}> <div className={`actorlib-drop${candidateDrop.dragging ? " is-dragover" : ""}`} {...candidateDrop.dropProps} role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg> <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : "点击选择本地模特图片上传"}</div> <div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : candidateDrop.dragging ? "松开即可上传" : "点击或拖拽本地模特图片上传"}</div>
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>JPG / PNG / WEBP</div> <div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>JPG / PNG / WEBP</div>
</div> </div>
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void uploadCandidate(e.target.files?.[0]); e.currentTarget.value = ""; }} /> <input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void uploadCandidate(e.target.files?.[0]); e.currentTarget.value = ""; }} />
@@ -0,0 +1,53 @@
import { useCallback, useRef, useState } from "react";
/** 全站上传区共用的拖拽投放。各页只管拿 dragging 加自己的高亮类,逻辑不再各写一份。 */
export function useFileDrop(
onFiles: (files: File[]) => void,
options?: { disabled?: boolean; accept?: (file: File) => boolean }
) {
const [dragging, setDragging] = useState(false);
// 拖过子元素时浏览器会连发 dragenter/dragleave,只看 leave 会让高亮疯狂闪。用进出计数兜住。
const depth = useRef(0);
const disabled = Boolean(options?.disabled);
const accept = options?.accept;
const reset = useCallback(() => {
depth.current = 0;
setDragging(false);
}, []);
const hasFiles = (event: React.DragEvent) =>
Array.from(event.dataTransfer?.types || []).includes("Files");
const onDragEnter = useCallback((event: React.DragEvent) => {
if (disabled || !hasFiles(event)) return;
event.preventDefault();
depth.current += 1;
setDragging(true);
}, [disabled]);
const onDragOver = useCallback((event: React.DragEvent) => {
if (disabled || !hasFiles(event)) return;
// 不 preventDefault 浏览器会把文件当导航打开,drop 根本不触发
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
}, [disabled]);
const onDragLeave = useCallback((event: React.DragEvent) => {
if (disabled) return;
event.preventDefault();
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDragging(false);
}, [disabled]);
const onDrop = useCallback((event: React.DragEvent) => {
if (disabled) return;
event.preventDefault();
reset();
const files = Array.from(event.dataTransfer?.files || []);
const picked = accept ? files.filter(accept) : files;
if (picked.length) onFiles(picked);
}, [disabled, accept, onFiles, reset]);
return { dragging, dropProps: { onDragEnter, onDragOver, onDragLeave, onDrop } };
}
+7
View File
@@ -1052,3 +1052,10 @@
.fc-player-nav.prev { left: 6px; } .fc-player-nav.prev { left: 6px; }
.fc-player-nav.next { right: 6px; } .fc-player-nav.next { right: 6px; }
} }
/* 素材库弹窗:整块 body 接住拖进来的文件 */
.fc-lib-body.is-dragover {
outline: 1.5px dashed var(--heat);
outline-offset: -8px;
background: var(--heat-8);
}
+13
View File
@@ -338,3 +338,16 @@
.model-detail-b { grid-template-columns: 1fr; padding: 20px; } .model-detail-b { grid-template-columns: 1fr; padding: 20px; }
.model-detail-left { width: min(100%, 320px); margin: 0 auto; } .model-detail-left { width: min(100%, 320px); margin: 0 auto; }
} }
/* 拖图进列表即添加模特 */
.models-page .ml-grid.is-dragover {
outline: 1.5px dashed var(--heat);
outline-offset: 8px;
border-radius: var(--r-md, 8px);
}
/* 模特详情:拖图替换形象图 */
.model-detail-portrait.is-dragover {
outline: 1.5px solid var(--heat);
outline-offset: 2px;
}
+7
View File
@@ -2187,3 +2187,10 @@
.chat-model-menu.chip-menu .mi.selected .mi-check { .chat-model-menu.chip-menu .mi.selected .mi-check {
color: var(--klein) !important; color: var(--klein) !important;
} }
/* 模特立绘上传区:拖拽投放高亮 */
.actorlib-drop.is-dragover {
border-style: solid;
border-color: var(--heat);
background: var(--heat-8);
}
@@ -939,3 +939,11 @@
.task-stats { grid-template-columns: repeat(2, 1fr); } .task-stats { grid-template-columns: repeat(2, 1fr); }
} }
} }
/* 拖拽投放高亮:实线 + 主橙,和 hover 区分 */
.img-upload.is-dragover {
border-style: solid;
border-color: var(--heat);
color: var(--heat);
background: var(--heat-8);
}
+19
View File
@@ -66,6 +66,18 @@
.quick-create-page .quick-create-shell.is-generating .quick-form-panel { opacity: .82; } .quick-create-page .quick-create-shell.is-generating .quick-form-panel { opacity: .82; }
.quick-create-page .quick-create-shell.is-generating .quick-form-footer { pointer-events: auto; opacity: 1; } .quick-create-page .quick-create-shell.is-generating .quick-form-footer { pointer-events: auto; opacity: 1; }
.quick-create-page .quick-status-panel { position: relative; display: grid; place-items: center; min-height: 0; padding: 34px; background: radial-gradient(circle at 50% 45%,rgba(0,47,167,.075),transparent 34%),rgba(250,251,253,.88); }.quick-create-page .quick-state { width: min(560px,100%); display: none; }.quick-create-page .quick-state-ready { display: grid; justify-items: center; text-align: center; } .quick-create-page .quick-status-panel { position: relative; display: grid; place-items: center; min-height: 0; padding: 34px; background: radial-gradient(circle at 50% 45%,rgba(0,47,167,.075),transparent 34%),rgba(250,251,253,.88); }.quick-create-page .quick-state { width: min(560px,100%); display: none; }.quick-create-page .quick-state-ready { display: grid; justify-items: center; text-align: center; }
/* 读取任务状态:确认完之前不给看空表单,左侧整块也不能操作 —— 否则用户以为没任务又提交一次 */
.quick-create-page .quick-create-shell.is-restoring .quick-state-restoring { display: grid; }
.quick-create-page .quick-create-shell.is-restoring .quick-state-ready,
.quick-create-page .quick-create-shell.is-restoring .quick-state-generating,
.quick-create-page .quick-create-shell.is-restoring .quick-state-complete,
.quick-create-page .quick-create-shell.is-restoring .quick-state-failed { display: none; }
.quick-create-page .quick-state-restoring { width: min(460px,100%); justify-items: center; text-align: center; gap: 14px; }
.quick-create-page .quick-state-restoring h2 { margin: 0; font-size: 23px; }
.quick-create-page .quick-state-restoring p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }
.quick-create-page .quick-restoring-spinner { width: 34px; height: 34px; border-radius: 50%; border: 2px solid rgba(0,47,167,.16); border-top-color: var(--quick-blue); animation: quick-spinner-rotate 0.8s linear infinite; }
.quick-create-page .quick-form-panel.is-locked { pointer-events: none; opacity: .55; filter: saturate(.85); transition: opacity 160ms ease; }
.quick-create-page .quick-create-shell.is-generating .quick-state-ready,.quick-create-page .quick-create-shell.is-complete .quick-state-ready,.quick-create-page .quick-create-shell.is-failed .quick-state-ready { display: none; } .quick-create-page .quick-create-shell.is-generating .quick-state-ready,.quick-create-page .quick-create-shell.is-complete .quick-state-ready,.quick-create-page .quick-create-shell.is-failed .quick-state-ready { display: none; }
.quick-create-page .quick-create-shell.is-generating .quick-state-generating { display: grid; } .quick-create-page .quick-create-shell.is-generating .quick-state-generating { display: grid; }
.quick-create-page .quick-create-shell.is-complete .quick-state-complete { display: grid; } .quick-create-page .quick-create-shell.is-complete .quick-state-complete { display: grid; }
@@ -149,3 +161,10 @@
@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; } } @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; } }
@media (max-width: 980px) { .quick-create-page { padding-inline: 18px; }.quick-create-page .quick-create-shell { grid-template-columns: 1fr; }.quick-create-page .quick-create-panel { min-height: 620px; }.quick-create-page .quick-result-actions { grid-template-columns: 1fr; } } @media (max-width: 980px) { .quick-create-page { padding-inline: 18px; }.quick-create-page .quick-create-shell { grid-template-columns: 1fr; }.quick-create-page .quick-create-panel { min-height: 620px; }.quick-create-page .quick-result-actions { grid-template-columns: 1fr; } }
@media (max-width: 620px) { .quick-create-page .quick-parameter-grid { grid-template-columns: 1fr; }.quick-create-page .quick-upload-filled { width: min(100%,240px); grid-template-columns: 1fr; }.quick-create-page .quick-upload-more { min-height: 148px; } } @media (max-width: 620px) { .quick-create-page .quick-parameter-grid { grid-template-columns: 1fr; }.quick-create-page .quick-upload-filled { width: min(100%,240px); grid-template-columns: 1fr; }.quick-create-page .quick-upload-more { min-height: 148px; } }
/* 拖拽投放高亮 */
.quick-upload.is-dragover {
border-style: solid;
border-color: var(--heat);
background: var(--heat-8);
}
+9 -3
View File
@@ -29,6 +29,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types"; import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
import { api } from "../api"; import { api } from "../api";
import { useFileDrop } from "../components/use-file-drop";
import { imageModelPickerOptions } from "../model-display"; import { imageModelPickerOptions } from "../model-display";
import { ModelLibrary } from "../components/model-library"; import { ModelLibrary } from "../components/model-library";
import { SkeletonRows, SystemLoading } from "../components/loading"; import { SkeletonRows, SystemLoading } from "../components/loading";
@@ -711,14 +712,19 @@ export function ImageWorkbenchPage({
// YYX#14:无真封面时回退到按商品名匹配的 mock 图,与商品库一致显图,不再露灰占位 // YYX#14:无真封面时回退到按商品名匹配的 mock 图,与商品库一致显图,不再露灰占位
return p.cover_preview_url || primary?.preview_url || productMockCoverUrl(p.title); return p.cover_preview_url || primary?.preview_url || productMockCoverUrl(p.title);
}; };
function pickReference(event: ChangeEvent<HTMLInputElement>) { function acceptReferences(files: File[]) {
const files = Array.from(event.target.files || []);
if (!files.length) return; if (!files.length) return;
// 追加到已选(支持多次点 + 累加),逐张生成本地预览;file 留着提交时上传 // 追加到已选(支持多次点 + 累加),逐张生成本地预览;file 留着提交时上传
setRefImages((prev) => [...prev, ...files.map((f) => ({ name: f.name, url: URL.createObjectURL(f), file: f }))]); setRefImages((prev) => [...prev, ...files.map((f) => ({ name: f.name, url: URL.createObjectURL(f), file: f }))]);
}
function pickReference(event: ChangeEvent<HTMLInputElement>) {
acceptReferences(Array.from(event.target.files || []));
event.target.value = ""; event.target.value = "";
} }
const refDrop = useFileDrop(acceptReferences, { accept: (f) => f.type.startsWith("image/") });
const imageModels = modelConfigs.filter((model) => model.capability.includes("image")); const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
// 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1 // 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1
const [priceMultiplier, setPriceMultiplier] = useState(1); const [priceMultiplier, setPriceMultiplier] = useState(1);
@@ -1741,7 +1747,7 @@ export function ImageWorkbenchPage({
</div> </div>
)} )}
</div> </div>
<div className="image-composer"> <div className={`image-composer${refDrop.dragging ? " is-dragover" : ""}`} {...refDrop.dropProps}>
<div className="image-composer-main"> <div className="image-composer-main">
<div> <div>
<button type="button" className="image-reference-button" title="上传参考图" onClick={() => refInputRef.current?.click()}> <button type="button" className="image-reference-button" title="上传参考图" onClick={() => refInputRef.current?.click()}>
+19 -3
View File
@@ -3,6 +3,7 @@ import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Check, RefreshCw, Settings2, Trash2, Upload, User, UserPlus, X } from "lucide-react"; import { Check, RefreshCw, Settings2, Trash2, Upload, User, UserPlus, X } from "lucide-react";
import { api } from "../api"; import { api } from "../api";
import { useFileDrop } from "../components/use-file-drop";
import { generationErrorText } from "../generation-error"; import { generationErrorText } from "../generation-error";
import type { ModelEntity } from "../types"; import type { ModelEntity } from "../types";
import { SystemLoading } from "../components/loading"; import { SystemLoading } from "../components/loading";
@@ -120,6 +121,11 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
setSaving(false); setSaving(false);
} }
} }
const portraitDrop = useFileDrop(
(files) => selectPortrait(files[0]),
{ disabled: saving, accept: (f) => f.type.startsWith("image/") }
);
function selectPortrait(file?: File) { function selectPortrait(file?: File) {
if (!file || currentModel.is_official || saving) return; if (!file || currentModel.is_official || saving) return;
clearPendingPortrait(); clearPendingPortrait();
@@ -203,7 +209,8 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
</div> </div>
<input ref={portraitFileRef} type="file" accept="image/*" hidden onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ""; selectPortrait(file); }} /> <input ref={portraitFileRef} type="file" accept="image/*" hidden onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ""; selectPortrait(file); }} />
<div <div
className={`placeholder model-detail-portrait${portraitUrl ? " has-mock-media" : ""}`} className={`placeholder model-detail-portrait${portraitUrl ? " has-mock-media" : ""}${portraitDrop.dragging ? " is-dragover" : ""}`}
{...(model.is_official ? {} : portraitDrop.dropProps)}
style={portraitUrl ? mediaStyle(portraitUrl) : undefined} style={portraitUrl ? mediaStyle(portraitUrl) : undefined}
role={portraitUrl ? "button" : undefined} role={portraitUrl ? "button" : undefined}
tabIndex={portraitUrl ? 0 : undefined} tabIndex={portraitUrl ? 0 : undefined}
@@ -307,9 +314,18 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
useEffect(() => { setSelected(new Set()); }, [tab]); useEffect(() => { setSelected(new Set()); }, [tab]);
const modelDrop = useFileDrop(
(files) => { void acceptModelFile(files[0]); },
{ disabled: uploading, accept: (f) => f.type.startsWith("image/") }
);
async function onPick(e: ChangeEvent<HTMLInputElement>) { async function onPick(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = ""; e.target.value = "";
await acceptModelFile(file);
}
async function acceptModelFile(file?: File | null) {
if (!file) return; if (!file) return;
setUploading(true); setUploading(true);
try { try {
@@ -357,7 +373,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
</button> </button>
<button className="ml-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}> <button className="ml-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
<UserPlus /> <UserPlus />
<span>{uploading ? "上传中…" : "添加模特"}</span> <span>{uploading ? "上传中…" : modelDrop.dragging ? "松开即可添加" : "添加模特"}</span>
</button> </button>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPick} /> <input ref={fileRef} type="file" accept="image/*" hidden onChange={onPick} />
</div> </div>
@@ -388,7 +404,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
<span>/</span> <span>/</span>
</div> </div>
) : ( ) : (
<div className="ml-grid"> <div className={`ml-grid${modelDrop.dragging ? " is-dragover" : ""}`} {...modelDrop.dropProps}>
{items.map((m) => { {items.map((m) => {
const selectable = !m.is_official; const selectable = !m.is_official;
const isSelected = selected.has(m.id); const isSelected = selected.has(m.id);
+39 -13
View File
@@ -888,9 +888,33 @@ export function PipelinePage(props: {
delBusy(busyKey); delBusy(busyKey);
} }
} }
// 流程步骤4 · 生成人物立绘(App 层自动接力三视图) // 流程步骤4 · 生成人物立绘 → 出图后自动接力三视图(用户不必再进详情弹窗手点「AI 生成三视图」;
async function genPersonPortrait(prompt: string, label: string | undefined, busyKey: string) { // 弹窗里那个按钮保留,用于重跑 / 补生成)。后端 generate-base-asset 已带 auto_triview 自动接力,
return await genBaseAsset("person", prompt, label, busyKey); // 这里只做「保持转圈 + 兜底」:立绘落库后先确认后端已把三视图任务排上,排上了就把 loading 交给
// pending-assets 轮询;真没排上(旧后端 / 接力异常)才补发一次,避免重复出图重复扣费。
async function genPersonPortrait(prompt: string, label: string | undefined, busyKey: string, referenceAssetId?: string) {
const res = await genBaseAsset("person", prompt, label, busyKey, referenceAssetId);
const portraitId = res?.adopted_asset || "";
if (!portraitId) return res;
// 立绘的 busy 已在 genBaseAsset 里落回,这里立刻把三视图的 busy 顶上,卡片不闪「已就绪」
const triKeys = [...new Set([busyKey, ...entityGenKeys("person", label)])].map((k) => `${k}:tri`);
triKeys.forEach(addBusy);
try {
let chained = false;
for (let i = 0; i < 3 && !chained; i += 1) {
try {
const pending = (await api.pendingAssets(project.id)).pending || [];
chained = pending.some((p) => p.is_triview && p.triview_of === portraitId);
} catch {
/* 网络抖动:下一轮再看 */
}
if (!chained && i < 2) await new Promise((resolve) => window.setTimeout(resolve, 3000));
}
if (!chained) await onGenerateTriview(portraitId);
} finally {
triKeys.forEach(delBusy);
}
return res;
} }
// ── 流程步骤4 · 实体提取闸门(进资产趴入口):不自动花钱,用户点按钮才提取/生成 ── // ── 流程步骤4 · 实体提取闸门(进资产趴入口):不自动花钱,用户点按钮才提取/生成 ──
// idle=露三按钮 / running=露 loading;提取后 metadata.script_entities 落库、或已有资产 → 闸门由渲染层自动隐藏 // idle=露三按钮 / running=露 loading;提取后 metadata.script_entities 落库、或已有资产 → 闸门由渲染层自动隐藏
@@ -906,8 +930,7 @@ export function PipelinePage(props: {
for (const e of entities) { for (const e of entities) {
const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`; const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`;
if (e.type === "character") { if (e.type === "character") {
if (mode === "full") await genPersonPortrait(e.visual_prompt, e.name, bk); await genPersonPortrait(e.visual_prompt, e.name, bk); // 立绘出完自动接力三视图
else await genBaseAsset("person", e.visual_prompt, e.name, bk);
} else { } else {
await genBaseAsset("scene", e.visual_prompt, e.name, bk); await genBaseAsset("scene", e.visual_prompt, e.name, bk);
} }
@@ -2944,6 +2967,7 @@ export function PipelinePage(props: {
if (activeDot !== 2 && viewStage !== 2) return; if (activeDot !== 2 && viewStage !== 2) return;
let stopped = false; let stopped = false;
let timer = 0; let timer = 0;
let idleTicks = 0; // 连续「无在途」次数:立绘刚出片、后端接力的三视图任务可能晚半拍才入库,别一空就停
const tick = async () => { const tick = async () => {
if (document.hidden) { if (!stopped) timer = window.setTimeout(tick, 4000); return; } // 后台不空跑(纯读),保留心跳回前台即恢复 if (document.hidden) { if (!stopped) timer = window.setTimeout(tick, 4000); return; } // 后台不空跑(纯读),保留心跳回前台即恢复
try { try {
@@ -2961,7 +2985,9 @@ export function PipelinePage(props: {
prevPendingIdsRef.current = list.map((pending) => pending.id); prevPendingIdsRef.current = list.map((pending) => pending.id);
setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview, triview_of: p.triview_of }))); setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview, triview_of: p.triview_of })));
// 没有在途出图、本地也没有生成在跑 → 没什么可等,停轮询;再点生成(genBusy 变)时本 effect 会重订阅恢复。 // 没有在途出图、本地也没有生成在跑 → 没什么可等,停轮询;再点生成(genBusy 变)时本 effect 会重订阅恢复。
if (list.length === 0 && genBusy.size === 0) { stopped = true; return; } // 连空两轮(~8s)才停:立绘落库瞬间后端才接力建三视图任务,一空就停会把「三视图生成中」整条漏掉。
idleTicks = list.length === 0 && genBusy.size === 0 ? idleTicks + 1 : 0;
if (idleTicks >= 2) { stopped = true; return; }
} catch { } catch {
/* 忽略,下一轮再试 */ /* 忽略,下一轮再试 */
} }
@@ -3722,7 +3748,7 @@ export function PipelinePage(props: {
{kind === "scene" ? <Image /> : <UsersRound />} {kind === "scene" ? <Image /> : <UsersRound />}
<span>{kind === "scene" ? "场景库替换" : "模特库替换"}</span> <span>{kind === "scene" ? "场景库替换" : "模特库替换"}</span>
</button> </button>
<button className="as-ai-btn" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); void genBaseAsset(kind, p, tag, seedKey); }}> <button className="as-ai-btn" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); void (kind === "person" ? genPersonPortrait(p, tag, seedKey) : genBaseAsset(kind, p, tag, seedKey)); }}>
<Sparkles /><span>{busy ? "生成中…" : "AI 生成"}</span> <Sparkles /><span>{busy ? "生成中…" : "AI 生成"}</span>
</button> </button>
</div> </div>
@@ -3738,7 +3764,6 @@ export function PipelinePage(props: {
const portraitBusy = genKeys.some((k) => isBusy(k)) || (!mainUrl && pendingHas(kind, entity.name)); const portraitBusy = genKeys.some((k) => isBusy(k)) || (!mainUrl && pendingHas(kind, entity.name));
const triBusy = kind === "person" && (genKeys.some((k) => isBusy(`${k}:tri`)) || pendingTriFor(kind, entity)); const triBusy = kind === "person" && (genKeys.some((k) => isBusy(`${k}:tri`)) || pendingTriFor(kind, entity));
const busy = portraitBusy || triBusy; const busy = portraitBusy || triBusy;
const loadingText = kind === "person" && mainUrl ? "三视图生成中…" : "生成中…";
const rs = (kind === "person" || kind === "scene") && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : ""; const rs = (kind === "person" || kind === "scene") && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : "";
const previewState = `${mainUrl ? " ready" : busy ? " generating" : " pending"}${busy ? " generating" : ""}`; const previewState = `${mainUrl ? " ready" : busy ? " generating" : " pending"}${busy ? " generating" : ""}`;
return ( return (
@@ -3763,7 +3788,7 @@ export function PipelinePage(props: {
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}
> >
{kind === "person" && mainUrl ? <img className="as-gen-photo" src={mainUrl} alt="" /> : null} {kind === "person" && mainUrl ? <img className="as-gen-photo" src={mainUrl} alt="" /> : null}
{busy ? <span className={`asset-card-loading${mainUrl ? " veil" : ""}`}><span className="asset-spinner" aria-hidden="true"></span><span>{loadingText}</span></span> : null} {busy ? <span className={`asset-card-loading${mainUrl ? " veil" : ""}`}><span className="asset-spinner" aria-hidden="true"></span><span></span></span> : null}
</div> </div>
<div className="as-gen-meta"> <div className="as-gen-meta">
<h4 onClick={() => openAssetDetail(kind, entity)}>{entity.name}</h4> <h4 onClick={() => openAssetDetail(kind, entity)}>{entity.name}</h4>
@@ -3789,7 +3814,7 @@ export function PipelinePage(props: {
{kind === "scene" ? <Image /> : <UsersRound />} {kind === "scene" ? <Image /> : <UsersRound />}
<span>{kind === "scene" ? "场景库替换" : "模特库替换"}</span> <span>{kind === "scene" ? "场景库替换" : "模特库替换"}</span>
</button> </button>
<button className="as-ai-btn" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; const ref = kind === "person" && grp.adopted_asset ? grp.adopted_asset : undefined; void genBaseAsset(kind, p, entity.name, entBK, ref); }}> <button className="as-ai-btn" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; const ref = kind === "person" && grp.adopted_asset ? grp.adopted_asset : undefined; void (kind === "person" ? genPersonPortrait(p, entity.name, entBK, ref) : genBaseAsset(kind, p, entity.name, entBK)); }}>
<Sparkles /><span>{busy ? "生成中…" : mainUrl ? "重新生成" : "AI 生成"}</span> <Sparkles /><span>{busy ? "生成中…" : mainUrl ? "重新生成" : "AI 生成"}</span>
</button> </button>
</div> </div>
@@ -4743,7 +4768,8 @@ export function PipelinePage(props: {
// 重跑立绘 → 追加新立绘并自动接力该版三视图。 // 重跑立绘 → 追加新立绘并自动接力该版三视图。
// 角色重跑:若该角色已有当前立绘,传它作参考图 → 后端走 image_edit 参考立绘+提示词,保持同一人物一致(不重抽随机人)。 // 角色重跑:若该角色已有当前立绘,传它作参考图 → 后端走 image_edit 参考立绘+提示词,保持同一人物一致(不重抽随机人)。
const ref = isPerson && viewPortraitAsset ? viewPortraitAsset : undefined; const ref = isPerson && viewPortraitAsset ? viewPortraitAsset : undefined;
await genBaseAsset(isPerson ? "person" : "scene", prompt, entity!.name, pBK, ref); if (isPerson) await genPersonPortrait(prompt, entity!.name, pBK, ref);
else await genBaseAsset("scene", prompt, entity!.name, pBK);
setAdPortraitId(null); setAdTriId(null); setAdPortraitId(null); setAdTriId(null);
} }
async function regenTri() { async function regenTri() {
@@ -4770,7 +4796,7 @@ export function PipelinePage(props: {
{/* 同三视图:大图随数据(portraitUrl)走,出图完成即显示,不被在途 busyPortrait 卡转圈 */} {/* 同三视图:大图随数据(portraitUrl)走,出图完成即显示,不被在途 busyPortrait 卡转圈 */}
<div className={`placeholder ad-lead-img${portraitUrl ? " has-mock-media" : ""}`} style={portraitUrl ? mediaStyle(portraitUrl) : undefined}> <div className={`placeholder ad-lead-img${portraitUrl ? " has-mock-media" : ""}`} style={portraitUrl ? mediaStyle(portraitUrl) : undefined}>
{!portraitUrl && (busyPortrait {!portraitUrl && (busyPortrait
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame"></span></div> ? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame"></span></div>
: <span className="ph-frame"></span>)} : <span className="ph-frame"></span>)}
</div> </div>
{portraitUrl && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: portraitUrl, kind: "image", name: `${entity.name} · 立绘` })}>{zoomSvg}</button>} {portraitUrl && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: portraitUrl, kind: "image", name: `${entity.name} · 立绘` })}>{zoomSvg}</button>}
@@ -4805,7 +4831,7 @@ export function PipelinePage(props: {
busyTri */} busyTri */}
<div className={`placeholder${triUrl ? " has-mock-media" : ""}`} style={triUrl ? mediaStyle(triUrl) : undefined}> <div className={`placeholder${triUrl ? " has-mock-media" : ""}`} style={triUrl ? mediaStyle(triUrl) : undefined}>
{!triUrl && (busyTri {!triUrl && (busyTri
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame"></span></div> ? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame"></span></div>
: <span className="ph-frame"> / / · </span>)} : <span className="ph-frame"> / / · </span>)}
{triUrl && busyTri && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>} {triUrl && busyTri && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
</div> </div>
+8 -1
View File
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react"; import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
import { ArrowLeft, Check, ChevronDown, Grid2X2, List, PackagePlus, PackageX, Search, Settings2, Trash2, X } from "lucide-react"; import { ArrowLeft, Check, ChevronDown, Grid2X2, List, PackagePlus, PackageX, Search, Settings2, Trash2, X } from "lucide-react";
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays"; import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
import { useFileDrop } from "../components/use-file-drop";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge"; import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { ProductCreateDrawer } from "../components/product-create-drawer"; import { ProductCreateDrawer } from "../components/product-create-drawer";
import { import {
@@ -786,9 +787,15 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
return () => { alive = false; }; return () => { alive = false; };
}, [product?.id, product.cover_asset, assetReload]); }, [product?.id, product.cover_asset, assetReload]);
const imgDrop = useFileDrop((files) => { void uploadProductImage(files[0]); }, { disabled: uploading });
async function onPickProductImage(event: ChangeEvent<HTMLInputElement>) { async function onPickProductImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ""; event.target.value = "";
await uploadProductImage(file);
}
async function uploadProductImage(file?: File | null) {
if (!file || !onUploadImage) return; if (!file || !onUploadImage) return;
setUploading(true); setUploading(true);
try { try {
@@ -1163,7 +1170,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
</div> </div>
); );
})} })}
<div className="img-upload" id="ov-img-add" title="上传图片" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}> <div className={`img-upload${imgDrop.dragging ? " is-dragover" : ""}`} {...imgDrop.dropProps} id="ov-img-add" title="上传图片" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
{uploading ? ( {uploading ? (
<span className="ph-frame" style={{ fontSize: 12 }}></span> <span className="ph-frame" style={{ fontSize: 12 }}></span>
) : ( ) : (
+49 -6
View File
@@ -21,6 +21,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api, ApiError, type QuickCreateJob } from "../api"; import { api, ApiError, type QuickCreateJob } from "../api";
import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays"; import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays";
import { useFileDrop } from "../components/use-file-drop";
import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock"; import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock";
import type { ModelConfig } from "../types"; import type { ModelConfig } from "../types";
import { import {
@@ -135,6 +136,9 @@ export function QuickCreatePage({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [cancelling, setCancelling] = useState(false); const [cancelling, setCancelling] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false); const [confirmCancel, setConfirmCancel] = useState(false);
// 进页面先向后端确认有没有在跑的任务。localStorage 可能被清、也可能换了浏览器,
// 只认它会让刷新后看到一张空表单,用户以为没任务又提交一次 —— 那会重复建商品、重复扣费。
const [restoring, setRestoring] = useState(true);
const [serviceUnavailable, setServiceUnavailable] = useState(false); const [serviceUnavailable, setServiceUnavailable] = useState(false);
const [unavailableMessage, setUnavailableMessage] = useState(""); const [unavailableMessage, setUnavailableMessage] = useState("");
const [pollEpoch, setPollEpoch] = useState(0); const [pollEpoch, setPollEpoch] = useState(0);
@@ -183,8 +187,19 @@ export function QuickCreatePage({
})) }))
.catch(() => undefined); .catch(() => undefined);
void api.quickCreateHistory() void api.quickCreateHistory()
.then((payload) => setHistory((payload.results || []).filter(jobIsComplete))) .then((payload) => {
.catch(() => undefined); setHistory((payload.results || []).filter(jobIsComplete));
const running = payload.inflight || null;
if (running) {
setJob(running);
setJobId(running.id);
rememberQuickCreateJob(running.id);
} else if (!savedJobId()) {
forgetQuickCreateJob();
}
})
.catch(() => undefined)
.finally(() => setRestoring(false));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -325,7 +340,7 @@ export function QuickCreatePage({
setPlaying({ url, title: title || "预览视频" }); setPlaying({ url, title: title || "预览视频" });
} }
function selectImages(files: FileList | null) { function selectImages(files: FileList | File[] | null) {
const selected = Array.from(files || []).filter((file) => file.type.startsWith("image/")); const selected = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
setImages((current) => { setImages((current) => {
const room = Math.max(0, 9 - savedImages.length - current.length); const room = Math.max(0, 9 - savedImages.length - current.length);
@@ -386,6 +401,10 @@ export function QuickCreatePage({
} }
async function startGeneration() { async function startGeneration() {
if (restoring) {
onNotify?.("info", "正在读取任务状态,请稍候");
return;
}
if (!name.trim() || (!images.length && !savedImages.length)) { if (!name.trim() || (!images.length && !savedImages.length)) {
onNotify?.("info", "请先填写商品名称并上传商品图片"); onNotify?.("info", "请先填写商品名称并上传商品图片");
return; return;
@@ -433,7 +452,16 @@ export function QuickCreatePage({
onNotify?.("success", "一键成片任务已启动"); onNotify?.("success", "一键成片任务已启动");
onProjectCreated?.(); onProjectCreated?.();
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.status === 503) { if (error instanceof ApiError && error.status === 409) {
// 后端单飞闸:已有任务在跑。直接接上它,别让用户对着报错干瞪眼。
const running = (error.payload as { inflight?: QuickCreateJob } | undefined)?.inflight;
if (running) {
setJob(running);
setJobId(running.id);
rememberQuickCreateJob(running.id);
}
onNotify?.("info", error.message || "已有一个一键成片正在进行中");
} else if (error instanceof ApiError && error.status === 503) {
setServiceUnavailable(true); setServiceUnavailable(true);
setUnavailableMessage(error.message || "一键成片服务暂不可用,请稍后再试"); setUnavailableMessage(error.message || "一键成片服务暂不可用,请稍后再试");
onNotify?.("info", error.message || "一键成片服务暂不可用,请稍后再试"); onNotify?.("info", error.message || "一键成片服务暂不可用,请稍后再试");
@@ -520,6 +548,7 @@ export function QuickCreatePage({
} }
const isGenerating = submitting || job?.status === "queued" || job?.status === "running"; const isGenerating = submitting || job?.status === "queued" || job?.status === "running";
const imageDrop = useFileDrop((files) => selectImages(files), { disabled: isGenerating });
const isComplete = job?.status === "succeeded"; const isComplete = job?.status === "succeeded";
const isCancelled = job?.status === "cancelled"; const isCancelled = job?.status === "cancelled";
const isFailed = !isGenerating && !isComplete && (job?.status === "failed" || isCancelled || serviceUnavailable); const isFailed = !isGenerating && !isComplete && (job?.status === "failed" || isCancelled || serviceUnavailable);
@@ -547,6 +576,7 @@ export function QuickCreatePage({
const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 60 : sceneCount * 240; const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 60 : sceneCount * 240;
const shellClass = [ const shellClass = [
"quick-create-shell", "quick-create-shell",
restoring ? "is-restoring" : "",
isGenerating ? "is-generating" : "", isGenerating ? "is-generating" : "",
isComplete ? "is-complete" : "", isComplete ? "is-complete" : "",
isFailed ? "is-failed" : "", isFailed ? "is-failed" : "",
@@ -562,7 +592,11 @@ export function QuickCreatePage({
</header> </header>
<div className={shellClass} id="quickCreateShell"> <div className={shellClass} id="quickCreateShell">
<section className="quick-create-panel quick-form-panel"> <section
className={`quick-create-panel quick-form-panel${restoring ? " is-locked" : ""}`}
aria-busy={restoring}
inert={restoring}
>
<div className="quick-form-copy"> <div className="quick-form-copy">
<h2></h2> <h2></h2>
</div> </div>
@@ -614,7 +648,10 @@ export function QuickCreatePage({
<div className="quick-field"> <div className="quick-field">
<span className="quick-field-label"><span></span><small> · 9</small></span> <span className="quick-field-label"><span></span><small> · 9</small></span>
<div className={`quick-upload${imageCount ? " has-images" : ""}`}> <div
className={`quick-upload${imageCount ? " has-images" : ""}${imageDrop.dragging ? " is-dragover" : ""}`}
{...imageDrop.dropProps}
>
<input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => { selectImages(event.target.files); event.currentTarget.value = ""; }} /> <input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => { selectImages(event.target.files); event.currentTarget.value = ""; }} />
{imageCount ? ( {imageCount ? (
<div className="quick-upload-filled"> <div className="quick-upload-filled">
@@ -686,6 +723,12 @@ export function QuickCreatePage({
</section> </section>
<section className="quick-create-panel quick-status-panel" aria-live="polite"> <section className="quick-create-panel quick-status-panel" aria-live="polite">
<div className="quick-state quick-state-restoring" role="status">
<div className="quick-restoring-spinner" aria-hidden="true" />
<h2></h2>
<p></p>
</div>
<div className="quick-state quick-state-ready"> <div className="quick-state quick-state-ready">
<div className="quick-ready-orbit"><span className="quick-ready-icon"><Sparkles /></span></div> <div className="quick-ready-orbit"><span className="quick-ready-icon"><Sparkles /></span></div>
<h2></h2> <h2></h2>
+11 -4
View File
@@ -10,6 +10,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { LoginSession, Team, User, UserPreference } from "../types"; import type { LoginSession, Team, User, UserPreference } from "../types";
import { TeamModal } from "../components/overlays"; import { TeamModal } from "../components/overlays";
import { useFileDrop } from "../components/use-file-drop";
type SectionKey = "profile" | "security" | "notify" | "pref" | "display"; type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
@@ -346,13 +347,18 @@ export function SettingsPage({
setModal("avatar"); setModal("avatar");
} }
function onPickAvatar(event: ChangeEvent<HTMLInputElement>) { function acceptAvatar(file?: File | null) {
const file = event.target.files?.[0];
if (!file) return; if (!file) return;
setAvatarFile(file); setAvatarFile(file);
setAvatarPreview(URL.createObjectURL(file)); setAvatarPreview(URL.createObjectURL(file));
} }
function onPickAvatar(event: ChangeEvent<HTMLInputElement>) {
acceptAvatar(event.target.files?.[0]);
}
const avatarDrop = useFileDrop((files) => acceptAvatar(files[0]), { disabled: savingAvatar });
async function handleUploadAvatar() { async function handleUploadAvatar() {
if (!avatarFile || savingAvatar) return; if (!avatarFile || savingAvatar) return;
setSavingAvatar(true); setSavingAvatar(true);
@@ -717,7 +723,8 @@ export function SettingsPage({
onChange={onPickAvatar} onChange={onPickAvatar}
/> />
<div <div
className="upload-zone" className={`upload-zone${avatarDrop.dragging ? " dragover" : ""}`}
{...avatarDrop.dropProps}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label="点击选择图片上传" aria-label="点击选择图片上传"
@@ -732,7 +739,7 @@ export function SettingsPage({
<span className="uz-ic"> <span className="uz-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>
</span> </span>
<div><strong></strong> · </div> <div><strong>{avatarDrop.dragging ? "松开即可上传" : "点击选择"}</strong> · </div>
<span className="uz-hint">JPG / PNG / WebP · 2 MB · 256 × 256</span> <span className="uz-hint">JPG / PNG / WebP · 2 MB · 256 × 256</span>
</div> </div>
+99 -36
View File
@@ -13,6 +13,7 @@ import {
PanelsTopLeft, PanelsTopLeft,
Play, Play,
RectangleVertical, RectangleVertical,
RefreshCw,
Save, Save,
ScanLine, ScanLine,
ScanSearch, ScanSearch,
@@ -21,6 +22,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api, ApiError } from "../api"; import { api, ApiError } from "../api";
import { ConfirmModal, MediaLightbox } from "../components/overlays"; import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { useFileDrop } from "../components/use-file-drop";
import type { ModelConfig, VideoDigestHistory, VideoDigestJob } from "../types"; import type { ModelConfig, VideoDigestHistory, VideoDigestJob } from "../types";
import type { NavigateFn } from "./route-config"; import type { NavigateFn } from "./route-config";
@@ -167,6 +169,8 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
const [openHistoryId, setOpenHistoryId] = useState(""); const [openHistoryId, setOpenHistoryId] = useState("");
const [playing, setPlaying] = useState<VideoDigestHistory | null>(null); const [playing, setPlaying] = useState<VideoDigestHistory | null>(null);
const [confirmCancel, setConfirmCancel] = useState(false); const [confirmCancel, setConfirmCancel] = useState(false);
// 进页面先向后端确认有没有在跑的提炼,确认完再决定显示表单还是进度
const [restoring, setRestoring] = useState(true);
const promptRef = useRef<HTMLTextAreaElement>(null); const promptRef = useRef<HTMLTextAreaElement>(null);
const completedNoticeRef = useRef(""); const completedNoticeRef = useRef("");
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
@@ -236,21 +240,31 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
} }
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
await loadHistory(); const listed = await loadHistory();
if (cancelled) return; if (cancelled) return;
// 优先信服务端:列表接口会回本团队在跑的那条。localStorage 可能被清、
// 也可能换了浏览器/标签页,只认它会让「退出再进来任务就没了」。
const inflight = listed?.inflight || null;
const stored = readJobId(); const stored = readJobId();
if (!stored) return; const restoreId = (inflight ? jobIdOf(inflight) : "") || stored;
if (!restoreId) {
forgetJob();
setRestoring(false);
return;
}
try { try {
const job = await api.getVideoDigest(stored); const job = inflight && jobIdOf(inflight) === restoreId
? inflight
: await api.getVideoDigest(restoreId);
if (cancelled) return; if (cancelled) return;
if (job.status === "processing") { if (job.status === "processing") {
if (!job.video_url && !job.cover_url) { // 之前这里在「拿不到封面/原片直链」时把任务取消掉 —— 那是拿渲染缩略图的
forgetJob(); // 能力去判活,TOS 慢一点或签名失败就误杀正在跑的任务。一律恢复并续上轮询,
void api.cancelVideoDigest(stored).catch(() => undefined); // 真死掉的由后端 expire_stale_team_digests(15 分钟)回收。
return;
}
applyJobMeta(job); applyJobMeta(job);
setJobId(stored); setJobId(restoreId);
rememberJob(restoreId);
setRestoring(false);
return; return;
} }
if (job.status === "succeeded") { if (job.status === "succeeded") {
@@ -260,7 +274,9 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
/* 过期 / 已取消 / 不存在:当没任务 */ /* 过期 / 已取消 / 不存在:当没任务 */
} }
forgetJob(); forgetJob();
})(); })().finally(() => {
if (!cancelled) setRestoring(false);
});
return () => { return () => {
cancelled = true; cancelled = true;
}; };
@@ -322,6 +338,11 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
}; };
}, [pollId, onNotify]); }, [pollId, onNotify]);
const videoDrop = useFileDrop(
(files) => { void pickFile(files[0] || null); },
{ disabled: analyzing }
);
const pickFile = async (next: File | null) => { const pickFile = async (next: File | null) => {
if (!next || analyzing) return; if (!next || analyzing) return;
if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) { if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) {
@@ -391,6 +412,19 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
setJobId(id); setJobId(id);
} catch (error) { } catch (error) {
if (userCancelledRef.current || (error instanceof DOMException && error.name === "AbortError")) return; if (userCancelledRef.current || (error instanceof DOMException && error.name === "AbortError")) return;
if (error instanceof ApiError && error.status === 409) {
// 后端单飞闸:已有提炼在跑。直接接上它,别让用户对着报错干瞪眼。
const running = (error.payload as { inflight?: VideoDigestJob } | undefined)?.inflight;
if (running) {
const id = jobIdOf(running);
applyJobMeta(running);
rememberJob(id);
setWatchId("");
setJobId(id);
}
onNotify("info", error.message || "已有一个视频正在提炼中");
return;
}
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试"); onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@@ -460,6 +494,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
const stage: ProgressStage = analyzing ? "analyze" : hasResult ? "prompt" : file || remoteVideoUrl ? "analyze" : "upload"; const stage: ProgressStage = analyzing ? "analyze" : hasResult ? "prompt" : file || remoteVideoUrl ? "analyze" : "upload";
const panelClass = [ const panelClass = [
"video-result-panel remix-information-panel", "video-result-panel remix-information-panel",
restoring ? "is-restoring" : "",
hasResult ? "has-result" : "", hasResult ? "has-result" : "",
analyzing ? "is-analyzing" : "", analyzing ? "is-analyzing" : "",
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
@@ -496,32 +531,54 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
</div> </div>
<div className="video-flow-grid remix-flow-grid"> <div className="video-flow-grid remix-flow-grid">
<section className="video-flow-panel video-remix-upload-panel"> <section
className={`video-flow-panel video-remix-upload-panel${restoring ? " is-locked" : ""}`}
aria-busy={restoring}
inert={restoring}
>
<h2></h2> <h2></h2>
<div className="video-flow-step"> <div className="video-flow-step">
<div className="video-flow-step-head"> <div className="video-flow-step-head">
<strong></strong> <strong></strong>
<span>MP4 / MOV · 60 </span> <span>MP4 / MOV · 3 · 200MB</span>
</div> </div>
{previewUrl ? ( {previewUrl ? (
<div className="video-upload-field has-file has-preview"> <div
<video src={previewUrl} poster={coverUrl || undefined} controls playsInline preload="metadata" /> className={`video-upload-field has-file has-preview${videoDrop.dragging ? " is-dragover" : ""}`}
<label className={`remix-replace-video${analyzing ? " is-disabled" : ""}`}> {...videoDrop.dropProps}
<input >
type="file" <div className="video-upload-preview">
accept="video/mp4,video/quicktime,video/webm" <video src={previewUrl} poster={coverUrl || undefined} controls playsInline preload="metadata" />
hidden <div className="video-upload-preview-meta">
disabled={analyzing} <strong>{fileName || file?.name || "参考视频"}</strong>
onChange={(event) => { <small>
void pickFile(event.target.files?.[0] || null); {analyzing
event.target.value = ""; ? "正在提炼分镜稿…"
}} : [duration ? `${duration}` : "", ratio, fileMetaCopy(kind, fileSize, width, height)]
/> .filter(Boolean).join(" · ")}
</small>
</label> </div>
<label className={`video-upload-change${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 = "";
}}
/>
<RefreshCw />
</label>
</div>
</div> </div>
) : ( ) : (
<label className="video-upload-field"> <label
className={`video-upload-field${videoDrop.dragging ? " is-dragover" : ""}`}
{...videoDrop.dropProps}
>
<input <input
type="file" type="file"
accept="video/mp4,video/quicktime,video/webm" accept="video/mp4,video/quicktime,video/webm"
@@ -534,7 +591,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
/> />
<span> <span>
<FileVideo /> <FileVideo />
<strong></strong> <strong>{videoDrop.dragging ? "松开即可上传" : "点击或拖拽上传参考视频"}</strong>
<small></small> <small></small>
</span> </span>
</label> </label>
@@ -544,7 +601,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
<button <button
type="button" type="button"
className="primary-action" className="primary-action"
disabled={analyzing || !canAnalyze} disabled={restoring || analyzing || !canAnalyze}
onClick={() => void analyze()} onClick={() => void analyze()}
> >
<ScanSearch /> <ScanSearch />
@@ -554,6 +611,13 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
</section> </section>
<aside className={panelClass} aria-live="polite"> <aside className={panelClass} aria-live="polite">
<div className="remix-restoring-state" role="status" aria-live="polite">
<div className="remix-restoring-content">
<span className="remix-restoring-spinner" aria-hidden="true" />
<strong></strong>
<span></span>
</div>
</div>
<div className="video-result-placeholder"> <div className="video-result-placeholder">
<div> <div>
<span className="remix-placeholder-icon"><ScanLine /></span> <span className="remix-placeholder-icon"><ScanLine /></span>
@@ -568,6 +632,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
</div> </div>
<strong></strong> <strong></strong>
<span></span> <span></span>
<div className="remix-generating-bar" aria-hidden="true"><span /></div>
<button type="button" className="secondary-action remix-cancel-analyze" onClick={() => setConfirmCancel(true)}> <button type="button" className="secondary-action remix-cancel-analyze" onClick={() => setConfirmCancel(true)}>
<X /> <X />
@@ -616,6 +681,10 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
<div><h2></h2></div> <div><h2></h2></div>
</div> </div>
<div className="remix-prompt-head-actions"> <div className="remix-prompt-head-actions">
<button type="button" className="primary-action remix-prompt-head-btn" onClick={() => continueGenerate()}>
<span></span>
<ArrowRight />
</button>
<button type="button" className="secondary-action remix-prompt-head-btn" onClick={() => void savePrompt()}> <button type="button" className="secondary-action remix-prompt-head-btn" onClick={() => void savePrompt()}>
<Save /> <Save />
@@ -629,12 +698,6 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
value={prompt} value={prompt}
onChange={(event) => setPrompt(event.target.value)} onChange={(event) => setPrompt(event.target.value)}
/> />
<div className="video-flow-actions remix-prompt-actions">
<button type="button" className="primary-action" onClick={() => continueGenerate()}>
<span></span>
<ArrowRight />
</button>
</div>
</div> </div>
</section> </section>
+199 -29
View File
@@ -20,6 +20,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api, ApiError } from "../api"; import { api, ApiError } from "../api";
import { OverlayPortal, useBodyScrollLock } from "../components/overlays"; import { OverlayPortal, useBodyScrollLock } from "../components/overlays";
import { useFileDrop } from "../components/use-file-drop";
import { import {
DEFAULT_BILLING_RATES, DEFAULT_BILLING_RATES,
FC_MODELS, FC_MODELS,
@@ -37,6 +38,13 @@ const JOB_KEY = "airshelf:video-replace-job";
const MODE_KEY = "airshelf:video-replace-mode"; const MODE_KEY = "airshelf:video-replace-mode";
const CHARACTER_MARK = "[视频复刻·角色]"; const CHARACTER_MARK = "[视频复刻·角色]";
const VIDEO_ACCEPT = "video/mp4,video/quicktime"; const VIDEO_ACCEPT = "video/mp4,video/quicktime";
// 商品复刻的参考视频只用来提炼分镜稿,不发火山,所以能到 60 秒;
// 角色复刻仍把参考视频直传火山,卡死在火山的 15 秒。
const PRODUCT_SOURCE_PURPOSE = "video_replace_product";
const REF_SECONDS_MAX = { product: 60, character: 15 } as const;
const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 50 * 1024 * 1024 } as const;
// 火山单次出片上限。参考视频更长时不报错,成片按这个截断,提示词里会要求模型压缩改编。
const SEEDANCE_MAX_OUTPUT_SECONDS = 15;
type ProductSource = "library" | "temporary" | ""; type ProductSource = "library" | "temporary" | "";
type ReplaceMode = "product" | "character"; type ReplaceMode = "product" | "character";
@@ -50,7 +58,7 @@ const REPLACE_MODE_COPY = {
videoReady: "视频已就绪,将先提炼分镜稿再复刻", videoReady: "视频已就绪,将先提炼分镜稿再复刻",
libraryTitle: "从商品库选择", libraryTitle: "从商品库选择",
libraryEmpty: "选择已创建的商品", libraryEmpty: "选择已创建的商品",
temporaryTitle: "临时上传商品", temporaryTitle: "临时上传素材",
temporaryEmpty: "仅用于本次任务 · 最多 9 张", temporaryEmpty: "仅用于本次任务 · 最多 9 张",
temporaryNoun: "商品图", temporaryNoun: "商品图",
temporaryFallback: "临时商品素材", temporaryFallback: "临时商品素材",
@@ -170,8 +178,9 @@ function formatClock(seconds: number) {
} }
function clampDuration(seconds: number) { function clampDuration(seconds: number) {
const rounded = Math.round(Number(seconds) || 15); const rounded = Math.round(Number(seconds) || SEEDANCE_MAX_OUTPUT_SECONDS);
return Math.min(15, Math.max(4, rounded || 15)); // 60 秒参考视频不是错误:成片取火山能出的最长,分镜稿由模型压缩改编。
return Math.min(SEEDANCE_MAX_OUTPUT_SECONDS, Math.max(4, rounded || SEEDANCE_MAX_OUTPUT_SECONDS));
} }
function isCharacterRemix(task?: Partial<FreeVideoTask> | null) { function isCharacterRemix(task?: Partial<FreeVideoTask> | null) {
@@ -240,6 +249,24 @@ function productImageCount(product: Product) {
return product.images?.filter((image) => image.asset || image.preview_url).length || 0; return product.images?.filter((image) => image.asset || image.preview_url).length || 0;
} }
/** 选中后这次实际会带给模型的参考图。三视图是 standalone 资产,不在 images 里,单独点名。 */
function librarySelectionDetail(mode: ReplaceMode, product: Product | null, model: ModelEntity | null) {
if (mode === "character") {
if (!model) return "";
const parts = [model.portrait ? "定妆照" : "", model.triview ? "三视图" : ""].filter(Boolean);
return parts.length ? `将带上 ${parts.join(" + ")}` : "";
}
if (!product) return "";
const count = productImageCount(product) || (product.cover_preview_url ? 1 : 0);
const parts = [count ? `${count} 张商品图` : ""];
parts.push(product.triview_preview_url ? "三视图" : "");
const kept = parts.filter(Boolean);
if (!kept.length) return "";
return product.triview_preview_url
? `将带上 ${kept.join(" + ")}`
: `将带上 ${kept.join("")} · 该商品还没有三视图`;
}
function modelCover(model: ModelEntity) { function modelCover(model: ModelEntity) {
return model.portrait || model.triview || ""; return model.portrait || model.triview || "";
} }
@@ -266,8 +293,12 @@ export function VideoReplacePage({
const [models, setModels] = useState<ModelEntity[]>([]); const [models, setModels] = useState<ModelEntity[]>([]);
const [replaceMode, setReplaceMode] = useState<ReplaceMode>(readReplaceMode); const [replaceMode, setReplaceMode] = useState<ReplaceMode>(readReplaceMode);
const [videoFile, setVideoFile] = useState<File | null>(null); const [videoFile, setVideoFile] = useState<File | null>(null);
// 选完立刻用本地 objectURL 放预览,不等上传回来 —— 用户要先看见自己选的片子
const [videoPreview, setVideoPreview] = useState("");
const [videoRef, setVideoRef] = useState<FreeVideoRef | null>(null); const [videoRef, setVideoRef] = useState<FreeVideoRef | null>(null);
const [videoUploading, setVideoUploading] = useState(false); const [videoUploading, setVideoUploading] = useState(false);
// 进页面先向后端确认有没有在跑的任务,确认完再决定显示表单还是进度
const [restoring, setRestoring] = useState(true);
const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 }); const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 });
const [source, setSource] = useState<ProductSource>(""); const [source, setSource] = useState<ProductSource>("");
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
@@ -314,6 +345,7 @@ export function VideoReplacePage({
const productReady = source === "library" const productReady = source === "library"
? (replaceMode === "character" ? Boolean(selectedModel) : Boolean(selectedProduct)) ? (replaceMode === "character" ? Boolean(selectedModel) : Boolean(selectedProduct))
: (tempFiles.length > 0 || tempAssetRefs.length > 0); : (tempFiles.length > 0 || tempAssetRefs.length > 0);
const librarySelected = source === "library" && Boolean(replaceMode === "character" ? selectedModel : selectedProduct);
const libraryPreview = replaceMode === "character" const libraryPreview = replaceMode === "character"
? (selectedModel ? modelCover(selectedModel) : "") ? (selectedModel ? modelCover(selectedModel) : "")
: (selectedProduct ? productCover(selectedProduct) : ""); : (selectedProduct ? productCover(selectedProduct) : "");
@@ -345,7 +377,9 @@ export function VideoReplacePage({
], ],
}, billingRates); }, billingRates);
const points = estimated.points || 220; const points = estimated.points || 220;
const generating = Boolean(job && isInFlight(job.status)) || submitting || videoUploading; // 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。
// 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。
const generating = Boolean(job && isInFlight(job.status)) || submitting;
const digesting = Boolean(job && isDigesting(job)); const digesting = Boolean(job && isDigesting(job));
// 商品复刻提交后先进拆解态;审核态是角色复刻专有(参考视频直传火山才需要送审)。 // 商品复刻提交后先进拆解态;审核态是角色复刻专有(参考视频直传火山才需要送审)。
const reviewing = !digesting && (submitting || Boolean(job && (job.review_stage === "reviewing" || job.status === "created"))); const reviewing = !digesting && (submitting || Boolean(job && (job.review_stage === "reviewing" || job.status === "created")));
@@ -353,6 +387,7 @@ export function VideoReplacePage({
const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy; const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy;
const panelClass = [ const panelClass = [
"video-result-panel replace-result-panel", "video-result-panel replace-result-panel",
restoring ? "is-restoring" : "",
generating ? "is-generating" : "", generating ? "is-generating" : "",
reviewing || digesting ? "is-reviewing" : "", reviewing || digesting ? "is-reviewing" : "",
hasResult ? "has-result" : "", hasResult ? "has-result" : "",
@@ -367,11 +402,40 @@ export function VideoReplacePage({
try { try {
const data = await api.videoReplaceTasks(0, 50); const data = await api.videoReplaceTasks(0, 50);
setHistory((data.results || []).filter((item) => item.status === "succeeded")); setHistory((data.results || []).filter((item) => item.status === "succeeded"));
return data;
} catch { } catch {
/* 历史失败不挡当前复刻 */ /* 历史失败不挡当前复刻 */
return null;
} }
}; };
// 恢复在跑的任务。拉到之前一律显示「读取中」——否则用户以为没任务,又传一次。
const restoreInflight = async () => {
const data = await loadHistory();
const running = data?.inflight || null;
if (running) {
// 静默恢复:只把进度接上。不要走 fillFormFromTask —— 它会弹 toast、滚动页面,
// 还依赖商品/模特列表加载完,拿来做刷新恢复会又吵又不稳。
setJob(running);
setJobId(running.id);
rememberJob(running.id);
const mode = modeFromTask(running);
setReplaceMode(mode);
rememberReplaceMode(mode);
const video = videoRefFromTask(running);
if (video?.url) setVideoPreview(video.url);
setVideoRef(video ? { ...video, type: "video", role: "reference_video", label: video.label || "参考视频" } : null);
setFilledSubjectName(subjectNameFromTask(running));
setVideoMeta({
duration: Number(video?.duration || running.duration || 0),
...sizeFromRatio(running.aspect_ratio || "9:16"),
});
} else {
forgetJob();
}
setRestoring(false);
};
useEffect(() => { useEffect(() => {
void api.products(100).then((payload) => setProducts(payload.results || [])).catch(() => undefined); 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.listModels({ pageSize: 200 }).then((payload) => setModels((payload.results || []).filter((item) => !item.is_deleted))).catch(() => undefined);
@@ -382,7 +446,7 @@ export function VideoReplacePage({
multiplier: Number(config.team_price_multiplier) || 1, multiplier: Number(config.team_price_multiplier) || 1,
})) }))
.catch(() => undefined); .catch(() => undefined);
void loadHistory(); void restoreInflight();
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -455,7 +519,10 @@ export function VideoReplacePage({
const pickVideo = async (file: File | null) => { const pickVideo = async (file: File | null) => {
if (!file) return; if (!file) return;
const check = await checkRefFile(file); const check = await checkRefFile(file, {
maxSeconds: REF_SECONDS_MAX[replaceMode],
maxVideoBytes: REF_BYTES_MAX[replaceMode],
});
if (!check.ok) { if (!check.ok) {
onNotify("error", check.error); onNotify("error", check.error);
return; return;
@@ -465,11 +532,13 @@ export function VideoReplacePage({
return; return;
} }
setVideoFile(file); setVideoFile(file);
setVideoPreview(URL.createObjectURL(file));
setVideoUploading(true); setVideoUploading(true);
setJob((current) => (current && isInFlight(current.status) ? current : null)); setJob((current) => (current && isInFlight(current.status) ? current : null));
if (job && !isInFlight(job.status)) setJob(null); if (job && !isInFlight(job.status)) setJob(null);
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
if (replaceMode === "product") form.append("purpose", PRODUCT_SOURCE_PURPOSE);
try { try {
const uploaded = await api.uploadFreeVideoRef(form); const uploaded = await api.uploadFreeVideoRef(form);
setVideoRef({ setVideoRef({
@@ -490,13 +559,14 @@ export function VideoReplacePage({
} catch (error) { } catch (error) {
setVideoFile(null); setVideoFile(null);
setVideoRef(null); setVideoRef(null);
setVideoPreview("");
onNotify("error", error instanceof Error ? error.message : "参考视频上传失败"); onNotify("error", error instanceof Error ? error.message : "参考视频上传失败");
} finally { } finally {
setVideoUploading(false); setVideoUploading(false);
} }
}; };
const addTempImages = (files: FileList | null) => { const addTempImages = (files: FileList | File[] | null) => {
const incoming = [...(files || [])].filter((file) => IMAGE_TYPES.includes(file.type)); const incoming = [...(files || [])].filter((file) => IMAGE_TYPES.includes(file.type));
if (!incoming.length) { if (!incoming.length) {
onNotify("error", "请选择 JPG、PNG 或 WebP 图片"); onNotify("error", "请选择 JPG、PNG 或 WebP 图片");
@@ -530,8 +600,33 @@ export function VideoReplacePage({
if (job && !isInFlight(job.status)) setJob(null); if (job && !isInFlight(job.status)) setJob(null);
}; };
useEffect(() => {
// 换一条预览或离开页面时释放 objectURL,不然一路选下去会攒一堆 blob
if (!videoPreview.startsWith("blob:")) return;
return () => URL.revokeObjectURL(videoPreview);
}, [videoPreview]);
const tempDrop = useFileDrop(
(files) => addTempImages(files),
{ disabled: generating }
);
const videoDrop = useFileDrop(
(files) => { void pickVideo(files[0] || null); },
{ disabled: generating || videoUploading }
);
const switchReplaceMode = (next: ReplaceMode) => { const switchReplaceMode = (next: ReplaceMode) => {
if (next === replaceMode || generating) return; if (next === replaceMode || generating) return;
// 商品复刻能传 60 秒,角色复刻只能 15 秒。带着超长视频切过去会一路走到提交才报错,
// 这里直接清掉并说明原因。
const tooLongForNext = (videoMeta.duration || 0) > REF_SECONDS_MAX[next] + 0.5;
if (tooLongForNext) {
setVideoFile(null);
setVideoRef(null);
setVideoPreview("");
setVideoMeta({ duration: 0, width: 0, height: 0 });
}
setReplaceMode(next); setReplaceMode(next);
rememberReplaceMode(next); rememberReplaceMode(next);
setSource(""); setSource("");
@@ -544,7 +639,12 @@ export function VideoReplacePage({
setPendingModelId(""); setPendingModelId("");
setLibraryOpen(false); setLibraryOpen(false);
if (job && !isInFlight(job.status)) setJob(null); if (job && !isInFlight(job.status)) setJob(null);
onNotify("info", `已切换为${REPLACE_MODE_COPY[next].modeLabel}`); onNotify(
"info",
tooLongForNext
? `已切换为${REPLACE_MODE_COPY[next].modeLabel},参考视频最长 ${REF_SECONDS_MAX[next]} 秒,请重新上传`
: `已切换为${REPLACE_MODE_COPY[next].modeLabel}`
);
}; };
const confirmLibrarySelection = () => { const confirmLibrarySelection = () => {
@@ -647,6 +747,17 @@ export function VideoReplacePage({
forgetJob(); forgetJob();
} }
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.status === 409) {
// 后端单飞闸:已有复刻在跑。直接接上它,别让用户对着报错干瞪眼。
const running = (error.payload as { inflight?: FreeVideoTask } | undefined)?.inflight;
if (running) {
setJob(running);
setJobId(running.id);
rememberJob(running.id);
}
onNotify("info", error.message || "已有一个视频正在复刻中");
return;
}
onNotify("error", error instanceof Error ? error.message : "视频复刻提交失败"); onNotify("error", error instanceof Error ? error.message : "视频复刻提交失败");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@@ -666,6 +777,8 @@ export function VideoReplacePage({
setReplaceMode(mode); setReplaceMode(mode);
rememberReplaceMode(mode); rememberReplaceMode(mode);
setVideoFile(null); setVideoFile(null);
// 从历史「重新生成」回填时也要把原视频放进预览框,否则第 1 步看起来像没选
setVideoPreview(video.url || "");
setVideoRef({ setVideoRef({
...video, ...video,
type: "video", type: "video",
@@ -750,7 +863,11 @@ export function VideoReplacePage({
</header> </header>
<div className="video-flow-grid"> <div className="video-flow-grid">
<section className="video-flow-panel replace-flow-panel"> <section
className={`video-flow-panel replace-flow-panel${restoring ? " is-locked" : ""}`}
aria-busy={restoring}
inert={restoring}
>
<h2></h2> <h2></h2>
<div className="replace-mode-switch" role="tablist" aria-label="选择视频复刻功能"> <div className="replace-mode-switch" role="tablist" aria-label="选择视频复刻功能">
<button <button
@@ -781,9 +898,17 @@ export function VideoReplacePage({
<div className="video-flow-step"> <div className="video-flow-step">
<div className="video-flow-step-head"> <div className="video-flow-step-head">
<strong>1. </strong> <strong>1. </strong>
<span>MP4 / MOV · 15 </span> <span>MP4 / MOV · {REF_SECONDS_MAX[replaceMode]} </span>
</div> </div>
<label className={`video-upload-field${videoReady ? " has-file" : ""}`}> <div
className={[
"video-upload-field",
videoReady ? "has-file" : "",
videoPreview ? "has-preview" : "",
videoDrop.dragging ? "is-dragover" : "",
].filter(Boolean).join(" ")}
{...videoDrop.dropProps}
>
<input <input
ref={videoInputRef} ref={videoInputRef}
type="file" type="file"
@@ -794,12 +919,40 @@ export function VideoReplacePage({
event.target.value = ""; event.target.value = "";
}} }}
/> />
<span> {videoPreview ? (
<FileVideo2 /> <div className="video-upload-preview">
<strong>{videoFile?.name || (videoReady ? "参考视频已就绪" : "点击上传参考视频")}</strong> <video src={videoPreview} controls playsInline preload="metadata" />
<small>{videoReady ? copy.videoReady : copy.videoEmpty}</small> <div className="video-upload-preview-meta">
</span> <strong>{videoFile?.name || filledSubjectName || "参考视频"}</strong>
</label> <small>
{videoUploading
? "正在上传参考视频…"
: `${formatClock(videoMeta.duration)} · ${ratioCopy(aspectRatio)} · ${copy.videoReady}`}
</small>
</div>
<button
type="button"
className="video-upload-change"
disabled={generating || videoUploading}
onClick={() => videoInputRef.current?.click()}
>
<RefreshCw />
</button>
</div>
) : (
<button
type="button"
className="video-upload-trigger"
disabled={generating}
onClick={() => videoInputRef.current?.click()}
>
<FileVideo2 />
<strong>{videoDrop.dragging ? "松开即可上传" : "点击或拖拽上传参考视频"}</strong>
<small>{copy.videoEmpty}</small>
</button>
)}
</div>
</div> </div>
<div className="video-flow-step"> <div className="video-flow-step">
@@ -810,30 +963,34 @@ export function VideoReplacePage({
<div className="product-replace-options"> <div className="product-replace-options">
<button <button
type="button" type="button"
className={`replace-product-method${source === "library" ? " active" : ""}${libraryPreview ? " has-product-preview" : ""}`} className={`replace-product-method${source === "library" ? " active" : ""}${librarySelected ? " has-product-preview" : ""}`}
onClick={() => { onClick={() => {
if (replaceMode === "character") setPendingModelId(selectedModel?.id || ""); if (replaceMode === "character") setPendingModelId(selectedModel?.id || "");
else setPendingProductId(selectedProduct?.id || ""); else setPendingProductId(selectedProduct?.id || "");
setLibraryOpen(true); 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">
<span className="replace-product-method-icon"><LibraryBig /></span> {librarySelected && libraryPreview
? <img src={libraryPreview} alt="" aria-hidden="true" />
: <LibraryBig />}
</span>
<span className="replace-product-method-copy"> <span className="replace-product-method-copy">
<strong>{copy.libraryTitle}</strong> <strong>
<small> {librarySelected
{source === "library" && (replaceMode === "character" ? selectedModel : selectedProduct) ? (replaceMode === "character" ? selectedModel?.name : selectedProduct?.title)
? `已选择 · ${replaceMode === "character" ? selectedModel?.name : selectedProduct?.title}` : copy.libraryTitle}
: copy.libraryEmpty} </strong>
</small> <small>{librarySelected ? librarySelectionDetail(replaceMode, selectedProduct, selectedModel) || copy.libraryTitle : copy.libraryEmpty}</small>
</span> </span>
<ChevronRight /> <ChevronRight />
</button> </button>
<div <div
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempDisplay.length ? " has-images" : ""}`} className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempDisplay.length ? " has-images" : ""}${tempDrop.dragging ? " is-dragover" : ""}`}
role="button" role="button"
tabIndex={0} tabIndex={0}
{...tempDrop.dropProps}
onClick={() => tempInputRef.current?.click()} onClick={() => tempInputRef.current?.click()}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
@@ -846,7 +1003,13 @@ export function VideoReplacePage({
<span className="replace-product-method-icon"><Upload /></span> <span className="replace-product-method-icon"><Upload /></span>
<span className="replace-product-method-copy"> <span className="replace-product-method-copy">
<strong>{copy.temporaryTitle}</strong> <strong>{copy.temporaryTitle}</strong>
<small>{tempDisplay.length ? `已上传 ${tempDisplay.length}${copy.temporaryNoun}` : copy.temporaryEmpty}</small> <small>
{tempDrop.dragging
? "松开即可添加"
: tempDisplay.length
? `已上传 ${tempDisplay.length}${copy.temporaryNoun}`
: copy.temporaryEmpty}
</small>
</span> </span>
<ImagePlus /> <ImagePlus />
{tempDisplay.length ? ( {tempDisplay.length ? (
@@ -924,7 +1087,7 @@ export function VideoReplacePage({
<button <button
type="button" type="button"
className="primary-action" className="primary-action"
disabled={!videoReady || !productReady || generating} disabled={restoring || !videoReady || !productReady || generating}
onClick={() => void startGeneration()} onClick={() => void startGeneration()}
> >
<Replace /> <Replace />
@@ -934,6 +1097,13 @@ export function VideoReplacePage({
</section> </section>
<aside className={panelClass} aria-live="polite"> <aside className={panelClass} aria-live="polite">
<div className="replace-restoring-state" role="status" aria-live="polite">
<div className="replace-restoring-content">
<span className="replace-restoring-spinner" aria-hidden="true" />
<strong></strong>
<span></span>
</div>
</div>
<div className="video-result-placeholder"> <div className="video-result-placeholder">
<div> <div>
<div className="replace-placeholder-visual"> <div className="replace-placeholder-visual">
+2
View File
@@ -255,6 +255,8 @@ export type Product = {
purged_at?: string | null; purged_at?: string | null;
cover_asset?: string | null; cover_asset?: string | null;
cover_preview_url?: string; cover_preview_url?: string;
/** 商品三视图(白底多角度单张 16:9)。是 standalone 资产,不在 images 里。 */
triview_preview_url?: string;
images?: Array<{ id: string; asset: string; preview_url?: string; sort_order: number; is_primary: boolean }>; images?: Array<{ id: string; asset: string; preview_url?: string; sort_order: number; is_primary: boolean }>;
selling_points: Array<{ id: string; title: string; detail: string; sort_order: number }>; selling_points: Array<{ id: string; title: string; detail: string; sort_order: number }>;
created_at: string; created_at: string;
+130 -41
View File
@@ -358,11 +358,17 @@
.vr-page .remix-prompt-head-actions .remix-prompt-head-btn { .vr-page .remix-prompt-head-actions .remix-prompt-head-btn {
min-width: 0; min-width: 0;
height: 36px; height: 36px;
padding: 0 12px; padding: 0 14px;
font-size: 12px; font-size: 13px;
font-weight: 500; font-weight: 500;
} }
/* 「生成视频」是这一步的主动作,放按钮组最左边 */
.vr-page .remix-prompt-head-actions .primary-action.remix-prompt-head-btn {
order: -1;
font-weight: 600;
}
.vr-page .remix-prompt-head-actions .remix-prompt-head-btn svg { .vr-page .remix-prompt-head-actions .remix-prompt-head-btn svg {
width: 14px; width: 14px;
height: 14px; height: 14px;
@@ -408,11 +414,6 @@
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.08); box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.08);
} }
.vr-page .video-flow-actions.remix-prompt-actions {
justify-content: flex-end;
gap: 12px;
}
.vr-page .video-flow-actions.remix-analyze-actions { .vr-page .video-flow-actions.remix-analyze-actions {
justify-content: flex-end; justify-content: flex-end;
margin-top: auto; margin-top: auto;
@@ -481,11 +482,18 @@
} }
.vr-page .video-upload-field:hover, .vr-page .video-upload-field:hover,
.vr-page .video-upload-field.has-file { .vr-page .video-upload-field.has-file,
.vr-page .video-upload-field.is-dragover {
border-color: var(--klein); border-color: var(--klein);
background: rgba(0, 47, 167, 0.075); background: rgba(0, 47, 167, 0.075);
} }
/* 拖拽悬停:实线 + 略深底,和「已选好」区分开 */
.vr-page .video-upload-field.is-dragover {
border-style: solid;
background: rgba(0, 47, 167, 0.11);
}
.vr-page .video-upload-field > span { .vr-page .video-upload-field > span {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -689,6 +697,71 @@
line-height: 1.55; line-height: 1.55;
} }
/* 读取任务状态期间左侧整块不可操作:只锁提交按钮不够,用户照样能点/拖上传 */
.vr-page .video-remix-upload-panel.is-locked {
pointer-events: none;
opacity: 0.55;
filter: saturate(0.85);
transition: opacity 160ms ease;
}
/* 进页面先确认后端有没有在跑的任务,确认完之前不给看空表单也不让提交 */
.vr-page .remix-restoring-state { display: none; place-items: center; text-align: center; }
.vr-page .remix-information-panel.is-restoring .remix-restoring-state { display: grid; }
.vr-page .remix-information-panel.is-restoring .video-result-placeholder,
.vr-page .remix-information-panel.is-restoring .remix-generating-state,
.vr-page .remix-information-panel.is-restoring .video-analysis-result { display: none; }
.vr-page .remix-restoring-content {
display: grid;
justify-items: center;
gap: 10px;
width: min(280px, 100%);
}
.vr-page .remix-restoring-content strong { color: var(--text); font-size: 16px; font-weight: 600; }
.vr-page .remix-restoring-content span:not(.remix-restoring-spinner) {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.vr-page .remix-restoring-spinner {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid rgba(0, 47, 167, 0.16);
border-top-color: var(--klein);
animation: remix-restoring-spin 0.8s linear infinite;
}
@keyframes remix-restoring-spin { to { transform: rotate(360deg); } }
.vr-page .remix-generating-bar {
width: 210px;
height: 4px;
margin-top: 8px;
overflow: hidden;
border-radius: 999px;
background: rgba(16, 16, 18, 0.08);
}
.vr-page .remix-generating-bar span {
width: 44%;
height: 100%;
display: block;
border-radius: inherit;
background: var(--klein);
animation: remix-progress-slide 1.15s ease-in-out infinite;
}
@keyframes remix-progress-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(330%); }
}
@keyframes remix-frame-scan { @keyframes remix-frame-scan {
0% { transform: translateX(-130%); } 0% { transform: translateX(-130%); }
100% { transform: translateX(130%); } 100% { transform: translateX(130%); }
@@ -815,7 +888,8 @@
} }
.vr-page .remix-page .video-upload-field:hover, .vr-page .remix-page .video-upload-field:hover,
.vr-page .remix-page .video-upload-field:focus-within { .vr-page .remix-page .video-upload-field:focus-within,
.vr-page .remix-page .video-upload-field.is-dragover {
border-color: var(--klein); border-color: var(--klein);
background: #f0f5ff; background: #f0f5ff;
box-shadow: inset 0 0 0 1px rgba(0, 47, 167, 0.05); box-shadow: inset 0 0 0 1px rgba(0, 47, 167, 0.05);
@@ -841,56 +915,74 @@
font-size: 15px; font-size: 15px;
} }
/* 预览卡与「视频复刻」页保持同一套:缩略图 + 文件信息 + 更换按钮,不再整块铺黑底 */
.vr-page .remix-page .video-upload-field.has-preview { .vr-page .remix-page .video-upload-field.has-preview {
position: relative; min-height: 0;
display: block; padding: 12px;
padding: 0; place-items: stretch;
overflow: hidden; text-align: left;
border-style: solid;
cursor: default; cursor: default;
background: #0f1728;
border-color: rgba(0, 47, 167, 0.28);
box-shadow: none;
} }
.vr-page .remix-page .video-upload-field.has-preview:hover, .vr-page .video-upload-preview {
.vr-page .remix-page .video-upload-field.has-preview:focus-within { display: grid;
background: #0f1728; grid-template-columns: minmax(0, 132px) minmax(0, 1fr) auto;
border-color: rgba(0, 47, 167, 0.28); align-items: center;
box-shadow: none; gap: 14px;
width: 100%;
min-width: 0;
} }
.vr-page .remix-page .video-upload-field.has-preview video { .vr-page .remix-page .video-upload-field.has-preview video {
width: 100%; width: 100%;
height: 196px; height: auto;
aspect-ratio: 9 / 16;
max-height: 168px;
display: block; display: block;
border-radius: 8px;
object-fit: contain; object-fit: contain;
background: #0f1728; background: #0f1728;
} }
.vr-page .remix-replace-video { .vr-page .video-upload-preview-meta {
position: absolute; display: grid;
top: 10px; gap: 4px;
right: 10px; min-width: 0;
z-index: 1; }
padding: 7px 10px;
border: 1px solid rgba(255, 255, 255, 0.28); .vr-page .video-upload-change {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 13px;
border: 1px solid rgba(0, 47, 167, 0.3);
border-radius: 8px; border-radius: 8px;
color: #fff; background: transparent;
background: rgba(13, 19, 31, 0.72); color: var(--klein);
font-size: 11px; font-size: 13px;
font-weight: 600; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background 160ms ease, border-color 160ms ease;
} }
.vr-page .remix-replace-video:hover { .vr-page .video-upload-change:hover {
background: rgba(0, 47, 167, 0.92); border-color: var(--klein);
background: rgba(0, 47, 167, 0.075);
} }
.vr-page .remix-replace-video.is-disabled { .vr-page .video-upload-change.is-disabled {
pointer-events: none; pointer-events: none;
opacity: 0.45; opacity: 0.45;
} }
.vr-page .video-upload-change svg { width: 15px; height: 15px; }
@media (max-width: 720px) {
.vr-page .video-upload-preview { grid-template-columns: minmax(0, 96px) minmax(0, 1fr); }
.vr-page .video-upload-change { grid-column: 1 / -1; justify-content: center; }
}
.vr-page .remix-page .video-upload-field strong { .vr-page .remix-page .video-upload-field strong {
color: #1d2940; color: #1d2940;
font-size: 15px; font-size: 15px;
@@ -903,7 +995,8 @@
.vr-page .remix-page .remix-information-panel .video-result-placeholder, .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 { .vr-page .remix-page .remix-information-panel .remix-generating-state,
.vr-page .remix-page .remix-information-panel .remix-restoring-state {
flex: 1; flex: 1;
} }
@@ -935,10 +1028,6 @@
gap: 9px; gap: 9px;
} }
.vr-page .remix-page .remix-prompt-actions {
padding-top: 2px;
}
@keyframes remixReveal { @keyframes remixReveal {
from { from {
opacity: 0; opacity: 0;
+152 -40
View File
@@ -213,24 +213,104 @@
color: #42506a; color: #42506a;
background: rgba(0, 47, 167, 0.04); background: rgba(0, 47, 167, 0.04);
text-align: center; text-align: center;
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease; transition: border-color 160ms ease, background 160ms ease;
} }
.vrep-page .video-upload-field:hover, .vrep-page .video-upload-field:hover,
.vrep-page .video-upload-field.has-file { .vrep-page .video-upload-field.has-file,
.vrep-page .video-upload-field.is-dragover {
border-color: var(--klein); border-color: var(--klein);
background: rgba(0, 47, 167, 0.075); background: rgba(0, 47, 167, 0.075);
} }
.vrep-page .video-upload-field > span { /* 拖拽悬停:实线 + 略深底,和「已选好」区分开 */
.vrep-page .video-upload-field.is-dragover {
border-style: solid;
background: rgba(0, 47, 167, 0.11);
}
/* 已有预览时容器让位给视频本身 */
.vrep-page .video-upload-field.has-preview {
min-height: 0;
padding: 12px;
border-style: solid;
place-items: stretch;
text-align: left;
}
.vrep-page .video-upload-trigger {
display: grid; display: grid;
place-items: center; place-items: center;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
width: 100%;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: center;
cursor: pointer;
} }
.vrep-page .video-upload-field svg { .vrep-page .video-upload-trigger:disabled { cursor: default; opacity: .6; }
.vrep-page .video-upload-preview {
display: grid;
grid-template-columns: minmax(0, 132px) minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
width: 100%;
min-width: 0;
}
.vrep-page .video-upload-preview video {
width: 100%;
aspect-ratio: 9 / 16;
max-height: 168px;
border-radius: 8px;
background: #0f1728;
object-fit: contain;
}
.vrep-page .video-upload-preview-meta {
display: grid;
gap: 4px;
min-width: 0;
}
.vrep-page .video-upload-change {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 13px;
border: 1px solid rgba(0, 47, 167, 0.3);
border-radius: 8px;
background: transparent;
color: var(--klein);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease;
}
.vrep-page .video-upload-change:hover:not(:disabled) {
border-color: var(--klein);
background: rgba(0, 47, 167, 0.075);
}
.vrep-page .video-upload-change:disabled { opacity: .5; cursor: default; }
.vrep-page .video-upload-change svg { width: 15px; height: 15px; }
@media (max-width: 720px) {
.vrep-page .video-upload-preview {
grid-template-columns: minmax(0, 96px) minmax(0, 1fr);
}
.vrep-page .video-upload-change { grid-column: 1 / -1; justify-content: center; }
}
/* 只给空态大图标撑到 28px:更换按钮里的小图标不能被这条盖掉 */
.vrep-page .video-upload-trigger > svg {
width: 28px; width: 28px;
height: 28px; height: 28px;
color: var(--klein); color: var(--klein);
@@ -251,6 +331,63 @@
font-size: 12px; font-size: 12px;
} }
/* 临时上传商品图:拖拽悬停沿用「已选中」的蓝框,再压一层底色区分 */
.vrep-page .replace-temporary-method.is-dragover {
border-color: var(--klein);
background: rgba(0, 47, 167, 0.11);
}
/* 进页面先确认后端有没有在跑的任务确认完之前不给看空表单,也不让提交
否则用户以为没任务,又传一遍,重复扣费 */
/* 面板是 flex column,不给 flex:1 + min-height 就会顶在最上面、下面留一大块空 */
.vrep-page .replace-restoring-state {
min-height: 470px;
flex: 1;
display: none;
place-items: center;
text-align: center;
}
/* 读取任务状态期间左侧整块不可操作:只锁提交按钮不够,用户照样能点/拖上传 */
.vrep-page .replace-flow-panel.is-locked,
.vrep-page .video-flow-panel.is-locked {
pointer-events: none;
opacity: 0.55;
filter: saturate(0.85);
transition: opacity 160ms ease;
}
.vrep-page .replace-result-panel.is-restoring .replace-restoring-state { display: grid; }
.vrep-page .replace-result-panel.is-restoring .video-result-placeholder,
.vrep-page .replace-result-panel.is-restoring .replace-generating-state,
.vrep-page .replace-result-panel.is-restoring .video-analysis-result { display: none; }
.vrep-page .replace-restoring-content {
display: grid;
justify-items: center;
gap: 10px;
width: min(280px, 100%);
}
.vrep-page .replace-restoring-content strong { color: var(--text); font-size: 16px; font-weight: 600; }
.vrep-page .replace-restoring-content span:not(.replace-restoring-spinner) {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.vrep-page .replace-restoring-spinner {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid rgba(0, 47, 167, 0.16);
border-top-color: var(--klein);
animation: replace-restoring-spin 0.8s linear infinite;
}
@keyframes replace-restoring-spin { to { transform: rotate(360deg); } }
.vrep-page .video-flow-actions { .vrep-page .video-flow-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -347,48 +484,23 @@
transition: border-color 170ms ease, background-color 170ms ease, box-shadow 170ms ease, transform 170ms ease; transition: border-color 170ms ease, background-color 170ms ease, box-shadow 170ms ease, transform 170ms ease;
} }
.vrep-page .replace-product-method::after { /* 选中商品后不再整块铺商品图:图一花文字就读不了,active 的蓝底也被盖没
content: ""; 改成图标位直接放缩略图,文字保持全对比度 */
position: absolute; .vrep-page .replace-product-method-background { display: none; }
inset: 0;
z-index: 1; .vrep-page .replace-product-method.has-product-preview .replace-product-method-icon {
opacity: 0; padding: 0;
pointer-events: none; overflow: hidden;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0.08)); border-color: rgba(0, 47, 167, 0.22);
transition: opacity 180ms ease; background: #f1f5ff;
} }
.vrep-page .replace-product-method.has-product-preview::after { .vrep-page .replace-product-method-icon img {
opacity: 1;
}
.vrep-page .replace-product-method > :not(.replace-product-method-background) {
position: relative;
z-index: 2;
}
.vrep-page .replace-product-method-background {
position: absolute;
inset: 0;
z-index: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
object-fit: cover; object-fit: cover;
opacity: 0; border-radius: inherit;
pointer-events: none;
transition: opacity 180ms ease;
}
.vrep-page .replace-product-method.has-product-preview .replace-product-method-background {
opacity: 0.5;
}
.vrep-page .replace-product-method.has-product-preview .replace-product-method-copy {
padding: 8px 10px;
border-radius: 9px;
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(5px);
} }
.vrep-page .replace-product-method:hover { .vrep-page .replace-product-method:hover {