From 158c66ac258bdd20147ba39751a69e4100b91c62 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Fri, 21 Aug 2026 15:08:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=84=9A=E6=9C=AC=20?= =?UTF-8?q?=E5=B0=B1=E8=AF=A5=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/.env | 10 +-- core/backend/apps/adminpanel/tests.py | 55 +++++++++++++++ core/backend/apps/adminpanel/urls.py | 2 + core/backend/apps/adminpanel/views.py | 59 ++++++++++++++++ core/backend/apps/ai/script_agent.py | 70 ++++++++++++++++++- .../apps/ai/test_script_duration_combo.py | 20 ++++++ .../skills/ecommerce-video-script/SKILL.md | 8 ++- .../references/hook-library.md | 4 ++ .../references/methodology.md | 24 ++++++- core/frontend/src/admin-page.css | 5 ++ core/frontend/src/api.ts | 6 ++ .../frontend/src/routes/admin/admin-tasks.tsx | 21 +++++- 12 files changed, 273 insertions(+), 11 deletions(-) diff --git a/core/backend/.env b/core/backend/.env index c714cfa..7f6ccff 100644 --- a/core/backend/.env +++ b/core/backend/.env @@ -19,16 +19,16 @@ TOS_ENDPOINT=https://tos-s3-cn-shanghai.volces.com TOS_BUCKET=airshelf TOS_ACCESS_KEY_ID=AKLTODVhY2U1NzY1MTU3NDA4NThiYzk2ZDMyZDNjYmZhZGY TOS_SECRET_ACCESS_KEY=TWpjNVpqVm1NbVkzTWprNE5ESXlZMkUyT1dNNFlqVmtaRGRoTVdNME5qRQ== -VOLCANO_ARK_API_KEY=ark-2e6b8833-1f62-4565-bb6a-9b02a825eb98-f2520 +VOLCANO_ARK_API_KEY=ark-a0efcea2-fbf4-47bb-a43e-e3eed4138abc-0ad26 VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # 2026-07-09 视频(Seedance)ARK key 换成新账号,与新账号素材库审核同账号 → 真人 asset:// 可解析。EP=ep-20260707204223-wkcn9(备用,代码按模型名调不用它)。 -VIDEO_ARK_API_KEY=ark-2e6b8833-1f62-4565-bb6a-9b02a825eb98-f2520 +VIDEO_ARK_API_KEY=ark-a0efcea2-fbf4-47bb-a43e-e3eed4138abc-0ad26 DEFAULT_TRIAL_CREDITS=0 -YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf +YUNQI_API_KEY=sk-xvfKxM6q2TlGEV4SMZxmg8nf20tR94yG6nSBT5iHfXyuaAvl YUNQI_BASE_URL=https://www.yunqiai.chat/v1 # YunQi 对话(脚本助手)· 按模型分开计费的两把 key(同一 base_url,各自独立额度) -YUNQI_GPT_API_KEY=sk-WX9ClkkjJmquaz4CsmtiUTNQ7fYgDmngNasDUUjbK504uFEd -YUNQI_GEMINI_API_KEY=sk-WNmbNpwHiln5PUfSFgepXKdfoIe3L59rSpaYffp1hHqZ0s5N +YUNQI_GPT_API_KEY=sk-xvfKxM6q2TlGEV4SMZxmg8nf20tR94yG6nSBT5iHfXyuaAvl +YUNQI_GEMINI_API_KEY=sk-xvfKxM6q2TlGEV4SMZxmg8nf20tR94yG6nSBT5iHfXyuaAvl # tokenssr 中转站(主力:gpt-image-2 参考图 + gpt-5.5/gemini 文本 + gemini 图像)· 一把 key 通吃 90 模型 TOKENSSR_API_KEY=sk-vDg00IAX6EW4ABo4ePwAFPRaIjScFLUs1ZM1lInZJvs7z6M0 diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index 18b6f50..9d2314d 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -515,6 +515,61 @@ class AdminTaskMonitorTests(TestCase): task = self._mk_stuck_reserved("k-reap-perm") self.assertEqual(self.nc.post(f"/api/admin/tasks/{task.id}/reap/").status_code, 403) + def _mk_inflight_video(self, *, key: str, provider_task_id: str = "ark-poll-1"): + return self.AITask.objects.create( + team=self.team, + model_config=self.mc, + task_type=self.AITask.Type.FREE_VIDEO, + status=self.AITask.Status.POLLING, + estimated_cost="1.0", + actual_cost="0", + idempotency_key=key, + provider_task_id=provider_task_id, + ) + + def test_poll_inflight_free_video(self): + task = self._mk_inflight_video(key="k-poll-fv") + + def fake_finalize(*, task): + task.status = self.AITask.Status.SUCCEEDED + task.save(update_fields=["status", "updated_at"]) + return task + + with patch("apps.ai.free_video.finalize_free_video", side_effect=fake_finalize) as mock_fin: + r = self.ac.post("/api/admin/tasks/poll/", {}, format="json") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["polled"], 1) + self.assertEqual(r.data["statuses"][str(task.id)], "succeeded") + mock_fin.assert_called_once() + task.refresh_from_db() + self.assertEqual(task.status, self.AITask.Status.SUCCEEDED) + + def test_poll_skips_terminal_and_missing_provider_id(self): + self.AITask.objects.create( + team=self.team, model_config=self.mc, task_type=self.AITask.Type.FREE_VIDEO, + status=self.AITask.Status.POLLING, estimated_cost="1.0", actual_cost="0", + idempotency_key="k-poll-nopid", + ) + with patch("apps.ai.free_video.finalize_free_video") as mock_fin: + r = self.ac.post("/api/admin/tasks/poll/", {}, format="json") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["polled"], 0) + mock_fin.assert_not_called() + + def test_poll_task_ids_filter(self): + keep = self._mk_inflight_video(key="k-poll-keep", provider_task_id="ark-keep") + skip = self._mk_inflight_video(key="k-poll-skip", provider_task_id="ark-skip") + with patch("apps.ai.free_video.finalize_free_video", side_effect=lambda *, task: task) as mock_fin: + r = self.ac.post("/api/admin/tasks/poll/", {"task_ids": [str(keep.id)]}, format="json") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["polled"], 1) + self.assertIn(str(keep.id), r.data["statuses"]) + self.assertNotIn(str(skip.id), r.data["statuses"]) + mock_fin.assert_called_once() + + def test_poll_requires_admin(self): + self.assertEqual(self.nc.post("/api/admin/tasks/poll/", {}, format="json").status_code, 403) + class AdminBillingTests(TestCase): """Phase 7:计费审计(流水浏览/手动调额)+ 4 层额度策略(CRUD + 拦截生效)+ 权限。""" diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py index 0c7b6e5..fdef78b 100644 --- a/core/backend/apps/adminpanel/urls.py +++ b/core/backend/apps/adminpanel/urls.py @@ -21,6 +21,7 @@ from .views import ( admin_task_reap, admin_task_retry, admin_tasks, + admin_tasks_poll, admin_prompt_template_detail, admin_prompt_templates, admin_quality_word_detail, @@ -54,6 +55,7 @@ urlpatterns = [ path("asset-reviews/submit/", admin_asset_reviews_submit, name="admin-asset-reviews-submit"), path("asset-reviews/poll/", admin_asset_reviews_poll, name="admin-asset-reviews-poll"), path("tasks/", admin_tasks, name="admin-tasks"), + path("tasks/poll/", admin_tasks_poll, name="admin-tasks-poll"), path("tasks//", admin_task_detail, name="admin-task-detail"), path("tasks//retry/", admin_task_retry, name="admin-task-retry"), path("tasks//reap/", admin_task_reap, name="admin-task-reap"), diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 57b18d0..123c74b 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -1,5 +1,6 @@ """平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。""" +import logging from decimal import Decimal, ROUND_HALF_UP from django.db.models import Count, F, Q @@ -39,6 +40,12 @@ from .serializers import ( QualityWordSerializer, ) +logger = logging.getLogger(__name__) + +# 后台「刷新状态」只向供应商拉取已提交的异步视频任务;单次上限避免请求拖死。 +_ADMIN_TASK_POLL_LIMIT = 20 +_ADMIN_TASK_POLL_STATUSES = (AITask.Status.SUBMITTED, AITask.Status.POLLING) + def _team_qs(): return ( @@ -410,6 +417,31 @@ def admin_asset_reviews_poll(request): return Response({"polled": len(statuses), "statuses": statuses}) +def _refresh_inflight_task(task: AITask, *, operator) -> AITask: + """对单条在途任务向供应商拉一次最新态。无远端 ID / 非视频异步任务则原样返回。""" + if not task.provider_task_id: + return task + if task.task_type == AITask.Type.FREE_VIDEO: + from apps.ai.free_video import finalize_free_video + + return finalize_free_video(task=task) + if task.task_type == AITask.Type.VIDEO_SEGMENT: + from apps.ai.services import poll_video_segment + from apps.projects.models import VideoSegment + + segment_id = (task.request_payload or {}).get("video_segment_id") + if not segment_id: + return task + segment = VideoSegment.objects.filter(id=segment_id).first() + if segment is None: + return task + user = task.created_by or getattr(task.team, "owner", None) or operator + poll_video_segment(video_segment=segment, user=user) + task.refresh_from_db() + return task + return task + + # ─────────────────────────── AI 任务监控 + 成本异常 ─────────────────────────── @@ -440,6 +472,33 @@ def admin_tasks(request): return paginator.get_paginated_response(AdminTaskSerializer(page, many=True).data) +@api_view(["POST"]) +@permission_classes([IsPlatformAdmin]) +def admin_tasks_poll(request): + """向供应商刷新在途视频任务状态。给 task_ids 则只拉这些,否则拉全平台 submitted/polling。 + + 图片/脚本等同步任务没有远端 task id,点刷新后前端会再拉一次列表,worker 已落库的终态会一并更新。 + """ + ids = request.data.get("task_ids") + qs = ( + AITask.objects.select_related("team", "model_config", "model_config__provider", "project") + .filter(status__in=_ADMIN_TASK_POLL_STATUSES) + .exclude(provider_task_id="") + .order_by("-updated_at") + ) + if ids: + qs = qs.filter(id__in=ids) + statuses = {} + for task in qs[:_ADMIN_TASK_POLL_LIMIT]: + try: + refreshed = _refresh_inflight_task(task, operator=request.user) + statuses[str(refreshed.id)] = refreshed.status + except Exception: # noqa: BLE001 — 单条失败不阻断整页刷新 + logger.warning("admin poll task %s failed", task.id, exc_info=True) + statuses[str(task.id)] = task.status + return Response({"polled": len(statuses), "statuses": statuses}) + + @api_view(["GET"]) @permission_classes([IsPlatformAdmin]) def admin_task_detail(request, task_id): diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index e69e9ca..ab6a25b 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -306,6 +306,59 @@ _OUTPUT_PROTOCOL = """ """ +# 创作方向不放在前端文案里,而是和输出协议一起作为运行时最高优先级约束。 +# 它解决的不是 JSON 合不合法,而是「脚本合格却像商品详情页朗读」的问题。 +_CREATIVE_DIRECTION = """ + +--- + +## 出片感与转化感(硬性创作方向) + +你的脚本不能像商品详情页、说明书或主播念稿;它必须让用户在第一秒看到一个 +**正在发生的具体瞬间**。先选一个最能代表目标人群的「时间 + 地点 + 小麻烦/小欲望」, +全片只围绕这一个情境推进。不要在一条短视频里罗列所有卖点。 + +### 每一镜都必须有推进,禁止平铺直叙 +- 钩子镜:前 15 个字就抛出反常、尴尬、选择或结果;禁止「大家好」「今天分享」 + 「给你们推荐」「这款很好用」「值得买」等任何寒暄或泛推荐开场。 +- 痛点镜:拍得到的细节,而非抽象感受。例如「开会前刘海粘成一绺」而不是「头发很油」。 +- 卖点镜:只证明一个卖点,必须写清「原本卡在哪里 → 手怎么使用商品 → 眼前有什么变化」。 + 不准只报参数、堆形容词或重复商品名。 +- CTA 镜:回到前面那个具体情境,给一个自然的选择/动作;不要硬喊「赶紧下单」。 + +### 画面与台词必须互相提供新信息 +- 每个 15 秒场只安排**一个连续的核心动作链**,例如「拆开 → 倒入 → 颜色变化」, + 不要在同一段塞进无关剧情、三个卖点和多次场景跳转。 +- 台词说人物的判断、感受或转折;画面证明这句话。台词不要逐字复述画面。 +- 至少有一个可见证据:包装/质地/声音/前后状态/动作结果。没有可见证据的卖点不写。 +- 用像真实人在当下会说的短句,可以有停顿、转折和个人立场;禁止「不仅…而且…」 + 「全面升级」「高品质」「性价比很高」「闭眼入」等详情页腔。 + +输出前自问:遮住商品名后,这是不是仍然像一个真实的人在讲自己刚遇到的一件事? +如果像广告口号,重写得更具体、更有立场。 + +### visual 必须写成「导演说明书」,不是一句画面摘要 +每个 segment 的 `visual` 采用下面的层级;这不是给用户看的散文,而是会原样交给故事板和视频模型的执行指令: +``` +【本镜任务】这一段要让观众看懂的变化或悬念。 +【声音】只写可发生的同期声 / 人声状态;没有配乐就明确写「无配乐,仅同期声」。 +【画面内容】 +0-3s:景别;构图;运镜;人物/商品动作;情绪或信息变化。 +3-8s:景别;构图;运镜;手与商品的空间关系;可见细节。 +8-12s:景别;构图;运镜;动作结果或卖点证据。 +12-15s:景别;构图;运镜;反应/悬念/自然收束。 +``` +每条秒级分镜都要明确写出 **景别、构图、运镜、动作、信息变化** 五项中的至少四项。 +同一角色、商品、场景的外观一律引用 entities 里的既定设定,不在每一镜随意换发型、服装、包装、光线或地点。 +""" + +# 这些句式几乎总会让首屏像模板广告。只作为生成后的最后一道门,不替代模型的创作判断。 +_GENERIC_HOOK_PHRASES = ( + "大家好", "今天", "给大家", "给你们", "给姐妹", "分享一下", "推荐一下", + "这款", "好物分享", "真的好用", "值得买", "闭眼入", "宝子们", +) + + # --------------------------------------------------------------------------- # # 提示词构建(3 模式) # --------------------------------------------------------------------------- # @@ -417,7 +470,7 @@ def build_agent_messages( total = DEFAULT_TOTAL_DURATION else: total = coerce_total_duration(total_duration) - system = load_ecommerce_skill(fmt, structure) + _OUTPUT_PROTOCOL + system = load_ecommerce_skill(fmt, structure) + _CREATIVE_DIRECTION + _OUTPUT_PROTOCOL suggested = plan_segment_durations(total, fmt) shot_n = len(suggested) @@ -436,8 +489,9 @@ def build_agent_messages( speech_floor = narration_floor(SEGMENT_DURATION_MAX) speech_cap = narration_limit(SEGMENT_DURATION_MAX) beats_line = ( - f"【秒级分镜】每个 {SEGMENT_DURATION_MAX} 秒场必须拆成 3–5 个分镜,visual 写成多行," - f"格式`0-3s:景别,谁在做什么`,最后一条接到 {SEGMENT_DURATION_MAX}s。" + f"【秒级分镜】每个 {SEGMENT_DURATION_MAX} 秒场必须拆成 3–5 个分镜,visual 必须按「导演说明书」写成多行:" + "先写【本镜任务】、【声音】、【画面内容】,再写秒级分镜;" + f"每条格式`0-3s:景别;构图;运镜;谁在做什么;信息变化`,最后一条接到 {SEGMENT_DURATION_MAX}s。" "每条写清手/商品/容器的空间关系和真实用法" "(茶=热水+蒸汽+茶汤变色,禁止茶包丢进冷白开;手从真实方向入画,禁止悬浮肢体)。" "禁止一句空画面撑满 15 秒。允许另给 beats 数组,后端会折进 visual。\n" @@ -689,6 +743,16 @@ def assert_shot_density(draft: dict, presentation_format: str = DEFAULT_PRESENTA raise ValueError(f"第 {index + 1} 镜旁白太短,口播至少要说到 {floor} 字才能撑满 {duration} 秒") +def assert_script_has_a_hook(draft: dict) -> None: + """拦住最常见的模板开场,逼模型从一个具体瞬间切入而不是先介绍商品。""" + hook = str(draft.get("hook") or "") + if _compact_len(hook) < 6: + raise ValueError("开场钩子太弱,请用一个具体场景、反差或问题重写前3秒") + opening = hook[:18] + if any(phrase in opening for phrase in _GENERIC_HOOK_PHRASES): + raise ValueError("开场像泛泛推荐,请直接从具体场景、反差或问题切入") + + def assert_intra_shot_beats(seg: dict, index: int, duration: int) -> None: """15 秒场必须按秒拆出分镜,否则下游视频只能对着一句空描述乱编。""" need = min_beats_for_duration(duration) diff --git a/core/backend/apps/ai/test_script_duration_combo.py b/core/backend/apps/ai/test_script_duration_combo.py index a1c82fe..d92df26 100644 --- a/core/backend/apps/ai/test_script_duration_combo.py +++ b/core/backend/apps/ai/test_script_duration_combo.py @@ -21,6 +21,7 @@ from apps.ai.script_agent import ( _product_facts, allowed_structures, assert_product_facts_used, + assert_script_has_a_hook, assert_shot_density, build_agent_messages, coerce_combo, @@ -167,6 +168,14 @@ class ProductFactTests(SimpleTestCase): ) +class HookQualityTests(SimpleTestCase): + def test_generic_recommendation_hook_is_rejected(self): + with self.assertRaises(ValueError): + assert_script_has_a_hook({"hook": "大家好,今天给大家推荐一款很好用的茶"}) + + def test_specific_scene_hook_passes(self): + assert_script_has_a_hook({"hook": "下午三点开会前,我的刘海又粘成一绺了"}) + class ProductFactsLookupTests(SimpleTestCase): def _project(self, points): class _QS(list): @@ -356,6 +365,17 @@ class ShotDensityTests(SimpleTestCase): ) self.assertIn("按秒拆分镜", str(ctx.exception)) + def test_director_treatment_keeps_timed_beats_parseable(self): + visual = ( + "【本镜任务】让观众看到茶汤从等待变成可以入口的过程\n" + "【声音】无配乐,仅同期声:热水注入杯中的水声\n" + "【画面内容】\n" + self._VISUAL + ) + beats = parse_visual_beats(visual) + self.assertEqual(len(beats), 4) + self.assertEqual(beats[0][0], 0) + self.assertEqual(beats[-1][1], 15) + def test_beats_array_flattens_into_visual(self): import json diff --git a/core/backend/skills/ecommerce-video-script/SKILL.md b/core/backend/skills/ecommerce-video-script/SKILL.md index 971565e..62e76ba 100644 --- a/core/backend/skills/ecommerce-video-script/SKILL.md +++ b/core/backend/skills/ecommerce-video-script/SKILL.md @@ -96,9 +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-3s:景别;构图;运镜;谁在做什么;手和商品的空间关系;信息变化` 第一条从 0 秒起,最后一条接到 15s。每条都要有景别/运镜,15 秒内至少切两次景别。 **禁止**用一句静态动作撑满 15 秒(如「女主举起商品展示」)。这是给故事板和 Seedance 的导演说明书,**不占 narration 字数**。 +- **`visual` 固定使用导演层级**:先写 `【本镜任务】`(这一段要完成的情绪/信息变化),再写 + `【声音】`(可发生的同期声、人声状态或「无配乐,仅同期声」),接着写 `【画面内容】`, + 最后在该标题下列出 3–5 条秒级分镜。不要把基础设定、画面风格、镜头动作、声音、旁白混成一段散文。 +- **每条秒级分镜都写执行细节**:景别、构图、运镜、动作、信息变化五项至少有四项;例如 + `3-8s:中近景;人物位于画面左三分之一,商品从右下入画;手持跟拍;拧开瓶盖并挤出质地;观众第一次看清产品状态。` + 这比「展示商品」更容易被分镜图和视频模型准确执行。 - **画面物理常识**:商品用法必须是真人会做的。茶/咖啡 = 热水、蒸汽、茶汤渐染,禁止茶包丢进看起来像冷白开的杯子;护肤品 = 挤出/涂抹;食品 = 打开/入口。手从真实方向入画,禁止悬浮肢体、反关节、商品凭空出现。每条分镜写清容器/包装长什么样、液体或材质当前状态。 - 不要输出 schema 之外的字段,也不要省略必填字段。 diff --git a/core/backend/skills/ecommerce-video-script/references/hook-library.md b/core/backend/skills/ecommerce-video-script/references/hook-library.md index ebe329e..71af18e 100644 --- a/core/backend/skills/ecommerce-video-script/references/hook-library.md +++ b/core/backend/skills/ecommerce-video-script/references/hook-library.md @@ -5,6 +5,10 @@ 钩子的唯一任务:**让用户在 3 秒内停止划走**。手段是制造"和我有关 / 没想到 / 想看下去"。 +**先过反模板门槛:**钩子必须从正在发生的一幕切入,前 15 个字出现具体时间、地点、身份、 +动作、后果或反差中的至少一个。禁止「大家好」「今天分享」「给大家推荐」「这款很好用」 +「值得买」等寒暄和泛推荐;它们没有任何让人停下来的理由。 + --- ## 公式 1 · 痛点提问(最稳,默认首选) diff --git a/core/backend/skills/ecommerce-video-script/references/methodology.md b/core/backend/skills/ecommerce-video-script/references/methodology.md index 4f44835..ced2a3f 100644 --- a/core/backend/skills/ecommerce-video-script/references/methodology.md +++ b/core/backend/skills/ecommerce-video-script/references/methodology.md @@ -19,6 +19,10 @@ - **首镜永远是钩子**——3 秒内必须抛出钩子,否则用户划走。 - **末镜永远收 CTA**——可以是独立 CTA 镜,也可以是末镜旁白末尾收一句行动指令。 - **卖点必须挂在痛点上**——不是罗列参数,是"这个痛点 → 商品这样解决"。 +- **一条只讲一个核心情境**——先选一个「时间 + 地点 + 小麻烦/小欲望」,整条围绕它推进; + 不要把商品详情页的所有卖点搬进来。 +- **每镜必须有变化**——人物的处境、手上的动作或眼前的结果至少推进一项;镜与镜不能只是 + 换种说法重复夸商品。 --- @@ -108,6 +112,8 @@ 主流程每镜都是 15 秒:**要说满,不要宁可短。** 一句 20 字撑不满镜头。 留白靠句间停顿和画面,不靠少写字。只有 Vlog 才允许个别镜纯画面无声。 - **不浮夸、不空喊**:卖点要给具体理由,不堆形容词。 +- **拒绝详情页腔**:禁止用「全面升级 / 高品质 / 性价比很高 / 闭眼入 / 值得买」替代体验与证据。 + 台词要有当下的个人判断,例如先说自己卡在哪,再说为什么这一步让自己改观。 - **违规词禁令**(电商广告法红线,一律不写): - **医疗功效类**:治疗 / 根治 / 疗效 / 抗癌 / 消炎 / 杀菌(无证) / 排毒 / 速效 / 抑制 ××病 … - **绝对化用语**:最 / 第一 / 顶级 / 唯一 / 100% / 国家级 / 世界级 / 永久 / 绝对 / 史上 … @@ -122,6 +128,20 @@ 主流程一场就是 15 秒。15 秒里**必须再切 3–5 刀**,写进 `visual`,下游故事板和视频只认这段。 +`visual` 不写普通画面摘要,固定按下面的导演说明书结构输出: + +``` +【本镜任务】本段要让观众看懂的变化或悬念 +【声音】无配乐,仅同期声:包装摩擦声、倒水声;人物说话自然停顿 +【画面内容】 +0-3s:景别;构图;运镜;动作;信息变化 +3-8s:景别;构图;运镜;动作;信息变化 +... +``` + +这样「人物/商品/场景设定」留在 entities 中保持全片一致,「本镜任务」管情绪推进, +「画面内容」只管可执行的镜头,声音也不会漏给视频模型。不得写虚构或无法发生的音效。 + ``` 0-3s:近景,人看镜头或看向场景,先立住环境和情绪 3-8s:手部入画,开始使用商品(写清容器、材质、液体/膏体状态) @@ -130,7 +150,9 @@ ``` - 第一条从 **0 秒**起,最后一条接到 **15s**。 -- 每一条写:景别 + 谁 + 手从哪来 + 商品/容器什么样 + 这一拍在变什么。 +- 每一条写:**景别 + 构图 + 运镜 + 动作 + 信息变化**(至少四项),并写清谁、手从哪来、商品/容器什么样。 - 15 秒内至少切 **两次景别**(近景 / 特写 / 手持跟拍…)。 - 商品用法必须真实:茶要热水和蒸汽,不要把茶包丢进冷白开;手不要悬浮。 +- **一场一个动作链**:把 15 秒拆成同一件事的起因 → 操作 → 可见变化 → 反应,不能把无关的 + 剧情、多个卖点、跳场景硬塞进同一场。画面提供证据,台词提供判断或转折,不要互相复述。 - 允许另给 `beats:[{start,end,shot_size,action}]`,但 `visual` 仍要能折成上面这种多行文本。 diff --git a/core/frontend/src/admin-page.css b/core/frontend/src/admin-page.css index 3621e93..2e3aa53 100644 --- a/core/frontend/src/admin-page.css +++ b/core/frontend/src/admin-page.css @@ -214,6 +214,11 @@ padding: 52px clamp(40px, 3.2vw, 68px) 48px; } .admin-page .page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; margin-bottom: 28px; } .admin-page .page-head h1 { diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 8ad18a7..e744615 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -1033,6 +1033,12 @@ export const adminApi = { retryTask(id: string) { return request<{ retried: boolean; task_id: string }>(`/api/admin/tasks/${id}/retry/`, { method: "POST" }); }, + pollTasks(taskIds?: string[]) { + return request<{ polled: number; statuses: Record }>( + "/api/admin/tasks/poll/", + { method: "POST", body: JSON.stringify(taskIds ? { task_ids: taskIds } : {}) } + ); + }, reapTask(id: string) { return request(`/api/admin/tasks/${id}/reap/`, { method: "POST" }); }, diff --git a/core/frontend/src/routes/admin/admin-tasks.tsx b/core/frontend/src/routes/admin/admin-tasks.tsx index d947114..ecad162 100644 --- a/core/frontend/src/routes/admin/admin-tasks.tsx +++ b/core/frontend/src/routes/admin/admin-tasks.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { Activity, X } from "lucide-react"; +import { Activity, RefreshCw, X } from "lucide-react"; import { adminApi } from "../../api"; import { Pager } from "../../components/pager"; import { IconKitSvg } from "../../components/IconKitSvg"; @@ -77,6 +77,20 @@ export function AdminTasksPage({ notify }: { notify: Notify }) { } } + async function refreshStatus() { + if (busy) return; + setBusy(true); + try { + const res = await adminApi.pollTasks(); + notify("success", res.polled > 0 ? `已刷新 ${res.polled} 个进行中任务` : "列表已更新"); + await load(); + } catch { + notify("error", "刷新失败"); + } finally { + setBusy(false); + } + } + async function retry(t: AdminTask) { if (busy) return; setBusy(true); @@ -114,6 +128,11 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {

任务监控

{count} 个任务 · 全局 AI 任务 · 成本异常 · 失败重投
+
+ +