diff --git a/package.json b/package.json
index 344fd23..9420202 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"smoke:knowledge-rag": "node scripts/smoke-knowledge-rag.mjs",
"smoke:knowledge-governance": "node scripts/smoke-knowledge-governance.mjs",
"smoke:asset-governance": "node scripts/smoke-asset-governance.mjs",
+ "smoke:style-kits": "node scripts/smoke-style-kits.mjs",
"smoke:model-connectors": "node scripts/smoke-model-connectors.mjs",
"smoke:model-approvals": "node scripts/smoke-model-approvals.mjs",
"smoke:model-routing": "node scripts/smoke-model-routing.mjs",
diff --git a/scripts/smoke-all.mjs b/scripts/smoke-all.mjs
index 52def52..59f6c74 100644
--- a/scripts/smoke-all.mjs
+++ b/scripts/smoke-all.mjs
@@ -23,6 +23,7 @@ const scripts = [
"smoke:knowledge-rag",
"smoke:knowledge-governance",
"smoke:asset-governance",
+ "smoke:style-kits",
"smoke:model-connectors",
"smoke:model-approvals",
"smoke:model-routing",
diff --git a/scripts/smoke-style-kits.mjs b/scripts/smoke-style-kits.mjs
new file mode 100644
index 0000000..98f4f0e
--- /dev/null
+++ b/scripts/smoke-style-kits.mjs
@@ -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 });
+ }
+}
diff --git a/server/db.mjs b/server/db.mjs
index d26fd02..b5a830c 100644
--- a/server/db.mjs
+++ b/server/db.mjs
@@ -247,6 +247,8 @@ function seedRoles() {
["task:view", "查看项目协作任务"],
["task:manage", "创建、分派和管理项目协作任务"],
["task:complete", "更新本人负责的协作任务状态"],
+ ["style:read", "查看组织、工作区和项目风格生产标准"],
+ ["style:manage", "管理风格标准、品牌规范和项目绑定"],
["script:edit", "编辑剧本和对白"],
["script:read", "查看剧本、分集和镜头"],
["asset:edit", "编辑角色、场景和道具锁"],
@@ -283,17 +285,17 @@ function seedRoles() {
org_admin: [
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
"workspace:members:manage", "project:create", "project:manage", "project:members:manage",
- "workflow:manage", "task:view", "task:manage", "task:complete", "model:manage", "model:approve", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
+ "workflow:manage", "task:view", "task:manage", "task:complete", "style:read", "style:manage", "model:manage", "model:approve", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
"system:settings:view", "service:health:view", "organization:roles:manage"
],
- producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
- writer: ["script:read", "script:edit", "task:view", "task:complete", "job:create"],
- art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "job:create"],
- voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "job:create"],
- reviewer: ["script:read", "task:view", "task:complete", "qa:review", "voice:approve", "delivery:view"],
- project_guest: ["script:read", "task:view", "delivery:view"],
- project_editor: ["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "task:view", "task:manage", "task:complete", "job:create", "delivery:view"],
- project_viewer: ["script:read", "task:view", "delivery:view"]
+ producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "style:read", "style:manage", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
+ writer: ["script:read", "script:edit", "task:view", "task:complete", "style:read", "job:create"],
+ art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "style:read", "style:manage", "job:create"],
+ voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "style:read", "job:create"],
+ reviewer: ["script:read", "task:view", "task:complete", "style:read", "qa:review", "voice:approve", "delivery:view"],
+ project_guest: ["script:read", "task:view", "style:read", "delivery:view"],
+ project_editor: ["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "task:view", "task:manage", "task:complete", "style:read", "job:create", "delivery:view"],
+ project_viewer: ["script:read", "task:view", "style:read", "delivery:view"]
};
for (const [roleKey, permissionKeys] of Object.entries(rolePermissions)) {
for (const permissionKey of permissionKeys) {
@@ -362,6 +364,7 @@ function commercialEntitlementDefaults(billing = {}) {
["limit.workspaces", "工作区数量", "tenant", 8, "个", 1, "block", { commercialGate: "workspace:create" }],
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
["limit.generation_jobs_monthly", "月度生成任务", "production", clipLimit, "job", 1, "block", { commercialGate: "generation_job:create" }],
+ ["limit.style_kits", "风格生产标准", "production", 24, "套", 1, "block", { commercialGate: "style_kit:create" }],
["limit.storage_gb", "存储容量", "storage", storageGb, "GB", 1, "block", { commercialGate: "storage:write" }],
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
@@ -604,6 +607,179 @@ function seedModelRoutes() {
}
}
+function seedStyleKits() {
+ const timestamp = now();
+ const kits = [
+ {
+ id: "stylekit-studio-guoman-vertical",
+ organizationId: "org-studio-lab",
+ workspaceId: "ws-local-aidrama",
+ projectId: null,
+ scopeMode: "workspace",
+ name: "星河国漫竖屏生产标准",
+ description: "工作区默认商用风格标准:原创国漫 2D、9:16、固定字幕与声线策略,所有关键帧强制单画面。",
+ status: "active",
+ visualProfile: {
+ styleFamily: "国产漫画 / 国漫 2D 动画",
+ linework: "干净线稿、清晰轮廓、轻赛璐璐阴影",
+ colorScript: "雷雨青绿、暖肤色、低饱和环境光",
+ cameraLanguage: "稳定中景、反应镜头、环境插入镜头优先",
+ continuityLocks: ["character", "costume", "location", "prop", "weather", "camera"]
+ },
+ subtitlePreset: {
+ language: "zh-CN",
+ fontFamily: "PingFang SC / Noto Sans SC",
+ position: "bottom-safe-area",
+ maxCharsPerLine: 18,
+ style: "白字深色描边,避免遮挡人物脸部和关键道具"
+ },
+ voicePolicy: {
+ finalVoiceMode: "fixed-reference-required",
+ forbidRandomNativeVoice: true,
+ ttsPreference: "local-or-owned-first",
+ fallbackMouthPlan: ["侧脸", "背影", "低头", "远景", "反应镜头", "环境插入镜头"]
+ },
+ deliverySpec: {
+ aspectRatio: "9:16",
+ resolution: "1080x1920",
+ fps: 24,
+ clipDurationSec: [5, 10],
+ exportFormat: "mp4",
+ projectFolder: "exports/{projectId}"
+ },
+ promptRules: {
+ positivePrefix: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, one continuous scene, one complete image.",
+ negativeTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "故事板拼图"],
+ renderNotes: ["角色服装、道具、天气和机位必须继承 continuity ledger", "每次图片生成 batch size 固定为 1"]
+ },
+ singleFramePolicy: {
+ enabled: true,
+ imageOutputCount: 1,
+ batchSize: 1,
+ forbiddenTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "多时间点"],
+ requiredPhrase: "one single complete frame"
+ },
+ namingRules: {
+ shotPattern: "{projectId}/S{season}E{episode}/shot-{shotNumber}-{assetKind}-{version}",
+ subtitlePattern: "{episodeId}-{voiceLineId}.srt",
+ deliveryPattern: "{projectName}-{episodeId}-{releaseVersion}"
+ },
+ qaPolicy: {
+ gates: ["single-frame", "continuity", "voice-rights", "subtitle-asr-alignment", "clip-transition"],
+ minimumReviewScore: 90,
+ requireLockedAssets: true
+ },
+ metadata: {
+ source: "seed://style-kits/studio-guoman-vertical",
+ commercialReference: "brand-kit-style-guide-production-standard"
+ },
+ bindProjectId: "thunder-mouth",
+ bindingNotes: "《雷雨口》商用 MVP 默认继承工作区国漫竖屏生产标准。"
+ },
+ {
+ id: "stylekit-northstar-fog-pilot",
+ organizationId: "org-northstar",
+ workspaceId: "ws-northstar-main",
+ projectId: "northstar-pilot",
+ scopeMode: "project",
+ name: "北辰雾境试播标准",
+ description: "北辰内容厂牌专用试播标准,用于验证跨组织隔离和项目级风格绑定。",
+ status: "active",
+ visualProfile: {
+ styleFamily: "原创国漫 2D 奇幻",
+ linework: "偏硬朗线条、雾中高反差边缘光",
+ colorScript: "冷雾灰、青铜色、少年角色暖色点光",
+ cameraLanguage: "稳定中景与环境压迫感,禁止多格构图",
+ continuityLocks: ["character", "location", "prop", "weather", "camera"]
+ },
+ subtitlePreset: {
+ language: "zh-CN",
+ fontFamily: "PingFang SC",
+ position: "bottom-safe-area",
+ maxCharsPerLine: 16,
+ style: "白字深描边"
+ },
+ voicePolicy: {
+ finalVoiceMode: "fixed-reference-required",
+ forbidRandomNativeVoice: true,
+ ttsPreference: "local-runner-only",
+ fallbackMouthPlan: ["远景", "侧脸", "反应镜头"]
+ },
+ deliverySpec: {
+ aspectRatio: "9:16",
+ resolution: "1080x1920",
+ fps: 24,
+ clipDurationSec: [5, 8],
+ exportFormat: "mp4",
+ projectFolder: "exports/{projectId}"
+ },
+ promptRules: {
+ positivePrefix: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, fantasy fog gate, one scene only.",
+ negativeTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "分屏"],
+ renderNotes: ["不得串用星河实验室角色或道具", "首尾帧必须来自当前项目"]
+ },
+ singleFramePolicy: {
+ enabled: true,
+ imageOutputCount: 1,
+ batchSize: 1,
+ forbiddenTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏"],
+ requiredPhrase: "one single complete frame"
+ },
+ namingRules: {
+ shotPattern: "{projectId}/pilot/shot-{shotNumber}-{version}",
+ subtitlePattern: "{projectId}-{voiceLineId}.srt",
+ deliveryPattern: "{projectName}-pilot-{releaseVersion}"
+ },
+ qaPolicy: {
+ gates: ["single-frame", "cross-org-isolation", "continuity", "clip-transition"],
+ minimumReviewScore: 92,
+ requireLockedAssets: true
+ },
+ metadata: {
+ source: "seed://style-kits/northstar-fog-pilot",
+ commercialReference: "tenant-isolated-production-standard"
+ },
+ bindProjectId: "northstar-pilot",
+ bindingNotes: "北辰试播项目强制使用项目级雾境风格标准。"
+ }
+ ];
+ for (const kit of kits) {
+ insertIgnore(
+ `INSERT OR IGNORE INTO style_kits(
+ id, organization_id, workspace_id, project_id, scope_mode, name, description, status,
+ visual_profile_json, subtitle_preset_json, voice_policy_json, delivery_spec_json,
+ prompt_rules_json, single_frame_policy_json, naming_rules_json, qa_policy_json,
+ metadata_json, created_by, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'u-owner', ?, ?)`,
+ [
+ kit.id,
+ kit.organizationId,
+ kit.workspaceId,
+ kit.projectId,
+ kit.scopeMode,
+ kit.name,
+ kit.description,
+ kit.status,
+ JSON.stringify(kit.visualProfile),
+ JSON.stringify(kit.subtitlePreset),
+ JSON.stringify(kit.voicePolicy),
+ JSON.stringify(kit.deliverySpec),
+ JSON.stringify(kit.promptRules),
+ JSON.stringify(kit.singleFramePolicy),
+ JSON.stringify(kit.namingRules),
+ JSON.stringify(kit.qaPolicy),
+ JSON.stringify(kit.metadata),
+ timestamp,
+ timestamp
+ ]
+ );
+ insertIgnore(
+ "INSERT OR IGNORE INTO project_style_kit_bindings(id, organization_id, workspace_id, project_id, style_kit_id, enforcement, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'strict', ?, 'u-owner', ?, ?)",
+ [`binding-${kit.bindProjectId}-style-kit`, kit.organizationId, kit.workspaceId, kit.bindProjectId, kit.id, kit.bindingNotes, timestamp, timestamp]
+ );
+ }
+}
+
function seedKnowledgeLibrary() {
const timestamp = now();
const content = [
@@ -1068,6 +1244,7 @@ withTransaction(() => {
seedModels();
seedModelCatalog();
seedModelRoutes();
+ seedStyleKits();
seedSystemGovernance();
seedWorkflowTemplates();
seedProductionGraph();
diff --git a/server/execution.mjs b/server/execution.mjs
index 134694f..df2fcea 100644
--- a/server/execution.mjs
+++ b/server/execution.mjs
@@ -17,6 +17,7 @@ import { productionGraph } from "./production.mjs";
import { dispatchNotificationEvent } from "./notifications.mjs";
import { registerJobArtifacts } from "./media-artifacts.mjs";
import { resolveKnowledgeContextForJob } from "./knowledge.mjs";
+import { effectiveStyleKitForProject, styleKitForbiddenTerms, styleKitNegativeTerms } from "./style-kits.mjs";
const projectRoot = resolve(import.meta.dirname, "..");
const jobStorageRoot = resolve(projectRoot, "storage", "jobs");
@@ -373,13 +374,15 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
const shot = shotForJob(context, body.shotId);
const kind = String(body.kind || "自定义生成任务").trim();
const knowledge = resolveKnowledgeContextForJob(context, body);
+ const styleKit = context.project ? effectiveStyleKitForProject(context, context.project.id).styleKit : null;
const inputs = { ...(body.inputs || {}) };
if (knowledge) {
inputs.knowledgeContext = knowledge.promptContext;
inputs.knowledgeCitations = knowledge.citations;
inputs.knowledgeChunkIds = knowledge.chunkIds;
}
- const blockedTerms = ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "故事板拼图"];
+ const blockedTerms = styleKitForbiddenTerms(styleKit);
+ const negativeTerms = styleKitNegativeTerms(styleKit);
// Negative prompts intentionally name the forbidden layouts; only inspect positive generation text here.
const promptText = [shot?.prompt, shot?.videoPrompt, shot?.action, shot?.camera, body.prompt].filter(Boolean).join(" ").toLowerCase();
const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase()));
@@ -408,15 +411,24 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
} : null,
preflight,
knowledge,
+ styleKit,
shot,
inputs,
constraints: {
singleFrameOnly: true,
imageOutputCount: 1,
+ batchSize: 1,
requireActualLastFrame: true,
localOnly: routing ? routing.policyMode === "local-only" || routing.connectorCostMode === "local" : adapter.costMode === "local",
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired),
- blockedTerms
+ blockedTerms,
+ negativePromptTerms: negativeTerms,
+ styleEnforcement: styleKit?.source || "default-contract",
+ aspectRatio: styleKit?.deliverySpec?.aspectRatio || "9:16",
+ resolution: styleKit?.deliverySpec?.resolution || "1080x1920",
+ fps: styleKit?.deliverySpec?.fps || 24,
+ voicePolicy: styleKit?.voicePolicy || null,
+ subtitlePreset: styleKit?.subtitlePreset || null
}
};
}
diff --git a/server/local-api.mjs b/server/local-api.mjs
index 47539d2..5ce5366 100644
--- a/server/local-api.mjs
+++ b/server/local-api.mjs
@@ -158,6 +158,7 @@ import {
import { reclaimStorage, requireStorageQuota, storageCleanupPreview, storageSummary } from "./storage.mjs";
import { backupSummary, createDatabaseBackup } from "./backup.mjs";
import { systemReadiness } from "./readiness.mjs";
+import { bindProjectStyleKit, createStyleKit, getProjectStyleKit, getStyleKit, listStyleKits, updateStyleKit } from "./style-kits.mjs";
import { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs";
import { composeProject, listCompositions } from "./composition.mjs";
import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.mjs";
@@ -1974,6 +1975,18 @@ createServer(async (req, res) => {
return send(res, 200, { project: updateProject(context, projectId, await readBody(req)) });
}
+ const projectStyleKitMatch = pathname.match(/^\/api\/projects\/([^/]+)\/style-kit$/);
+ if (req.method === "GET" && projectStyleKitMatch) {
+ const projectId = decodeURIComponent(projectStyleKitMatch[1]);
+ const context = contextWith({ ...Object.fromEntries(url.searchParams), projectId }, req);
+ return send(res, 200, getProjectStyleKit(context, projectId));
+ }
+ if (req.method === "POST" && projectStyleKitMatch) {
+ const projectId = decodeURIComponent(projectStyleKitMatch[1]);
+ const context = contextWith({ ...Object.fromEntries(url.searchParams), projectId }, req);
+ return send(res, 200, bindProjectStyleKit(context, projectId, await readBody(req)));
+ }
+
const projectMemberMatch = pathname.match(/^\/api\/projects\/([^/]+)\/members$/);
if (req.method === "GET" && projectMemberMatch) {
const context = resolveContext(req.headers, url.searchParams);
@@ -2063,6 +2076,31 @@ createServer(async (req, res) => {
return send(res, 200, { roles: payload.roles, permissions: payload.permissions, effective: context.permissions });
}
+ if (req.method === "GET" && pathname === "/api/style-kits") {
+ const context = resolveContext(req.headers, url.searchParams);
+ return send(res, 200, listStyleKits(context, { includeArchived: url.searchParams.get("includeArchived") === "1" }));
+ }
+
+ if (req.method === "POST" && pathname === "/api/style-kits") {
+ const context = resolveContext(req.headers, url.searchParams);
+ return send(res, 201, createStyleKit(context, await readBody(req)));
+ }
+
+ if (req.method === "GET" && pathname === "/api/style-kits/effective") {
+ const context = resolveContext(req.headers, url.searchParams);
+ return send(res, 200, getProjectStyleKit(context, url.searchParams.get("projectId") || ""));
+ }
+
+ const styleKitMatch = pathname.match(/^\/api\/style-kits\/([^/]+)$/);
+ if (req.method === "GET" && styleKitMatch) {
+ const context = resolveContext(req.headers, url.searchParams);
+ return send(res, 200, getStyleKit(context, decodeURIComponent(styleKitMatch[1])));
+ }
+ if (req.method === "PATCH" && styleKitMatch) {
+ const context = resolveContext(req.headers, url.searchParams);
+ return send(res, 200, updateStyleKit(context, decodeURIComponent(styleKitMatch[1]), await readBody(req)));
+ }
+
if (req.method === "GET" && pathname === "/api/project") {
const context = resolveContext(req.headers, url.searchParams);
const { project, jobs } = scopedProjectPayload(context);
diff --git a/server/schema.sql b/server/schema.sql
index 8161ceb..a1db62c 100644
--- a/server/schema.sql
+++ b/server/schema.sql
@@ -709,6 +709,45 @@ CREATE TABLE IF NOT EXISTS model_route_approval_requests (
updated_at TEXT NOT NULL
);
+CREATE TABLE IF NOT EXISTS style_kits (
+ id TEXT PRIMARY KEY,
+ organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
+ scope_mode TEXT NOT NULL DEFAULT 'workspace' CHECK (scope_mode IN ('organization', 'workspace', 'project')),
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'paused', 'archived')),
+ visual_profile_json TEXT NOT NULL DEFAULT '{}',
+ subtitle_preset_json TEXT NOT NULL DEFAULT '{}',
+ voice_policy_json TEXT NOT NULL DEFAULT '{}',
+ delivery_spec_json TEXT NOT NULL DEFAULT '{}',
+ prompt_rules_json TEXT NOT NULL DEFAULT '{}',
+ single_frame_policy_json TEXT NOT NULL DEFAULT '{}',
+ naming_rules_json TEXT NOT NULL DEFAULT '{}',
+ qa_policy_json TEXT NOT NULL DEFAULT '{}',
+ metadata_json TEXT NOT NULL DEFAULT '{}',
+ created_by TEXT NOT NULL REFERENCES users(id),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS project_style_kit_bindings (
+ id TEXT PRIMARY KEY,
+ organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
+ style_kit_id TEXT NOT NULL REFERENCES style_kits(id) ON DELETE CASCADE,
+ enforcement TEXT NOT NULL DEFAULT 'locked' CHECK (enforcement IN ('advisory', 'locked', 'strict')),
+ notes TEXT NOT NULL DEFAULT '',
+ created_by TEXT NOT NULL REFERENCES users(id),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_style_kits_scope ON style_kits(organization_id, workspace_id, project_id, scope_mode, status, updated_at DESC);
+CREATE INDEX IF NOT EXISTS idx_style_kit_bindings_scope ON project_style_kit_bindings(organization_id, workspace_id, project_id, style_kit_id);
+
CREATE TABLE IF NOT EXISTS series (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
diff --git a/server/style-kits.mjs b/server/style-kits.mjs
new file mode 100644
index 0000000..1e5c06b
--- /dev/null
+++ b/server/style-kits.mjs
@@ -0,0 +1,492 @@
+import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
+import { addAudit, hasPermission, httpError, requireEntitlement, requirePermission, requireProjectWritable } from "./tenant.mjs";
+
+const now = () => new Date().toISOString();
+const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
+
+const DEFAULT_FORBIDDEN_TERMS = [
+ "split-screen",
+ "comic panel",
+ "collage",
+ "contact sheet",
+ "storyboard",
+ "多格",
+ "拼图",
+ "分屏",
+ "故事板拼图"
+];
+
+const DEFAULT_STYLE_KIT = {
+ id: "",
+ name: "平台默认单画面生产标准",
+ scopeMode: "default",
+ status: "active",
+ visualProfile: {
+ styleFamily: "国产漫画 / 国漫 2D 动画",
+ cameraLanguage: "one continuous scene, one complete frame",
+ continuityLocks: ["character", "costume", "location", "prop", "weather", "camera"]
+ },
+ subtitlePreset: {
+ language: "zh-CN",
+ position: "bottom-safe-area",
+ maxCharsPerLine: 18
+ },
+ 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, one continuous scene, one complete image.",
+ negativeTerms: DEFAULT_FORBIDDEN_TERMS,
+ renderNotes: ["batch size must be 1", "do not generate storyboards or panels"]
+ },
+ singleFramePolicy: {
+ enabled: true,
+ imageOutputCount: 1,
+ batchSize: 1,
+ forbiddenTerms: DEFAULT_FORBIDDEN_TERMS,
+ requiredPhrase: "one single complete frame"
+ },
+ namingRules: {
+ shotPattern: "{projectId}/S{season}E{episode}/shot-{shotNumber}-{assetKind}-{version}",
+ deliveryPattern: "{projectName}-{episodeId}-{releaseVersion}"
+ },
+ qaPolicy: {
+ gates: ["single-frame", "continuity", "voice-rights", "subtitle-asr-alignment", "clip-transition"],
+ requireLockedAssets: true
+ },
+ metadata: { source: "platform-default" }
+};
+
+function parseJson(value, fallback) {
+ try {
+ const parsed = JSON.parse(value);
+ return parsed === undefined || parsed === null ? fallback : parsed;
+ } catch {
+ return fallback;
+ }
+}
+
+function jsonObject(value, fallback = {}) {
+ if (value === undefined) return fallback;
+ if (typeof value === "string") {
+ const parsed = parseJson(value, fallback);
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback;
+ }
+ return value && typeof value === "object" && !Array.isArray(value) ? value : fallback;
+}
+
+function uniqueList(values = []) {
+ return [...new Set(values.map((item) => String(item || "").trim()).filter(Boolean))];
+}
+
+export function styleKitForbiddenTerms(styleKit) {
+ return uniqueList([
+ ...DEFAULT_FORBIDDEN_TERMS,
+ ...(styleKit?.singleFramePolicy?.forbiddenTerms || []),
+ ...(styleKit?.promptRules?.forbiddenTerms || [])
+ ]);
+}
+
+export function styleKitNegativeTerms(styleKit) {
+ return uniqueList([
+ ...DEFAULT_FORBIDDEN_TERMS,
+ ...(styleKit?.promptRules?.negativeTerms || []),
+ ...(styleKit?.singleFramePolicy?.forbiddenTerms || [])
+ ]);
+}
+
+function canReadStyleKits(context) {
+ return [
+ "style:read",
+ "style:manage",
+ "script:read",
+ "job:create",
+ "project:manage",
+ "model:manage",
+ "compliance:manage"
+ ].some((permission) => hasPermission(context, permission));
+}
+
+function requireStyleRead(context) {
+ if (!canReadStyleKits(context)) throw httpError(403, "permission_denied", "当前角色没有风格标准访问权限");
+}
+
+function accessibleProjectIds(context) {
+ return new Set((context.projects || []).map((project) => project.id).filter(Boolean));
+}
+
+function ensureProjectInContext(context, projectId = "") {
+ const id = String(projectId || context.project?.id || "").trim();
+ if (!id) throw httpError(400, "project_required", "项目级风格标准必须绑定项目");
+ const project = dbGet(
+ `SELECT p.*, w.organization_id
+ FROM projects p
+ JOIN workspaces w ON w.id = p.workspace_id
+ WHERE p.id = ? AND p.workspace_id = ? AND w.organization_id = ?`,
+ [id, context.workspace.id, context.organization.id]
+ );
+ if (!project) throw httpError(404, "project_not_found", "项目不存在或不属于当前工作区", { projectId: id });
+ if (!accessibleProjectIds(context).has(project.id) && context.project?.id !== project.id) {
+ throw httpError(403, "project_forbidden", "当前用户没有该项目访问权", { projectId: id });
+ }
+ return project;
+}
+
+function styleKitRowVisible(context, id) {
+ const projectIds = [...accessibleProjectIds(context)];
+ const projectClause = projectIds.length ? `OR sk.project_id IN (${projectIds.map(() => "?").join(",")})` : "";
+ const row = dbGet(
+ `SELECT sk.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS creator_name
+ FROM style_kits sk
+ LEFT JOIN workspaces w ON w.id = sk.workspace_id
+ LEFT JOIN projects p ON p.id = sk.project_id
+ LEFT JOIN users u ON u.id = sk.created_by
+ WHERE sk.id = ? AND sk.organization_id = ?
+ AND (
+ (sk.workspace_id IS NULL AND sk.project_id IS NULL)
+ OR sk.workspace_id = ?
+ ${projectClause}
+ )`,
+ [id, context.organization.id, context.workspace.id, ...projectIds]
+ );
+ if (!row) throw httpError(404, "style_kit_not_found", "风格标准不存在或不属于当前组织/工作区", { styleKitId: id });
+ return row;
+}
+
+function styleKitPayload(row, source = "") {
+ if (!row) return null;
+ return {
+ id: row.id,
+ organizationId: row.organization_id,
+ workspaceId: row.workspace_id || "",
+ projectId: row.project_id || "",
+ scopeMode: row.scope_mode,
+ name: row.name,
+ description: row.description || "",
+ status: row.status,
+ visualProfile: parseJson(row.visual_profile_json, {}),
+ subtitlePreset: parseJson(row.subtitle_preset_json, {}),
+ voicePolicy: parseJson(row.voice_policy_json, {}),
+ deliverySpec: parseJson(row.delivery_spec_json, {}),
+ promptRules: parseJson(row.prompt_rules_json, {}),
+ singleFramePolicy: parseJson(row.single_frame_policy_json, {}),
+ namingRules: parseJson(row.naming_rules_json, {}),
+ qaPolicy: parseJson(row.qa_policy_json, {}),
+ metadata: parseJson(row.metadata_json, {}),
+ workspaceName: row.workspace_name || "",
+ projectName: row.project_name || "",
+ creatorName: row.creator_name || row.created_by || "",
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ ...(source ? { source } : {})
+ };
+}
+
+function bindingPayload(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ organizationId: row.organization_id,
+ workspaceId: row.workspace_id,
+ projectId: row.project_id,
+ styleKitId: row.style_kit_id,
+ enforcement: row.enforcement,
+ notes: row.notes || "",
+ createdBy: row.created_by,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at
+ };
+}
+
+function normalizeStyleKitBody(context, body = {}, current = null) {
+ const scopeMode = String(body.scopeMode ?? body.scope_mode ?? current?.scope_mode ?? "workspace").trim();
+ if (!["organization", "workspace", "project"].includes(scopeMode)) throw httpError(400, "style_kit_scope_invalid", "风格标准作用域只能是 organization、workspace 或 project");
+ const project = scopeMode === "project" ? ensureProjectInContext(context, body.projectId ?? body.project_id ?? current?.project_id) : null;
+ const name = String(body.name ?? current?.name ?? "").trim().slice(0, 100);
+ if (name.length < 2) throw httpError(400, "style_kit_name_required", "风格标准名称至少需要 2 个字符");
+ const status = String(body.status ?? current?.status ?? "draft").trim();
+ if (!["draft", "active", "paused", "archived"].includes(status)) throw httpError(400, "style_kit_status_invalid", "风格标准状态无效");
+ const currentJson = (column, fallback) => current ? parseJson(current[column], fallback) : fallback;
+ const styleKit = {
+ scopeMode,
+ name,
+ description: String(body.description ?? current?.description ?? "").trim().slice(0, 800),
+ status,
+ workspaceId: scopeMode === "organization" ? null : context.workspace.id,
+ projectId: scopeMode === "project" ? project.id : null,
+ visualProfile: jsonObject(body.visualProfile ?? body.visual_profile, currentJson("visual_profile_json", DEFAULT_STYLE_KIT.visualProfile)),
+ subtitlePreset: jsonObject(body.subtitlePreset ?? body.subtitle_preset, currentJson("subtitle_preset_json", DEFAULT_STYLE_KIT.subtitlePreset)),
+ voicePolicy: jsonObject(body.voicePolicy ?? body.voice_policy, currentJson("voice_policy_json", DEFAULT_STYLE_KIT.voicePolicy)),
+ deliverySpec: jsonObject(body.deliverySpec ?? body.delivery_spec, currentJson("delivery_spec_json", DEFAULT_STYLE_KIT.deliverySpec)),
+ promptRules: jsonObject(body.promptRules ?? body.prompt_rules, currentJson("prompt_rules_json", DEFAULT_STYLE_KIT.promptRules)),
+ singleFramePolicy: jsonObject(body.singleFramePolicy ?? body.single_frame_policy, currentJson("single_frame_policy_json", DEFAULT_STYLE_KIT.singleFramePolicy)),
+ namingRules: jsonObject(body.namingRules ?? body.naming_rules, currentJson("naming_rules_json", DEFAULT_STYLE_KIT.namingRules)),
+ qaPolicy: jsonObject(body.qaPolicy ?? body.qa_policy, currentJson("qa_policy_json", DEFAULT_STYLE_KIT.qaPolicy)),
+ metadata: jsonObject(body.metadata ?? body.metadata_json, currentJson("metadata_json", {}))
+ };
+ styleKit.promptRules.negativeTerms = styleKitNegativeTerms(styleKit);
+ styleKit.singleFramePolicy = {
+ enabled: true,
+ imageOutputCount: 1,
+ batchSize: 1,
+ ...styleKit.singleFramePolicy,
+ forbiddenTerms: styleKitForbiddenTerms(styleKit)
+ };
+ styleKit.voicePolicy = {
+ ...styleKit.voicePolicy,
+ forbidRandomNativeVoice: styleKit.voicePolicy.forbidRandomNativeVoice !== false
+ };
+ return styleKit;
+}
+
+function scopedStyleKitRows(context, { includeArchived = false } = {}) {
+ const projectIds = [...accessibleProjectIds(context)];
+ const projectClause = projectIds.length ? `OR sk.project_id IN (${projectIds.map(() => "?").join(",")})` : "";
+ return dbAll(
+ `SELECT sk.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS creator_name
+ FROM style_kits sk
+ LEFT JOIN workspaces w ON w.id = sk.workspace_id
+ LEFT JOIN projects p ON p.id = sk.project_id
+ LEFT JOIN users u ON u.id = sk.created_by
+ WHERE sk.organization_id = ?
+ AND (? = 1 OR sk.status != 'archived')
+ AND (
+ (sk.workspace_id IS NULL AND sk.project_id IS NULL)
+ OR sk.workspace_id = ?
+ ${projectClause}
+ )
+ ORDER BY CASE sk.scope_mode WHEN 'project' THEN 0 WHEN 'workspace' THEN 1 ELSE 2 END, sk.updated_at DESC, sk.name`,
+ [context.organization.id, includeArchived ? 1 : 0, context.workspace.id, ...projectIds]
+ );
+}
+
+function currentBinding(context, projectId = "") {
+ const project = ensureProjectInContext(context, projectId || context.project?.id);
+ const row = dbGet(
+ `SELECT b.*
+ FROM project_style_kit_bindings b
+ WHERE b.organization_id = ? AND b.workspace_id = ? AND b.project_id = ?`,
+ [context.organization.id, context.workspace.id, project.id]
+ );
+ return bindingPayload(row);
+}
+
+function activeStyleKitCandidate(context, projectId = "") {
+ const project = ensureProjectInContext(context, projectId || context.project?.id);
+ const binding = dbGet(
+ `SELECT sk.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS creator_name,
+ b.id AS binding_id, b.organization_id AS binding_organization_id,
+ b.workspace_id AS binding_workspace_id, b.project_id AS binding_project_id,
+ b.style_kit_id AS binding_style_kit_id, b.enforcement AS binding_enforcement,
+ b.notes AS binding_notes, b.created_by AS binding_created_by,
+ b.created_at AS binding_created_at, b.updated_at AS binding_updated_at
+ FROM project_style_kit_bindings b
+ JOIN style_kits sk ON sk.id = b.style_kit_id
+ LEFT JOIN workspaces w ON w.id = sk.workspace_id
+ LEFT JOIN projects p ON p.id = sk.project_id
+ LEFT JOIN users u ON u.id = sk.created_by
+ WHERE b.organization_id = ? AND b.workspace_id = ? AND b.project_id = ?
+ AND sk.organization_id = b.organization_id AND sk.status = 'active'
+ AND (
+ (sk.scope_mode = 'organization' AND sk.workspace_id IS NULL AND sk.project_id IS NULL)
+ OR (sk.scope_mode = 'workspace' AND sk.workspace_id = b.workspace_id AND sk.project_id IS NULL)
+ OR (sk.scope_mode = 'project' AND sk.project_id = b.project_id)
+ )
+ LIMIT 1`,
+ [context.organization.id, context.workspace.id, project.id]
+ );
+ if (binding) {
+ return {
+ styleKit: styleKitPayload(binding, "project-binding"),
+ binding: {
+ id: binding.binding_id,
+ organizationId: binding.binding_organization_id,
+ workspaceId: binding.binding_workspace_id,
+ projectId: binding.binding_project_id,
+ styleKitId: binding.binding_style_kit_id,
+ enforcement: binding.binding_enforcement,
+ notes: binding.binding_notes || "",
+ createdBy: binding.binding_created_by,
+ createdAt: binding.binding_created_at,
+ updatedAt: binding.binding_updated_at
+ },
+ project
+ };
+ }
+ const fallback = dbGet(
+ `SELECT sk.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS creator_name
+ FROM style_kits sk
+ LEFT JOIN workspaces w ON w.id = sk.workspace_id
+ LEFT JOIN projects p ON p.id = sk.project_id
+ LEFT JOIN users u ON u.id = sk.created_by
+ WHERE sk.organization_id = ? AND sk.status = 'active'
+ AND (
+ sk.project_id = ?
+ OR (sk.project_id IS NULL AND sk.workspace_id = ?)
+ OR (sk.project_id IS NULL AND sk.workspace_id IS NULL)
+ )
+ ORDER BY CASE
+ WHEN sk.project_id = ? THEN 0
+ WHEN sk.workspace_id = ? THEN 1
+ ELSE 2
+ END, sk.updated_at DESC
+ LIMIT 1`,
+ [context.organization.id, project.id, context.workspace.id, project.id, context.workspace.id]
+ );
+ return { styleKit: fallback ? styleKitPayload(fallback, fallback.project_id ? "project-active" : fallback.workspace_id ? "workspace-default" : "organization-default") : null, binding: null, project };
+}
+
+export function effectiveStyleKitForProject(context, projectId = "") {
+ if (!context?.organization?.id || !context?.workspace?.id) {
+ return { styleKit: { ...DEFAULT_STYLE_KIT, source: "default-contract" }, binding: null, project: null };
+ }
+ const effective = activeStyleKitCandidate(context, projectId);
+ return {
+ ...effective,
+ styleKit: effective.styleKit || { ...DEFAULT_STYLE_KIT, source: "default-contract" }
+ };
+}
+
+export function listStyleKits(context, options = {}) {
+ requireStyleRead(context);
+ const effective = context.project ? effectiveStyleKitForProject(context) : { styleKit: { ...DEFAULT_STYLE_KIT, source: "default-contract" }, binding: null, project: null };
+ return {
+ kits: scopedStyleKitRows(context, { includeArchived: options.includeArchived }).map(styleKitPayload),
+ effective,
+ projects: (context.projects || []).map((project) => ({ id: project.id, name: project.name, status: project.status })),
+ summary: {
+ total: Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND status != 'archived'", [context.organization.id])?.count || 0),
+ active: Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND status = 'active'", [context.organization.id])?.count || 0),
+ currentWorkspace: Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND workspace_id = ? AND status != 'archived'", [context.organization.id, context.workspace.id])?.count || 0),
+ boundProjects: Number(dbGet("SELECT COUNT(*) AS count FROM project_style_kit_bindings WHERE organization_id = ? AND workspace_id = ?", [context.organization.id, context.workspace.id])?.count || 0)
+ },
+ defaults: DEFAULT_STYLE_KIT
+ };
+}
+
+export function getStyleKit(context, styleKitId) {
+ requireStyleRead(context);
+ return { styleKit: styleKitPayload(styleKitRowVisible(context, String(styleKitId || "").trim())) };
+}
+
+export function createStyleKit(context, body = {}) {
+ requirePermission(context, "style:manage");
+ requireEntitlement(context, "limit.style_kits", 1);
+ const kit = normalizeStyleKitBody(context, body);
+ const id = String(body.id || makeId("stylekit")).trim();
+ const timestamp = now();
+ dbRun(
+ `INSERT INTO style_kits(
+ id, organization_id, workspace_id, project_id, scope_mode, name, description, status,
+ visual_profile_json, subtitle_preset_json, voice_policy_json, delivery_spec_json,
+ prompt_rules_json, single_frame_policy_json, naming_rules_json, qa_policy_json,
+ metadata_json, created_by, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ id,
+ context.organization.id,
+ kit.workspaceId,
+ kit.projectId,
+ kit.scopeMode,
+ kit.name,
+ kit.description,
+ kit.status,
+ JSON.stringify(kit.visualProfile),
+ JSON.stringify(kit.subtitlePreset),
+ JSON.stringify(kit.voicePolicy),
+ JSON.stringify(kit.deliverySpec),
+ JSON.stringify(kit.promptRules),
+ JSON.stringify(kit.singleFramePolicy),
+ JSON.stringify(kit.namingRules),
+ JSON.stringify(kit.qaPolicy),
+ JSON.stringify(kit.metadata),
+ context.user.id,
+ timestamp,
+ timestamp
+ ]
+ );
+ addAudit({ context, action: "style_kit.created", targetType: "style_kit", targetId: id, metadata: { name: kit.name, scopeMode: kit.scopeMode, status: kit.status } });
+ return { styleKit: styleKitPayload(styleKitRowVisible(context, id)), ...listStyleKits(context, { includeArchived: true }) };
+}
+
+export function updateStyleKit(context, styleKitId, body = {}) {
+ requirePermission(context, "style:manage");
+ const id = String(styleKitId || "").trim();
+ const current = styleKitRowVisible(context, id);
+ const kit = normalizeStyleKitBody(context, body, current);
+ const timestamp = now();
+ dbRun(
+ `UPDATE style_kits
+ SET workspace_id = ?, project_id = ?, scope_mode = ?, name = ?, description = ?, status = ?,
+ visual_profile_json = ?, subtitle_preset_json = ?, voice_policy_json = ?, delivery_spec_json = ?,
+ prompt_rules_json = ?, single_frame_policy_json = ?, naming_rules_json = ?, qa_policy_json = ?,
+ metadata_json = ?, updated_at = ?
+ WHERE id = ?`,
+ [
+ kit.workspaceId,
+ kit.projectId,
+ kit.scopeMode,
+ kit.name,
+ kit.description,
+ kit.status,
+ JSON.stringify(kit.visualProfile),
+ JSON.stringify(kit.subtitlePreset),
+ JSON.stringify(kit.voicePolicy),
+ JSON.stringify(kit.deliverySpec),
+ JSON.stringify(kit.promptRules),
+ JSON.stringify(kit.singleFramePolicy),
+ JSON.stringify(kit.namingRules),
+ JSON.stringify(kit.qaPolicy),
+ JSON.stringify(kit.metadata),
+ timestamp,
+ id
+ ]
+ );
+ addAudit({ context, action: "style_kit.updated", targetType: "style_kit", targetId: id, metadata: { name: kit.name, scopeMode: kit.scopeMode, status: kit.status } });
+ return { styleKit: styleKitPayload(styleKitRowVisible(context, id)), ...listStyleKits(context, { includeArchived: true }) };
+}
+
+export function bindProjectStyleKit(context, projectId, body = {}) {
+ requirePermission(context, "style:manage");
+ const project = ensureProjectInContext(context, projectId);
+ requireProjectWritable({ ...context, project });
+ const styleKitId = String(body.styleKitId || body.style_kit_id || "").trim();
+ const timestamp = now();
+ if (!styleKitId) {
+ dbRun("DELETE FROM project_style_kit_bindings WHERE organization_id = ? AND workspace_id = ? AND project_id = ?", [context.organization.id, context.workspace.id, project.id]);
+ addAudit({ context: { ...context, project }, action: "style_kit.unbound", targetType: "project", targetId: project.id, metadata: { projectId: project.id } });
+ return listStyleKits({ ...context, project }, { includeArchived: true });
+ }
+ const kit = styleKitPayload(styleKitRowVisible(context, styleKitId));
+ if (kit.status !== "active") throw httpError(409, "style_kit_not_active", "只有 active 状态的风格标准可以绑定到项目", { styleKitId, status: kit.status });
+ if (kit.scopeMode === "project" && kit.projectId !== project.id) throw httpError(403, "style_kit_project_mismatch", "项目级风格标准不能绑定到其他项目", { styleKitId, kitProjectId: kit.projectId, projectId: project.id });
+ if (kit.scopeMode === "workspace" && kit.workspaceId !== context.workspace.id) throw httpError(403, "style_kit_workspace_mismatch", "工作区级风格标准不能跨工作区绑定", { styleKitId, workspaceId: kit.workspaceId });
+ const enforcement = String(body.enforcement || "locked").trim();
+ if (!["advisory", "locked", "strict"].includes(enforcement)) throw httpError(400, "style_kit_enforcement_invalid", "项目风格绑定强度无效");
+ const notes = String(body.notes || "").trim().slice(0, 500);
+ withTransaction(() => {
+ dbRun(
+ `INSERT INTO project_style_kit_bindings(id, organization_id, workspace_id, project_id, style_kit_id, enforcement, notes, created_by, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(project_id) DO UPDATE SET style_kit_id = excluded.style_kit_id, enforcement = excluded.enforcement, notes = excluded.notes, created_by = excluded.created_by, updated_at = excluded.updated_at`,
+ [`binding-${project.id}-style-kit`, context.organization.id, context.workspace.id, project.id, styleKitId, enforcement, notes, context.user.id, timestamp, timestamp]
+ );
+ });
+ addAudit({ context: { ...context, project }, action: "style_kit.bound", targetType: "project", targetId: project.id, metadata: { projectId: project.id, styleKitId, enforcement } });
+ return listStyleKits({ ...context, project }, { includeArchived: true });
+}
+
+export function getProjectStyleKit(context, projectId = "") {
+ requireStyleRead(context);
+ const project = ensureProjectInContext(context, projectId || context.project?.id);
+ return effectiveStyleKitForProject({ ...context, project }, project.id);
+}
diff --git a/server/tenant.mjs b/server/tenant.mjs
index 9f905f9..d358ee2 100644
--- a/server/tenant.mjs
+++ b/server/tenant.mjs
@@ -823,6 +823,7 @@ function entitlementUsageValue(organizationId, key) {
if (key === "limit.seats") return organizationSeatSummary(organizationId).reserved;
if (key === "limit.workspaces") return Number(dbGet("SELECT COUNT(*) AS count FROM workspaces WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
if (key === "limit.projects") return Number(dbGet("SELECT COUNT(*) AS count FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE w.organization_id = ? AND p.status != 'archived'", [organizationId])?.count || 0);
+ if (key === "limit.style_kits") return Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0);
if (key === "limit.model_connectors") return Number(dbGet("SELECT COUNT(*) AS count FROM model_connectors WHERE organization_id = ?", [organizationId])?.count || 0);
if (key === "limit.api_clients") return Number(dbGet("SELECT COUNT(*) AS count FROM api_clients WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
if (key === "limit.knowledge_documents") return Number(dbGet("SELECT COUNT(*) AS count FROM knowledge_documents WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0);
@@ -874,6 +875,7 @@ function defaultEntitlementRows(organizationId) {
["limit.workspaces", "工作区数量", "tenant", 8, "个", 1, "block", { commercialGate: "workspace:create" }],
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
["limit.generation_jobs_monthly", "月度生成任务", "production", Number(billing.monthly_clip_quota || 2400), "job", 1, "block", { commercialGate: "generation_job:create" }],
+ ["limit.style_kits", "风格生产标准", "production", 24, "套", 1, "block", { commercialGate: "style_kit:create" }],
["limit.storage_gb", "存储容量", "storage", Number(billing.storage_gb || 1024), "GB", 1, "block", { commercialGate: "storage:write" }],
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
diff --git a/src/App.jsx b/src/App.jsx
index 85e43b3..05944f2 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -29,6 +29,7 @@ import {
Menu,
Mic2,
Network,
+ Palette,
Plus,
Play,
RadioTower,
@@ -53,6 +54,7 @@ import {
AdminAuditPage,
AdminModelsPage,
AdminOverviewPage,
+ AdminStyleKitsPage,
AdminQueuePage,
AdminUsagePage,
AccountSecurityPage,
@@ -149,6 +151,7 @@ const navigationGroups = [
{ id: "admin-overview", label: "管理概览", icon: Gauge, requiredPermission: "usage:view" },
{ id: "admin-organizations", label: "组织与工作区", icon: Building2, requiredPermission: "organization:manage" },
{ id: "admin-members", label: "用户与权限", icon: Users, requiredPermission: "organization:members:invite" },
+ { id: "admin-style-kits", label: "风格标准", icon: Palette, requiredAnyPermissions: ["style:read", "style:manage"] },
{ id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
{ id: "admin-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" },
{ id: "admin-usage", label: "用量与成本", icon: Coins, requiredPermission: "usage:view" },
@@ -1710,7 +1713,7 @@ function App() {
};
const enterpriseArea = activeTab === "creator-home" || activeTab === "tasks" || activeTab === "assistant" || activeTab === "knowledge" || activeTab === "asset-library" || activeTab === "voice-studio" || activeTab === "batch-production" || activeTab === "account" || activeTab === "factory" || activeTab === "script" || activeTab === "casting" || activeTab === "director" || activeTab === "jobs" || activeTab === "bible" || activeTab === "locks" || activeTab === "shots" || activeTab === "modelops" || activeTab === "qa" || activeTab === "export" || activeTab === "delivery-portal" || activeTab.startsWith("admin-") || activeTab.startsWith("system-");
const effectivePermissions = new Set(platformContext?.context?.permissions || []);
- const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "usage:view", "compliance:manage", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission));
+ const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "style:manage", "style:read", "usage:view", "compliance:manage", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission));
const canViewSystem = platformContext?.context?.systemAdmin || effectivePermissions.has("system:settings:view");
const canCreateProject = effectivePermissions.has("project:create");
const canCreateJob = effectivePermissions.has("job:create");
@@ -1888,6 +1891,7 @@ function App() {
{activeTab === "admin" &&
{JSON.stringify({ styleKit: effectiveKit, binding: effectiveBinding }, null, 2)}
+