fix: 清单4 未完成项二轮(视频项目收口/选用角色/场景空镜/场景刷新 + 4:5 尺寸)
ZWQ#15 视频进度卡 3/4(真因补全): - current_stage 没推到 VIDEO 时 settle 守卫直接返回 → 永不收口。 submit/poll/adopt 幂等补推 current_stage→VIDEO;进详情(retrieve)检测全段已出片即自愈收口。 存量卡死项目一打开就自动变「已完成」。+2 测试 ZWQ#7 演员库选用角色未自动「已过审/已采用」: - 真实流程是 attach(选用),非详情弹窗按钮。attach 时显式置 adopt=adopted + 人物立绘自动送审(on_commit)。+1 测试 ZWQ#13 场景出真人致视频被拒(加强): - 场景空镜约束改为显式压过脚本描述里的人物词(「即使上文提到人也只画环境」)。 ZWQ#17 场景生成中刷新跳新窗口: - 正在生成的场景草稿不再落盘 → 生成中刷新页面不会与新实体卡并存成两个窗口。 附:平台套图 4:5 尺寸 1664x2080→1728x2160(≥Seedream 5.0 最小面积 3686400px,否则 ARK 退 400)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -770,7 +770,9 @@ def _ratio_to_volcano_size(ratio: str) -> str:
|
|||||||
"4:3": "2304x1728",
|
"4:3": "2304x1728",
|
||||||
"9:16": "1440x2560",
|
"9:16": "1440x2560",
|
||||||
"16:9": "2560x1440",
|
"16:9": "2560x1440",
|
||||||
"4:5": "1664x2080",
|
# 4:5 必须 ≥ Seedream 5.0 的最小面积 3,686,400 px:旧值 1664x2080=3.46M 会被 ARK 退 400
|
||||||
|
# (InvalidParameter: image size must be at least 3686400 pixels)。1728x2160 精确 4:5 且 3.73M 达标。
|
||||||
|
"4:5": "1728x2160",
|
||||||
}
|
}
|
||||||
r = (ratio or "").strip()
|
r = (ratio or "").strip()
|
||||||
if r in presets:
|
if r in presets:
|
||||||
@@ -1069,12 +1071,16 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
|
|||||||
elif kind == BaseAssetGroup.Kind.PERSON:
|
elif kind == BaseAssetGroup.Kind.PERSON:
|
||||||
gen_prompt = build_person_frontal_prompt(prompt) # 人物立绘:包成「电商真人模特/正面全身/纯色背景」统一模板
|
gen_prompt = build_person_frontal_prompt(prompt) # 人物立绘:包成「电商真人模特/正面全身/纯色背景」统一模板
|
||||||
else:
|
else:
|
||||||
# 场景图 = 空镜。不论 admin「提示词」页的 scene 模板写没写,都强制叠加「无真人」约束:
|
# 场景图 = 空镜。不论 admin「提示词」页的 scene 模板写没写,都强制叠加「无真人」硬约束,并显式压过
|
||||||
# 否则模型常自作主张往场景里塞真人模特,下游用这张场景图生成视频时被火山以
|
# 场景描述里可能出现的人物词(如「主播在客厅」)—— 否则模型会照着画出真人,下游用这张场景图生成视频时
|
||||||
# 「输入图像可能包含真实人物」拒绝(ZWQ#13)。已含「真人」字样则不重复叠加。
|
# 被火山以「输入图像可能包含真实人物」拒绝(ZWQ#13)。已叠加过(含「空镜」)则不重复。
|
||||||
gen_prompt = render_prompt("scene", "{场景描述}", 场景描述=(prompt or "").strip())
|
gen_prompt = render_prompt("scene", "{场景描述}", 场景描述=(prompt or "").strip())
|
||||||
if "真人" not in gen_prompt:
|
if "空镜" not in gen_prompt:
|
||||||
gen_prompt = f"{gen_prompt},空镜场景、只有环境与陈设,画面中不要出现任何真人 / 人物 / 模特 / 人脸 / 人手,干净构图"
|
gen_prompt = (
|
||||||
|
f"{gen_prompt}。"
|
||||||
|
"重要约束:这是一张空镜场景图,画面里绝对不要出现任何人物 / 真人 / 模特 / 人脸 / 人手 / 人影;"
|
||||||
|
"即使上文描述里提到了人,也只画对应的环境、空间与陈设,把人物完全省略。干净构图,9:16 竖屏。"
|
||||||
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt,
|
"model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt,
|
||||||
"kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "",
|
"kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "",
|
||||||
|
|||||||
@@ -648,6 +648,30 @@ class ProjectApiTests(TestCase):
|
|||||||
self.assertEqual(project.current_stage, ProjectStage.Stage.VIDEO)
|
self.assertEqual(project.current_stage, ProjectStage.Stage.VIDEO)
|
||||||
self.assertEqual(project.status, Project.Status.COMPLETED)
|
self.assertEqual(project.status, Project.Status.COMPLETED)
|
||||||
|
|
||||||
|
def test_retrieve_recovers_stuck_project_to_completed(self):
|
||||||
|
"""ZWQ#15(存量项目):已经卡在 storyboard 阶段、视频段都出片采用的历史项目,
|
||||||
|
进入详情(GET)时应自动补推阶段并收口成 completed,不必用户再做任何操作。"""
|
||||||
|
project = Project.objects.create(
|
||||||
|
team=self.team, created_by=self.user, product=self.product, name="P",
|
||||||
|
current_stage=ProjectStage.Stage.STORYBOARD, status=Project.Status.STORYBOARDING,
|
||||||
|
)
|
||||||
|
for i in range(2):
|
||||||
|
seg = VideoSegment.objects.create(project=project, sort_order=i, target_duration_seconds=15)
|
||||||
|
asset = Asset.objects.create(
|
||||||
|
team=self.team, created_by=self.user, name=f"clip{i}",
|
||||||
|
asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.VIDEO_CLIP,
|
||||||
|
)
|
||||||
|
ver = VideoSegmentVersion.objects.create(video_segment=seg, asset=asset, is_adopted=True)
|
||||||
|
seg.adopted_version = ver
|
||||||
|
seg.status = VideoSegment.Status.SUCCEEDED
|
||||||
|
seg.save(update_fields=["adopted_version", "status"])
|
||||||
|
|
||||||
|
res = self.client.get(f"/api/projects/{project.id}/")
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
project.refresh_from_db()
|
||||||
|
self.assertEqual(project.current_stage, ProjectStage.Stage.VIDEO)
|
||||||
|
self.assertEqual(project.status, Project.Status.COMPLETED)
|
||||||
|
|
||||||
def _make_audio_model(self):
|
def _make_audio_model(self):
|
||||||
return ModelConfig.objects.create(
|
return ModelConfig.objects.create(
|
||||||
provider=self.provider, name="doubao-voice-bigtts", display_name="豆包语音合成",
|
provider=self.provider, name="doubao-voice-bigtts", display_name="豆包语音合成",
|
||||||
@@ -975,3 +999,31 @@ class AttachOfficialModelTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(res.status_code, 404)
|
self.assertEqual(res.status_code, 404)
|
||||||
self.assertEqual(self.BaseAssetGroup.objects.filter(project=self.proj).count(), before)
|
self.assertEqual(self.BaseAssetGroup.objects.filter(project=self.proj).count(), before)
|
||||||
|
|
||||||
|
def test_attach_own_actor_auto_adopts_and_submits_review(self):
|
||||||
|
"""ZWQ#7:在演员库选用「自己创建的角色」(本团队 person 资产)后,组应自动标 adopt=adopted(已采用),
|
||||||
|
且自动送审(已过审),不必用户再手动点采用 / 灰盾。"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from apps.assets.models import Asset, AssetFile
|
||||||
|
|
||||||
|
mine = Asset.objects.create(
|
||||||
|
team=self.team, created_by=self.user, name="我的主播",
|
||||||
|
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON,
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(asset=mine, object_key="me/host.png", bucket="b", content_type="image/png", preview_url="http://x/host.png", is_primary=True)
|
||||||
|
group = self.BaseAssetGroup.objects.create(project=self.proj, kind=self.BaseAssetGroup.Kind.PERSON, metadata={"label": "主播"})
|
||||||
|
|
||||||
|
with patch("apps.assets.review.submit_asset_for_review") as sub:
|
||||||
|
with self.captureOnCommitCallbacks(execute=True):
|
||||||
|
res = self.client.post(
|
||||||
|
f"/api/projects/{self.proj.id}/attach-base-asset/",
|
||||||
|
{"group_id": str(group.id), "asset_id": str(mine.id)},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 200, res.content)
|
||||||
|
group.refresh_from_db()
|
||||||
|
self.assertEqual(group.adopted_asset_id, mine.id)
|
||||||
|
self.assertEqual((group.metadata or {}).get("adopt"), "adopted") # 自动采用
|
||||||
|
sub.assert_called_once() # 自动送审(on_commit 在 TestCase 事务提交点触发)
|
||||||
|
self.assertEqual(sub.call_args.args[0].id, mine.id)
|
||||||
|
|||||||
@@ -238,15 +238,20 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
# 用轻量查询判定 + 同步,再交给 super 做重 prefetch 序列化(避免重查询跑两遍)。
|
# 用轻量查询判定 + 同步,再交给 super 做重 prefetch 序列化(避免重查询跑两遍)。
|
||||||
proj = (
|
proj = (
|
||||||
Project.objects.filter(pk=kwargs.get("pk"), **{self.team_field: self.get_team()})
|
Project.objects.filter(pk=kwargs.get("pk"), **{self.team_field: self.get_team()})
|
||||||
.only("id", "current_stage")
|
.only("id", "current_stage", "status")
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if proj is not None and proj.current_stage == ProjectStage.Stage.VIDEO:
|
if proj is not None:
|
||||||
adopted_script = proj.script_versions.filter(is_adopted=True).order_by("-created_at").first()
|
if proj.current_stage == ProjectStage.Stage.VIDEO:
|
||||||
if adopted_script is not None:
|
adopted_script = proj.script_versions.filter(is_adopted=True).order_by("-created_at").first()
|
||||||
self._sync_video_segments_to_script(proj, adopted_script)
|
if adopted_script is not None:
|
||||||
# 自愈完成态:历史项目「全部片段已出片」却卡在 videoing(导出藏起来后没有环节翻状态),
|
self._sync_video_segments_to_script(proj, adopted_script)
|
||||||
# 进详情时收口成 completed;若有段被重跑则退回 videoing。
|
# 自愈卡阶段:历史项目「视频片段全部出片并采用」却因 current_stage 没推到 VIDEO 而永远停在
|
||||||
|
# 「3/4 故事板」、不收口(ZWQ#15)。进详情时若检测到全段已出片,就补推到视频阶段再收口。
|
||||||
|
segs = list(VideoSegment.objects.filter(project=proj).values_list("status", "adopted_version_id"))
|
||||||
|
if segs and all(s == VideoSegment.Status.SUCCEEDED and a is not None for s, a in segs):
|
||||||
|
_ensure_video_stage(proj)
|
||||||
|
# 自愈完成态:全部片段已出片却卡在 videoing → 收口成 completed;若有段被重跑则退回 videoing。
|
||||||
settle_video_completion(proj)
|
settle_video_completion(proj)
|
||||||
return super().retrieve(request, *args, **kwargs)
|
return super().retrieve(request, *args, **kwargs)
|
||||||
|
|
||||||
@@ -580,7 +585,17 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
group = BaseAssetGroup.objects.select_for_update().get(id=group.id)
|
group = BaseAssetGroup.objects.select_for_update().get(id=group.id)
|
||||||
group.candidate_assets.add(asset)
|
group.candidate_assets.add(asset)
|
||||||
group.adopted_asset_id = asset.id
|
group.adopted_asset_id = asset.id
|
||||||
group.save(update_fields=["adopted_asset", "updated_at"])
|
# 选用/替换即「采用」:显式置 metadata.adopt=adopted,让卡片直接显「已采用」,不必用户再手点采用(ZWQ#7)。
|
||||||
|
meta = dict(group.metadata or {})
|
||||||
|
meta["adopt"] = "adopted"
|
||||||
|
group.metadata = meta
|
||||||
|
group.save(update_fields=["adopted_asset", "metadata", "updated_at"])
|
||||||
|
# 人物立绘/三视图属送审类:选用后自动送审(变绿盾「已过审」),不必用户再手点灰盾(ZWQ#7)。
|
||||||
|
# 仅当还没过审时提交;best-effort,审核服务不可用也不阻断采用。on_commit 确保资产已落库再送审。
|
||||||
|
if asset.category in Asset.REVIEW_CATEGORIES and asset.review_status != "active":
|
||||||
|
from apps.assets.review import submit_asset_for_review
|
||||||
|
|
||||||
|
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
|
||||||
promote_base_asset_stage_if_ready(project)
|
promote_base_asset_stage_if_ready(project)
|
||||||
return Response(BaseAssetGroupSerializer(group).data)
|
return Response(BaseAssetGroupSerializer(group).data)
|
||||||
|
|
||||||
|
|||||||
@@ -614,11 +614,14 @@ export function PipelinePage(props: {
|
|||||||
// 出图成功后从草稿列表移除(生成结果会作为实体卡出现在下方)。不直接生成,避免一点就出一张默认图。
|
// 出图成功后从草稿列表移除(生成结果会作为实体卡出现在下方)。不直接生成,避免一点就出一张默认图。
|
||||||
// 持久化到 localStorage(按项目隔离):新增场景没点 AI 生成就刷新页面也不会丢(ZWQ#10)。
|
// 持久化到 localStorage(按项目隔离):新增场景没点 AI 生成就刷新页面也不会丢(ZWQ#10)。
|
||||||
const sceneDraftsKey = `airshelf:scenedrafts:${project.id}`;
|
const sceneDraftsKey = `airshelf:scenedrafts:${project.id}`;
|
||||||
const [sceneDrafts, setSceneDrafts] = useState<Array<{ id: string; title: string; prompt: string }>>(() => {
|
type SceneDraft = { id: string; title: string; prompt: string; generating?: boolean };
|
||||||
try { const raw = localStorage.getItem(sceneDraftsKey); return raw ? (JSON.parse(raw) as Array<{ id: string; title: string; prompt: string }>) : []; } catch { return []; }
|
const [sceneDrafts, setSceneDrafts] = useState<SceneDraft[]>(() => {
|
||||||
|
try { const raw = localStorage.getItem(sceneDraftsKey); return raw ? (JSON.parse(raw) as SceneDraft[]).map((d) => ({ ...d, generating: false })) : []; } catch { return []; }
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try { localStorage.setItem(sceneDraftsKey, JSON.stringify(sceneDrafts)); } catch { /* localStorage 不可用时静默降级 */ }
|
// 只持久化「还没点 AI 生成」的草稿;正在生成的草稿不落盘——否则生成中刷新页面后,
|
||||||
|
// 这张草稿会和生成完成后新出现的实体卡并存成两个窗口(ZWQ#17)。
|
||||||
|
try { localStorage.setItem(sceneDraftsKey, JSON.stringify(sceneDrafts.filter((d) => !d.generating))); } catch { /* localStorage 不可用时静默降级 */ }
|
||||||
}, [sceneDrafts, sceneDraftsKey]);
|
}, [sceneDrafts, sceneDraftsKey]);
|
||||||
// 行39 · 商品三视图:点「AI 生成三视图」展开卡片右侧预览面板(对齐原版 prod-preview,非弹窗)
|
// 行39 · 商品三视图:点「AI 生成三视图」展开卡片右侧预览面板(对齐原版 prod-preview,非弹窗)
|
||||||
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
|
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
|
||||||
@@ -3029,7 +3032,14 @@ export function PipelinePage(props: {
|
|||||||
<div className="hstack" style={{ marginTop: 10 }}>
|
<div className="hstack" style={{ marginTop: 10 }}>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => setSceneDrafts((list) => list.filter((d) => d.id !== draft.id))}>移除</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => setSceneDrafts((list) => list.filter((d) => d.id !== draft.id))}>移除</button>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={async () => { const p = draft.prompt.trim() || genPrompt; const res = await genBaseAsset("scene", p, draft.title.trim() || undefined, dk); if (res && (res.id || res.adopted_asset)) setSceneDrafts((list) => list.filter((d) => d.id !== draft.id)); }}>{busy ? "生成中…" : "AI 生成"}</button>
|
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={async () => {
|
||||||
|
const p = draft.prompt.trim() || genPrompt;
|
||||||
|
// 一启动就标 generating(此后不再落盘):生成中刷新页面不会留下这张草稿 → 不会和新实体卡并存(ZWQ#17)
|
||||||
|
setSceneDrafts((list) => list.map((d) => d.id === draft.id ? { ...d, generating: true } : d));
|
||||||
|
const res = await genBaseAsset("scene", p, draft.title.trim() || undefined, dk);
|
||||||
|
if (res && (res.id || res.adopted_asset)) setSceneDrafts((list) => list.filter((d) => d.id !== draft.id)); // 成功:结果已成实体卡,移除草稿
|
||||||
|
else setSceneDrafts((list) => list.map((d) => d.id === draft.id ? { ...d, generating: false } : d)); // 失败:恢复草稿可重试
|
||||||
|
}}>{busy ? "生成中…" : "AI 生成"}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user