模特库标签分页与全能创作收口:藏长视频、选择器分页
角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
This commit is contained in:
@@ -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
|
||||
@@ -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 万)。"""
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -43,6 +43,15 @@ _KNOWN_CATS = [
|
||||
]
|
||||
|
||||
|
||||
def _not_omni_create_q() -> Q:
|
||||
"""「不是全能创作产出」。不能只写 ~Q(metadata__feature="omni_create")。
|
||||
|
||||
metadata 里没有 feature 键时取值是 SQL NULL,NOT(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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user