完成极速成品和脚本优化
This commit is contained in:
@@ -52,6 +52,8 @@ VIDEO_STRUCTURES: dict[str, str] = {
|
||||
"contrast": "前后对比",
|
||||
"review": "测评验证",
|
||||
"scene": "场景种草",
|
||||
"promo": "促销抢购",
|
||||
"knowledge": "知识分享",
|
||||
}
|
||||
DEFAULT_PRESENTATION_FORMAT = "oral"
|
||||
DEFAULT_VIDEO_STRUCTURE = "pain"
|
||||
@@ -62,7 +64,9 @@ FORBIDDEN_COMBOS: set[tuple[str, str]] = {("drama", "review")}
|
||||
# 表现形式推荐的默认总时长:口播短平快,短剧要装下三幕,Vlog 要铺氛围。
|
||||
FORMAT_DEFAULT_DURATION: dict[str, int] = {"oral": 30, "drama": 45, "vlog": 30}
|
||||
# 各结构能压到的最短总时长(须落在 15 秒步进上),见 playbooks/combo-matrix.md。
|
||||
STRUCTURE_MIN_DURATION: dict[str, int] = {"pain": 15, "contrast": 15, "review": 30, "scene": 30}
|
||||
STRUCTURE_MIN_DURATION: dict[str, int] = {
|
||||
"pain": 15, "contrast": 15, "review": 30, "scene": 30, "promo": 15, "knowledge": 30,
|
||||
}
|
||||
|
||||
# 设定卡人物 key → 中文(与前端 WIZ_PERSONA_LABEL / 模板 coerce_persona 对齐)。
|
||||
PERSONA_LABELS: dict[str, str] = {
|
||||
@@ -83,11 +87,11 @@ PERSONA_BRIEFS: dict[str, str] = {
|
||||
}
|
||||
_PERSONA_KEY_BY_LABEL = {label: key for key, label in PERSONA_LABELS.items()}
|
||||
|
||||
# 成片有同期画面与自然停顿,3.5 字/秒会让 15 秒口播在约 10 秒时说完。
|
||||
# 电商真人口播以 3.6–4.5 字/秒为可用区间:既留出换气,也不会让画面空转。
|
||||
NARRATION_CHARS_PER_SECOND = 4.5
|
||||
NARRATION_CHARS_PER_SECOND_MIN = 3.6
|
||||
NARRATION_CHARS_HARD_CAP = 68
|
||||
# 电商口播需要同时承载观点、证据和转折;15 秒以 4.3–5.2 字/秒为可用区间。
|
||||
# 这比日常闲聊快,但仍给句间停顿和镜头切换留出空间。
|
||||
NARRATION_CHARS_PER_SECOND = 5.2
|
||||
NARRATION_CHARS_PER_SECOND_MIN = 4.3
|
||||
NARRATION_CHARS_HARD_CAP = 78
|
||||
VISUAL_CHARS_MIN = 72
|
||||
SHOT_BEATS_MIN = 3
|
||||
BEAT_SPAN_RE = re.compile(
|
||||
@@ -161,7 +165,7 @@ def coerce_combo(fmt: str | None, structure: str | None) -> tuple[str, str]:
|
||||
|
||||
|
||||
def narration_limit(duration: int) -> int:
|
||||
"""这一镜旁白的字数上限:秒数 × 4.5,且不超过硬上限 68。"""
|
||||
"""这一镜旁白的字数上限:秒数 × 5.2,且不超过硬上限 78。"""
|
||||
return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND)))
|
||||
|
||||
|
||||
@@ -342,14 +346,15 @@ _CREATIVE_DIRECTION = """
|
||||
每个 segment 的 `visual` 采用下面的层级;这不是给用户看的散文,而是会原样交给故事板和视频模型的执行指令:
|
||||
```
|
||||
【本镜任务】这一段要让观众看懂的变化或悬念。
|
||||
【声音】只写可发生的同期声 / 人声状态;没有配乐就明确写「无配乐,仅同期声」。
|
||||
【声音】台词/旁白状态;音效;背景音乐;字幕。没有就写「无」;没有配乐写「无配乐,仅同期声」。
|
||||
【画面内容】
|
||||
0-3s:景别;构图;运镜;人物/商品动作;情绪或信息变化。
|
||||
3-8s:景别;构图;运镜;手与商品的空间关系;可见细节。
|
||||
8-12s:景别;构图;运镜;动作结果或卖点证据。
|
||||
12-15s:景别;构图;运镜;反应/悬念/自然收束。
|
||||
0-3s:景别;机位;运镜;人物/商品动作;情绪或信息变化。
|
||||
3-8s:景别;机位;运镜;手与商品的空间关系;可见细节。
|
||||
8-12s:景别;机位;运镜;动作结果或卖点证据。
|
||||
12-15s:景别;机位;运镜;反应/悬念/自然收束。
|
||||
```
|
||||
每条秒级分镜都要明确写出 **景别、构图、运镜、动作、信息变化** 五项中的至少四项。
|
||||
每条秒级分镜都要明确写出 **景别、机位、运镜、动作、信息变化** 五项中的至少四项。
|
||||
输入如果是 `【镜头 01】` 导演分镜稿,把原稿的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进对应栏,不要压成一句画面摘要。
|
||||
同一角色、商品、场景的外观一律引用 entities 里的既定设定,不在每一镜随意换发型、服装、包装、光线或地点。
|
||||
"""
|
||||
|
||||
@@ -492,7 +497,7 @@ def build_agent_messages(
|
||||
beats_line = (
|
||||
f"【秒级分镜】每个 {SEGMENT_DURATION_MAX} 秒场必须拆成 3–5 个分镜,visual 必须按「导演说明书」写成多行:"
|
||||
"先写【本镜任务】、【声音】、【画面内容】,再写秒级分镜;"
|
||||
f"每条格式`0-3s:景别;构图;运镜;谁在做什么;信息变化`,最后一条接到 {SEGMENT_DURATION_MAX}s。"
|
||||
f"每条格式`0-3s:景别;机位;运镜;谁在做什么;信息变化`,最后一条接到 {SEGMENT_DURATION_MAX}s。"
|
||||
"每条写清手/商品/容器的空间关系和真实用法"
|
||||
"(茶=热水+蒸汽+茶汤变色,禁止茶包丢进冷白开;手从真实方向入画,禁止悬浮肢体)。"
|
||||
"禁止一句空画面撑满 15 秒。允许另给 beats 数组,后端会折进 visual。\n"
|
||||
@@ -519,6 +524,18 @@ def build_agent_messages(
|
||||
"【场景种草骨架】按镜走完:场景建立(商品先不出现)→商品被使用着入场→场景因它更好/软CTA。"
|
||||
"每镜写出具体时刻+地点+光/声音/物件。口播时每镜仍要说满字数,留白靠停顿不是靠少写字。\n"
|
||||
)
|
||||
elif structure == "promo":
|
||||
structure_line = (
|
||||
"【促销抢购合规】只可使用商品资料中明确给出的价格、优惠、库存、赠品和活动时间;"
|
||||
"没有明确数据时,绝不写原价、最低价、限时、限量、领券或赠品。"
|
||||
"结尾只给一个清楚的购买入口和操作动作,不制造虚假的紧迫感。\n"
|
||||
)
|
||||
elif structure == "knowledge":
|
||||
structure_line = (
|
||||
"【知识分享骨架】先提出一个与商品相关的选购/使用误区或判断方法,再用实物与步骤证明,"
|
||||
"最后把商品作为符合该判断的具体实例。只能讲商品资料可支持的事实,"
|
||||
"禁止编造专业身份、数据、标准或功效承诺。\n"
|
||||
)
|
||||
combo_line = (
|
||||
f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写,"
|
||||
f"不要串成别的结构的套话。\n"
|
||||
@@ -562,6 +579,21 @@ def build_agent_messages(
|
||||
)
|
||||
else:
|
||||
extra_block = f"\n\n【补充要求】{extra}\n" if extra else "\n"
|
||||
if extra and _looks_like_shot_digest(extra):
|
||||
user = (
|
||||
"【任务】参考视频改写:用户给了一份逐镜拆解稿。"
|
||||
"照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,"
|
||||
"把人物、商品、品牌、台词全部换成当前商品。"
|
||||
"写 visual 时把拆解稿里的景别/机位/运镜/人物动作/表情/音效/背景音乐/字幕/备注"
|
||||
"折进【声音】和【画面内容】的秒级分镜,不要压成一句画面摘要。"
|
||||
"原稿写「无 / 不可见 / 听不清」的栏不要编造。"
|
||||
"钩子、痛点、卖点必须能对上这份商品。"
|
||||
"每镜旁白要能撑满指定时长;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
|
||||
f"{head}"
|
||||
f"{extra_block}\n"
|
||||
"请按技能流程一次性产出 ScriptDraft。"
|
||||
)
|
||||
else:
|
||||
user = (
|
||||
"【任务】全自动(模式①):仅凭上面的商品事实与前置条件,按指定的表现形式与视频结构套路"
|
||||
"自动定镜/选 tone/造 entity/填结构骨架。"
|
||||
@@ -607,6 +639,14 @@ def _balanced_object(text: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_shot_digest(text: str) -> bool:
|
||||
"""用户贴进来的是视频提炼分镜稿,不是一句补充要求。"""
|
||||
blob = text or ""
|
||||
has_shot = "【镜头" in blob or ("【第" in blob and "镜】" in blob)
|
||||
has_picture = "画面:" in blob or "画面:" in blob
|
||||
return has_shot and has_picture
|
||||
|
||||
|
||||
def _looks_like_draft(blob: str) -> bool:
|
||||
try:
|
||||
d = json.loads(blob)
|
||||
|
||||
@@ -3225,7 +3225,30 @@ def collect_video_review_blockers(project, only_segment: "VideoSegment | None" =
|
||||
return blockers
|
||||
|
||||
|
||||
def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> VideoSegmentVersion | None:
|
||||
def submit_video_segment(
|
||||
*,
|
||||
video_segment: VideoSegment,
|
||||
user,
|
||||
prompt: str,
|
||||
model_config_id=None,
|
||||
aspect_ratio: str = "9:16",
|
||||
resolution: str = "720p",
|
||||
) -> VideoSegmentVersion | None:
|
||||
model_config = None
|
||||
if model_config_id:
|
||||
model_config = (
|
||||
ModelConfig.objects.select_related("provider")
|
||||
.filter(
|
||||
id=model_config_id,
|
||||
capability=ModelConfig.Capability.VIDEO,
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
provider__status="active",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model_config is None:
|
||||
raise ValueError("selected video model is unavailable")
|
||||
else:
|
||||
model_config = get_default_model(ModelConfig.Capability.VIDEO)
|
||||
if model_config is None:
|
||||
raise ValueError("no active video model configured")
|
||||
@@ -3244,15 +3267,15 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
|
||||
reference_images = [r["url"] for r in refs]
|
||||
final_prompt = build_video_segment_prompt(project, video_segment, scene, refs, prompt)
|
||||
|
||||
# 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按 9:16/720p/目标时长预估,
|
||||
# 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按用户选定的比例/清晰度/目标时长预估,
|
||||
# 预留=积分×buffer,终态按火山真实 usage.total_tokens 结算(poll_video_segment true-up)。
|
||||
# 这里终结了「视频 ¥1/段、成本 ¥15」的倒贴定价。
|
||||
from apps.billing.pricing import quote_video_estimate, video_reserve_amount
|
||||
|
||||
est_tokens, quote = quote_video_estimate(
|
||||
model_config,
|
||||
aspect_ratio="9:16",
|
||||
resolution="720p",
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
duration=video_segment.target_duration_seconds,
|
||||
references=[],
|
||||
team=project.team,
|
||||
@@ -3269,8 +3292,8 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
|
||||
"endpoint": model_config.endpoint,
|
||||
"prompt": final_prompt,
|
||||
"duration": video_segment.target_duration_seconds,
|
||||
"ratio": "9:16",
|
||||
"resolution": "720p",
|
||||
"ratio": aspect_ratio,
|
||||
"resolution": resolution,
|
||||
"estimated_tokens": est_tokens,
|
||||
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律)
|
||||
"price_multiplier": quote.meta.get("price_multiplier", "1"),
|
||||
@@ -3288,8 +3311,8 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
|
||||
primary_model=model_config,
|
||||
prompt=final_prompt,
|
||||
duration=video_segment.target_duration_seconds,
|
||||
ratio="9:16",
|
||||
resolution="720p",
|
||||
ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
reference_images=reference_images,
|
||||
request_summary={"video_segment_id": str(video_segment.id)},
|
||||
)
|
||||
|
||||
@@ -249,6 +249,29 @@ class PromptAssemblyTests(SimpleTestCase):
|
||||
self.assertIn("禁止一句 20 字收工", user)
|
||||
self.assertIn("秒级分镜", user)
|
||||
|
||||
def test_video_digest_prompt_maps_camera_and_sound(self):
|
||||
digest = (
|
||||
"片名:《雨夜归家》\n视频类型:剧情短片\n"
|
||||
"【镜头 01】\n时间:00:00-00:04\n景别:城市远景\n运镜:缓慢向前推进\n"
|
||||
"画面:深夜街道被雨水覆盖。\n台词/旁白:无\n音效:雨声\n"
|
||||
)
|
||||
messages = build_agent_messages(
|
||||
project=self._project(),
|
||||
mode="auto",
|
||||
user_prompt=digest,
|
||||
selling_point_ids=["1"],
|
||||
base_draft=None,
|
||||
aspect_ratio="9:16",
|
||||
total_duration=15,
|
||||
persona="urban",
|
||||
)
|
||||
user = messages[1]["content"]
|
||||
self.assertIn("参考视频改写", user)
|
||||
self.assertNotIn("全自动", user)
|
||||
self.assertIn("景别/机位/运镜", user)
|
||||
self.assertIn("音效/背景音乐", user)
|
||||
self.assertIn("【镜头 01】", user)
|
||||
|
||||
def test_scene_oral_prompt_locks_skeleton_and_density(self):
|
||||
messages = build_agent_messages(
|
||||
project=self._project(),
|
||||
@@ -284,15 +307,15 @@ class PromptAssemblyTests(SimpleTestCase):
|
||||
|
||||
class NarrationLimitTests(SimpleTestCase):
|
||||
def test_limit_scales_with_shot_length(self):
|
||||
self.assertEqual(narration_limit(4), 18)
|
||||
self.assertEqual(narration_limit(8), 36)
|
||||
self.assertEqual(narration_limit(15), 67)
|
||||
self.assertEqual(narration_limit(4), 20)
|
||||
self.assertEqual(narration_limit(8), 41)
|
||||
self.assertEqual(narration_limit(15), 78)
|
||||
|
||||
def test_never_exceeds_hard_cap(self):
|
||||
self.assertLessEqual(narration_limit(60), 68)
|
||||
self.assertLessEqual(narration_limit(60), 78)
|
||||
|
||||
def test_fifteen_second_floor_is_about_fifty_four(self):
|
||||
self.assertEqual(narration_floor(15), 54)
|
||||
def test_fifteen_second_floor_is_about_sixty_four(self):
|
||||
self.assertEqual(narration_floor(15), 64)
|
||||
self.assertLess(narration_floor(15), narration_limit(15))
|
||||
|
||||
|
||||
@@ -408,7 +431,7 @@ class ComboTests(SimpleTestCase):
|
||||
def test_drama_cannot_pick_review(self):
|
||||
# 演出来的实测没有可信度 —— 这是唯一的禁用组合
|
||||
self.assertNotIn("review", allowed_structures("drama"))
|
||||
self.assertEqual(len(allowed_structures("drama")), 3)
|
||||
self.assertEqual(len(allowed_structures("drama")), 5)
|
||||
|
||||
def test_other_formats_allow_everything(self):
|
||||
for fmt in ("oral", "vlog"):
|
||||
|
||||
@@ -10,7 +10,7 @@ import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
@@ -36,6 +36,14 @@ from apps.ai.video_digest import (
|
||||
)
|
||||
|
||||
|
||||
def _shot_digest(shots=3, *, heading="lens"):
|
||||
lines = ["片名:《示例》\n视频类型:口播带货\n时长:15秒\n整体风格:写实\n"]
|
||||
for index in range(1, shots + 1):
|
||||
mark = f"【镜头 {index:02d}】" if heading == "lens" else f"【第 {index} 镜】"
|
||||
lines.append(f"{mark}\n时间:00:00-00:04\n画面:一位女生拿着商品\n台词/旁白:无\n")
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
class FramePlanTests(SimpleTestCase):
|
||||
def test_frame_count_scales_with_duration_within_bounds(self):
|
||||
for duration, expected in [(3, MIN_FRAMES), (10, MIN_FRAMES), (30, 15), (60, 30), (180, MAX_FRAMES)]:
|
||||
@@ -98,12 +106,15 @@ class MessageBuildTests(SimpleTestCase):
|
||||
def test_system_prompt_is_the_digest_skill(self):
|
||||
messages = build_digest_messages(self.frames, 10)
|
||||
self.assertEqual(messages[0]["role"], "system")
|
||||
self.assertIn("解说词", messages[0]["content"])
|
||||
self.assertIn("台词/旁白", messages[0]["content"])
|
||||
self.assertIn("覆盖全片", messages[0]["content"])
|
||||
self.assertIn("【镜头 01】", messages[0]["content"])
|
||||
|
||||
def test_user_prompt_asks_for_full_timeline(self):
|
||||
content = build_digest_messages(self.frames, 60)[1]["content"]
|
||||
self.assertIn("全片", content[0]["text"])
|
||||
self.assertIn("景别", content[0]["text"])
|
||||
self.assertIn("背景音乐", content[0]["text"])
|
||||
|
||||
def test_every_frame_is_inlined_with_its_timestamp(self):
|
||||
content = build_digest_messages(self.frames, 10)[1]["content"]
|
||||
@@ -121,17 +132,19 @@ class MessageBuildTests(SimpleTestCase):
|
||||
self.assertIn("蓝牙耳机 · 数码3C", content[0]["text"])
|
||||
|
||||
def test_skill_loads_from_disk(self):
|
||||
self.assertIn("解说词", load_digest_skill())
|
||||
self.assertIn("台词/旁白", load_digest_skill())
|
||||
self.assertIn("景别", load_digest_skill())
|
||||
|
||||
|
||||
class NativeVideoInputTests(SimpleTestCase):
|
||||
def test_short_clip_is_sent_as_whole_video(self):
|
||||
video, frames, duration = digest_input_from_upload(_synth_clip(seconds=6))
|
||||
video, frames, duration, extras = digest_input_from_upload(_synth_clip(seconds=6))
|
||||
self.assertIsNotNone(video)
|
||||
self.assertEqual(frames, [])
|
||||
self.assertTrue(video.mime.startswith("video/"))
|
||||
self.assertGreater(len(video.data), 100)
|
||||
self.assertAlmostEqual(duration, 6, delta=1)
|
||||
self.assertIn("cover_jpeg", extras)
|
||||
|
||||
def test_native_video_message_inlines_video_data_uri(self):
|
||||
video = DigestVideo(mime="video/mp4", data=b"\x00\x00fake")
|
||||
@@ -140,6 +153,8 @@ class NativeVideoInputTests(SimpleTestCase):
|
||||
self.assertEqual(len(media), 1)
|
||||
self.assertTrue(media[0]["image_url"]["url"].startswith("data:video/mp4;base64,"))
|
||||
self.assertIn("口播", content[0]["text"])
|
||||
self.assertIn("景别", content[0]["text"])
|
||||
self.assertIn("背景音乐", content[0]["text"])
|
||||
|
||||
|
||||
class DigestValidationTests(SimpleTestCase):
|
||||
@@ -153,11 +168,15 @@ class DigestValidationTests(SimpleTestCase):
|
||||
validate_digest_text("这是一条很好的带货视频,节奏明快,画面精美。" * 5)
|
||||
|
||||
def test_accepts_well_formed_digest(self):
|
||||
good = _shot_digest()
|
||||
self.assertEqual(validate_digest_text(f" {good} "), good.strip())
|
||||
|
||||
def test_accepts_legacy_shot_heading(self):
|
||||
good = "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3
|
||||
self.assertEqual(validate_digest_text(f" {good} "), good.strip())
|
||||
|
||||
def test_rejects_too_few_shots_for_a_long_clip(self):
|
||||
stub = "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3
|
||||
stub = _shot_digest(3)
|
||||
with self.assertRaises(ValueError):
|
||||
validate_digest_text(stub, duration=60, frame_count=30)
|
||||
|
||||
@@ -324,7 +343,142 @@ class VideoDigestApiTests(TestCase):
|
||||
self.assertTrue(response.data["detail"])
|
||||
self.assertEqual(response.data["error"]["operation"], "video_digest")
|
||||
|
||||
def _seed_model(self):
|
||||
from decimal import Decimal
|
||||
|
||||
provider, _ = ModelProvider.objects.get_or_create(
|
||||
name="remix-hist-relay",
|
||||
defaults={"display_name": "Gemini 官转", "status": ModelProvider.Status.ACTIVE},
|
||||
)
|
||||
return ModelConfig.objects.create(
|
||||
provider=provider,
|
||||
name=DIGEST_VISION_MODEL_NAME,
|
||||
display_name="Gemini 3.1 Pro 官转",
|
||||
capability=ModelConfig.Capability.TEXT,
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
unit_price=Decimal("10"),
|
||||
)
|
||||
|
||||
def _seed_digest_task(self, *, team=None, status=None, is_deleted=False, prompt="示例提示词", extra_key=""):
|
||||
from apps.ai.models import AITask
|
||||
|
||||
model = getattr(self, "_hist_model", None) or self._seed_model()
|
||||
self._hist_model = model
|
||||
task = AITask.objects.create(
|
||||
team=team or self.team,
|
||||
created_by=self.user,
|
||||
project=None,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
status=status or AITask.Status.SUCCEEDED,
|
||||
model_config=model,
|
||||
idempotency_key=f"digest-hist-{team.id if team else self.team.id}-{extra_key or AITask.objects.count()}",
|
||||
request_payload={
|
||||
"feature": "video_remix",
|
||||
"file_name": "参考视频.mp4",
|
||||
"title": "轻醒咖啡场景种草视频",
|
||||
"duration_seconds": 15,
|
||||
"ratio": "9:16 竖屏",
|
||||
"shot_count": 5,
|
||||
"video_url": "https://cdn.example/coffee.mp4",
|
||||
"cover_url": "https://cdn.example/coffee.jpg",
|
||||
},
|
||||
response_payload={"digest": prompt, "prompt": prompt},
|
||||
is_deleted=is_deleted,
|
||||
)
|
||||
return task
|
||||
|
||||
def test_history_empty_by_default(self):
|
||||
response = self.client.get("/api/ai/video-digest/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["results"], [])
|
||||
self.assertEqual(response.data["total"], 0)
|
||||
|
||||
def test_history_lists_succeeded_team_digests(self):
|
||||
from apps.ai.models import AITask
|
||||
|
||||
self._seed_digest_task(prompt="咖啡提示词", extra_key="ok")
|
||||
self._seed_digest_task(status=AITask.Status.FAILED, extra_key="fail")
|
||||
self._seed_digest_task(is_deleted=True, extra_key="del")
|
||||
other_user = User.objects.create_user(username="remix-other", password="p")
|
||||
other_team = Team.objects.create(name="Other", owner=other_user)
|
||||
TeamMember.objects.create(team=other_team, user=other_user, role=TeamMember.Role.OWNER)
|
||||
self._seed_digest_task(team=other_team, extra_key="other")
|
||||
|
||||
response = self.client.get("/api/ai/video-digest/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["total"], 1)
|
||||
item = response.data["results"][0]
|
||||
self.assertEqual(item["title"], "轻醒咖啡场景种草视频")
|
||||
self.assertEqual(item["status"], "已完成")
|
||||
self.assertEqual(item["duration_label"], "00:15")
|
||||
self.assertEqual(item["ratio"], "9:16 竖屏")
|
||||
self.assertEqual(item["shots"], 5)
|
||||
self.assertEqual(item["file_name"], "参考视频.mp4")
|
||||
self.assertEqual(item["prompt"], "咖啡提示词")
|
||||
self.assertEqual(item["video_url"], "https://cdn.example/coffee.mp4")
|
||||
self.assertEqual(item["cover_url"], "https://cdn.example/coffee.jpg")
|
||||
self.assertRegex(item["created_date"], r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
@patch("apps.assets.storage.TosStorage")
|
||||
def test_history_signs_video_url_from_object_key(self, storage_cls):
|
||||
storage = storage_cls.return_value
|
||||
storage.presigned_get_url.return_value = "https://signed.example/play.mp4"
|
||||
storage.public_url.return_value = "https://cdn.example/cover.jpg"
|
||||
task = self._seed_digest_task(extra_key="signed")
|
||||
task.request_payload = {
|
||||
**task.request_payload,
|
||||
"video_url": "",
|
||||
"video_key": "teams/1/video-digest/abc.mp4",
|
||||
"cover_url": "",
|
||||
"cover_key": "teams/1/video-digest/abc.jpg",
|
||||
}
|
||||
task.save(update_fields=["request_payload"])
|
||||
|
||||
response = self.client.get("/api/ai/video-digest/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
item = response.data["results"][0]
|
||||
self.assertEqual(item["video_url"], "https://signed.example/play.mp4")
|
||||
self.assertEqual(item["cover_url"], "https://cdn.example/cover.jpg")
|
||||
storage.presigned_get_url.assert_called()
|
||||
|
||||
def test_history_without_saved_source_has_empty_video_url(self):
|
||||
task = self._seed_digest_task(extra_key="nosource")
|
||||
payload = dict(task.request_payload)
|
||||
payload.pop("video_url", None)
|
||||
payload.pop("video_key", None)
|
||||
task.request_payload = payload
|
||||
task.save(update_fields=["request_payload"])
|
||||
|
||||
response = self.client.get("/api/ai/video-digest/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["results"][0]["video_url"], "")
|
||||
|
||||
def test_save_prompt_updates_history(self):
|
||||
task = self._seed_digest_task(prompt="旧提示词", extra_key="save")
|
||||
response = self.client.patch(
|
||||
f"/api/ai/video-digest/{task.id}/",
|
||||
{"prompt": "新提示词"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["prompt"], "新提示词")
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.response_payload["prompt"], "新提示词")
|
||||
self.assertEqual(task.response_payload["digest"], "旧提示词")
|
||||
|
||||
def test_save_prompt_rejects_empty_and_missing(self):
|
||||
task = self._seed_digest_task(extra_key="empty")
|
||||
empty = self.client.patch(f"/api/ai/video-digest/{task.id}/", {"prompt": " "}, format="json")
|
||||
self.assertEqual(empty.status_code, 400)
|
||||
missing = self.client.patch(
|
||||
"/api/ai/video-digest/00000000-0000-0000-0000-000000000099/",
|
||||
{"prompt": "还在"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(missing.status_code, 404)
|
||||
|
||||
|
||||
@override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}})
|
||||
class VideoDigestBillingTests(TestCase):
|
||||
"""提炼提示词按功能价 30 积分结算,不跟 Gemini 文本单价。"""
|
||||
|
||||
@@ -362,7 +516,7 @@ class VideoDigestBillingTests(TestCase):
|
||||
with (
|
||||
patch(
|
||||
"apps.ai.video_digest.digest_input_from_upload",
|
||||
return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0),
|
||||
return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0, {}),
|
||||
),
|
||||
patch(
|
||||
"apps.ai.services.execute_routed_text_request",
|
||||
@@ -380,6 +534,13 @@ class VideoDigestBillingTests(TestCase):
|
||||
from decimal import Decimal
|
||||
|
||||
from apps.ai.models import AITask
|
||||
from apps.billing.pricing import VIDEO_DIGEST_POINTS, quote_video_digest
|
||||
|
||||
self.assertEqual(VIDEO_DIGEST_POINTS, Decimal("30"))
|
||||
self.assertEqual(quote_video_digest(model_config=self.model).points, Decimal("30"))
|
||||
self.team.price_multiplier = Decimal("0.80")
|
||||
self.assertEqual(quote_video_digest(team=self.team, model_config=self.model).points, Decimal("24"))
|
||||
self.team.price_multiplier = Decimal("1")
|
||||
|
||||
result = self._run_digest()
|
||||
self.account.refresh_from_db()
|
||||
@@ -401,7 +562,7 @@ class VideoDigestBillingTests(TestCase):
|
||||
with (
|
||||
patch(
|
||||
"apps.ai.video_digest.digest_input_from_upload",
|
||||
return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0),
|
||||
return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0, {}),
|
||||
),
|
||||
patch("apps.ai.services.execute_routed_text_request") as mocked,
|
||||
):
|
||||
|
||||
@@ -3,6 +3,7 @@ from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
AITaskViewSet,
|
||||
VideoDigestDetailView,
|
||||
VideoDigestView,
|
||||
FreeVideoDetailView,
|
||||
FreeVideoFavoriteView,
|
||||
@@ -25,6 +26,7 @@ router.register("image-conversations", ImageConversationViewSet, basename="image
|
||||
urlpatterns = [
|
||||
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
|
||||
path("video-digest/", VideoDigestView.as_view(), name="ai-video-digest"),
|
||||
path("video-digest/<uuid:task_id>/", VideoDigestDetailView.as_view(), name="ai-video-digest-detail"),
|
||||
path("free-video/", FreeVideoView.as_view(), name="ai-free-video"),
|
||||
path("free-video/trash/", FreeVideoTrashView.as_view(), name="ai-free-video-trash"),
|
||||
path("free-video/upload/", FreeVideoUploadView.as_view(), name="ai-free-video-upload"),
|
||||
|
||||
@@ -46,8 +46,8 @@ MIN_FRAMES = 8
|
||||
MAX_FRAMES = 36
|
||||
FRAME_WIDTH = 768
|
||||
FRAME_QUALITY = 3
|
||||
DIGEST_MAX_TOKENS = 8192
|
||||
_SHOT_MARK = re.compile(r"【第\s*\d+\s*镜】")
|
||||
DIGEST_MAX_TOKENS = 12288
|
||||
_SHOT_MARK = re.compile(r"【(?:镜头\s*\d+|第\s*\d+\s*镜)】")
|
||||
|
||||
_FFMPEG_TIMEOUT = 60
|
||||
|
||||
@@ -93,9 +93,10 @@ def load_digest_skill() -> str:
|
||||
return main.read_text(encoding="utf-8")
|
||||
# 兜底:skill 丢了也别整条链路挂掉,退化成一句话提示词(产出会明显变差,交接文档已注明须带 skills 目录)
|
||||
return (
|
||||
"你是分镜拆解 agent。输入是一条电商短视频的完整文件,含画面和口播。"
|
||||
"必须覆盖全片,从 0 秒写到片尾,有几镜写几镜,不要概括成几大段。"
|
||||
"每镜写画面和解说词,解说词按听到的口播逐字写。输出中文纯文本。"
|
||||
"你是分镜拆解 agent。输入是一条短视频的完整文件,含画面和音轨。"
|
||||
"必须覆盖全片,从 00:00 写到片尾,有几镜写几镜,不要概括成几大段。"
|
||||
"每镜按【镜头 01】写出时间、时长、景别、机位、运镜、画面、人物动作、人物表情、"
|
||||
"台词/旁白、音效、背景音乐、字幕、备注。没有就写无。输出中文纯文本。"
|
||||
)
|
||||
|
||||
|
||||
@@ -130,6 +131,73 @@ def probe_duration(path: str | Path) -> float:
|
||||
return duration
|
||||
|
||||
|
||||
def probe_video_size(path: str | Path) -> tuple[int, int]:
|
||||
"""ffprobe 读画面宽高。读不到返回 0,0,不挡拆解。"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
_binary("ffprobe"), "-v", "error",
|
||||
"-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height",
|
||||
"-of", "csv=p=0:s=x",
|
||||
str(path),
|
||||
],
|
||||
capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True,
|
||||
).stdout.decode("utf-8", errors="replace").strip()
|
||||
width_s, height_s = out.split("x", 1)
|
||||
return max(0, int(width_s)), max(0, int(height_s))
|
||||
except Exception: # noqa: BLE001
|
||||
return 0, 0
|
||||
|
||||
|
||||
def extract_cover_jpeg(path: str | Path, duration: float) -> bytes:
|
||||
"""抽一帧作历史封面。失败返回空字节,不挡拆解。"""
|
||||
try:
|
||||
ffmpeg = _binary("ffmpeg")
|
||||
except VideoDigestError:
|
||||
return b""
|
||||
at = max(0.0, min(float(duration) * 0.35, max(0.0, float(duration) - 0.15)))
|
||||
try:
|
||||
done = subprocess.run(
|
||||
[
|
||||
ffmpeg, "-v", "error", "-ss", f"{at:.2f}", "-i", str(path),
|
||||
"-frames:v", "1", "-vf", "scale=640:-2",
|
||||
"-q:v", "4", "-f", "image2", "-",
|
||||
],
|
||||
capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return b""
|
||||
return done.stdout or b""
|
||||
|
||||
|
||||
def ratio_label(width: int, height: int) -> str:
|
||||
if not width or not height:
|
||||
return ""
|
||||
ratio = width / height
|
||||
if abs(ratio - 9 / 16) < 0.08:
|
||||
return "9:16 竖屏"
|
||||
if abs(ratio - 16 / 9) < 0.08:
|
||||
return "16:9 横屏"
|
||||
if abs(ratio - 1) < 0.08:
|
||||
return "1:1"
|
||||
return "横屏" if width > height else "竖屏"
|
||||
|
||||
|
||||
def title_from_filename(name: str) -> str:
|
||||
stem = Path(name or "").stem.strip()
|
||||
return (stem or "参考视频")[:80]
|
||||
|
||||
|
||||
def duration_clock(seconds: float) -> str:
|
||||
total = max(0, int(round(float(seconds or 0))))
|
||||
return f"{total // 60:02d}:{total % 60:02d}"
|
||||
|
||||
|
||||
def shot_count(text: str) -> int:
|
||||
return len(_SHOT_MARK.findall(text or ""))
|
||||
|
||||
|
||||
def plan_frame_times(duration: float) -> list[int]:
|
||||
"""均匀采样时间点。取每段的**中点**,避开首尾黑场与片尾卡片。"""
|
||||
count = max(MIN_FRAMES, min(MAX_FRAMES, math.ceil(duration / SECONDS_PER_FRAME)))
|
||||
@@ -223,6 +291,60 @@ def _compress_video(path: str) -> bytes | None:
|
||||
Path(out.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _is_browser_playable(path: str) -> bool:
|
||||
"""Chrome 播不了微信常见的 HEVC。H.264 + AAC/无音轨才直接存。"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
_binary("ffprobe"), "-v", "error",
|
||||
"-show_entries", "stream=codec_name,codec_type",
|
||||
"-of", "json",
|
||||
str(path),
|
||||
],
|
||||
capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True,
|
||||
)
|
||||
streams = json.loads(out.stdout).get("streams") or []
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
video_ok = False
|
||||
audio_ok = True
|
||||
for stream in streams:
|
||||
kind = stream.get("codec_type")
|
||||
codec = str(stream.get("codec_name") or "").lower()
|
||||
if kind == "video":
|
||||
video_ok = codec in {"h264", "vp8", "vp9", "av1"}
|
||||
elif kind == "audio":
|
||||
audio_ok = codec in {"aac", "mp3", "opus", "vorbis"}
|
||||
return video_ok and audio_ok
|
||||
|
||||
|
||||
def _prepare_browser_video(path: str, suffix: str) -> tuple[str, str, bool]:
|
||||
"""转成浏览器能播的 H.264 AAC。失败退回原片。第三项表示调用方要删临时文件。"""
|
||||
ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}"
|
||||
if _is_browser_playable(path):
|
||||
return path, ext, False
|
||||
ffmpeg = _binary("ffmpeg")
|
||||
out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
|
||||
out.close()
|
||||
try:
|
||||
done = subprocess.run(
|
||||
[
|
||||
ffmpeg, "-v", "error", "-y", "-i", path,
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-movflags", "+faststart", out.name,
|
||||
],
|
||||
capture_output=True, timeout=_FFMPEG_TIMEOUT * 3,
|
||||
)
|
||||
if done.returncode != 0 or not Path(out.name).is_file() or Path(out.name).stat().st_size <= 0:
|
||||
Path(out.name).unlink(missing_ok=True)
|
||||
return path, ext, False
|
||||
return out.name, ".mp4", True
|
||||
except Exception: # noqa: BLE001 — 转码失败仍存原片,总比历史卡不能播好
|
||||
Path(out.name).unlink(missing_ok=True)
|
||||
return path, ext, False
|
||||
|
||||
|
||||
def _native_video(path: str, size: int, suffix: str) -> DigestVideo | None:
|
||||
mime = _SUFFIX_MIME.get(suffix.lower(), "video/mp4")
|
||||
if size <= INLINE_VIDEO_MAX_BYTES:
|
||||
@@ -233,17 +355,37 @@ def _native_video(path: str, size: int, suffix: str) -> DigestVideo | None:
|
||||
return None
|
||||
|
||||
|
||||
def digest_input_from_upload(upload) -> tuple[DigestVideo | None, list[VideoFrame], float]:
|
||||
"""优先整段视频(含音轨);塞不进请求才抽帧。"""
|
||||
def digest_input_from_upload(
|
||||
upload,
|
||||
*,
|
||||
keep_source: bool = False,
|
||||
) -> tuple[DigestVideo | None, list[VideoFrame], float, dict]:
|
||||
"""优先整段视频(含音轨);塞不进请求才抽帧。顺带抽出历史卡要用的封面/宽高。
|
||||
|
||||
keep_source=True 时不删临时文件,调用方上传原片后再删。
|
||||
"""
|
||||
path = ""
|
||||
keep = False
|
||||
original_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4"
|
||||
try:
|
||||
path, suffix, size, duration = _materialize_upload(upload)
|
||||
width, height = probe_video_size(path)
|
||||
cover_jpeg = extract_cover_jpeg(path, duration)
|
||||
video = _native_video(path, size, suffix)
|
||||
if video is not None:
|
||||
return video, [], duration
|
||||
return None, extract_frames(path, plan_frame_times(duration)), duration
|
||||
frames = [] if video is not None else extract_frames(path, plan_frame_times(duration))
|
||||
extras = {
|
||||
"file_name": original_name,
|
||||
"file_size": size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"cover_jpeg": cover_jpeg,
|
||||
"suffix": suffix,
|
||||
"source_path": path if keep_source else "",
|
||||
}
|
||||
keep = keep_source
|
||||
return video, frames, duration, extras
|
||||
finally:
|
||||
if path:
|
||||
if path and not keep:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@@ -267,20 +409,28 @@ def build_digest_messages(
|
||||
*,
|
||||
product_hint: str = "",
|
||||
video: DigestVideo | None = None,
|
||||
aspect_ratio: str = "",
|
||||
file_title: str = "",
|
||||
) -> list[dict]:
|
||||
"""system = 拆解 skill;user = 完整视频(优先)或抽帧。"""
|
||||
frames = frames or []
|
||||
measured = f"实测时长约 {round(duration)} 秒"
|
||||
if aspect_ratio:
|
||||
measured += f",画面比例 {aspect_ratio}"
|
||||
title_hint = f"文件名可参考片名:《{file_title}》。" if file_title else ""
|
||||
if video is not None:
|
||||
head = [
|
||||
f"这是一条时长约 {round(duration)} 秒的电商带货短视频的**完整文件**(含画面和口播音轨)。",
|
||||
"请按技能还原全片分镜:从 0 秒写到片尾,有几镜写几镜。",
|
||||
"解说词按听到的口播逐字写;画面上的花字一并写进画面。",
|
||||
f"这是一条{measured}的短视频**完整文件**(含画面和口播音轨)。{title_hint}",
|
||||
"请按技能还原全片导演分镜稿:从 00:00 写到片尾,有几镜写几镜。",
|
||||
"每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、台词/旁白、音效、背景音乐、字幕、备注。",
|
||||
"台词/旁白按听到的口播逐字写;画面上的花字写进字幕。",
|
||||
]
|
||||
else:
|
||||
head = [
|
||||
f"这是一条时长约 {round(duration)} 秒的电商带货短视频,",
|
||||
f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。",
|
||||
"请按技能还原**全片**分镜:从 0 秒写到片尾,有几镜写几镜。",
|
||||
f"这是一条{measured}的短视频,",
|
||||
f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。{title_hint}",
|
||||
"请按技能还原**全片**导演分镜稿:从 00:00 写到片尾,有几镜写几镜。",
|
||||
"每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、台词/旁白、音效、背景音乐、字幕、备注。",
|
||||
]
|
||||
if product_hint:
|
||||
head.append(f"用户接下来想用这条片子的结构去拍自己的商品:{product_hint}。")
|
||||
@@ -429,6 +579,127 @@ def digest_team_video(*, team, user, upload, model_config_id=None) -> dict:
|
||||
)
|
||||
|
||||
|
||||
def _store_digest_cover(*, team, jpeg: bytes) -> tuple[str, str]:
|
||||
"""封面传到 TOS。失败返回空,历史卡走占位底。"""
|
||||
from io import BytesIO
|
||||
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
if not jpeg:
|
||||
return "", ""
|
||||
key = f"teams/{team.id}/video-digest/{uuid.uuid4()}.jpg"
|
||||
storage = TosStorage()
|
||||
stored = storage.upload_fileobj(fileobj=BytesIO(jpeg), object_key=key, content_type="image/jpeg")
|
||||
return stored.object_key, storage.public_url(object_key=stored.object_key)
|
||||
|
||||
|
||||
def _store_digest_video(*, team, path: str, suffix: str) -> tuple[str, str]:
|
||||
"""原片(优先转成 H.264)传到 TOS,历史封面点击才能播。失败返回空,不挡拆解。"""
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
if not path or not Path(path).is_file():
|
||||
return "", ""
|
||||
upload_path, ext, ephemeral = _prepare_browser_video(path, suffix)
|
||||
try:
|
||||
if ext.lower() not in ALLOWED_SUFFIXES:
|
||||
ext = ".mp4"
|
||||
mime = _SUFFIX_MIME.get(ext.lower(), "video/mp4")
|
||||
key = f"teams/{team.id}/video-digest/{uuid.uuid4()}{ext.lower()}"
|
||||
storage = TosStorage()
|
||||
with Path(upload_path).open("rb") as fileobj:
|
||||
stored = storage.upload_fileobj(fileobj=fileobj, object_key=key, content_type=mime)
|
||||
return stored.object_key, storage.public_url(object_key=stored.object_key)
|
||||
finally:
|
||||
if ephemeral:
|
||||
Path(upload_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _media_url_from_payload(req: dict, url_key: str, object_key_name: str, *, signed: bool = False) -> str:
|
||||
stored = str(req.get(url_key) or "").strip()
|
||||
key = str(req.get(object_key_name) or "").strip()
|
||||
if not key:
|
||||
return stored
|
||||
try:
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
storage = TosStorage()
|
||||
if signed:
|
||||
return storage.presigned_get_url(object_key=key, expires_in=6 * 3600)
|
||||
return storage.public_url(object_key=key)
|
||||
except Exception: # noqa: BLE001
|
||||
return stored
|
||||
|
||||
|
||||
def serialize_digest_history(task) -> dict:
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask
|
||||
|
||||
req = task.request_payload or {}
|
||||
resp = task.response_payload or {}
|
||||
prompt = str(resp.get("prompt") or resp.get("digest") or "").strip()
|
||||
duration = float(req.get("duration_seconds") or 0)
|
||||
file_name = str(req.get("file_name") or "") or "参考视频.mp4"
|
||||
title = str(req.get("title") or "").strip() or title_from_filename(file_name)
|
||||
shots = int(req.get("shot_count") or 0) or shot_count(prompt)
|
||||
created = timezone.localtime(task.created_at) if task.created_at else timezone.now()
|
||||
return {
|
||||
"id": str(task.id),
|
||||
"title": title,
|
||||
"status": "已完成" if task.status == AITask.Status.SUCCEEDED else "失败",
|
||||
"duration": int(round(duration)),
|
||||
"duration_label": duration_clock(duration),
|
||||
"ratio": str(req.get("ratio") or ""),
|
||||
"shots": shots,
|
||||
"file_name": file_name,
|
||||
"cover_url": _media_url_from_payload(req, "cover_url", "cover_key"),
|
||||
"video_url": _media_url_from_payload(req, "video_url", "video_key", signed=True),
|
||||
"prompt": prompt,
|
||||
"created_at": task.created_at.isoformat() if task.created_at else "",
|
||||
"created_date": created.strftime("%Y-%m-%d"),
|
||||
}
|
||||
|
||||
|
||||
def list_team_digest_history(*, team, limit: int = 50) -> list[dict]:
|
||||
from apps.ai.models import AITask
|
||||
|
||||
qs = (
|
||||
AITask.objects.filter(
|
||||
team=team,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
project__isnull=True,
|
||||
status=AITask.Status.SUCCEEDED,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
)
|
||||
.order_by("-created_at")[: max(1, min(int(limit), 50))]
|
||||
)
|
||||
return [serialize_digest_history(task) for task in qs]
|
||||
|
||||
|
||||
def save_digest_prompt(*, team, task_id, prompt: str) -> dict | None:
|
||||
from apps.ai.models import AITask
|
||||
|
||||
cleaned = (prompt or "").strip()
|
||||
if not cleaned:
|
||||
raise VideoDigestError("提示词不能为空")
|
||||
task = AITask.objects.filter(
|
||||
id=task_id,
|
||||
team=team,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
project__isnull=True,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
).first()
|
||||
if task is None:
|
||||
return None
|
||||
payload = dict(task.response_payload or {})
|
||||
payload["prompt"] = cleaned[:32000]
|
||||
task.response_payload = payload
|
||||
task.save(update_fields=["response_payload", "updated_at"])
|
||||
return serialize_digest_history(task)
|
||||
|
||||
|
||||
def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None) -> dict:
|
||||
"""上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。"""
|
||||
from django.db import transaction
|
||||
@@ -439,10 +710,20 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
from apps.billing.pricing import quote_video_digest
|
||||
from apps.billing.services.ledger import charge_reserved_credit, reserve_credit
|
||||
|
||||
video, frames, duration = digest_input_from_upload(upload)
|
||||
video, frames, duration, extras = digest_input_from_upload(
|
||||
upload, keep_source=(project is None)
|
||||
)
|
||||
extras = extras or {}
|
||||
source_path = str(extras.get("source_path") or "")
|
||||
file_name = str(extras.get("file_name") or getattr(upload, "name", "") or "参考视频.mp4")
|
||||
width = int(extras.get("width") or 0)
|
||||
height = int(extras.get("height") or 0)
|
||||
file_size = int(extras.get("file_size") or getattr(upload, "size", 0) or 0)
|
||||
|
||||
model_config = resolve_digest_model_config(preferred_id=model_config_id)
|
||||
if model_config is None:
|
||||
if source_path:
|
||||
Path(source_path).unlink(missing_ok=True)
|
||||
raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
|
||||
logger.info(
|
||||
"video digest using %s:%s (%s) input=%s duration=%.1fs bytes=%s frames=%s",
|
||||
@@ -456,7 +737,12 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
)
|
||||
|
||||
messages = build_digest_messages(
|
||||
frames, duration, product_hint=product_hint, video=video
|
||||
frames,
|
||||
duration,
|
||||
product_hint=product_hint,
|
||||
video=video,
|
||||
aspect_ratio=ratio_label(width, height),
|
||||
file_title=title_from_filename(file_name),
|
||||
)
|
||||
request_payload = {
|
||||
"model": model_config.name,
|
||||
@@ -467,6 +753,12 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
"frame_count": len(frames),
|
||||
"video_bytes": len(video.data) if video is not None else 0,
|
||||
"frame_times": [f.at_seconds for f in frames],
|
||||
"file_name": file_name,
|
||||
"title": title_from_filename(file_name),
|
||||
"file_size": file_size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"ratio": ratio_label(width, height),
|
||||
}
|
||||
|
||||
quote = quote_video_digest(team=team, model_config=model_config)
|
||||
@@ -502,6 +794,8 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
task.status = AITask.Status.RESERVED
|
||||
task.save(update_fields=["status", "updated_at"])
|
||||
except ValueError as exc:
|
||||
if source_path:
|
||||
Path(source_path).unlink(missing_ok=True)
|
||||
if "insufficient credit" in str(exc).lower():
|
||||
raise VideoDigestError("团队余额不足,请充值后重试") from exc
|
||||
raise VideoDigestError(str(exc)) from exc
|
||||
@@ -532,15 +826,56 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
)
|
||||
_text, _response, digest = routed.value
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if source_path:
|
||||
Path(source_path).unlink(missing_ok=True)
|
||||
_fail_digest_task(task, reservation, str(exc))
|
||||
raise
|
||||
|
||||
cover_key, cover_url, video_key, video_url = "", "", "", ""
|
||||
if project is None:
|
||||
try:
|
||||
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extras.get("cover_jpeg") or b"")
|
||||
except Exception: # noqa: BLE001 — 封面失败不挡拆解结果
|
||||
logger.warning("video digest cover upload failed", exc_info=True)
|
||||
try:
|
||||
video_key, video_url = _store_digest_video(
|
||||
team=team,
|
||||
path=source_path,
|
||||
suffix=str(extras.get("suffix") or ".mp4"),
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 原片失败仍可看提示词,封面不能播
|
||||
logger.warning("video digest source upload failed", exc_info=True)
|
||||
if source_path:
|
||||
Path(source_path).unlink(missing_ok=True)
|
||||
source_path = ""
|
||||
|
||||
shots = shot_count(digest)
|
||||
request_payload = {
|
||||
**(task.request_payload or {}),
|
||||
**request_payload,
|
||||
"shot_count": shots,
|
||||
"cover_key": cover_key,
|
||||
"cover_url": cover_url,
|
||||
"video_key": video_key,
|
||||
"video_url": video_url,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.response_payload = {"digest": digest[:32000]}
|
||||
task.request_payload = request_payload
|
||||
task.response_payload = {"digest": digest[:32000], "prompt": digest[:32000]}
|
||||
task.actual_cost = task.estimated_cost
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
||||
task.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"request_payload",
|
||||
"response_payload",
|
||||
"actual_cost",
|
||||
"completed_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
|
||||
|
||||
return {
|
||||
@@ -551,6 +886,14 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
"duration": round(duration, 1),
|
||||
"task_id": str(task.id),
|
||||
"estimated_cost": str(task.estimated_cost),
|
||||
"title": request_payload.get("title") or title_from_filename(file_name),
|
||||
"file_name": file_name,
|
||||
"ratio": request_payload.get("ratio") or "",
|
||||
"shots": shots,
|
||||
"cover_url": cover_url,
|
||||
"video_url": video_url,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
@@ -700,9 +700,17 @@ class VideoDigestView(APIView):
|
||||
"""视频提炼 · 上传参考视频提炼分镜稿(不绑项目)。
|
||||
|
||||
POST /api/ai/video-digest/ multipart file → 中文分镜稿。慢(30~60 秒),固定 30 积分/次,失败退还。
|
||||
GET /api/ai/video-digest/ 本团队已完成的提炼历史(新→旧)。
|
||||
"""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
def get(self, request):
|
||||
from .video_digest import list_team_digest_history
|
||||
|
||||
team = get_current_team(request.user)
|
||||
results = list_team_digest_history(team=team)
|
||||
return Response({"results": results, "total": len(results)})
|
||||
|
||||
def post(self, request):
|
||||
from .video_digest import VideoDigestError, digest_team_video
|
||||
@@ -730,6 +738,28 @@ class VideoDigestView(APIView):
|
||||
return Response({"name": getattr(upload, "name", "") or "参考视频", **result})
|
||||
|
||||
|
||||
class VideoDigestDetailView(APIView):
|
||||
"""PATCH /api/ai/video-digest/<id>/ 保存编辑后的提示词到这条历史。"""
|
||||
|
||||
parser_classes = [JSONParser, FormParser]
|
||||
|
||||
def patch(self, request, task_id):
|
||||
from .video_digest import VideoDigestError, save_digest_prompt
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
item = save_digest_prompt(
|
||||
team=team,
|
||||
task_id=task_id,
|
||||
prompt=str(request.data.get("prompt") or ""),
|
||||
)
|
||||
except VideoDigestError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if item is None:
|
||||
return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(item)
|
||||
|
||||
|
||||
class FreeVideoView(APIView):
|
||||
"""自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。
|
||||
|
||||
|
||||
@@ -231,15 +231,6 @@ class PricingEngineTests(TestCase):
|
||||
# 多单位(单图批量按张)
|
||||
self.assertEqual(quote_flat(self.text_model, units=3).points, Decimal("30"))
|
||||
|
||||
def test_video_digest_is_fixed_thirty_not_model_unit_price(self):
|
||||
from apps.billing.pricing import VIDEO_DIGEST_POINTS, quote_video_digest
|
||||
|
||||
self.assertEqual(VIDEO_DIGEST_POINTS, Decimal("30"))
|
||||
quote = quote_video_digest(model_config=self.text_model)
|
||||
self.assertEqual(quote.points, Decimal("30"))
|
||||
self.assertEqual(quote.meta["rule"], "video_digest_flat")
|
||||
self.assertEqual(quote.meta["unit_points"], "30")
|
||||
|
||||
def test_flat_fallback_when_unit_price_unset(self):
|
||||
from apps.billing.pricing import FLAT_FALLBACK_POINTS, quote_flat
|
||||
|
||||
@@ -423,14 +414,6 @@ class TeamPriceMultiplierTests(TestCase):
|
||||
# meta.rate 是汇率快照契约:create_ai_task 落 payload.points_per_yuan_snapshot,毛利报表用它防汇率漂移
|
||||
self.assertEqual(Decimal(quote.meta["rate"]), Decimal("10"))
|
||||
|
||||
def test_video_digest_applies_multiplier_to_feature_price(self):
|
||||
from apps.billing.pricing import quote_video_digest
|
||||
|
||||
# 30 × 0.8 = 24;不跟模型 unit_price=20
|
||||
quote = quote_video_digest(team=self.team, model_config=self.image_model)
|
||||
self.assertEqual(quote.points, Decimal("24"))
|
||||
self.assertEqual(quote_video_digest().points, Decimal("30"))
|
||||
|
||||
def test_two_step_rounding(self):
|
||||
from apps.billing.pricing import quote_video_from_cost
|
||||
|
||||
|
||||
@@ -27,13 +27,39 @@ class WorkerUnavailable(APIException):
|
||||
_OK_TTL = 30.0
|
||||
_FAIL_TTL = 5.0
|
||||
_cache = {"ok": False, "expires": 0.0}
|
||||
# True / False / None(inspect 没答上来,不当成「未部署」)
|
||||
_registered_task_cache: dict[str, tuple[bool | None, float]] = {}
|
||||
|
||||
|
||||
def _task_name_listed(task_name: str, task_names) -> bool:
|
||||
"""Celery inspect.registered() 可能给全名、短名,或带 [rate] 后缀。"""
|
||||
short = task_name.rsplit(".", 1)[-1]
|
||||
for raw in task_names or []:
|
||||
name = str(raw).split("[", 1)[0].strip()
|
||||
if name in {task_name, short} or name.endswith("." + short):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _inspect_task_registered(task_name: str) -> bool | None:
|
||||
"""True 已注册 / False 明确没有 / None 问不到(超时或空应答)。"""
|
||||
try:
|
||||
from airshelf.celery import app as celery_app
|
||||
|
||||
registered = celery_app.control.inspect(timeout=2.5).registered()
|
||||
except Exception: # noqa: BLE001 — inspect 失败不等于任务没部署
|
||||
return None
|
||||
if not registered:
|
||||
return None
|
||||
return any(_task_name_listed(task_name, names) for names in registered.values())
|
||||
|
||||
|
||||
def _ping_workers() -> bool:
|
||||
from airshelf.celery import app as celery_app
|
||||
|
||||
# limit=1:收到第一个 worker 回包立即返回,不傻等满 timeout
|
||||
return bool(celery_app.control.ping(timeout=1.0, limit=1))
|
||||
# limit=1:收到第一个 worker 回包立即返回。本机连火山 Redis 往返常超过 1s,
|
||||
# 超时会被误判成 worker 未运行,生成入口整页锁死。
|
||||
return bool(celery_app.control.ping(timeout=3.0, limit=1))
|
||||
|
||||
|
||||
def celery_worker_available() -> bool:
|
||||
@@ -55,3 +81,29 @@ def require_worker() -> None:
|
||||
"""生成类提交入口的前置检查:无 worker 直接 503,不让任务出门。"""
|
||||
if not celery_worker_available():
|
||||
raise WorkerUnavailable()
|
||||
|
||||
|
||||
def require_worker_task(task_name: str) -> None:
|
||||
"""确认在线 Worker 已加载指定任务,避免新功能被旧进程静默丢弃。
|
||||
|
||||
Worker 在线不代表它已经加载了最新 tasks.py。Celery 收到未注册任务会直接丢弃消息,
|
||||
页面只能看到永久「生成中」。提交极速成片等新编排前,额外确认任务名已注册。
|
||||
|
||||
inspect 超时/空应答不能当成「未部署」——本机常见 ping 通、inspect 1s 问不到,
|
||||
会误报「正在升级」。只有明确看到已注册列表且没有该任务时才拦截。
|
||||
"""
|
||||
if getattr(settings, "CELERY_TASK_ALWAYS_EAGER", False):
|
||||
return
|
||||
require_worker()
|
||||
now = time.monotonic()
|
||||
cached = _registered_task_cache.get(task_name)
|
||||
if cached and now < cached[1]:
|
||||
available = cached[0]
|
||||
else:
|
||||
available = _inspect_task_registered(task_name)
|
||||
ttl = _OK_TTL if available else _FAIL_TTL
|
||||
_registered_task_cache[task_name] = (available, now + ttl)
|
||||
if available is False:
|
||||
exc = WorkerUnavailable()
|
||||
exc.detail = "极速成片后台任务尚未加载,请重启 Celery worker 后再试。"
|
||||
raise exc
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
from apps.common import celery_health
|
||||
from apps.common.celery_health import require_worker_task
|
||||
|
||||
|
||||
class RequireWorkerTaskTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
celery_health._registered_task_cache.clear()
|
||||
celery_health._cache["expires"] = 0.0
|
||||
|
||||
def tearDown(self):
|
||||
celery_health._registered_task_cache.clear()
|
||||
celery_health._cache["expires"] = 0.0
|
||||
|
||||
def test_registered_name_accepts_short_name_and_rate_suffix(self):
|
||||
listed = ["apps.projects.tasks.advance_quick_create_task[1/s]"]
|
||||
self.assertTrue(
|
||||
celery_health._task_name_listed("apps.projects.tasks.advance_quick_create_task", listed)
|
||||
)
|
||||
self.assertTrue(celery_health._task_name_listed("advance_quick_create_task", listed))
|
||||
self.assertFalse(celery_health._task_name_listed("apps.projects.tasks.missing_task", listed))
|
||||
|
||||
@override_settings(CELERY_TASK_ALWAYS_EAGER=False)
|
||||
@patch("apps.common.celery_health.celery_worker_available", return_value=True)
|
||||
@patch("apps.common.celery_health._inspect_task_registered", return_value=None)
|
||||
def test_inspect_unknown_does_not_block(self, _inspect, _available):
|
||||
require_worker_task("apps.projects.tasks.advance_quick_create_task")
|
||||
|
||||
@override_settings(CELERY_TASK_ALWAYS_EAGER=False)
|
||||
@patch("apps.common.celery_health.celery_worker_available", return_value=True)
|
||||
@patch("apps.common.celery_health._inspect_task_registered", return_value=False)
|
||||
def test_missing_task_blocks_with_restart_hint(self, _inspect, _available):
|
||||
with self.assertRaises(APIException) as raised:
|
||||
require_worker_task("apps.projects.tasks.advance_quick_create_task")
|
||||
self.assertEqual(raised.exception.status_code, 503)
|
||||
self.assertIn("重启", str(raised.exception.detail))
|
||||
self.assertNotIn("正在升级", str(raised.exception.detail))
|
||||
@@ -0,0 +1,92 @@
|
||||
# Generated by Django 5.1.15 on 2026-08-24
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0009_team_price_multiplier"),
|
||||
("projects", "0008_scripttemplate"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="QuickCreateJob",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("queued", "Queued"),
|
||||
("running", "Running"),
|
||||
("succeeded", "Succeeded"),
|
||||
("failed", "Failed"),
|
||||
("cancelled", "Cancelled"),
|
||||
],
|
||||
default="queued",
|
||||
max_length=24,
|
||||
),
|
||||
),
|
||||
(
|
||||
"phase",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("product", "Product"),
|
||||
("script", "Script"),
|
||||
("assets", "Assets"),
|
||||
("production", "Storyboard and Video"),
|
||||
("complete", "Complete"),
|
||||
],
|
||||
default="product",
|
||||
max_length=24,
|
||||
),
|
||||
),
|
||||
("progress", models.PositiveSmallIntegerField(default=0)),
|
||||
("message", models.CharField(blank=True, max_length=255)),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
("completed_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_%(class)s_set",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="quick_create_job",
|
||||
to="projects.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"team",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="%(class)s_set",
|
||||
to="accounts.team",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(fields=["team", "status", "-created_at"], name="projects_qu_team_id_a8a924_idx"),
|
||||
models.Index(fields=["team", "phase"], name="projects_qu_team_id_98380e_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -70,6 +70,44 @@ class ProjectStage(TimeStampedModel):
|
||||
ordering = ["created_at"]
|
||||
|
||||
|
||||
class QuickCreateJob(TeamOwnedModel):
|
||||
"""“极速成片”自动编排任务。
|
||||
|
||||
AI 子任务仍落在 AITask;本表只保存跨商品、脚本、资产、故事板、视频和导出的编排状态,
|
||||
让页面刷新、浏览器关闭后都能继续查询同一条流水线。
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
QUEUED = "queued", "Queued"
|
||||
RUNNING = "running", "Running"
|
||||
SUCCEEDED = "succeeded", "Succeeded"
|
||||
FAILED = "failed", "Failed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
class Phase(models.TextChoices):
|
||||
PRODUCT = "product", "Product"
|
||||
SCRIPT = "script", "Script"
|
||||
ASSETS = "assets", "Assets"
|
||||
PRODUCTION = "production", "Storyboard and Video"
|
||||
COMPLETE = "complete", "Complete"
|
||||
|
||||
project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="quick_create_job")
|
||||
status = models.CharField(max_length=24, choices=Status.choices, default=Status.QUEUED)
|
||||
phase = models.CharField(max_length=24, choices=Phase.choices, default=Phase.PRODUCT)
|
||||
progress = models.PositiveSmallIntegerField(default=0)
|
||||
message = models.CharField(max_length=255, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["team", "status", "-created_at"], name="projects_qu_team_id_a8a924_idx"),
|
||||
models.Index(fields=["team", "phase"], name="projects_qu_team_id_98380e_idx"),
|
||||
]
|
||||
|
||||
|
||||
class ScriptVersion(TimeStampedModel):
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="script_versions")
|
||||
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="script_versions")
|
||||
@@ -334,4 +372,3 @@ class ExportJob(TimeStampedModel):
|
||||
progress = models.PositiveIntegerField(default=0)
|
||||
error_message = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .models import (
|
||||
ExportJob,
|
||||
Project,
|
||||
ProjectStage,
|
||||
QuickCreateJob,
|
||||
ScriptSegment,
|
||||
ScriptTemplate,
|
||||
ScriptVersion,
|
||||
@@ -329,6 +330,132 @@ class ExportJobSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class QuickCreateJobSerializer(serializers.ModelSerializer):
|
||||
"""极速成片轮询响应:只下发用户需要的四阶段进度和最终可播结果。"""
|
||||
|
||||
project_id = serializers.UUIDField(source="project.id", read_only=True)
|
||||
product_id = serializers.UUIDField(source="project.product_id", read_only=True)
|
||||
product_name = serializers.CharField(source="project.product.title", read_only=True)
|
||||
title = serializers.CharField(source="project.name", read_only=True)
|
||||
product_images = serializers.SerializerMethodField()
|
||||
phase_index = serializers.SerializerMethodField()
|
||||
settings = serializers.SerializerMethodField()
|
||||
result = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = QuickCreateJob
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"product_id",
|
||||
"product_name",
|
||||
"title",
|
||||
"product_images",
|
||||
"status",
|
||||
"phase",
|
||||
"phase_index",
|
||||
"settings",
|
||||
"progress",
|
||||
"message",
|
||||
"error_message",
|
||||
"result",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"completed_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_product_images(self, obj) -> list[dict]:
|
||||
product = getattr(obj.project, "product", None)
|
||||
if product is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"asset_id": str(image.asset_id),
|
||||
"url": _asset_preview_url(image.asset),
|
||||
}
|
||||
for image in product.images.all()
|
||||
]
|
||||
|
||||
def get_phase_index(self, obj) -> int:
|
||||
return {
|
||||
QuickCreateJob.Phase.PRODUCT: 0,
|
||||
QuickCreateJob.Phase.SCRIPT: 1,
|
||||
QuickCreateJob.Phase.ASSETS: 2,
|
||||
QuickCreateJob.Phase.PRODUCTION: 3,
|
||||
QuickCreateJob.Phase.COMPLETE: 3,
|
||||
}.get(obj.phase, 0)
|
||||
|
||||
def get_settings(self, obj) -> dict:
|
||||
wizard = dict((obj.project.metadata or {}).get("wizard") or {})
|
||||
return {
|
||||
"aspect_ratio": str(wizard.get("aspect_ratio") or "9:16"),
|
||||
"resolution": str(wizard.get("resolution") or "720p").lower(),
|
||||
"total_duration": int(wizard.get("total_duration") or 15),
|
||||
"video_model_config_id": str(wizard.get("video_model_config_id") or ""),
|
||||
"video_model_name": str(wizard.get("video_model_name") or ""),
|
||||
"video_model_label": str(wizard.get("video_model_label") or ""),
|
||||
}
|
||||
|
||||
def get_result(self, obj) -> dict | None:
|
||||
if obj.status != QuickCreateJob.Status.SUCCEEDED:
|
||||
return None
|
||||
project = obj.project
|
||||
settings = self.get_settings(obj)
|
||||
video_url = _final_video_url(project)
|
||||
segments = list(project.video_segments.all())
|
||||
if not video_url and len(segments) == 1:
|
||||
version = segments[0].adopted_version
|
||||
if version is not None and is_playable_video(version.asset):
|
||||
video_url = _asset_preview_url(version.asset)
|
||||
first_shot = next(
|
||||
(shot for shot in project.storyboard_shots.all() if shot.adopted_version_id),
|
||||
None,
|
||||
)
|
||||
poster_url = _asset_preview_url(first_shot.adopted_version.asset) if first_shot else ""
|
||||
script = next((version for version in project.script_versions.all() if version.is_adopted), None)
|
||||
script_meta = (script.metadata or {}) if script is not None else {}
|
||||
format_label = {"oral": "口播展示", "drama": "短剧演绎", "vlog": "Vlog种草"}.get(
|
||||
script_meta.get("presentation_format"), "智能推荐"
|
||||
)
|
||||
structure_label = {
|
||||
"pain": "痛点解决",
|
||||
"contrast": "前后对比",
|
||||
"review": "测评验证",
|
||||
"scene": "场景种草",
|
||||
}.get(script_meta.get("video_structure"), "智能推荐")
|
||||
groups = list(project.base_asset_groups.all())
|
||||
person = next((group for group in groups if group.kind == BaseAssetGroup.Kind.PERSON and group.adopted_asset_id), None)
|
||||
scene = next((group for group in groups if group.kind == BaseAssetGroup.Kind.SCENE and group.adopted_asset_id), None)
|
||||
duration = sum(segment.target_duration_seconds for segment in segments)
|
||||
video_segments = []
|
||||
for segment in sorted(segments, key=lambda item: item.sort_order):
|
||||
version = segment.adopted_version
|
||||
clip_url = _asset_preview_url(version.asset) if version is not None and is_playable_video(version.asset) else ""
|
||||
video_segments.append(
|
||||
{
|
||||
"id": str(segment.id),
|
||||
"sort_order": segment.sort_order,
|
||||
"duration_seconds": segment.target_duration_seconds or 15,
|
||||
"video_url": clip_url,
|
||||
"poster_url": poster_url,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"video_url": video_url,
|
||||
"poster_url": poster_url,
|
||||
"duration_seconds": duration or 15,
|
||||
"aspect_ratio": settings["aspect_ratio"],
|
||||
"resolution": settings["resolution"],
|
||||
"video_model": settings["video_model_label"] or settings["video_model_name"],
|
||||
"structure": structure_label,
|
||||
"presentation": format_label,
|
||||
"person": ((person.metadata or {}).get("label") or "自动匹配") if person else "自动匹配",
|
||||
"scene": ((scene.metadata or {}).get("label") or "自动匹配") if scene else "自动匹配",
|
||||
"video_segments": video_segments,
|
||||
}
|
||||
|
||||
|
||||
class ScriptSegmentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ScriptSegment
|
||||
@@ -359,13 +486,14 @@ class ProjectListSerializer(serializers.ModelSerializer):
|
||||
video_segment_count = serializers.IntegerField(read_only=True, default=0)
|
||||
# 合成成片地址:项目列表的播放按钮据此直接播成片(没合成过为空 → 退回进流水线)
|
||||
final_video_url = serializers.SerializerMethodField()
|
||||
quick_create = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = [
|
||||
"id", "name", "product", "product_title", "cover_preview_url",
|
||||
"status", "current_stage", "script_version_count", "video_segment_count",
|
||||
"final_video_url",
|
||||
"final_video_url", "quick_create",
|
||||
"is_deleted", "purged_at", "created_at", "updated_at",
|
||||
]
|
||||
|
||||
@@ -375,6 +503,9 @@ class ProjectListSerializer(serializers.ModelSerializer):
|
||||
def get_final_video_url(self, obj) -> str:
|
||||
return _final_video_url(obj)
|
||||
|
||||
def get_quick_create(self, obj) -> bool:
|
||||
return bool((obj.metadata or {}).get("quick_create"))
|
||||
|
||||
|
||||
class ProjectSerializer(serializers.ModelSerializer):
|
||||
stages = ProjectStageSerializer(many=True, read_only=True)
|
||||
|
||||
@@ -98,27 +98,28 @@ def _wrap_cjk(draw, text: str, font, max_width: int) -> list[str]:
|
||||
return lines[:3] # 最多 3 行,够长截断
|
||||
|
||||
|
||||
def _render_subtitle_png(text: str, style_key: str, path: Path) -> tuple[int, int]:
|
||||
"""把一条字幕渲染成 1080 宽的透明 PNG(居中,带描边/底框),返回 (w,h)。"""
|
||||
def _render_subtitle_png(text: str, style_key: str, path: Path, *, canvas_w: int = 1080) -> tuple[int, int]:
|
||||
"""把一条字幕渲染成与导出宽度一致的透明 PNG(居中,带描边/底框)。"""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
st = SUBTITLE_STYLES.get(style_key) or SUBTITLE_STYLES["plain"]
|
||||
canvas_w = 1080
|
||||
margin_x = 90
|
||||
base = SUBTITLE_STYLES.get(style_key) or SUBTITLE_STYLES["plain"]
|
||||
scale = max(0.4, canvas_w / 1080)
|
||||
st = {**base, "size": max(24, round(base["size"] * scale)), "stroke_w": max(0, round(base["stroke_w"] * scale))}
|
||||
margin_x = max(30, round(90 * scale))
|
||||
font = _load_font(st["size"])
|
||||
probe = ImageDraw.Draw(Image.new("RGBA", (10, 10)))
|
||||
lines = _wrap_cjk(probe, (text or "").strip().replace("\n", " "), font, canvas_w - 2 * margin_x)
|
||||
line_h = st["size"] + 16
|
||||
pad = 22
|
||||
line_h = st["size"] + max(6, round(16 * scale))
|
||||
pad = max(8, round(22 * scale))
|
||||
text_h = line_h * len(lines)
|
||||
canvas_h = text_h + 2 * pad
|
||||
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
if st["box"]:
|
||||
widest = max((draw.textlength(ln, font=font) for ln in lines), default=0)
|
||||
box_w = int(widest) + 2 * pad + 24
|
||||
box_w = int(widest) + 2 * pad + max(10, round(24 * scale))
|
||||
x0 = (canvas_w - box_w) // 2
|
||||
draw.rounded_rectangle([x0, 0, x0 + box_w, canvas_h], radius=16, fill=st["box"])
|
||||
draw.rounded_rectangle([x0, 0, x0 + box_w, canvas_h], radius=max(6, round(16 * scale)), fill=st["box"])
|
||||
y = pad
|
||||
for ln in lines:
|
||||
w = draw.textlength(ln, font=font)
|
||||
@@ -186,7 +187,8 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
transition: str, sub_overlays: list[tuple[str, float, float]],
|
||||
bgm_name: str | None, bgm_volume: float,
|
||||
has_audio: list[bool] | None = None, fps: float = 30.0,
|
||||
voice_overlays: list[tuple[str, float, float]] | None = None) -> list[str]:
|
||||
voice_overlays: list[tuple[str, float, float]] | None = None,
|
||||
output_width: int = 1080, output_height: int = 1920) -> list[str]:
|
||||
has_audio = has_audio or [False] * n
|
||||
voice_overlays = voice_overlays or []
|
||||
fps_expr = f"{fps:.6g}"
|
||||
@@ -194,8 +196,8 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
for i, s in enumerate(specs):
|
||||
parts.append(
|
||||
f"[{i}:v]trim=start={s['ts']:.3f}:end={s['te']:.3f},setpts=PTS-STARTPTS,"
|
||||
"scale=1080:1920:force_original_aspect_ratio=decrease,"
|
||||
f"pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]"
|
||||
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]"
|
||||
)
|
||||
xname = XFADE_MAP.get(transition or "none")
|
||||
if xname and n > 1:
|
||||
@@ -213,8 +215,9 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
for j, (_png, start, end) in enumerate(sub_overlays):
|
||||
idx = sub_base + j
|
||||
out = "vout" if j == len(sub_overlays) - 1 else f"ov{j}"
|
||||
bottom_offset = max(40, round(output_height * 150 / 1920))
|
||||
parts.append(
|
||||
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
|
||||
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-{bottom_offset}:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
|
||||
)
|
||||
vlabel = out
|
||||
|
||||
@@ -331,6 +334,19 @@ def _split_subtitle_text(text: str) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _timeline_output_size(timeline) -> tuple[int, int]:
|
||||
"""读取时间线输出尺寸;旧项目或异常值安全回落到原来的 1080×1920。"""
|
||||
try:
|
||||
width_text, height_text = str(timeline.resolution or "").lower().split("x", 1)
|
||||
width, height = int(width_text), int(height_text)
|
||||
except (TypeError, ValueError):
|
||||
return 1080, 1920
|
||||
if not 320 <= width <= 4400 or not 320 <= height <= 4400:
|
||||
return 1080, 1920
|
||||
# H.264 yuv420p 要求偶数尺寸。
|
||||
return width - width % 2, height - height % 2
|
||||
|
||||
|
||||
def _subtitle_cues(timeline, project, specs, starts, total) -> list[tuple[float, float, str]]:
|
||||
"""字幕条目(逐句):优先用 SubtitleTrack.content 里每条 cue 自带的 start_ms——
|
||||
先定位到所属片段(输入时间轴=各片段时长累计),再重映射到输出时间轴(xfade 会压缩起点);
|
||||
@@ -424,6 +440,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
style_key = str((subtitle_track.style or {}).get("key", "plain")) if subtitle_track else "plain"
|
||||
|
||||
specs = _clip_specs(clips)
|
||||
output_width, output_height = _timeline_output_size(timeline)
|
||||
xfade = 0.5 if XFADE_MAP.get(transition) and len(clips) > 1 else 0.0
|
||||
starts, total = _output_starts(specs, xfade)
|
||||
|
||||
@@ -451,7 +468,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
sub_overlays: list[tuple[str, float, float]] = []
|
||||
for i, (start, end, text) in enumerate(cues):
|
||||
png = f"sub{i}.png"
|
||||
_render_subtitle_png(text, style_key, tmp / png)
|
||||
_render_subtitle_png(text, style_key, tmp / png, canvas_w=output_width)
|
||||
sub_overlays.append((png, start, end))
|
||||
|
||||
# 旁白配音(TTS 资产):按 timeline.metadata.voiceover 映射下载,人声轨混在 BGM 之上;
|
||||
@@ -484,6 +501,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
|
||||
sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0,
|
||||
has_audio=has_audio, fps=output_fps, voice_overlays=voice_overlays,
|
||||
output_width=output_width, output_height=output_height,
|
||||
)
|
||||
# 加超时:ffmpeg 卡死(资源不足/被 OOM 杀)时不能让任务永久停在 RUNNING、前端无限转圈;
|
||||
# 超时即按失败收尾,前端能看到「导出失败」而非一直卡。15 分钟足够正常 60s 成片。
|
||||
@@ -527,4 +545,3 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
project.status = project.Status.COMPLETED
|
||||
project.save(update_fields=["status", "updated_at"])
|
||||
return export_job
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from apps.projects.models import ProjectStage
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.projects.models import Project, ProjectStage, ScriptVersion, VideoSegment
|
||||
|
||||
|
||||
STAGE_ORDER = [
|
||||
@@ -35,3 +37,107 @@ def can_enter_stage(current_stage: str, target_stage: str, allow_skip_storyboard
|
||||
|
||||
return StageTransition(current_stage, target_stage, False, "stage prerequisite is not satisfied")
|
||||
|
||||
|
||||
def initialize_project_pipeline(project: Project, *, placeholder_segments: int = 4) -> None:
|
||||
"""建立专业创作和极速成片共用的阶段、视频占位数据。幂等,可安全重试。"""
|
||||
for stage_name in STAGE_ORDER:
|
||||
ProjectStage.objects.get_or_create(project=project, stage=stage_name)
|
||||
for index in range(placeholder_segments):
|
||||
VideoSegment.objects.get_or_create(
|
||||
project=project,
|
||||
sort_order=index,
|
||||
defaults={"target_duration_seconds": 15},
|
||||
)
|
||||
|
||||
|
||||
def sync_video_segments_to_script(project: Project, script: ScriptVersion) -> None:
|
||||
"""把视频段数量和时长收口到采用脚本;已生成过的段绝不裁剪或改时长。"""
|
||||
if not script.is_adopted:
|
||||
return
|
||||
script_segments = list(script.segments.order_by("sort_order"))
|
||||
target = len(script_segments)
|
||||
segments = list(project.video_segments.order_by("sort_order"))
|
||||
while len(segments) > target:
|
||||
tail = segments[-1]
|
||||
if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists():
|
||||
tail.delete()
|
||||
segments.pop()
|
||||
else:
|
||||
break
|
||||
|
||||
next_order = (segments[-1].sort_order + 1) if segments else 0
|
||||
for _ in range(target - len(segments)):
|
||||
index = len(segments)
|
||||
seconds = script_segments[index].duration_seconds if index < target else 15
|
||||
segments.append(
|
||||
VideoSegment.objects.create(
|
||||
project=project,
|
||||
sort_order=next_order,
|
||||
target_duration_seconds=seconds,
|
||||
)
|
||||
)
|
||||
next_order += 1
|
||||
|
||||
stale: list[VideoSegment] = []
|
||||
for index, video_segment in enumerate(segments):
|
||||
if index >= target:
|
||||
break
|
||||
seconds = script_segments[index].duration_seconds
|
||||
if not seconds or video_segment.target_duration_seconds == seconds:
|
||||
continue
|
||||
if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists():
|
||||
continue
|
||||
video_segment.target_duration_seconds = seconds
|
||||
stale.append(video_segment)
|
||||
if stale:
|
||||
VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"])
|
||||
|
||||
|
||||
def adopt_script_version(project: Project, script: ScriptVersion) -> None:
|
||||
"""采用脚本并推进到资产阶段,供专业创作按钮和极速编排共同调用。"""
|
||||
ScriptVersion.objects.filter(project=project).exclude(id=script.id).update(is_adopted=False)
|
||||
if not script.is_adopted:
|
||||
script.is_adopted = True
|
||||
script.save(update_fields=["is_adopted", "updated_at"])
|
||||
sync_video_segments_to_script(project, script)
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
stage.completed_at = timezone.now()
|
||||
stage.error_message = ""
|
||||
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
|
||||
project.current_stage = ProjectStage.Stage.BASE_ASSETS
|
||||
project.status = Project.Status.ASSETING
|
||||
project.failure_reason = ""
|
||||
project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"])
|
||||
|
||||
|
||||
def finish_storyboard_stage(project: Project) -> None:
|
||||
"""故事板全部成功后推进到视频阶段,并再次校准片段数量。"""
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
stage.completed_at = timezone.now()
|
||||
stage.error_message = ""
|
||||
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
|
||||
adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
|
||||
if adopted_script is not None:
|
||||
sync_video_segments_to_script(project, adopted_script)
|
||||
project.current_stage = ProjectStage.Stage.VIDEO
|
||||
project.status = Project.Status.VIDEOING
|
||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
|
||||
|
||||
def finish_video_stage(project: Project) -> bool:
|
||||
"""全部片段成功即完成专业创作的视频阶段。"""
|
||||
segments = list(project.video_segments.values_list("status", "adopted_version_id"))
|
||||
if not segments or not all(status == VideoSegment.Status.SUCCEEDED and adopted for status, adopted in segments):
|
||||
return False
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.VIDEO)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
stage.completed_at = timezone.now()
|
||||
stage.error_message = ""
|
||||
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
|
||||
project.current_stage = ProjectStage.Stage.VIDEO
|
||||
project.status = Project.Status.COMPLETED
|
||||
project.failure_reason = ""
|
||||
project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"])
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,806 @@
|
||||
"""“极速成片”自动编排。
|
||||
|
||||
这不是另一套生成实现:脚本、基础资产、三视图、故事板、视频和合成全部调用专业创作现有服务。
|
||||
本模块只负责按状态推进,并把子任务 ID 持久化到 QuickCreateJob,方便刷新后恢复进度。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.db import connections, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask, ModelConfig
|
||||
from apps.ai.script_agent import stream_script_agent
|
||||
from apps.ai.services import (
|
||||
collect_video_review_blockers,
|
||||
create_export_job,
|
||||
generate_base_asset,
|
||||
generate_person_triview,
|
||||
get_default_model,
|
||||
poll_storyboard,
|
||||
submit_storyboard,
|
||||
submit_video_segment,
|
||||
)
|
||||
from apps.assets import assets_client
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.review import poll_team_reviews, submit_asset_for_review
|
||||
from apps.projects.models import (
|
||||
BaseAssetGroup,
|
||||
ExportJob,
|
||||
Project,
|
||||
ProjectStage,
|
||||
QuickCreateJob,
|
||||
Timeline,
|
||||
TimelineClip,
|
||||
VideoSegment,
|
||||
)
|
||||
from apps.projects.services.pipeline import (
|
||||
STAGE_ORDER,
|
||||
adopt_script_version,
|
||||
finish_storyboard_stage,
|
||||
finish_video_stage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POLL_DELAY_SECONDS = 10
|
||||
SCRIPT_POLL_SECONDS = 5
|
||||
SCRIPT_TIMEOUT = timedelta(minutes=4)
|
||||
SCRIPT_STOLEN_AFTER = timedelta(seconds=25)
|
||||
STALE_AFTER = timedelta(seconds=12)
|
||||
_ACTIVE_TASK_STATUSES = {
|
||||
AITask.Status.CREATED,
|
||||
AITask.Status.RESERVED,
|
||||
AITask.Status.SUBMITTED,
|
||||
AITask.Status.POLLING,
|
||||
AITask.Status.POSTPROCESSING,
|
||||
}
|
||||
_FINISHED_STATUSES = {
|
||||
QuickCreateJob.Status.SUCCEEDED,
|
||||
QuickCreateJob.Status.FAILED,
|
||||
QuickCreateJob.Status.CANCELLED,
|
||||
}
|
||||
|
||||
|
||||
def _is_finished(job: QuickCreateJob) -> bool:
|
||||
return job.status in _FINISHED_STATUSES
|
||||
|
||||
|
||||
def _quick_settings(project: Project) -> dict:
|
||||
wizard = dict((project.metadata or {}).get("wizard") or {})
|
||||
return {
|
||||
"aspect_ratio": str(wizard.get("aspect_ratio") or "9:16"),
|
||||
"resolution": str(wizard.get("resolution") or "720p").lower(),
|
||||
"total_duration": int(wizard.get("total_duration") or 15),
|
||||
"video_model_config_id": str(wizard.get("video_model_config_id") or ""),
|
||||
"video_model_name": str(wizard.get("video_model_name") or ""),
|
||||
"video_model_label": str(wizard.get("video_model_label") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _save_job(job: QuickCreateJob, **changes) -> None:
|
||||
fields = []
|
||||
for field, value in changes.items():
|
||||
setattr(job, field, value)
|
||||
fields.append(field)
|
||||
if fields:
|
||||
job.save(update_fields=[*fields, "updated_at"])
|
||||
|
||||
|
||||
def _safe_error(exc: Exception) -> str:
|
||||
raw = str(exc or "").strip()
|
||||
lower = raw.lower()
|
||||
if "insufficient credit" in lower or "额度不足" in raw:
|
||||
return "可用积分不足,极速成片已暂停"
|
||||
if "no active" in lower or "not configured" in lower or "没有可用" in raw:
|
||||
return "当前缺少可用的生成模型,请联系管理员配置"
|
||||
if "review" in lower or "审核" in raw:
|
||||
return "生成素材未通过审核,请进入专业模式调整后重试"
|
||||
return "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
|
||||
|
||||
def fail_quick_create(job: QuickCreateJob, message: str, *, internal_error: str = "") -> None:
|
||||
job.refresh_from_db(fields=["status", "metadata"])
|
||||
if job.status in {QuickCreateJob.Status.SUCCEEDED, QuickCreateJob.Status.CANCELLED}:
|
||||
return
|
||||
public_message = (message or "极速成片暂未完成,请稍后重试").strip()[:500]
|
||||
metadata = dict(job.metadata or {})
|
||||
if internal_error:
|
||||
metadata["internal_error"] = internal_error[:2000]
|
||||
_save_job(
|
||||
job,
|
||||
status=QuickCreateJob.Status.FAILED,
|
||||
error_message=public_message,
|
||||
message=public_message,
|
||||
metadata=metadata,
|
||||
)
|
||||
project = job.project
|
||||
if project.status != Project.Status.COMPLETED:
|
||||
project.status = Project.Status.FAILED
|
||||
project.failure_reason = public_message
|
||||
project.save(update_fields=["status", "failure_reason", "updated_at"])
|
||||
if project.current_stage in STAGE_ORDER:
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=project.current_stage)
|
||||
stage.status = ProjectStage.Status.FAILED
|
||||
stage.error_message = public_message
|
||||
stage.save(update_fields=["status", "error_message", "updated_at"])
|
||||
|
||||
|
||||
def cancel_quick_create(job: QuickCreateJob) -> QuickCreateJob:
|
||||
"""用户主动停掉进行中的极速成片;已完成的任务保持原样。"""
|
||||
if job.status == QuickCreateJob.Status.SUCCEEDED:
|
||||
return job
|
||||
if job.status in {QuickCreateJob.Status.FAILED, QuickCreateJob.Status.CANCELLED}:
|
||||
return job
|
||||
public_message = "已取消本次生成"
|
||||
metadata = dict(job.metadata or {})
|
||||
metadata["cancelled"] = True
|
||||
_save_job(
|
||||
job,
|
||||
status=QuickCreateJob.Status.CANCELLED,
|
||||
error_message=public_message,
|
||||
message=public_message,
|
||||
metadata=metadata,
|
||||
completed_at=timezone.now(),
|
||||
)
|
||||
project = job.project
|
||||
project.status = Project.Status.FAILED
|
||||
project.failure_reason = public_message
|
||||
project.save(update_fields=["status", "failure_reason", "updated_at"])
|
||||
if project.current_stage in STAGE_ORDER:
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=project.current_stage)
|
||||
if stage.status not in {ProjectStage.Status.SUCCEEDED, ProjectStage.Status.FAILED}:
|
||||
stage.status = ProjectStage.Status.FAILED
|
||||
stage.error_message = public_message
|
||||
stage.save(update_fields=["status", "error_message", "updated_at"])
|
||||
return job
|
||||
|
||||
|
||||
def _consume_script_agent(job: QuickCreateJob) -> None:
|
||||
project = job.project
|
||||
settings = _quick_settings(project)
|
||||
user = job.created_by or project.created_by
|
||||
if user is None:
|
||||
raise ValueError("极速成片任务缺少创建人")
|
||||
model_config = get_default_model(ModelConfig.Capability.TEXT)
|
||||
if model_config is None:
|
||||
raise ValueError("no active text model configured")
|
||||
|
||||
error_detail = ""
|
||||
stream = stream_script_agent(
|
||||
project=project,
|
||||
user=user,
|
||||
model_config=model_config,
|
||||
mode="auto",
|
||||
user_prompt=(
|
||||
"请根据商品名称与商品参考图信息,自动推荐最适合的"
|
||||
f"{settings['total_duration']}秒、{settings['aspect_ratio']}画幅带货方案。"
|
||||
),
|
||||
aspect_ratio=settings["aspect_ratio"],
|
||||
total_duration=settings["total_duration"],
|
||||
presentation_format="oral",
|
||||
video_structure="pain",
|
||||
entry_source="ai",
|
||||
persona="reviewer",
|
||||
)
|
||||
for frame in stream:
|
||||
job.refresh_from_db(fields=["status"])
|
||||
if _is_finished(job):
|
||||
return
|
||||
if not frame.startswith("data:"):
|
||||
continue
|
||||
try:
|
||||
event = json.loads(frame[5:].strip())
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if event.get("type") == "error":
|
||||
error_detail = str(event.get("detail") or "脚本生成失败")
|
||||
if error_detail:
|
||||
raise ValueError(error_detail)
|
||||
|
||||
script = project.script_versions.order_by("-created_at").first()
|
||||
if script is None or not script.segments.exists():
|
||||
raise ValueError("脚本生成后没有可用分镜")
|
||||
adopt_script_version(project, script)
|
||||
|
||||
|
||||
def consume_quick_script(job_id: str) -> None:
|
||||
"""Celery 专用:跑完专业创作同一套脚本 agent,再把编排推去资产阶段。"""
|
||||
from apps.projects.tasks import advance_quick_create_task
|
||||
|
||||
job = QuickCreateJob.objects.select_related("project__product", "created_by", "team").get(id=job_id)
|
||||
if _is_finished(job):
|
||||
return
|
||||
adopted = job.project.script_versions.filter(is_adopted=True).first()
|
||||
if adopted is not None and adopted.segments.exists():
|
||||
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
|
||||
return
|
||||
try:
|
||||
_consume_script_agent(job)
|
||||
except Exception as exc: # noqa: BLE001 — 脚本失败必须落终态
|
||||
logger.exception("quick create script failed for job %s", job.id)
|
||||
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
|
||||
return
|
||||
job.refresh_from_db(fields=["status"])
|
||||
if _is_finished(job):
|
||||
return
|
||||
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
|
||||
|
||||
|
||||
def _adopted_script(project: Project):
|
||||
script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
|
||||
if script is not None and script.segments.exists():
|
||||
return script
|
||||
return None
|
||||
|
||||
|
||||
def _parse_iso(value) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if timezone.is_naive(parsed):
|
||||
parsed = timezone.make_aware(parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
def _advance_product(job: QuickCreateJob) -> int:
|
||||
"""商品已在提交接口建好;这里只补卖点并切到脚本阶段,让页面先看到「识别商品」。"""
|
||||
from apps.products.models import ProductSellingPoint
|
||||
|
||||
product = job.project.product
|
||||
if product is not None and not product.selling_points.exists():
|
||||
ProductSellingPoint.objects.create(
|
||||
product=product,
|
||||
title=product.title,
|
||||
detail="由极速成片根据商品名称自动填写",
|
||||
sort_order=0,
|
||||
)
|
||||
_save_job(
|
||||
job,
|
||||
status=QuickCreateJob.Status.RUNNING,
|
||||
phase=QuickCreateJob.Phase.SCRIPT,
|
||||
progress=18,
|
||||
message="已识别商品与卖点,正在推荐脚本方向",
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def _advance_script(job: QuickCreateJob) -> int | None:
|
||||
if _adopted_script(job.project) is not None:
|
||||
_save_job(job, phase=QuickCreateJob.Phase.ASSETS, progress=48, message="脚本已生成,正在匹配模特与场景")
|
||||
return 1
|
||||
|
||||
metadata = dict(job.metadata or {})
|
||||
if not metadata.get("script_started"):
|
||||
from apps.projects.tasks import run_quick_script_task
|
||||
|
||||
metadata["script_started"] = True
|
||||
metadata["script_started_at"] = timezone.now().isoformat()
|
||||
_save_job(
|
||||
job,
|
||||
metadata=metadata,
|
||||
progress=28,
|
||||
message="正在根据商品名称与图片生成带货脚本",
|
||||
)
|
||||
run_quick_script_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
|
||||
return SCRIPT_POLL_SECONDS
|
||||
|
||||
failed = (
|
||||
AITask.objects.filter(
|
||||
project=job.project,
|
||||
task_type=AITask.Type.SCRIPT_GENERATION,
|
||||
status=AITask.Status.FAILED,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if failed is not None:
|
||||
fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message)
|
||||
return None
|
||||
|
||||
started_at = _parse_iso(metadata.get("script_started_at"))
|
||||
if started_at and timezone.now() - started_at > SCRIPT_TIMEOUT:
|
||||
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
|
||||
return None
|
||||
|
||||
elapsed = int((timezone.now() - started_at).total_seconds()) if started_at else 0
|
||||
_save_job(job, progress=min(44, 28 + elapsed // 8), message="正在生成分镜脚本…")
|
||||
return SCRIPT_POLL_SECONDS
|
||||
|
||||
|
||||
def _ensure_fallback_entities(project: Project) -> list[dict]:
|
||||
"""短脚本偶尔只提到商品;极速模式仍补齐一名模特和一个空镜场景,保证自动链路可走完。"""
|
||||
metadata = dict(project.metadata or {})
|
||||
entities = [dict(item) for item in (metadata.get("script_entities") or []) if isinstance(item, dict)]
|
||||
types = {str(item.get("type") or "") for item in entities}
|
||||
added_ids: list[str] = []
|
||||
if "character" not in types:
|
||||
entities.append(
|
||||
{
|
||||
"id": "quick_character_1",
|
||||
"type": "character",
|
||||
"name": "推荐模特",
|
||||
"visual_prompt": f"专业电商测评模特,亲和自然,适合展示{project.product.title}",
|
||||
"ref_index": 1,
|
||||
}
|
||||
)
|
||||
added_ids.append("quick_character_1")
|
||||
if "scene" not in types:
|
||||
settings = _quick_settings(project)
|
||||
entities.append(
|
||||
{
|
||||
"id": "quick_scene_1",
|
||||
"type": "scene",
|
||||
"name": "推荐场景",
|
||||
"visual_prompt": (
|
||||
f"明亮干净的电商产品展示空间,适合突出{project.product.title},"
|
||||
f"{settings['aspect_ratio']}构图"
|
||||
),
|
||||
"ref_index": 2,
|
||||
}
|
||||
)
|
||||
added_ids.append("quick_scene_1")
|
||||
if added_ids:
|
||||
metadata["script_entities"] = entities
|
||||
metadata["cast"] = [item["name"] for item in entities if item.get("type") == "character"]
|
||||
metadata["scenes"] = [item["name"] for item in entities if item.get("type") == "scene"]
|
||||
metadata["cast_prompts"] = {
|
||||
item["name"]: item.get("visual_prompt", "") for item in entities if item.get("type") == "character"
|
||||
}
|
||||
metadata["scene_prompts"] = {
|
||||
item["name"]: item.get("visual_prompt", "") for item in entities if item.get("type") == "scene"
|
||||
}
|
||||
project.metadata = metadata
|
||||
project.save(update_fields=["metadata", "updated_at"])
|
||||
script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
|
||||
if script is not None:
|
||||
for segment in script.segments.all():
|
||||
refs = list(segment.entity_refs or [])
|
||||
segment.entity_refs = [*refs, *(item for item in added_ids if item not in refs)]
|
||||
segment.save(update_fields=["entity_refs", "updated_at"])
|
||||
return entities
|
||||
|
||||
|
||||
def _start_base_assets(job: QuickCreateJob) -> None:
|
||||
project = job.project
|
||||
user = job.created_by or project.created_by
|
||||
if user is None:
|
||||
raise ValueError("极速成片任务缺少创建人")
|
||||
with transaction.atomic():
|
||||
job = QuickCreateJob.objects.select_for_update().select_related("project").get(id=job.id)
|
||||
metadata = dict(job.metadata or {})
|
||||
if metadata.get("base_asset_task_ids") or metadata.get("assets_started"):
|
||||
return
|
||||
metadata["assets_started"] = True
|
||||
job.metadata = metadata
|
||||
job.save(update_fields=["metadata", "updated_at"])
|
||||
entities = _ensure_fallback_entities(job.project)
|
||||
specs = [
|
||||
(BaseAssetGroup.Kind.PRODUCT, job.project.product.title, job.project.product.title),
|
||||
*[
|
||||
(BaseAssetGroup.Kind.PERSON, str(item.get("visual_prompt") or item.get("name") or "电商模特"), str(item.get("name") or "推荐模特"))
|
||||
for item in entities
|
||||
if item.get("type") == "character"
|
||||
],
|
||||
*[
|
||||
(BaseAssetGroup.Kind.SCENE, str(item.get("visual_prompt") or item.get("name") or "电商场景"), str(item.get("name") or "推荐场景"))
|
||||
for item in entities
|
||||
if item.get("type") == "scene"
|
||||
],
|
||||
]
|
||||
task_ids = []
|
||||
for kind, prompt, label in specs:
|
||||
task = generate_base_asset(project=job.project, user=user, kind=kind, prompt=prompt, label=label)
|
||||
task_ids.append(str(task.id))
|
||||
metadata = dict(job.metadata or {})
|
||||
metadata["base_asset_task_ids"] = task_ids
|
||||
_save_job(job, metadata=metadata, message="正在生成商品、模特与场景资产", progress=52)
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.BASE_ASSETS)
|
||||
stage.status = ProjectStage.Status.RUNNING
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
|
||||
|
||||
def _task_failure(tasks) -> AITask | None:
|
||||
return next(
|
||||
(
|
||||
task
|
||||
for task in tasks
|
||||
if task.status == AITask.Status.FAILED
|
||||
or task.status not in _ACTIVE_TASK_STATUSES | {AITask.Status.SUCCEEDED}
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _task_public_error(task: AITask) -> str:
|
||||
from apps.ai.generation_errors import public_error_for_task
|
||||
|
||||
public_error = public_error_for_task(task)
|
||||
return public_error.fallback_message if public_error else "生成任务失败,请稍后重试"
|
||||
|
||||
|
||||
def _advance_assets(job: QuickCreateJob) -> int | None:
|
||||
metadata = dict(job.metadata or {})
|
||||
base_ids = metadata.get("base_asset_task_ids") or []
|
||||
if not base_ids:
|
||||
_start_base_assets(job)
|
||||
return POLL_DELAY_SECONDS
|
||||
tasks = list(AITask.objects.filter(project=job.project, id__in=base_ids))
|
||||
failed = _task_failure(tasks)
|
||||
if failed is not None:
|
||||
fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message)
|
||||
return None
|
||||
if len(tasks) < len(base_ids) or any(task.status in _ACTIVE_TASK_STATUSES for task in tasks):
|
||||
done = sum(task.status == AITask.Status.SUCCEEDED for task in tasks)
|
||||
_save_job(job, progress=min(64, 52 + done * 4), message="正在匹配并生成模特与场景")
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
triview_ids = metadata.get("triview_task_ids")
|
||||
if triview_ids is None:
|
||||
person_groups = list(
|
||||
job.project.base_asset_groups.filter(
|
||||
kind=BaseAssetGroup.Kind.PERSON,
|
||||
task_id__in=base_ids,
|
||||
adopted_asset__isnull=False,
|
||||
).select_related("adopted_asset")
|
||||
)
|
||||
triview_ids = [
|
||||
str(generate_person_triview(project=job.project, user=job.created_by or job.project.created_by, portrait_asset=group.adopted_asset).id)
|
||||
for group in person_groups
|
||||
]
|
||||
metadata["triview_task_ids"] = triview_ids
|
||||
_save_job(job, metadata=metadata, progress=65, message="正在为推荐模特生成三视图")
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
triview_tasks = list(AITask.objects.filter(project=job.project, id__in=triview_ids))
|
||||
failed = _task_failure(triview_tasks)
|
||||
if failed is not None:
|
||||
fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message)
|
||||
return None
|
||||
if len(triview_tasks) < len(triview_ids) or any(task.status in _ACTIVE_TASK_STATUSES for task in triview_tasks):
|
||||
_save_job(job, progress=68, message="正在为推荐模特生成三视图")
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
for group in job.project.base_asset_groups.filter(adopted_asset__isnull=False):
|
||||
if group.kind != BaseAssetGroup.Kind.PRODUCT:
|
||||
group_meta = dict(group.metadata or {})
|
||||
group_meta["adopt"] = "adopted"
|
||||
group.metadata = group_meta
|
||||
group.save(update_fields=["metadata", "updated_at"])
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.BASE_ASSETS)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
stage.completed_at = timezone.now()
|
||||
stage.error_message = ""
|
||||
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
|
||||
job.project.current_stage = ProjectStage.Stage.STORYBOARD
|
||||
job.project.status = Project.Status.STORYBOARDING
|
||||
job.project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
_save_job(
|
||||
job,
|
||||
phase=QuickCreateJob.Phase.PRODUCTION,
|
||||
progress=72,
|
||||
message="正在生成故事板与镜头画面",
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def _reviews_ready(job: QuickCreateJob) -> bool | None:
|
||||
"""True=可出视频,False=继续等,None=审核失败且任务已终止。"""
|
||||
if not assets_client.is_enabled():
|
||||
return True
|
||||
poll_team_reviews(job.team)
|
||||
blockers = collect_video_review_blockers(job.project)
|
||||
if not blockers:
|
||||
return True
|
||||
if any(item.get("review_status") == "failed" for item in blockers):
|
||||
fail_quick_create(job, "生成素材未通过审核,请进入专业模式调整后重试")
|
||||
return None
|
||||
metadata = dict(job.metadata or {})
|
||||
wait_started = metadata.get("review_wait_started")
|
||||
if not wait_started:
|
||||
metadata["review_wait_started"] = timezone.now().isoformat()
|
||||
_save_job(job, metadata=metadata)
|
||||
else:
|
||||
try:
|
||||
started_at = datetime.fromisoformat(str(wait_started))
|
||||
if timezone.is_naive(started_at):
|
||||
started_at = timezone.make_aware(started_at)
|
||||
if timezone.now() - started_at > timedelta(minutes=20):
|
||||
fail_quick_create(job, "素材质量检查等待超时,请进入专业模式查看")
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
metadata["review_wait_started"] = timezone.now().isoformat()
|
||||
_save_job(job, metadata=metadata)
|
||||
for item in blockers:
|
||||
if not item.get("review_status") and item.get("asset_id"):
|
||||
asset = Asset.objects.filter(team=job.team, id=item["asset_id"]).first()
|
||||
if asset is not None:
|
||||
submit_asset_for_review(asset)
|
||||
_save_job(job, progress=82, message="故事板已完成,正在进行视频素材质量检查")
|
||||
return False
|
||||
|
||||
|
||||
def _start_videos(job: QuickCreateJob) -> None:
|
||||
from apps.projects.tasks import poll_video_segment_task
|
||||
|
||||
settings = _quick_settings(job.project)
|
||||
for segment in job.project.video_segments.order_by("sort_order"):
|
||||
if segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED, VideoSegment.Status.SUCCEEDED}:
|
||||
continue
|
||||
submit_video_segment(
|
||||
video_segment=segment,
|
||||
user=job.created_by or job.project.created_by,
|
||||
prompt="极速成片自动生成,严格遵循本镜故事板与脚本。",
|
||||
model_config_id=settings["video_model_config_id"] or None,
|
||||
aspect_ratio=settings["aspect_ratio"],
|
||||
resolution=settings["resolution"],
|
||||
)
|
||||
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
|
||||
metadata = dict(job.metadata or {})
|
||||
metadata["video_started"] = True
|
||||
_save_job(
|
||||
job,
|
||||
metadata=metadata,
|
||||
progress=86,
|
||||
message=f"正在生成{settings['total_duration']}秒 {settings['aspect_ratio']} 视频",
|
||||
)
|
||||
|
||||
|
||||
def _start_export(job: QuickCreateJob) -> None:
|
||||
from apps.projects.tasks import run_export_job_task
|
||||
from apps.ai.video_pricing import get_resolution
|
||||
|
||||
project = job.project
|
||||
settings = _quick_settings(project)
|
||||
total_duration = sum(project.video_segments.values_list("target_duration_seconds", flat=True)) or 15
|
||||
width, height = get_resolution(settings["aspect_ratio"], settings["resolution"])
|
||||
with transaction.atomic():
|
||||
timeline, _ = Timeline.objects.get_or_create(
|
||||
project=project,
|
||||
defaults={
|
||||
"name": f"{project.name} Timeline",
|
||||
"duration_seconds": total_duration,
|
||||
"aspect_ratio": settings["aspect_ratio"],
|
||||
"resolution": f"{width}x{height}",
|
||||
},
|
||||
)
|
||||
timeline.duration_seconds = total_duration
|
||||
timeline.aspect_ratio = settings["aspect_ratio"]
|
||||
timeline.resolution = f"{width}x{height}"
|
||||
timeline.save(update_fields=["duration_seconds", "aspect_ratio", "resolution", "updated_at"])
|
||||
timeline.clips.all().delete()
|
||||
start_ms = 0
|
||||
for segment in project.video_segments.select_related("adopted_version__asset").order_by("sort_order"):
|
||||
TimelineClip.objects.create(
|
||||
timeline=timeline,
|
||||
asset=segment.adopted_version.asset,
|
||||
sort_order=segment.sort_order,
|
||||
start_ms=start_ms,
|
||||
duration_ms=segment.target_duration_seconds * 1000,
|
||||
)
|
||||
start_ms += segment.target_duration_seconds * 1000
|
||||
export_job = create_export_job(timeline=timeline, user=job.created_by or project.created_by)
|
||||
run_export_job_task.delay(str(export_job.id))
|
||||
metadata = dict(job.metadata or {})
|
||||
metadata["export_job_id"] = str(export_job.id)
|
||||
_save_job(job, metadata=metadata, progress=96, message="各镜头已生成,正在合成为完整视频")
|
||||
|
||||
|
||||
def _videos_ready(job: QuickCreateJob) -> bool:
|
||||
segments = list(job.project.video_segments.order_by("sort_order"))
|
||||
if not segments:
|
||||
return False
|
||||
return all(
|
||||
segment.status == VideoSegment.Status.SUCCEEDED and segment.adopted_version_id
|
||||
for segment in segments
|
||||
)
|
||||
|
||||
|
||||
def _can_complete(job: QuickCreateJob) -> bool:
|
||||
if not _videos_ready(job):
|
||||
return False
|
||||
segments = list(job.project.video_segments.all())
|
||||
if len(segments) <= 1:
|
||||
return True
|
||||
export_job_id = (job.metadata or {}).get("export_job_id")
|
||||
if not export_job_id:
|
||||
return False
|
||||
export_job = ExportJob.objects.filter(id=export_job_id, timeline__project=job.project).first()
|
||||
return export_job is not None and export_job.status == ExportJob.Status.SUCCEEDED
|
||||
|
||||
|
||||
def _complete(job: QuickCreateJob) -> None:
|
||||
_save_job(
|
||||
job,
|
||||
status=QuickCreateJob.Status.SUCCEEDED,
|
||||
phase=QuickCreateJob.Phase.COMPLETE,
|
||||
progress=100,
|
||||
message="视频已生成",
|
||||
error_message="",
|
||||
completed_at=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
def _advance_production(job: QuickCreateJob) -> int | None:
|
||||
metadata = dict(job.metadata or {})
|
||||
if not metadata.get("storyboard_started"):
|
||||
submit_storyboard(project=job.project, user=job.created_by or job.project.created_by, prompt="")
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
stage.status = ProjectStage.Status.RUNNING
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
metadata["storyboard_started"] = True
|
||||
_save_job(job, metadata=metadata, progress=74, message="正在生成故事板与镜头画面")
|
||||
|
||||
if job.project.current_stage != ProjectStage.Stage.VIDEO:
|
||||
result = poll_storyboard(project=job.project, user=job.created_by or job.project.created_by)
|
||||
if result.get("status") == "failed":
|
||||
fail_quick_create(job, str(result.get("error") or "故事板生成失败"))
|
||||
return None
|
||||
if result.get("status") != "succeeded":
|
||||
total = max(1, int(result.get("total") or 1))
|
||||
done = int(result.get("done") or 0)
|
||||
_save_job(job, progress=min(80, 74 + round(done / total * 6)), message=f"正在生成故事板({done}/{total})")
|
||||
return POLL_DELAY_SECONDS
|
||||
finish_storyboard_stage(job.project)
|
||||
|
||||
review_state = _reviews_ready(job)
|
||||
if review_state is None:
|
||||
return None
|
||||
if not review_state:
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
if not (job.metadata or {}).get("video_started"):
|
||||
_start_videos(job)
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
segments = list(job.project.video_segments.order_by("sort_order"))
|
||||
failed = next((segment for segment in segments if segment.status == VideoSegment.Status.FAILED), None)
|
||||
if failed is not None:
|
||||
fail_quick_create(job, failed.error_message or f"第{failed.sort_order + 1}段视频生成失败")
|
||||
return None
|
||||
completed = sum(
|
||||
1
|
||||
for segment in segments
|
||||
if segment.status == VideoSegment.Status.SUCCEEDED and segment.adopted_version_id
|
||||
)
|
||||
if completed < len(segments):
|
||||
_save_job(job, progress=min(94, 86 + completed * 3), message=f"正在生成视频({completed}/{len(segments)})")
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
finish_video_stage(job.project)
|
||||
if len(segments) == 1:
|
||||
_complete(job)
|
||||
return None
|
||||
|
||||
export_job_id = (job.metadata or {}).get("export_job_id")
|
||||
if not export_job_id:
|
||||
_start_export(job)
|
||||
return POLL_DELAY_SECONDS
|
||||
export_job = ExportJob.objects.filter(id=export_job_id, timeline__project=job.project).first()
|
||||
if export_job is None:
|
||||
raise ValueError("视频合成任务不存在")
|
||||
if export_job.status == ExportJob.Status.FAILED:
|
||||
fail_quick_create(job, "视频片段已生成,但自动合成失败,请进入专业模式查看", internal_error=export_job.error_message)
|
||||
return None
|
||||
if export_job.status != ExportJob.Status.SUCCEEDED:
|
||||
_save_job(job, progress=max(96, min(99, int(export_job.progress or 0))), message="正在合成为完整视频")
|
||||
return POLL_DELAY_SECONDS
|
||||
_complete(job)
|
||||
return None
|
||||
|
||||
|
||||
_local_script_jobs: set[str] = set()
|
||||
|
||||
|
||||
def _script_task_started(job: QuickCreateJob) -> bool:
|
||||
return job.project.ai_tasks.filter(task_type=AITask.Type.SCRIPT_GENERATION).exists()
|
||||
|
||||
|
||||
def _claim_next_advance(job_id: str, delay_seconds: int) -> bool:
|
||||
"""同一任务同一时刻只允许一枚后续推进,避免轮询把队列打爆。"""
|
||||
with transaction.atomic():
|
||||
job = QuickCreateJob.objects.select_for_update().get(id=job_id)
|
||||
metadata = dict(job.metadata or {})
|
||||
eta = _parse_iso(metadata.get("next_advance_at"))
|
||||
if eta and eta > timezone.now():
|
||||
return False
|
||||
metadata["next_advance_at"] = (timezone.now() + timedelta(seconds=max(1, delay_seconds))).isoformat()
|
||||
job.metadata = metadata
|
||||
job.save(update_fields=["metadata", "updated_at"])
|
||||
return True
|
||||
|
||||
|
||||
def _run_quick_script_in_thread(job_id: str) -> None:
|
||||
"""共享 Redis 上的旧 worker 会把未注册任务直接丢掉;本机线程兜底,避免页面永久转圈。"""
|
||||
if job_id in _local_script_jobs:
|
||||
return
|
||||
_local_script_jobs.add(job_id)
|
||||
|
||||
def _worker() -> None:
|
||||
try:
|
||||
consume_quick_script(job_id)
|
||||
except Exception as exc: # noqa: BLE001 — 线程失败必须落终态
|
||||
job = QuickCreateJob.objects.select_related("project").filter(id=job_id).first()
|
||||
if job is not None and not _is_finished(job):
|
||||
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
|
||||
finally:
|
||||
_local_script_jobs.discard(job_id)
|
||||
connections.close_all()
|
||||
|
||||
threading.Thread(target=_worker, daemon=True, name=f"quick-script-{job_id[:8]}").start()
|
||||
|
||||
|
||||
def recover_quick_create(job: QuickCreateJob) -> None:
|
||||
"""前端轮询时把卡住的编排拉起来:超时落失败,被旧 worker 丢掉的脚本改走本机线程。"""
|
||||
job.refresh_from_db()
|
||||
if job.status == QuickCreateJob.Status.FAILED and _can_complete(job):
|
||||
_complete(job)
|
||||
return
|
||||
if _is_finished(job):
|
||||
return
|
||||
job_id = str(job.id)
|
||||
metadata = dict(job.metadata or {})
|
||||
if job.phase == QuickCreateJob.Phase.SCRIPT and metadata.get("script_started"):
|
||||
started_at = _parse_iso(metadata.get("script_started_at"))
|
||||
adopted = _adopted_script(job.project)
|
||||
local_running = job_id in _local_script_jobs
|
||||
timed_out = bool(started_at and timezone.now() - started_at > SCRIPT_TIMEOUT)
|
||||
stolen = bool(
|
||||
started_at
|
||||
and timezone.now() - started_at > SCRIPT_STOLEN_AFTER
|
||||
and adopted is None
|
||||
and not local_running
|
||||
)
|
||||
if stolen:
|
||||
metadata["script_local"] = True
|
||||
metadata["script_started_at"] = timezone.now().isoformat()
|
||||
_save_job(job, metadata=metadata, message="正在继续生成分镜脚本…")
|
||||
_run_quick_script_in_thread(job_id)
|
||||
return
|
||||
if timed_out and adopted is None and not local_running:
|
||||
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
|
||||
return
|
||||
if timezone.now() - job.updated_at > STALE_AFTER and _claim_next_advance(str(job.id), SCRIPT_POLL_SECONDS):
|
||||
from apps.projects.tasks import advance_quick_create_task
|
||||
|
||||
try:
|
||||
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
|
||||
except Exception: # noqa: BLE001 — 队列不可用时就地推进一步,避免永久 loading
|
||||
advance_quick_create(str(job.id))
|
||||
|
||||
|
||||
def advance_quick_create(job_id: str) -> int | None:
|
||||
"""推进一个状态并返回下次轮询秒数;返回 None 表示终态。
|
||||
|
||||
一次只做一段:识别商品 → 脚本(独立任务) → 资产 → 故事板/视频/合成。
|
||||
脚本不再在编排任务里同步吃完整条 SSE,避免页面一直停在「推荐脚本方向」。
|
||||
"""
|
||||
job = QuickCreateJob.objects.select_related("project__product", "created_by", "team").get(id=job_id)
|
||||
if _is_finished(job):
|
||||
return None
|
||||
try:
|
||||
if job.status == QuickCreateJob.Status.QUEUED:
|
||||
_save_job(job, status=QuickCreateJob.Status.RUNNING, progress=8, message="正在识别商品图片与卖点")
|
||||
if job.phase == QuickCreateJob.Phase.PRODUCT:
|
||||
return _advance_product(job)
|
||||
if job.phase == QuickCreateJob.Phase.SCRIPT:
|
||||
return _advance_script(job)
|
||||
if job.phase == QuickCreateJob.Phase.ASSETS:
|
||||
return _advance_assets(job)
|
||||
if job.phase == QuickCreateJob.Phase.PRODUCTION:
|
||||
return _advance_production(job)
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001 — 编排失败必须落可恢复终态,不能留下永久 loading
|
||||
logger.exception("quick create job %s failed", job.id)
|
||||
if _can_complete(job):
|
||||
_complete(job)
|
||||
return None
|
||||
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
|
||||
return None
|
||||
@@ -42,3 +42,56 @@ def run_export_job_task(self, export_job_id: str) -> str:
|
||||
export_job.save(update_fields=["status", "error_message", "updated_at"])
|
||||
raise
|
||||
return export_job_id
|
||||
|
||||
|
||||
QUICK_CREATE_QUEUE = "airshelf.quick"
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0, soft_time_limit=240, time_limit=270, queue=QUICK_CREATE_QUEUE)
|
||||
def run_quick_script_task(self, quick_job_id: str) -> str:
|
||||
"""脚本生成单独跑,避免把整条极速成片编排堵在一次 SSE 消费里。"""
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
|
||||
from apps.projects.models import QuickCreateJob
|
||||
from apps.projects.services.quick_create import consume_quick_script, fail_quick_create
|
||||
|
||||
try:
|
||||
consume_quick_script(quick_job_id)
|
||||
except SoftTimeLimitExceeded:
|
||||
job = QuickCreateJob.objects.select_related("project").filter(id=quick_job_id).first()
|
||||
if job is not None and job.status not in {
|
||||
QuickCreateJob.Status.SUCCEEDED,
|
||||
QuickCreateJob.Status.FAILED,
|
||||
QuickCreateJob.Status.CANCELLED,
|
||||
}:
|
||||
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
|
||||
raise
|
||||
return quick_job_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0, queue=QUICK_CREATE_QUEUE)
|
||||
def advance_quick_create_task(self, quick_job_id: str) -> str:
|
||||
"""一次只推进一个可重入状态,等待型阶段通过重新入队轮询,不占 worker 睡眠。"""
|
||||
from apps.projects.services.quick_create import advance_quick_create
|
||||
|
||||
next_delay = advance_quick_create(quick_job_id)
|
||||
if next_delay is not None:
|
||||
from apps.projects.services.quick_create import _claim_next_advance
|
||||
|
||||
if not _claim_next_advance(quick_job_id, int(next_delay)):
|
||||
return quick_job_id
|
||||
try:
|
||||
advance_quick_create_task.apply_async(
|
||||
args=[quick_job_id],
|
||||
countdown=max(1, int(next_delay)),
|
||||
queue=QUICK_CREATE_QUEUE,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — 重排失败不能留下永久“生成中”
|
||||
from apps.projects.models import QuickCreateJob
|
||||
from apps.projects.services.quick_create import fail_quick_create
|
||||
|
||||
job = QuickCreateJob.objects.select_related("project").filter(id=quick_job_id).first()
|
||||
if job is not None:
|
||||
fail_quick_create(job, "生成队列暂时中断,请稍后重试", internal_error=str(exc))
|
||||
raise
|
||||
return quick_job_id
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
from types import SimpleNamespace
|
||||
import uuid
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import ModelConfig, ModelProvider
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.products.models import Product, ProductImage
|
||||
from apps.projects.models import Project, ProjectStage, QuickCreateJob, ScriptSegment, ScriptVersion, VideoSegment, VideoSegmentVersion
|
||||
from apps.projects.serializers import ProjectListSerializer, QuickCreateJobSerializer
|
||||
from apps.projects.services.pipeline import initialize_project_pipeline
|
||||
from apps.projects.services.quick_create import (
|
||||
_start_videos,
|
||||
advance_quick_create,
|
||||
cancel_quick_create,
|
||||
recover_quick_create,
|
||||
)
|
||||
|
||||
|
||||
class QuickCreateApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="quick-owner", password="pass")
|
||||
self.team = Team.objects.create(name="Quick Team", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
def _uploaded_asset(self, **kwargs):
|
||||
upload = kwargs["upload"]
|
||||
return Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name=upload.name,
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=Asset.Category.PRODUCT_IMAGE,
|
||||
)
|
||||
|
||||
@patch("apps.projects.views.get_default_model", return_value=object())
|
||||
@patch("apps.projects.views.advance_quick_create_task.apply_async")
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
@patch("apps.projects.views._store_uploaded_asset")
|
||||
def test_submit_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model):
|
||||
store_asset.side_effect = self._uploaded_asset
|
||||
video_model_id = uuid.uuid4()
|
||||
get_model.side_effect = [
|
||||
object(),
|
||||
object(),
|
||||
SimpleNamespace(
|
||||
id=video_model_id,
|
||||
name="doubao-seedance-2-0-fast-260128",
|
||||
display_name="Seedance 2.0 Fast",
|
||||
metadata={"capabilities": {"resolutions": ["480p", "720p"], "aspect_ratios": ["9:16"], "durations": [15]}},
|
||||
),
|
||||
]
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "轻醒咖啡",
|
||||
"images": [
|
||||
SimpleUploadedFile("front.png", b"png-one", content_type="image/png"),
|
||||
SimpleUploadedFile("detail.webp", b"webp-two", content_type="image/webp"),
|
||||
],
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "720p",
|
||||
"total_duration": "30",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
product = Product.objects.get(title="轻醒咖啡")
|
||||
self.assertEqual(product.images.count(), 2)
|
||||
self.assertEqual(product.cover_asset_id, product.images.order_by("sort_order").first().asset_id)
|
||||
project = Project.objects.get(product=product)
|
||||
self.assertTrue(project.metadata["quick_create"])
|
||||
self.assertEqual(project.metadata["wizard"]["total_duration"], 30)
|
||||
self.assertEqual(project.metadata["wizard"]["resolution"], "720p")
|
||||
self.assertEqual(project.metadata["wizard"]["video_model_config_id"], str(video_model_id))
|
||||
self.assertEqual(project.stages.count(), 5)
|
||||
self.assertEqual(project.video_segments.count(), 2)
|
||||
self.assertTrue(product.selling_points.exists())
|
||||
job = QuickCreateJob.objects.get(project=project)
|
||||
self.assertEqual(str(job.id), response.data["id"])
|
||||
enqueue.assert_called_once()
|
||||
self.assertEqual(enqueue.call_args.kwargs["args"], [str(job.id)])
|
||||
self.assertEqual(enqueue.call_args.kwargs["queue"], "airshelf.quick")
|
||||
require_worker_task.assert_called_once_with("apps.projects.tasks.advance_quick_create_task")
|
||||
self.assertEqual(get_model.call_count, 3)
|
||||
self.assertEqual(len(response.data["product_images"]), 2)
|
||||
self.assertEqual(
|
||||
{item["asset_id"] for item in response.data["product_images"]},
|
||||
{str(image.asset_id) for image in product.images.order_by("sort_order")},
|
||||
)
|
||||
|
||||
@patch("apps.projects.views.get_default_model", return_value=object())
|
||||
@patch("apps.projects.views.advance_quick_create_task.apply_async")
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
@patch("apps.projects.views._store_uploaded_asset")
|
||||
def test_submit_reuses_source_product_images(self, store_asset, require_worker_task, enqueue, get_model):
|
||||
source_asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="已有主图.png",
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=Asset.Category.PRODUCT_IMAGE,
|
||||
)
|
||||
source = Product.objects.create(team=self.team, created_by=self.user, title="旧商品", cover_asset=source_asset)
|
||||
ProductImage.objects.create(product=source, asset=source_asset, sort_order=0, is_primary=True)
|
||||
get_model.side_effect = [object(), object(), SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={})]
|
||||
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "复用商品图",
|
||||
"source_product_id": str(source.id),
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "720p",
|
||||
"total_duration": "15",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
store_asset.assert_not_called()
|
||||
product = Product.objects.get(title="复用商品图")
|
||||
self.assertEqual(product.images.count(), 1)
|
||||
self.assertEqual(product.images.first().asset_id, source_asset.id)
|
||||
self.assertEqual(response.data["product_images"][0]["asset_id"], str(source_asset.id))
|
||||
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
def test_submit_rejects_foreign_source_product_images(self, require_worker_task):
|
||||
other = User.objects.create_user(username="quick-image-other", password="pass")
|
||||
other_team = Team.objects.create(name="Image Other Team", owner=other)
|
||||
foreign = Product.objects.create(team=other_team, created_by=other, title="别人的图")
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "偷图",
|
||||
"source_product_id": str(foreign.id),
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(Product.objects.filter(title="偷图").exists())
|
||||
|
||||
def test_status_returns_saved_product_images(self):
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="主图.png",
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=Asset.Category.PRODUCT_IMAGE,
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key="quick/product.png",
|
||||
bucket="test",
|
||||
content_type="image/png",
|
||||
preview_url="https://cdn.example/product.png",
|
||||
is_primary=True,
|
||||
)
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="带图商品", cover_asset=asset)
|
||||
ProductImage.objects.create(product=product, asset=asset, sort_order=0, is_primary=True)
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="带图项目")
|
||||
job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project)
|
||||
|
||||
response = self.client.get(f"/api/projects/quick-create-status/{job.id}/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["product_images"], [{"asset_id": str(asset.id), "url": "https://cdn.example/product.png"}])
|
||||
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
def test_submit_rejects_non_image(self, require_worker_task):
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "错误文件",
|
||||
"images": [SimpleUploadedFile("note.txt", b"text", content_type="text/plain")],
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(Product.objects.filter(title="错误文件").exists())
|
||||
|
||||
@patch("apps.projects.views.get_default_model", return_value=object())
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
def test_submit_rejects_resolution_not_supported_by_selected_model(self, require_worker_task, get_model):
|
||||
provider = ModelProvider.objects.create(name="quick-video-provider", display_name="Quick", status="active")
|
||||
model = ModelConfig.objects.create(
|
||||
provider=provider,
|
||||
name="quick-fast",
|
||||
display_name="Quick Fast",
|
||||
capability=ModelConfig.Capability.VIDEO,
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
metadata={"capabilities": {"resolutions": ["480p", "720p"], "durations": [15]}},
|
||||
)
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "不兼容清晰度",
|
||||
"images": [SimpleUploadedFile("front.png", b"png", content_type="image/png")],
|
||||
"video_model_config_id": str(model.id),
|
||||
"resolution": "1080p",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("不支持 1080P", response.data["detail"])
|
||||
self.assertFalse(Product.objects.filter(title="不兼容清晰度").exists())
|
||||
|
||||
def test_status_is_team_scoped(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="本团队商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="本团队项目")
|
||||
job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project)
|
||||
|
||||
other = User.objects.create_user(username="quick-other", password="pass")
|
||||
other_team = Team.objects.create(name="Other Team", owner=other)
|
||||
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
|
||||
other_client = APIClient()
|
||||
other_client.force_authenticate(other)
|
||||
|
||||
self.assertEqual(other_client.get(f"/api/projects/quick-create-status/{job.id}/").status_code, 404)
|
||||
|
||||
def test_cancel_stops_running_job(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="待取消商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="待取消项目")
|
||||
job = QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
project=project,
|
||||
status=QuickCreateJob.Status.RUNNING,
|
||||
phase=QuickCreateJob.Phase.SCRIPT,
|
||||
message="正在根据商品名称与图片生成带货脚本",
|
||||
)
|
||||
|
||||
response = self.client.post(f"/api/projects/quick-create-cancel/{job.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
job.refresh_from_db()
|
||||
self.assertEqual(job.status, QuickCreateJob.Status.CANCELLED)
|
||||
self.assertEqual(response.data["status"], "cancelled")
|
||||
self.assertIn("取消", response.data["error_message"])
|
||||
|
||||
def test_cancel_is_team_scoped(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="本团队商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="本团队项目")
|
||||
job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project)
|
||||
|
||||
other = User.objects.create_user(username="quick-cancel-other", password="pass")
|
||||
other_team = Team.objects.create(name="Cancel Other Team", owner=other)
|
||||
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
|
||||
other_client = APIClient()
|
||||
other_client.force_authenticate(other)
|
||||
|
||||
self.assertEqual(other_client.post(f"/api/projects/quick-create-cancel/{job.id}/").status_code, 404)
|
||||
|
||||
def test_history_lists_succeeded_jobs_for_team(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="历史商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="历史商品 · 极速成片")
|
||||
QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
project=project,
|
||||
status=QuickCreateJob.Status.SUCCEEDED,
|
||||
phase=QuickCreateJob.Phase.COMPLETE,
|
||||
)
|
||||
other = User.objects.create_user(username="quick-history-other", password="pass")
|
||||
other_team = Team.objects.create(name="History Other Team", owner=other)
|
||||
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
|
||||
other_product = Product.objects.create(team=other_team, created_by=other, title="别人的商品")
|
||||
other_project = Project.objects.create(team=other_team, created_by=other, product=other_product, name="别人的项目")
|
||||
QuickCreateJob.objects.create(
|
||||
team=other_team,
|
||||
created_by=other,
|
||||
project=other_project,
|
||||
status=QuickCreateJob.Status.SUCCEEDED,
|
||||
phase=QuickCreateJob.Phase.COMPLETE,
|
||||
)
|
||||
|
||||
response = self.client.get("/api/projects/quick-create-history/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
self.assertEqual(response.data["results"][0]["product_name"], "历史商品")
|
||||
self.assertEqual(response.data["results"][0]["title"], "历史商品 · 极速成片")
|
||||
|
||||
def test_list_serializer_flags_quick_create_projects(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="列表商品")
|
||||
quick = Project.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=product,
|
||||
name="列表商品 · 极速成片",
|
||||
metadata={"quick_create": True},
|
||||
)
|
||||
normal = Project.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=product,
|
||||
name="列表商品 · 专业创作",
|
||||
)
|
||||
self.assertTrue(ProjectListSerializer(quick).data["quick_create"])
|
||||
self.assertFalse(ProjectListSerializer(normal).data["quick_create"])
|
||||
|
||||
|
||||
class QuickCreateCoordinatorTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="quick-worker", password="pass")
|
||||
self.team = Team.objects.create(name="Worker Team", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="测试精华")
|
||||
self.project = Project.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=self.product,
|
||||
name="测试精华 · 极速成片",
|
||||
status=Project.Status.SCRIPTING,
|
||||
current_stage=ProjectStage.Stage.SCRIPT,
|
||||
)
|
||||
initialize_project_pipeline(self.project)
|
||||
self.job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=self.project)
|
||||
|
||||
@patch("apps.projects.tasks.run_quick_script_task.apply_async")
|
||||
def test_first_advance_recognizes_product_then_starts_script(self, start_script):
|
||||
delay = advance_quick_create(str(self.job.id))
|
||||
self.job.refresh_from_db()
|
||||
self.assertEqual(delay, 1)
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
|
||||
self.assertEqual(self.job.phase, QuickCreateJob.Phase.SCRIPT)
|
||||
start_script.assert_not_called()
|
||||
|
||||
delay = advance_quick_create(str(self.job.id))
|
||||
self.job.refresh_from_db()
|
||||
self.assertEqual(delay, 5)
|
||||
self.assertTrue(self.job.metadata.get("script_started"))
|
||||
start_script.assert_called_once()
|
||||
self.assertEqual(start_script.call_args.kwargs["args"], [str(self.job.id)])
|
||||
self.assertEqual(start_script.call_args.kwargs["queue"], "airshelf.quick")
|
||||
|
||||
def test_adopted_script_moves_to_assets_without_rerunning(self):
|
||||
script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True)
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场")
|
||||
self.job.status = QuickCreateJob.Status.RUNNING
|
||||
self.job.phase = QuickCreateJob.Phase.SCRIPT
|
||||
self.job.save(update_fields=["status", "phase", "updated_at"])
|
||||
|
||||
delay = advance_quick_create(str(self.job.id))
|
||||
self.job.refresh_from_db()
|
||||
self.assertEqual(delay, 1)
|
||||
self.assertEqual(self.job.phase, QuickCreateJob.Phase.ASSETS)
|
||||
|
||||
def test_cancelled_job_does_not_keep_advancing(self):
|
||||
cancel_quick_create(self.job)
|
||||
delay = advance_quick_create(str(self.job.id))
|
||||
self.job.refresh_from_db()
|
||||
self.assertIsNone(delay)
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.CANCELLED)
|
||||
|
||||
@patch("apps.projects.services.quick_create._run_quick_script_in_thread")
|
||||
def test_recover_reruns_script_locally_when_queue_drops_it(self, run_local):
|
||||
self.job.status = QuickCreateJob.Status.RUNNING
|
||||
self.job.phase = QuickCreateJob.Phase.SCRIPT
|
||||
self.job.metadata = {
|
||||
"script_started": True,
|
||||
"script_started_at": (timezone.now() - timedelta(minutes=5)).isoformat(),
|
||||
}
|
||||
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
|
||||
|
||||
recover_quick_create(self.job)
|
||||
self.job.refresh_from_db()
|
||||
self.assertTrue(self.job.metadata.get("script_local"))
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
|
||||
run_local.assert_called_once_with(str(self.job.id))
|
||||
|
||||
def test_recover_marks_success_when_video_already_finished(self):
|
||||
self.project.video_segments.exclude(sort_order=0).delete()
|
||||
segment = self.project.video_segments.get(sort_order=0)
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="已完成视频",
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.VIDEO_CLIP,
|
||||
)
|
||||
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
|
||||
segment.adopted_version = version
|
||||
segment.status = VideoSegment.Status.SUCCEEDED
|
||||
segment.save(update_fields=["adopted_version", "status", "updated_at"])
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.save(update_fields=["status", "phase", "error_message", "updated_at"])
|
||||
|
||||
recover_quick_create(self.job)
|
||||
self.job.refresh_from_db()
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED)
|
||||
self.assertEqual(self.job.phase, QuickCreateJob.Phase.COMPLETE)
|
||||
|
||||
@patch("apps.projects.services.quick_create._reviews_ready", return_value=True)
|
||||
def test_production_counts_ready_videos_without_adding_version_ids(self, _reviews):
|
||||
self.project.video_segments.exclude(sort_order=0).delete()
|
||||
segment = self.project.video_segments.get(sort_order=0)
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="计数视频",
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.VIDEO_CLIP,
|
||||
)
|
||||
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
|
||||
segment.adopted_version = version
|
||||
segment.status = VideoSegment.Status.SUCCEEDED
|
||||
segment.save(update_fields=["adopted_version", "status", "updated_at"])
|
||||
self.job.status = QuickCreateJob.Status.RUNNING
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.metadata = {"storyboard_started": True, "video_started": True}
|
||||
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
|
||||
self.project.current_stage = ProjectStage.Stage.VIDEO
|
||||
self.project.save(update_fields=["current_stage", "updated_at"])
|
||||
|
||||
delay = advance_quick_create(str(self.job.id))
|
||||
self.job.refresh_from_db()
|
||||
self.assertIsNone(delay)
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED)
|
||||
|
||||
def test_success_serializer_returns_direct_single_segment_video(self):
|
||||
self.project.video_segments.exclude(sort_order=0).delete()
|
||||
segment = self.project.video_segments.get(sort_order=0)
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="极速成片",
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.VIDEO_CLIP,
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key="quick/final.mp4",
|
||||
bucket="test",
|
||||
content_type="video/mp4",
|
||||
preview_url="https://cdn.example/quick.mp4",
|
||||
is_primary=True,
|
||||
)
|
||||
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
|
||||
segment.adopted_version = version
|
||||
segment.status = VideoSegment.Status.SUCCEEDED
|
||||
segment.save(update_fields=["adopted_version", "status", "updated_at"])
|
||||
self.job.status = QuickCreateJob.Status.SUCCEEDED
|
||||
self.job.phase = QuickCreateJob.Phase.COMPLETE
|
||||
self.job.progress = 100
|
||||
self.job.save(update_fields=["status", "phase", "progress", "updated_at"])
|
||||
|
||||
data = QuickCreateJobSerializer(self.job).data
|
||||
self.assertEqual(data["phase_index"], 3)
|
||||
self.assertEqual(data["result"]["video_url"], "https://cdn.example/quick.mp4")
|
||||
self.assertEqual(data["result"]["duration_seconds"], 15)
|
||||
|
||||
@patch("apps.projects.tasks.poll_video_segment_task.apply_async")
|
||||
@patch("apps.projects.services.quick_create.submit_video_segment")
|
||||
def test_video_start_uses_quick_create_core_parameters(self, submit_video, schedule_poll):
|
||||
self.project.metadata = {
|
||||
"wizard": {
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "1080p",
|
||||
"total_duration": 30,
|
||||
"video_model_config_id": "11111111-1111-4111-8111-111111111111",
|
||||
}
|
||||
}
|
||||
self.project.save(update_fields=["metadata", "updated_at"])
|
||||
self.project.video_segments.exclude(sort_order__lt=2).delete()
|
||||
|
||||
_start_videos(self.job)
|
||||
|
||||
self.assertEqual(submit_video.call_count, 2)
|
||||
for call in submit_video.call_args_list:
|
||||
self.assertEqual(call.kwargs["aspect_ratio"], "16:9")
|
||||
self.assertEqual(call.kwargs["resolution"], "1080p")
|
||||
self.assertEqual(call.kwargs["model_config_id"], "11111111-1111-4111-8111-111111111111")
|
||||
self.job.refresh_from_db()
|
||||
self.assertIn("30秒 16:9", self.job.message)
|
||||
@@ -834,6 +834,26 @@ class ProjectApiTests(TestCase):
|
||||
self.assertIn("[avoice][vo0][vo1]amix=inputs=3", graph)
|
||||
self.assertIn("[anarr][abgm]amix=inputs=2", graph)
|
||||
|
||||
def test_export_command_uses_selected_output_size(self):
|
||||
"""极速成片选横屏/清晰度后,多段合成必须沿用该尺寸,不能回退成固定竖屏。"""
|
||||
from apps.projects.services.export import _build_export_command
|
||||
|
||||
cmd = _build_export_command(
|
||||
n=1,
|
||||
specs=[{"ts": 0.0, "te": 15.0, "dur": 15.0}],
|
||||
starts=[0.0],
|
||||
total=15.0,
|
||||
transition="none",
|
||||
sub_overlays=[],
|
||||
bgm_name=None,
|
||||
bgm_volume=1.0,
|
||||
output_width=1920,
|
||||
output_height=1080,
|
||||
)
|
||||
graph = cmd[cmd.index("-filter_complex") + 1]
|
||||
self.assertIn("scale=1920:1080", graph)
|
||||
self.assertIn("pad=1920:1080", graph)
|
||||
|
||||
def test_save_timeline_updates_voiceover_offsets(self):
|
||||
"""拖动字幕块后保存:按 asset 回写句内起点 offset_ms,未拖动的句不受影响。"""
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VOFF")
|
||||
|
||||
@@ -44,9 +44,10 @@ from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.serializers import AssetFileSerializer
|
||||
from apps.assets.storage import TosStorage
|
||||
from apps.common.api import TeamScopedViewSetMixin
|
||||
from apps.common.celery_health import require_worker
|
||||
from apps.common.celery_health import require_worker, require_worker_task
|
||||
from apps.ai.generation_errors import classify_generation_error, public_error_for_task
|
||||
from apps.ai.video_digest import VideoDigestError, digest_project_video
|
||||
from apps.products.models import Product, ProductImage, ProductSellingPoint
|
||||
|
||||
from .models import (
|
||||
BaseAssetGroup,
|
||||
@@ -54,6 +55,7 @@ from .models import (
|
||||
ExportJob,
|
||||
Project,
|
||||
ProjectStage,
|
||||
QuickCreateJob,
|
||||
ScriptSegment,
|
||||
ScriptTemplate,
|
||||
ScriptVersion,
|
||||
@@ -72,6 +74,7 @@ from .serializers import (
|
||||
ExportJobSerializer,
|
||||
ProjectListSerializer,
|
||||
ProjectSerializer,
|
||||
QuickCreateJobSerializer,
|
||||
ScriptTemplateSerializer,
|
||||
ScriptVersionSerializer,
|
||||
StoryboardVersionSerializer,
|
||||
@@ -79,10 +82,15 @@ from .serializers import (
|
||||
is_playable_video,
|
||||
)
|
||||
from .services.export import run_export_job_in_thread
|
||||
from .services.pipeline import STAGE_ORDER
|
||||
from .services.pipeline import (
|
||||
adopt_script_version,
|
||||
finish_storyboard_stage,
|
||||
initialize_project_pipeline,
|
||||
sync_video_segments_to_script,
|
||||
)
|
||||
from .services.script_import import ScriptFileError, extract_script_text
|
||||
from .services.templates import build_template_fields, coerce_persona, coerce_template_combo, render_outline_text
|
||||
from .tasks import poll_video_segment_task
|
||||
from .tasks import advance_quick_create_task, poll_video_segment_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,7 +109,12 @@ class ServerSentEventRenderer(BaseRenderer):
|
||||
|
||||
def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, name: str) -> Asset:
|
||||
"""把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。"""
|
||||
suffix = Path(upload.name).suffix.lower() or (".mp4" if asset_type == Asset.Type.VIDEO else ".mp3")
|
||||
fallback_suffix = {
|
||||
Asset.Type.IMAGE: ".png",
|
||||
Asset.Type.VIDEO: ".mp4",
|
||||
Asset.Type.AUDIO: ".mp3",
|
||||
}.get(asset_type, ".bin")
|
||||
suffix = Path(upload.name).suffix.lower() or fallback_suffix
|
||||
asset_id = uuid.uuid4()
|
||||
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
|
||||
stored = TosStorage().upload_fileobj(
|
||||
@@ -435,17 +448,271 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
by_status = {row["status"]: row["n"] for row in base.values("status").annotate(n=Count("id"))}
|
||||
return Response({"total": base.count(), "by_status": by_status})
|
||||
|
||||
@action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="quick-create",
|
||||
parser_classes=[MultiPartParser, FormParser],
|
||||
)
|
||||
def quick_create(self, request):
|
||||
"""商品名称 + 1–9 张图 → 新建商品与项目,并启动完整自动生产流水线。"""
|
||||
require_worker_task("apps.projects.tasks.advance_quick_create_task")
|
||||
name = str(request.data.get("name") or "").strip()
|
||||
uploads = request.FILES.getlist("images") or request.FILES.getlist("images[]")
|
||||
source_product_id = str(request.data.get("source_product_id") or "").strip()
|
||||
requested_asset_ids = [
|
||||
str(value).strip()
|
||||
for value in (request.data.getlist("image_asset_ids") or request.data.getlist("image_asset_ids[]") or [])
|
||||
if str(value).strip()
|
||||
]
|
||||
if not name:
|
||||
return Response({"detail": "请填写商品名称"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if len(name) > 255:
|
||||
return Response({"detail": "商品名称不能超过255个字符"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not uploads and not source_product_id:
|
||||
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if len(uploads) > 9:
|
||||
return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
allowed_types = {"image/jpeg", "image/png", "image/webp"}
|
||||
for upload in uploads:
|
||||
if (upload.content_type or "").lower() not in allowed_types:
|
||||
return Response({"detail": "仅支持 JPG、PNG 或 WebP 商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if upload.size > 20 * 1024 * 1024:
|
||||
return Response({"detail": "单张商品图片不能超过20MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
|
||||
resolution = str(request.data.get("resolution") or "720p").lower()
|
||||
try:
|
||||
total_duration = int(request.data.get("total_duration") or 15)
|
||||
except (TypeError, ValueError):
|
||||
total_duration = 0
|
||||
if aspect_ratio not in {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}:
|
||||
return Response({"detail": "请选择有效的视频比例"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if resolution not in {"480p", "720p", "1080p", "4k"}:
|
||||
return Response({"detail": "请选择有效的视频分辨率"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if total_duration not in {15, 30, 45, 60}:
|
||||
return Response({"detail": "视频时长仅支持15、30、45或60秒"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
text_model = get_default_model(ModelConfig.Capability.TEXT)
|
||||
image_model = get_default_model(ModelConfig.Capability.IMAGE)
|
||||
requested_video_model_id = str(request.data.get("video_model_config_id") or "").strip()
|
||||
video_model = None
|
||||
if requested_video_model_id:
|
||||
try:
|
||||
video_model_uuid = uuid.UUID(requested_video_model_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response({"detail": "请选择有效的视频模型"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
video_model = (
|
||||
ModelConfig.objects.select_related("provider")
|
||||
.filter(
|
||||
id=video_model_uuid,
|
||||
capability=ModelConfig.Capability.VIDEO,
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
provider__status="active",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
video_model = get_default_model(ModelConfig.Capability.VIDEO)
|
||||
|
||||
missing = [
|
||||
label
|
||||
for model, label in ((text_model, "文本"), (image_model, "图像"), (video_model, "视频"))
|
||||
if model is None
|
||||
]
|
||||
if missing:
|
||||
return Response(
|
||||
{"detail": f"当前缺少可用的{'、'.join(missing)}模型,请联系管理员配置"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
capabilities = dict((video_model.metadata or {}).get("capabilities") or {})
|
||||
supported_resolutions = set(capabilities.get("resolutions") or (video_model.metadata or {}).get("resolutions") or [])
|
||||
supported_ratios = set(capabilities.get("aspect_ratios") or [])
|
||||
supported_durations = set(capabilities.get("durations") or (video_model.metadata or {}).get("durations") or [])
|
||||
if supported_resolutions and resolution not in supported_resolutions:
|
||||
return Response(
|
||||
{"detail": f"{video_model.display_name} 不支持 {resolution.upper()},请更换清晰度或模型"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if supported_ratios and aspect_ratio not in supported_ratios:
|
||||
return Response(
|
||||
{"detail": f"{video_model.display_name} 不支持 {aspect_ratio} 比例"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# 极速成片把总时长拆成多个15秒镜头,视频模型只需支持单镜15秒。
|
||||
if supported_durations and 15 not in supported_durations:
|
||||
return Response(
|
||||
{"detail": f"{video_model.display_name} 不支持15秒单镜生成"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
team = self.get_team()
|
||||
reused_assets = []
|
||||
if source_product_id:
|
||||
try:
|
||||
source_uuid = uuid.UUID(source_product_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
source = (
|
||||
Product.objects.filter(id=source_uuid, team=team)
|
||||
.prefetch_related("images__asset")
|
||||
.first()
|
||||
)
|
||||
if source is None:
|
||||
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
source_images = list(source.images.all())
|
||||
by_asset_id = {str(image.asset_id): image.asset for image in source_images}
|
||||
if requested_asset_ids:
|
||||
if any(asset_id not in by_asset_id for asset_id in requested_asset_ids):
|
||||
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
reused_assets = [by_asset_id[asset_id] for asset_id in requested_asset_ids]
|
||||
else:
|
||||
reused_assets = [image.asset for image in source_images]
|
||||
if not reused_assets and not uploads:
|
||||
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if len(reused_assets) + len(uploads) > 9:
|
||||
return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
with transaction.atomic():
|
||||
assets = list(reused_assets)
|
||||
assets.extend(
|
||||
_store_uploaded_asset(
|
||||
team=team,
|
||||
user=request.user,
|
||||
upload=upload,
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
category=Asset.Category.PRODUCT_IMAGE,
|
||||
name=upload.name or f"{name}-商品图{index + 1}",
|
||||
)
|
||||
for index, upload in enumerate(uploads)
|
||||
)
|
||||
if not assets:
|
||||
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
product = Product.objects.create(
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
title=name,
|
||||
business_type=Product.BusinessType.ECOMMERCE,
|
||||
cover_asset=assets[0],
|
||||
)
|
||||
ProductImage.objects.bulk_create(
|
||||
[
|
||||
ProductImage(
|
||||
product=product,
|
||||
asset=asset,
|
||||
sort_order=index,
|
||||
is_primary=index == 0,
|
||||
)
|
||||
for index, asset in enumerate(assets)
|
||||
]
|
||||
)
|
||||
project = Project.objects.create(
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
product=product,
|
||||
name=f"{name} · 极速成片",
|
||||
status=Project.Status.SCRIPTING,
|
||||
current_stage=ProjectStage.Stage.SCRIPT,
|
||||
metadata={
|
||||
"quick_create": True,
|
||||
"wizard": {
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"resolution": resolution,
|
||||
"total_duration": total_duration,
|
||||
"video_model_config_id": str(video_model.id),
|
||||
"video_model_name": video_model.name,
|
||||
"video_model_label": video_model.display_name,
|
||||
"presentation_format": "oral",
|
||||
"video_structure": "pain",
|
||||
"persona": "reviewer",
|
||||
},
|
||||
},
|
||||
)
|
||||
initialize_project_pipeline(project, placeholder_segments=max(1, total_duration // 15))
|
||||
if not product.selling_points.exists():
|
||||
ProductSellingPoint.objects.create(
|
||||
product=product,
|
||||
title=name,
|
||||
detail="由极速成片根据商品名称自动填写",
|
||||
sort_order=0,
|
||||
)
|
||||
job = QuickCreateJob.objects.create(
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
project=project,
|
||||
status=QuickCreateJob.Status.QUEUED,
|
||||
phase=QuickCreateJob.Phase.PRODUCT,
|
||||
progress=0,
|
||||
message="等待开始极速成片",
|
||||
)
|
||||
|
||||
try:
|
||||
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
|
||||
except Exception as exc: # noqa: BLE001 — broker 极小窗口失败也必须给任务落终态
|
||||
from .services.quick_create import fail_quick_create
|
||||
|
||||
logger.exception("quick create enqueue failed for job %s", job.id)
|
||||
fail_quick_create(job, "生成队列暂时不可用,请稍后重试", internal_error=str(exc))
|
||||
return Response(QuickCreateJobSerializer(job).data, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
|
||||
job = self._quick_job_queryset().get(id=job.id)
|
||||
return Response(QuickCreateJobSerializer(job).data, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
def _quick_job_queryset(self):
|
||||
return (
|
||||
QuickCreateJob.objects.select_related("project__product", "project__timeline")
|
||||
.prefetch_related(
|
||||
"project__product__images__asset__files",
|
||||
"project__script_versions",
|
||||
"project__base_asset_groups",
|
||||
"project__storyboard_shots__adopted_version__asset__files",
|
||||
"project__video_segments__adopted_version__asset__files",
|
||||
"project__timeline__export_jobs__output_asset__files",
|
||||
)
|
||||
.filter(team=self.get_team())
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path=r"quick-create-status/(?P<job_id>[^/.]+)")
|
||||
def quick_create_status(self, request, job_id=None):
|
||||
job = self._quick_job_queryset().filter(id=job_id).first()
|
||||
if job is None:
|
||||
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
from .services.quick_create import recover_quick_create
|
||||
|
||||
try:
|
||||
recover_quick_create(job)
|
||||
except Exception: # noqa: BLE001 — 恢复失败不能把进度接口打成 500
|
||||
logger.exception("quick create recover failed for job %s", job.id)
|
||||
job = self._quick_job_queryset().get(id=job.id)
|
||||
return Response(QuickCreateJobSerializer(job).data)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path=r"quick-create-cancel/(?P<job_id>[^/.]+)")
|
||||
def quick_create_cancel(self, request, job_id=None):
|
||||
job = self._quick_job_queryset().filter(id=job_id).first()
|
||||
if job is None:
|
||||
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if job.status == QuickCreateJob.Status.SUCCEEDED:
|
||||
return Response({"detail": "已完成的任务不能取消"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
from .services.quick_create import cancel_quick_create
|
||||
|
||||
cancel_quick_create(job)
|
||||
job = self._quick_job_queryset().get(id=job.id)
|
||||
return Response(QuickCreateJobSerializer(job).data)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="quick-create-history")
|
||||
def quick_create_history(self, request):
|
||||
jobs = self._quick_job_queryset().filter(status=QuickCreateJob.Status.SUCCEEDED).order_by("-created_at")
|
||||
return Response({
|
||||
"count": jobs.count(),
|
||||
"results": QuickCreateJobSerializer(jobs[:30], many=True).data,
|
||||
})
|
||||
|
||||
@transaction.atomic
|
||||
def perform_create(self, serializer):
|
||||
project = serializer.save(team=self.get_team(), created_by=self.request.user)
|
||||
self._apply_wizard_template(project)
|
||||
for stage in STAGE_ORDER:
|
||||
ProjectStage.objects.create(project=project, stage=stage)
|
||||
# 先铺 4 段占位;真实段数与每段时长在采用脚本时由 _sync_video_segments_to_script 收口
|
||||
for index in range(4):
|
||||
VideoSegment.objects.create(
|
||||
project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX
|
||||
)
|
||||
# 先铺 4 段占位;真实段数与每段时长在采用脚本时收口。
|
||||
initialize_project_pipeline(project, placeholder_segments=4)
|
||||
|
||||
def _apply_wizard_template(self, project) -> None:
|
||||
"""新建向导选了套路模板 → 后端按模板真值回填 metadata.wizard,并计一次使用。
|
||||
@@ -583,19 +850,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
project = self.get_object()
|
||||
script_id = request.data.get("script_version_id")
|
||||
script = ScriptVersion.objects.select_for_update().get(project=project, id=script_id)
|
||||
ScriptVersion.objects.filter(project=project).update(is_adopted=False)
|
||||
script.is_adopted = True
|
||||
script.save(update_fields=["is_adopted", "updated_at"])
|
||||
# 采用脚本时把视频片段数对齐到这版分镜数:用户常在「采用前」就增删分镜,
|
||||
# 那些编辑因 _sync 的 is_adopted 闸而未同步到 VideoSegment(项目创建时固定铺了 4 段),
|
||||
# 不在此收口的话视频步骤会一直停在 4 段,与故事板/分镜数对不上。已生成的段绝不动。
|
||||
self._sync_video_segments_to_script(project, script)
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
project.current_stage = ProjectStage.Stage.BASE_ASSETS
|
||||
project.status = Project.Status.ASSETING
|
||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
adopt_script_version(project, script)
|
||||
return Response(ScriptVersionSerializer(script).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="extract-entities")
|
||||
@@ -900,47 +1155,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
时长同步:脚本镜主流程固定 15 秒,而出片、计价、时间线读的都是
|
||||
VideoSegment.target_duration_seconds。不同步的话脚本写了 15 秒、出片仍按旧默认跑。
|
||||
已出片的段不改时长——改了会跟已渲染的成片对不上。"""
|
||||
if not script.is_adopted:
|
||||
return
|
||||
script_segments = list(script.segments.order_by("sort_order"))
|
||||
target = len(script_segments)
|
||||
segments = list(project.video_segments.order_by("sort_order"))
|
||||
while len(segments) > target:
|
||||
tail = segments[-1]
|
||||
if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists():
|
||||
tail.delete()
|
||||
segments.pop()
|
||||
else:
|
||||
break
|
||||
next_order = (segments[-1].sort_order + 1) if segments else 0
|
||||
for _ in range(target - len(segments)):
|
||||
index = len(segments)
|
||||
seconds = (
|
||||
script_segments[index].duration_seconds
|
||||
if index < target
|
||||
else SEGMENT_DURATION_MAX
|
||||
)
|
||||
segments.append(
|
||||
VideoSegment.objects.create(
|
||||
project=project, sort_order=next_order, target_duration_seconds=seconds
|
||||
)
|
||||
)
|
||||
next_order += 1
|
||||
|
||||
# 已存在的段:只对「还没出过片」的回填脚本时长,已渲染的保持原样
|
||||
stale: list[VideoSegment] = []
|
||||
for index, video_segment in enumerate(segments):
|
||||
if index >= target:
|
||||
break
|
||||
seconds = script_segments[index].duration_seconds
|
||||
if not seconds or video_segment.target_duration_seconds == seconds:
|
||||
continue
|
||||
if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists():
|
||||
continue
|
||||
video_segment.target_duration_seconds = seconds
|
||||
stale.append(video_segment)
|
||||
if stale:
|
||||
VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"])
|
||||
sync_video_segments_to_script(project, script)
|
||||
|
||||
def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None:
|
||||
"""采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐):
|
||||
@@ -1133,16 +1348,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
project = self.get_object()
|
||||
result = poll_storyboard(project=project, user=request.user)
|
||||
if result.get("status") == "succeeded":
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
# 进入视频阶段前再收口一次视频片段数,确保与采用版分镜数一致(已生成的段不动)
|
||||
adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
|
||||
if adopted_script is not None:
|
||||
self._sync_video_segments_to_script(project, adopted_script)
|
||||
project.current_stage = ProjectStage.Stage.VIDEO
|
||||
project.status = Project.Status.VIDEOING
|
||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
finish_storyboard_stage(project)
|
||||
http_status = status.HTTP_200_OK if result.get("status") == "succeeded" else status.HTTP_202_ACCEPTED
|
||||
return Response(result, status=http_status)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ description: >
|
||||
"hook": "前3秒主打钩子(一句话)",
|
||||
"tone": "种草|测评|剧情|痛点",
|
||||
"presentation_format": "口播|短剧|Vlog",
|
||||
"video_structure": "痛点解决|前后对比|测评验证|场景种草",
|
||||
"video_structure": "痛点解决|前后对比|测评验证|场景种草|促销抢购|知识分享",
|
||||
"aspect_ratio": "9:16",
|
||||
"total_duration": 30,
|
||||
"segment_count": 3,
|
||||
@@ -59,7 +59,7 @@ description: >
|
||||
"index": 0,
|
||||
"duration": 15,
|
||||
"role": "钩子|痛点|卖点|CTA",
|
||||
"narration": "这一镜被说出来的台词/旁白,15秒口播目标55-63字(下限54,上限67)",
|
||||
"narration": "这一镜被说出来的台词/旁白,15秒口播目标66-74字(下限64,上限78)",
|
||||
"speaker": "可选,指向某 entity 的 id;画外旁白时为 null",
|
||||
"visual": "0-3s:近景,……\\n3-8s:手持跟拍,开始使用商品\\n8-12s:特写,商品用法过程\\n12-15s:拉回中近景,使用后的反应",
|
||||
"product_exposure": "商品露出方式(手持/特写/使用中)",
|
||||
@@ -73,7 +73,7 @@ description: >
|
||||
字段纪律:
|
||||
- `tone` 必须是四选一枚举;`role` 必须是四选一枚举。
|
||||
- **表现形式与视频结构由输入给定**:`presentation_format`(`口播|短剧|Vlog`)和
|
||||
`video_structure`(`痛点解决|前后对比|测评验证|场景种草`)**原样回填到输出里**,不要自己改。
|
||||
`video_structure`(`痛点解决|前后对比|测评验证|场景种草|促销抢购|知识分享`)**原样回填到输出里**,不要自己改。
|
||||
输入没给时,按商品品类与人群自行选一组最合适的,并如实填进这两个字段。
|
||||
- **画幅由输入给定**:`aspect_ratio` 默认 `"9:16"`(电商竖屏主场景),但**不写死**——输入指定了其他比例(如 `"16:9"`、`"1:1"`、`"4:5"`)就照用,原样透传给下游。画幅只影响 `visual` 的构图措辞,不改变结构与镜数。
|
||||
- **总时长由输入给定**:`total_duration` 取 **15 / 30 / 45 / 60**(15 秒步进),输入未指定时默认 `30`。**不要写死成别的数。**
|
||||
@@ -96,14 +96,15 @@ description: >
|
||||
- **场景必抽,且每镜必绑一个场景**:每条脚本**至少声明 1 个 `type:"scene"` 实体**表示画面所在环境;**每个 segment 的 `entity_refs` 必须恰好引用一个 scene**。多镜在同一环境就**复用同一个 scene id**(绝不为同一环境写两份 visual_prompt,否则下游背景漂移);只有真正换了环境才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(如「宿舍书桌」「厨房台面」),没有合适环境时复用主场景。
|
||||
- `visual_prompt` 由你自动生成,小白无需打字。
|
||||
- **每镜 `visual` 必须按秒拆分镜**:15 秒一场里至少 3 刀、目标 4 刀,写成多行:
|
||||
`0-3s:景别;构图;运镜;谁在做什么;手和商品的空间关系;信息变化`
|
||||
第一条从 0 秒起,最后一条接到 15s。每条都要有景别/运镜,15 秒内至少切两次景别。
|
||||
`0-3s:景别;机位;运镜;谁在做什么;手和商品的空间关系;信息变化`
|
||||
第一条从 0 秒起,最后一条接到 15s。每条都要有景别/机位/运镜,15 秒内至少切两次景别。
|
||||
**禁止**用一句静态动作撑满 15 秒(如「女主举起商品展示」)。这是给故事板和 Seedance 的导演说明书,**不占 narration 字数**。
|
||||
输入是 `【镜头 01】` 分镜稿时,把原稿的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进导演说明书,不要压成一句画面摘要。
|
||||
- **`visual` 固定使用导演层级**:先写 `【本镜任务】`(这一段要完成的情绪/信息变化),再写
|
||||
`【声音】`(可发生的同期声、人声状态或「无配乐,仅同期声」),接着写 `【画面内容】`,
|
||||
`【声音】`(台词/旁白状态、音效、背景音乐、字幕;没有写无),接着写 `【画面内容】`,
|
||||
最后在该标题下列出 3–5 条秒级分镜。不要把基础设定、画面风格、镜头动作、声音、旁白混成一段散文。
|
||||
- **每条秒级分镜都写执行细节**:景别、构图、运镜、动作、信息变化五项至少有四项;例如
|
||||
`3-8s:中近景;人物位于画面左三分之一,商品从右下入画;手持跟拍;拧开瓶盖并挤出质地;观众第一次看清产品状态。`
|
||||
- **每条秒级分镜都写执行细节**:景别、机位、运镜、动作、信息变化五项至少有四项;例如
|
||||
`3-8s:中近景;人物侧后方;手持跟拍;拧开瓶盖并挤出质地;观众第一次看清产品状态。`
|
||||
这比「展示商品」更容易被分镜图和视频模型准确执行。
|
||||
- **画面物理常识**:商品用法必须是真人会做的。茶/咖啡 = 热水、蒸汽、茶汤渐染,禁止茶包丢进看起来像冷白开的杯子;护肤品 = 挤出/涂抹;食品 = 打开/入口。手从真实方向入画,禁止悬浮肢体、反关节、商品凭空出现。每条分镜写清容器/包装长什么样、液体或材质当前状态。
|
||||
- 不要输出 schema 之外的字段,也不要省略必填字段。
|
||||
@@ -117,8 +118,8 @@ description: >
|
||||
|
||||
详见 `references/methodology.md`「旁白红线」。最关键的几条:
|
||||
- **口语化**,像真人对着镜头说话,不准书面腔 / AI 腔。
|
||||
- **口播 15 秒镜要说满**:`narration` **54–67 字**(写作目标 **55–63 字,3–5 句短句**)。
|
||||
上限为 `duration × 4.5` 且不超过 68 字。禁止一句 20 字收工。
|
||||
- **口播 15 秒镜要说满**:`narration` **64–78 字**(写作目标 **66–74 字,4–5 句短句**)。
|
||||
上限为 `duration × 5.2` 且不超过 78 字。禁止一句 20 字收工。
|
||||
商品名全片点名 1–2 次即可,其余镜用卖点原词和可感知细节(口感/气味/动作/使用场景)。
|
||||
Vlog 才允许个别镜纯画面;口播禁止整镜无声。
|
||||
- **禁违规词**:医疗功效(治疗/根治/抗癌…)、绝对化用语(最/第一/100%/国家级…)一律不写。
|
||||
@@ -139,7 +140,7 @@ description: >
|
||||
| 画幅 | **默认 9:16 竖屏**,由输入可覆盖(16:9 / 1:1 / 4:5 等照用) |
|
||||
| 总时长 | **15 / 30 / 45 / 60 秒**,由输入给定;未指定时默认 30 |
|
||||
| 表现形式 | **口播 / 短剧 / Vlog**,由输入给定;未指定时按品类人群自选 |
|
||||
| 视频结构 | **痛点解决 / 前后对比 / 测评验证 / 场景种草**,由输入给定;未指定时按品类人群自选 |
|
||||
| 视频结构 | **痛点解决 / 前后对比 / 测评验证 / 场景种草 / 促销抢购 / 知识分享**,由输入给定;未指定时按品类人群自选 |
|
||||
| 分镜 | **每镜固定 15 秒**;镜数 = 总时长 / 15 |
|
||||
| 镜头功能 | 钩子 → 痛点 → 卖点 → CTA(黄金结构;具体骨架以视频结构套路为准) |
|
||||
| 发声方式 | 出片模型直接生成画面+音效+人声(**不走 TTS**) |
|
||||
@@ -154,7 +155,7 @@ description: >
|
||||
| ---- | ---- | ---- | ---- |
|
||||
| **① 全自动** | 用户只给【商品信息 + 前置条件(表现形式/视频结构/时长/人群/卖点勾选)】,无主题无原稿 | 凭商品与前置条件,自动定镜数、选 tone、造 entity、按套路填镜 | `methodology.md` + `hook-library.md` + `category-playbook.md` + `platform-tone.md` |
|
||||
| **② 一句话** | 用户额外给了一句主题(如「主打熬夜党救星」) | 以该主题为脚本主轴扩写,其余同全自动 | 同上(主题优先于自动选题) |
|
||||
| **③ 改稿 / 上传脚本** | 用户给了已有脚本/文案 | **保留用户原意**,先识别原稿结构与叙述顺序,再增强钩子/节奏/卖点证明/CTA,并归一化到 JSON 结构 | `methodology.md` + `hook-library.md` + `checklist.md` |
|
||||
| **③ 改稿 / 上传脚本 / 上传视频提炼** | 用户给了已有脚本,或一份 `【镜头 01】` 导演分镜稿 | **脚本**:保留用户原意,增强钩子/节奏/卖点证明/CTA,并归一化到 JSON。**视频分镜稿**:照搬镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,把人物、商品、品牌、台词换成当前商品;`visual` 必须把景别/机位/运镜/动作/表情/音效/背景音乐/字幕/备注折进导演说明书,不要压成一句画面摘要 | `methodology.md` + `hook-library.md` + `checklist.md` |
|
||||
| 信息不足 | 连商品信息都缺,无法生成 | 唯一允许的回退:用一句话问清最少必要信息 | — |
|
||||
|
||||
**进入任何模式前,必须先读取该行列出的参考资料。**
|
||||
@@ -165,7 +166,7 @@ description: >
|
||||
并根据输入指定的表现形式与视频结构,**各读一份**:
|
||||
|
||||
- `references/playbooks/format-{口播→oral | 短剧→drama | Vlog→vlog}.md`
|
||||
- `references/playbooks/structure-{痛点解决→pain | 前后对比→contrast | 测评验证→review | 场景种草→scene}.md`
|
||||
- `references/playbooks/structure-{痛点解决→pain | 前后对比→contrast | 测评验证→review | 场景种草→scene | 促销抢购→promo | 知识分享→knowledge}.md`
|
||||
|
||||
> 运行时后端只会把被指定的那两份套路拼进上下文。**看到哪份就用哪份,
|
||||
> 不要凭记忆套用没加载进来的套路。**
|
||||
@@ -182,7 +183,7 @@ description: >
|
||||
4. **抽取/创建 entities** — 识别脚本需要的角色 / 场景 / 商品;为每个 entity 写一份**全脚本共用**的 `visual_prompt`(保证多镜同一角色同一张脸);可选写 `voice_ref` 锁音色。
|
||||
5. **按套路填结构** — 优先用视频结构套路里的骨架给每个 segment 分配 `role`;
|
||||
套路没覆盖到的用「role 按镜数分配」表兜底;钩子镜套用该套路指定的钩子写法。
|
||||
6. **写 narration / visual / 商品露出** — 口播 15 秒镜旁白 55–63 字(下限 54)、口语化、过红线;
|
||||
6. **写 narration / visual / 商品露出** — 口播 15 秒镜旁白 66–74 字(下限 64)、口语化、过红线;
|
||||
**visual 按秒拆成 3–5 条分镜**;必须把输入给出的卖点原词和商品描述细节写进旁白或对白,不要每句只重复商品名;
|
||||
发声方式按表现形式套路来;每镜规划自然的 `product_exposure`。
|
||||
7. **连引用** — 填 `entity_refs` 与 `speaker`,确认每个 entity 都被引用、id 都合法。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- [ ] `tone` ∈ `{种草, 测评, 剧情, 痛点}`。
|
||||
- [ ] `aspect_ratio` 存在;输入指定了就用输入值,未指定为 `"9:16"`。
|
||||
- [ ] `presentation_format` ∈ `{口播, 短剧, Vlog}`,且与输入一致(输入没给则自选并如实填写)。
|
||||
- [ ] `video_structure` ∈ `{痛点解决, 前后对比, 测评验证, 场景种草}`,同上。
|
||||
- [ ] `video_structure` ∈ `{痛点解决, 前后对比, 测评验证, 场景种草, 促销抢购, 知识分享}`,同上。
|
||||
- [ ] **组合合法**:不是「短剧 × 测评验证」(唯一禁用组合,见 `playbooks/combo-matrix.md`)。
|
||||
- [ ] `total_duration` 是 **15 / 30 / 45 / 60**。
|
||||
- [ ] **每个 `segment.duration` 必须是 15**。
|
||||
@@ -35,6 +35,8 @@
|
||||
- [ ] 顶层 `hook` 与钩子镜口径一致。
|
||||
- [ ] 卖点镜的卖点**挂在前面铺的痛点上**,不是干罗列参数。
|
||||
- [ ] CTA 给了明确动作(点小黄车/领券/主页链接)。**场景种草结构的 CTA 要软**,别硬喊。
|
||||
- [ ] **促销抢购**:所有价格、优惠、库存、赠品、开售时间和适用条件均来自商品资料;缺失就不写,绝不虚构限时限量。
|
||||
- [ ] **知识分享**:知识点可由商品资料支持,商品是在判断方法建立后自然出现;没有虚构专业身份、数据、标准或功效。
|
||||
- [ ] **结构骨架走完了**:段的顺序与功能符合 `playbooks/structure-*.md` 里那份骨架表。
|
||||
- [ ] **表现形式的镜头语言到位**:景别变化、单镜时长、人物数量符合 `playbooks/format-*.md`。
|
||||
- [ ] role 序列符合结构套路;套路没覆盖的部分回落到 `methodology.md`「role 按镜数分配」表。
|
||||
@@ -46,9 +48,9 @@
|
||||
|
||||
## C. 旁白红线扫描(逐镜)
|
||||
|
||||
- [ ] **每镜 `narration` ≤ `duration × 4.5` 字,且绝不超过 68 字**(15 秒镜 ≤67 字)。
|
||||
- [ ] **口播 15 秒镜旁白 ≥ 54 字**(写作目标 55–63 字,3–5 句)。禁止一句 20 字收工;口播禁止整镜无声。
|
||||
- [ ] **秒级分镜**:每镜 `visual` 至少 3 条 `0-3s:景别,动作`,从 0 接到 15s;15 秒内至少切两次景别。
|
||||
- [ ] **每镜 `narration` ≤ `duration × 5.2` 字,且绝不超过 78 字**(15 秒镜 ≤78 字)。
|
||||
- [ ] **口播 15 秒镜旁白 ≥ 64 字**(写作目标 66–74 字,4–5 句)。禁止一句 20 字收工;口播禁止整镜无声。
|
||||
- [ ] **秒级分镜**:每镜 `visual` 至少 3 条 `0-3s:景别;机位;运镜;动作`,从 0 接到 15s;15 秒内至少切两次景别。上传视频提炼稿还要把音效/背景音乐/字幕折进 `【声音】`。
|
||||
- [ ] **商品原词**:旁白/对白出现了输入给出的至少一个勾选卖点原词;商品名全片点名 1–2 次即可,其余镜写描述/卖点细节,没有用空话替换。
|
||||
- [ ] 每镜 `visual` 能撑满 15 秒(至少 72 字),写清手/商品/容器的空间关系和真实用法,不是一句静态动作。
|
||||
- [ ] 口语化,无书面腔/AI 腔("综上""不仅…而且""值得一提"等已清除)。
|
||||
|
||||
@@ -96,8 +96,8 @@
|
||||
- **口语化**:像真人对着镜头唠嗑,不准书面腔 / 不准 AI 腔("综上所述""不仅…而且""值得一提的是"全禁)。
|
||||
- **必须用商品原词**:输入给了商品名、品牌、卖点,就用这些词,不许改成「补水/好用/值得买」这种空卖点。品类示例句只是语气参考。
|
||||
- **`narration` 字数按这一镜的秒数算**:出片模型在镜内直接发声。
|
||||
可懂语速上限约 **4.5 字 / 秒**,口播下限约 **3.6 字 / 秒**(再短镜头空一半)。
|
||||
**每镜上限 `duration × 4.5` 字,且不超过 68 字。口播 15 秒镜还有下限 54 字。**
|
||||
可懂语速上限约 **5.2 字 / 秒**,口播下限约 **4.3 字 / 秒**(再短镜头空一半)。
|
||||
**每镜上限 `duration × 5.2` 字,且不超过 78 字。口播 15 秒镜还有下限 64 字。**
|
||||
|
||||
| 单镜时长 | 下限 | 字数上限 | 写作目标 |
|
||||
| ---- | ---- | ---- | ---- |
|
||||
@@ -107,7 +107,7 @@
|
||||
| 8 秒 | — | 28 字 | ≤ 25 字 |
|
||||
| 10 秒 | — | 35 字 | ≤ 31 字 |
|
||||
| 12 秒 | — | 42 字 | ≤ 38 字 |
|
||||
| 15 秒 | **54 字** | 67 字 | **55–63 字,3–5 句** |
|
||||
| 15 秒 | **64 字** | 78 字 | **66–74 字,4–5 句** |
|
||||
|
||||
主流程每镜都是 15 秒:**要说满,不要宁可短。** 一句 20 字撑不满镜头。
|
||||
留白靠句间停顿和画面,不靠少写字。只有 Vlog 才允许个别镜纯画面无声。
|
||||
@@ -132,13 +132,15 @@
|
||||
|
||||
```
|
||||
【本镜任务】本段要让观众看懂的变化或悬念
|
||||
【声音】无配乐,仅同期声:包装摩擦声、倒水声;人物说话自然停顿
|
||||
【声音】台词/旁白状态;音效;背景音乐;字幕(没有写无)
|
||||
【画面内容】
|
||||
0-3s:景别;构图;运镜;动作;信息变化
|
||||
3-8s:景别;构图;运镜;动作;信息变化
|
||||
0-3s:景别;机位;运镜;动作;表情;信息变化
|
||||
3-8s:景别;机位;运镜;动作;表情;信息变化
|
||||
...
|
||||
```
|
||||
|
||||
输入如果是 `【镜头 01】` 导演分镜稿(上传视频提炼),把原稿每镜的景别、机位、运镜、人物动作、表情、音效、背景音乐、字幕、备注折进对应栏,不要压成一句画面摘要。原稿写「无 / 不可见 / 听不清」的不要编造。
|
||||
|
||||
这样「人物/商品/场景设定」留在 entities 中保持全片一致,「本镜任务」管情绪推进,
|
||||
「画面内容」只管可执行的镜头,声音也不会漏给视频模型。不得写虚构或无法发生的音效。
|
||||
|
||||
@@ -150,7 +152,7 @@
|
||||
```
|
||||
|
||||
- 第一条从 **0 秒**起,最后一条接到 **15s**。
|
||||
- 每一条写:**景别 + 构图 + 运镜 + 动作 + 信息变化**(至少四项),并写清谁、手从哪来、商品/容器什么样。
|
||||
- 每一条写:**景别 + 机位 + 运镜 + 动作 + 信息变化**(至少四项),并写清谁、手从哪来、商品/容器什么样。
|
||||
- 15 秒内至少切 **两次景别**(近景 / 特写 / 手持跟拍…)。
|
||||
- 商品用法必须真实:茶要热水和蒸汽,不要把茶包丢进冷白开;手不要悬浮。
|
||||
- **一场一个动作链**:把 15 秒拆成同一件事的起因 → 操作 → 可见变化 → 反应,不能把无关的
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 表现形式决定**怎么拍**(谁在画面里、镜头怎么动、谁在说话)。
|
||||
> 视频结构决定**怎么说服**(信息按什么顺序给、靠什么建立可信)。
|
||||
> 两者正交:同一个结构用三种形式拍出来完全不同,同一种形式套四个结构说服力也不同。
|
||||
> 两者正交:同一个结构用三种形式拍出来完全不同,同一种形式套六个结构说服力也不同。
|
||||
|
||||
**加载规则:** 本文件每次都读。另外只读输入指定的那一份 `format-*.md` 和一份 `structure-*.md`,
|
||||
不要凭记忆套用没加载的套路。
|
||||
@@ -11,11 +11,11 @@
|
||||
|
||||
## 一、合法组合表
|
||||
|
||||
| | 痛点解决 | 前后对比 | 测评验证 | 场景种草 |
|
||||
| ---- | :----: | :----: | :----: | :----: |
|
||||
| **口播** | ✅ 强 | ✅ 可 | ✅ **最强** | ✅ 可 |
|
||||
| **短剧** | ✅ **最强** | ✅ 强 | ❌ **禁用** | ✅ 可 |
|
||||
| **Vlog** | ✅ 可 | ✅ 可 | ✅ 强 | ✅ **最强** |
|
||||
| | 痛点解决 | 前后对比 | 测评验证 | 场景种草 | 促销抢购 | 知识分享 |
|
||||
| ---- | :----: | :----: | :----: | :----: | :----: | :----: |
|
||||
| **口播** | ✅ 强 | ✅ 可 | ✅ **最强** | ✅ 可 | ✅ **强** | ✅ **强** |
|
||||
| **短剧** | ✅ **最强** | ✅ 强 | ❌ **禁用** | ✅ 可 | ✅ 可 | ✅ 可 |
|
||||
| **Vlog** | ✅ 可 | ✅ 可 | ✅ 强 | ✅ **最强** | ✅ 可 | ✅ 可 |
|
||||
|
||||
**唯一禁用组合:短剧 × 测评验证。**
|
||||
理由:测评的说服力来自「这是真实发生的」,短剧的本质是「这是演的」。
|
||||
@@ -48,7 +48,7 @@
|
||||
最弱的一格,因为口播是「对镜说话」,天然打断场景沉浸。
|
||||
- **必须弱化对镜**:人在场景里自然活动,偶尔看向镜头,不要全程盯着
|
||||
- 台词改成自言自语的语气,不要介绍腔
|
||||
- **每镜仍要说满 36–47 字**:写具体时刻、动作、使用感,不要一句商品名就收
|
||||
- **每镜仍要说满 66–74 字**:写具体时刻、动作、使用感,不要一句商品名就收
|
||||
- 商品名全片点名 1–2 次,其余镜用卖点原词和场景细节
|
||||
- 如果商品本身氛围属性强(香氛、饮品),优先考虑改用 Vlog
|
||||
|
||||
@@ -94,6 +94,18 @@
|
||||
- 商品在两到三个时间节点重复出现,靠**重复**建立记忆
|
||||
- 至少一个镜头让商品外形被看清,这是本组合唯一的硬要求
|
||||
|
||||
### 促销抢购(口播优先)
|
||||
优惠机制清晰时,口播最容易把“适合谁、怎么买、买哪个规格”说清。
|
||||
- 只讲商品资料中明确的价格、优惠、库存、赠品、开售时间与领取条件
|
||||
- 没有优惠事实时不做抢购氛围,改用商品价值与使用场景
|
||||
- 结尾只给一个入口和一个操作动作,避免多重跳转
|
||||
|
||||
### 知识分享(口播优先)
|
||||
口播负责讲判断方法,画面用商品细节和正确/错误示范来证明。
|
||||
- 先讲用户能复述的一个判断标准,再出现商品
|
||||
- 知识结论必须由商品资料支持;不能编数据、专业身份或功效
|
||||
- 30 秒起步,才有空间完成“误区 → 方法 → 演示 → 商品承接”
|
||||
|
||||
---
|
||||
|
||||
## 三、组合与时长的关系
|
||||
@@ -106,5 +118,7 @@
|
||||
| 前后对比 | 15 秒 | 前后对比至少要撑满一镜 |
|
||||
| 测评验证 | 30 秒 | 举证需要时间,1 镜证据不足 |
|
||||
| 场景种草 | 30 秒 | 氛围需要铺陈,太短像随机片段 |
|
||||
| 促销抢购 | 15 秒 | 优惠机制已清楚时,可压进适合谁 + 怎么买 + CTA |
|
||||
| 知识分享 | 30 秒 | 判断方法和实物演示都需要时间 |
|
||||
|
||||
低于该结构的最短时长时,优先保住「钩子 + 结构核心 + CTA」三件,其余全砍。
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
- **单镜固定 15 秒**(短剧用镜内动作和对白交换撑满这 15 秒,不要靠切很多短镜)
|
||||
- 一镜一次冲突或一次对话交换,**别在一镜里塞完整三幕**
|
||||
- 有对白的镜,每句 `line` ≤ 25 字(两个人说话,总量还是受 68 字限制)
|
||||
- 有对白的镜,每句 `line` ≤ 25 字(两个人说话,总量还是受 78 字限制)
|
||||
|
||||
**推荐总时长 45 秒(3 镜)。** 短剧需要装下三幕,低于 30 秒会讲不完整;
|
||||
超过 60 秒在电商场景下性价比开始下降。
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
- **单镜固定 15 秒**(口播里说完一个完整意思需要时间;15 秒靠景别切换和手部动作撑满,不要切碎)
|
||||
- 一句话讲一件事,**一镜 2–4 句短句**,禁止一句收工
|
||||
- **visual 按秒拆 3–5 刀**,写成 `0-3s:近景……` / `3-8s:手持……` / `8-12s:特写……` / `12-15s:拉回……`
|
||||
- 语速按 **3.6–4.5 字 / 秒** 估:15 秒镜 **54–67 字**,写作目标 **55–63 字**
|
||||
- 语速按 **4.3–5.2 字 / 秒** 估:15 秒镜 **64–78 字**,写作目标 **66–74 字**
|
||||
|
||||
**推荐总时长 30 秒(2 镜)。** 口播超过 45 秒完播率掉得很快,除非是测评验证这种
|
||||
需要举证的结构。
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# 视频结构 · 知识分享
|
||||
|
||||
> 适合需要用户教育、选购判断、使用指导或原理说明的数码家电、美妆个护、食品饮料、服饰材质、家居用品和工具类商品。
|
||||
|
||||
## 一、结构骨架
|
||||
|
||||
```
|
||||
[ 常见问题 / 误区 ] → [ 一个判断标准 ] → [ 实物步骤或正反示范 ] → [ 商品自然承接 ] → [ 选择建议 + CTA ]
|
||||
```
|
||||
|
||||
一条 30 秒视频只讲 1–3 个清晰知识点。商品不是突然插入的卖点,而是符合前述判断标准的具体实例。
|
||||
|
||||
## 二、写法
|
||||
|
||||
- 开头提出目标用户关心的问题、常见误区、实用技巧,或一个可验证的知识结论。
|
||||
- 用通俗语言解释原理;专业概念必须转换成用户能观察、能操作的判断方法。
|
||||
- 画面必须有实物细节、操作步骤、正确与错误示范或具体场景,不能全靠人物讲解。
|
||||
- 商品出现后,只说明它的材质、设计、功能或使用方式如何对应前面已经建立的判断标准。
|
||||
- 收尾总结适用条件或选择方法,再自然引导用户查看商品。
|
||||
|
||||
## 三、红线
|
||||
|
||||
- 不得编造数据、标准、专业身份或案例,也不得把一般知识偷换成商品功效承诺。
|
||||
- 健康、食品、美妆相关内容避免医疗、治疗及绝对化效果表述。
|
||||
- 当商品资料不足以支持某知识结论时,不要为了带货硬讲;改为可从商品实物观察到的使用或选购技巧。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 视频结构 · 促销抢购
|
||||
|
||||
> 适合优惠机制清楚、库存可控、决策成本较低的商品,以及大促、新品拉新、福利款、组合装和阶段性促销。
|
||||
|
||||
## 一、结构骨架
|
||||
|
||||
```
|
||||
[ 适合谁 / 解决什么 ] → [ 商品与活动事实 ] → [ 怎么买最合适 ] → [ 明确入口与操作 ]
|
||||
```
|
||||
|
||||
短时长时把「适合谁」与活动事实合并;结尾必须保留清楚的购买动作。
|
||||
|
||||
## 二、写法
|
||||
|
||||
- 先说清适合谁、什么使用场景或组合需求,不能一开场只喊“抢”。
|
||||
- 价格、优惠、赠品、库存、开售时间、适用人群和领取条件,只能使用输入商品资料里明确给出的事实。
|
||||
- 商品露出优先包装正面、规格/组合细节和真实使用场景;画面不能用伪造倒计时、销量或库存数字。
|
||||
- CTA 说明一个购买入口和一个操作,例如“从商品卡选择这个规格”;没有活动信息时,只引导“查看商品详情”。
|
||||
|
||||
## 三、红线
|
||||
|
||||
- 不得虚构原价、最低价、优惠幅度、活动时间、库存数量、赠品价值或“仅剩”。
|
||||
- 不得把普通价格写成限时、限量,也不得用低价引流到不同规格。
|
||||
- 没有明确优惠事实时,改用商品价值与适用场景说服,不制造抢购氛围。
|
||||
@@ -81,7 +81,7 @@
|
||||
|
||||
- 语气是**自述或旁白**,轻、慢;留白靠**句间停顿和画面**,不靠少写字
|
||||
- **不讲参数、不讲价格、不讲功效** —— 这些会立刻把观众从场景里踢出来
|
||||
- **口播 × 场景种草禁止整镜无声**:每镜仍要说满 36–47 字(2–4 句自述),像在场景里自言自语
|
||||
- **口播 × 场景种草禁止整镜无声**:每镜仍要说满 66–74 字(4–5 句自述),像在场景里自言自语
|
||||
- **Vlog** 才可以个别镜无声,让环境音和画面说话
|
||||
- CTA 要软:「链接放下面了」而不是「快去抢」
|
||||
- 商品名全片点名 1–2 次,其余镜写时刻、动作、使用感、卖点原词
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
name: video-shot-digest
|
||||
description: >
|
||||
上传视频提炼·分镜拆解领域技能(模型无关)。
|
||||
服务对象不是人类,而是 AirShelf 产品后端的「上传视频提炼」入口——用户上传一条参考视频(多为已投放的电商带货成片),
|
||||
服务对象不是人类,而是 AirShelf 产品后端的「上传视频提炼 / 提炼提示词」入口——用户上传一条参考视频,
|
||||
后端把**完整视频文件(含音轨)**喂给多模态模型,加载本技能作为系统提示词。
|
||||
能力:看完整画面、听口播,还原这条视频的分镜——逐镜写清画面和解说词。
|
||||
产出是**给人看、可人工逐镜修改的中文分镜稿**,随后由用户确认后交给脚本 agent 改写成自己商品的脚本。
|
||||
能力:看完整画面、听口播与环境声,还原这条视频的分镜——逐镜写清景别、机位、运镜、画面、人物、台词、音效和字幕。
|
||||
产出是**给人看、可人工逐镜修改的中文导演分镜稿**,随后由用户确认后交给脚本 agent 改写成自己商品的脚本。
|
||||
当任务为「拆解参考视频、还原分镜、提炼视频结构、把视频变成可复用的脚本素材」时使用本技能。
|
||||
---
|
||||
|
||||
# 参考视频 · 分镜拆解师
|
||||
|
||||
你是一个**分镜拆解 agent**。输入是**一条电商带货短视频的完整文件**,包含画面和口播音轨。
|
||||
你的任务是**还原这条视频的分镜结构**,产出一份**中文分镜稿**。
|
||||
你是一个**分镜拆解 agent**。输入是**一条短视频的完整文件**,包含画面和音轨。
|
||||
你的任务是**还原这条视频的分镜结构**,产出一份**中文导演分镜稿**。
|
||||
|
||||
这份稿子有两个去处,缺一不可:
|
||||
|
||||
1. **给用户看、给用户改** —— 用户会逐镜校对你的拆解。必须逐镜分段、编号清楚、说人话。
|
||||
2. **喂给下游脚本 agent** —— 用户确认后,这份稿子会连同他自己的商品一起交给脚本 agent。每一镜要把画面动作和解说词写清楚。
|
||||
1. **给用户看、给用户改** —— 用户会逐镜校对。必须编号清楚、栏位齐全、说人话。
|
||||
2. **喂给下游脚本 agent** —— 用户确认后,这份稿子会连同他自己的商品一起交给脚本 agent。每一镜要把景别、运镜、动作、台词和声音写清楚,下游才能复用节奏,而不是只剩一句画面摘要。
|
||||
|
||||
> **模型无关声明**:本技能不依赖任何特定模型的能力或语气。无论运行在豆包 / GPT / Gemini / Claude 上,规则一致。
|
||||
|
||||
@@ -26,52 +26,79 @@ description: >
|
||||
## 铁律(优先级最高)
|
||||
|
||||
### 铁律 1 · 只写你真看见、真听见的
|
||||
- **禁止编造**没有画面或声音依据的内容:编造的商品名、编造的品牌、编造的口播。
|
||||
- 口播听不清就写「听不清」,画面看不清就写「看不清」。宁可承认缺失,也不要编一个具体的。
|
||||
- **禁止编造**没有画面或声音依据的内容:编造的商品名、品牌、口播、音效、BGM。
|
||||
- 口播听不清就写「听不清」,画面看不清就写「看不清」,人物脸看不清就写「不可见」。没有就写「无」。宁可承认缺失,也不要编一个具体的。
|
||||
- 不要评价(「拍得很好」「节奏很棒」)。你是拆解,不是影评。
|
||||
|
||||
### 铁律 2 · 口播按听到的写
|
||||
你拿得到音轨。`解说词` 一栏写**口播原文**,逐字转写,不要润色、不要概括成「强调结实」。
|
||||
- 这一镜没有人说话:写画面上的花字 / 字幕;都没有就写「无」。
|
||||
- 不要写 BGM、音效、语气、音量。
|
||||
### 铁律 2 · 声音按听到的写
|
||||
你拿得到音轨。
|
||||
- `台词/旁白`:口播或对白**原文**,逐字转写,不要润色、不要概括。
|
||||
- `字幕`:画面上的花字 / 字幕**逐字照抄**;没有就写「无」。
|
||||
- `音效`:雨声、脚步、开瓶、汽车经过等你确实听见的同期声。
|
||||
- `背景音乐`:听得出的风格即可(如「低沉钢琴」「轻快电子」);听不出旋律名不要瞎编曲名。没有配乐就写「无」。
|
||||
|
||||
### 铁律 3 · 覆盖全片,宁多勿少
|
||||
用户要的是**整条片子的分镜稿**,不是片头摘要,也不是三幕概括。
|
||||
|
||||
- 从 **0 秒写到片尾**。`【整体结构】` 里的时长必须接近输入给出的时长。
|
||||
- **有几镜写几镜**。口播带货片常见 6~20 镜;每换一个动作、一个卖点、一句新口播,就另起一镜。
|
||||
- 从 **00:00 写到片尾**。片头 `时长` 必须接近输入给出的时长。
|
||||
- **有几镜写几镜**。口播带货片常见 6~20 镜;每换一个动作、一个卖点、一句新口播、一次明显切镜,就另起一镜。
|
||||
- **禁止**把全片压成「开头 / 中间 / 结尾」三大段,禁止只写前几镜就停。
|
||||
- 花字和字幕**逐字照抄**进画面描述,不要改写成「强调功效」。
|
||||
|
||||
---
|
||||
|
||||
## 输出格式(严格照抄这个骨架,纯文本,不要 JSON、不要代码块、不要 markdown 表格)
|
||||
|
||||
```
|
||||
【整体结构】
|
||||
片名:《从画面和文件名判断的短标题》
|
||||
视频类型:口播带货 / 剧情短片 / Vlog / 看不出
|
||||
画面比例:9:16 竖屏 / 16:9 横屏 / 1:1 / 看不出
|
||||
时长:15秒
|
||||
整体风格:光线、色调、质感用三到六个词写清,例如「写实电影感、冷色调、压抑安静」
|
||||
形式:口播 / 短剧 / Vlog(三选一,看不出就写「看不出」)
|
||||
结构:痛点解决 / 前后对比 / 测评验证 / 场景种草(四选一,看不出就写「看不出」)
|
||||
时长:约 N 秒 · 共 M 镜
|
||||
主线:一句话说清这条片子从头到尾讲了什么
|
||||
|
||||
【第 1 镜】0-3 秒 · 钩子
|
||||
画面:谁在哪做什么,商品怎么出现,景别和动作写进这一句
|
||||
解说词:口播原文。没有口播就写花字/字幕,都没有写「无」
|
||||
【镜头 01】
|
||||
时间:00:00-00:04
|
||||
时长:4秒
|
||||
景别:城市远景 / 全景 / 中景 / 中近景 / 近景 / 特写
|
||||
机位:高位俯拍 / 平视 / 人物侧后方 / 看不清
|
||||
运镜:缓慢向前推进 / 低速跟拍 / 固定 / 手持微晃
|
||||
画面:深夜街道被雨水覆盖,路灯倒映在积水中,一名黑衣女孩独自走过空旷街口。
|
||||
人物动作:女孩低头行走,右手撑着黑伞。没有人物写「无」。
|
||||
人物表情:警觉、疑惑。看不见脸写「不可见」。
|
||||
台词/旁白:是谁? 没有人声写「无」。
|
||||
音效:雨声、远处汽车经过声。
|
||||
背景音乐:低沉而克制的钢琴音。
|
||||
字幕:是谁? 没有花字写「无」。
|
||||
备注:冷蓝色灯光,路面反光明显。没有补充写「无」。
|
||||
|
||||
【第 2 镜】3-8 秒 · 卖点
|
||||
画面:…
|
||||
解说词:…
|
||||
【镜头 02】
|
||||
时间:00:04-00:08
|
||||
时长:4秒
|
||||
景别:中近景
|
||||
机位:人物侧后方
|
||||
运镜:低速跟拍
|
||||
画面:镜头跟随女孩前进,她的外套和头发被风吹动。
|
||||
人物动作:女孩突然放慢脚步,微微转头。
|
||||
人物表情:警觉、疑惑。
|
||||
台词/旁白:是谁?
|
||||
音效:脚步声停止,雨声短暂增强。
|
||||
背景音乐:钢琴停止,加入低频氛围音。
|
||||
字幕:是谁?
|
||||
备注:强调人物停步的一瞬间。
|
||||
|
||||
…(有几镜写几镜)
|
||||
…(有几镜写几镜,镜号两位,01、02、03…)
|
||||
|
||||
【拆解存疑】
|
||||
- 逐条列出你不确定的地方,让用户重点校对
|
||||
```
|
||||
|
||||
### 格式硬要求
|
||||
- 镜号连续,从 1 开始。
|
||||
- 时间区间写成「0-3 秒」这种整数闭区间。
|
||||
- 每镜标题里的作用只能从**钩子 / 痛点 / 卖点 / 证明 / 转化 / 过渡**里选一个词。
|
||||
- 每镜只要 **画面 + 解说词** 两行,不要再拆成主体/运镜/光线等七栏——栏太多会写不完整。
|
||||
- 镜号写成 `【镜头 01】` 这种两位数字,从 01 连续递增。
|
||||
- `时间` 用 `00:00-00:04` 这种分:秒闭区间,必须接上上一镜结束点,不能跳秒、不能重叠。
|
||||
- `时长` 必须等于该镜时间区间的秒数。
|
||||
- 上面列出的栏位**每一镜都要有**,不要省略;没有内容就写「无」或「不可见」。
|
||||
- **`【拆解存疑】`一节必须有**,哪怕只有一条。
|
||||
- 全文中文,不出现 markdown 标题符号(`#`)和代码块围栏。
|
||||
- 不要把一镜压成「画面 + 解说词」两行。栏位太少,下游脚本就只能得到一句摘要。
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
TeamPage
|
||||
} from "./routes";
|
||||
import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config";
|
||||
import { isOwnerOnlyPage, parentPage, pathForPage, resolveRoute } from "./routes/route-config";
|
||||
import { isOwnerOnlyPage, isPage, parentPage, pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
|
||||
import { AdminApp } from "./routes/admin/admin-app";
|
||||
import { TrashPage } from "./routes/trash";
|
||||
import { ModelsPage } from "./routes/models";
|
||||
@@ -522,8 +522,8 @@ export function App() {
|
||||
setNotice({ type: "info", text: "当前账号暂无访问权限" });
|
||||
return;
|
||||
}
|
||||
// 图片创作只有显式入口才携带商品;从图片生成首页进入时不能继承全局当前商品。
|
||||
const productId = next === "imageOptimize" ? options.productId : (options.productId ?? activeProductId);
|
||||
// 图片创作 / 极速成片只有显式入口才携带商品;从工作台或视频创作进入时不能继承全局当前商品。
|
||||
const productId = next === "imageOptimize" || next === "quickCreate" ? options.productId : (options.productId ?? activeProductId);
|
||||
const projectId = options.projectId ?? activeProjectId;
|
||||
if (options.productId !== undefined) setActiveProductId(options.productId);
|
||||
if (options.projectId !== undefined) setActiveProjectId(options.projectId);
|
||||
@@ -566,8 +566,14 @@ export function App() {
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
|
||||
function entryOrigin(fallback: Page = parentPage(page)): Page {
|
||||
const fromPage = readNavState(window.history.state)?.from?.page;
|
||||
return fromPage && isPage(fromPage) ? fromPage : fallback;
|
||||
}
|
||||
|
||||
function goBack(fallback: Page = parentPage(page)) {
|
||||
if (readNavState(window.history.state)?.from?.page) {
|
||||
const from = readNavState(window.history.state)?.from;
|
||||
if (from?.page && isPage(from.page)) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
@@ -900,7 +906,7 @@ export function App() {
|
||||
);
|
||||
case "productCreateUpload":
|
||||
// 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开
|
||||
// 创建成功后由 ProductsPage 弹「继续创建商品 / 去新建项目」选择弹窗,不再自动跳详情
|
||||
// 创建成功后由 ProductsPage 弹「继续创建商品 / 极速成片 / 专业创作」选择弹窗,不再自动跳详情
|
||||
return (
|
||||
<ProductsPage
|
||||
products={products}
|
||||
@@ -1048,7 +1054,17 @@ export function App() {
|
||||
case "freeCreate":
|
||||
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => goBack("projects")} />;
|
||||
case "quickCreate":
|
||||
return <QuickCreatePage onBack={() => goBack("projects")} />;
|
||||
return (
|
||||
<QuickCreatePage
|
||||
onBack={() => goBack(entryOrigin("projects"))}
|
||||
backLabel={`返回${routeLabels[entryOrigin("projects")]}`}
|
||||
initialProductId={route.productId}
|
||||
navigate={navigate}
|
||||
modelConfigs={modelConfigs}
|
||||
onNotify={(type, text) => setNotice({ type, text })}
|
||||
onProjectCreated={() => { void loadData(); }}
|
||||
/>
|
||||
);
|
||||
case "videoRemix":
|
||||
return <VideoRemixPage textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")} onNotify={(type, text) => setNotice({ type, text })} onBack={() => goBack("projects")} navigate={navigate} />;
|
||||
case "imageOptimize":
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
TeamMember,
|
||||
User,
|
||||
UserPreference,
|
||||
VideoDigestHistory,
|
||||
VoiceoverInfo
|
||||
} from "./types";
|
||||
import type { PresentationFormat, VideoStructure } from "./script-setup";
|
||||
@@ -59,6 +60,51 @@ const FRIENDLY_AUTH_MISSING_MESSAGE = "登录状态异常,请刷新页面后
|
||||
|
||||
export type RememberInfo = { username: string; expireAt: number };
|
||||
|
||||
export type QuickCreateJob = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
title: string;
|
||||
product_images: Array<{ asset_id: string; url: string }>;
|
||||
status: "queued" | "running" | "succeeded" | "failed" | "cancelled";
|
||||
phase: "product" | "script" | "assets" | "production" | "complete";
|
||||
phase_index: number;
|
||||
progress: number;
|
||||
message: string;
|
||||
error_message: string;
|
||||
settings: {
|
||||
aspect_ratio: string;
|
||||
resolution: string;
|
||||
total_duration: number;
|
||||
video_model_config_id: string;
|
||||
video_model_name: string;
|
||||
video_model_label: string;
|
||||
};
|
||||
result: null | {
|
||||
video_url: string;
|
||||
poster_url: string;
|
||||
duration_seconds: number;
|
||||
aspect_ratio: string;
|
||||
resolution: string;
|
||||
video_model: string;
|
||||
structure: string;
|
||||
presentation: string;
|
||||
person: string;
|
||||
scene: string;
|
||||
video_segments: Array<{
|
||||
id: string;
|
||||
sort_order: number;
|
||||
duration_seconds: number;
|
||||
video_url: string;
|
||||
poster_url: string;
|
||||
}>;
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
};
|
||||
|
||||
export function getRemember(): RememberInfo | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(REMEMBER_KEY);
|
||||
@@ -317,6 +363,18 @@ export const api = {
|
||||
createProject(payload: { name: string; product: string; metadata?: Record<string, unknown> }) {
|
||||
return request<Project>("/api/projects/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
startQuickCreate(formData: FormData) {
|
||||
return request<QuickCreateJob>("/api/projects/quick-create/", { method: "POST", body: formData });
|
||||
},
|
||||
quickCreateStatus(jobId: string) {
|
||||
return request<QuickCreateJob>(`/api/projects/quick-create-status/${jobId}/`);
|
||||
},
|
||||
cancelQuickCreate(jobId: string) {
|
||||
return request<QuickCreateJob>(`/api/projects/quick-create-cancel/${jobId}/`, { method: "POST" });
|
||||
},
|
||||
quickCreateHistory() {
|
||||
return request<{ count: number; results: QuickCreateJob[] }>("/api/projects/quick-create-history/");
|
||||
},
|
||||
// 整体替换 metadata —— 调用方务必先展开现有 project.metadata 再合并,别把别的 key 冲掉
|
||||
updateProject(id: string, payload: { name?: string; metadata?: Record<string, unknown> }) {
|
||||
return request<Project>(`/api/projects/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
@@ -350,11 +408,37 @@ export const api = {
|
||||
},
|
||||
// 视频提炼页:不绑项目,整段视频交给 Gemini 出中文分镜稿。慢(约 1–2 分钟),固定 30 积分,失败退还。
|
||||
extractVideoDigest(formData: FormData) {
|
||||
return request<{ name: string; chars: number; text: string; frames: number; duration: number; input?: string; estimated_cost?: string }>(
|
||||
return request<{
|
||||
name: string;
|
||||
chars: number;
|
||||
text: string;
|
||||
frames: number;
|
||||
duration: number;
|
||||
input?: string;
|
||||
estimated_cost?: string;
|
||||
task_id?: string;
|
||||
title?: string;
|
||||
file_name?: string;
|
||||
ratio?: string;
|
||||
shots?: number;
|
||||
cover_url?: string;
|
||||
video_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>(
|
||||
"/api/ai/video-digest/",
|
||||
{ method: "POST", body: formData }
|
||||
);
|
||||
},
|
||||
listVideoDigests() {
|
||||
return request<{ results: VideoDigestHistory[]; total: number }>("/api/ai/video-digest/");
|
||||
},
|
||||
saveVideoDigest(id: string, prompt: string) {
|
||||
return request<VideoDigestHistory>(`/api/ai/video-digest/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ prompt }),
|
||||
});
|
||||
},
|
||||
// ── 套路模板(5.1 存 / 5.2 换商品重跑)· 团队隔离 ──
|
||||
// 存模板只抽套路不抽文案:后端从这版脚本抽镜数/每镜作用/节奏/商品露出/转化写法。
|
||||
saveProjectAsTemplate(projectId: string, payload: { name: string; script_version_id?: string }) {
|
||||
|
||||
@@ -16,6 +16,7 @@ const SHELL_COMMANDS: Command[] = [
|
||||
{ id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
|
||||
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
|
||||
{ id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
|
||||
{ id: "quick-create", group: "导航", label: "极速成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" },
|
||||
{ id: "video-remix", group: "导航", label: "提炼提示词", sub: "上传参考视频,提炼可编辑提示词", page: "videoRemix", icon: "scan", key: "R" },
|
||||
{ id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
|
||||
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
|
||||
@@ -24,6 +25,7 @@ const SHELL_COMMANDS: Command[] = [
|
||||
{ id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" },
|
||||
{ id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" },
|
||||
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
|
||||
{ id: "quick-create-action", group: "常用动作", label: "极速成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" },
|
||||
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
|
||||
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" },
|
||||
{ id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" }
|
||||
@@ -392,6 +394,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
||||
models: "models",
|
||||
projects: "projects",
|
||||
projectWizard: "projects",
|
||||
quickCreate: "projects",
|
||||
pipeline: "projects",
|
||||
assetFactory: "assetFactory",
|
||||
imageOptimize: "assetFactory",
|
||||
|
||||
@@ -24,3 +24,8 @@ export function publicModelDisplayName(
|
||||
if (!model) return fallback;
|
||||
return publicModelRouteName(model.name, model.display_name?.trim() || model.name || fallback);
|
||||
}
|
||||
|
||||
/** Gemini 3.1 只给「提炼提示词」用,脚本助手下拉里不出现。 */
|
||||
export function isHiddenFromScriptPicker(model: Pick<ModelConfig, "name" | "display_name">) {
|
||||
return model.name === "gemini-3.1-pro-preview" || publicModelDisplayName(model) === "AirShelf Script";
|
||||
}
|
||||
|
||||
@@ -2120,3 +2120,19 @@
|
||||
.tpl-capture .v { flex: 1; color: var(--accent-black); word-break: break-word; }
|
||||
.tpl-note { margin: 10px 0 0; font-size: 12px; line-height: 1.7; color: var(--black-alpha-56); }
|
||||
.tpl-note .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); margin-right: 4px; }
|
||||
|
||||
/* 脚本模型菜单挂到 body:输入卡 overflow:hidden 会裁掉向上展开的列表 */
|
||||
.chat-model-menu.chip-menu {
|
||||
--klein: #002fa7;
|
||||
position: fixed;
|
||||
top: auto;
|
||||
display: block;
|
||||
z-index: 9999;
|
||||
}
|
||||
.chat-model-menu.chip-menu .mi.selected {
|
||||
color: var(--klein) !important;
|
||||
background: rgba(0, 47, 167, 0.10) !important;
|
||||
}
|
||||
.chat-model-menu.chip-menu .mi.selected .mi-check {
|
||||
color: var(--klein) !important;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,117 @@
|
||||
/* 极速成片:按用户提供的「影擎」页面逐项转写,仅作用于该页面。 */
|
||||
.quick-create-page { --quick-blue: #002fa7; --quick-blue-hover: #002680; --quick-muted: #6f747c; width: min(1560px,100%); min-height: calc(100vh - 164px); margin: -24px auto -60px; padding: 52px 0 48px; }
|
||||
.quick-create-page { --quick-blue: #002fa7; --quick-blue-hover: #002680; --quick-muted: #6f747c; width: min(1560px,100%); min-height: calc(100vh - 164px); margin: -24px auto -60px; padding: 59px 0 48px; }
|
||||
.quick-create-page .project-builder-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 20px; }
|
||||
.quick-create-page .project-builder-title { display: flex; align-items: center; gap: 14px; }
|
||||
.quick-create-page .project-builder-title h1 { margin: 0 0 5px; color: #17181a; font-size: 28px; line-height: 1.2; font-weight: 700; }
|
||||
.quick-create-page .project-builder-title p { margin: 0; color: var(--quick-muted); font-size: 13px; }
|
||||
.quick-create-page .project-builder-status { display: inline-flex; align-items: center; gap: 8px; padding: 9px 13px; border: 1px solid rgba(34,42,54,.09); border-radius: 999px; color: var(--quick-muted); background: rgba(255,255,255,.76); font-size: 12px; }
|
||||
.quick-create-page .project-builder-title p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 18px; }
|
||||
.quick-create-page .project-builder-status { display: inline-flex; align-items: center; gap: 8px; margin-right: 5px; padding: 9px 15.6px; border: 1px solid rgba(34,42,54,.09); border-radius: 999px; color: var(--quick-muted); background: rgba(255,255,255,.76); font-size: 12px; line-height: 18px; }
|
||||
.quick-create-page .project-builder-status strong { color: var(--quick-blue); font-size: 13px; }
|
||||
.quick-create-page .image-back-button { width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto; margin-top: 2px; border: 1px solid rgba(0,47,167,.46); border-radius: 12px; color: var(--quick-blue); background: rgba(255,255,255,.82); cursor: pointer; box-shadow: 0 5px 14px rgba(0,47,167,.07); transition: background-color 180ms ease, transform 180ms ease; }
|
||||
.quick-create-page .image-back-button:hover { transform: translateX(-2px); border-color: var(--quick-blue); background: rgba(0,47,167,.055); }.quick-create-page .image-back-button svg { width: 19px; height: 19px; }
|
||||
.quick-create-header { margin-bottom: 22px !important; }
|
||||
.quick-create-header { margin-bottom: 16px !important; }
|
||||
.quick-create-page .quick-create-shell { min-height: clamp(650px, calc(100vh - 266px), 850px); display: grid; grid-template-columns: minmax(0, .92fr) minmax(0, 1.08fr); gap: 22px; }
|
||||
.quick-create-page .quick-create-panel { min-width: 0; overflow: hidden; border: 1px solid rgba(34,42,54,.10); border-radius: 18px; background: rgba(255,255,255,.88); box-shadow: 0 16px 40px rgba(20,27,38,.09); }
|
||||
.quick-create-page .quick-form-panel { display: flex; flex-direction: column; padding: 30px; }
|
||||
.quick-create-page .quick-form-copy h2 { margin: 0 0 8px; font-size: 26px; }
|
||||
.quick-create-page .quick-form-panel { display: flex; flex-direction: column; padding: 25px 30px 30px; border-top: 3px solid var(--quick-blue); }
|
||||
.quick-create-page .quick-status-panel { border-top: 3px solid #111216; }
|
||||
.quick-create-page .quick-form-copy h2 { margin: 0; font-size: 26px; }
|
||||
.quick-create-page .quick-form-copy p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }
|
||||
.quick-create-page .quick-field { display: grid; gap: 9px; margin-top: 24px; }
|
||||
.quick-create-page .quick-field { display: grid; gap: 9px; margin-top: 20px; }
|
||||
.quick-create-page .quick-form-copy + .quick-field { margin-top: 17px; }
|
||||
.quick-create-page .quick-field-label { display: flex; align-items: center; justify-content: space-between; gap: 14px; font-size: 14px; font-weight: 700; }
|
||||
.quick-create-page .quick-field-label small { color: var(--quick-muted); font-size: 11px; font-weight: 500; }
|
||||
.quick-create-page .quick-name-input { height: 50px; padding: 0 15px; border: 1px solid rgba(34,42,54,.13); border-radius: 12px; outline: none; background: rgba(248,249,252,.88); transition: border-color 180ms ease, box-shadow 180ms ease, background 180ms ease; }
|
||||
.quick-create-page .quick-name-input:focus { border-color: rgba(0,47,167,.55); background: #fff; box-shadow: 0 0 0 4px rgba(0,47,167,.08); }
|
||||
.quick-create-page .quick-upload { position: relative; min-height: 190px; display: grid; place-items: center; overflow: hidden; border: 1px dashed rgba(0,47,167,.28); border-radius: 14px; background: radial-gradient(circle at center,rgba(0,47,167,.07),transparent 47%),rgba(248,249,252,.86); cursor: pointer; }
|
||||
.quick-create-page .quick-upload { position: relative; min-height: 190px; display: grid; place-items: center; overflow: hidden; border: 1px dashed rgba(0,47,167,.28); border-radius: 14px; background: radial-gradient(circle at center,rgba(0,47,167,.07),transparent 47%),rgba(248,249,252,.86); }
|
||||
.quick-create-page .quick-upload input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.quick-create-page .quick-upload-copy { display: grid; justify-items: center; gap: 8px; padding: 24px; text-align: center; }
|
||||
.quick-create-page .quick-upload-copy { display: grid; justify-items: center; gap: 8px; padding: 24px; border: 0; color: inherit; background: transparent; font: inherit; text-align: center; cursor: pointer; }
|
||||
.quick-create-page .quick-upload-icon { width: 40px; height: 40px; display: grid; place-items: center; color: var(--quick-blue); }
|
||||
.quick-create-page .quick-upload-icon svg { width: 34px; height: 34px; stroke-width: 1.75; }
|
||||
.quick-create-page .quick-upload-copy strong { font-size: 15px; }.quick-create-page .quick-upload-copy small { color: var(--quick-muted); font-size: 11px; }
|
||||
.quick-create-page .quick-upload-preview { position: absolute; inset: 0; display: none; background: #f5f6f9; }.quick-create-page .quick-upload.has-image .quick-upload-copy { display: none; }.quick-create-page .quick-upload.has-image .quick-upload-preview { display: block; }
|
||||
.quick-create-page .quick-upload-preview img { width: 100%; height: 100%; object-fit: cover; }.quick-create-page .quick-image-count { position: absolute; right: 12px; bottom: 12px; padding: 6px 9px; border-radius: 999px; color: #fff; background: rgba(16,16,18,.72); font-size: 11px; }
|
||||
.quick-create-page .quick-image-clear { position: absolute; top: 12px; right: 12px; width: 34px; height: 34px; display: grid; place-items: center; border: 0; border-radius: 10px; color: #fff; background: rgba(16,16,18,.72); cursor: pointer; }.quick-create-page .quick-image-clear svg { width: 16px; height: 16px; }
|
||||
.quick-create-page .quick-auto-note { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 8px; margin-top: 20px; }.quick-create-page .quick-auto-note span { min-height: 54px; display: grid; place-items: center; padding: 8px; border-radius: 10px; color: #4f5662; background: rgba(34,42,54,.045); text-align: center; font-size: 11px; line-height: 1.45; }
|
||||
.quick-create-page .quick-form-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: auto; padding-top: 26px; }.quick-create-page .quick-cost strong,.quick-create-page .quick-cost span { display: block; }.quick-create-page .quick-cost span { color: var(--quick-muted); font-size: 11px; }.quick-create-page .quick-cost strong { margin-top: 5px; font-size: 14px; }
|
||||
.quick-create-page .quick-generate-button { min-width: 220px; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 12px; color: #fff; background: var(--quick-blue); font: inherit; font-weight: 700; cursor: pointer; box-shadow: 0 12px 24px rgba(0,47,167,.22); }.quick-create-page .quick-generate-button:disabled { color: #99a1ae; background: #e5e8ee; box-shadow: none; cursor: not-allowed; }.quick-create-page .quick-generate-button svg { width: 20px; height: 20px; }
|
||||
.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%); }.quick-create-page .quick-state-ready { display: grid; justify-items: center; text-align: center; }
|
||||
.quick-create-page .quick-upload.has-images { min-height: 268px; padding: 14px; background: rgba(248,249,252,.86); }
|
||||
.quick-create-page .quick-upload-filled { width: min(100%,492px); display: grid; grid-template-columns: 240px 240px; gap: 12px; }
|
||||
.quick-create-page .quick-image-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); grid-template-rows: repeat(3,minmax(0,1fr)); gap: 6px; padding: 8px; border-radius: 13px; background: rgba(255,255,255,.92); }
|
||||
.quick-create-page .quick-image-tile { position: relative; min-width: 0; aspect-ratio: 1; overflow: hidden; border: 1px dashed rgba(0,47,167,.15); border-radius: 8px; background: rgba(255,255,255,.48); }
|
||||
.quick-create-page .quick-image-tile img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
.quick-create-page .quick-image-tile button { position: absolute; top: 3px; right: 3px; width: 19px; height: 19px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; color: #fff; background: rgba(25,28,34,.72); cursor: pointer; }.quick-create-page .quick-image-tile button svg { width: 12px; height: 12px; }
|
||||
.quick-create-page .quick-upload-more { min-height: 240px; display: grid; place-items: center; align-content: center; gap: 8px; border: 1px dashed rgba(0,47,167,.35); border-radius: 13px; background: rgba(243,247,255,.78); }
|
||||
.quick-create-page .quick-upload-trigger { display: grid; justify-items: center; gap: 8px; padding: 0; border: 0; color: #303746; background: transparent; font: inherit; cursor: pointer; }.quick-create-page .quick-upload-trigger strong { font-size: 14px; font-weight: 700; }.quick-create-page .quick-upload-trigger small { color: var(--quick-muted); font-size: 12px; }
|
||||
.quick-create-page .quick-upload-more-icon { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 12px; color: var(--quick-blue); background: #fff; box-shadow: 0 5px 16px rgba(0,47,167,.08); }.quick-create-page .quick-upload-more-icon svg { width: 24px; height: 24px; stroke-width: 1.75; }
|
||||
.quick-create-page .quick-clear-all { padding: 5px 9px; border: 0; border-radius: 7px; color: var(--quick-muted); background: rgba(255,255,255,.72); font: inherit; font-size: 11px; cursor: pointer; }
|
||||
.quick-create-page .quick-parameter-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; margin-top: 20px; }
|
||||
.quick-create-page .quick-parameter-field { min-width: 0; display: grid; gap: 9px; padding: 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 12px; background: rgba(248,249,252,.72); }
|
||||
.quick-create-page .quick-parameter-field > span { color: #30343a; font-size: 12px; font-weight: 700; }
|
||||
.quick-create-page .quick-parameter-field select { width: 100%; height: 38px; padding: 0 36px 0 13px; border: 1px solid rgba(34,42,54,.13); border-radius: 10px; outline: none; color: #272a30; background-color: rgba(255,255,255,.92); font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.quick-create-page .quick-parameter-field select:focus { border-color: rgba(0,47,167,.55); box-shadow: 0 0 0 3px rgba(0,47,167,.08); }
|
||||
.quick-create-page .quick-parameter-field select:disabled { cursor: not-allowed; opacity: .65; }
|
||||
.quick-create-page .quick-form-footer { display: flex; margin-top: 26px; }
|
||||
.quick-create-page .quick-generate-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 12px; color: #fff; background: var(--quick-blue); font: inherit; font-weight: 700; cursor: pointer; box-shadow: 0 12px 24px rgba(0,47,167,.22); }.quick-create-page .quick-generate-button:disabled { color: #99a1ae; background: #e5e8ee; box-shadow: none; cursor: not-allowed; }.quick-create-page .quick-generate-button svg { width: 20px; height: 20px; }
|
||||
.quick-create-page .quick-cancel-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid rgba(34,42,54,.16); border-radius: 12px; color: #272a30; background: #fff; font: inherit; font-weight: 700; cursor: pointer; }.quick-create-page .quick-cancel-button:hover:not(:disabled) { border-color: rgba(200,61,77,.35); color: #c83d4d; background: rgba(200,61,77,.06); }.quick-create-page .quick-cancel-button:disabled { opacity: .55; cursor: not-allowed; }.quick-create-page .quick-cancel-button svg { width: 18px; height: 18px; }
|
||||
.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-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-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-complete .quick-state-complete { display: grid; }
|
||||
.quick-create-page .quick-create-shell.is-failed .quick-state-failed { display: grid; }
|
||||
.quick-create-page .quick-ready-orbit { position: relative; width: 176px; height: 176px; display: grid; place-items: center; margin-bottom: 24px; border: 1px solid rgba(0,47,167,.14); border-radius: 50%; }.quick-create-page .quick-ready-orbit::before,.quick-create-page .quick-ready-orbit::after { content: ""; position: absolute; border: 1px solid rgba(0,47,167,.07); border-radius: 50%; }.quick-create-page .quick-ready-orbit::before { inset: 20px; }.quick-create-page .quick-ready-orbit::after { inset: -28px; }.quick-create-page .quick-ready-icon { width: 54px; height: 54px; display: grid; place-items: center; color: var(--quick-blue); }.quick-create-page .quick-ready-icon svg { width: 42px; height: 42px; stroke-width: 1.7; }
|
||||
.quick-create-page .quick-state-ready { position: relative; top: -10px; }
|
||||
.quick-create-page .quick-state-ready h2 { margin: 0 0 9px; font-size: 23px; }.quick-create-page .quick-state-ready > p { max-width: 430px; margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }.quick-create-page .quick-ready-tags { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 20px; }.quick-create-page .quick-ready-tags span { padding: 7px 10px; border: 1px solid rgba(34,42,54,.08); border-radius: 999px; color: #505762; background: rgba(255,255,255,.72); font-size: 11px; }
|
||||
.quick-create-page .quick-state-generating { width: min(460px,100%); justify-items: center; text-align: center; }
|
||||
.quick-create-page .quick-generating-preview { position: relative; width: 460px; max-width: 100%; height: 258px; display: grid; place-items: center; margin-bottom: 27px; border: 1px solid rgba(0,47,167,.16); border-radius: 15px; background: #edf4ff; box-shadow: 0 18px 34px rgba(0,47,167,.08); }.quick-create-page .quick-generating-preview::before { content: ""; position: absolute; inset: 18px; border-radius: 10px; background: #fff; }
|
||||
.quick-create-page .quick-preview-spinner { position: relative; z-index: 1; width: 62px; height: 62px; border-radius: 50%; background: conic-gradient(from 160deg,transparent 0deg,transparent 136deg,var(--quick-blue) 300deg,rgba(0,47,167,.12) 360deg); -webkit-mask: radial-gradient(circle,transparent 0 66%,#000 69%); mask: radial-gradient(circle,transparent 0 66%,#000 69%); animation: quick-spinner-rotate 1.1s linear infinite; }
|
||||
.quick-create-page .quick-generating-copy { width: 100%; text-align: center; }.quick-create-page .quick-generating-copy h2 { margin: 0 0 8px; font-size: 23px; }.quick-create-page .quick-generating-copy p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }
|
||||
.quick-create-page .quick-progress-track { width: 100%; display: flex; align-items: flex-start; margin-top: 28px; }.quick-create-page .quick-progress-node { position: relative; z-index: 0; flex: 1 1 0; display: grid; justify-items: center; color: #9aa1ab; font-size: 12px; }.quick-create-page .quick-progress-node:not(:last-child)::after { content: ""; position: absolute; z-index: -1; top: 23px; left: 50%; width: 100%; height: 2px; background: rgba(34,42,54,.12); }.quick-create-page .quick-progress-node.done:not(:last-child)::after { background: var(--quick-blue); }.quick-create-page .quick-progress-dot { width: 46px; height: 46px; display: grid; place-items: center; border: 5px solid rgba(0,47,167,.10); border-radius: 50%; color: #fff; background: var(--quick-blue); }.quick-create-page .quick-progress-dot svg { width: 18px; height: 18px; stroke-width: 1.8; }.quick-create-page .quick-progress-node strong { margin-top: 9px; max-width: 8em; text-align: center; font-size: 12px; font-weight: 700; line-height: 1.35; }.quick-create-page .quick-progress-node.done,.quick-create-page .quick-progress-node.active { color: var(--quick-blue); }.quick-create-page .quick-progress-node.active { color: #16171a; }.quick-create-page .quick-progress-node.active .quick-progress-dot { border-color: rgba(22,23,26,.12); background: #16171a; }
|
||||
.quick-create-page .quick-generating-actions { display: flex; justify-content: center; margin-top: 22px; }.quick-create-page .quick-generating-actions button { min-width: 132px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-generating-actions svg { width: 17px; height: 17px; }
|
||||
.quick-create-page .quick-state-complete { width: min(560px,100%); gap: 17px; }
|
||||
.quick-create-page .quick-video-result-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; }
|
||||
.quick-create-page .quick-video-result-grid.is-single { grid-template-columns: minmax(0, 1fr); width: min(320px,100%); justify-self: center; }
|
||||
.quick-create-page .quick-video-result-card { min-width: 0; padding: 8px 8px 9px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; color: #25282d; background: #fff; font: inherit; text-align: left; cursor: pointer; }
|
||||
.quick-create-page .quick-video-result-thumb { position: relative; display: block; aspect-ratio: 16/9; overflow: hidden; border-radius: 8px; background: #eef1f5; }
|
||||
.quick-create-page .quick-video-result-thumb img,.quick-create-page .quick-video-result-thumb video { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
.quick-create-page .quick-video-play { position: absolute; inset: 0; display: grid; place-items: center; color: #fff; background: rgba(0,0,0,.22); }
|
||||
.quick-create-page .quick-video-play svg { width: 42px; height: 42px; fill: currentColor; stroke: currentColor; stroke-width: 1.2; filter: drop-shadow(0 2px 8px rgba(0,0,0,.28)); }
|
||||
.quick-create-page .quick-video-result-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 3px 0; }
|
||||
.quick-create-page .quick-video-result-meta strong { font-size: 12px; font-weight: 600; }
|
||||
.quick-create-page .quick-video-result-meta small { color: var(--quick-muted); font-size: 11px; }
|
||||
.quick-create-page .quick-result-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.quick-create-page .quick-result-head h2,.quick-create-page .quick-result-head p { margin: 0; }.quick-create-page .quick-result-head h2 { font-size: 22px; }.quick-create-page .quick-result-head p { margin-top: 5px; color: var(--quick-muted); font-size: 11px; }
|
||||
.quick-create-page .quick-result-badge { padding: 7px 10px; border-radius: 999px; color: #168d43; background: rgba(22,184,78,.10); font-size: 11px; font-weight: 700; white-space: nowrap; }
|
||||
.quick-create-page .quick-result-actions { display: grid; grid-template-columns: auto auto minmax(160px,1fr); gap: 10px; }
|
||||
.quick-create-page .quick-result-actions.is-single { grid-template-columns: auto minmax(160px,1fr); }.quick-create-page .quick-result-actions button,.quick-create-page .quick-result-actions a { min-height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; text-decoration: none; }.quick-create-page .quick-result-actions svg { width: 17px; height: 17px; }
|
||||
.quick-create-page .primary-action,.quick-create-page .secondary-action { min-height: 44px; padding: 0 15px; border-radius: 8px; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: background-color 180ms ease,border-color 180ms ease,color 180ms ease; }
|
||||
.quick-create-page .primary-action { border: 1px solid var(--quick-blue); color: #fff; background: var(--quick-blue); }.quick-create-page .primary-action:hover:not(:disabled) { border-color: var(--quick-blue-hover); background: var(--quick-blue-hover); }.quick-create-page .primary-action:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.quick-create-page .secondary-action { border: 1px solid rgba(34,42,54,.12); color: #272a30; background: rgba(255,255,255,.78); }.quick-create-page .secondary-action:hover:not(:disabled) { color: var(--quick-blue); border-color: rgba(0,47,167,.24); background: rgba(0,47,167,.05); }
|
||||
.quick-create-page .quick-download-action { border-radius: 8px; }
|
||||
.quick-create-page .quick-state-failed { justify-items: center; gap: 14px; text-align: center; }.quick-create-page .quick-state-failed h2,.quick-create-page .quick-state-failed p { margin: 0; }.quick-create-page .quick-state-failed p { max-width: 430px; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }.quick-create-page .quick-failed-icon { width: 82px; height: 82px; display: grid; place-items: center; border-radius: 50%; color: #c83d4d; background: rgba(200,61,77,.08); }.quick-create-page .quick-failed-icon svg { width: 36px; height: 36px; }.quick-create-page .quick-failed-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 8px; }.quick-create-page .quick-failed-actions button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-failed-actions svg { width: 17px; height: 17px; }
|
||||
@keyframes quick-spinner-rotate { to { transform: rotate(360deg); } }
|
||||
.quick-create-page .quick-history { margin-top: 28px; }
|
||||
.quick-create-page .quick-history-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||
.quick-create-page .quick-history-head h2 { margin: 0; font-size: 20px; font-weight: 600; }
|
||||
.quick-create-page .quick-history-head span { color: var(--quick-muted); font-size: 13px; }
|
||||
.quick-create-page .quick-history-list { display: grid; gap: 10px; }
|
||||
.quick-create-page .quick-history-card { display: grid; grid-template-columns: 112px minmax(0,1fr) auto; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; background: #fff; box-shadow: inset 3px 0 0 var(--quick-blue); }
|
||||
.quick-create-page .quick-history-thumb { position: relative; width: 112px; aspect-ratio: 16/9; overflow: hidden; padding: 0; border: 0; border-radius: 8px; background: #eef1f5; cursor: pointer; }
|
||||
.quick-create-page .quick-history-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
.quick-create-page .quick-history-thumb-empty { width: 100%; height: 100%; display: grid; place-items: center; color: var(--quick-blue); }
|
||||
.quick-create-page .quick-history-thumb-empty svg { width: 18px; height: 18px; }
|
||||
.quick-create-page .quick-history-thumb small { position: absolute; right: 6px; bottom: 6px; padding: 1px 6px; border-radius: 4px; color: #fff; background: rgba(0,0,0,.62); font-size: 10px; line-height: 16px; }
|
||||
.quick-create-page .quick-history-copy { min-width: 0; }
|
||||
.quick-create-page .quick-history-badge { display: inline-flex; padding: 2px 8px; border-radius: 999px; color: var(--quick-blue); background: rgba(0,47,167,.08); font-size: 11px; font-weight: 600; }
|
||||
.quick-create-page .quick-history-copy h3 { margin: 6px 0 4px; font-size: 16px; font-weight: 600; }
|
||||
.quick-create-page .quick-history-copy p { margin: 0; color: var(--quick-muted); font-size: 12px; }
|
||||
.quick-create-page .quick-history-open { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border: 1px solid rgba(34,42,54,.12); border-radius: 8px; color: var(--quick-blue); background: #fff; font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.quick-create-page .quick-history-open:hover { background: rgba(0,47,167,.05); }
|
||||
.quick-create-page .quick-history-open svg { width: 15px; height: 15px; }
|
||||
.quick-create-page .quick-history-empty { margin: 0; padding: 28px 8px; color: var(--quick-muted); font-size: 13px; }
|
||||
.quick-create-page .quick-player-bg { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 24px; background: rgba(22,23,26,.58); }
|
||||
.quick-create-page .quick-player { width: min(920px,100%); overflow: hidden; border-radius: 8px; background: #111216; }
|
||||
.quick-create-page .quick-player-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; color: #fff; }
|
||||
.quick-create-page .quick-player-bar strong { font-size: 13px; font-weight: 600; }
|
||||
.quick-create-page .quick-player-bar button { width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 8px; color: #fff; background: transparent; cursor: pointer; }
|
||||
.quick-create-page .quick-player-bar svg { width: 16px; height: 16px; }
|
||||
.quick-create-page .quick-player video { width: 100%; max-height: min(72vh, 620px); display: block; background: #000; }
|
||||
@media (max-width: 980px) { .quick-create-page .quick-history-card { grid-template-columns: 96px minmax(0,1fr); }.quick-create-page .quick-history-open { grid-column: 1 / -1; justify-self: start; } }
|
||||
@media (min-width: 1753px) { .quick-create-page { position: relative; left: -21.5px; } }
|
||||
@media (max-width: 1400px) { .quick-create-page .quick-create-shell { grid-template-columns: minmax(0,.9fr) minmax(0,1.1fr); }.quick-create-page .quick-form-panel,.quick-create-page .quick-status-panel { padding: 24px; } }
|
||||
@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; } }
|
||||
|
||||
@@ -72,9 +72,16 @@ function dashStageLabel(project: Project): string {
|
||||
};
|
||||
return map[project.current_stage] || "视频生成";
|
||||
}
|
||||
function isQuickCreateProject(project: Project) {
|
||||
if (project.quick_create) return true;
|
||||
if (project.metadata?.quick_create) return true;
|
||||
return / · 极速成片$/.test(project.name || "");
|
||||
}
|
||||
|
||||
function dashCardMeta(project: Project, productTitle: string): string {
|
||||
const shots = project.video_segment_count ?? project.video_segments?.length ?? 0;
|
||||
return ["专业创作", productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / ");
|
||||
const mode = isQuickCreateProject(project) ? "极速成片" : "专业创作";
|
||||
return [mode, productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
function EntryIcon({ name }: { name: "wand" | "folder" | "scan" | "replace" }) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } fr
|
||||
import { ArrowLeft, ArrowRight, Check, ChevronRight, Image, Info, LayoutList, Play, RefreshCw, Route, Sparkles, Upload, UsersRound, X } from "lucide-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types";
|
||||
import { publicModelDisplayName } from "../model-display";
|
||||
import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-display";
|
||||
import { isPublicGenerationError, presentGenerationError } from "../generation-error";
|
||||
import type { Notice, Page } from "./route-config";
|
||||
import { stageOrder, statusPill } from "./stage-config";
|
||||
@@ -516,7 +516,8 @@ export function PipelinePage(props: {
|
||||
logout: () => void;
|
||||
onNotify?: (type: "success" | "error", text: string) => void;
|
||||
scriptModelName: string;
|
||||
textModels?: ModelConfig[]; onAdoptScript: (scriptId: string) => void | Promise<unknown>;
|
||||
textModels?: ModelConfig[];
|
||||
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
|
||||
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
|
||||
onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise<unknown>;
|
||||
onDeleteShot: (segmentId: string) => Promise<unknown>;
|
||||
@@ -1399,18 +1400,53 @@ export function PipelinePage(props: {
|
||||
} catch { /* localStorage 不可用则忽略 */ }
|
||||
}, [chatKey, chatMsgs]);
|
||||
const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { id: nextMsgId(), role, text, time: nowHm() }]);
|
||||
// 脚本模型下拉:用户可选 豆包/GPT-5.5/Gemini(空 = 用后端默认文本模型)
|
||||
// 脚本模型下拉:用户可选 豆包/GPT 等;Gemini 3.1(AirShelf Script)只给视频提炼,不出现在这里
|
||||
const [scriptModelId, setScriptModelId] = useState<string>("");
|
||||
const activeScriptModelId = scriptModelId || textModels?.[0]?.id || "";
|
||||
const scriptPickerModels = useMemo(
|
||||
() => (textModels || []).filter((model) => !isHiddenFromScriptPicker(model)),
|
||||
[textModels],
|
||||
);
|
||||
const activeScriptModelId = (
|
||||
scriptPickerModels.some((model) => model.id === scriptModelId) ? scriptModelId : ""
|
||||
) || scriptPickerModels[0]?.id || "";
|
||||
// 模型选择小按钮(输入框下方)· 自建 restraint 下拉(幽灵触发 + popover 菜单),不再用原生 select + inline
|
||||
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
||||
const activeModel = textModels?.find((m) => m.id === activeScriptModelId);
|
||||
const [modelMenuPos, setModelMenuPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
|
||||
const modelPickRef = useRef<HTMLDivElement>(null);
|
||||
const activeModel = scriptPickerModels.find((m) => m.id === activeScriptModelId);
|
||||
const activeModelName = publicModelDisplayName(activeModel);
|
||||
useEffect(() => {
|
||||
if (!modelMenuOpen) return;
|
||||
const close = (e: MouseEvent) => { if (!(e.target as HTMLElement).closest(".chat-model-pick")) setModelMenuOpen(false); };
|
||||
if (!modelMenuOpen) {
|
||||
setModelMenuPos(null);
|
||||
return;
|
||||
}
|
||||
const place = () => {
|
||||
const trigger = modelPickRef.current?.querySelector("button.chip");
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
setModelMenuPos({
|
||||
left: Math.min(rect.left, window.innerWidth - Math.max(200, rect.width) - 8),
|
||||
bottom: window.innerHeight - rect.top + 4,
|
||||
width: Math.max(200, rect.width),
|
||||
});
|
||||
};
|
||||
place();
|
||||
const close = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest(".chat-model-pick") || target.closest(".chat-model-menu")) return;
|
||||
setModelMenuOpen(false);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setModelMenuOpen(false); };
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
document.addEventListener("keydown", onKey);
|
||||
window.addEventListener("resize", place);
|
||||
window.addEventListener("scroll", place, true);
|
||||
return () => {
|
||||
document.removeEventListener("click", close);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("resize", place);
|
||||
window.removeEventListener("scroll", place, true);
|
||||
};
|
||||
}, [modelMenuOpen]);
|
||||
// 删除分镜的两步确认(行内变红,3 秒不二次点击自动复位;不用原生 confirm)
|
||||
const [armedDelete, setArmedDelete] = useState<string | null>(null);
|
||||
@@ -1660,9 +1696,10 @@ export function PipelinePage(props: {
|
||||
setChatText("");
|
||||
// 上传视频提炼:参考的是**结构与节奏**,画面里的商品是别人的,必须换成用户自己的商品重写
|
||||
await runScriptGeneration(
|
||||
`${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的叙事结构、镜头节奏和每镜的作用顺序,`
|
||||
+ `把内容整体换成我自己的商品重写一遍;参考视频里出现的商品、品牌、人物一律不要保留。`
|
||||
+ `原稿里标注「看不清 / 缺失 / 存疑」的地方,由你按我的商品补全。目标人群:${personaLabel}`,
|
||||
`${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,`
|
||||
+ `把人物、商品、品牌、台词全部换成我自己的商品;参考视频里出现的商品、品牌、人物一律不要保留。`
|
||||
+ `写 visual 时把每镜的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进导演说明书,不要压成一句画面摘要。`
|
||||
+ `原稿里标注「无 / 不可见 / 听不清 / 存疑」的地方不要编造,由你按我的商品补全。目标人群:${personaLabel}`,
|
||||
`上传视频提炼 · ${combo}`,
|
||||
"video",
|
||||
);
|
||||
@@ -1782,8 +1819,8 @@ export function PipelinePage(props: {
|
||||
pushMsg(
|
||||
"ai",
|
||||
digest.input === "native_video"
|
||||
? `已拆出 ${digest.duration} 秒整段视频。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。`
|
||||
: `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。`
|
||||
? `已拆出 ${digest.duration} 秒整段视频。拆解稿在下面输入框里,请先逐镜核对景别、运镜、台词和声音再点确定。确认后我按你的商品改写脚本,镜头节奏会跟稿子对齐。`
|
||||
: `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,请先逐镜核对景别、运镜、台词和声音再点确定。确认后我按你的商品改写脚本,镜头节奏会跟稿子对齐。`
|
||||
);
|
||||
chatTextareaRef.current?.focus();
|
||||
} catch (error) {
|
||||
@@ -3195,22 +3232,30 @@ export function PipelinePage(props: {
|
||||
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg>}
|
||||
</button>
|
||||
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */}
|
||||
{textModels && textModels.length > 0 ? (
|
||||
<div className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
|
||||
<button type="button" className="chip" title="选择脚本生成模型" onClick={() => setModelMenuOpen((v) => !v)}>
|
||||
{scriptPickerModels.length > 0 ? (
|
||||
<div ref={modelPickRef} className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
|
||||
<button type="button" className="chip" title="选择脚本生成模型" aria-expanded={modelMenuOpen} aria-haspopup="listbox" onClick={() => setModelMenuOpen((v) => !v)}>
|
||||
{activeModelName}
|
||||
<svg className="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu align-up">
|
||||
{textModels.map((m) => (
|
||||
<div key={m.id} className={`mi${m.id === activeScriptModelId ? " selected" : ""}`} role="menuitemradio" aria-checked={m.id === activeScriptModelId} tabIndex={0}
|
||||
{modelMenuOpen && modelMenuPos && createPortal(
|
||||
<div
|
||||
className="chip-menu chat-model-menu"
|
||||
role="listbox"
|
||||
aria-label="脚本生成模型"
|
||||
style={{ left: modelMenuPos.left, bottom: modelMenuPos.bottom, minWidth: modelMenuPos.width }}
|
||||
>
|
||||
{scriptPickerModels.map((m) => (
|
||||
<div key={m.id} className={`mi${m.id === activeScriptModelId ? " selected" : ""}`} role="option" aria-selected={m.id === activeScriptModelId} tabIndex={0}
|
||||
onClick={() => { setScriptModelId(m.id); setModelMenuOpen(false); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setScriptModelId(m.id); setModelMenuOpen(false); } }}>
|
||||
{publicModelDisplayName(m)}
|
||||
<svg className="mi-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<span className="spacer"></span>
|
||||
@@ -3876,9 +3921,9 @@ export function PipelinePage(props: {
|
||||
)}
|
||||
{/* AI 生成片段不需单卡上传(自定义替换走 queue-bar 全局上传);移除多余「上传」按钮 */}
|
||||
<span className="spacer"></span>
|
||||
{url
|
||||
? <a className="btn btn-ghost btn-sm" href={url} target="_blank" rel="noreferrer" data-vstop>下载</a>
|
||||
: <button className="btn btn-ghost btn-sm" type="button" data-vstop disabled>下载</button>}
|
||||
{url && !showBusy ? (
|
||||
<a className="btn btn-ghost btn-sm" href={url} target="_blank" rel="noreferrer" data-vstop>下载</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ const PROD_PAGE_SIZE = 10;
|
||||
// 商品详情「AI 素材」grid 每页条数(4 列 → 3 整行)
|
||||
const MAT_PAGE_SIZE = 12;
|
||||
import type { Asset, Product, ProductMaterials, Project } from "../types";
|
||||
import type { NavigateFn, Page } from "./route-config";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
import "../product-create-page.css";
|
||||
|
||||
const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"];
|
||||
@@ -87,7 +87,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
products: Product[];
|
||||
projects?: Project[];
|
||||
loading?: boolean;
|
||||
navigate: (page: Page) => void;
|
||||
navigate: NavigateFn;
|
||||
openProduct: (productId: string, tab?: "assets" | "videos") => void;
|
||||
onCreate: (payload: ProductPayload) => Promise<Product | null | undefined> | void;
|
||||
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | void;
|
||||
@@ -132,8 +132,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
}
|
||||
};
|
||||
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
|
||||
// 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择
|
||||
const [createdName, setCreatedName] = useState<string | null>(null);
|
||||
// 创建成功弹窗:存刚创建的商品,非空即展示「继续创建 / 极速成片 / 专业创作」
|
||||
const [createdProduct, setCreatedProduct] = useState<Product | null>(null);
|
||||
const [openChip, setOpenChip] = useState<"" | "cat" | "date" | "type">("");
|
||||
// 商品分类筛选改回多选:Set 存已选分类(空 = 全部),菜单含每项计数 + chip 上橙色计数徽标
|
||||
const [catFilter, setCatFilter] = useState<Set<string>>(new Set());
|
||||
@@ -351,14 +351,15 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
/>
|
||||
|
||||
<SuccessModal
|
||||
open={Boolean(createdName)}
|
||||
open={Boolean(createdProduct)}
|
||||
title="商品创建成功"
|
||||
detail={`「${createdName}」已加入商品库。你可以继续创建商品,或直接为它新建一个视频项目。`}
|
||||
close={() => setCreatedName(null)}
|
||||
detail={`「${createdProduct?.title}」已加入商品库。你可以继续创建商品,或直接为它生成视频。`}
|
||||
close={() => setCreatedProduct(null)}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn" type="button" onClick={() => { setCreatedName(null); setDrawer(true); }}>继续创建商品</button>
|
||||
<button className="btn btn-primary" type="button" onClick={() => { setCreatedName(null); navigate("projectWizard"); }}>去新建项目</button>
|
||||
<button className="btn" type="button" onClick={() => { setCreatedProduct(null); setDrawer(true); }}>继续创建商品</button>
|
||||
<button className="btn" type="button" onClick={() => { const productId = createdProduct?.id; setCreatedProduct(null); navigate("projectWizard", { productId }); }}>专业创作</button>
|
||||
<button className="btn btn-primary" type="button" onClick={() => { const productId = createdProduct?.id; setCreatedProduct(null); navigate("quickCreate", { productId }); }}>极速成片</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -369,8 +370,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
close={() => setDrawer(false)}
|
||||
onCreate={onCreate}
|
||||
onUploadImage={onUploadImage}
|
||||
// 创建成功 → 弹「继续创建商品 / 去新建项目」选择弹窗(替代纯 toast)
|
||||
onCreated={(product) => setCreatedName(product.title)}
|
||||
// 创建成功 → 弹「继续创建商品 / 极速成片 / 专业创作」选择弹窗(替代纯 toast)
|
||||
onCreated={(product) => setCreatedProduct(product)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
@@ -1208,10 +1209,14 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
</div>
|
||||
<div className="qa-section">
|
||||
<div className="qa-section-h">视频生成</div>
|
||||
<div className="qa-row-1">
|
||||
<div className="qa-item primary" data-go="projects-new" role="button" tabIndex={0} onClick={() => navigate("projectWizard", { productId: product.id })}>
|
||||
<div className="qa-row-2">
|
||||
<div className="qa-item primary" data-go="quick-create" role="button" tabIndex={0} onClick={() => navigate("quickCreate", { productId: product.id })}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m21.64 3-1.28 1.28a5.5 5.5 0 0 0-7.78 7.78l-8.5 8.5a2.12 2.12 0 0 0 3 3l8.5-8.5a5.5 5.5 0 0 0 7.78-7.78Z"/><path d="m14 7 3 3"/></svg></span>
|
||||
极速成片
|
||||
</div>
|
||||
<div className="qa-item" data-go="projects-new" role="button" tabIndex={0} onClick={() => navigate("projectWizard", { productId: product.id })}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg></span>
|
||||
生成视频
|
||||
专业创作
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -435,7 +435,7 @@ const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; labe
|
||||
type ProjTab = (typeof PROJ_TABS)[number]["filter"];
|
||||
|
||||
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string; primary?: boolean }> = [
|
||||
{ title: "极速成片", desc: "输入商品名称并上传图片,自动完成脚本、场景、故事板和视频生成。", page: "projectWizard", image: "/assets/yz/video-quick.jpg", primary: true },
|
||||
{ title: "极速成片", desc: "输入商品名称并上传图片,自动完成脚本、场景、故事板和视频生成。", page: "quickCreate", image: "/assets/yz/video-quick.jpg", primary: true },
|
||||
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产、故事板和最终视频。", page: "projectWizard", image: "/assets/yz/video-pro.jpg", primary: true },
|
||||
];
|
||||
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
|
||||
@@ -444,13 +444,24 @@ const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: strin
|
||||
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
|
||||
];
|
||||
|
||||
function isQuickCreateProject(project: Project) {
|
||||
if (project.quick_create) return true;
|
||||
if (project.metadata?.quick_create) return true;
|
||||
return / · 极速成片$/.test(project.name || "");
|
||||
}
|
||||
|
||||
function projectModeLabel(project: Project) {
|
||||
return isQuickCreateProject(project) ? "极速成片" : "专业创作";
|
||||
}
|
||||
|
||||
function projCardSub(project: Project, productTitle: string): string {
|
||||
if (project.status === "failed") return project.failure_reason || "生成失败,可重新拍摄";
|
||||
const mode = projectModeLabel(project);
|
||||
const shots = projShotMeta(project);
|
||||
const stage = projStageLabel(project);
|
||||
if (project.status === "completed") return `专业创作 · ${productTitle} · ${shots}`;
|
||||
if (project.status === "completed") return `${mode} · ${productTitle} · ${shots}`;
|
||||
const no = projStageNo(project);
|
||||
return `专业创作 · ${stage} · ${no} / ${PROJ_STAGE_TOTAL}`;
|
||||
return `${mode} · ${stage} · ${no} / ${PROJ_STAGE_TOTAL}`;
|
||||
}
|
||||
function projStageLabel(project: Project): string {
|
||||
if (project.status === "failed") return "失败";
|
||||
@@ -741,7 +752,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
<p>{projCardSub(project, productTitle(project.product))}</p>
|
||||
<div className="vc-line">
|
||||
<span className={`vc-tag ${bucket}`}>{statusLabel}</span>
|
||||
<span className="vc-tag type">专业创作</span>
|
||||
<span className="vc-tag type">{projectModeLabel(project)}</span>
|
||||
</div>
|
||||
{bucket === "wip" && (
|
||||
<div className="vc-progress" aria-hidden="true"><span style={{ width: `${Math.round((no / PROJ_STAGE_TOTAL) * 100)}%` }} /></div>
|
||||
|
||||
@@ -1,78 +1,649 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, Sparkles, Trash2, Upload, WandSparkles } from "lucide-react";
|
||||
import type { Page } from "./route-config";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Clapperboard,
|
||||
Download,
|
||||
ImagePlus,
|
||||
RefreshCw,
|
||||
ScanSearch,
|
||||
ScrollText,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Play,
|
||||
Upload,
|
||||
UsersRound,
|
||||
WandSparkles,
|
||||
X,
|
||||
Columns2,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError, type QuickCreateJob } from "../api";
|
||||
import type { ModelConfig } from "../types";
|
||||
import {
|
||||
DEFAULT_BILLING_RATES,
|
||||
estimateCost,
|
||||
FC_MODELS,
|
||||
modelLabel,
|
||||
type BillingRates,
|
||||
} from "../components/free-create/constants";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
export function QuickCreatePage({ onBack }: { onBack: () => void; navigate?: (page: Page) => void }) {
|
||||
const QUICK_JOB_KEY = "airshelf:quick-create-job";
|
||||
const PROGRESS_STEPS = [
|
||||
{ label: "识别商品与卖点", icon: ScanSearch },
|
||||
{ label: "推荐脚本方向", icon: ScrollText },
|
||||
{ label: "匹配模特与场景", icon: UsersRound },
|
||||
{ label: "生成故事板与视频", icon: Clapperboard },
|
||||
];
|
||||
const QUICK_RATIOS = [
|
||||
{ value: "9:16", label: "9:16 竖屏" },
|
||||
{ value: "16:9", label: "16:9 横屏" },
|
||||
{ value: "1:1", label: "1:1 方形" },
|
||||
{ value: "3:4", label: "3:4 竖版" },
|
||||
{ value: "4:3", label: "4:3 横版" },
|
||||
{ value: "21:9", label: "21:9 超宽" },
|
||||
];
|
||||
const QUICK_RESOLUTIONS = [
|
||||
{ value: "480p", label: "480p 流畅" },
|
||||
{ value: "720p", label: "720p 高清" },
|
||||
{ value: "1080p", label: "1080p 超清" },
|
||||
{ value: "4k", label: "4K 超清" },
|
||||
];
|
||||
const QUICK_DURATIONS = [15, 30, 45, 60];
|
||||
|
||||
function modelResolutions(config: ModelConfig | undefined) {
|
||||
const capabilities = (config?.metadata?.capabilities || {}) as Record<string, unknown>;
|
||||
const nested = Array.isArray(capabilities.resolutions) ? capabilities.resolutions : [];
|
||||
const legacy = Array.isArray(config?.metadata?.resolutions) ? config.metadata.resolutions : [];
|
||||
return (nested.length ? nested : legacy).map(String);
|
||||
}
|
||||
|
||||
function formatClock(seconds: number) {
|
||||
const total = Math.max(0, Math.round(Number(seconds) || 0));
|
||||
const minutes = Math.floor(total / 60);
|
||||
const rest = total % 60;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function historyTitle(item: QuickCreateJob) {
|
||||
return (item.title || item.product_name || "极速成片").replace(/ · 极速成片$/, "");
|
||||
}
|
||||
|
||||
function savedJobId() {
|
||||
try {
|
||||
return localStorage.getItem(QUICK_JOB_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function QuickCreatePage({
|
||||
onBack,
|
||||
backLabel = "返回视频创作",
|
||||
initialProductId,
|
||||
navigate,
|
||||
onNotify,
|
||||
onProjectCreated,
|
||||
modelConfigs,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
backLabel?: string;
|
||||
initialProductId?: string;
|
||||
navigate: NavigateFn;
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
onProjectCreated?: () => void;
|
||||
modelConfigs: ModelConfig[];
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [savedImages, setSavedImages] = useState<Array<{ asset_id: string; url: string }>>([]);
|
||||
const [sourceProductId, setSourceProductId] = useState("");
|
||||
const [preview, setPreview] = useState("");
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [jobId, setJobId] = useState(savedJobId);
|
||||
const [job, setJob] = useState<QuickCreateJob | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [serviceUnavailable, setServiceUnavailable] = useState(false);
|
||||
const [unavailableMessage, setUnavailableMessage] = useState("");
|
||||
const [history, setHistory] = useState<QuickCreateJob[]>([]);
|
||||
const [playing, setPlaying] = useState<{ url: string; poster: string; title: string } | null>(null);
|
||||
const videoConfigs = useMemo(
|
||||
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
|
||||
[modelConfigs],
|
||||
);
|
||||
const preferredModel = useMemo(
|
||||
() => videoConfigs.find((config) => config.name === FC_MODELS[1].name) || videoConfigs[0],
|
||||
[videoConfigs],
|
||||
);
|
||||
const [aspectRatio, setAspectRatio] = useState("9:16");
|
||||
const [resolution, setResolution] = useState("720p");
|
||||
const [totalDuration, setTotalDuration] = useState(15);
|
||||
const [videoModelId, setVideoModelId] = useState("");
|
||||
const [billingRates, setBillingRates] = useState<BillingRates>(DEFAULT_BILLING_RATES);
|
||||
const completedNoticeRef = useRef("");
|
||||
const cancelRequestedRef = useRef(false);
|
||||
const notifyRef = useRef(onNotify);
|
||||
const projectCreatedRef = useRef(onProjectCreated);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!images[0]) {
|
||||
setPreview("");
|
||||
notifyRef.current = onNotify;
|
||||
projectCreatedRef.current = onProjectCreated;
|
||||
}, [onNotify, onProjectCreated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoModelId && preferredModel) setVideoModelId(preferredModel.id);
|
||||
}, [preferredModel, videoModelId]);
|
||||
|
||||
useEffect(() => {
|
||||
void api.billingConfig()
|
||||
.then((config) => setBillingRates({
|
||||
margin: Number(config.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin,
|
||||
rate: Number(config.points_per_yuan) || DEFAULT_BILLING_RATES.rate,
|
||||
multiplier: Number(config.team_price_multiplier) || 1,
|
||||
}))
|
||||
.catch(() => undefined);
|
||||
void api.quickCreateHistory()
|
||||
.then((payload) => setHistory(payload.results || []))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialProductId || jobId) return;
|
||||
let cancelled = false;
|
||||
void api.product(initialProductId)
|
||||
.then((product) => {
|
||||
if (cancelled) return;
|
||||
setName((current) => current || product.title || "");
|
||||
setSourceProductId((current) => current || product.id);
|
||||
setSavedImages((current) => {
|
||||
if (current.length) return current;
|
||||
return (product.images || [])
|
||||
.filter((image) => image.asset)
|
||||
.slice(0, 9)
|
||||
.map((image) => ({ asset_id: image.asset, url: image.preview_url || "" }));
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialProductId, jobId]);
|
||||
|
||||
const selectedVideoModel = useMemo(
|
||||
() => videoConfigs.find((config) => config.id === videoModelId) || preferredModel,
|
||||
[preferredModel, videoConfigs, videoModelId],
|
||||
);
|
||||
const supportedResolutions = useMemo(
|
||||
() => modelResolutions(selectedVideoModel),
|
||||
[selectedVideoModel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportedResolutions.length || supportedResolutions.includes(resolution)) return;
|
||||
setResolution(supportedResolutions.includes("720p") ? "720p" : supportedResolutions[0]);
|
||||
}, [resolution, supportedResolutions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!images.length) {
|
||||
setImagePreviews([]);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(images[0]);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
const urls = images.map((image) => URL.createObjectURL(image));
|
||||
setImagePreviews(urls);
|
||||
return () => urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
}, [images]);
|
||||
|
||||
function selectImages(files: FileList | null) {
|
||||
setImages(Array.from(files || []).slice(0, 9));
|
||||
useEffect(() => {
|
||||
setPreview(imagePreviews[0] || savedImages.find((image) => image.url)?.url || "");
|
||||
}, [imagePreviews, savedImages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await api.quickCreateStatus(jobId);
|
||||
if (cancelled) return;
|
||||
setJob(next);
|
||||
if (next.product_name) setName((current) => current || next.product_name);
|
||||
if (next.product_images?.length) {
|
||||
setSavedImages(next.product_images.filter((image) => image.asset_id));
|
||||
if (next.product_id) setSourceProductId(next.product_id);
|
||||
setImages([]);
|
||||
}
|
||||
if (next.settings) {
|
||||
setAspectRatio(next.settings.aspect_ratio);
|
||||
setResolution(next.settings.resolution);
|
||||
setTotalDuration(next.settings.total_duration);
|
||||
if (next.settings.video_model_config_id) setVideoModelId(next.settings.video_model_config_id);
|
||||
}
|
||||
if (next.status === "succeeded") {
|
||||
if (completedNoticeRef.current !== next.id) {
|
||||
completedNoticeRef.current = next.id;
|
||||
notifyRef.current?.("success", "极速成片已生成");
|
||||
projectCreatedRef.current?.();
|
||||
void api.quickCreateHistory().then((payload) => setHistory(payload.results || [])).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (next.status === "failed" || next.status === "cancelled") return;
|
||||
timer = window.setTimeout(poll, 2500);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
// 极速任务按团队隔离。本机切换账号后,localStorage 里可能仍保留上个团队的任务 ID;
|
||||
// 404 不是生成失败,清掉这条旧记录即可,不能把“任务不存在”不断弹给新账号。
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setJob(null);
|
||||
setJobId("");
|
||||
try {
|
||||
localStorage.removeItem(QUICK_JOB_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
notifyRef.current?.("info", "已清除其他账号的极速成片记录");
|
||||
return;
|
||||
}
|
||||
notifyRef.current?.("error", error instanceof Error ? error.message : "读取极速成片进度失败");
|
||||
timer = window.setTimeout(poll, 8000);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setPlaying(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [playing]);
|
||||
|
||||
function playClip(url?: string, poster?: string, title?: string) {
|
||||
if (!url) {
|
||||
onNotify?.("info", "视频还不能播放,请稍后再试");
|
||||
return;
|
||||
}
|
||||
setPlaying({ url, poster: poster || "", title: title || "预览视频" });
|
||||
}
|
||||
|
||||
function selectImages(files: FileList | null) {
|
||||
const selected = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
|
||||
setImages((current) => {
|
||||
const room = Math.max(0, 9 - savedImages.length - current.length);
|
||||
const accepted = selected.slice(0, room);
|
||||
if (selected.length > room) onNotify?.("info", "最多上传9张图片,已保留前9张");
|
||||
return [...current, ...accepted];
|
||||
});
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
if (index < savedImages.length) {
|
||||
setSavedImages((current) => current.filter((_, imageIndex) => imageIndex !== index));
|
||||
return;
|
||||
}
|
||||
setImages((current) => current.filter((_, imageIndex) => imageIndex !== index - savedImages.length));
|
||||
}
|
||||
|
||||
function clearImages() {
|
||||
setSavedImages([]);
|
||||
setImages([]);
|
||||
}
|
||||
|
||||
async function startGeneration() {
|
||||
if (!name.trim() || (!images.length && !savedImages.length)) {
|
||||
onNotify?.("info", "请先填写商品名称并上传商品图片");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setJob(null);
|
||||
setServiceUnavailable(false);
|
||||
setUnavailableMessage("");
|
||||
cancelRequestedRef.current = false;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("name", name.trim());
|
||||
form.append("aspect_ratio", aspectRatio);
|
||||
form.append("resolution", resolution);
|
||||
form.append("total_duration", String(totalDuration));
|
||||
if (videoModelId) form.append("video_model_config_id", videoModelId);
|
||||
if (sourceProductId && savedImages.length) {
|
||||
form.append("source_product_id", sourceProductId);
|
||||
savedImages.forEach((image) => form.append("image_asset_ids", image.asset_id));
|
||||
}
|
||||
images.forEach((image) => form.append("images", image));
|
||||
const created = await api.startQuickCreate(form);
|
||||
if (cancelRequestedRef.current) {
|
||||
try {
|
||||
await api.cancelQuickCreate(created.id);
|
||||
} catch {
|
||||
/* 启动刚成功但用户已点取消时,尽量停掉后台任务 */
|
||||
}
|
||||
resetResult();
|
||||
notifyRef.current?.("info", "已取消本次生成");
|
||||
return;
|
||||
}
|
||||
setJob(created);
|
||||
setJobId(created.id);
|
||||
setImages([]);
|
||||
setSavedImages(created.product_images || []);
|
||||
setSourceProductId(created.product_id || "");
|
||||
try {
|
||||
localStorage.setItem(QUICK_JOB_KEY, created.id);
|
||||
} catch {
|
||||
/* 本地存储不可用时只影响刷新恢复,不影响本次生成 */
|
||||
}
|
||||
onNotify?.("success", "极速成片任务已启动");
|
||||
onProjectCreated?.();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 503) {
|
||||
setServiceUnavailable(true);
|
||||
setUnavailableMessage(error.message || "极速成片服务暂不可用,请稍后再试");
|
||||
onNotify?.("info", error.message || "极速成片服务暂不可用,请稍后再试");
|
||||
} else {
|
||||
onNotify?.("error", error instanceof Error ? error.message : "极速成片启动失败");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetResult() {
|
||||
setJob(null);
|
||||
setJobId("");
|
||||
setCancelling(false);
|
||||
setServiceUnavailable(false);
|
||||
setUnavailableMessage("");
|
||||
completedNoticeRef.current = "";
|
||||
try {
|
||||
localStorage.removeItem(QUICK_JOB_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelGeneration() {
|
||||
if (cancelling) return;
|
||||
cancelRequestedRef.current = true;
|
||||
if (!jobId) {
|
||||
setSubmitting(false);
|
||||
resetResult();
|
||||
notifyRef.current?.("info", "已取消本次生成");
|
||||
return;
|
||||
}
|
||||
setCancelling(true);
|
||||
try {
|
||||
const next = await api.cancelQuickCreate(jobId);
|
||||
setJob(next);
|
||||
notifyRef.current?.("info", "已取消本次生成");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
resetResult();
|
||||
notifyRef.current?.("info", "已退出本次生成");
|
||||
return;
|
||||
}
|
||||
// 接口失败也不能把人锁在转圈里:清掉本地任务,允许重新开始。
|
||||
resetResult();
|
||||
notifyRef.current?.("info", error instanceof Error ? error.message : "已停止当前生成");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isGenerating = submitting || job?.status === "queued" || job?.status === "running";
|
||||
const isComplete = job?.status === "succeeded";
|
||||
const isCancelled = job?.status === "cancelled";
|
||||
const isFailed = job?.status === "failed" || isCancelled || serviceUnavailable;
|
||||
const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews];
|
||||
const imageCount = savedImages.length + images.length;
|
||||
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel);
|
||||
const activePhase = submitting ? 0 : Math.max(0, Math.min(3, job?.phase_index ?? 0));
|
||||
const result = job?.result;
|
||||
const videoClips = result?.video_segments?.length
|
||||
? result.video_segments
|
||||
: result?.video_url
|
||||
? [{ id: "final", sort_order: 0, duration_seconds: result.duration_seconds, video_url: result.video_url, poster_url: result.poster_url }]
|
||||
: [];
|
||||
const sceneCount = Math.max(1, Math.round(totalDuration / 15));
|
||||
const videoEstimate = estimateCost(
|
||||
selectedVideoModel,
|
||||
{ ratio: aspectRatio, resolution, duration: totalDuration, refs: [] },
|
||||
billingRates,
|
||||
);
|
||||
// 商品理解、基础资产和每镜故事板在脚本生成前无法精确报价;按每场约60积分给出透明预估,最终按成功任务结算。
|
||||
const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 60 : sceneCount * 240;
|
||||
const shellClass = [
|
||||
"quick-create-shell",
|
||||
isGenerating ? "is-generating" : "",
|
||||
isComplete ? "is-complete" : "",
|
||||
isFailed ? "is-failed" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className="quick-create-page">
|
||||
<header className="project-builder-header quick-create-header">
|
||||
<div className="project-builder-title">
|
||||
<button type="button" className="image-back-button" onClick={onBack} aria-label="返回工作台"><ArrowLeft /></button>
|
||||
<div><h1>极速成片</h1><p>输入商品名称并上传图片,系统将自动完成从商品理解到视频生成的全部流程</p></div>
|
||||
<button type="button" className="image-back-button" onClick={onBack} aria-label={backLabel}><ArrowLeft /></button>
|
||||
<div><h1>极速成片</h1></div>
|
||||
</div>
|
||||
<div className="project-builder-status"><strong>AI 自动编排</strong><span>· 无需逐步配置</span></div>
|
||||
</header>
|
||||
|
||||
<div className="quick-create-shell" id="quickCreateShell">
|
||||
<div className={shellClass} id="quickCreateShell">
|
||||
<section className="quick-create-panel quick-form-panel">
|
||||
<div className="quick-form-copy">
|
||||
<h2>告诉我们这是什么商品</h2>
|
||||
<p>系统会识别商品信息,自动选择带货结构、表现形式、模特和场景,并完成15秒竖屏视频。</p>
|
||||
</div>
|
||||
|
||||
<label className="quick-field">
|
||||
<span className="quick-field-label"><span>商品名称</span><small>必填</small></span>
|
||||
<input className="quick-name-input" autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" />
|
||||
<input className="quick-name-input" autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" disabled={isGenerating} />
|
||||
</label>
|
||||
|
||||
<div className="quick-field">
|
||||
<span className="quick-field-label"><span>商品图片</span><small>必填 · 最多9张</small></span>
|
||||
<label className={`quick-upload${preview ? " has-image" : ""}`}>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => selectImages(event.target.files)} />
|
||||
<span className="quick-upload-copy"><span className="quick-upload-icon"><Upload /></span><strong>上传商品图片</strong><small>建议包含清晰主图、包装和使用细节</small></span>
|
||||
<span className="quick-upload-preview">
|
||||
<img src={preview} alt="极速成片商品预览" />
|
||||
<span className="quick-image-count">{images.length}张图片</span>
|
||||
<button type="button" className="quick-image-clear" onClick={(event) => { event.preventDefault(); setImages([]); }} aria-label="删除已上传图片"><Trash2 /></button>
|
||||
</span>
|
||||
<div className={`quick-upload${imageCount ? " has-images" : ""}`}>
|
||||
<input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => { selectImages(event.target.files); event.currentTarget.value = ""; }} />
|
||||
{imageCount ? (
|
||||
<div className="quick-upload-filled">
|
||||
<div className="quick-image-grid" aria-label={`已上传 ${imageCount} 张商品图片`}>
|
||||
{Array.from({ length: 9 }, (_, index) => {
|
||||
const image = displayUrls[index];
|
||||
return <div key={image || savedImages[index]?.asset_id || `empty-${index}`} className={`quick-image-tile${index < imageCount ? " is-filled" : ""}`}>
|
||||
{index < imageCount ? <>{image ? <img src={image} alt={`商品图片 ${index + 1}`} /> : null}<button type="button" disabled={isGenerating} onClick={() => removeImage(index)} aria-label={`删除商品图片 ${index + 1}`}><X /></button></> : null}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<div className="quick-upload-more">
|
||||
<button type="button" className="quick-upload-trigger" onClick={() => imageInputRef.current?.click()} disabled={isGenerating || imageCount >= 9}>
|
||||
<span className="quick-upload-more-icon"><ImagePlus /></span>
|
||||
<strong>继续上传</strong>
|
||||
<small>已上传 {imageCount} / 9</small>
|
||||
</button>
|
||||
<button type="button" className="quick-clear-all" onClick={clearImages} disabled={isGenerating}>清空全部</button>
|
||||
</div>
|
||||
</div>
|
||||
) : <button type="button" className="quick-upload-copy" onClick={() => imageInputRef.current?.click()} disabled={isGenerating}><span className="quick-upload-icon"><Upload /></span><strong>上传商品图片</strong><small>建议包含清晰主图、包装和使用细节</small></button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-parameter-grid" aria-label="视频核心参数">
|
||||
<label className="quick-parameter-field">
|
||||
<span>视频比例</span>
|
||||
<select value={aspectRatio} onChange={(event) => setAspectRatio(event.target.value)} disabled={isGenerating}>
|
||||
{QUICK_RATIOS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="quick-parameter-field">
|
||||
<span>分辨率</span>
|
||||
<select value={resolution} onChange={(event) => setResolution(event.target.value)} disabled={isGenerating}>
|
||||
{QUICK_RESOLUTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value))}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="quick-parameter-field">
|
||||
<span>视频时长</span>
|
||||
<select value={totalDuration} onChange={(event) => setTotalDuration(Number(event.target.value))} disabled={isGenerating}>
|
||||
{QUICK_DURATIONS.map((duration) => <option key={duration} value={duration}>{duration / 15} 场({duration}s)</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="quick-parameter-field">
|
||||
<span>视频模型</span>
|
||||
<select value={videoModelId} onChange={(event) => setVideoModelId(event.target.value)} disabled={isGenerating || !videoConfigs.length}>
|
||||
{videoConfigs.length ? videoConfigs.map((config) => (
|
||||
<option key={config.id} value={config.id}>{FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name)}</option>
|
||||
)) : <option value="">暂无可用视频模型</option>}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="quick-auto-note" aria-label="系统自动完成内容"><span>识别商品与卖点</span><span>推荐脚本方向</span><span>匹配模特与场景</span><span>生成故事板与视频</span></div>
|
||||
|
||||
<div className="quick-form-footer">
|
||||
<div className="quick-cost"><span>仅在视频生成成功后扣费</span><strong>预计 240 积分</strong></div>
|
||||
<button type="button" className="quick-generate-button" disabled><WandSparkles /><span>立即生成视频</span></button>
|
||||
{isGenerating ? (
|
||||
<button type="button" className="quick-cancel-button" onClick={() => void cancelGeneration()} disabled={cancelling}>
|
||||
<X /><span>{cancelling ? "正在取消…" : "取消生成"}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canRetry}>
|
||||
<WandSparkles /><span>{`立即生成视频 · 消耗 ${estimatedPoints} 积分`}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="quick-create-panel quick-status-panel" aria-live="polite">
|
||||
<div className="quick-state quick-state-ready">
|
||||
<div className="quick-ready-orbit"><span className="quick-ready-icon"><Sparkles /></span></div>
|
||||
<h2>系统会替你完成所有选择</h2>
|
||||
<p>上传商品后,AI 将根据品类、图片信息和适用场景自动编排完整视频,不需要理解复杂的制作参数。</p>
|
||||
<div className="quick-ready-tags"><span>自动推荐结构</span><span>自动选择表现形式</span><span>自动匹配资产</span><span>自动质量检查</span></div>
|
||||
<h2>核心参数可选,其余自动完成</h2>
|
||||
</div>
|
||||
|
||||
<div className="quick-state quick-state-generating">
|
||||
<div className="quick-generating-preview" aria-label="视频生成中"><div className="quick-preview-spinner" /></div>
|
||||
<div className="quick-generating-copy"><h2>正在为商品生成视频</h2><p>{job?.message || "正在生成视频并进行质量检查…"}</p></div>
|
||||
<div className="quick-progress-track">
|
||||
{PROGRESS_STEPS.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
const done = index < activePhase;
|
||||
const active = isGenerating && index === activePhase;
|
||||
return <div key={step.label} className={`quick-progress-node${active ? " active" : ""}${done ? " done" : ""}`}><span className="quick-progress-dot"><Icon /></span><strong>{step.label}</strong></div>;
|
||||
})}
|
||||
</div>
|
||||
<div className="quick-generating-actions">
|
||||
<button type="button" className="secondary-action" onClick={() => void cancelGeneration()} disabled={cancelling}>
|
||||
<X />{cancelling ? "正在取消…" : "取消生成"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-state quick-state-complete">
|
||||
<div className={`quick-video-result-grid${videoClips.length === 1 ? " is-single" : ""}`}>
|
||||
{videoClips.map((clip, index) => (
|
||||
<button
|
||||
key={clip.id}
|
||||
type="button"
|
||||
className="quick-video-result-card"
|
||||
onClick={() => playClip(clip.video_url || result?.video_url, clip.poster_url || result?.poster_url || preview, `第${index + 1}场视频`)}
|
||||
>
|
||||
<span className="quick-video-result-thumb">
|
||||
{clip.poster_url || preview ? <img src={clip.poster_url || preview} alt={`第${index + 1}场视频首帧`} /> : clip.video_url ? <video src={clip.video_url} muted playsInline preload="metadata" /> : null}
|
||||
<span className="quick-video-play" aria-hidden="true"><Play /></span>
|
||||
</span>
|
||||
<span className="quick-video-result-meta"><strong>第{index + 1}场视频</strong><small>{clip.duration_seconds || 15}秒</small></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="quick-result-head"><div><h2>{videoClips.length || 1} 个视频已生成</h2><p>{videoClips.length || 1}场 · 每场{videoClips[0]?.duration_seconds || 15}秒 · {result?.aspect_ratio || "9:16"} · {(result?.resolution || "720p").toUpperCase()} · {result?.video_model || "智能模型"}</p></div><span className="quick-result-badge">质量检查通过</span></div>
|
||||
<div className={`quick-result-actions${videoClips.length > 1 ? "" : " is-single"}`}>
|
||||
<button type="button" className="secondary-action" onClick={resetResult}><RefreshCw />重新生成</button>
|
||||
{videoClips.length > 1 ? (
|
||||
<button type="button" className="secondary-action" onClick={() => job && navigate("pipeline", { projectId: job.project_id })}><Columns2 />合并视频</button>
|
||||
) : null}
|
||||
{result?.video_url ? <a className="primary-action quick-download-action" href={result.video_url} target="_blank" rel="noreferrer" download><Download />下载视频</a> : <button type="button" className="primary-action" disabled><Download />下载视频</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-state quick-state-failed">
|
||||
<span className="quick-failed-icon"><AlertCircle /></span>
|
||||
<h2>{serviceUnavailable ? "极速成片暂不可用" : isCancelled ? "已取消本次生成" : "本次生成未完成"}</h2>
|
||||
<p>{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成过程中遇到问题,可以重试或重新开始。"}</p>
|
||||
<div className="quick-failed-actions">
|
||||
{canRetry && !serviceUnavailable ? (
|
||||
<button type="button" className="primary-action" onClick={() => void startGeneration()}><RefreshCw />重试</button>
|
||||
) : null}
|
||||
<button type="button" className={canRetry && !serviceUnavailable ? "secondary-action" : "primary-action"} onClick={resetResult}>
|
||||
<RefreshCw />重新开始
|
||||
</button>
|
||||
{job?.project_id ? (
|
||||
<button type="button" className="secondary-action" onClick={() => navigate("pipeline", { projectId: job.project_id })}>
|
||||
<SlidersHorizontal />进入专业模式
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="quick-history" aria-label="过往极速成片项目">
|
||||
<div className="quick-history-head">
|
||||
<h2>过往极速成片项目</h2>
|
||||
<span>{history.length}个项目</span>
|
||||
</div>
|
||||
{history.length ? (
|
||||
<div className="quick-history-list">
|
||||
{history.map((item) => {
|
||||
const poster = item.result?.poster_url || "";
|
||||
const videoUrl = item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
|
||||
const duration = item.result?.duration_seconds || item.settings?.total_duration || 15;
|
||||
const scenes = item.result?.video_segments?.length || Math.max(1, Math.round(duration / 15));
|
||||
return (
|
||||
<article key={item.id} className="quick-history-card">
|
||||
<button
|
||||
type="button"
|
||||
className="quick-history-thumb"
|
||||
onClick={() => playClip(videoUrl, poster, historyTitle(item))}
|
||||
aria-label={`播放${historyTitle(item)}`}
|
||||
>
|
||||
{poster ? <img src={poster} alt="" /> : <span className="quick-history-thumb-empty"><Play /></span>}
|
||||
<small>{formatClock(duration)}</small>
|
||||
</button>
|
||||
<div className="quick-history-copy">
|
||||
<span className="quick-history-badge">已完成</span>
|
||||
<h3>{historyTitle(item)}</h3>
|
||||
<p>极速成片 · {scenes}场 · {item.result?.aspect_ratio || item.settings?.aspect_ratio || "9:16"} · {(item.result?.resolution || item.settings?.resolution || "720p").toUpperCase()} · {item.result?.video_model || item.settings?.video_model_label || "智能模型"}</p>
|
||||
</div>
|
||||
<button type="button" className="quick-history-open" onClick={() => navigate("pipeline", { projectId: item.project_id })}>
|
||||
<ArrowUpRight />查看项目
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="quick-history-empty">还没有完成的极速成片,生成成功后会出现在这里。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{playing ? (
|
||||
<div className="quick-player-bg" onClick={() => setPlaying(null)} role="presentation">
|
||||
<div className="quick-player" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={playing.title}>
|
||||
<div className="quick-player-bar">
|
||||
<strong>{playing.title}</strong>
|
||||
<button type="button" onClick={() => setPlaying(null)} aria-label="关闭播放"><X /></button>
|
||||
</div>
|
||||
<video src={playing.url} poster={playing.poster || undefined} controls autoPlay playsInline controlsList="nodownload" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,7 +165,9 @@ export function resolveRoute(): ResolvedRoute {
|
||||
if (path === "/messages") return { page: "messages", authMode: "login", hash };
|
||||
if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash };
|
||||
if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash };
|
||||
if (path === "/quick-create") return { page: "quickCreate", authMode: "login", hash };
|
||||
if (path === "/quick-create") {
|
||||
return { page: "quickCreate", authMode: "login", productId: search.get("product_id") || undefined, hash };
|
||||
}
|
||||
if (path === "/video-remix") return { page: "videoRemix", authMode: "login", hash };
|
||||
if (path === "/image-optimize") {
|
||||
return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash };
|
||||
@@ -211,7 +213,7 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
|
||||
case "freeCreate":
|
||||
return "/free-create";
|
||||
case "quickCreate":
|
||||
return "/quick-create";
|
||||
return options.productId ? `/quick-create?product_id=${encodeURIComponent(options.productId)}` : "/quick-create";
|
||||
case "videoRemix":
|
||||
return "/video-remix";
|
||||
case "imageOptimize":
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
ArrowRight,
|
||||
BadgeCheck,
|
||||
Clock3,
|
||||
Copy,
|
||||
FileText,
|
||||
FileVideo,
|
||||
FileVideo2,
|
||||
PanelsTopLeft,
|
||||
Play,
|
||||
RectangleVertical,
|
||||
Save,
|
||||
ScanLine,
|
||||
@@ -15,45 +18,15 @@ import {
|
||||
TextCursorInput,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import type { ModelConfig } from "../types";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import type { ModelConfig, VideoDigestHistory } from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
export const REMIX_PROMPT_KEY = "fc-remix-prompt";
|
||||
const REMIX_DRAFT_KEY = "vr-digest-draft";
|
||||
const VIDEO_DIGEST_POINTS = 30;
|
||||
|
||||
type RemixDraft = {
|
||||
prompt: string;
|
||||
duration: number;
|
||||
shots: number;
|
||||
ratio: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileKind: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function loadDraft(): RemixDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(REMIX_DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
const draft = JSON.parse(raw) as RemixDraft;
|
||||
if (typeof draft.prompt !== "string" || !draft.prompt.trim()) return null;
|
||||
return draft;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft(draft: RemixDraft) {
|
||||
try {
|
||||
localStorage.setItem(REMIX_DRAFT_KEY, JSON.stringify(draft));
|
||||
localStorage.setItem(REMIX_PROMPT_KEY, draft.prompt);
|
||||
} catch {
|
||||
/* 隐私模式写满时静默,内存态仍可用 */
|
||||
}
|
||||
}
|
||||
type ProgressStage = "upload" | "analyze" | "prompt";
|
||||
|
||||
const VIDEO_DIGEST_MAX_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
@@ -116,6 +89,20 @@ function fileMetaCopy(kind: string, size: number, width: number, height: number)
|
||||
return kind;
|
||||
}
|
||||
|
||||
function progressClass(step: ProgressStage, stage: ProgressStage) {
|
||||
const order: ProgressStage[] = ["upload", "analyze", "prompt"];
|
||||
const active = order.indexOf(stage);
|
||||
const index = order.indexOf(step);
|
||||
if (index < active) return "remix-progress-step done";
|
||||
if (index === active) return "remix-progress-step active";
|
||||
return "remix-progress-step";
|
||||
}
|
||||
|
||||
function historySummary(item: VideoDigestHistory) {
|
||||
const bits = [item.ratio || "—", item.shots ? `${item.shots} 个镜头` : "—", item.file_name || "参考视频.mp4"];
|
||||
return bits.join(" · ");
|
||||
}
|
||||
|
||||
export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: {
|
||||
textModels?: ModelConfig[];
|
||||
onNotify: (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -124,18 +111,22 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [draft] = useState(loadDraft);
|
||||
const [prompt, setPrompt] = useState(draft?.prompt || "");
|
||||
const [duration, setDuration] = useState(draft?.duration || 0);
|
||||
const [shots, setShots] = useState(draft?.shots || 0);
|
||||
const [ratio, setRatio] = useState(draft?.ratio || "");
|
||||
const [fileName, setFileName] = useState(draft?.fileName || "");
|
||||
const [fileSize, setFileSize] = useState(draft?.fileSize || 0);
|
||||
const [kind, setKind] = useState(draft?.fileKind || "MP4");
|
||||
const [width, setWidth] = useState(draft?.width || 0);
|
||||
const [height, setHeight] = useState(draft?.height || 0);
|
||||
const [hasResult, setHasResult] = useState(Boolean(draft?.prompt.trim()));
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [shots, setShots] = useState(0);
|
||||
const [ratio, setRatio] = useState("");
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [fileSize, setFileSize] = useState(0);
|
||||
const [kind, setKind] = useState("MP4");
|
||||
const [width, setWidth] = useState(0);
|
||||
const [height, setHeight] = useState(0);
|
||||
const [taskId, setTaskId] = useState("");
|
||||
const [hasResult, setHasResult] = useState(false);
|
||||
const [history, setHistory] = useState<VideoDigestHistory[]>([]);
|
||||
const [openHistoryId, setOpenHistoryId] = useState("");
|
||||
const [playing, setPlaying] = useState<VideoDigestHistory | null>(null);
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
|
||||
|
||||
const digestModels = useMemo(
|
||||
() => textModels.filter((model) => model.status === "active" && isGemini31(model)),
|
||||
@@ -150,20 +141,27 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
? VIDEO_DIGEST_POINTS
|
||||
: Math.max(1, Math.round(Number((VIDEO_DIGEST_POINTS * priceMultiplier).toFixed(6))));
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const data = await api.listVideoDigests();
|
||||
setHistory(data.results || []);
|
||||
} catch {
|
||||
/* 历史失败不挡当前拆解 */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!prompt.trim()) return;
|
||||
saveDraft({
|
||||
prompt,
|
||||
duration,
|
||||
shots,
|
||||
ratio,
|
||||
fileName: file?.name || fileName,
|
||||
fileSize: file?.size || fileSize,
|
||||
fileKind: kind,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}, [prompt, duration, shots, ratio, file, fileName, fileSize, kind, width, height]);
|
||||
try {
|
||||
localStorage.removeItem(REMIX_DRAFT_KEY);
|
||||
} catch {
|
||||
/* 无痕模式忽略 */
|
||||
}
|
||||
void loadHistory();
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
}, [previewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = promptRef.current;
|
||||
@@ -192,6 +190,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
setRatio(ratioLabel(meta.width, meta.height));
|
||||
if (meta.duration) setDuration(Math.round(meta.duration));
|
||||
setHasResult(false);
|
||||
setTaskId("");
|
||||
};
|
||||
|
||||
const analyze = async () => {
|
||||
@@ -205,11 +204,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
const text = digest.text.trim();
|
||||
setPrompt(text);
|
||||
setDuration(digest.duration || duration);
|
||||
setShots(shotCount(text, digest.frames));
|
||||
setFileName(file.name);
|
||||
setShots(digest.shots || shotCount(text, digest.frames));
|
||||
setFileName(digest.file_name || file.name);
|
||||
setFileSize(file.size);
|
||||
if (digest.ratio) setRatio(digest.ratio);
|
||||
if (digest.width) setWidth(digest.width);
|
||||
if (digest.height) setHeight(digest.height);
|
||||
setTaskId(digest.task_id || "");
|
||||
setHasResult(true);
|
||||
onNotify("success", "视频拆解完成,已生成提示词");
|
||||
void loadHistory();
|
||||
} catch (error) {
|
||||
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
|
||||
} finally {
|
||||
@@ -221,6 +225,15 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
const text = prompt.trim();
|
||||
if (!text) return;
|
||||
sessionStorage.setItem(REMIX_PROMPT_KEY, text);
|
||||
if (taskId) {
|
||||
try {
|
||||
const saved = await api.saveVideoDigest(taskId, text);
|
||||
setHistory((items) => items.map((item) => (item.id === saved.id ? saved : item)));
|
||||
} catch {
|
||||
onNotify("error", "提示词保存失败,请重试");
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
onNotify("success", "提示词已保存,可继续生成或直接粘贴");
|
||||
@@ -236,10 +249,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
navigate("freeCreate");
|
||||
};
|
||||
|
||||
const copyHistoryPrompt = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
onNotify("success", "提示词已复制");
|
||||
} catch {
|
||||
onNotify("error", "复制失败,请手动选择文本");
|
||||
}
|
||||
};
|
||||
|
||||
const analyzeLabel = busy
|
||||
? "正在拆解…"
|
||||
: `生成这个需要 ${estimatedPoints} 积分`;
|
||||
|
||||
const stage: ProgressStage = busy ? "analyze" : hasResult ? "prompt" : file ? "analyze" : "upload";
|
||||
|
||||
return (
|
||||
<div className="vr-page">
|
||||
<div className="vr-inner">
|
||||
@@ -256,6 +280,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="remix-progress-strip" aria-label="提示词提炼进度">
|
||||
<div className={progressClass("upload", stage)} data-remix-progress="upload">
|
||||
<span className="remix-progress-dot">1</span>
|
||||
<span>上传视频</span>
|
||||
</div>
|
||||
<div className={progressClass("analyze", stage)} data-remix-progress="analyze">
|
||||
<span className="remix-progress-dot">2</span>
|
||||
<span>智能拆解</span>
|
||||
</div>
|
||||
<div className={progressClass("prompt", stage)} data-remix-progress="prompt">
|
||||
<span className="remix-progress-dot">3</span>
|
||||
<span>生成提示词</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="video-flow-grid remix-flow-grid">
|
||||
<section className="video-flow-panel video-remix-upload-panel">
|
||||
<h2>上传参考视频</h2>
|
||||
@@ -264,10 +303,27 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<strong>参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 60 秒</span>
|
||||
</div>
|
||||
<label className={`video-upload-field${file ? " has-file" : ""}`}>
|
||||
{file && previewUrl ? (
|
||||
<div className="video-upload-field has-file has-preview">
|
||||
<video src={previewUrl} controls playsInline preload="metadata" />
|
||||
<label className="remix-replace-video">
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
void pickFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
更换视频
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<label className="video-upload-field">
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
void pickFile(event.target.files?.[0] || null);
|
||||
@@ -276,16 +332,11 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
/>
|
||||
<span>
|
||||
<FileVideo />
|
||||
<strong>{file?.name || fileName || "点击上传参考视频"}</strong>
|
||||
<small>
|
||||
{file
|
||||
? "视频已就绪,可开始拆解"
|
||||
: fileName
|
||||
? "上次拆解还在,刷新不会丢。要重拆请再选一次文件"
|
||||
: "上传后自动识别镜头结构与内容节奏"}
|
||||
</small>
|
||||
<strong>点击上传参考视频</strong>
|
||||
<small>上传后自动识别镜头结构与内容节奏</small>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="video-flow-actions remix-analyze-actions">
|
||||
<button type="button" className="primary-action" disabled={!file || busy} onClick={() => void analyze()}>
|
||||
@@ -363,8 +414,94 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="remix-history-section" aria-labelledby="remixHistoryTitle">
|
||||
<div className="remix-history-head">
|
||||
<h2 id="remixHistoryTitle">提取过的项目</h2>
|
||||
<span className="remix-history-count">{history.length} 个项目</span>
|
||||
</div>
|
||||
{history.length === 0 ? (
|
||||
<div className="remix-history-empty">还没有提取过的项目</div>
|
||||
) : (
|
||||
<div className="remix-history-list">
|
||||
{history.map((item) => {
|
||||
const open = openHistoryId === item.id;
|
||||
const promptId = `remix-history-prompt-${item.id}`;
|
||||
return (
|
||||
<article className="remix-history-card" key={item.id}>
|
||||
<div
|
||||
className="remix-history-cover is-playable"
|
||||
onClick={() => {
|
||||
if (item.video_url) {
|
||||
setPlaying(item);
|
||||
return;
|
||||
}
|
||||
onNotify("info", "这条没有保存原片,重新上传拆一次就能播放");
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.click();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`播放 ${item.title}`}
|
||||
>
|
||||
{item.cover_url ? (
|
||||
<img src={item.cover_url} alt={`${item.title}封面`} />
|
||||
) : null}
|
||||
<span className="remix-history-play" aria-hidden="true"><Play /></span>
|
||||
<span className="remix-history-duration">{item.duration_label || "00:00"}</span>
|
||||
</div>
|
||||
<div className="remix-history-content">
|
||||
<div className="remix-history-copy">
|
||||
<span className="remix-history-status">{item.status || "已完成"}</span>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{historySummary(item)}</p>
|
||||
</div>
|
||||
<div className="remix-history-actions">
|
||||
<time dateTime={item.created_date}>{item.created_date}</time>
|
||||
<button
|
||||
type="button"
|
||||
className="remix-history-open"
|
||||
aria-expanded={open}
|
||||
aria-controls={promptId}
|
||||
onClick={() => setOpenHistoryId(open ? "" : item.id)}
|
||||
>
|
||||
<FileText />
|
||||
<span>{open ? "收起提示词" : "查看提示词"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="remix-history-prompt" id={promptId} hidden={!open}>
|
||||
<div className="remix-history-prompt-head">
|
||||
<strong>提示词</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="remix-history-copy-button"
|
||||
onClick={() => void copyHistoryPrompt(item.prompt)}
|
||||
>
|
||||
<Copy />
|
||||
复制提示词
|
||||
</button>
|
||||
</div>
|
||||
<textarea readOnly value={item.prompt} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
<MediaLightbox
|
||||
open={Boolean(playing?.video_url)}
|
||||
src={playing?.video_url || ""}
|
||||
kind="video"
|
||||
name={playing?.title}
|
||||
close={() => setPlaying(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
export type PresentationFormat = "oral" | "drama" | "vlog";
|
||||
export type VideoStructure = "pain" | "contrast" | "review" | "scene";
|
||||
export type VideoStructure = "pain" | "contrast" | "review" | "scene" | "promo" | "knowledge";
|
||||
|
||||
export const PRESENTATION_FORMATS: Record<PresentationFormat, string> = {
|
||||
oral: "口播",
|
||||
@@ -18,6 +18,8 @@ export const VIDEO_STRUCTURES: Record<VideoStructure, string> = {
|
||||
contrast: "前后对比",
|
||||
review: "测评验证",
|
||||
scene: "场景种草",
|
||||
promo: "促销抢购",
|
||||
knowledge: "知识分享",
|
||||
};
|
||||
|
||||
export const PRESENTATION_KEYS = Object.keys(PRESENTATION_FORMATS) as PresentationFormat[];
|
||||
@@ -55,6 +57,8 @@ export const STRUCTURE_HINT: Record<VideoStructure, string> = {
|
||||
contrast: "用前后差距说话,视觉冲击最强",
|
||||
review: "当场举证验证,适合高客单价",
|
||||
scene: "把商品放进向往的场景里种草",
|
||||
promo: "优惠机制清楚时,直接讲怎么买最划算",
|
||||
knowledge: "先教用户怎么判断,再自然带出商品",
|
||||
};
|
||||
|
||||
/** 唯一禁用组合:短剧 × 测评验证 —— 演出来的实测没有可信度。 */
|
||||
@@ -83,7 +87,7 @@ export const DURATION_OPTIONS: number[] = Array.from(
|
||||
/** 表现形式推荐的默认总时长:口播短平快 / 短剧要装下三幕 / Vlog 要铺氛围。 */
|
||||
const FORMAT_DEFAULT_DURATION: Record<PresentationFormat, number> = { oral: 30, drama: 45, vlog: 30 };
|
||||
/** 各结构能压到的最短总时长 —— 低于它证据或氛围就不成立了。须落在 15 秒步进上。 */
|
||||
const STRUCTURE_MIN_DURATION: Record<VideoStructure, number> = { pain: 15, contrast: 15, review: 30, scene: 30 };
|
||||
const STRUCTURE_MIN_DURATION: Record<VideoStructure, number> = { pain: 15, contrast: 15, review: 30, scene: 30, promo: 15, knowledge: 30 };
|
||||
|
||||
export function recommendDuration(format: PresentationFormat, structure: VideoStructure): number {
|
||||
return Math.max(FORMAT_DEFAULT_DURATION[format], STRUCTURE_MIN_DURATION[structure]);
|
||||
|
||||
@@ -512,6 +512,8 @@ export type Project = {
|
||||
video_segment_count?: number;
|
||||
// 合成成片地址(最新一次成功拼接):列表播放按钮 / 视频阶段「播放成片」直接用它;没合成过为空串
|
||||
final_video_url?: string;
|
||||
// 是否由极速成片入口创建;列表页据此显示「极速成片」而不是「专业创作」
|
||||
quick_create?: boolean;
|
||||
stages: ProjectStage[];
|
||||
script_versions: ScriptVersion[];
|
||||
video_segments: VideoSegment[];
|
||||
@@ -664,6 +666,22 @@ export type FreeVideoTask = {
|
||||
completed_at: string | null;
|
||||
};
|
||||
|
||||
export type VideoDigestHistory = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
duration_label: string;
|
||||
ratio: string;
|
||||
shots: number;
|
||||
file_name: string;
|
||||
cover_url: string;
|
||||
video_url: string;
|
||||
prompt: string;
|
||||
created_at: string;
|
||||
created_date: string;
|
||||
};
|
||||
|
||||
export type FreeVideoUploadResult = {
|
||||
asset_id: string;
|
||||
url: string;
|
||||
|
||||
@@ -560,9 +560,556 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin-top: 22px;
|
||||
padding: 15px 20px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 10px 28px rgba(22, 45, 92, 0.055);
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-step {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
color: #8893a7;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-step:not(:last-child)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: calc(50% + 52px);
|
||||
width: calc(100% - 104px);
|
||||
height: 1px;
|
||||
background: rgba(0, 47, 167, 0.13);
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(0, 47, 167, 0.16);
|
||||
border-radius: 50%;
|
||||
color: #78849a;
|
||||
background: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-step.active,
|
||||
.vr-page .remix-progress-step.done {
|
||||
color: #23314a;
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-step.active .remix-progress-dot {
|
||||
border-color: var(--klein);
|
||||
color: #fff;
|
||||
background: var(--klein);
|
||||
box-shadow: 0 0 0 5px rgba(0, 47, 167, 0.08);
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-step.done .remix-progress-dot {
|
||||
border-color: rgba(0, 47, 167, 0.28);
|
||||
color: var(--klein);
|
||||
background: #eef3ff;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-flow-grid {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-remix-upload-panel,
|
||||
.vr-page .remix-page .remix-information-panel,
|
||||
.vr-page .remix-page .remix-prompt-panel {
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-remix-upload-panel:hover,
|
||||
.vr-page .remix-page .remix-information-panel:hover,
|
||||
.vr-page .remix-page .remix-prompt-panel:hover {
|
||||
border-color: rgba(0, 47, 167, 0.22);
|
||||
box-shadow: 0 18px 42px rgba(22, 45, 92, 0.09), 0 3px 10px rgba(34, 42, 54, 0.04);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-remix-upload-panel {
|
||||
min-height: 354px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-remix-upload-panel > h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-remix-upload-panel > h2::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--klein);
|
||||
box-shadow: 0 0 0 5px rgba(0, 47, 167, 0.075);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-flow-step {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field {
|
||||
flex: 1;
|
||||
min-height: 196px;
|
||||
border-width: 1px;
|
||||
border-color: rgba(0, 47, 167, 0.28);
|
||||
background: #f5f8ff;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field:hover,
|
||||
.vr-page .remix-page .video-upload-field:focus-within {
|
||||
border-color: var(--klein);
|
||||
background: #f0f5ff;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 47, 167, 0.05);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field > span {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field svg {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 8px;
|
||||
box-sizing: content-box;
|
||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 20px rgba(0, 47, 167, 0.08);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field strong {
|
||||
color: #1d2940;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field.has-preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field.has-preview video {
|
||||
width: 100%;
|
||||
height: 196px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: #0f1728;
|
||||
}
|
||||
|
||||
.vr-page .remix-replace-video {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
background: rgba(13, 19, 31, 0.72);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vr-page .remix-replace-video:hover {
|
||||
background: rgba(0, 47, 167, 0.92);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field.has-preview:hover,
|
||||
.vr-page .remix-page .video-upload-field.has-preview:focus-within {
|
||||
background: #0f1728;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field strong {
|
||||
color: #1d2940;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-information-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-information-panel .video-result-placeholder,
|
||||
.vr-page .remix-page .remix-information-panel .video-analysis-result {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-information-panel.has-result .video-analysis-result,
|
||||
.vr-page .remix-page .remix-prompt-panel.has-result .remix-prompt-result {
|
||||
animation: remixReveal 260ms ease both;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-info-item {
|
||||
box-shadow: 0 5px 16px rgba(25, 49, 95, 0.045);
|
||||
transition: border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-info-item:hover {
|
||||
border-color: rgba(0, 47, 167, 0.2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 20px rgba(25, 49, 95, 0.075);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-prompt-panel .analysis-prompt {
|
||||
border-left: 3px solid var(--klein);
|
||||
background: #fff;
|
||||
box-shadow: inset 0 1px 3px rgba(27, 45, 83, 0.03), 0 8px 22px rgba(22, 45, 92, 0.045);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-prompt-title h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-prompt-actions {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
@keyframes remixReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vr-page .remix-page .remix-information-panel.has-result .video-analysis-result,
|
||||
.vr-page .remix-page .remix-prompt-panel.has-result .remix-prompt-result {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.vr-page .remix-history-section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-head h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-count {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-empty {
|
||||
padding: 28px 16px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||
border-radius: 15px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 184px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 47, 167, 0.13);
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 12px 30px rgba(22, 45, 92, 0.06);
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .remix-history-card:hover {
|
||||
border-color: rgba(0, 47, 167, 0.24);
|
||||
box-shadow: 0 16px 36px rgba(22, 45, 92, 0.09);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.vr-page .remix-history-cover {
|
||||
position: relative;
|
||||
height: 108px;
|
||||
overflow: hidden;
|
||||
border-radius: 11px;
|
||||
background: #e9eef8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-cover img,
|
||||
.vr-page .remix-history-cover video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-cover.is-playable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: rgba(0, 47, 167, 0.88);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-play svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-cover.is-playing .remix-history-play {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-duration {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
padding: 4px 7px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
background: rgba(13, 19, 31, 0.78);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-content {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 8px 8px 8px 0;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||
border-radius: 99px;
|
||||
color: var(--klein);
|
||||
background: #eef3ff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-status::before {
|
||||
content: "";
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .remix-history-copy h3 {
|
||||
margin: 9px 0 0;
|
||||
overflow: hidden;
|
||||
color: #1d2940;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-copy p {
|
||||
margin: 7px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-actions time {
|
||||
color: #8b96a8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.24);
|
||||
border-radius: 9px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-open:hover {
|
||||
border-color: var(--klein);
|
||||
color: #fff;
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .remix-history-open svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-prompt {
|
||||
grid-column: 1 / -1;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.1);
|
||||
border-radius: 11px;
|
||||
background: #f7f9fd;
|
||||
animation: remixReveal 220ms ease both;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-prompt[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-prompt-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-prompt-head strong {
|
||||
color: #334058;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-prompt textarea {
|
||||
width: 100%;
|
||||
min-height: 108px;
|
||||
display: block;
|
||||
resize: none;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 9px;
|
||||
outline: none;
|
||||
color: #27334a;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-copy-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 11px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, border-color 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-copy-button:hover {
|
||||
border-color: var(--klein);
|
||||
color: #fff;
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .remix-history-copy-button svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.vr-page .video-flow-grid,
|
||||
.vr-page .remix-flow-grid { grid-template-columns: 1fr; }
|
||||
.vr-page .remix-prompt-head { flex-direction: column; }
|
||||
.vr-page .video-flow-actions { flex-wrap: wrap; }
|
||||
.vr-page .remix-progress-step:not(:last-child)::after {
|
||||
left: calc(50% + 42px);
|
||||
width: calc(100% - 84px);
|
||||
}
|
||||
.vr-page .remix-history-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.vr-page .remix-history-content {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user