feat(video): 期3 视频趴 — 人物→角色 + 引用模特库/自动入库 + 分镜合规归类 + 资产库素材

后端:
- B 路:视频流程新生成角色立绘+三视图成套 → 自动入模特库(run_triview_task,幂等 additive)
- 合规闭合:三视图 person→tri_view、分镜图 scene→storyboard,两者均加 on_commit 送审(此前只有角色立绘送审,三视图/分镜图漏审 = 合规缺口)
- 资产库 _tab_q:成片 finals→素材 materials(所有视频排除最终成片;最终成片隐藏不列)
- 单测 TriviewAutoEnrollTests:自动入库 + 幂等 + 三视图落审核范围

前端:
- 视频项目资产趴「人物」→「角色」(KIND_LABEL,区块名/新增/空态/详情弹窗一处全改;向导人设 persona 不动)
- A 路:演员库收编模特库 —— 顶部「模特库」tab 取自模特库实体(含官方跨团队模特),选一个即用其形象图作角色参考
- 资产库 library:人物→角色、成片 tab→素材、分类标签补全(模特上身图/平台套图/自由创作/视频素材/最终成片)
- vite.config:端口与后端代理目标可用 VITE_PORT/VITE_API_TARGET 覆盖(默认不变),避免多实例撞车

验收:后端单测全绿(基线既有 7 失败零新增);tsc+build 全绿;无头 0 console error
(资产库角色/素材改名、最终成片隐藏;pipeline 资产趴显角色;演员库模特库 tab 含 5 模特)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-21 19:27:23 +08:00
co-authored by Claude Opus 4.8
parent c8f3f91d38
commit c6e9c7e77b
8 changed files with 192 additions and 27 deletions
+23 -2
View File
@@ -851,7 +851,7 @@ def run_triview_task(*, task_id: str) -> None:
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
asset = _store_generated_media(
team=project.team, user=user, project=project, task=task, media=media,
name=f"{project.name}-三视图", category=Asset.Category.PERSON, asset_type=Asset.Type.IMAGE,
name=f"{project.name}-三视图", category=Asset.Category.TRI_VIEW, asset_type=Asset.Type.IMAGE,
)
# 复用该立绘的三视图组(triview_of==立绘asset id):追加候选 + 采用最新
group = next((g for g in project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PERSON).order_by("created_at")
@@ -864,6 +864,23 @@ def run_triview_task(*, task_id: str) -> None:
group.candidate_assets.add(asset)
group.adopted_asset = asset
group.save(update_fields=["adopted_asset", "updated_at"])
# B 路(期3):视频流程新生成的「角色」立绘 + 三视图成套 → 自动加入模特库(团队级可复用)。
# 幂等:同一立绘只建一次 Model;best-effort,不影响主链路。
from apps.assets.models import Model as ModelEntity
portrait = Asset.objects.filter(id=asset_key).first()
if portrait is not None and not ModelEntity.objects.filter(portrait_asset=portrait).exists():
ModelEntity.objects.create(
team=project.team, created_by=user,
name=(portrait.name or f"{project.name}-角色").split("·")[0].strip()[:255] or "角色",
source=ModelEntity.Source.AI,
portrait_asset=portrait, triview_asset=asset,
metadata={"from_project": str(project.id), "auto_enrolled": True},
)
# 合规(期3):三视图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort)
from apps.assets.review import submit_asset_for_review
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
except Exception as exc: # noqa: BLE001 — 失败退费 + 错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
task.status = AITask.Status.FAILED
task.error_message = str(exc)
@@ -1144,7 +1161,7 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
task=task,
media=media,
name=f"{project.name}-storyboard-{segment.sort_order + 1}",
category=Asset.Category.SCENE,
category=Asset.Category.STORYBOARD,
asset_type=Asset.Type.IMAGE,
)
with transaction.atomic():
@@ -1163,6 +1180,10 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
sort_order=segment.sort_order,
prompt=segment.visual_prompt,
)
# 合规(期3):分镜图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort)
from apps.assets.review import submit_asset_for_review
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务失败供 poll 上报
task.status = AITask.Status.FAILED
task.error_message = str(exc)
+58
View File
@@ -275,6 +275,64 @@ class StandaloneCategoryTests(TestCase):
self.assertEqual(a.category, Asset.Category.PERSON)
class TriviewAutoEnrollTests(TestCase):
"""B 路(期3):视频流程新生成的角色立绘 + 三视图成套 → 自动入模特库;幂等不重复建。"""
def setUp(self):
from apps.projects.models import Project
self.user = User.objects.create_user(username="trio", password="p")
self.team = Team.objects.create(name="TR", owner=self.user)
CreditAccount.objects.create(team=self.team, balance="100.0000")
self.product = Product.objects.create(team=self.team, created_by=self.user, title="P")
self.project = Project.objects.create(team=self.team, name="项目甲", product=self.product, created_by=self.user)
self.portrait = Asset.objects.create(
team=self.team, created_by=self.user, name="项目甲-person", asset_type=Asset.Type.IMAGE,
source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON,
)
AssetFile.objects.create(asset=self.portrait, object_key="p.png", bucket="b", content_type="image/png", preview_url="http://x/p.png", is_primary=True)
def _patch_provider(self):
provider = patch("apps.ai.services.get_image_provider").start()
prov = provider.return_value
prov.image_edit.return_value = {"data": [{"url": "http://x/tri.png"}]}
prov.extract_first_media_url.return_value = "http://x/tri.png"
media = patch("apps.ai.services.VolcanoArkProvider.media_to_bytes").start()
media.return_value = (BytesIO(b"img"), "image/png")
store = patch("apps.ai.services.TosStorage").start()
stored = store.return_value.upload_fileobj.return_value
stored.object_key, stored.bucket, stored.content_type, stored.size_bytes = "o.png", "b", "image/png", 3
self.addCleanup(patch.stopall)
def _gen_triview(self):
from apps.ai.services import generate_person_triview, run_triview_task
task = generate_person_triview(project=self.project, user=self.user, portrait_asset=self.portrait)
run_triview_task(task_id=str(task.id))
def test_triview_auto_enrolls_model(self):
from apps.assets.models import Model
self._patch_provider()
self._gen_triview()
m = Model.objects.filter(portrait_asset=self.portrait).first()
self.assertIsNotNone(m)
self.assertIsNotNone(m.triview_asset) # 成套
self.assertTrue(m.metadata.get("auto_enrolled"))
self.assertEqual(m.source, Model.Source.AI)
# 合规(期3):三视图归 tri_view → 落在送审范围内
self.assertEqual(m.triview_asset.category, Asset.Category.TRI_VIEW)
self.assertIn(m.triview_asset.category, Asset.REVIEW_CATEGORIES)
def test_auto_enroll_is_idempotent(self):
from apps.assets.models import Model
self._patch_provider()
self._gen_triview()
self._gen_triview() # 同一立绘再出一版三视图
self.assertEqual(Model.objects.filter(portrait_asset=self.portrait).count(), 1)
class _FakeStreamResp:
"""模拟 requests 流式响应:支持 with、raise_for_status、可写 encoding、iter_lines。"""
status_code = 200
+4 -4
View File
@@ -52,9 +52,9 @@ def _tab_q(tab: str) -> Q:
return Q(category="free_create")
if tab == "uploads":
return Q(category="upload")
if tab == "finals": # final_video,或「未归类但是视频」
return Q(category="final_video") | (~Q(category__in=_KNOWN_CATS) & Q(asset_type="video"))
if tab == "unclassified": # 未归类且非视频
if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列)
return ~Q(category="final_video") & Q(asset_type="video")
if tab == "unclassified": # 未归类且非视频(也不含最终成片)
return ~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video")
return Q()
@@ -110,7 +110,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
def summary(self, request):
"""资产库 tab 计数(人物/场景/商品图/成片/我的上传/未分类),供 tab 徽标——不必取全量。"""
base = Asset.objects.filter(team=self.get_team())
tabs = ["people", "scenes", "products", "tryon", "kits", "creations", "finals", "uploads", "unclassified"]
tabs = ["people", "scenes", "products", "tryon", "kits", "creations", "materials", "uploads", "unclassified"]
return Response({t: base.filter(_tab_q(t)).count() for t in tabs})
@action(detail=False, methods=["get"])
+29 -9
View File
@@ -2,15 +2,29 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { CSSProperties } from "react";
import { api } from "../api";
import type { Asset } from "../types";
import type { Asset, ModelEntity } from "../types";
import { useBodyScrollLock, MediaLightbox } from "./overlays";
// 流程步骤4 · 演员库:平台预设演员 + 我的演员(本地上传/AI 生成)。
// 两种用法:browse(纯浏览 / 添加演员)与 replace(从演员库选一个回填到某基础资产卡)
// 流程步骤4 · 演员库(期3 收编):角色可「引用模特库」—— 顶部「模特库」tab 取自模特库实体(含官方跨团队模特),
// 选一个即用其形象图作该角色的参考/回填;「我的演员」= 本项目未入库的 person 资产
// 两种用法:browse(纯浏览 / 添加演员)与 replace(从库里选一个回填到某基础资产卡)。
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
const previewOf = (a: Asset): string => a.files?.find((f) => f.is_primary)?.preview_url || a.files?.[0]?.preview_url || "";
// 平台预设 = 模特库生成的(metadata.kind==="model")或系统来源;其余 person 资产归「我的演员」
const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model" || a.source === "system" || a.source === "ai_generated";
// 模特库实体 → Asset 形(id=形象图资产 id,选中即作角色参考图);标 kind=model 归「模特库」tab
const modelToAsset = (m: ModelEntity): Asset => ({
id: m.portrait_asset as string,
name: m.name,
asset_type: "image",
source: "ai_generated",
category: "person",
description: "",
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official },
files: m.portrait ? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }] : [],
created_at: m.created_at,
updated_at: m.updated_at
});
// 模特库 = 模特库实体(metadata.kind==="model");其余 person 资产归「我的演员」
const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model";
export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPick, onGenerate, onUpload, onGenerateTriview, onRename, onRefresh }: {
open: boolean;
@@ -57,8 +71,14 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
// 否则平台预设/我的演员全空。打开时拉一页(团队 person 资产),保存后 reload 让新人物即时入列。
const [fetched, setFetched] = useState<Asset[]>([]);
const reload = useCallback(async () => {
const res = await api.assetsPage({ category: "person", pageSize: 200, ordering: "-created_at" }).catch(() => null);
setFetched(res?.results ?? []);
// 期3:同时拉「项目 person 资产」+「模特库实体」。模特放后面 → 按 id 去重时模特胜出,
// 同一张形象图(既是 person 资产又被模特库引用)归「模特库」tab,可作角色参考。
const [persons, models] = await Promise.all([
api.assetsPage({ category: "person", pageSize: 200, ordering: "-created_at" }).catch(() => null),
api.listModels({ pageSize: 200 }).catch(() => null)
]);
const mapped = (models?.results ?? []).filter((m) => m.portrait_asset).map(modelToAsset);
setFetched([...(persons?.results ?? []), ...mapped]);
}, []);
useEffect(() => { if (open) void reload(); }, [open, reload]);
@@ -228,7 +248,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
<div className="actorlib-body">
<div className="actorlib-toolbar">
<div className="actorlib-tabs">
<button className={`al-tab${tab === "preset" ? " active" : ""}`} type="button" onClick={() => setTab("preset")}> · {people.filter(isPreset).length}</button>
<button className={`al-tab${tab === "preset" ? " active" : ""}`} type="button" onClick={() => setTab("preset")}> · {people.filter(isPreset).length}</button>
<button className={`al-tab${tab === "mine" ? " active" : ""}`} type="button" onClick={() => setTab("mine")}> · {people.filter((a) => !isPreset(a)).length}</button>
</div>
<span className="spacer" style={{ flex: 1 }}></span>
@@ -266,7 +286,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
</>
) : (
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
<span className="ph-frame">// {tab === "preset" ? "暂无平台预设演员" : "还没有自己的演员 · 点右上「添加演员」"}</span>
<span className="ph-frame">// {tab === "preset" ? "模特库暂无模特 · 去「模特库」页添加" : "还没有自己的演员 · 点右上「添加演员」"}</span>
</div>
)}
</div>
+9 -8
View File
@@ -13,7 +13,7 @@ const LIB_PAGE_SIZE = 10;
// asset.source / asset.asset_type / asset.category → 中文标签(筛选下拉 + 卡片 meta 用,不给用户看裸枚举)
const SOURCE_LABELS: Record<string, string> = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" };
const KIND_LABELS: Record<string, string> = { image: "图片", video: "视频", audio: "音频", subtitle: "字幕", document: "文档" };
const CATEGORY_LABELS: Record<string, string> = { person: "人物", scene: "场景", product_image: "商品图", video_clip: "视频片段", final_video: "成片", upload: "上传", uncategorized: "未分类" };
const CATEGORY_LABELS: Record<string, string> = { person: "角色", scene: "场景", product_image: "商品图", model_tryon: "模特上身图", platform_kit: "平台套图", free_create: "自由创作", video_clip: "视频素材", final_video: "最终成片", upload: "上传", uncategorized: "未分类" };
// 上传文件 → 资产类型:按 MIME 推断,字幕按后缀,兜底文档(后端直接存这个值,不能一律写死 image)
function inferAssetType(file: File): string {
@@ -35,12 +35,13 @@ const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint:
];
// 图片趴三类(期2):tryon=模特上身图 / kits=平台套图 / creations=自由创作
type LibTab = "people" | "scenes" | "products" | "tryon" | "kits" | "creations" | "finals" | "uploads" | "unclassified";
// 期3:人物→角色;成片 tab→素材(视频素材,排除最终成片;最终成片隐藏不列)
type LibTab = "people" | "scenes" | "products" | "tryon" | "kits" | "creations" | "materials" | "uploads" | "unclassified";
const LIB_TABS: Array<{ key: LibTab; label: string }> = [
{ key: "people", label: "人物" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" },
{ key: "people", label: "角色" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" },
{ key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "creations", label: "自由创作" },
{ key: "finals", label: "成片" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" }
{ key: "materials", label: "素材" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" }
];
// 对齐 api-bridge:工具栏 chip 按 tab 显隐
@@ -50,8 +51,8 @@ const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
{ key: "role", label: "角色标签", tabs: ["people"] },
{ key: "sceneType", label: "场景类型", tabs: ["scenes"] },
{ key: "product", label: "关联商品", tabs: ["products", "tryon", "kits"] },
{ key: "project", label: "关联项目", tabs: ["finals"] },
{ key: "duration", label: "时长", tabs: ["finals"] },
{ key: "project", label: "关联项目", tabs: ["materials"] },
{ key: "duration", label: "时长", tabs: ["materials"] },
{ key: "kind", label: "资产类型", tabs: ["uploads"] },
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "tryon", "kits", "creations", "uploads"] }
];
@@ -427,7 +428,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
const [items, setItems] = useState<Asset[]>([]);
const [total, setTotal] = useState(0);
const [counts, setCounts] = useState<Record<LibTab, number>>({ people: 0, scenes: 0, products: 0, tryon: 0, kits: 0, creations: 0, finals: 0, uploads: 0, unclassified: 0 });
const [counts, setCounts] = useState<Record<LibTab, number>>({ people: 0, scenes: 0, products: 0, tryon: 0, kits: 0, creations: 0, materials: 0, uploads: 0, unclassified: 0 });
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>({ sources: [], kinds: [], metadata: {} });
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
@@ -522,7 +523,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-finals">{counts.finals}</span> </span></div>
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 角色 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-materials">{counts.materials}</span> 素材</span></div>
</div>
<div className="actions">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
+2 -2
View File
@@ -12,8 +12,8 @@ import { IconKitSvg } from "../components/IconKitSvg";
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
// 基础资产组 kind → 中文区块名(对齐 design.md 商品/人物/场景三类)
const KIND_LABEL: Record<string, string> = { product: "商品", person: "人物", scene: "场景" };
// 基础资产组 kind → 中文区块名(期3:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象)
const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色", scene: "场景" };
// 脚本来源 → 「来源」brief pill 文案
const SOURCE_LABEL: Record<string, string> = { ai: "AI 全生", theme: "一句话主题", manual: "自带脚本" };
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
+3 -2
View File
@@ -4,11 +4,12 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
// 端口与后端代理目标可用环境变量覆盖,避免多项目/多实例撞车(默认保持原值)
port: Number(process.env.VITE_PORT) || 5173,
strictPort: false,
proxy: {
"/api": {
target: "http://127.0.0.1:8010",
target: process.env.VITE_API_TARGET || "http://127.0.0.1:8010",
changeOrigin: true
}
}
+64
View File
@@ -0,0 +1,64 @@
// 期3 走查:资产库「角色/素材」改名 + 最终成片隐藏 + pipeline 资产趴「角色」+ 演员库「模特库」tab。
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.BASE || "http://localhost:5200";
const PROJECT = process.env.PROJECT || "bdbf39d3-1a89-4135-b1b7-26d3331a7021";
const OUT = path.resolve("../../../_qa_shots/models-p3");
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ headless: true });
const r = { consoleErrors: [], library: {}, pipeline: {} };
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(m.text()); });
p.on("pageerror", (e) => r.consoleErrors.push("PAGEERR:" + e.message));
// 登录
await p.goto(BASE + "/login", { waitUntil: "load" });
await p.waitForTimeout(400);
await p.fill("#auth-username", "airshelf");
await p.fill("#auth-pwd", "Restraint2026");
await p.click("button.btn-cta");
await p.waitForFunction(() => !location.pathname.startsWith("/login"), { timeout: 12000 });
// ── 资产库:角色 / 素材 改名,人物 / 成片 消失 ──
await p.goto(BASE + "/library", { waitUntil: "load" });
await p.waitForTimeout(2600);
const libTabs = await p.locator(".tabs .tab, .tab").allInnerTexts().catch(() => []);
const tabText = libTabs.join(" | ");
r.library.tabs = tabText;
r.library.hasRoleTab = /角色/.test(tabText);
r.library.hasMaterialTab = /素材/.test(tabText);
r.library.peopleTabGone = !/(^|\|)\s*人物\s*\d/.test(tabText) && !/人物\s/.test(tabText);
r.library.finalsTabGone = !/成片/.test(tabText);
await p.screenshot({ path: path.join(OUT, "library-tabs.png"), fullPage: true });
// ── pipeline 资产趴(stage 2):KIND_LABEL person → 角色 ──
await p.goto(BASE + "/pipeline/" + PROJECT, { waitUntil: "load" });
await p.waitForTimeout(2500);
await p.locator('a[data-stage="2"]').first().click().catch(() => {});
await p.waitForTimeout(2500);
const bodyText = await p.locator('[data-stage-pane="2"]').innerText().catch(() => "");
r.pipeline.hasRole = /角色/.test(bodyText);
r.pipeline.hasPersonOld = /(新增人物|暂无人物)/.test(bodyText);
await p.screenshot({ path: path.join(OUT, "pipeline.png"), fullPage: true });
// 尝试打开演员库(点资产趴里的「新增角色」或「模特库」入口),验证「模特库」tab
let openedActorLib = false;
const trigger = p.locator("button", { hasText: /新增角色|模特库|演员库/ }).first();
if (await trigger.count()) {
await trigger.click().catch(() => {});
await p.waitForTimeout(1800);
const alText = await p.locator(".actorlib").innerText().catch(() => "");
r.pipeline.actorLibText = alText.slice(0, 120);
r.pipeline.actorLibHasModelLib = /模特库/.test(alText);
openedActorLib = await p.locator(".actorlib").count() > 0;
if (openedActorLib) await p.screenshot({ path: path.join(OUT, "actor-library.png"), fullPage: true });
}
r.pipeline.openedActorLib = openedActorLib;
await browser.close();
console.log(JSON.stringify(r, null, 2));
fs.writeFileSync(path.join(OUT, "summary.json"), JSON.stringify(r, null, 2));