Merge dev: v0.19.1~v0.19.4 (素材组删除API / prompt@转图片N / 1080P单价显示 / 历史加载不跳底)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m30s

- v0.19.1 (f2dc8d4): 素材组删除改用一次性 DeleteAssetGroup API + 幂等
  清本地(NotFound.group_id 视为已清,修复火山后台手删遗留本地孤儿)
- v0.19.2 (13440f2): prompt 里 @素材名按火山规范转为「图片N/视频N/音频N」,
  修复多角色生成时人物颠倒的概率性现象 (火山模型只能理解位置指代,
  不能读懂 @文件名 / asset id)
- v0.19.3 (ecdb9cb): admin 系统设置页 1080P 两个单价输入框显示空的 UI
  bug, _settings_dict GET 漏返回字段; 计费本来就正确, 纯 UI 层修复
- v0.19.4 (10994df): 生成页往上翻加载历史不再跳到底部, useEffect 从
  比 tasks.length 改为比末尾 task id 变化, 区分"头部 prepend 历史"
  vs "尾部 push 新任务"
- 17fc3e5: 所有任务展示 / 列表 / CSV 导出补「分辨率」字段 (v0.19.0
  遗留的 1080P 可见性完善)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-04-25 00:32:05 +08:00
commit 965d155daf
21 changed files with 2355 additions and 19 deletions

View File

@ -244,6 +244,34 @@ jimeng-clone/
| `/admin/assets` | AdminAssetsPage | Admin | Content assets (team→member→video hierarchy) | | `/admin/assets` | AdminAssetsPage | Admin | Content assets (team→member→video hierarchy) |
| `/team/assets` | TeamAssetsPage | TeamAdmin | Team content assets (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 ## Incremental Development Guide
### How to Add Features to This Project ### How to Add Features to This Project

View File

@ -69,6 +69,27 @@ def _sum_video_duration(references):
return total 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): def _get_token_price(config, model, has_video_ref, resolution):
"""根据模型、是否含视频、分辨率选择单价。 """根据模型、是否含视频、分辨率选择单价。
@ -328,6 +349,20 @@ def video_generate_view(request):
seen_urls = set() # 去重:同一个素材只引用一次 seen_urls = set() # 去重:同一个素材只引用一次
_asset_cache = {} # group_id → [(asset_url, asset_type), ...],避免同一素材组重复查询 _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 from .models import Asset as AssetModel
def _resolve_asset_group_all(gid, lbl): def _resolve_asset_group_all(gid, lbl):
@ -408,11 +443,16 @@ def video_generate_view(request):
aid = 'asset-' + aid[6:] aid = 'asset-' + aid[6:]
resolved_asset_url = f'asset://{aid}' resolved_asset_url = f'asset://{aid}'
if asset_obj.asset_type == 'Video': if asset_obj.asset_type == 'Video':
video_n += 1
content_items.append({'type': 'video_url', 'video_url': {'url': resolved_asset_url}, 'role': 'reference_video'}) content_items.append({'type': 'video_url', 'video_url': {'url': resolved_asset_url}, 'role': 'reference_video'})
elif asset_obj.asset_type == 'Audio': elif asset_obj.asset_type == 'Audio':
audio_n += 1
content_items.append({'type': 'audio_url', 'audio_url': {'url': resolved_asset_url}, 'role': 'reference_audio'}) content_items.append({'type': 'audio_url', 'audio_url': {'url': resolved_asset_url}, 'role': 'reference_audio'})
else: else:
image_n += 1
content_items.append({'type': 'image_url', 'image_url': {'url': resolved_asset_url}, 'role': 'reference_image'}) 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: except AssetModel.DoesNotExist:
return Response({ return Response({
'error': 'asset_not_found', 'error': 'asset_not_found',
@ -442,11 +482,17 @@ def video_generate_view(request):
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
for asset_url, asset_type in asset_list: for asset_url, asset_type in asset_list:
if asset_type == 'Video': if asset_type == 'Video':
video_n += 1
content_items.append({'type': 'video_url', 'video_url': {'url': asset_url}, 'role': 'reference_video'}) content_items.append({'type': 'video_url', 'video_url': {'url': asset_url}, 'role': 'reference_video'})
elif asset_type == 'Audio': elif asset_type == 'Audio':
audio_n += 1
content_items.append({'type': 'audio_url', 'audio_url': {'url': asset_url}, 'role': 'reference_audio'}) content_items.append({'type': 'audio_url', 'audio_url': {'url': asset_url}, 'role': 'reference_audio'})
else: else:
image_n += 1
content_items.append({'type': 'image_url', 'image_url': {'url': asset_url}, 'role': 'reference_image'}) 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: except Exception as e:
logger.warning('Failed to resolve asset group URL %s: %s', url, e) logger.warning('Failed to resolve asset group URL %s: %s', url, e)
return Response({ return Response({
@ -456,6 +502,7 @@ def video_generate_view(request):
continue # 素材组已展开为多个 content_items跳过下面的单项处理 continue # 素材组已展开为多个 content_items跳过下面的单项处理
if ref_type == 'image': if ref_type == 'image':
image_n += 1
item = {'type': 'image_url', 'image_url': {'url': url}} item = {'type': 'image_url', 'image_url': {'url': url}}
# API 文档要求:参考图模式下所有图片的 role 必须为 reference_image # API 文档要求:参考图模式下所有图片的 role 必须为 reference_image
if mode == 'universal': if mode == 'universal':
@ -464,15 +511,25 @@ def video_generate_view(request):
item['role'] = role item['role'] = role
content_items.append(item) content_items.append(item)
elif ref_type == 'video': elif ref_type == 'video':
video_n += 1
item = {'type': 'video_url', 'video_url': {'url': url}} item = {'type': 'video_url', 'video_url': {'url': url}}
if role: if role:
item['role'] = role item['role'] = role
content_items.append(item) content_items.append(item)
elif ref_type == 'audio': elif ref_type == 'audio':
audio_n += 1
item = {'type': 'audio_url', 'audio_url': {'url': url}} item = {'type': 'audio_url', 'audio_url': {'url': url}}
if role: if role:
item['role'] = role item['role'] = role
content_items.append(item) 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]) 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事务外避免持锁 ── # ── 调用 AirDrama API事务外避免持锁 ──
from django.conf import settings as django_settings from django.conf import settings as django_settings
if django_settings.SEEDANCE_ENABLED and django_settings.ARK_API_KEY: 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: try:
ark_response = create_task( ark_response = create_task(
prompt=prompt, prompt=api_prompt,
model=model, model=model,
content_items=content_items, content_items=content_items,
aspect_ratio=aspect_ratio, aspect_ratio=aspect_ratio,
@ -1845,6 +1907,8 @@ def _settings_dict(config):
'base_token_price_video': float(config.base_token_price_video), 'base_token_price_video': float(config.base_token_price_video),
'base_token_price_fast': float(config.base_token_price_fast), 'base_token_price_fast': float(config.base_token_price_fast),
'base_token_price_fast_video': float(config.base_token_price_fast_video), 'base_token_price_fast_video': float(config.base_token_price_fast_video),
'base_token_price_1080p': float(config.base_token_price_1080p),
'base_token_price_1080p_video': float(config.base_token_price_1080p_video),
'announcement': config.announcement, 'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled, 'announcement_enabled': config.announcement_enabled,
'max_desktop_sessions': config.max_desktop_sessions, 'max_desktop_sessions': config.max_desktop_sessions,
@ -3204,15 +3268,20 @@ def asset_group_detail_view(request, group_id):
return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND)
if request.method == 'DELETE': if request.method == 'DELETE':
# Delete all remote assets in this group
from utils import assets_client from utils import assets_client
for asset in Asset.objects.filter(group=group): from utils.assets_client import AssetsAPIError
if asset.remote_asset_id: if group.remote_group_id:
try: try:
assets_client.delete_asset(asset.remote_asset_id) assets_client.delete_asset_group(group.remote_group_id)
except Exception as e: except AssetsAPIError as e:
logger.warning('Failed to delete remote asset %s: %s', asset.remote_asset_id, e) # 火山那边已经没了(比如被后台手动删了)就继续清本地,保证幂等
# Delete local records if e.code != 'NotFound.group_id':
logger.warning('Failed to delete remote group %s: %s', group.remote_group_id, e)
return Response(
{'error': 'assets_api_error', 'message': e.user_message},
status=status.HTTP_502_BAD_GATEWAY,
)
logger.info('Remote group %s already gone, cleaning local only', group.remote_group_id)
Asset.objects.filter(group=group).delete() Asset.objects.filter(group=group).delete()
group.delete() group.delete()
return Response({'message': '素材组已删除'}) return Response({'message': '素材组已删除'})

View File

@ -127,5 +127,38 @@ class TestVideoGenerateResolution(TestCase):
self.assertNotEqual(body.get('error'), 'invalid_resolution') self.assertNotEqual(body.get('error'), 'invalid_resolution')
class TestAdminSettingsResponse(TestCase):
"""GET /api/v1/admin/settings 必须返回所有 token_price 字段,
以防 v0.19.0 那种"字段在 serializer 里加了、但 _settings_dict 漏了"的回归"""
def setUp(self):
QuotaConfig.objects.get_or_create(pk=1)
self.admin = User.objects.create_user(
username='test_admin_settings',
email='test_admin_settings@example.com',
password='testpass123',
is_staff=True,
is_superuser=True,
)
self.client = APIClient()
self.client.force_authenticate(user=self.admin)
def test_get_returns_all_token_price_fields(self):
"""GET 返回 4 档单价(全部分辨率 + 是否含视频),缺一不可 — 缺字段会导致前端输入框显示空。"""
resp = self.client.get('/api/v1/admin/settings')
self.assertEqual(resp.status_code, 200)
body = resp.json()
for field in (
'base_token_price',
'base_token_price_video',
'base_token_price_fast',
'base_token_price_fast_video',
'base_token_price_1080p',
'base_token_price_1080p_video',
):
self.assertIn(field, body, f'GET /admin/settings response missing {field!r} — 前端这个输入框会显示空')
self.assertIsInstance(body[field], (int, float), f'{field} 应该是数字类型')
if __name__ == '__main__': if __name__ == '__main__':
unittest.main(verbosity=2) unittest.main(verbosity=2)

View File

@ -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 里 @ 多次,全部替换成同一 placeholderstr.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.jpgreEdit 才能重建带缩略图的标签。"""
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 递增但不登记 labelWARNING 日志,@组名 原样留在 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 张 imagegroup 两张在前)
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)

View File

@ -83,6 +83,7 @@ def _get_service():
'UpdateAssetGroup': ApiInfo('POST', '/', {'Action': 'UpdateAssetGroup', 'Version': API_VERSION}, {}, {}), 'UpdateAssetGroup': ApiInfo('POST', '/', {'Action': 'UpdateAssetGroup', 'Version': API_VERSION}, {}, {}),
'UpdateAsset': ApiInfo('POST', '/', {'Action': 'UpdateAsset', 'Version': API_VERSION}, {}, {}), 'UpdateAsset': ApiInfo('POST', '/', {'Action': 'UpdateAsset', 'Version': API_VERSION}, {}, {}),
'DeleteAsset': ApiInfo('POST', '/', {'Action': 'DeleteAsset', 'Version': API_VERSION}, {}, {}), 'DeleteAsset': ApiInfo('POST', '/', {'Action': 'DeleteAsset', 'Version': API_VERSION}, {}, {}),
'DeleteAssetGroup': ApiInfo('POST', '/', {'Action': 'DeleteAssetGroup', 'Version': API_VERSION}, {}, {}),
} }
return Service(service_info, api_info) return Service(service_info, api_info)
@ -225,3 +226,9 @@ def delete_asset(asset_id: str):
"""Delete a single asset from the remote API.""" """Delete a single asset from the remote API."""
body = {'Id': asset_id, 'ProjectName': PROJECT_NAME} body = {'Id': asset_id, 'ProjectName': PROJECT_NAME}
_do_request('DeleteAsset', body) _do_request('DeleteAsset', body)
def delete_asset_group(group_id: str):
"""Delete an asset group and cascade-delete all its assets on the remote API."""
body = {'Id': group_id, 'ProjectName': PROJECT_NAME}
_do_request('DeleteAssetGroup', body)

View File

@ -0,0 +1,81 @@
`POST https://ark.cn-beijing.volcengineapi.com/?Action=DeleteAssetGroup&Version=2024-01-01`
删除素材资产组Asset Group
:::warning
* 删除素材组将批量删除组内所有素材资产,该操作不可逆,一经删除,不可恢复,请谨慎操作。
* 如待删除的素材组包含较多素材资产,删除操作可能耗费一定时间。
* 对于在方舟控制台创建的真人素材组,**仅可删除授权已过期或已拒绝接收的素材组;** 授权有效期内、有效期未开始或已接收的素材无法删除。
:::
```mixin-react
return (<Tabs>
<Tabs.TabPane title="快速入口" key="lvNm2K2e"><RenderMd content={`<span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_57d0bca8e0d122ab1191b40101b5df75.png =20x) </span> [调用教程](https://www.volcengine.com/docs/82379/2333565) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_f45b5cd5863d1eed3bc3c81b9af54407.png =20x) </span> [接口列表](https://www.volcengine.com/docs/82379/2333601) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_bef4bc3de3535ee19d0c5d6c37b0ffdd.png =20x) </span> [开通模型](https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false)
`}></RenderMd></Tabs.TabPane>
<Tabs.TabPane title="鉴权说明" key="jTfWOr00"><RenderMd content={`本接口仅支持 Access KeyAK/SK鉴权。
`}></RenderMd></Tabs.TabPane></Tabs>);
```
---
<span id="jsH8fA91"></span>
## 请求参数
<span id="LZAiABbI"></span>
### 请求体
---
**Id** `string` %%require%%
需删除的素材资产组 ID。
---
**ProjectName** `string`
需删除素材资产组所属的项目名称,默认值为 default。
若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。
<span id="pzKKGMI2"></span>
## 响应参数
:::tip
本接口无业务返回参数。
:::
---
<span id="W7hm7e18"></span>
## 请求示例
```text
POST /?Action=DeleteAssetGroup&Version=2024-01-01 HTTP/1.1
Host: ark.cn-beijing.volcengineapi.com
Content-Type: application/json
X-Date: 20260328T000000Z
X-Content-Sha256: 287e874e******d653b44d21e
Authorization: HMAC-SHA256 Credential=AKLTYz******/20260328/cn-beijing/ark/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature=47a7d934******e41085f
{
"Id": "group-2026**********-*****",
"ProjectName": "default"
}
```
<span id="wUMBBtP9"></span>
## 响应示例
```json
{
"ResponseMetadata": {
"RequestId": "20260328000000000000000000000000",
"Action": "DeleteAssetGroup",
"Version": "2024-01-01",
"Service": "ark",
"Region": "cn-beijing"
},
"Result": {}
}
```

View File

@ -0,0 +1,74 @@
`POST https://ark.cn-beijing.volcengineapi.com/?Action=DeleteAsset&Version=2024-01-01`
本文介绍删除素材资产AssetAPI 的输入输出参数,供您使用接口时查阅字段含义。
```mixin-react
return (<Tabs>
<Tabs.TabPane title="快速入口" key="XUjyLha2Xp"><RenderMd content={`<span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_57d0bca8e0d122ab1191b40101b5df75.png =20x) </span> [调用教程](https://www.volcengine.com/docs/82379/2333565) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_f45b5cd5863d1eed3bc3c81b9af54407.png =20x) </span> [接口列表](https://www.volcengine.com/docs/82379/2333601) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_bef4bc3de3535ee19d0c5d6c37b0ffdd.png =20x) </span> [开通模型](https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false)
`}></RenderMd></Tabs.TabPane>
<Tabs.TabPane title="鉴权说明" key="ja8gRJxaz4"><RenderMd content={`本接口仅支持 Access KeyAK/SK鉴权。
`}></RenderMd></Tabs.TabPane></Tabs>);
```
---
<span id="request-params"></span>
## 请求参数
<span id="request-body"></span>
### 请求体
---
**Id** `string` %%require%%
需要删除的 Asset素材资产的 Id。
---
**ProjectName** `string`
需要删除的 Asset素材资产所属的项目名称默认值为default。
若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。
<span id="response-params"></span>
## 响应参数
:::tip
本接口无业务返回参数。
:::
---
<span id=".6K-35rGC56S65L6L"></span>
## 请求示例
```text
POST /?Action=DeleteAsset&Version=2024-01-01 HTTP/1.1
Host: ark.cn-beijing.volcengineapi.com
Content-Type: application/json
X-Date: 20260328T000000Z
X-Content-Sha256: 287e874e******d653b44d21e
Authorization: HMAC-SHA256 Credential=AKLTYz******/20260328/cn-beijing/ark/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature=47a7d934******e41085f
{
"Id": "Asset-2026**********-*****",
"ProjectName": "default"
}
```
<span id=".5ZON5bqU56S65L6L"></span>
## 响应示例
```json
{
"ResponseMetadata": {
"RequestId": "20260328000000000000000000000000",
"Action": "DeleteAsset",
"Version": "2024-01-01",
"Service": "ark",
"Region": "cn-beijing"
},
"Result": {}
}
```

View File

@ -0,0 +1,86 @@
`POST https://ark.cn-beijing.volcengineapi.com/?Action=UpdateAsset&Version=2024-01-01`
本文介绍更新素材资产信息AssetAPI 的输入输出参数,供您使用接口时查阅字段含义。当前仅支持更新 `Name`。
```mixin-react
return (<Tabs>
<Tabs.TabPane title="快速入口" key="GKGUlkIXAR"><RenderMd content={`<span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_57d0bca8e0d122ab1191b40101b5df75.png =20x) </span> [调用教程](https://www.volcengine.com/docs/82379/2333565) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_f45b5cd5863d1eed3bc3c81b9af54407.png =20x) </span> [接口列表](https://www.volcengine.com/docs/82379/2318269) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_bef4bc3de3535ee19d0c5d6c37b0ffdd.png =20x) </span> [开通模型](https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false)
`}></RenderMd></Tabs.TabPane>
<Tabs.TabPane title="鉴权说明" key="Hufey2Y56Z"><RenderMd content={`本接口仅支持 Access KeyAK/SK鉴权。
`}></RenderMd></Tabs.TabPane></Tabs>);
```
---
<span id="request-params"></span>
## 请求参数
<span id="request-body"></span>
### 请求体
---
**Id** `string` %%require%%
需要更新的 Asset素材资产的 Id。
---
**Name** `string`
需要更新的 Asset素材资产的新名称上限为 64 个字符。
---
**ProjectName** `string`
需要更新的 Asset素材资产所属的项目名称默认值为default。
若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。
<span id="response-params"></span>
## 响应参数
---
**Id** `string`
Asset素材资产的 Id。
---
<span id=".6K-35rGC56S65L6L"></span>
## 请求示例
```text
POST /?Action=UpdateAsset&Version=2024-01-01 HTTP/1.1
Host: ark.cn-beijing.volcengineapi.com
Content-Type: application/json
X-Date: 20260328T000000Z
X-Content-Sha256: 287e874e******d653b44d21e
Authorization: HMAC-SHA256 Credential=AKLTYz******/20260328/cn-beijing/ark/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature=47a7d934******e41085f
{
"Id": "Asset-2026**********-*****",
"Name": "new-name",
"ProjectName": "default"
}
```
<span id=".5ZON5bqU56S65L6L"></span>
## 响应示例
```json
{
"ResponseMetadata": {
"RequestId": "20260328000000000000000000000000",
"Action": "UpdateAsset",
"Version": "2024-01-01",
"Service": "ark",
"Region": "cn-beijing"
},
"Result": {
"Id": "Asset-2026**********-*****"
}
}
```

View File

@ -0,0 +1,93 @@
`POST https://ark.cn-beijing.volcengineapi.com/?Action=UpdateAssetGroup&Version=2024-01-01`
更新单个 Asset Group素材资产组合信息。当前仅支持更新 Asset Group素材资产组合的 Name 和 Description。
```mixin-react
return (<Tabs>
<Tabs.TabPane title="快速入口" key="dZF0anlOBU"><RenderMd content={`<span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_57d0bca8e0d122ab1191b40101b5df75.png =20x) </span> [调用教程](https://www.volcengine.com/docs/82379/2333565) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_f45b5cd5863d1eed3bc3c81b9af54407.png =20x) </span> [接口列表](https://www.volcengine.com/docs/82379/2318269) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_bef4bc3de3535ee19d0c5d6c37b0ffdd.png =20x) </span> [开通模型](https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false)
`}></RenderMd></Tabs.TabPane>
<Tabs.TabPane title="鉴权说明" key="peae1e0Xvc"><RenderMd content={`本接口仅支持 Access KeyAK/SK鉴权。
`}></RenderMd></Tabs.TabPane></Tabs>);
```
---
<span id="request-params"></span>
## 请求参数
<span id="request-body"></span>
### 请求体
---
**Id** `string` %%require%%
需要更新的 Asset Group素材资产组合的 Id。
---
**Name** `string`
需要更新的 Asset Group素材资产组合的新名称上限为 64 个字符。
---
**Description** `string`
需要更新的 Asset Group素材资产组合的新描述上限为 300 字符。
---
**ProjectName** `string`
需要更新的 Asset Group素材资产组合所属的项目名称默认值为default。
若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。
<span id="response-params"></span>
## 响应参数
---
**Id** `string`
Asset Group素材资产组合的 Id。
---
<span id=".6K-35rGC56S65L6L"></span>
## 请求示例
```text
POST /?Action=UpdateAssetGroup&Version=2024-01-01 HTTP/1.1
Host: ark.cn-beijing.volcengineapi.com
Content-Type: application/json
X-Date: 20260328T000000Z
X-Content-Sha256: 287e874e******d653b44d21e
Authorization: HMAC-SHA256 Credential=AKLTYz******/20260328/cn-beijing/ark/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature=47a7d934******e41085f
{
"Id": "group-2026**********-*****",
"Name": "new-name",
"Description": "new-description",
"ProjectName": "default"
}
```
<span id=".5ZON5bqU56S65L6L"></span>
## 响应示例
```json
{
"ResponseMetadata": {
"RequestId": "20260328000000000000000000000000",
"Action": "UpdateAssetGroup",
"Version": "2024-01-01",
"Service": "ark",
"Region": "cn-beijing"
},
"Result": {
"Id": "group-2026**********-*****"
}
}
```

View File

@ -0,0 +1,168 @@
`POST https://ark.cn-beijing.volcengineapi.com/?Action=GetAsset&Version=2024-01-01`
查询素材资产状态,确认素材是否已完成预处理并可用于推理。
```mixin-react
return (<Tabs>
<Tabs.TabPane title="快速入口" key="k6xoCAAzLe"><RenderMd content={`<span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_57d0bca8e0d122ab1191b40101b5df75.png =20x) </span> [调用教程](https://www.volcengine.com/docs/82379/2333565) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_f45b5cd5863d1eed3bc3c81b9af54407.png =20x) </span> [接口列表](https://www.volcengine.com/docs/82379/2318269) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_bef4bc3de3535ee19d0c5d6c37b0ffdd.png =20x) </span> [开通模型](https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false)
`}></RenderMd></Tabs.TabPane>
<Tabs.TabPane title="鉴权说明" key="NcZDHXiFXA"><RenderMd content={`本接口仅支持 Access KeyAK/SK鉴权。
`}></RenderMd></Tabs.TabPane></Tabs>);
```
---
<span id="request-params"></span>
## 请求参数
<span id="request-body"></span>
### 请求体
---
**Id** `string` %%require%%
Asset素材资产的 Id。
---
**ProjectName** `string`
需要查询的 Asset素材资产所属的项目名称默认值为 `default`。
若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。
<span id="response-params"></span>
## 响应参数
---
**Id** `string`
Asset素材资产的 Id。
---
**Name** `string`
Asset素材资产的名称上限为 64 个字符。
---
**URL** `string`
Asset素材资产的访问地址。有效期为 12 小时,请及时保存。
---
**AssetType** `string`
Asset素材资产的类型。可选值
* `Image`
* `Video`
* `Audio`
---
**GroupId** `string`
Asset素材资产所属的 Asset Group素材资产组合的 Id。
---
**Status** `string`
素材资产状态。可选值:
* `Active`:已处理完毕,可以使用
* `Processing`:正在预处理,无法使用
* `Failed`:处理失败
---
**Error** `object`
错误信息。
属性
---
Error.**Code** `string`
错误码。
---
Error.**Message** `string`
错误信息。
---
**CreateTime** `string`
创建时间。
---
**UpdateTime** `string`
更新时间。
---
**ProjectName** `string`
资源所属的项目名称。
---
<span id=".6K-35rGC56S65L6L"></span>
## 请求示例
```text
POST /?Action=GetAsset&Version=2024-01-01 HTTP/1.1
Host: ark.cn-beijing.volcengineapi.com
Content-Type: application/json
X-Date: 20260328T000000Z
X-Content-Sha256: 287e874e******d653b44d21e
Authorization: HMAC-SHA256 Credential=AKLTYz******/20260328/cn-beijing/ark/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature=47a7d934******e41085f
{
"Id": "Asset-2026**********-*****",
"ProjectName": "default"
}
```
<span id=".5ZON5bqU56S65L6L"></span>
## 响应示例
```json
{
"ResponseMetadata": {
"RequestId": "20260328000000000000000000000000",
"Action": "GetAsset",
"Version": "2024-01-01",
"Service": "ark",
"Region": "cn-beijing"
},
"Result": {
"Id": "Asset-2026**********-*****",
"Name": "test",
"URL": "https://example.com/asset-url",
"AssetType": "Image",
"GroupId": "group-2026**********-*****",
"Status": "Active",
"Error": {
"Code": "",
"Message": ""
},
"CreateTime": "2026-03-28T00:00:00Z",
"UpdateTime": "2026-03-28T00:00:00Z",
"ProjectName": "default"
}
}
```

View File

@ -0,0 +1,280 @@
`POST https://ark.cn-beijing.volcengineapi.com/?Action=ListAssets&Version=2024-01-01`
查询符合筛选条件的 Assets素材资产列表。
```mixin-react
return (<Tabs>
<Tabs.TabPane title="快速入口" key="X3ZqVHf6Rr"><RenderMd content={`<span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_57d0bca8e0d122ab1191b40101b5df75.png =20x) </span> [调用教程](https://www.volcengine.com/docs/82379/2333565) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_f45b5cd5863d1eed3bc3c81b9af54407.png =20x) </span> [接口列表](https://www.volcengine.com/docs/82379/2318269) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_bef4bc3de3535ee19d0c5d6c37b0ffdd.png =20x) </span> [开通模型](https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false)
`}></RenderMd></Tabs.TabPane>
<Tabs.TabPane title="鉴权说明" key="hEYP88LFdX"><RenderMd content={`本接口仅支持 Access KeyAK/SK鉴权。
`}></RenderMd></Tabs.TabPane></Tabs>);
```
---
<span id="request-params"></span>
## 请求参数
<span id="request-body"></span>
### 请求体
---
**Filter** `object` %%require%%
搜索的过滤条件。
属性
---
Filter.**GroupIds** `string[]`
Asset素材资产所属的 Asset Group素材资产组合的 Id 列表。
---
Filter.**GroupType** `string` %%require%%
Asset Group素材资产组合的类型。可选值
* `AIGC`:虚拟人像
* `LivenessFace`:真人素材
---
Filter.**Statuses** `string[]`
素材资产状态。可选值:
* `Active`:素材资产已处理完毕,可以使用
* `Processing`:素材资产正在预处理,无法使用
* `Failed`:素材资产处理失败
---
Filter.**Name** `string`
Asset素材资产的名称上限为 64 个字符。
---
**PageNumber** `integer (i64)` %%require%%
搜索页码,从 1 开始。例如:`1` 表示返回第一页的搜索结果。
---
**PageSize** `integer (i64)` %%require%%
每页搜索结果的数量,上限为 100。
---
**SortBy** `string`
用于排序的字段名称,默认值为 `CreateTime`。可选值:
* `CreateTime`:根据创建时间排序
* `UpdateTime`:根据更新时间排序
* `GroupId`:根据资产素材组的 Id 排序
---
**SortOrder** `string`
排序顺序,默认值为 `Desc`。可选值:
* `Desc`:降序
* `Asc`:升序
---
**ProjectName** `string`
资源所属的项目名称默认值为default。
若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。
<span id="response-params"></span>
## 响应参数
---
**Items** `object[]`
符合筛选条件的 Asset素材资产数组。
属性
---
Items.**Id** `string`
Asset素材资产的 Id。
---
Items.**Name** `string`
Asset素材资产的名称上限为 64 个字符。
---
Items.**URL** `string`
Asset素材资产的公共可访问地址。有效期为 12 小时,请及时保存。
---
Items.**GroupId** `string`
Asset素材资产所属的 Asset Group素材资产组合的 Id。
---
Items.**AssetType** `string`
Asset素材资产的类型。可选值
* `Image`:图像
* `Video`:视频
* `Audio`:音频
---
Items.**Status** `string`
任务状态。可选值:
* `Active`
* `Processing`
* `Failed`
---
Items.**Error** `object`
错误信息。
属性
---
Items.Error.**Code** `string`
错误码。
---
Items.Error.**Message** `string`
错误信息。
---
Items.**ProjectName** `string`
资源所属的项目名称。
---
Items.**CreateTime** `string`
创建时间。
---
Items.**UpdateTime** `string`
更新时间。
---
**TotalCount** `integer (i64)`
返回总数。
---
**PageNumber** `integer (i64)`
返回的页数。
---
**PageSize** `integer (i64)`
每页搜索结果的数量,上限为 100。
---
<span id=".6K-35rGC56S65L6L"></span>
## 请求示例
```text
POST /?Action=ListAssets&Version=2024-01-01 HTTP/1.1
Host: ark.cn-beijing.volcengineapi.com
Content-Type: application/json
X-Date: 20260328T000000Z
X-Content-Sha256: 287e874e******d653b44d21e
Authorization: HMAC-SHA256 Credential=AKLTYz******/20260328/cn-beijing/ark/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature=47a7d934******e41085f
{
"Filter": {
"GroupIds": [
"group-2026**********-*****"
],
"GroupType": "AIGC",
"Statuses": [
"Active"
],
"Name": "test"
},
"PageNumber": 1,
"PageSize": 10,
"SortBy": "CreateTime",
"SortOrder": "Desc",
"ProjectName": "default"
}
```
<span id=".5ZON5bqU56S65L6L"></span>
## 响应示例
```json
{
"ResponseMetadata": {
"RequestId": "20260328000000000000000000000000",
"Action": "ListAssets",
"Version": "2024-01-01",
"Service": "ark",
"Region": "cn-beijing"
},
"Result": {
"Items": [
{
"Id": "Asset-2026**********-*****",
"Name": "test",
"URL": "https://example.com/asset-url",
"GroupId": "group-2026**********-*****",
"AssetType": "Image",
"Status": "Active",
"Error": {
"Code": "",
"Message": ""
},
"ProjectName": "default",
"CreateTime": "2026-03-28T00:00:00Z",
"UpdateTime": "2026-03-28T00:00:00Z"
}
],
"TotalCount": 1,
"PageNumber": 1,
"PageSize": 10
}
}
```

View File

@ -0,0 +1,125 @@
`POST https://ark.cn-beijing.volcengineapi.com/?Action=GetAssetGroup&Version=2024-01-01`
获取单个Asset Group素材资产组合信息。
```mixin-react
return (<Tabs>
<Tabs.TabPane title="快速入口" key="HRBKuuT0TQ"><RenderMd content={`<span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_57d0bca8e0d122ab1191b40101b5df75.png =20x) </span> [调用教程](https://www.volcengine.com/docs/82379/2333565) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_f45b5cd5863d1eed3bc3c81b9af54407.png =20x) </span> [接口列表](https://www.volcengine.com/docs/82379/2333601) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_bef4bc3de3535ee19d0c5d6c37b0ffdd.png =20x) </span> [开通模型](https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false)
`}></RenderMd></Tabs.TabPane>
<Tabs.TabPane title="鉴权说明" key="MtPj6OfJKp"><RenderMd content={`本接口仅支持 Access KeyAK/SK鉴权。
`}></RenderMd></Tabs.TabPane></Tabs>);
```
---
<span id="request-params"></span>
## 请求参数
<span id="request-body"></span>
### 请求体
---
**Id** `string` %%require%%
Asset Group素材资产组合的 Id。
---
**ProjectName** `string`
需要查询的 Asset Group素材资产组合所属的项目名称默认值为default。
若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。
<span id="response-params"></span>
## 响应参数
---
**Id** `string`
Asset Group素材资产组合的 Id。
---
**Name** `string`
Asset Group素材资产组合的名称上限为 64 个字符。
---
**Description** `string`
Asset Group素材资产组合的描述上限为 300 字符。
---
**GroupType** `string`
Asset Group素材资产组合的类型。可选值
* `AIGC`:虚拟人像
* `LivenessFace`:真人素材
---
**ProjectName** `string`
资源所属的项目名称。
---
**CreateTime** `string`
创建时间。
---
**UpdateTime** `string`
更新时间。
---
<span id=".6K-35rGC56S65L6L"></span>
## 请求示例
```text
POST /?Action=GetAssetGroup&Version=2024-01-01 HTTP/1.1
Host: ark.cn-beijing.volcengineapi.com
Content-Type: application/json
X-Date: 20260328T000000Z
X-Content-Sha256: 287e874e******d653b44d21e
Authorization: HMAC-SHA256 Credential=AKLTYz******/20260328/cn-beijing/ark/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature=47a7d934******e41085f
{
"Id": "group-2026**********-*****",
"ProjectName": "default"
}
```
<span id=".5ZON5bqU56S65L6L"></span>
## 响应示例
```json
{
"ResponseMetadata": {
"RequestId": "20260328000000000000000000000000",
"Action": "GetAssetGroup",
"Version": "2024-01-01",
"Service": "ark",
"Region": "cn-beijing"
},
"Result": {
"Id": "group-2026**********-*****",
"Name": "test",
"Description": "test",
"GroupType": "AIGC",
"ProjectName": "default",
"CreateTime": "2026-03-28T00:00:00Z",
"UpdateTime": "2026-03-28T00:00:00Z"
}
}
```

View File

@ -0,0 +1,220 @@
`POST https://ark.cn-beijing.volcengineapi.com/?Action=ListAssetGroups&Version=2024-01-01`
查询符合筛选条件的Asset Groups素材资产组合列表。
```mixin-react
return (<Tabs>
<Tabs.TabPane title="快速入口" key="NdTz6AmVwP"><RenderMd content={`<span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_57d0bca8e0d122ab1191b40101b5df75.png =20x) </span> [调用教程](https://www.volcengine.com/docs/82379/2333565) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_f45b5cd5863d1eed3bc3c81b9af54407.png =20x) </span> [接口列表](https://www.volcengine.com/docs/82379/2318269) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_bef4bc3de3535ee19d0c5d6c37b0ffdd.png =20x) </span> [开通模型](https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false)
`}></RenderMd></Tabs.TabPane>
<Tabs.TabPane title="鉴权说明" key="jAo0Qz18Pr"><RenderMd content={`本接口仅支持 Access KeyAK/SK鉴权。
`}></RenderMd></Tabs.TabPane></Tabs>);
```
---
<span id="request-params"></span>
## 请求参数
<span id="request-body"></span>
### 请求体
---
**Filter** `object` %%require%%
搜索的过滤条件。
属性
Filter.**GroupIds** `array`
Asset Group素材资产组合的 Id 列表。
---
Filter.**GroupType** `string` %%require%%
Asset Group素材资产组合的类型。可选值
* `AIGC`:虚拟人像
* `LivenessFace`: 真人素材
---
Filter.**Name** `string`
Asset Group素材资产组合的名称上限为 64 个字符。
---
**PageNumber** `integer (i64)` %%require%%
搜索页码,可用于列表分页功能,从 1 开始。例如:"page_number": 1即返回第一页的搜索结果。
---
**PageSize** `integer (i64)` %%require%%
每页搜索结果的数量,上限为 100。
---
**SortBy** `string`
用于排序的字段名称,默认值为 `CreateTime`。可选值:
* `CreateTime`:根据创建时间排序
* `UpdateTime`:根据更新时间排序
---
**SortOrder** `string`
排序顺序,默认值为 `Desc`。可选值:
* `Desc`:降序
* `Asc`:升序
---
**ProjectName** `string`
资源所属的项目名称,默认值为 default。
若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。
<span id="response-params"></span>
## 响应参数
---
**TotalCount ** `int (i64)`
返回的 Asset Group素材资产组合的总数。
---
**Items** `array[] `
符合筛选条件的 Asset Group素材资产组合数组。
---
Items.**Id** `string`
Asset Group素材资产组合的 Id。
---
Items.**Name** `string`
Asset Group素材资产组合的名称上限为64个字符。
---
Items.**Description** `string`
Asset Group素材资产组合的描述上限为 300 字符。
---
Items.**GroupType** `string`
Asset Group素材资产组合的类型。
* `AIGC`:虚拟人像。
* `LivenessFace`: 真人素材
---
Items.**ProjectName** `string`
资源所属的项目名称。
---
Items.**CreateTime** `string`
创建时间。
---
Items.**UpdateTime** `string`
更新时间。
---
<span id="jb2Vro9B"></span>
### **PageNumber ** int (i64)
返回的页数。
---
<span id="r0oQyygI"></span>
### **PageSize ** int (i64)
每页搜索结果的数量上限为100。
---
<span id=".6K-35rGC56S65L6L"></span>
## 请求示例
```text
POST /?Action=ListAssetGroups&Version=2024-01-01 HTTP/1.1
Host: ark.cn-beijing.volcengineapi.com
Content-Type: application/json
X-Date: 20260328T000000Z
X-Content-Sha256: 287e874e******d653b44d21e
Authorization: HMAC-SHA256 Credential=AKLTYz******/20260328/cn-beijing/ark/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature=47a7d934******e41085f
{
"Filter": {
"Name": "test",
"GroupType": "AIGC"
},
"PageNumber": 1,
"PageSize": 10,
"SortBy": "CreateTime",
"SortOrder": "Desc",
"ProjectName": "default"
}
```
<span id=".5ZON5bqU56S65L6L"></span>
## 响应示例
```json
{
"ResponseMetadata": {
"RequestId": "20260328000000000000000000000000",
"Action": "ListAssetGroups",
"Version": "2024-01-01",
"Service": "ark",
"Region": "cn-beijing"
},
"Result": {
"TotalCount": 1,
"Items": [
{
"Id": "group-2026**********-*****",
"Name": "test",
"Title": "test",
"Description": "test",
"GroupType": "AIGC",
"ProjectName": "default",
"CreateTime": "2026-03-28T00:00:00Z",
"UpdateTime": "2026-03-28T00:00:00Z"
}
],
"PageNumber": 1,
"PageSize": 10
}
}
```

View File

@ -0,0 +1,820 @@
:::danger
* 仅限邀测用户阅读,请勿截图/分享给其他人员。
* 上传素材 CreateAsset API 为异步接口,系统处理可能出现排队,导致入库时间增加。不承诺上传时间 SLA。
* 素材资产应为虚拟人像,非虚拟人像类素材无需入库。
* 您需确保上传的虚拟人像符合以下条件:
* 您合法拥有该素材,并享有完整的使用及处分权限。素材不包含未获授权的第三方商标、标识类内容。
* 素材不得与任何自然人肖像或形象雷同,素材不存在抄袭、盗用情形,不会侵害任何第三方的人格权、知识产权等合法权益。
* 素材不包含违反法规、违背公序良俗、危害国家安全的内容。
:::
Seedance 2.0 系列模型具有完备的防范 Deepfake 和侵犯版权风险能力。在生成视频时,会对有风险的参考素材输入进行拦截,最大限度保证生成视频合规和安全性。
为确保创作者能充分利用 Seedance 2.0 强大的视频生成能力高效生成视频内容,同时规避 AI 生成内容的潜在风险,方舟推出了私域可信素材库。完成入库的可信素材将进入您的私域素材库,在视频生成中使用。
私域素材库使用流程如下:
<div style="text-align: center"><img src="https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/fa131ff017324d228b8a07c9bde49d4d~tplv-goo7wpa0wc-image.image" width="3866px" /></div>
<span id="2b7bf522"></span>
# 素材资产库结构说明
* **Asset Group素材资产组合**:单个素材文件为一个 Asset每个 Asset 属于一个 Asset Group。
* 可以使用素材组自由管理素材,例如可将同一虚拟人物素材放入同一素材组合进行管理。
* **Asset素材资产**:一个素材文件(当前支持上传图像、视频、音频),是方舟 Seedance 2.0 系列模型可直接用于推理的可信资产。
:::tip
注意
* 仅需入库推理需使用的素材资产,不需使用的素材资产请勿入库。
* 仅可使用已入库素材资产的 Id (Asset ID) 进行视频生成,同一形象未入库素材无法使用。
* 每个上传的素材资产需经过预处理,可轮询调用 **GetAsset** 接口查询素材状态(对应参数为 **Status**,仅当状态变为 `Active` 后,该素材资产方可用于后续推理使用;若状态为 `Failed` 则表示处理失败,无法用于后续推理使用。**详情可参考**[示例:上传素材并使用 GetAsset 获取素材信息](/docs/82379/2333565#5c0ee427)**。**
:::
**以图像资产上传为例:**
* **单张图片文件格式要求:**
* 格式jpeg、png、webp、bmp、tiff、gif、heic/heif
* 宽高比(宽/高): (0.4, 2.5)
* 宽高长度px(300, 6000)
* 大小:单张图片小于 30 MB。
* 为保证上传的图片素材资产在后续生成视频时,**人物面部、服装细节等与上传的素材资产一致,​**推荐按照如下规则及示例将同一人物的多个素材传入同一资产组合:
* **人像资产内容最佳实践:**
:::tip
**全身参考图要求**
* 板式:竖版
* 图片内容:人物全身正面图片
:::
<div style="text-align: center"><img src="https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/86fef6988c8449c2a3d9062b2fa50e96~tplv-goo7wpa0wc-image.image" width="333px" /></div>
:::tip
**人脸特写图要求**
* 板式: 竖版
* 图片内容人物正面无表情特写肩部以上人物面部占画面2/3左右
:::
<div style="text-align: center"><img src="https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/6188f2b280eb43a3821644071a2c5485~tplv-goo7wpa0wc-image.image" width="272px" /></div>
<span id="d54e09a3"></span>
# 素材资产AssetsAPI 接口功能
:::warning
调用素材资产AssetsAPI 接口需使用 Access Key 鉴权,详情参考 [获取 API 访问密钥AK/SK](https://www.volcengine.com/docs/6257/64983?lang=zh)。
:::
<span id="85305caa"></span>
## 接口列表
<span id="72169511"></span>
## **Asset (Group) 创建接口**
1. [CreateAssetGroup](https://www.volcengine.com/docs/82379/2318270):创建素材资产组合。**首次创建素材资产组合时需在控制台签署授权函,详情参考**[私域虚拟人像素材资产库使用指南(邀测用户版)](/docs/82379/2333565)
2. [CreateAsset](https://www.volcengine.com/docs/82379/2318271):创建素材资产。该接口可用于上传个人素材资产,创建素材资产后可利用返回字段中的素材 **Id (需处于** **`Active` 状态)**用于 Seedance 2.0 系列模型生成视频。
<span id="5e9c0b10"></span>
## **Asset (Group) 管理接口**
* [ListAssetGroups](https://www.volcengine.com/docs/82379/2318272):查询素材资产组合列表。
* [ListAssets](https://www.volcengine.com/docs/82379/2318273):查询素材资产列表。
* [GetAsset](https://www.volcengine.com/docs/82379/2318274):查询素材资产信息。
* [GetAssetGroup](https://www.volcengine.com/docs/82379/2318275):查询素材资产组合信息。
* [UpdateAssetGroup](https://www.volcengine.com/docs/82379/2318276):更新素材资产组合信息。
* [UpdateAsset](https://www.volcengine.com/docs/82379/2318277):更新素材资产信息。
* [DeleteAsset](https://www.volcengine.com/docs/82379/2318278):删除单个素材资产。
* [DeleteAssetGroup](https://www.volcengine.com/docs/82379/2341606): 删除指定素材组。
<span id="987b4caa"></span>
## 限流要求
:::tip
* **QPS**API 接口**每秒**允许的请求总数上限,超出则请求报错。
* **QPM**API 接口**每分钟**允许的请求总数上限,超出则请求报错。
:::
| | | \
|接口名 |账号维度的限流 |
|---|---|
| | | \
|CreateAssetGroup |10 QPS |
| | | \
|CreateAsset |300 QPM |
| | | \
|ListAssetGroups |10 QPS |
| | | \
|ListAssets |10 QPS |
| | | \
|GetAsset |100 QPS |
| | | \
|GetAssetGroup |10 QPS |
| | | \
|UpdateAsset |10 QPS |
| | | \
|UpdateAssetGroup |10 QPS |
| | | \
|DeleteAsset |10 QPS |
| | | \
|DeleteAssetGroup |5 QPS |
<span id="5d0da843"></span>
# 使用教程
<span id="b4a41fe1"></span>
## 上传素材至私域虚拟人像库 API & 控制台)
您可将自有的虚拟形象上传至私域虚拟人像库。
:::danger
您需确保上传的虚拟人像符合以下条件:
您合法拥有该素材,并享有完整的使用及处分权限。素材不包含未获授权的第三方商标、标识类内容。
素材不得与任何自然人肖像或形象雷同,素材不存在抄袭、盗用情形,不会侵害任何第三方的人格权、知识产权等合法权益。
素材不包含违反法规、违背公序良俗、危害国家安全的内容。
:::
方舟将对您上传的素材进行安全审核。审核通过后,即可在体验中心和 API 中使用素材生成视频。
您可使用 OpenAPI 或在体验中心上传虚拟素材。
<span id="65934594"></span>
### 阅读并同意协议
首次入库前,需打开 [控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new) > **开通管理** > **开通素材资产库权限,​**阅读和同意相关规则和协议:
<div style="text-align: center"><img src="https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/4b083981c8ca48ddbcedd6e750061626~tplv-goo7wpa0wc-image.image" width="2938px" /></div>
先创建 Asset Group, 再向 Group 中添加虚拟人像素材。
:::tip
素材格式的具体要求,请参考[素材资产库结构说明](/docs/82379/2333565#2b7bf522)。
:::
<span id="f9a31891"></span>
### 使用控制台
1. 打开 [方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128&tab=GenVideo) > **我的** > **虚拟人像**
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/b1152a6834bc4a7e8f5137474bf34391~tplv-goo7wpa0wc-image.image =1541x)
2. 创建素材组合。
3. 向素材组合中上传素材。
<span id="f96dab35"></span>
### 使用 API
先调用 `CreateAssetGroup` 接口创建素材组合,再调用 `CreateAsset` 接口向组合中上传素材。请求示例:
1. **创建素材组合**
:::tip
**注意**
* 调用素材资产AssetsAPI 接口需使用 Access Key 鉴权,详情参考 [API访问密钥管理](https://www.volcengine.com/docs/6257/64983?lang=zh)。
* API 参数信息请参考[私域虚拟人像库 API 参考文档](/docs/82379/2333601)。
* **素材库**[项目](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)**Project隔离说明**
向指定的 Asset Group素材资产组合内创建或查询 Asset素材资产需保证两者的 **ProjectName** 一致
Asset素材资产所属的 **ProjectName** 需与调用视频生成 API 接口时使用的 API key 所属的 **ProjectName** 一致
:::
使用 **POST` `**`CreateAssetGroup` 接口创建素材组合。
在请求中传入:
* **Name**:素材组合的名称。
* **Description**: 素材组合的文字描述。
* **GroupType**: 选填,默认为 AIGC虚拟人像素材
:::tip
当前仅支持 AIGC 类型。
:::
* **ProjectName**:选填,指定资源项目名称,默认为 default。一个项目中的资源仅可被该项目下的推理接入点使用获取项目名称请参考[文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。
:::tip
**注意**
如果请求中不指定 **ProjectName**,默认将创建素材组至 **default** 项目中。
:::
请求示例:
**注意**:需使用 AK/SK 鉴权,详情参考 [API访问密钥管理](https://www.volcengine.com/docs/6257/64983?lang=zh)。
```Go
package main
import (
"fmt"
"github.com/bytedance/sonic"
"github.com/volcengine/volcengine-go-sdk/volcengine"
"github.com/volcengine/volcengine-go-sdk/volcengine/credentials"
"github.com/volcengine/volcengine-go-sdk/volcengine/session"
"github.com/volcengine/volcengine-go-sdk/volcengine/universal"
)
func main() {
config := volcengine.NewConfig().WithCredentials(credentials.NewStaticCredentials("<YOUR_AK>", "<YOUR_SK>", "")).WithRegion("cn-beijing")
sess, _ := session.NewSession(config)
resp, err := universal.New(sess).DoCall(
universal.RequestUniversal{
ServiceName: "ark",
Action: "CreateAssetGroup",
Version: "2024-01-01",
HttpMethod: universal.POST,
ContentType: universal.ApplicationJSON,
},
&map[string]any{
"Name": "figure_group_1",
"Description": "Figure group 1",
"ProjectName": "<PROJECT_NAME>",
},
)
if err != nil {
fmt.Printf("error: %v\n", err)
return
}
if resp == nil {
return
}
respData, err := sonic.Marshal(resp)
fmt.Println(string(respData))
}
```
返回示例:
```JSON
{
"Id":"group-20260318033332-*****"}
```
2. **上传素材**
:::danger
上传素材 CreateAsset API 为异步接口,系统处理可能出现排队,导致入库时间增加。不承诺上传时间 SLA。
视频素材处理将耗费更长时间。
:::
使用 **POST** `CreateAsset`接口上传素材。
在请求中提供:
* **GroupId**:必填,素材组合 ID
* **URL**: 必填,图片/视频/音频可访问的 URL
* **AssetType**: 必填,支持上传图片/视频/音频类型素材,需指定为 **Image/Video/Audio。**素材文件的具体限制详见 [Assets API 参考文档](https://www.volcengine.com/docs/82379/2318271)**。**
* **Name**: 选填,素材名称,可用于管理素材,如素材文件名。
:::tip
该字段仅用于使用 ListAssets 接口时模糊搜索素材,不会被带入模型推理。关于如何使用素材生成视频,请参考[使用虚拟人像](/docs/82379/2291680#2bf01416) 和[3. 提示词content.text中应该如何准确指代参考素材](/docs/82379/2333565#15e21eb8)。
:::
* **ProjectName**:选填,指定资源项目名称,默认为 **default**。一个项目中的资源仅可被该项目下的推理接入点使用,获取项目名称请参考[文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。
:::tip
**注意**
如果请求中不指定 **ProjectName**,则默认上传素材至 **default** 项目中。您需使用该字段确保将素材上传至对应的项目中。
:::
**注意**
* 每次请求上传一个素材文件。
* 该请求返回素材 ID可使用 GetAsset API 查看是否上传成功。
```Go
package main
import (
"fmt"
"github.com/bytedance/sonic"
"github.com/volcengine/volcengine-go-sdk/volcengine"
"github.com/volcengine/volcengine-go-sdk/volcengine/credentials"
"github.com/volcengine/volcengine-go-sdk/volcengine/session"
"github.com/volcengine/volcengine-go-sdk/volcengine/universal"
)
func main() {
config := volcengine.NewConfig().WithCredentials(credentials.NewStaticCredentials("<YOUR_AK>", "<YOUR_SK>", "")).WithRegion("cn-beijing")
sess, _ := session.NewSession(config)
resp, err := universal.New(sess).DoCall(
universal.RequestUniversal{
ServiceName: "ark",
Action: "CreateAsset",
Version: "2024-01-01",
HttpMethod: universal.POST,
ContentType: universal.ApplicationJSON,
},
&map[string]any{
"GroupId": "group-20260318070359-*****",
"URL": "<IMAGE_URL>",
"AssetType": "Image",
"ProjectName": "<PROJECT_NAME>"
},
)
if err != nil {
fmt.Printf("error: %v\n", err)
return
}
if resp == nil {
return
}
respData, err := sonic.Marshal(resp)
fmt.Println(string(respData))
}
```
返回示例:
```JSON
{
"Id": "asset-20260318071009-*****"
}
```
<span id="cd721316"></span>
## 检索虚拟人像资产 API & 控制台)
您可使用以下方式检索虚拟人像资产。
* **控制台**:您可在 [方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128&tab=GenVideo) > **我的** > **我的虚拟人像** 中搜索和查看已上传的虚拟人像资产。
* **API**
* **POST** `GetAsset `获取单个素材
* **POST** `ListAssets` 查询素材
* **POST** `ListAssetGroups` 查询素材组合信息
<span id="a32de856"></span>
### 获取单个素材信息
可使用 **POST** GetAsset 获取单个素材信息,指定素材资产 ID。
:::tip
要获取完整的 API 参数、限流等信息,请查看[私域虚拟人像库 API 参考文档](/docs/82379/2333601)。
:::
```Go
package main
import (
"fmt"
"github.com/bytedance/sonic"
"github.com/volcengine/volcengine-go-sdk/volcengine"
"github.com/volcengine/volcengine-go-sdk/volcengine/credentials"
"github.com/volcengine/volcengine-go-sdk/volcengine/session"
"github.com/volcengine/volcengine-go-sdk/volcengine/universal"
)
func main() {
config := volcengine.NewConfig().WithCredentials(credentials.NewStaticCredentials("your_ak", "your_sk", "")).WithRegion("cn-beijing")
sess, _ := session.NewSession(config)
resp, err := universal.New(sess).DoCall(
universal.RequestUniversal{
ServiceName: "ark",
Action: "GetAsset",
Version: "2024-01-01",
HttpMethod: universal.POST,
ContentType: universal.ApplicationJSON,
},
&map[string]any{
"Id": "asset-20260318070533-*****",
"ProjectName": "<PROJECT_NAME>", // 需确保填入素材所在项目的名称
},
)
if err != nil {
fmt.Printf("error: %v\n", err)
return
}
if resp == nil {
return
}
respData, err := sonic.Marshal(resp)
fmt.Println(string(respData))
}
```
返回示例:
```JSON
{
"GroupId": "group-20260318033332-*****",
"Status": "Active",
"CreateTime": "2026-03-18T03:57:10Z",
"AssetType": "Image",
"UpdateTime": "2026-03-18T03:57:14Z",
"ProjectName": "default",
"Id": "asset-20260318035710-*****",
"Name": "",
"URL": "https://ark-media-asset-stg.tos-cn-beijing.volces.com/2100000825/031807095608757847.jpg?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Credential=****&X-Tos-Expires=43200&X-Tos-Security-Token=****&X-Tos-Signature=****&X-Tos-SignedHeaders=host" // 有效期为 12 小时
}
```
<span id="8910c01c"></span>
### 查询素材资产
可使用 **POST** ListAssets 查询 Assets。
* 支持根据组合 ID (GroupId)、素材状态Statuses和素材名称Name查询。筛选出符合所有条件的素材。
* 支持使用 Name 进行模糊搜索,同时使用 GroupId 精确搜索,便于检索所需的素材。
支持使用 SortBySortOrder 对结果进行排序。
```Go
package main
import (
"fmt"
"github.com/bytedance/sonic"
"github.com/volcengine/volcengine-go-sdk/volcengine"
"github.com/volcengine/volcengine-go-sdk/volcengine/credentials"
"github.com/volcengine/volcengine-go-sdk/volcengine/session"
"github.com/volcengine/volcengine-go-sdk/volcengine/universal"
)
func main() {
config := volcengine.NewConfig().WithCredentials(credentials.NewStaticCredentials("<YOUR_AK>", "<YOUR_SK>", "")).WithRegion("cn-beijing")
sess, _ := session.NewSession(config)
resp, err := universal.New(sess).DoCall(
universal.RequestUniversal{
ServiceName: "ark",
Action: "ListAssets",
Version: "2024-01-01",
HttpMethod: universal.POST,
ContentType: universal.ApplicationJSON,
},
&map[string]any{
"Filter": map[string]any{
"GroupIds": []string{"group-20260318033332-*****"},
"GroupType": "AIGC",
"Statuses": []string{"Active", "Processing"}, // 支持 Active素材上传成功可使用Asset ID, Processing素材处理中, Failed素材上传失败
"Name": "figure", // 支持模糊搜索
},
"PageNumber": 1,
"PageSize": 10,
"SortBy": "GroupId",
"SortOrder": "Asc",
},
)
if err != nil {
fmt.Printf("list assets error: %v\n", err)
return
}
if resp == nil {
return
}
respData, err := sonic.Marshal(resp)
fmt.Println(string(respData))
}
```
返回示例:
```JSON
"Items": [
{
"Id": "asset-20260318035710-kctzf",
"Name": "",
"AssetType": "Image",
"CreateTime": "2026-03-18T03:57:10Z",
"UpdateTime": "2026-03-18T03:57:14Z",
"ProjectName": "default",
"URL": "image_url", // 有效期为 12 小时
"GroupId": "group-20260318033332-*****",
"Status": "Active"
},
{
"GroupId": "group-20260318033332-*****",
"Status": "Active",
"Id": "asset-20260318034804-*****",
"Name": "",
"URL": "https://ark-media-asset-stg.tos-cn-beijing.volces.com/2100000825/031807095608757847.jpg?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Credential=****&X-Tos-Expires=43200&X-Tos-Security-Token=****&X-Tos-Signature=****&X-Tos-SignedHeaders=host",
"AssetType": "Image",
"CreateTime": "2026-03-18T03:48:04Z",
"UpdateTime": "2026-03-18T03:48:08Z",
"ProjectName": "default"
}
],
"TotalCount": 2,
"PageNumber": 1,
"PageSize": 10
```
<span id="f95b9753"></span>
### 查询素材组
使用 **POST** ListAssetGroups 查询素材组合信息。
支持模糊搜索素材组合名称Name或提供多个素材组合GroupId
如有多个素材组,可使用 Name 字段进行模糊搜索。
:::tip
要获取完整的 API 参考文档,请查看[私域虚拟人像库 API 参考文档](/docs/82379/2333601)。
:::
```Go
package main
import (
"fmt"
"github.com/bytedance/sonic"
"github.com/volcengine/volcengine-go-sdk/volcengine"
"github.com/volcengine/volcengine-go-sdk/volcengine/credentials"
"github.com/volcengine/volcengine-go-sdk/volcengine/session"
"github.com/volcengine/volcengine-go-sdk/volcengine/universal"
)
func main() {
config := volcengine.NewConfig().WithCredentials(credentials.NewStaticCredentials("<YOUR_AK>", "<YOUR_SK>", "")).WithRegion("cn-beijing")
sess, _ := session.NewSession(config)
resp, err := universal.New(sess).DoCall(
universal.RequestUniversal{
ServiceName: "ark",
Action: "ListAssetGroups",
Version: "2024-01-01",
HttpMethod: universal.POST,
ContentType: universal.ApplicationJSON,
},
&map[string]any{
"Filter": map[string]any{
"Name": "figure_group", // Support fuzzy search
"GroupIds": []string{"group-20260318033332-*****"},
"GroupType": "AIGC",
},
"PageNumber": 1,
"PageSize": 10,
},
)
if err != nil {
fmt.Printf("error: %v\n", err)
return
}
if resp == nil {
return
}
respData, err := sonic.Marshal(resp)
fmt.Println(string(respData))
}
```
返回示例:
```JSON
{
"TotalCount": 1,
"Items": [
{
"UpdateTime": "2026-03-18T03:33:32Z",
"Id": "group-20260318033332-*****",
"Name": "figure_group_1",
"Title": "figure_group_1",
"Description": "Figure group 1",
"GroupType": "AIGC",
"ProjectName": "default",
"CreateTime": "2026-03-18T03:33:32Z"
}
],
"PageNumber": 1,
"PageSize": 10
}
```
<span id="e545fe77"></span>
### 更新/删除素材和素材组
请参考: [私域虚拟人像库 API 参考文档](/docs/82379/2333601)。
<span id="5c0ee427"></span>
## 示例:上传素材并使用 GetAsset 获取素材信息
以下示例创建素材资产后,查询资产 Status 并根据状态,判断是否继续查询或返回对应结果。
代码执行以下逻辑:
1. createAsset 上传资源,获取 AssetId
2. waitForAssetActive开始查询循环调用 getAssetStatus 查询当前资产状态
3. 根据 Status 判断
* Processing → 继续轮询
* Active → 返回 URL结束状态为 `Active` 后,可使用该素材 Asset ID (URI格式) 进行视频生成,如何使用人像素材生成视频,详见[使用虚拟人像](/docs/82379/2291680#2bf01416)。
* Failed → 返回错误(结束)
4. 返回结果并打印结果
<Attachment link="https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/2782fff499e24d2cbb7836229b428ab4~tplv-goo7wpa0wc-image.image" name="Upload_Asset_Get_Info.go" ></Attachment>
查询结果示意如下:
```JSON
asset status: Active
asset is active, URL = https://ark-media-asset-stg.tos-cn-beijing.volces.com/2100000825/031807095608757847.jpg?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Credential=****&X-Tos-Expires=43200&X-Tos-Security-Token=****&X-Tos-Signature=****&X-Tos-SignedHeaders=host
```
<span id="ca82b8d7"></span>
## 其他编程语言示例
查看更多语言的示例代码请下载:
<Attachment link="https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/bcc7d8a175744793a79b09027f0cf1ee~tplv-goo7wpa0wc-image.image" name="demo.zip" ></Attachment>
:::tip
注意替换 Demo 中的 AK与SK若需调用其他接口如 ListAssets需替换 ACTION 与对应请求参数。
:::
<span id="c78f9931"></span>
## 使用人像素材生成视频
在获取素材 Asset ID后可使用私域人像素材生成视频。效果预览及使用方式请参考下文。
<span id="225e69c7"></span>
### 视频生成
在 Video Generation API 的 **content.<模态>_url.url** 字段中使用 素材 URI 生成视频。
:::tip
资产 URI 拼接方式:`asset://<asset_ID`**`>`**
:::
具体方式请参考 [Seedance 2.0 教程](https://www.volcengine.com/docs/82379/2291680?lang=zh) 和 [Seedance 2.0 API 参考](https://www.volcengine.com/docs/82379/1520757?lang=zh)。
:::tip
在传入给模型的 Prompt 中,需要使用**图片 1**、**视频 1** 的方式指代参考素材,素材序号为素材在请求体中的顺序。请勿直接在 Prompt 中直接使用 Asset ID。
例:“**图片1** 里的女孩身着**图片2**中的服装,正在整理柜台上的物品。**图片3**中的男孩是一位顾客,他走上前,想要向女孩索要联系方式。”
调用示例请参考[3. 提示词content.text中应该如何准确指代参考素材](/docs/82379/2333565#15e21eb8)
:::
示例代码:
```Python
import os
import time
# Install SDK: pip install 'volcengine-python-sdk[ark]'
from volcenginesdkarkruntime import Ark
client = Ark(
# The base URL for model invocation
base_url='https://ark.cn-beijing.volces.com/api/v3',
# Get API Keyhttps://console.volcengine.com/ark/region:ark+cn-beijing/apikey
api_key=os.environ.get("ARK_API_KEY"),
)
if __name__ == "__main__":
print("----- create request -----")
create_result = client.content_generation.tasks.create(
model="doubao-seedance-2-0-260128", # Replace with Model ID
content=[
{
"type": "text",
"text": "图片1中美妆博主用中文进行介绍妆容改为明艳大气去掉脸部反光笑容甜美近景镜头手持图片2的面霜面向镜头展示清新简约背景元气甜美风格。博主台词挖到本命面霜了质地像云朵一样软糯一抹就吸收熬夜急救、补水保湿全搞定素颜都自带柔光感。"
},
{
"type": "image_url",
"image_url": {
"url": "asset://asset-20260224200602-qn7wr" # Asset ID
},
"role": "reference_image"
},
{
"type": "image_url",
"image_url": {
"url": "https://ark-project.tos-cn-beijing.volces.com/doc_image/r2v_edit_pic1.jpg"
},
"role": "reference_image"
},
],
generate_audio=True,
ratio="16:9",
duration=11,
watermark=True,
)
print(create_result)
print("----- polling task status -----")
task_id = create_result.id
while True:
get_result = client.content_generation.tasks.get(task_id=task_id)
status = get_result.status
if status == "succeeded":
print("----- task succeeded -----")
print(get_result)
break
elif status == "failed":
print("----- task failed -----")
print(f"Error: {get_result.error}")
break
else:
print(f"Current status: {status}, Retrying after 30 seconds...")
time.sleep(30)
```
<span id="9f864be5"></span>
# 常见问题
<span id="cbc4063e"></span>
#### 1. 为什么素材上传成功后,无法使用素材生成视频或获取素材信息?
素材库按[项目](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)**Project隔离**。
* 在视频生成时,必须使用**素材所在项目**中的推理接入点进行推理。
* 如果素材上传成功,但使用获取素材接口获取素材失败,可能是因为调用上传素材(CreateAsset)和获取素材接口时传入了不同的 **ProjectName**
* **ProjectName** 默认值为 `default`,即如果不指定该字段,则默认将资源创建至 `default` 项目中。
* 建议在同一个项目中管理素材。
<span id="617ff561"></span>
#### 2. 怎样管理用户对素材库的权限?
您可使用[访问控制](https://console.volcengine.com/iam/identitymanage/user) IAM精细化管理用户操作素材库的权限。可按以下方式设置
1. **创建自定义策略**
1. 打开[访问控制](https://console.volcengine.com/iam/policymanage) > **新建自定义策略**
2. 输入策略名称。
3. 切换到 **JSON编辑器**,将下方自定义策略粘贴至编辑器中,点击 **提交** 保存。
<div style="text-align: center"><img src="https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/937e2b58f8294223a06f3860fc461f15~tplv-goo7wpa0wc-image.image" width="1125px" /></div>
```Python
{
"Statement": [
{
"Effect": "Allow",
"Action": [
"ark:*Asset*"
],
"Resource": [
"*"
]
}
]
}
```
2. **为用户/用户组赋权**
1. 点击 **用户管理** > **用户**/**用户组**,选择需要赋权的用户或用户组,点击右侧的 **添加权限。**
2. 在 **授权策略** 中选择**步骤 1** 中创建的策略。
3. (可选)在 **限制到项目资源** 中选择策略应用的项目。
4. 点击 **提交。**
完成上述操作后,该用户/用户组即可在对应项目中管理素材。
关于 IAM 的更多信息,请参考[访问控制](http://volcengine.com/docs/6257?lang=zh)。
<span id="15e21eb8"></span>
#### 3. 提示词content.**text**)中应该如何准确指代参考素材?
需在提示词输入中使用”**素材类型+序号**”格式引用素材,例如 **图片 1**、**视频 1**、**音频 1**。序号为请求体中该素材在同类素材中的排序。
**注意**:请勿在提示词中使用 Asset ID 指代素材。
例如,下方示例中包含 5 张参考图和 1 个参考音频,可参考示例提示词的写法引用素材。
* **参考:**
<div style="display: flex;">
<div style="flex-shrink: 0;width: calc((100% - 64px) * 0.2000);">
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/bc3f0a1951c94cd282c690d2f8a938e0~tplv-goo7wpa0wc-image.image =426x)
图片 1
</div>
<div style="flex-shrink: 0;width: calc((100% - 64px) * 0.2000);margin-left: 16px;">
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/c9b934d1e50246cdb840318f59e4f00a~tplv-goo7wpa0wc-image.image =157x)
图片 2
</div>
<div style="flex-shrink: 0;width: calc((100% - 64px) * 0.2000);margin-left: 16px;">
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/e987f41012a24a6fa8e746126916a933~tplv-goo7wpa0wc-image.image =534x)
图片 3
</div>
<div style="flex-shrink: 0;width: calc((100% - 64px) * 0.2000);margin-left: 16px;">
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/f74e55364f664c67885761a1a02648ae~tplv-goo7wpa0wc-image.image =674x)
图片 4
</div>
<div style="flex-shrink: 0;width: calc((100% - 64px) * 0.2000);margin-left: 16px;">
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/1ed8333cf28649e9a6efdef54529e436~tplv-goo7wpa0wc-image.image =574x)
图片 5
</div>
</div>
* **提示词**
```Plain Text
清新奶油画风短剧,轻快吉他卡点快切,奶油白主色 + 蜜桃粉高光画面柔和无特效靠表情传情。0-2 秒:快切 2 镜图片 1中的霸总不小心撞到穿着图片 2的衣服的图片 3中的女主两人错愕对视+ 霸总扯下自己的西装外套披在女主身上(手部特写)」,背景吉他声起,咖啡杯掉落 / 衣服摩擦的轻柔音效2-6 秒:快切 3 镜「女主穿霸总外套低头偷笑(脸颊泛红特写)+ 霸总看着女主背影嘴角微扬,说“我们一起走吧”参考音频 1侧颜 + 两人在雨夜共撑一把黑伞,指尖相触快速收回(近景)」,雨天背景为图片 4每镜卡点轻鼓重拍配雨滴落地 / 伞骨撑开的音效画面带轻微柔雾质感6-8 秒:慢放两人对视笑眼,画面右下角出现图片 5的文字部分左下角小字「NEW EP DAILY」背景飘淡粉色花瓣极简BGM 落温柔尾音,画面定格两人同框侧脸。
```
* **示例代码**
```Bash
curl --location 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks' \
-X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ARK_API_KEY"\
-d '{
"model": "doubao-seedance-2-0-260128",
"content": [
{
"type": "text",
"text": "清新奶油画风短剧,轻快吉他卡点快切,奶油白主色 + 蜜桃粉高光画面柔和无特效靠表情传情。0-2 秒:快切 2 镜图片 1中的霸总不小心撞到穿着图片 2的衣服的图片 3中的女主两人错愕对视+ 霸总扯下自己的西装外套披在女主身上(手部特写)」,背景吉他声起,咖啡杯掉落 / 衣服摩擦的轻柔音效2-6 秒:快切 3 镜「女主穿霸总外套低头偷笑(脸颊泛红特写)+ 霸总看着女主背影嘴角微扬,说“我们一起走吧”参考音频 1侧颜 + 两人在雨夜共撑一把黑伞,指尖相触快速收回(近景)」,雨天背景为图片 4每镜卡点轻鼓重拍配雨滴落地 / 伞骨撑开的音效画面带轻微柔雾质感6-8 秒:慢放两人对视笑眼,画面右下角出现图片 5的文字部分左下角小字「NEW EP DAILY」背景飘淡粉色花瓣极简BGM 落温柔尾音,画面定格两人同框侧脸。"
},
{
"type": "image_url",
"role": "reference_image",
"image_url": {
"url": "asset://asset-20260224185115-hnjhb"
}
},
{
"type": "image_url",
"role": "reference_image",
"image_url": {
"url": "asset://asset-20260224185115-8gghm"
}
},
{
"type": "image_url",
"role": "reference_image",
"image_url": {
"url": "asset://asset-20260224185115-cjkwr"
}
},
{
"type": "image_url",
"role": "reference_image",
"image_url": {
"url": "asset://asset-20260224185115-pxbk9"
}
},
{
"type": "image_url",
"role": "reference_image",
"image_url": {
"url": "asset://asset-20260224185115-2c698"
}
},
{
"type": "audio_url",
"role": "reference_audio",
"audio_url": {
"url": "asset://asset-20260224185115-dp9qm"
}
}
],
"generate_audio": true,
"ratio": "16:9",
"duration": 11,
"watermark": false
}'
```

View File

@ -73,6 +73,7 @@ export function RecordDetailModal({ record: r, onClose, showTeam, showCost }: Pr
<InfoItem label="模型" value={r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'} /> <InfoItem label="模型" value={r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'} />
<InfoItem label="模式" value={MODE_MAP[r.mode] || r.mode} /> <InfoItem label="模式" value={MODE_MAP[r.mode] || r.mode} />
<InfoItem label="比例" value={r.aspect_ratio || '-'} /> <InfoItem label="比例" value={r.aspect_ratio || '-'} />
<InfoItem label="分辨率" value={r.resolution ? r.resolution.toUpperCase() : '-'} />
<InfoItem label="时长" value={r.duration != null ? `${r.duration}` : '-'} /> <InfoItem label="时长" value={r.duration != null ? `${r.duration}` : '-'} />
<InfoItem label="Tokens" value={(r.tokens_consumed || 0).toLocaleString()} /> <InfoItem label="Tokens" value={(r.tokens_consumed || 0).toLocaleString()} />
{showCost && <InfoItem label="费用" value={`¥${(r.cost_amount || 0).toFixed(2)}`} />} {showCost && <InfoItem label="费用" value={`¥${(r.cost_amount || 0).toFixed(2)}`} />}

View File

@ -537,6 +537,8 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<span>{task.duration}s</span> <span>{task.duration}s</span>
<span className={styles.infoBarDot} /> <span className={styles.infoBarDot} />
<span>{task.aspectRatio}</span> <span>{task.aspectRatio}</span>
<span className={styles.infoBarDot} />
<span>{task.resolution.toUpperCase()}</span>
{(task.tokensConsumed ?? 0) > 0 && ( {(task.tokensConsumed ?? 0) > 0 && (
<> <>
<span>{(task.tokensConsumed ?? 0).toLocaleString()} tokens</span> <span>{(task.tokensConsumed ?? 0).toLocaleString()} tokens</span>

View File

@ -20,7 +20,7 @@ export function VideoGenerationPage() {
const regenerate = useGenerationStore((s) => s.regenerate); const regenerate = useGenerationStore((s) => s.regenerate);
const removeTask = useGenerationStore((s) => s.removeTask); const removeTask = useGenerationStore((s) => s.removeTask);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const prevCountRef = useRef(tasks.length); const prevLastIdRef = useRef<string | null>(null);
const initialLoadRef = useRef(true); const initialLoadRef = useRef(true);
const savedScrollTop = useGenerationStore((s) => s.savedScrollTop); const savedScrollTop = useGenerationStore((s) => s.savedScrollTop);
const saveScrollPosition = useGenerationStore((s) => s.saveScrollPosition); const saveScrollPosition = useGenerationStore((s) => s.saveScrollPosition);
@ -36,9 +36,14 @@ export function VideoGenerationPage() {
loadTasks(); loadTasks();
}, [loadTasks]); }, [loadTasks]);
// Restore scroll position after initial load, or scroll to bottom for new tasks // Restore scroll position after initial load, or scroll to bottom ONLY when a new task
// is appended at the tail. 通过比较末尾 task 的 id 来判断 —— 头部加载历史prepend
// 任务状态更新(如轮询完成)、删除某条都不会改变末尾 id因此不会触发滚动
// 避免用户往上翻时被突然拽回底部。
useEffect(() => { useEffect(() => {
if (tasks.length === 0) return; if (tasks.length === 0) return;
const currentLastId = tasks[tasks.length - 1]?.id ?? null;
if (initialLoadRef.current) { if (initialLoadRef.current) {
initialLoadRef.current = false; initialLoadRef.current = false;
// Use requestAnimationFrame to ensure DOM has rendered // Use requestAnimationFrame to ensure DOM has rendered
@ -50,15 +55,16 @@ export function VideoGenerationPage() {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}); });
prevCountRef.current = tasks.length; prevLastIdRef.current = currentLastId;
return; return;
} }
if (tasks.length > prevCountRef.current && scrollRef.current) {
if (currentLastId !== prevLastIdRef.current && scrollRef.current) {
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
} }
prevCountRef.current = tasks.length; prevLastIdRef.current = currentLastId;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tasks.length]); }, [tasks]);
// Save scroll position + auto-load older tasks when scrolled near top // Save scroll position + auto-load older tasks when scrolled near top
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {

View File

@ -227,6 +227,7 @@ export function ProfilePage() {
</div> </div>
<div className={styles.recordRight}> <div className={styles.recordRight}>
<span className={styles.recordSeconds}>¥{(r.cost_amount || 0).toFixed(2)}</span> <span className={styles.recordSeconds}>¥{(r.cost_amount || 0).toFixed(2)}</span>
{r.resolution && <span className={styles.recordMode}>{r.resolution.toUpperCase()}</span>}
<span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span> <span className={styles.recordMode}>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</span>
<span className={`${styles.recordStatus} ${styles[r.status]}`}>{statusMap[r.status]}</span> <span className={`${styles.recordStatus} ${styles[r.status]}`}>{statusMap[r.status]}</span>
</div> </div>

View File

@ -60,7 +60,7 @@ export function RecordsPage() {
team_id: teamFilter ? Number(teamFilter) : undefined, team_id: teamFilter ? Number(teamFilter) : undefined,
}); });
const header = '任务ID,提交时间,完成时间,耗时,团队,用户名,模型,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),成本(元),利润(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n'; const header = '任务ID,提交时间,完成时间,耗时,团队,用户名,模型,视频时长(秒),模式,比例,分辨率,消费秒数,Tokens,费用(元),成本(元),利润(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n';
const rows = data.results.map((r) => { const rows = data.results.map((r) => {
const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧'; const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
@ -70,7 +70,8 @@ export function RecordsPage() {
const elapsed = r.completed_at ? Math.round((new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 1000) + '秒' : ''; const elapsed = r.completed_at ? Math.round((new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 1000) + '秒' : '';
const completedAt = r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : ''; const completedAt = r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '';
const refCount = (r.reference_urls || []).length; const refCount = (r.reference_urls || []).length;
return `"${r.ark_task_id || ''}","${new Date(r.created_at).toLocaleString('zh-CN')}","${completedAt}","${elapsed}","${r.team_name || '-'}","${r.username}","${modelLabel}","${r.duration ?? ''}","${modeLabel}","${r.aspect_ratio || ''}","${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${(r.base_cost_amount || 0).toFixed(2)}","${profit}","${r.seed != null && r.seed !== -1 ? r.seed : ''}","${statusLabel}","${esc(r.prompt || '')}","${esc(r.error_message || '')}","${esc(r.raw_error || '')}","${refCount}"`; const resolutionLabel = r.resolution ? r.resolution.toUpperCase() : '';
return `"${r.ark_task_id || ''}","${new Date(r.created_at).toLocaleString('zh-CN')}","${completedAt}","${elapsed}","${r.team_name || '-'}","${r.username}","${modelLabel}","${r.duration ?? ''}","${modeLabel}","${r.aspect_ratio || ''}","${resolutionLabel}","${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${(r.base_cost_amount || 0).toFixed(2)}","${profit}","${r.seed != null && r.seed !== -1 ? r.seed : ''}","${statusLabel}","${esc(r.prompt || '')}","${esc(r.error_message || '')}","${esc(r.raw_error || '')}","${refCount}"`;
}).join('\n'); }).join('\n');
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });

View File

@ -49,7 +49,7 @@ export function TeamRecordsPage() {
end_date: endDate || undefined, end_date: endDate || undefined,
}); });
const header = '任务ID,提交时间,完成时间,耗时,用户名,模型,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n'; const header = '任务ID,提交时间,完成时间,耗时,用户名,模型,视频时长(秒),模式,比例,分辨率,消费秒数,Tokens,费用(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n';
const rows = data.results.map((r) => { const rows = data.results.map((r) => {
const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧'; const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
@ -58,7 +58,8 @@ export function TeamRecordsPage() {
const elapsed = r.completed_at ? Math.round((new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 1000) + '秒' : ''; const elapsed = r.completed_at ? Math.round((new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 1000) + '秒' : '';
const completedAt = r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : ''; const completedAt = r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '';
const refCount = (r.reference_urls || []).length; const refCount = (r.reference_urls || []).length;
return `"${r.ark_task_id || ''}","${new Date(r.created_at).toLocaleString('zh-CN')}","${completedAt}","${elapsed}","${r.username}","${modelLabel}","${r.duration ?? ''}","${modeLabel}","${r.aspect_ratio || ''}","${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${r.seed != null && r.seed !== -1 ? r.seed : ''}","${statusLabel}","${esc(r.prompt || '')}","${esc(r.error_message || '')}","${esc(r.raw_error || '')}","${refCount}"`; const resolutionLabel = r.resolution ? r.resolution.toUpperCase() : '';
return `"${r.ark_task_id || ''}","${new Date(r.created_at).toLocaleString('zh-CN')}","${completedAt}","${elapsed}","${r.username}","${modelLabel}","${r.duration ?? ''}","${modeLabel}","${r.aspect_ratio || ''}","${resolutionLabel}","${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${r.seed != null && r.seed !== -1 ? r.seed : ''}","${statusLabel}","${esc(r.prompt || '')}","${esc(r.error_message || '')}","${esc(r.raw_error || '')}","${refCount}"`;
}).join('\n'); }).join('\n');
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });

View File

@ -209,6 +209,7 @@ export interface AdminRecord {
mode: CreationMode; mode: CreationMode;
model: ModelOption; model: ModelOption;
aspect_ratio?: string; aspect_ratio?: string;
resolution?: Resolution;
status: 'queued' | 'processing' | 'completed' | 'failed'; status: 'queued' | 'processing' | 'completed' | 'failed';
error_message?: string; error_message?: string;
raw_error?: string; raw_error?: string;