模特库标签分页与全能创作收口:藏长视频、选择器分页

角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
This commit is contained in:
Azmat@qq.com
2026-09-21 16:24:37 +08:00
parent 690eb3d843
commit 0bd1db6bf9
48 changed files with 3386 additions and 896 deletions
+113
View File
@@ -0,0 +1,113 @@
"""角色库文件名 → 模特标签。
文件名形如「警察-男性-日本-年轻男性.png」「动画角色-齿轮蝠.png」。
文件夹名(医生/警察/动画角色…)作为职业/分类标签;性别归一成「男人」「女人」。
"""
from __future__ import annotations
import re
from pathlib import Path
GENDER_TOKENS = {
"男性": "男人",
"男童": "男人",
"年轻男性": "男人",
"中年男性": "男人",
"老年男性": "男人",
"青少年男性": "男人",
"女性": "女人",
"女童": "女人",
"少女": "女人",
"年轻女性": "女人",
"中年女性": "女人",
"老年女性": "女人",
"青少年女性": "女人",
"成年女性": "女人",
}
AGE_TOKENS = ("年轻", "中年", "老年", "青少年", "", "成年")
# 文件名里常带的噪声后缀,不当标签
NOISE = {"面孔", "中国面孔", "欧美面孔", "亚洲面孔", "非洲面孔", "美国面孔", "印度面孔"}
def _split_tokens(stem: str) -> list[str]:
parts = re.split(r"[-_·//]+", stem)
return [p.strip() for p in parts if p and p.strip()]
def parse_character_tags(*, folder: str, filename: str) -> list[str]:
"""从文件夹 + 文件名解析去重后的标签列表(顺序:分类 → 性别 → 其余)。"""
stem = Path(filename).stem
tokens = _split_tokens(stem)
folder_tag = folder.strip()
tags: list[str] = []
seen: set[str] = set()
def add(tag: str) -> None:
tag = tag.strip()
if not tag or tag in seen or tag in NOISE:
return
seen.add(tag)
tags.append(tag)
if folder_tag:
add(folder_tag)
# 性别优先归一
for tok in tokens:
if tok in GENDER_TOKENS:
add(GENDER_TOKENS[tok])
break
for key, gender in GENDER_TOKENS.items():
if key in tok:
add(gender)
break
for tok in tokens:
if tok == folder_tag:
continue
if tok in GENDER_TOKENS:
continue
# 年龄词单独保留简写
age_hit = next((a for a in AGE_TOKENS if a in tok and tok not in GENDER_TOKENS), None)
if age_hit and tok in GENDER_TOKENS.values():
continue
if tok in NOISE:
continue
# 去掉已归一进性别的复合词本体
if any(g in tok for g in ("男性", "女性", "男童", "女童", "少女")):
for a in AGE_TOKENS:
if a in tok:
add(a)
break
continue
add(tok)
return tags
def display_name_from_file(*, folder: str, filename: str) -> str:
stem = Path(filename).stem
parts = _split_tokens(stem)
if not parts:
return folder or "角色"
# 动画角色:「动画角色-齿轮蝠」→ 齿轮蝠
if folder == "动画角色" and len(parts) >= 2:
return parts[-1][:64]
body = parts[1:] if parts[0] in {folder, "民族服饰"} and len(parts) > 1 else parts
# 去掉纯性别词,保留「中年女性」这类年龄+性别复合,以及地区
cleaned: list[str] = []
for tok in body:
if tok in {"男性", "女性"}:
continue
cleaned.append(tok.replace("面孔", ""))
label = "·".join(t for t in cleaned if t) or stem
if folder and folder not in {"动画角色"}:
return f"{folder}·{label}"[:64]
return label[:64]
def import_key(relative_path: str) -> str:
return f"character_pack:{relative_path.replace(chr(92), '/')}"
@@ -0,0 +1,157 @@
"""把本地「角色库」文件夹批量导入为官方模特(带标签)。
用法:
python manage.py import_character_pack "/Users/Admin/Downloads/角色库"
python manage.py import_character_pack "/path" --dry-run
python manage.py import_character_pack "/path" --team-owner admin
幂等:同一相对路径(metadata.import_key)不会重复导入。
"""
from __future__ import annotations
import mimetypes
import uuid
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import Team, TeamMember, User
from apps.assets.character_tags import display_name_from_file, import_key, parse_character_tags
from apps.assets.models import Asset, AssetFile, Model
from apps.assets.storage import TosStorage
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
class Command(BaseCommand):
help = "Import a local character-pack folder into official Model library with tags"
def add_arguments(self, parser):
parser.add_argument("folder", type=str, help="角色库根目录")
parser.add_argument("--dry-run", action="store_true", help="只解析标签,不上传")
parser.add_argument("--team-owner", default="admin", help="托管官方模特的团队 owner 用户名")
parser.add_argument("--limit", type=int, default=0, help="最多导入 N 张(0=全部)")
def handle(self, *args, **options):
root = Path(options["folder"]).expanduser().resolve()
if not root.is_dir():
raise CommandError(f"目录不存在: {root}")
owner = User.objects.filter(username=options["team_owner"]).first()
if owner is None:
raise CommandError(f"找不到用户 {options['team_owner']}")
team = (
Team.objects.filter(owner=owner, is_personal=True).order_by("created_at").first()
or Team.objects.filter(owner=owner).order_by("created_at").first()
)
if team is None:
team = Team.objects.create(name=f"{owner.username}-official", owner=owner, is_personal=True)
TeamMember.objects.create(team=team, user=owner, role=TeamMember.Role.OWNER)
self.stdout.write(f"已创建托管团队 {team.name}")
files = sorted(
p for p in root.rglob("*")
if p.is_file() and p.suffix.lower() in IMAGE_SUFFIXES and p.name != ".DS_Store"
)
if options["limit"]:
files = files[: options["limit"]]
if not files:
raise CommandError("目录里没有图片")
storage = None if options["dry_run"] else TosStorage()
created = skipped = failed = 0
for path in files:
rel = str(path.relative_to(root)).replace("\\", "/")
folder = path.parent.name if path.parent != root else ""
tags = parse_character_tags(folder=folder, filename=path.name)
name = display_name_from_file(folder=folder, filename=path.name)
key = import_key(rel)
existing = Model.objects.filter(
is_deleted=False,
metadata__import_key=key,
).first()
if existing:
skipped += 1
self.stdout.write(f"skip {rel} tags={tags}")
continue
self.stdout.write(f"{'dry' if options['dry_run'] else 'add '} {rel}{name} tags={tags}")
if options["dry_run"]:
created += 1
continue
try:
self._create_one(
storage=storage,
team=team,
owner=owner,
path=path,
name=name,
tags=tags,
key=key,
folder=folder,
rel=rel,
)
created += 1
except Exception as exc: # noqa: BLE001
failed += 1
self.stderr.write(self.style.ERROR(f"fail {rel}: {exc}"))
self.stdout.write(self.style.SUCCESS(
f"done created={created} skipped={skipped} failed={failed} team={team.id}"
))
def _create_one(self, *, storage, team, owner, path: Path, name: str, tags: list[str], key: str, folder: str, rel: str):
asset_id = uuid.uuid4()
suffix = path.suffix.lower() or ".png"
object_key = f"teams/{team.id}/models/official/{asset_id}{suffix}"
content_type = mimetypes.guess_type(path.name)[0] or "image/png"
with path.open("rb") as fh:
stored = storage.upload_fileobj(
fileobj=fh,
object_key=object_key,
content_type=content_type,
)
with transaction.atomic():
portrait = Asset.objects.create(
id=asset_id,
team=team,
created_by=owner,
name=f"{name}·形象图",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.MODEL_PORTRAIT,
metadata={
"kind": "model",
"view": "frontal",
"source": "character_pack",
"tags": tags,
},
)
AssetFile.objects.create(
asset=portrait,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
Model.objects.create(
team=team,
created_by=owner,
name=name,
is_official=True,
source=Model.Source.UPLOAD,
portrait_asset=portrait,
description=f"角色库 · {folder}" if folder else "角色库",
metadata={
"source": "character_pack",
"tags": tags,
"import_key": key,
"folder": folder,
"relative_path": rel,
},
)
@@ -0,0 +1,29 @@
# Generated by Django 5.1.15 on 2026-09-21 08:04
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0010_asset_model_purged_at'),
]
operations = [
migrations.CreateModel(
name='ModelLibraryTag',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=64, unique=True)),
('label', models.CharField(blank=True, max_length=64)),
('visible', models.BooleanField(default=True)),
('sort', models.IntegerField(default=0)),
],
options={
'ordering': ['sort', 'name'],
},
),
]
@@ -0,0 +1,144 @@
"""模特库标签目录:从 Model.metadata.tags 聚合 + 与 ModelLibraryTag 同步。"""
from __future__ import annotations
from collections import Counter
from django.db.models import Q
from .models import Model, ModelLibraryTag
# 前台默认优先露出的粗粒度标签(即使 count 偏低也显示)
PREFERRED = (
"男人", "女人", "动画角色", "学生", "医生", "警察", "法官", "厨师",
"民族", "民族服饰", "中国",
)
# 首次入库:出现次数低于此值且不在 PREFERRED → 默认隐藏
DEFAULT_VISIBLE_MIN_COUNT = 3
def aggregate_tag_counts(*, official_only: bool = False, mine_team_id=None) -> dict[str, int]:
qs = Model.objects.filter(is_deleted=False, purged_at__isnull=True)
if official_only:
qs = qs.filter(is_official=True)
elif mine_team_id is not None:
qs = qs.filter(is_official=False, team_id=mine_team_id)
counts: Counter[str] = Counter()
for md in qs.values_list("metadata", flat=True):
tags = (md or {}).get("tags") if isinstance(md, dict) else None
if not isinstance(tags, list):
continue
for tag in tags:
if isinstance(tag, str) and tag.strip():
counts[tag.strip()] += 1
return dict(counts)
def sync_tag_catalog(*, create_missing: bool = True) -> list[ModelLibraryTag]:
"""把库里出现过的标签写入目录;已有行不改 visible/sort/label。"""
counts = aggregate_tag_counts()
existing = {t.name: t for t in ModelLibraryTag.objects.all()}
created: list[ModelLibraryTag] = []
if create_missing:
for name, count in counts.items():
if name in existing:
continue
visible = name in PREFERRED or count >= DEFAULT_VISIBLE_MIN_COUNT
sort = PREFERRED.index(name) if name in PREFERRED else 100 + (0 if count >= 10 else 50)
obj = ModelLibraryTag.objects.create(name=name, visible=visible, sort=sort)
existing[name] = obj
created.append(obj)
return created
def public_tag_rows(*, tab: str | None = None, team_id=None) -> list[dict]:
"""前台筛选条:只返回 visible=True 的标签 + 当前范围计数。"""
sync_tag_catalog(create_missing=True)
if tab == "official":
counts = aggregate_tag_counts(official_only=True)
elif tab == "mine":
counts = aggregate_tag_counts(mine_team_id=team_id)
else:
counts = aggregate_tag_counts()
rows = []
for tag in ModelLibraryTag.objects.filter(visible=True).order_by("sort", "name"):
c = counts.get(tag.name, 0)
if c <= 0:
continue
rows.append({"name": tag.name, "label": tag.display_name, "count": c})
return rows
def rename_tag_everywhere(old: str, new: str) -> int:
old, new = old.strip(), new.strip()
if not old or not new or old == new:
return 0
updated = 0
qs = Model.objects.filter(metadata__tags__contains=[old])
for m in qs.iterator():
md = dict(m.metadata or {})
tags = md.get("tags")
if not isinstance(tags, list):
continue
nxt: list[str] = []
seen: set[str] = set()
for t in tags:
if not isinstance(t, str):
continue
v = new if t.strip() == old else t.strip()
if not v or v in seen:
continue
seen.add(v)
nxt.append(v)
if nxt != tags:
md["tags"] = nxt
m.metadata = md
m.save(update_fields=["metadata", "updated_at"])
updated += 1
# catalog row
src = ModelLibraryTag.objects.filter(name=old).first()
dst = ModelLibraryTag.objects.filter(name=new).first()
if src and dst:
src.delete()
elif src:
src.name = new
src.save(update_fields=["name", "updated_at"])
return updated
def replace_tag_on_models(old: str, new: str | None) -> int:
"""把 old 换成 new;new=None 表示从模特上移除该标签。"""
if new is None:
new_s = None
else:
new_s = new.strip()
if not new_s:
return 0
old = old.strip()
updated = 0
qs = Model.objects.filter(metadata__tags__contains=[old])
for m in qs.iterator():
md = dict(m.metadata or {})
tags = md.get("tags")
if not isinstance(tags, list):
continue
nxt: list[str] = []
seen: set[str] = set()
for t in tags:
if not isinstance(t, str):
continue
v = t.strip()
if v == old:
if new_s is None:
continue
v = new_s
if not v or v in seen:
continue
seen.add(v)
nxt.append(v)
if nxt != tags:
md["tags"] = nxt
m.metadata = md
m.save(update_fields=["metadata", "updated_at"])
updated += 1
return updated
+23
View File
@@ -118,6 +118,29 @@ class Model(TeamOwnedModel):
return self.name
class ModelLibraryTag(TimeStampedModel):
"""模特库筛选标签目录 · 控制前台芯片显隐 / 排序 / 展示名。
实际标签仍写在 Model.metadata.tags;本表只管「筛选条怎么展示」。
未入表的标签在 sync 时按出现次数决定默认显隐。
"""
name = models.CharField(max_length=64, unique=True)
label = models.CharField(max_length=64, blank=True) # 空则用 name
visible = models.BooleanField(default=True)
sort = models.IntegerField(default=0)
class Meta:
ordering = ["sort", "name"]
def __str__(self) -> str:
return self.label or self.name
@property
def display_name(self) -> str:
return (self.label or self.name).strip() or self.name
class AssetReviewGroup(TimeStampedModel):
"""一团队一火山人像素材组(真人资产审核统一上传到这里,单组可放 500 万)。"""
+40 -9
View File
@@ -888,22 +888,52 @@ class ReviewScopeTests(TestCase):
("person", "model_portrait", "tri_view", "storyboard", "scene", "product_image"),
)
def test_poll_team_reviews_only_scans_review_categories(self):
def test_poll_only_covers_assets_that_really_reached_volcano(self):
"""轮询的判据是「拿到了火山远端 Id」,不是类别。
force 送审的上传视频、临时图也会是 processing,worker 必须盯到绿/红;
反过来,没有远端 Id 的 processing 是没真正送出去的,轮它只会白打请求。
送审范围仍然只挑 REVIEW_CATEGORIES(见下一条)。
"""
from apps.assets import review
_, team = _mk_team("uc", "TeamC")
keep = {}
polled = {}
for cat in ("person", "model_portrait", "tri_view", "storyboard", "scene", "product_image"):
a = Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="processing")
keep[cat] = str(a.id)
# 图片趴:不应进送审队列
for cat in ("model_tryon", "platform_kit", "free_create"):
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="processing")
category=cat, review_status="processing",
review_remote_id=f"remote-{cat}")
polled[cat] = str(a.id)
# 图片趴不在送审范围里,但已经 force 送出去的同样要盯到终态。
forced = Asset.objects.create(
team=team, name="force", asset_type="image", source="ai_generated",
category="free_create", review_status="processing", review_remote_id="remote-force",
)
# 没有远端 Id = 没真送出去,不该被轮询。
Asset.objects.create(team=team, name="没送出去", asset_type="image", source="ai_generated",
category="person", review_status="processing")
with patch("apps.assets.review.assets_client.is_enabled", return_value=False):
out = review.poll_team_reviews(team)
self.assertEqual(set(out.keys()), set(keep.values()))
self.assertEqual(set(out.keys()), set(polled.values()) | {str(forced.id)})
def test_submission_still_only_covers_review_categories(self):
from apps.assets import review
_, team = _mk_team("uc-sub", "TeamCSub")
for cat in ("person", "product_image"):
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="")
for cat in ("model_tryon", "platform_kit", "free_create"):
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="")
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub:
review.submit_unsubmitted_reviews(team=team)
submitted = {asset.category for (asset,), _ in (call for call in sub.call_args_list)}
self.assertEqual(submitted, {"person", "product_image"})
def test_poll_team_reviews_auto_submits_unsubmitted(self):
from apps.assets import review
@@ -931,6 +961,7 @@ class ReviewScopeTests(TestCase):
proc = Asset.objects.create(
team=team, name="审中", asset_type="image", source="ai_generated",
category=Asset.Category.PERSON, review_status="processing",
review_remote_id="remote-proc", # 只有真的送到火山、拿到 Id 的才会被轮询
)
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub, patch(
"apps.assets.review.poll_asset_review", return_value="active"
+47 -4
View File
@@ -43,6 +43,15 @@ _KNOWN_CATS = [
]
def _not_omni_create_q() -> Q:
"""「不是全能创作产出」。不能只写 ~Q(metadata__feature="omni_create")。
metadata 里没有 feature 键时取值是 SQL NULLNOT(NULL = 'x') 还是 NULL,整行会被筛掉 ——
历史资产(没写过 feature)会从「视频自由创作」「自由创作」「素材」这些分桶里整批消失。
"""
return Q(metadata__feature__isnull=True) | ~Q(metadata__feature="omni_create")
def _tab_q(tab: str) -> Q:
if tab == "people":
return Q(category="person")
@@ -57,13 +66,13 @@ def _tab_q(tab: str) -> Q:
if tab == "image_creations": # 图片自由创作
return Q(category="free_create", asset_type="image")
if tab == "video_creations": # 视频自由创作
return Q(category="free_create", asset_type="video") & ~Q(metadata__feature="omni_create")
return Q(category="free_create", asset_type="video") & _not_omni_create_q()
if tab == "creations": # 自由创作(兼容旧入口:图片+视频)
return Q(category="free_create") & ~Q(metadata__feature="omni_create")
return Q(category="free_create") & _not_omni_create_q()
if tab == "uploads":
return Q(category="upload")
if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列)
return ~Q(category="final_video") & Q(asset_type="video") & ~Q(metadata__feature="omni_create")
return ~Q(category="final_video") & Q(asset_type="video") & _not_omni_create_q()
if tab == "others": # 其他(资产库成品化):我的上传 + 未归类非视频(兜底)
return Q(category="upload") | (~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video"))
if tab == "unclassified": # 未归类且非视频(也不含最终成片)
@@ -482,6 +491,14 @@ class ModelLibraryViewSet(ModelViewSet):
if self.request.query_params.get("q"):
q = self.request.query_params["q"]
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q))
# 标签筛选: ?tag=男人 或 ?tag=男人&tag=警察(多标签 AND)
raw_tags = self.request.query_params.getlist("tag") or []
if not raw_tags:
joined = (self.request.query_params.get("tags") or "").strip()
if joined:
raw_tags = [t.strip() for t in joined.split(",") if t.strip()]
for tag in raw_tags:
qs = qs.filter(metadata__tags__contains=[tag])
# 官方模板靠前,再按新→旧
return qs.order_by("-is_official", "-created_at")
@@ -605,6 +622,18 @@ class ModelLibraryViewSet(ModelViewSet):
model.save(update_fields=["purged_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["get"], url_path="tags")
def tags(self, request):
"""当前可见模特里出现过的标签(供前端筛选条)· 只返回后台标记为显示的。"""
from .model_library_tags import public_tag_rows
tab = (request.query_params.get("tab") or "").strip() or None
if tab not in (None, "official", "mine"):
tab = None
team = self.get_team()
items = public_tag_rows(tab=tab, team_id=getattr(team, "id", None))
return Response({"results": items})
@action(detail=False, methods=["post"], url_path="upload", parser_classes=[MultiPartParser, FormParser])
def upload(self, request):
"""真人上传:一张人像图 → 建 model_portrait 资产 + Model(source=upload)。三视图/声线后续补。"""
@@ -640,13 +669,27 @@ class ModelLibraryViewSet(ModelViewSet):
size_bytes=stored.size_bytes,
is_primary=True,
)
raw_tags = request.data.get("tags")
tags: list[str] = []
if isinstance(raw_tags, str) and raw_tags.strip():
import json
try:
parsed = json.loads(raw_tags)
if isinstance(parsed, list):
tags = [str(t).strip() for t in parsed if str(t).strip()]
else:
tags = [t.strip() for t in raw_tags.split(",") if t.strip()]
except Exception:
tags = [t.strip() for t in raw_tags.split(",") if t.strip()]
elif isinstance(raw_tags, list):
tags = [str(t).strip() for t in raw_tags if str(t).strip()]
model = Model.objects.create(
team=team,
created_by=request.user,
name=name,
source=Model.Source.UPLOAD,
portrait_asset=portrait,
metadata={"source": "upload"},
metadata={"source": "upload", "tags": tags},
)
return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED)