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" && } {activeTab === "admin-overview" && } {(activeTab === "admin-organizations" || activeTab === "admin-members") && } + {activeTab === "admin-style-kits" && } {activeTab === "admin-models" && } {activeTab === "admin-queue" && } {activeTab === "admin-usage" && } diff --git a/src/components/EnterprisePages.jsx b/src/components/EnterprisePages.jsx index e33f132..5cb2476 100644 --- a/src/components/EnterprisePages.jsx +++ b/src/components/EnterprisePages.jsx @@ -20,6 +20,7 @@ import { LogIn, ListFilter, Network, + Palette, Pencil, Play, Plus, @@ -157,7 +158,11 @@ import { fetchNotificationPreferences, markNotificationRead, markAllNotificationsRead, - updateNotificationPreference + updateNotificationPreference, + bindProjectStyleKit, + createStyleKit, + fetchStyleKits, + updateStyleKit } from "../lib/api"; import { downloadJson } from "../lib/exporters"; @@ -2293,6 +2298,280 @@ export function AdminModelsPage({ platformContext, contextOverrides, onModelsCha ); } +const styleKitScopeLabels = { organization: "组织级", workspace: "工作区级", project: "项目级", default: "平台默认" }; +const styleKitStatusLabels = { draft: "草稿", active: "启用", paused: "暂停", archived: "归档" }; +const styleKitEnforcementLabels = { advisory: "提示", locked: "锁定", strict: "强制" }; + +function stringifyStyleJson(value) { + return JSON.stringify(value && typeof value === "object" ? value : {}, null, 2); +} + +function defaultStyleKitForm(defaults = {}, projectId = "") { + return { + id: "", + name: "", + scopeMode: "workspace", + projectId, + status: "draft", + description: "", + visualProfileJson: stringifyStyleJson(defaults.visualProfile), + subtitlePresetJson: stringifyStyleJson(defaults.subtitlePreset), + voicePolicyJson: stringifyStyleJson(defaults.voicePolicy), + deliverySpecJson: stringifyStyleJson(defaults.deliverySpec), + promptRulesJson: stringifyStyleJson(defaults.promptRules), + singleFramePolicyJson: stringifyStyleJson(defaults.singleFramePolicy), + namingRulesJson: stringifyStyleJson(defaults.namingRules), + qaPolicyJson: stringifyStyleJson(defaults.qaPolicy), + metadataJson: stringifyStyleJson({ source: "admin-console" }) + }; +} + +function styleKitFormFromKit(kit, defaults = {}, projectId = "") { + if (!kit?.id) return defaultStyleKitForm(defaults, projectId); + return { + id: kit.id, + name: kit.name || "", + scopeMode: kit.scopeMode || "workspace", + projectId: kit.projectId || projectId, + status: kit.status || "draft", + description: kit.description || "", + visualProfileJson: stringifyStyleJson(kit.visualProfile), + subtitlePresetJson: stringifyStyleJson(kit.subtitlePreset), + voicePolicyJson: stringifyStyleJson(kit.voicePolicy), + deliverySpecJson: stringifyStyleJson(kit.deliverySpec), + promptRulesJson: stringifyStyleJson(kit.promptRules), + singleFramePolicyJson: stringifyStyleJson(kit.singleFramePolicy), + namingRulesJson: stringifyStyleJson(kit.namingRules), + qaPolicyJson: stringifyStyleJson(kit.qaPolicy), + metadataJson: stringifyStyleJson(kit.metadata) + }; +} + +function parseStyleKitJson(value, label) { + try { + const parsed = JSON.parse(value || "{}"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not-object"); + return parsed; + } catch { + throw new Error(`${label} 必须是 JSON 对象`); + } +} + +function parseStyleKitForm(form) { + return { + name: form.name, + scopeMode: form.scopeMode, + projectId: form.scopeMode === "project" ? form.projectId : "", + status: form.status, + description: form.description, + visualProfile: parseStyleKitJson(form.visualProfileJson, "visualProfile"), + subtitlePreset: parseStyleKitJson(form.subtitlePresetJson, "subtitlePreset"), + voicePolicy: parseStyleKitJson(form.voicePolicyJson, "voicePolicy"), + deliverySpec: parseStyleKitJson(form.deliverySpecJson, "deliverySpec"), + promptRules: parseStyleKitJson(form.promptRulesJson, "promptRules"), + singleFramePolicy: parseStyleKitJson(form.singleFramePolicyJson, "singleFramePolicy"), + namingRules: parseStyleKitJson(form.namingRulesJson, "namingRules"), + qaPolicy: parseStyleKitJson(form.qaPolicyJson, "qaPolicy"), + metadata: parseStyleKitJson(form.metadataJson, "metadata") + }; +} + +function StyleKitJsonField({ label, value, onChange, rows = 7 }) { + return