diff --git a/CLAUDE.md b/CLAUDE.md index d716ba8..918689f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,6 +244,34 @@ jimeng-clone/ | `/admin/assets` | AdminAssetsPage | Admin | Content assets (team→member→video hierarchy) | | `/team/assets` | TeamAssetsPage | TeamAdmin | Team content assets (member→video hierarchy) | +## AI Skills Reference + +本项目用 **skill**(可复用方法论)+ **memory**(个人纪律)+ **hook**(系统强制)三层指引 AI 开发。按编辑场景对应加载: + +| 编辑场景 | 应用的 skill / memory / hook | +|---|---| +| `backend/utils/seedance_client.py`、任何 Seedance API 对接 | **Skill** `seedance-api-integration` — 参数构造、token 计费、异步轮询、错误码映射、asset:// 引用、白标、5 起真实踩坑 | +| Django models 新增字段(`backend/apps/*/models.py`)或改 create() 调用 | **Memory** `feedback_mysql_default` / `feedback_mysql_explicit_fields` — MySQL 严格模式下 create() 必须显式传所有 CharField 值(已 1 起 2026-03-19 线上 500 事故) | +| 任何 `git push` 操作 | **Hook** `~/.claude/hooks/pre-git-push.sh` 会拦截;用户明确授权后用 `ALLOW_PUSH=1 git push` 重试 | +| 用户可见 label / 按钮 / 错误提示 | **Memory** `feedback_write_full_labels`(单位/类型/含义写全)+ `feedback_user_facing_docs_plain_language`(禁程序员术语)+ `feedback_seconds_unit`(只用秒,不换分钟/小时) | +| 任何 user-facing 字符串含模型名 / 错误提示 / 帮助文案 | **Memory** `feedback_no_seedance_branding` — 白标合同禁出现 "Seedance",用 "AirDrama" / "AirDrama Fast" | +| 内联编辑按钮(保存 / 取消) | **Memory** `feedback_inline_edit_style` — `whiteSpace: 'nowrap'` 必加,历史多次被挤成两行 | +| 对接火山 / 豆包 / Seedance 等第三方 API 添加新字段或错误码 | **Memory** `feedback_follow_official_api` — 严格按官方文档,不瞎编字段 / 错误码(历史:曾脑补 `.PolicyViolation` 子类型) | +| 生成页 / `@` mention / 素材库相关测试 | 用 `tudou`(团管)账号登录;`admin` 账号默认没 team,进不了生成页 | +| 改完代码、宣称"完成"之前 | **Memory** `feedback_verify_before_deliver` + `feedback_verify_thoroughly` — 必须自己跑完整 user 路径验证关键改动,不让用户替我们背锅 | +| 商业级代码要求(并发、失败、资源复用) | **Memory** `feedback_commercial_grade` — 不准说"可以接受 / 后续优化",要么做到位要么明确标风险 | +| 改方案 / 改 UI 前发现截图或讨论 | **Memory** `feedback_no_rush_changes` + `feedback_simple_first` — 先提方案(最简单的先给)等用户说"改吧"再动手 | + +**纪律分级:** +- **Skill** — 跨项目可复用的大块方法论(`~/.claude/skills/`) +- **Memory** — 协作纪律 / 个人偏好(自动加载到 session context,在 `~/.claude/projects/c--Airlabs-Project/memory/`) +- **Hook** — 系统级硬拦截(`~/.claude/settings.json` + `~/.claude/hooks/`) +- 项目特定架构约定直接写在本 CLAUDE.md 里(Project Architecture / API Endpoints / Database Models 等章节) + +**增删映射项时:** 新增跨项目规则写 memory、本项目特定规则写 CLAUDE.md 对应章节、跨项目技术方法论才做 skill(参考 `feedback_no_skill_bloat`)。 + +--- + ## Incremental Development Guide ### How to Add Features to This Project diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 034d742..ebec6a7 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -69,6 +69,27 @@ def _sum_video_duration(references): return total +def _format_prompt_for_ark(prompt, label_placeholders): + """把 prompt 中的 @label 替换为火山可识别的「图片N/视频N/音频N」。 + + 火山 Seedance 模型只能理解"素材类型+序号"格式的指代(官方文档 FAQ Q3); + 文件名 / asset id / URL 对它都是不可理解的字符串,会按位置概率性对齐, + 表现为"人物颠倒"。此函数在发给火山之前做一次静默替换,用户 prompt 原文 + 保留在 DB 便于 reEdit 回填带缩略图的标签。 + + label_placeholders: [(label, placeholder), ...] 调用方需保证按 label 长度 + 降序,防止"碧"先于"碧碧"被替换的子串吞噬问题。 + + 用 str.replace 而非 re.sub,避免 label 含正则元字符(如 "@[test].png")时崩溃。 + """ + result = prompt + for label, placeholder in label_placeholders: + if not label: + continue + result = result.replace(f'@{label}', placeholder) + return result + + def _get_token_price(config, model, has_video_ref, resolution): """根据模型、是否含视频、分辨率选择单价。 @@ -328,6 +349,20 @@ def video_generate_view(request): seen_urls = set() # 去重:同一个素材只引用一次 _asset_cache = {} # group_id → [(asset_url, asset_type), ...],避免同一素材组重复查询 + # 火山规范要求 prompt 里用「图片N/视频N/音频N」指代素材(不能用文件名/asset id)。 + # 循环同步维护各类型 counter 和 label→placeholder 映射,循环结束后一次性替换 prompt。 + # 不变量:任意时刻 image_n / video_n / audio_n == content_items 里该类型 *_url 已 push 的个数。 + label_to_placeholder: dict = {} + image_n = video_n = audio_n = 0 + + def _placeholder_for(asset_type): + """读取当前 counter 值对应的 placeholder。调用前 counter 必须已递增。""" + if asset_type == 'Video': + return f'视频{video_n}' + if asset_type == 'Audio': + return f'音频{audio_n}' + return f'图片{image_n}' + from .models import Asset as AssetModel def _resolve_asset_group_all(gid, lbl): @@ -408,11 +443,16 @@ def video_generate_view(request): aid = 'asset-' + aid[6:] resolved_asset_url = f'asset://{aid}' if asset_obj.asset_type == 'Video': + video_n += 1 content_items.append({'type': 'video_url', 'video_url': {'url': resolved_asset_url}, 'role': 'reference_video'}) elif asset_obj.asset_type == 'Audio': + audio_n += 1 content_items.append({'type': 'audio_url', 'audio_url': {'url': resolved_asset_url}, 'role': 'reference_audio'}) else: + image_n += 1 content_items.append({'type': 'image_url', 'image_url': {'url': resolved_asset_url}, 'role': 'reference_image'}) + if label and label not in label_to_placeholder: + label_to_placeholder[label] = _placeholder_for(asset_obj.asset_type) except AssetModel.DoesNotExist: return Response({ 'error': 'asset_not_found', @@ -442,11 +482,17 @@ def video_generate_view(request): }, status=status.HTTP_400_BAD_REQUEST) for asset_url, asset_type in asset_list: if asset_type == 'Video': + video_n += 1 content_items.append({'type': 'video_url', 'video_url': {'url': asset_url}, 'role': 'reference_video'}) elif asset_type == 'Audio': + audio_n += 1 content_items.append({'type': 'audio_url', 'audio_url': {'url': asset_url}, 'role': 'reference_audio'}) else: + image_n += 1 content_items.append({'type': 'image_url', 'image_url': {'url': asset_url}, 'role': 'reference_image'}) + # 老兼容路径:一个 label 对应 N 张图,展开成"图片N"会改变语义,不登记 label。 + # 但 counter 必须继续递增,否则后续 local 分支的编号会错位。 + logger.warning('legacy asset://group-%s used (label=%s), skip @-replacement (counter advanced by %d)', group_id, label, len(asset_list)) except Exception as e: logger.warning('Failed to resolve asset group URL %s: %s', url, e) return Response({ @@ -456,6 +502,7 @@ def video_generate_view(request): continue # 素材组已展开为多个 content_items,跳过下面的单项处理 if ref_type == 'image': + image_n += 1 item = {'type': 'image_url', 'image_url': {'url': url}} # API 文档要求:参考图模式下所有图片的 role 必须为 reference_image if mode == 'universal': @@ -464,15 +511,25 @@ def video_generate_view(request): item['role'] = role content_items.append(item) elif ref_type == 'video': + video_n += 1 item = {'type': 'video_url', 'video_url': {'url': url}} if role: item['role'] = role content_items.append(item) elif ref_type == 'audio': + audio_n += 1 item = {'type': 'audio_url', 'audio_url': {'url': url}} if role: item['role'] = role content_items.append(item) + else: + # 防御性:未知 ref_type(脏数据或未来扩展)→ 不推 content_item, 不登记 + logger.warning('unknown ref_type=%s url=%s label=%s, skipped', ref_type, url, label) + continue + + if label and label not in label_to_placeholder: + _type_map = {'image': 'Image', 'video': 'Video', 'audio': 'Audio'} + label_to_placeholder[label] = _placeholder_for(_type_map[ref_type]) logger.info('Video generate: %d content_items built (prompt=%s...)', len(content_items), prompt[:60]) @@ -501,9 +558,14 @@ def video_generate_view(request): # ── 调用 AirDrama API(事务外,避免持锁) ── from django.conf import settings as django_settings if django_settings.SEEDANCE_ENABLED and django_settings.ARK_API_KEY: + # 按火山规范把 @label 替换为「图片N/视频N/音频N」;DB 的 record.prompt 仍保留原文 + sorted_pairs = sorted(label_to_placeholder.items(), key=lambda kv: -len(kv[0])) + api_prompt = _format_prompt_for_ark(prompt, sorted_pairs) + logger.info('[ark-prompt] original=%s | converted=%s | mapping=%s', + prompt, api_prompt, label_to_placeholder) try: ark_response = create_task( - prompt=prompt, + prompt=api_prompt, model=model, content_items=content_items, aspect_ratio=aspect_ratio, diff --git a/backend/tests/test_ark_prompt_format.py b/backend/tests/test_ark_prompt_format.py new file mode 100644 index 0000000..6922cfe --- /dev/null +++ b/backend/tests/test_ark_prompt_format.py @@ -0,0 +1,239 @@ +""" +测试 prompt 转火山「图片N/视频N/音频N」格式 — v0.19.1+ + +火山模型无法理解文件名/asset id,必须用「素材类型+序号」指代(官方文档 FAQ Q3)。 +本测试文件覆盖: + 单元测试:纯函数 _format_prompt_for_ark + 集成测试:video_generate_view 端到端(含 counter 对齐关键回归) +""" +import os +import sys +import django +from unittest import mock + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +django.setup() + +import unittest +from django.test import TestCase, override_settings +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from apps.accounts.models import Team +from apps.generation.models import QuotaConfig, AssetGroup, Asset, GenerationRecord +from apps.generation.views import _format_prompt_for_ark + +User = get_user_model() + + +# ──────────────────────────────────────────────── +# 单元测试:纯函数 _format_prompt_for_ark +# ──────────────────────────────────────────────── + +class TestFormatPromptForArk(unittest.TestCase): + """覆盖各种 label 替换场景(字符串级别)。""" + + def test_basic_replacement(self): + """@label 替换为 placeholder。""" + out = _format_prompt_for_ark('@碧碧.jpg 是碧儿', [('碧碧.jpg', '图片1')]) + self.assertEqual(out, '图片1 是碧儿') + + def test_multi_type_independent_counters(self): + """图片/视频/音频各自独立编号。""" + pairs = [ + ('img1.jpg', '图片1'), + ('video1.mp4', '视频1'), + ('audio1.mp3', '音频1'), + ] + out = _format_prompt_for_ark('用 @img1.jpg @video1.mp4 和 @audio1.mp3', pairs) + self.assertEqual(out, '用 图片1 视频1 和 音频1') + + def test_same_label_multiple_at_signs(self): + """同一 label 在 prompt 里 @ 多次,全部替换成同一 placeholder(str.replace 全局)。""" + out = _format_prompt_for_ark('@foo 然后 @foo 再 @foo', [('foo', '图片1')]) + self.assertEqual(out, '图片1 然后 图片1 再 图片1') + + def test_substring_conflict_long_first(self): + """当存在子串关系('碧' 是 '碧碧' 的子串),长 label 必须先替换。""" + # 模拟调用方已经按长度降序传入 + pairs = [('碧碧', '图片2'), ('碧', '图片1')] + out = _format_prompt_for_ark('@碧碧 和 @碧 是姐妹', pairs) + self.assertEqual(out, '图片2 和 图片1 是姐妹') + + def test_label_with_regex_metachars(self): + """label 含正则元字符([ ] + . * ? 等),str.replace 不当正则处理。""" + pairs = [ + ('[test].png', '图片1'), + ('a+b.png', '图片2'), + ('a.b*.png', '图片3'), + ] + prompt = '@[test].png 和 @a+b.png 还有 @a.b*.png' + out = _format_prompt_for_ark(prompt, pairs) + self.assertEqual(out, '图片1 和 图片2 还有 图片3') + + def test_empty_mapping(self): + """无 @ 素材的 prompt 原样返回。""" + out = _format_prompt_for_ark('今天天气真好', []) + self.assertEqual(out, '今天天气真好') + + def test_label_in_mapping_not_in_prompt(self): + """mapping 里有 label 但 prompt 里没 @ → 不动。""" + out = _format_prompt_for_ark('一段普通文字', [('foo.jpg', '图片1')]) + self.assertEqual(out, '一段普通文字') + + def test_chinese_punctuation_around_label(self): + """中文标点不影响替换。""" + out = _format_prompt_for_ark('@碧碧.jpg,说:"你好。"', [('碧碧.jpg', '图片1')]) + self.assertEqual(out, '图片1,说:"你好。"') + + def test_empty_label_skipped(self): + """label 为空字符串时跳过,不崩溃。""" + out = _format_prompt_for_ark('@real.jpg 内容', [('', '图片0'), ('real.jpg', '图片1')]) + self.assertEqual(out, '图片1 内容') + + +# ──────────────────────────────────────────────── +# 集成测试:video_generate_view +# ──────────────────────────────────────────────── + +@override_settings(SEEDANCE_ENABLED=True, ARK_API_KEY='fake-test-key') +class TestVideoGenerateArkPrompt(TestCase): + """经 POST /api/v1/video/generate 验证 prompt 转换 + DB 原文保留 + counter 对齐。""" + + def setUp(self): + QuotaConfig.objects.get_or_create(pk=1) + self.team = Team.objects.create( + name='test-ark-prompt', + is_active=True, + monthly_spending_limit=10000, + markup_percentage=0, + balance=10000, + frozen_amount=0, + ) + self.user = User.objects.create_user( + username='ark_prompt_user', + email='arkprompt@example.com', + password='testpass123', + team=self.team, + spending_limit=-1, + daily_generation_limit=-1, + monthly_generation_limit=-1, + ) + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + # 建两个 local asset 方便多场景复用 + self.group_a = AssetGroup.objects.create( + team=self.team, remote_group_id='group-fake-a', name='角色A', + ) + self.asset_bibi = Asset.objects.create( + group=self.group_a, remote_asset_id='asset-fake-bibi', name='碧碧.jpg', + url='https://fake/bibi.jpg', asset_type='Image', status='active', + ) + self.asset_bubu = Asset.objects.create( + group=self.group_a, remote_asset_id='asset-fake-bubu', name='布布.jpg', + url='https://fake/bubu.jpg', asset_type='Image', status='active', + ) + + def _post_generate(self, prompt, references): + return self.client.post('/api/v1/video/generate', { + 'prompt': prompt, + 'mode': 'universal', + 'model': 'seedance_2.0', + 'aspect_ratio': '9:16', + 'duration': 5, + 'resolution': '720p', + 'references': references, + }, format='json') + + @mock.patch('apps.generation.tasks.poll_video_task') + @mock.patch('apps.generation.views.create_task') + def test_view_converts_prompt_for_local_assets(self, mock_create_task, mock_poll): + """prompt 里两个 @local 素材 → 发给火山的 prompt 变成「图片1/图片2」。""" + mock_create_task.return_value = {'id': 'ark-mock-1'} + prompt = '@碧碧.jpg 是碧儿,@布布.jpg 是步若' + resp = self._post_generate(prompt, [ + {'url': f'asset://local-{self.asset_bibi.id}', 'type': 'image', 'label': '碧碧.jpg'}, + {'url': f'asset://local-{self.asset_bubu.id}', 'type': 'image', 'label': '布布.jpg'}, + ]) + self.assertEqual(resp.status_code, 202, resp.content) + self.assertTrue(mock_create_task.called, 'create_task must be called') + sent_prompt = mock_create_task.call_args.kwargs['prompt'] + self.assertEqual(sent_prompt, '图片1 是碧儿,图片2 是步若') + + @mock.patch('apps.generation.tasks.poll_video_task') + @mock.patch('apps.generation.views.create_task') + def test_view_db_prompt_unchanged_for_reedit(self, mock_create_task, mock_poll): + """DB.prompt 必须保留用户原文(含 @xxx.jpg),reEdit 才能重建带缩略图的标签。""" + mock_create_task.return_value = {'id': 'ark-mock-2'} + prompt = '@碧碧.jpg 走过来' + resp = self._post_generate(prompt, [ + {'url': f'asset://local-{self.asset_bibi.id}', 'type': 'image', 'label': '碧碧.jpg'}, + ]) + self.assertEqual(resp.status_code, 202, resp.content) + rec = GenerationRecord.objects.filter(user=self.user).order_by('-id').first() + self.assertIsNotNone(rec) + self.assertEqual(rec.prompt, prompt) # 原文,不含 '图片1' + self.assertIn('@碧碧.jpg', rec.prompt) + self.assertNotIn('图片1', rec.prompt) + + @mock.patch('apps.generation.tasks.poll_video_task') + @mock.patch('apps.generation.views.create_task') + def test_legacy_group_url_skips_replacement(self, mock_create_task, mock_poll): + """asset://group-{id} 老路径:counter 递增但不登记 label,WARNING 日志,@组名 原样留在 prompt。""" + mock_create_task.return_value = {'id': 'ark-mock-3'} + prompt = '@角色A 做动作' + with self.assertLogs('apps.generation.views', level='WARNING') as cm: + resp = self._post_generate(prompt, [ + {'url': f'asset://group-{self.group_a.id}', 'type': 'image', 'label': '角色A'}, + ]) + self.assertEqual(resp.status_code, 202, resp.content) + sent_prompt = mock_create_task.call_args.kwargs['prompt'] + self.assertEqual(sent_prompt, prompt) # 未替换 + # 验证 WARNING log + self.assertTrue(any('legacy asset://group-' in line for line in cm.output), + f'expected legacy warning, got: {cm.output}') + + @mock.patch('apps.generation.tasks.poll_video_task') + @mock.patch('apps.generation.views.create_task') + def test_counter_alignment_with_mixed_local_and_group(self, mock_create_task, mock_poll): + """关键回归:group 展开 2 张图后,紧跟的 local asset 的 label 必须映射到「图片3」不是「图片1」。""" + mock_create_task.return_value = {'id': 'ark-mock-4'} + prompt = '@foo 是主角' + resp = self._post_generate(prompt, [ + {'url': f'asset://group-{self.group_a.id}', 'type': 'image', 'label': '角色A'}, # 展开 2 张图 + {'url': f'asset://local-{self.asset_bibi.id}', 'type': 'image', 'label': 'foo'}, + ]) + self.assertEqual(resp.status_code, 202, resp.content) + sent_prompt = mock_create_task.call_args.kwargs['prompt'] + # foo 对应第 3 张 image(group 两张在前) + self.assertEqual(sent_prompt, '图片3 是主角') + # content_items 验证长度 + sent_content_items = mock_create_task.call_args.kwargs['content_items'] + image_items = [it for it in sent_content_items if it['type'] == 'image_url'] + self.assertEqual(len(image_items), 3) + + @mock.patch('apps.generation.tasks.poll_video_task') + @mock.patch('apps.generation.views.create_task') + def test_counter_alignment_mixed_types(self, mock_create_task, mock_poll): + """图片/音频独立计数 — 图片序号不因音频夹在中间而跳变。""" + mock_create_task.return_value = {'id': 'ark-mock-5'} + # 新建一个 audio asset + asset_audio = Asset.objects.create( + group=self.group_a, remote_asset_id='asset-fake-audio', name='speech.mp3', + url='https://fake/speech.mp3', asset_type='Audio', status='active', + ) + prompt = '@碧碧.jpg 说 @speech.mp3 的话,@布布.jpg 听' + resp = self._post_generate(prompt, [ + {'url': f'asset://local-{self.asset_bibi.id}', 'type': 'image', 'label': '碧碧.jpg'}, + {'url': f'asset://local-{asset_audio.id}', 'type': 'audio', 'label': 'speech.mp3'}, + {'url': f'asset://local-{self.asset_bubu.id}', 'type': 'image', 'label': '布布.jpg'}, + ]) + self.assertEqual(resp.status_code, 202, resp.content) + sent_prompt = mock_create_task.call_args.kwargs['prompt'] + # 碧碧=图片1, speech=音频1, 布布=图片2(图片/音频独立计数) + self.assertEqual(sent_prompt, '图片1 说 音频1 的话,图片2 听') + + +if __name__ == '__main__': + unittest.main(verbosity=2)