feat: add style kit production standards
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { dbRun, withTransaction } from "../server/db.mjs";
|
||||
|
||||
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
|
||||
const projectRoot = resolve(import.meta.dirname, "..");
|
||||
const organizationId = "org-studio-lab";
|
||||
const workspaceId = "ws-local-aidrama";
|
||||
const projectId = "thunder-mouth";
|
||||
const northstarScope = {
|
||||
"x-organization-id": "org-northstar",
|
||||
"x-workspace-id": "ws-northstar-main",
|
||||
"x-project-id": "northstar-pilot"
|
||||
};
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${api}${path}`, {
|
||||
...options,
|
||||
headers: { "content-type": "application/json", ...(options.headers || {}) }
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
return { response, payload };
|
||||
}
|
||||
|
||||
function expectOk(result, label) {
|
||||
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function login(email) {
|
||||
const result = expectOk(await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password: "Demo@123456" })
|
||||
}), `${email} login`);
|
||||
return { authorization: `Bearer ${result.session.token}` };
|
||||
}
|
||||
|
||||
const ownerAuth = await login("producer@local.test");
|
||||
const writerAuth = await login("writer@local.test");
|
||||
const northstarAuth = await login("producer2@local.test");
|
||||
const headers = { ...ownerAuth, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId };
|
||||
const writerHeaders = { ...writerAuth, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId };
|
||||
const northstarHeaders = { ...northstarAuth, ...northstarScope };
|
||||
const createdStyleKitIds = [];
|
||||
const createdJobIds = [];
|
||||
|
||||
try {
|
||||
const initial = expectOk(await request("/api/style-kits", { headers }), "list seeded style kits");
|
||||
assert.ok(initial.kits.some((kit) => kit.id === "stylekit-studio-guoman-vertical"), "seeded studio style kit must be visible");
|
||||
assert.equal(initial.effective.styleKit.id, "stylekit-studio-guoman-vertical", "thunder-mouth must inherit seeded style kit binding");
|
||||
assert.equal(initial.effective.binding.projectId, projectId, "effective binding must belong to current project");
|
||||
assert.equal(initial.effective.styleKit.voicePolicy.forbidRandomNativeVoice, true, "fixed voice policy must forbid random native voice as final");
|
||||
|
||||
const writerRead = await request("/api/style-kits", { headers: writerHeaders });
|
||||
assert.equal(writerRead.response.status, 200, "writer should read effective style kit for generation");
|
||||
const writerCreate = await request("/api/style-kits", {
|
||||
method: "POST",
|
||||
headers: writerHeaders,
|
||||
body: JSON.stringify({ name: "编剧不应创建", scopeMode: "workspace" })
|
||||
});
|
||||
assert.equal(writerCreate.response.status, 403, "writer must not manage style kits");
|
||||
|
||||
const styleKitId = `stylekit-smoke-${Date.now()}`;
|
||||
const created = expectOk(await request("/api/style-kits", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
id: styleKitId,
|
||||
name: "Smoke 商用风格标准",
|
||||
scopeMode: "workspace",
|
||||
status: "active",
|
||||
description: "用于 smoke 验收的本地商用风格标准。",
|
||||
visualProfile: {
|
||||
styleFamily: "原创国漫 2D 动画",
|
||||
cameraLanguage: "稳定中景,单一完整画面",
|
||||
continuityLocks: ["character", "costume", "location", "prop", "weather", "camera"]
|
||||
},
|
||||
subtitlePreset: { language: "zh-CN", position: "bottom-safe-area", maxCharsPerLine: 17 },
|
||||
voicePolicy: { finalVoiceMode: "fixed-reference-required", forbidRandomNativeVoice: true, fallbackMouthPlan: ["侧脸", "反应镜头"] },
|
||||
deliverySpec: { aspectRatio: "9:16", resolution: "1080x1920", fps: 24, clipDurationSec: [5, 10], exportFormat: "mp4" },
|
||||
promptRules: {
|
||||
positivePrefix: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, smoke standard.",
|
||||
negativeTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "watermark"]
|
||||
},
|
||||
singleFramePolicy: { enabled: true, imageOutputCount: 1, batchSize: 1, forbiddenTerms: ["split-screen", "collage", "contact sheet", "多格"] },
|
||||
namingRules: { shotPattern: "{projectId}/smoke/shot-{shotNumber}-{version}", deliveryPattern: "{projectName}-smoke-{releaseVersion}" },
|
||||
qaPolicy: { gates: ["single-frame", "continuity", "voice-rights", "subtitle-asr-alignment"], minimumReviewScore: 91, requireLockedAssets: true },
|
||||
metadata: { source: "smoke-style-kits" }
|
||||
})
|
||||
}), "create style kit");
|
||||
createdStyleKitIds.push(styleKitId);
|
||||
assert.equal(created.styleKit.id, styleKitId, "created style kit id must be stable");
|
||||
assert.ok(created.styleKit.promptRules.negativeTerms.includes("contact sheet"), "server must normalize single-frame negative terms");
|
||||
assert.equal(created.styleKit.singleFramePolicy.batchSize, 1, "style kit must lock batch size to 1");
|
||||
|
||||
const bound = expectOk(await request(`/api/projects/${encodeURIComponent(projectId)}/style-kit`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ styleKitId, enforcement: "strict", notes: "smoke binds the workspace kit to the current project" })
|
||||
}), "bind style kit to project");
|
||||
assert.equal(bound.effective.styleKit.id, styleKitId, "effective style kit must switch after binding");
|
||||
assert.equal(bound.effective.binding.enforcement, "strict", "binding enforcement must be persisted");
|
||||
|
||||
const effective = expectOk(await request("/api/style-kits/effective", { headers }), "read effective style kit");
|
||||
assert.equal(effective.styleKit.id, styleKitId, "effective endpoint must return project binding");
|
||||
|
||||
const preview = expectOk(await request("/api/jobs/preview", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ kind: "单画面关键帧", shotId: "shot-01", adapter: "owned-model-platform" })
|
||||
}), "preview generation job with style kit");
|
||||
assert.equal(preview.preview.styleKit.id, styleKitId, "generation preview contract must include effective style kit");
|
||||
assert.equal(preview.preview.constraints.imageOutputCount, 1, "generation contract must keep image output count at 1");
|
||||
assert.equal(preview.preview.constraints.batchSize, 1, "generation contract must keep batch size at 1");
|
||||
assert.ok(preview.preview.constraints.negativePromptTerms.includes("contact sheet"), "generation contract must inject single-frame negative terms");
|
||||
assert.equal(preview.preview.constraints.voicePolicy.forbidRandomNativeVoice, true, "generation contract must carry final voice policy");
|
||||
|
||||
const blockedPrompt = await request("/api/jobs/preview", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ kind: "单画面关键帧", shotId: "shot-01", adapter: "owned-model-platform", prompt: "请生成 split-screen 对比图" })
|
||||
});
|
||||
assert.equal(blockedPrompt.response.status, 422, "positive prompt with split-screen must be blocked");
|
||||
assert.equal(blockedPrompt.payload.error, "single_frame_contract_violation", "single-frame violation error code must be explicit");
|
||||
|
||||
const crossOrgBind = await request(`/api/projects/${encodeURIComponent("northstar-pilot")}/style-kit`, {
|
||||
method: "POST",
|
||||
headers: northstarHeaders,
|
||||
body: JSON.stringify({ styleKitId, enforcement: "strict" })
|
||||
});
|
||||
assert.equal(crossOrgBind.response.status, 404, "another organization must not bind this style kit");
|
||||
|
||||
const createdJob = expectOk(await request("/api/jobs", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ kind: "单画面关键帧", shotId: "shot-01", adapter: "owned-model-platform", output: `qa/style-kit/${styleKitId}/frame.json` })
|
||||
}), "create generation job with style kit");
|
||||
createdJobIds.push(createdJob.job.id);
|
||||
assert.equal(createdJob.job.request.styleKit.id, styleKitId, "created job request_json must persist style kit");
|
||||
assert.equal(createdJob.job.request.constraints.styleEnforcement, "project-binding", "created job must record style enforcement source");
|
||||
|
||||
console.log(`style kits smoke passed: ${styleKitId}`);
|
||||
} finally {
|
||||
await request(`/api/projects/${encodeURIComponent(projectId)}/style-kit`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ styleKitId: "stylekit-studio-guoman-vertical", enforcement: "strict", notes: "《雷雨口》商用 MVP 默认继承工作区国漫竖屏生产标准。" })
|
||||
}).catch(() => {});
|
||||
withTransaction(() => {
|
||||
for (const jobId of createdJobIds) {
|
||||
dbRun("DELETE FROM job_dependencies WHERE job_id = ? OR depends_on_job_id = ?", [jobId, jobId]);
|
||||
dbRun("DELETE FROM job_attempts WHERE job_id = ?", [jobId]);
|
||||
dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]);
|
||||
dbRun("DELETE FROM audit_logs WHERE target_type = 'generation_job' AND target_id = ?", [jobId]);
|
||||
dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
|
||||
}
|
||||
for (const styleKitId of createdStyleKitIds) {
|
||||
dbRun("DELETE FROM project_style_kit_bindings WHERE style_kit_id = ?", [styleKitId]);
|
||||
dbRun("DELETE FROM audit_logs WHERE target_type = 'style_kit' AND target_id = ?", [styleKitId]);
|
||||
dbRun("DELETE FROM style_kits WHERE id = ?", [styleKitId]);
|
||||
}
|
||||
});
|
||||
for (const jobId of createdJobIds) {
|
||||
await rm(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user