feat: expand novel IP and production workflows

This commit is contained in:
www
2026-09-18 08:14:05 +02:00
parent b2ae4600b4
commit d9c81a3ac0
235 changed files with 117971 additions and 2721 deletions
@@ -0,0 +1,41 @@
CREATE TABLE `character_design_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`character_id` BIGINT NOT NULL,
`version_no` INTEGER NOT NULL,
`prompt_text` TEXT NULL,
`negative_prompt` TEXT NULL,
`image_asset_id` BIGINT NULL,
`notes` TEXT NULL,
`source` VARCHAR(50) NOT NULL DEFAULT 'workshop',
`is_final` BOOLEAN NOT NULL DEFAULT false,
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `character_design_versions_character_id_version_no_key`(`character_id`, `version_no`),
INDEX `character_design_versions_project_id_character_id_idx`(`project_id`, `character_id`),
INDEX `character_design_versions_character_id_is_final_idx`(`character_id`, `is_final`),
INDEX `character_design_versions_image_asset_id_idx`(`image_asset_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `character_states` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`character_id` BIGINT NOT NULL,
`state_code` VARCHAR(80) NOT NULL,
`display_name` VARCHAR(120) NULL,
`description` TEXT NULL,
`wardrobe_rules` TEXT NULL,
`emotion_rules` TEXT NULL,
`prompt_suffix` TEXT NULL,
`negative_rules` TEXT NULL,
`reference_asset_id` BIGINT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `character_states_character_id_state_code_key`(`character_id`, `state_code`),
INDEX `character_states_project_id_character_id_idx`(`project_id`, `character_id`),
INDEX `character_states_status_idx`(`status`),
INDEX `character_states_reference_asset_id_idx`(`reference_asset_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,51 @@
INSERT INTO `provider_configs`
(`provider_type`, `provider_code`, `display_name`, `mode`, `model_name`, `config_json`, `is_enabled`, `priority`, `rate_limit_json`, `cost_rule_json`, `created_at`, `updated_at`)
VALUES
(
'VideoProvider',
'volcengine_seedance_20_fast',
'火山方舟 Seedance 2.0 Fast 图生视频',
'real',
'doubao-seedance-2.0-fast',
'{"driver":"configurable_image_to_video","error_prefix":"SEEDANCE_VIDEO","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","timeout_ms":300000,"create_endpoint":"/videos/generations","task_endpoint_template":"/videos/generations/{task_id}","poll_interval_ms":10000,"max_poll_attempts":150,"image_field":"image","prompt_field":"prompt","model_field":"model","duration_field":"duration","resolution_field":"resolution","aspect_ratio_field":"ratio","duration":5,"resolution":"720p","aspect_ratio":"9:16","extra_body_json":{"watermark":false,"audio":false},"supports_reference_image":true,"supports_start_end_frame":true,"supports_audio":true,"supports_lipsync":true,"supports_character_reference":true,"note":"默认禁用。Seedance 2.0 Fast 适合先做快速小样;模型 ID 和字段请以火山方舟控制台实际开通为准。"}',
false,
77,
'{"rpm":5,"concurrency":1,"retry_limit":2}',
'{"flat_cost":0,"unit":"video_seconds","price_per_second":0,"currency":"CNY","max_cost_per_call":0,"daily_cost_limit":0,"estimated_seconds":5,"note":"Seedance 2.0 Fast 价格按火山方舟实际账单回填;启用前请设置单次和每日成本上限。"}',
NOW(3),
NOW(3)
),
(
'VideoProvider',
'volcengine_seedance_20',
'火山方舟 Seedance 2.0 图生视频',
'real',
'doubao-seedance-2.0',
'{"driver":"configurable_image_to_video","error_prefix":"SEEDANCE_VIDEO","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","timeout_ms":300000,"create_endpoint":"/videos/generations","task_endpoint_template":"/videos/generations/{task_id}","poll_interval_ms":10000,"max_poll_attempts":180,"image_field":"image","prompt_field":"prompt","model_field":"model","duration_field":"duration","resolution_field":"resolution","aspect_ratio_field":"ratio","duration":5,"resolution":"720p","aspect_ratio":"9:16","extra_body_json":{"watermark":false,"audio":false},"supports_reference_image":true,"supports_start_end_frame":true,"supports_audio":true,"supports_lipsync":true,"supports_character_reference":true,"note":"默认禁用。质量优先的 Seedance 2.0 配置;正式调用前先用单镜头验证请求字段、返回结构和账单。"}',
false,
76,
'{"rpm":5,"concurrency":1,"retry_limit":2}',
'{"flat_cost":0,"unit":"video_seconds","price_per_second":0,"currency":"CNY","max_cost_per_call":0,"daily_cost_limit":0,"estimated_seconds":5,"note":"Seedance 2.0 价格按火山方舟实际账单回填;启用前请设置单次和每日成本上限。"}',
NOW(3),
NOW(3)
)
ON DUPLICATE KEY UPDATE
`display_name` = VALUES(`display_name`),
`mode` = VALUES(`mode`),
`model_name` = VALUES(`model_name`),
`priority` = VALUES(`priority`),
`rate_limit_json` = VALUES(`rate_limit_json`),
`updated_at` = NOW(3);
UPDATE `system_configs`
SET
`config_value` = JSON_SET(
`config_value`,
'$."live_action_video"."zh-CN"."normal"."fallback_chain"',
JSON_ARRAY('minimax_hailuo_23_fast', 'volcengine_seedance_20_fast', 'volcengine_seedance_20', 'jimeng_seedance', 'mock-video'),
'$."live_action_video"."zh-CN"."premium"."fallback_chain"',
JSON_ARRAY('kling-image-to-video', 'volcengine_seedance_20', 'minimax_hailuo_23_fast', 'volcengine_seedance_20_fast', 'jimeng_seedance', 'mock-video')
),
`updated_at` = NOW(3)
WHERE `config_key` = 'ai.router.v1'
AND JSON_TYPE(`config_value`) = 'OBJECT';
@@ -0,0 +1,105 @@
INSERT INTO `provider_configs`
(`provider_type`, `provider_code`, `display_name`, `mode`, `model_name`, `config_json`, `is_enabled`, `priority`, `rate_limit_json`, `cost_rule_json`, `created_at`, `updated_at`)
VALUES
(
'TextProvider',
'volcengine-doubao-seed20-pro-text',
'Volcengine Doubao Seed 2.0 Pro Text',
'real',
'doubao-seed-2-0-pro-260215',
'{"driver":"openai_compatible_chat","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","chat_endpoint":"/chat/completions","timeout_ms":120000,"max_tokens":3200,"temperature":0.7,"instructions":"你是中文短剧总编剧,负责高质量剧情策划、世界观、人设、分集大纲和可拍摄脚本。输出结构清晰、冲突强、方便继续生成分镜。"}',
false,
30,
'{"rpm":60,"concurrency":3}',
'{"flat_cost":0,"unit":"provider_usage_metadata","estimated_output_chars":1200,"note":"真实费用以后续账单或 usage 元数据核算;默认禁用,启用前请填写成本阈值。"}',
NOW(3),
NOW(3)
),
(
'TextProvider',
'volcengine-doubao-seed20-lite-text',
'Volcengine Doubao Seed 2.0 Lite Text',
'real',
'doubao-seed-2-0-lite-260215',
'{"driver":"openai_compatible_chat","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","chat_endpoint":"/chat/completions","timeout_ms":90000,"max_tokens":2400,"temperature":0.7,"instructions":"你是中文短剧生产助手,适合高频改写、分镜描述、标题钩子和运营文案。"}',
false,
30,
'{"rpm":60,"concurrency":3}',
'{"flat_cost":0,"unit":"provider_usage_metadata","estimated_output_chars":1200,"note":"真实费用以后续账单或 usage 元数据核算;默认禁用,启用前请填写成本阈值。"}',
NOW(3),
NOW(3)
),
(
'NovelProvider',
'volcengine-doubao-seed20-pro-novel',
'Volcengine Doubao Seed 2.0 Pro Novel',
'real',
'doubao-seed-2-0-pro-260215',
'{"driver":"openai_compatible_chat","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","chat_endpoint":"/chat/completions","timeout_ms":150000,"max_tokens":5000,"temperature":0.7,"instructions":"你是中文网文与短剧改编主笔,擅长长上下文改编、人物弧光、爽点节奏和连续剧集结构。"}',
false,
30,
'{"rpm":30,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","estimated_output_chars":1200,"note":"真实费用以后续账单或 usage 元数据核算;默认禁用,启用前请填写成本阈值。"}',
NOW(3),
NOW(3)
),
(
'ImageProvider',
'volcengine-seedream-50-image',
'Volcengine Seedream 5.0 Image',
'real',
'doubao-seedream-5-0-260128',
'{"driver":"configurable_image_generation","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","timeout_ms":600000,"create_endpoint":"/images/generations","size":"1440x2560","output_format":"png","response_format":"b64_json","aspect_ratio":"9:16","extra_body_json":{"watermark":false},"note":"默认禁用。用于角色定妆、场景概念图、分镜关键帧;启用前先单图验证返回格式和账单。"}',
false,
30,
'{"rpm":20,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","estimated_output_chars":800,"note":"图片费用按供应商账单核算;默认禁用,启用前请填写单次/每日成本阈值。"}',
NOW(3),
NOW(3)
),
(
'ImageProvider',
'volcengine-seedream-50-lite-image',
'Volcengine Seedream 5.0 Lite Image',
'real',
'doubao-seedream-5-0-lite-260128',
'{"driver":"configurable_image_generation","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","timeout_ms":600000,"create_endpoint":"/images/generations","size":"1440x2560","output_format":"png","response_format":"b64_json","aspect_ratio":"9:16","extra_body_json":{"watermark":false},"note":"默认禁用。用于低成本高频角色草图、分镜草图和运营配图。"}',
false,
30,
'{"rpm":20,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","estimated_output_chars":800,"note":"图片费用按供应商账单核算;默认禁用,启用前请填写单次/每日成本阈值。"}',
NOW(3),
NOW(3)
),
(
'ImageProvider',
'volcengine-seedream-45-image',
'Volcengine Seedream 4.5 Image',
'real',
'doubao-seedream-4-5-251128',
'{"driver":"configurable_image_generation","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","timeout_ms":600000,"create_endpoint":"/images/generations","size":"1440x2560","output_format":"png","response_format":"b64_json","aspect_ratio":"9:16","extra_body_json":{"watermark":false},"note":"默认禁用。作为 Seedream 5.0 不可用时的图片生成 fallback。"}',
false,
30,
'{"rpm":20,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","estimated_output_chars":800,"note":"图片费用按供应商账单核算;默认禁用,启用前请填写单次/每日成本阈值。"}',
NOW(3),
NOW(3)
)
ON DUPLICATE KEY UPDATE
`display_name` = VALUES(`display_name`),
`mode` = VALUES(`mode`),
`model_name` = VALUES(`model_name`),
`priority` = VALUES(`priority`),
`rate_limit_json` = VALUES(`rate_limit_json`),
`updated_at` = NOW(3);
UPDATE `provider_configs`
SET
`model_name` = CASE
WHEN `provider_code` = 'volcengine_seedance_20' THEN 'doubao-seedance-2-0-260128'
WHEN `provider_code` = 'volcengine_seedance_20_fast' THEN 'doubao-seedance-2-0-fast-260128'
ELSE `model_name`
END,
`updated_at` = NOW(3)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN ('volcengine_seedance_20', 'volcengine_seedance_20_fast');
@@ -0,0 +1,164 @@
INSERT INTO `provider_configs`
(`provider_type`, `provider_code`, `display_name`, `mode`, `model_name`, `config_json`, `is_enabled`, `priority`, `rate_limit_json`, `cost_rule_json`, `created_at`, `updated_at`)
VALUES
(
'TextProvider',
'openai-gpt54-text',
'OpenAI GPT-5.4 Text',
'real',
'gpt-5.4',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":90000,"max_output_tokens":1800,"instructions":"你是中文漫剧创作助手,适合剧情策划、脚本润色、分镜提示词优化和复杂改写。"}',
false,
58,
'{"rpm":60,"concurrency":4}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'TextProvider',
'openai-gpt54-mini-text',
'OpenAI GPT-5.4 Mini Text',
'real',
'gpt-5.4-mini',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":60000,"max_output_tokens":1600,"instructions":"你是中文短剧生产助手,适合高频、低延迟的分镜草稿、标题、简介和运营文案。"}',
false,
57,
'{"rpm":60,"concurrency":4}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'TextProvider',
'openai-gpt54-nano-text',
'OpenAI GPT-5.4 Nano Text',
'real',
'gpt-5.4-nano',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":45000,"max_output_tokens":1200,"instructions":"你是中文短剧轻量助手,适合分类、短提示词改写、标题和低成本批处理。"}',
false,
56,
'{"rpm":60,"concurrency":4}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'TextProvider',
'openai-gpt5-text',
'OpenAI GPT-5 Text',
'real',
'gpt-5',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":90000,"max_output_tokens":1800,"instructions":"你是中文漫剧创作助手,用于和 GPT-5.4/5.5 做质量、成本、稳定性对比。"}',
false,
55,
'{"rpm":60,"concurrency":4}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'TextProvider',
'openai-gpt41-text',
'OpenAI GPT-4.1 Text',
'real',
'gpt-4.1',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":60000,"max_output_tokens":1600,"instructions":"你是中文漫剧创作助手,用于非推理模型基线测试、脚本润色和结构化输出。"}',
false,
54,
'{"rpm":60,"concurrency":4}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'TextProvider',
'openai-gpt41-mini-text',
'OpenAI GPT-4.1 Mini Text',
'real',
'gpt-4.1-mini',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":45000,"max_output_tokens":1200,"instructions":"你是中文短剧轻量助手,用于低成本文案、分镜草稿和结构化摘要。"}',
false,
53,
'{"rpm":60,"concurrency":4}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'NovelProvider',
'openai-gpt54-novel',
'OpenAI GPT-5.4 Novel',
'real',
'gpt-5.4',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":150000,"max_output_tokens":5000,"instructions":"你是中文网文和短剧改编主笔,擅长长上下文改编、分集大纲、角色弧光和连续剧结构。"}',
false,
58,
'{"rpm":30,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'NovelProvider',
'openai-gpt54-mini-novel',
'OpenAI GPT-5.4 Mini Novel',
'real',
'gpt-5.4-mini',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":120000,"max_output_tokens":3600,"instructions":"你是中文网文改编助手,适合低成本生成章节摘要、分集计划和脚本草稿。"}',
false,
57,
'{"rpm":30,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'NovelProvider',
'openai-gpt5-novel',
'OpenAI GPT-5 Novel',
'real',
'gpt-5',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":150000,"max_output_tokens":4200,"instructions":"你是中文网文和短剧改编主笔,用于和 GPT-5.4/5.5 做长文改编对比。"}',
false,
55,
'{"rpm":30,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'NovelProvider',
'openai-gpt41-novel',
'OpenAI GPT-4.1 Novel',
'real',
'gpt-4.1',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":120000,"max_output_tokens":3200,"instructions":"你是中文网文改编助手,用于非推理模型基线测试和长文本结构化整理。"}',
false,
54,
'{"rpm":30,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'VideoProvider',
'openai-sora-2-pro-video',
'OpenAI Sora 2 Pro Video',
'real',
'sora-2-pro',
'{"driver":"openai_video_generation","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":60000,"create_endpoint":"/videos","status_endpoint_template":"/videos/{video_id}","content_endpoint_template":"/videos/{video_id}/content","poll_interval_ms":10000,"max_poll_attempts":90,"size":"1280x720","seconds":"8"}',
false,
45,
'{"rpm":3,"concurrency":1}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"Sora Pro 费用较高;默认禁用,启用前必须设置单次和每日成本上限。"}',
NOW(3),
NOW(3)
)
ON DUPLICATE KEY UPDATE
`display_name` = VALUES(`display_name`),
`mode` = VALUES(`mode`),
`model_name` = VALUES(`model_name`),
`priority` = VALUES(`priority`),
`rate_limit_json` = VALUES(`rate_limit_json`),
`updated_at` = NOW(3);
@@ -0,0 +1,22 @@
UPDATE `provider_configs`
SET `config_json` = JSON_SET(
`config_json`,
'$.body_style', 'volcengine_content_generation',
'$.create_endpoint', '/contents/generations/tasks',
'$.task_endpoint_template', '/contents/generations/tasks/{task_id}'
)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN ('volcengine_seedance_20_fast', 'volcengine_seedance_20', 'jimeng_seedance');
UPDATE `provider_configs`
SET `config_json` = JSON_REMOVE(
`config_json`,
'$.image_field',
'$.prompt_field',
'$.model_field',
'$.duration_field',
'$.resolution_field',
'$.aspect_ratio_field'
)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN ('volcengine_seedance_20_fast', 'volcengine_seedance_20', 'jimeng_seedance');
@@ -0,0 +1,11 @@
UPDATE `provider_configs`
SET `cost_rule_json` = JSON_SET(
`cost_rule_json`,
'$.price_per_second', 0.9936,
'$.currency', 'CNY',
'$.max_cost_per_call', 20,
'$.daily_cost_limit', 500,
'$.note', '按火山方舟公开价格估算:720p、24fps、无视频输入约 46 元/百万 token,即约 0.9936 元/秒;最终以账单为准。'
)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN ('volcengine_seedance_20_fast', 'volcengine_seedance_20');
@@ -0,0 +1,10 @@
UPDATE `provider_configs`
SET
`config_json` = JSON_SET(COALESCE(`config_json`, JSON_OBJECT()), '$.size', '1440x2560'),
`updated_at` = NOW(3)
WHERE `provider_type` = 'ImageProvider'
AND `provider_code` IN (
'volcengine-seedream-50-image',
'volcengine-seedream-50-lite-image',
'volcengine-seedream-45-image'
);
@@ -0,0 +1,10 @@
UPDATE `provider_configs`
SET
`config_json` = JSON_SET(COALESCE(`config_json`, JSON_OBJECT()), '$.timeout_ms', 600000),
`updated_at` = NOW(3)
WHERE `provider_type` = 'ImageProvider'
AND `provider_code` IN (
'volcengine-seedream-50-image',
'volcengine-seedream-50-lite-image',
'volcengine-seedream-45-image'
);
@@ -0,0 +1,6 @@
ALTER TABLE `novel_chapters`
ADD COLUMN `volume_no` INT NULL AFTER `novel_source_id`,
ADD COLUMN `volume_title` VARCHAR(255) NULL AFTER `volume_no`;
CREATE INDEX `novel_chapters_novel_source_id_volume_no_idx`
ON `novel_chapters`(`novel_source_id`, `volume_no`);
@@ -0,0 +1,44 @@
CREATE TABLE `novel_reading_progress` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NOT NULL,
`chapter_id` BIGINT NOT NULL,
`page_index` INT NOT NULL DEFAULT 0,
`page_count` INT NOT NULL DEFAULT 1,
`progress_percent` DECIMAL(6, 3) NULL,
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `novel_reading_progress_user_id_novel_source_id_key` (`user_id`, `novel_source_id`),
KEY `novel_reading_progress_user_id_updated_at_idx` (`user_id`, `updated_at`),
KEY `novel_reading_progress_novel_source_id_idx` (`novel_source_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_bookmarks` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NOT NULL,
`chapter_id` BIGINT NOT NULL,
`page_index` INT NOT NULL DEFAULT 0,
`title` VARCHAR(255) NULL,
`note_text` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `novel_bookmarks_user_id_novel_source_id_created_at_idx` (`user_id`, `novel_source_id`, `created_at`),
KEY `novel_bookmarks_chapter_id_idx` (`chapter_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_annotations` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NOT NULL,
`chapter_id` BIGINT NOT NULL,
`page_index` INT NOT NULL DEFAULT 0,
`selected_text` TEXT NOT NULL,
`note_text` TEXT NULL,
`color` VARCHAR(30) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `novel_annotations_user_id_novel_source_id_created_at_idx` (`user_id`, `novel_source_id`, `created_at`),
KEY `novel_annotations_chapter_id_idx` (`chapter_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,187 @@
CREATE TABLE `novel_generation_plans` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`user_id` BIGINT NOT NULL,
`novel_scale` VARCHAR(30) NOT NULL DEFAULT 'medium',
`target_words` INT NULL,
`target_chapters` INT NULL,
`genre` VARCHAR(100) NULL,
`style_code` VARCHAR(100) NULL,
`brief_json` JSON NULL,
`ip_bible_json` JSON NULL,
`volume_plan_json` JSON NULL,
`pipeline_config_json` JSON NULL,
`quality_threshold_json` JSON NULL,
`automation_level` VARCHAR(20) NOT NULL DEFAULT 'L1',
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`current_chapter_no` INT NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `npg_project_status_idx` (`project_id`, `status`),
KEY `npg_source_idx` (`novel_source_id`),
KEY `npg_user_status_idx` (`user_id`, `status`),
KEY `npg_scale_level_idx` (`novel_scale`, `automation_level`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_chapter_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`novel_chapter_id` BIGINT NULL,
`chapter_no` INT NOT NULL,
`version_no` INT NOT NULL,
`version_type` VARCHAR(50) NOT NULL,
`title` VARCHAR(255) NULL,
`content_text` LONGTEXT NULL,
`content_json` JSON NULL,
`source_agent` VARCHAR(100) NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(100) NULL,
`quality_score` DECIMAL(5, 2) NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `ncv_source_chapter_version_type_key` (`novel_source_id`, `chapter_no`, `version_no`, `version_type`),
KEY `ncv_project_chapter_idx` (`project_id`, `chapter_no`),
KEY `ncv_chapter_idx` (`novel_chapter_id`),
KEY `ncv_agent_idx` (`source_agent`),
KEY `ncv_status_idx` (`status`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_context_memories` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`chapter_no` INT NULL,
`memory_type` VARCHAR(80) NOT NULL,
`memory_text` LONGTEXT NULL,
`memory_json` JSON NULL,
`importance_level` INT NOT NULL DEFAULT 0,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `ncm_project_type_idx` (`project_id`, `memory_type`),
KEY `ncm_source_chapter_idx` (`novel_source_id`, `chapter_no`),
KEY `ncm_type_status_idx` (`memory_type`, `status`),
KEY `ncm_importance_idx` (`importance_level`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_quality_reports` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`novel_chapter_id` BIGINT NULL,
`chapter_no` INT NULL,
`report_type` VARCHAR(50) NOT NULL,
`total_score` DECIMAL(5, 2) NULL,
`score_json` JSON NULL,
`problems_json` JSON NULL,
`suggestions_json` JSON NULL,
`pass_status` BOOLEAN NOT NULL DEFAULT false,
`rewrite_required` BOOLEAN NOT NULL DEFAULT false,
`source_agent` VARCHAR(100) NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(100) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `nqr_project_chapter_idx` (`project_id`, `chapter_no`),
KEY `nqr_source_chapter_idx` (`novel_source_id`, `chapter_no`),
KEY `nqr_chapter_idx` (`novel_chapter_id`),
KEY `nqr_pass_idx` (`pass_status`),
KEY `nqr_agent_idx` (`source_agent`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_version_snapshots` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NOT NULL,
`version_no` INT NOT NULL,
`title` VARCHAR(255) NULL,
`snapshot_scope` VARCHAR(50) NOT NULL,
`chapter_start` INT NULL,
`chapter_end` INT NULL,
`source_hash` VARCHAR(128) NULL,
`snapshot_text` LONGTEXT NULL,
`snapshot_json` JSON NULL,
`created_for` VARCHAR(50) NULL,
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `nvs_source_version_key` (`novel_source_id`, `version_no`),
KEY `nvs_project_idx` (`project_id`),
KEY `nvs_for_idx` (`created_for`),
KEY `nvs_range_idx` (`chapter_start`, `chapter_end`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_derivative_jobs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NOT NULL,
`snapshot_id` BIGINT NULL,
`user_id` BIGINT NOT NULL,
`derivative_type` VARCHAR(50) NOT NULL,
`target_project_id` BIGINT NULL,
`target_ref_id` BIGINT NULL,
`chapter_start` INT NULL,
`chapter_end` INT NULL,
`config_json` JSON NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`progress_json` JSON NULL,
`error_message` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `ndj_project_type_idx` (`project_id`, `derivative_type`),
KEY `ndj_source_status_idx` (`novel_source_id`, `status`),
KEY `ndj_snapshot_idx` (`snapshot_id`),
KEY `ndj_user_status_idx` (`user_id`, `status`),
KEY `ndj_target_project_idx` (`target_project_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `agent_prompts` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`agent_name` VARCHAR(100) NOT NULL,
`version` INT NOT NULL DEFAULT 1,
`provider_type` VARCHAR(80) NOT NULL DEFAULT 'NovelProvider',
`default_provider_code` VARCHAR(100) NULL,
`system_prompt` LONGTEXT NOT NULL,
`user_prompt_template` LONGTEXT NOT NULL,
`output_schema_json` JSON NULL,
`temperature` DECIMAL(4, 2) NULL,
`max_output_tokens` INT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `ap_name_version_key` (`agent_name`, `version`),
KEY `ap_name_active_idx` (`agent_name`, `is_active`),
KEY `ap_provider_type_idx` (`provider_type`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `agent_runs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NULL,
`novel_source_id` BIGINT NULL,
`chapter_no` INT NULL,
`agent_name` VARCHAR(100) NOT NULL,
`prompt_version` INT NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(100) NULL,
`provider_log_id` BIGINT NULL,
`input_json` JSON NULL,
`output_json` JSON NULL,
`output_text` LONGTEXT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'running',
`quality_score` DECIMAL(5, 2) NULL,
`error_code` VARCHAR(100) NULL,
`error_message` TEXT NULL,
`started_at` DATETIME(3) NULL,
`finished_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `ar_project_agent_idx` (`project_id`, `agent_name`),
KEY `ar_source_chapter_idx` (`novel_source_id`, `chapter_no`),
KEY `ar_status_created_idx` (`status`, `created_at`),
KEY `ar_provider_log_idx` (`provider_log_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,13 @@
ALTER TABLE `novel_sources`
ADD COLUMN `ai_provider_code` VARCHAR(100) NULL,
ADD COLUMN `ai_model_name` VARCHAR(160) NULL,
ADD COLUMN `ai_cost_estimate` DECIMAL(12, 4) NULL,
ADD COLUMN `ai_cost_actual` DECIMAL(12, 4) NULL,
ADD INDEX `ns_ai_provider_idx` (`ai_provider_code`);
ALTER TABLE `novel_chapters`
ADD COLUMN `ai_provider_code` VARCHAR(100) NULL,
ADD COLUMN `ai_model_name` VARCHAR(160) NULL,
ADD COLUMN `ai_cost_estimate` DECIMAL(12, 4) NULL,
ADD COLUMN `ai_cost_actual` DECIMAL(12, 4) NULL,
ADD INDEX `nc_ai_provider_idx` (`ai_provider_code`);
@@ -0,0 +1,7 @@
ALTER TABLE `novel_sources`
ADD COLUMN `design_json` JSON NULL AFTER `parse_report`,
ADD COLUMN `volume_plan_json` JSON NULL AFTER `design_json`;
ALTER TABLE `novel_chapters`
ADD COLUMN `outline_json` JSON NULL AFTER `visual_summary`,
ADD COLUMN `analysis_json` JSON NULL AFTER `outline_json`;
@@ -0,0 +1,2 @@
ALTER TABLE `story_bibles`
MODIFY `tone` TEXT NULL;
@@ -0,0 +1,3 @@
ALTER TABLE `story_bibles`
ADD COLUMN `production_bible_json` JSON NULL,
ADD COLUMN `quality_report_json` JSON NULL;
@@ -0,0 +1,35 @@
CREATE TABLE `character_extraction_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`story_bible_id` BIGINT NULL,
`provider_log_id` BIGINT NULL,
`source_type` VARCHAR(50) NOT NULL,
`source_label` VARCHAR(120) NULL,
`prompt_category` VARCHAR(100) NULL,
`prompt_version` VARCHAR(80) NULL,
`prompt_text` LONGTEXT NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(100) NULL,
`raw_text` LONGTEXT NULL,
`role_pool_plan_json` JSON NULL,
`characters_json` JSON NULL,
`quality_score` INT NULL,
`review_comment` TEXT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE INDEX `character_extraction_versions_project_id_status_idx`
ON `character_extraction_versions`(`project_id`, `status`);
CREATE INDEX `character_extraction_versions_story_bible_id_idx`
ON `character_extraction_versions`(`story_bible_id`);
CREATE INDEX `character_extraction_versions_provider_log_id_idx`
ON `character_extraction_versions`(`provider_log_id`);
CREATE INDEX `character_extraction_versions_source_type_idx`
ON `character_extraction_versions`(`source_type`);
@@ -0,0 +1,58 @@
ALTER TABLE `characters`
ADD COLUMN `global_character_look_version_id` BIGINT NULL,
ADD COLUMN `global_character_asset_id` BIGINT NULL,
ADD COLUMN `inherit_voice_from_global` BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN `inherit_digital_human_from_global` BOOLEAN NOT NULL DEFAULT false,
ADD INDEX `characters_global_character_look_version_id_idx` (`global_character_look_version_id`),
ADD INDEX `characters_global_character_asset_id_idx` (`global_character_asset_id`);
CREATE TABLE `global_character_look_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`global_character_id` BIGINT NOT NULL,
`version_name` VARCHAR(160) NOT NULL,
`style_type` VARCHAR(80) NULL,
`appearance_desc` TEXT NULL,
`hair_desc` TEXT NULL,
`makeup_desc` TEXT NULL,
`body_desc` TEXT NULL,
`costume_rules` TEXT NULL,
`color_palette` TEXT NULL,
`key_props` TEXT NULL,
`negative_rules` TEXT NULL,
`main_anchor_asset_id` BIGINT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'candidate',
`quality_score` DECIMAL(5,2) NULL,
`reviewer_comment` TEXT NULL,
`source_project_id` BIGINT NULL,
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `global_character_look_versions_global_character_id_status_idx` (`global_character_id`, `status`),
INDEX `global_character_look_versions_main_anchor_asset_id_idx` (`main_anchor_asset_id`),
INDEX `global_character_look_versions_source_project_id_idx` (`source_project_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `global_character_assets`
ADD COLUMN `look_version_id` BIGINT NULL,
ADD COLUMN `source_type` VARCHAR(50) NOT NULL DEFAULT 'upload',
ADD COLUMN `source_label` VARCHAR(160) NULL,
ADD COLUMN `negative_prompt` TEXT NULL,
ADD COLUMN `model_name` VARCHAR(160) NULL,
ADD COLUMN `seed` VARCHAR(100) NULL,
ADD COLUMN `resolution` VARCHAR(50) NULL,
ADD COLUMN `aspect_ratio` VARCHAR(50) NULL,
ADD COLUMN `cost_estimate` DECIMAL(12,4) NULL,
ADD COLUMN `cost_actual` DECIMAL(12,4) NULL,
ADD COLUMN `quality_score` DECIMAL(5,2) NULL,
ADD COLUMN `consistency_score` DECIMAL(5,2) NULL,
ADD COLUMN `face_similarity_score` DECIMAL(5,2) NULL,
ADD COLUMN `license_status` VARCHAR(50) NOT NULL DEFAULT 'internal_test',
ADD COLUMN `commercial_allowed` BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN `review_status` VARCHAR(50) NOT NULL DEFAULT 'pending',
ADD COLUMN `reviewer_note` TEXT NULL,
ADD COLUMN `usage_count` INT NOT NULL DEFAULT 0,
ADD COLUMN `metadata_json` JSON NULL,
ADD INDEX `global_character_assets_look_version_id_idx` (`look_version_id`),
ADD INDEX `global_character_assets_source_type_idx` (`source_type`),
ADD INDEX `global_character_assets_review_status_idx` (`review_status`);
@@ -0,0 +1,100 @@
ALTER TABLE `characters`
ADD COLUMN `story_character_id` BIGINT NULL,
ADD INDEX `characters_story_character_id_idx` (`story_character_id`);
CREATE TABLE `story_characters` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`story_bible_id` BIGINT NULL,
`global_character_id` BIGINT NULL,
`name` VARCHAR(100) NOT NULL,
`alias_names` JSON NULL,
`role_type` VARCHAR(50) NOT NULL DEFAULT 'supporting',
`identity_desc` TEXT NULL,
`background_desc` TEXT NULL,
`personality_desc` TEXT NULL,
`relationship_json` JSON NULL,
`character_arc` TEXT NULL,
`world_role_desc` TEXT NULL,
`negative_rules` TEXT NULL,
`source_type` VARCHAR(50) NOT NULL DEFAULT 'character_extraction',
`source_version_id` BIGINT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `story_characters_project_id_status_idx` (`project_id`, `status`),
INDEX `story_characters_novel_source_id_idx` (`novel_source_id`),
INDEX `story_characters_story_bible_id_idx` (`story_bible_id`),
INDEX `story_characters_global_character_id_idx` (`global_character_id`),
INDEX `story_characters_source_type_idx` (`source_type`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `video_clips`
ADD COLUMN `engine_version` VARCHAR(80) NULL,
ADD COLUMN `asset_lock_json` JSON NULL,
ADD COLUMN `motion_control_json` JSON NULL,
ADD COLUMN `camera_control_json` JSON NULL,
ADD COLUMN `composer_usage_json` JSON NULL,
ADD INDEX `video_clips_engine_version_idx` (`engine_version`);
CREATE TABLE `project_pipeline_configs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`ip_isolation_enabled` BOOLEAN NOT NULL DEFAULT true,
`video_engine_enabled` BOOLEAN NOT NULL DEFAULT true,
`scene_composer_enabled` BOOLEAN NOT NULL DEFAULT false,
`default_generation_mode` VARCHAR(50) NOT NULL DEFAULT 'single_model',
`video_engine_version` VARCHAR(80) NOT NULL DEFAULT 'video_engine_v1',
`scene_composer_version` VARCHAR(80) NOT NULL DEFAULT 'scene_composer_v1_reserved',
`ip_rules_json` JSON NULL,
`video_engine_config_json` JSON NULL,
`scene_composer_config_json` JSON NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `project_pipeline_configs_project_id_key` (`project_id`),
INDEX `project_pipeline_configs_scene_composer_enabled_idx` (`scene_composer_enabled`),
INDEX `project_pipeline_configs_default_generation_mode_idx` (`default_generation_mode`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
INSERT INTO `project_pipeline_configs` (
`project_id`,
`ip_isolation_enabled`,
`video_engine_enabled`,
`scene_composer_enabled`,
`default_generation_mode`,
`video_engine_version`,
`scene_composer_version`,
`ip_rules_json`,
`video_engine_config_json`,
`scene_composer_config_json`
)
SELECT
`id`,
true,
true,
false,
'single_model',
'video_engine_v1',
'scene_composer_v1_reserved',
JSON_OBJECT(
'character_ip_policy', 'clean_reusable_assets_only',
'story_character_policy', 'story_bible_and_relationships_only',
'project_character_policy', 'project_look_anchor_scene_state_only',
'forbidden_ip_fields', JSON_ARRAY('episode_plot', 'shot_state', 'project_costume', 'scene_action')
),
JSON_OBJECT(
'mode', 'single_model_first',
'candidate_count_default', 1,
'multi_model_compare_default_enabled', false,
'requires_human_clip_selection', true
),
JSON_OBJECT(
'enabled', false,
'reserved_stage', 'phase_5',
'notes', 'Scene Composer is recorded for future automatic ordering/emotion/transition logic. Current production keeps human clip selection and FFmpeg render.'
)
FROM `projects`
ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3);
@@ -0,0 +1,34 @@
CREATE TABLE `character_prompt_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NULL,
`character_id` BIGINT NULL,
`global_character_id` BIGINT NULL,
`look_version_id` BIGINT NULL,
`version_no` INTEGER NOT NULL,
`layer_code` VARCHAR(80) NOT NULL DEFAULT 'main_anchor',
`channel` VARCHAR(80) NOT NULL DEFAULT 'chatgpt_web',
`title` VARCHAR(160) NULL,
`source_type` VARCHAR(80) NOT NULL DEFAULT 'system_refresh',
`source_label` VARCHAR(160) NULL,
`prompt_engine_version` VARCHAR(80) NULL,
`prompt_text` LONGTEXT NOT NULL,
`negative_prompt` LONGTEXT NULL,
`model_name` VARCHAR(160) NULL,
`usage_note` TEXT NULL,
`quality_score` DECIMAL(5, 2) NULL,
`review_comment` TEXT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT false,
`metadata_json` JSON NULL,
`created_by_user_id` BIGINT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `cpv_character_version_key` (`character_id`, `version_no`),
UNIQUE INDEX `cpv_global_version_key` (`global_character_id`, `version_no`),
INDEX `cpv_project_idx` (`project_id`),
INDEX `cpv_character_layer_channel_idx` (`character_id`, `layer_code`, `channel`),
INDEX `cpv_global_layer_channel_idx` (`global_character_id`, `layer_code`, `channel`),
INDEX `cpv_look_version_idx` (`look_version_id`),
INDEX `cpv_active_status_idx` (`is_active`, `status`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,60 @@
CREATE TABLE `character_prompt_reviews` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`task_id` BIGINT NULL,
`character_id` BIGINT NULL,
`global_character_id` BIGINT NULL,
`prompt_version_id` BIGINT NULL,
`layer_code` VARCHAR(80) NOT NULL,
`image_type` VARCHAR(80) NULL,
`review_round` INTEGER NOT NULL DEFAULT 1,
`review_mode` VARCHAR(80) NOT NULL DEFAULT 'api_preflight',
`prompt_engine_version` VARCHAR(80) NULL,
`threshold_score` DECIMAL(5, 2) NOT NULL DEFAULT 92.00,
`score` DECIMAL(5, 2) NULL,
`passed` BOOLEAN NOT NULL DEFAULT false,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`source_prompt` LONGTEXT NULL,
`optimized_prompt` LONGTEXT NULL,
`negative_prompt` LONGTEXT NULL,
`issues_json` JSON NULL,
`suggestions_json` JSON NULL,
`reusable_rules_json` JSON NULL,
`quality_gate_json` JSON NULL,
`provider_log_id` BIGINT NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(160) NULL,
`raw_text` LONGTEXT NULL,
`error_message` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `cpr_project_created_idx` (`project_id`, `created_at`),
INDEX `cpr_task_idx` (`task_id`),
INDEX `cpr_character_layer_idx` (`character_id`, `layer_code`),
INDEX `cpr_global_layer_idx` (`global_character_id`, `layer_code`),
INDEX `cpr_prompt_version_idx` (`prompt_version_id`),
INDEX `cpr_layer_passed_idx` (`layer_code`, `passed`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `character_prompt_optimization_lessons` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`scope_type` VARCHAR(50) NOT NULL DEFAULT 'global',
`layer_code` VARCHAR(80) NOT NULL,
`lesson_type` VARCHAR(80) NOT NULL DEFAULT 'prompt_rule',
`title` VARCHAR(160) NULL,
`rule_text` TEXT NOT NULL,
`prompt_engine_version` VARCHAR(80) NULL,
`source_review_id` BIGINT NULL,
`source_character_id` BIGINT NULL,
`source_prompt_version_id` BIGINT NULL,
`quality_score` DECIMAL(5, 2) NULL,
`usage_count` INTEGER NOT NULL DEFAULT 0,
`metadata_json` JSON NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `cpol_scope_layer_status_idx` (`scope_type`, `layer_code`, `status`),
INDEX `cpol_review_idx` (`source_review_id`),
INDEX `cpol_character_idx` (`source_character_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,48 @@
CREATE TABLE `user_model_preferences` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`scope_key` VARCHAR(120) NOT NULL DEFAULT 'global',
`project_id` BIGINT NULL,
`preference_key` VARCHAR(120) NOT NULL,
`text_provider_code` VARCHAR(100) NULL,
`image_provider_code` VARCHAR(100) NULL,
`video_provider_code` VARCHAR(100) NULL,
`voice_provider_code` VARCHAR(100) NULL,
`metadata_json` JSON NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `ump_user_scope_key_unique` (`user_id`, `scope_key`, `preference_key`),
INDEX `ump_user_scope_idx` (`user_id`, `scope_key`),
INDEX `ump_project_idx` (`project_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `project_visual_assets` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`asset_id` BIGINT NULL,
`asset_kind` VARCHAR(50) NOT NULL,
`asset_type` VARCHAR(80) NOT NULL,
`name` VARCHAR(160) NOT NULL,
`label` VARCHAR(160) NULL,
`ownership_type` VARCHAR(50) NOT NULL DEFAULT 'shared_story',
`owner_character_id` BIGINT NULL,
`source_type` VARCHAR(80) NOT NULL DEFAULT 'manual',
`source_label` VARCHAR(160) NULL,
`prompt_text` LONGTEXT NULL,
`negative_prompt` LONGTEXT NULL,
`usage_tags_json` JSON NULL,
`quality_score` DECIMAL(5, 2) NULL,
`is_primary` BOOLEAN NOT NULL DEFAULT false,
`metadata_json` JSON NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `pva_project_kind_status_idx` (`project_id`, `asset_kind`, `status`),
INDEX `pva_project_type_idx` (`project_id`, `asset_type`),
INDEX `pva_owner_character_idx` (`owner_character_id`),
INDEX `pva_asset_idx` (`asset_id`),
INDEX `pva_user_kind_idx` (`user_id`, `asset_kind`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,20 @@
ALTER TABLE `project_visual_assets`
ADD COLUMN `aliases_json` JSON NULL,
ADD COLUMN `allowed_roles_json` JSON NULL,
ADD COLUMN `detected_from` VARCHAR(160) NULL,
ADD COLUMN `importance` INT NOT NULL DEFAULT 50,
ADD COLUMN `visual_lock` LONGTEXT NULL,
ADD COLUMN `key_objects_json` JSON NULL,
ADD COLUMN `prompt_block` LONGTEXT NULL,
ADD COLUMN `anchor_prompt` LONGTEXT NULL,
ADD COLUMN `reference_images_json` JSON NULL,
ADD COLUMN `anchor_images_json` JSON NULL,
ADD COLUMN `render_variants_json` JSON NULL,
ADD COLUMN `linked_assets_json` JSON NULL,
ADD COLUMN `reuse_rule` LONGTEXT NULL,
ADD COLUMN `story_scope` VARCHAR(120) NULL,
ADD COLUMN `version_note` LONGTEXT NULL,
ADD COLUMN `notes` LONGTEXT NULL;
CREATE INDEX `pva_project_ownership_idx` ON `project_visual_assets`(`project_id`, `ownership_type`);
CREATE INDEX `pva_project_status_importance_idx` ON `project_visual_assets`(`project_id`, `status`, `importance`);
@@ -0,0 +1,7 @@
ALTER TABLE `assets`
ADD COLUMN `display_name` VARCHAR(255) NULL,
ADD COLUMN `selection_status` VARCHAR(30) NOT NULL DEFAULT 'candidate',
ADD COLUMN `selection_note` TEXT NULL,
ADD COLUMN `metadata_json` JSON NULL;
CREATE INDEX `assets_project_id_selection_status_idx` ON `assets`(`project_id`, `selection_status`);
@@ -0,0 +1,4 @@
ALTER TABLE `storyboard_shots`
ADD COLUMN `transition_to_next` VARCHAR(50) NULL,
ADD COLUMN `transition_to_next_duration` DECIMAL(5, 2) NULL,
ADD COLUMN `transition_to_next_reason` TEXT NULL;
@@ -0,0 +1,42 @@
ALTER TABLE `character_images`
ADD COLUMN `prompt_quality_score` DECIMAL(5, 2) NULL AFTER `is_anchor`;
UPDATE `character_images`
SET `prompt_quality_score` = `quality_score`,
`quality_score` = NULL
WHERE `quality_score` IS NOT NULL;
CREATE TABLE `character_image_quality_reviews` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`character_id` BIGINT NOT NULL,
`character_image_id` BIGINT NOT NULL,
`asset_id` BIGINT NOT NULL,
`image_type` VARCHAR(80) NOT NULL,
`review_round` INTEGER NOT NULL DEFAULT 1,
`review_mode` VARCHAR(80) NOT NULL DEFAULT 'post_generation_visual',
`threshold_score` DECIMAL(5, 2) NOT NULL DEFAULT 96.00,
`score` DECIMAL(5, 2) NULL,
`grade` VARCHAR(20) NULL,
`passed` BOOLEAN NOT NULL DEFAULT false,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`dimensions_json` JSON NULL,
`strengths_json` JSON NULL,
`issues_json` JSON NULL,
`improvement_rules_json` JSON NULL,
`character_observations_json` JSON NULL,
`quality_gate_json` JSON NULL,
`provider_log_id` BIGINT NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(160) NULL,
`raw_text` LONGTEXT NULL,
`error_message` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `ciqr_project_created_idx` (`project_id`, `created_at`),
INDEX `ciqr_character_type_idx` (`character_id`, `image_type`),
INDEX `ciqr_image_round_idx` (`character_image_id`, `review_round`),
INDEX `ciqr_asset_idx` (`asset_id`),
INDEX `ciqr_type_passed_idx` (`image_type`, `passed`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,79 @@
ALTER TABLE `projects`
ADD COLUMN `engine_version` VARCHAR(50) NOT NULL DEFAULT 'legacy_v1',
ADD COLUMN `production_lifecycle` VARCHAR(80) NOT NULL DEFAULT 'legacy_snapshot';
CREATE INDEX `project_engine_lifecycle_idx`
ON `projects`(`engine_version`, `production_lifecycle`);
CREATE TABLE `production_contracts` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`contract_type` VARCHAR(50) NOT NULL,
`schema_version` VARCHAR(80) NOT NULL,
`version` INTEGER NOT NULL,
`scope_key` VARCHAR(100) NOT NULL DEFAULT 'project',
`parent_contract_id` BIGINT NULL,
`source_snapshot_id` VARCHAR(160) NULL,
`input_hash` VARCHAR(64) NOT NULL,
`input_snapshot_json` JSON NULL,
`payload_json` JSON NOT NULL,
`source_refs_json` JSON NULL,
`quality_result_json` JSON NULL,
`reviewer_delta_json` JSON NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`downstream_status` VARCHAR(50) NOT NULL DEFAULT 'blocked',
`created_by_user_id` BIGINT NULL,
`confirmed_at` DATETIME(3) NULL,
`released_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `pc_project_type_scope_version_key`(`project_id`, `contract_type`, `scope_key`, `version`),
INDEX `pc_project_type_status_idx`(`project_id`, `contract_type`, `status`),
INDEX `pc_parent_idx`(`parent_contract_id`),
INDEX `pc_input_hash_idx`(`input_hash`),
INDEX `pc_downstream_status_idx`(`downstream_status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `production_source_snapshots` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`snapshot_version` INTEGER NOT NULL,
`source_type` VARCHAR(50) NOT NULL,
`title` VARCHAR(255) NULL,
`content_hash` VARCHAR(64) NOT NULL,
`content_text` LONGTEXT NOT NULL,
`chapter_manifest_json` JSON NULL,
`metadata_json` JSON NULL,
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `pss_project_version_key`(`project_id`, `snapshot_version`),
INDEX `pss_project_source_idx`(`project_id`, `novel_source_id`),
INDEX `pss_content_hash_idx`(`content_hash`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `production_contract_reviews` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`contract_id` BIGINT NOT NULL,
`reviewer_type` VARCHAR(80) NOT NULL,
`reviewer_version` VARCHAR(80) NOT NULL,
`review_no` INTEGER NOT NULL,
`passed` BOOLEAN NOT NULL DEFAULT false,
`quality_score` DECIMAL(5, 2) NULL,
`issues_json` JSON NULL,
`delta_json` JSON NULL,
`provider_log_id` BIGINT NULL,
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `pcr_contract_reviewer_no_key`(`contract_id`, `reviewer_type`, `review_no`),
INDEX `pcr_project_reviewer_idx`(`project_id`, `reviewer_type`),
INDEX `pcr_contract_passed_idx`(`contract_id`, `passed`),
INDEX `pcr_provider_log_idx`(`provider_log_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,213 @@
UPDATE `provider_configs`
SET
`display_name` = CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video' THEN '可灵 v3 Omni 原生音频 720P 视频'
ELSE '可灵 v3 Omni 原生音频 1080P 视频'
END,
`config_json` = JSON_REMOVE(
JSON_SET(
COALESCE(`config_json`, JSON_OBJECT()),
'$.driver', 'kling_omni_video',
'$.auth_type', 'bearer',
'$.api_key_env', 'KLING_API_KEY',
'$.base_url', 'https://api-singapore.klingai.com',
'$.timeout_ms', 600000,
'$.create_endpoint', '/v1/videos/omni-video',
'$.task_endpoint_template', '/v1/videos/omni-video/{task_id}',
'$.poll_interval_ms', 10000,
'$.max_poll_attempts', 120,
'$.model_field', 'model_name',
'$.prompt_field', 'prompt',
'$.duration_field', 'duration',
'$.aspect_ratio_field', 'aspect_ratio',
'$.mode_field', 'mode',
'$.aspect_ratio', '9:16',
'$.mode', CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video' THEN 'std'
ELSE 'pro'
END,
'$.resolution', CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video' THEN '720p'
ELSE '1080p'
END,
'$.duration', 5,
'$.allowed_modes', JSON_ARRAY('std', 'pro', '4k'),
'$.allowed_resolutions', JSON_ARRAY('720p', '1080p', '4k'),
'$.allowed_durations', JSON_ARRAY(3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
'$.max_prompt_length', 2500,
'$.max_multi_prompt_length', 512,
'$.max_multi_shots', 6,
'$.max_image_inputs', 7,
'$.web_max_image_inputs', 7,
'$.max_reference_images', 6,
'$.reference_image_limit', 6,
'$.max_image_inputs_without_video', 7,
'$.max_image_inputs_with_video', 4,
'$.max_video_inputs', 1,
'$.max_elements_with_start_end_frames', 3,
'$.input_image_formats', JSON_ARRAY('jpg', 'jpeg', 'png'),
'$.input_image_mime_types', JSON_ARRAY('image/jpeg', 'image/png'),
'$.input_image_max_mb', 10,
'$.input_image_min_dimension', 300,
'$.input_image_min_aspect_ratio', 0.4,
'$.input_image_max_aspect_ratio', 2.5,
'$.video_input_formats', JSON_ARRAY('mp4', 'mov'),
'$.video_input_min_seconds', 3,
'$.video_input_max_seconds', 15.5,
'$.video_input_max_mb', 200,
'$.video_input_min_fps', 24,
'$.video_input_max_fps', 60,
'$.video_input_min_dimension', 700,
'$.video_input_max_dimension', 4553,
'$.video_input_max_area', 8294400,
'$.video_modes', JSON_ARRAY('text_to_video', 'first_frame', 'start_end_frame', 'multi_image_reference', 'video_reference'),
'$.extra_body_json', JSON_OBJECT('sound', 'on', 'watermark_info', JSON_OBJECT('enabled', false)),
'$.supports_4k', true,
'$.supports_reference_image', true,
'$.supports_start_end_frame', true,
'$.supports_multi_image_reference', true,
'$.supports_reference_video', true,
'$.supports_multi_shot', true,
'$.supports_audio', true,
'$.supports_lipsync', true,
'$.supports_character_reference', true,
'$.supports_multi_character', true,
'$.supports_style_reference', true,
'$.note', 'Kling 3.0 Omni 官方新接口:支持原生 4K、最多 7 个图片与元素参考、单个参考视频、多镜头及原生音频。'
),
'$.access_key_env',
'$.secret_key_env',
'$.jwt_ttl_seconds',
'$.jwt_nbf_skew_seconds',
'$.image_field',
'$.reference_images_field',
'$.resolution_field'
),
`cost_rule_json` = JSON_SET(
COALESCE(`cost_rule_json`, JSON_OBJECT()),
'$.unit', 'video_seconds',
'$.currency', 'USD',
'$.price_per_second', CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video' THEN 0.112
ELSE 0.14
END,
'$.cny_fx_rate', 6.79,
'$.max_cost_per_call', CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video' THEN 2
ELSE 3
END,
'$.daily_cost_limit', CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video' THEN 30
ELSE 50
END,
'$.estimated_seconds', 10,
'$.note', CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video'
THEN 'Kling v3 Omni 原生音频 720P$0.112/s10s 约 $1.12 / ¥7.61;最终以 Kling 账单为准。'
ELSE 'Kling v3 Omni 原生音频 1080P$0.14/s10s 约 $1.40 / ¥9.51;最终以 Kling 账单为准。'
END
),
`updated_at` = NOW(3)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN (
'kling-v3-omni-native-audio-720p-video',
'kling-v3-omni-native-audio-1080p-video'
);
INSERT INTO `provider_configs`
(`provider_type`, `provider_code`, `display_name`, `mode`, `model_name`, `config_json`, `is_enabled`, `priority`, `rate_limit_json`, `cost_rule_json`, `created_at`, `updated_at`)
VALUES
(
'VideoProvider',
'kling-v3-omni-native-audio-4k-video',
'可灵 v3 Omni 原生音频 4K 视频',
'real',
'kling-v3-omni',
JSON_OBJECT(
'driver', 'kling_omni_video',
'auth_type', 'bearer',
'api_key_env', 'KLING_API_KEY',
'base_url', 'https://api-singapore.klingai.com',
'timeout_ms', 600000,
'create_endpoint', '/v1/videos/omni-video',
'task_endpoint_template', '/v1/videos/omni-video/{task_id}',
'poll_interval_ms', 10000,
'max_poll_attempts', 120,
'model_field', 'model_name',
'prompt_field', 'prompt',
'duration_field', 'duration',
'aspect_ratio_field', 'aspect_ratio',
'mode_field', 'mode',
'aspect_ratio', '9:16',
'mode', '4k',
'resolution', '4k',
'duration', 5,
'allowed_modes', JSON_ARRAY('std', 'pro', '4k'),
'allowed_resolutions', JSON_ARRAY('720p', '1080p', '4k'),
'allowed_durations', JSON_ARRAY(3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
'max_prompt_length', 2500,
'max_multi_prompt_length', 512,
'max_multi_shots', 6,
'max_image_inputs', 7,
'web_max_image_inputs', 7,
'max_reference_images', 6,
'reference_image_limit', 6,
'max_image_inputs_without_video', 7,
'max_image_inputs_with_video', 4,
'max_video_inputs', 1,
'max_elements_with_start_end_frames', 3,
'input_image_formats', JSON_ARRAY('jpg', 'jpeg', 'png'),
'input_image_mime_types', JSON_ARRAY('image/jpeg', 'image/png'),
'input_image_max_mb', 10,
'input_image_min_dimension', 300,
'input_image_min_aspect_ratio', 0.4,
'input_image_max_aspect_ratio', 2.5,
'video_input_formats', JSON_ARRAY('mp4', 'mov'),
'video_input_min_seconds', 3,
'video_input_max_seconds', 15.5,
'video_input_max_mb', 200,
'video_input_min_fps', 24,
'video_input_max_fps', 60,
'video_input_min_dimension', 700,
'video_input_max_dimension', 4553,
'video_input_max_area', 8294400,
'video_modes', JSON_ARRAY('text_to_video', 'first_frame', 'start_end_frame', 'multi_image_reference', 'video_reference'),
'extra_body_json', JSON_OBJECT('sound', 'on', 'watermark_info', JSON_OBJECT('enabled', false)),
'supports_4k', true,
'supports_reference_image', true,
'supports_start_end_frame', true,
'supports_multi_image_reference', true,
'supports_reference_video', true,
'supports_multi_shot', true,
'supports_audio', true,
'supports_lipsync', true,
'supports_character_reference', true,
'supports_multi_character', true,
'supports_style_reference', true,
'note', '默认禁用。Kling 3.0 Omni 官方原生 4K 模式,支持最多 7 个图片与元素参考;4K 成本较高,正式批量前需单镜头验证。'
),
false,
41,
JSON_OBJECT('rpm', 5, 'concurrency', 1),
JSON_OBJECT(
'flat_cost', 0,
'unit', 'video_seconds',
'price_per_second', 0.42,
'currency', 'USD',
'cny_fx_rate', 6.79,
'max_cost_per_call', 7,
'daily_cost_limit', 100,
'estimated_seconds', 10,
'note', 'Kling v3 Omni 原生音频 4K$0.42/s10s 约 $4.20 / ¥28.52;最终以 Kling 账单为准。'
),
NOW(3),
NOW(3)
)
ON DUPLICATE KEY UPDATE
`display_name` = VALUES(`display_name`),
`mode` = VALUES(`mode`),
`model_name` = VALUES(`model_name`),
`priority` = VALUES(`priority`),
`rate_limit_json` = VALUES(`rate_limit_json`),
`cost_rule_json` = VALUES(`cost_rule_json`),
`updated_at` = NOW(3);
@@ -0,0 +1,230 @@
UPDATE `provider_configs`
SET
`config_json` = JSON_REMOVE(
JSON_SET(
COALESCE(`config_json`, JSON_OBJECT()),
'$.driver', 'kling_v3_image_to_video',
'$.auth_type', 'bearer',
'$.api_key_env', 'KLING_API_KEY',
'$.base_url', 'https://api-singapore.klingai.com',
'$.timeout_ms', 600000,
'$.create_endpoint', '/v1/videos/image2video',
'$.task_endpoint_template', '/v1/videos/image2video/{task_id}',
'$.poll_interval_ms', 10000,
'$.max_poll_attempts', 120,
'$.aspect_ratio', '9:16',
'$.mode', CASE
WHEN `provider_code` = 'kling-v3-native-audio-720p-video' THEN 'std'
ELSE 'pro'
END,
'$.resolution', CASE
WHEN `provider_code` = 'kling-v3-native-audio-720p-video' THEN '720p'
ELSE '1080p'
END,
'$.duration', 5,
'$.allowed_modes', JSON_ARRAY('std', 'pro', '4k'),
'$.allowed_resolutions', JSON_ARRAY('720p', '1080p', '4k'),
'$.allowed_durations', JSON_ARRAY(3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
'$.max_prompt_length', 2500,
'$.max_multi_prompt_length', 512,
'$.max_multi_shots', 6,
'$.max_image_inputs', 2,
'$.web_max_image_inputs', 2,
'$.max_elements', 3,
'$.max_voices', 2,
'$.input_image_formats', JSON_ARRAY('jpg', 'jpeg', 'png'),
'$.input_image_mime_types', JSON_ARRAY('image/jpeg', 'image/png'),
'$.input_image_max_mb', 10,
'$.input_image_min_dimension', 300,
'$.input_image_min_aspect_ratio', 0.4,
'$.input_image_max_aspect_ratio', 2.5,
'$.video_modes', JSON_ARRAY('first_frame', 'end_frame', 'start_end_frame'),
'$.extra_body_json', JSON_OBJECT('sound', 'on', 'watermark_info', JSON_OBJECT('enabled', false)),
'$.supports_4k', true,
'$.supports_reference_image', true,
'$.supports_start_end_frame', true,
'$.supports_elements', true,
'$.supports_voices', true,
'$.supports_multi_shot', true,
'$.supports_audio', true,
'$.supports_lipsync', true,
'$.supports_character_reference', true,
'$.supports_multi_character', true,
'$.supports_style_reference', false,
'$.capability_version', 'kling_video_capabilities_2026-07-15',
'$.formal_splus_model', true,
'$.official_doc_url', 'https://kling.ai/document-api/api/video/3-0-omni/image-to-video',
'$.note', 'Kling v3 官方 image2video 适配器:首帧/尾帧、最多3个元素、最多2个音色、自定义或智能多镜头、原生音频与4K。'
),
'$.access_key_env',
'$.secret_key_env',
'$.jwt_ttl_seconds',
'$.jwt_nbf_skew_seconds',
'$.model_field',
'$.image_field',
'$.prompt_field',
'$.duration_field',
'$.aspect_ratio_field',
'$.resolution_field',
'$.reference_images_field',
'$.max_reference_images',
'$.reference_image_limit'
),
`updated_at` = NOW(3)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN (
'kling-v3-native-audio-720p-video',
'kling-v3-native-audio-video'
);
INSERT INTO `provider_configs`
(`provider_type`, `provider_code`, `display_name`, `mode`, `model_name`, `config_json`, `is_enabled`, `priority`, `rate_limit_json`, `cost_rule_json`, `created_at`, `updated_at`)
VALUES
(
'VideoProvider',
'kling-v3-native-audio-4k-video',
'可灵 v3 原生音频 4K 图生视频',
'real',
'kling-v3',
JSON_OBJECT(
'driver', 'kling_v3_image_to_video',
'auth_type', 'bearer',
'api_key_env', 'KLING_API_KEY',
'base_url', 'https://api-singapore.klingai.com',
'timeout_ms', 600000,
'create_endpoint', '/v1/videos/image2video',
'task_endpoint_template', '/v1/videos/image2video/{task_id}',
'poll_interval_ms', 10000,
'max_poll_attempts', 120,
'aspect_ratio', '9:16',
'mode', '4k',
'resolution', '4k',
'duration', 5,
'allowed_modes', JSON_ARRAY('std', 'pro', '4k'),
'allowed_resolutions', JSON_ARRAY('720p', '1080p', '4k'),
'allowed_durations', JSON_ARRAY(3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
'max_prompt_length', 2500,
'max_multi_prompt_length', 512,
'max_multi_shots', 6,
'max_image_inputs', 2,
'web_max_image_inputs', 2,
'max_elements', 3,
'max_voices', 2,
'input_image_formats', JSON_ARRAY('jpg', 'jpeg', 'png'),
'input_image_mime_types', JSON_ARRAY('image/jpeg', 'image/png'),
'input_image_max_mb', 10,
'input_image_min_dimension', 300,
'input_image_min_aspect_ratio', 0.4,
'input_image_max_aspect_ratio', 2.5,
'video_modes', JSON_ARRAY('first_frame', 'end_frame', 'start_end_frame'),
'extra_body_json', JSON_OBJECT('sound', 'on', 'watermark_info', JSON_OBJECT('enabled', false)),
'supports_4k', true,
'supports_reference_image', true,
'supports_start_end_frame', true,
'supports_elements', true,
'supports_voices', true,
'supports_multi_shot', true,
'supports_audio', true,
'supports_lipsync', true,
'supports_character_reference', true,
'supports_multi_character', true,
'supports_style_reference', false,
'capability_version', 'kling_video_capabilities_2026-07-15',
'formal_splus_model', true,
'official_doc_url', 'https://kling.ai/document-api/api/video/3-0-omni/image-to-video',
'note', '默认禁用。Kling v3 官方图生视频4K模式;正式批量前需单镜头验证。'
),
false,
43,
JSON_OBJECT('rpm', 5, 'concurrency', 1),
JSON_OBJECT(
'flat_cost', 0,
'unit', 'video_seconds',
'price_per_second', 0.504,
'currency', 'USD',
'cny_fx_rate', 6.79,
'max_cost_per_call', 8,
'daily_cost_limit', 120,
'estimated_seconds', 10,
'note', 'Kling v3 原生音频4K暂估 $0.504/s10s约 $5.04 / ¥34.22;最终以Kling账单为准。'
),
NOW(3),
NOW(3)
)
ON DUPLICATE KEY UPDATE
`display_name` = VALUES(`display_name`),
`mode` = VALUES(`mode`),
`model_name` = VALUES(`model_name`),
`priority` = VALUES(`priority`),
`rate_limit_json` = VALUES(`rate_limit_json`),
`cost_rule_json` = VALUES(`cost_rule_json`),
`updated_at` = NOW(3);
UPDATE `provider_configs`
SET
`config_json` = JSON_SET(
COALESCE(`config_json`, JSON_OBJECT()),
'$.capability_version', 'kling_video_capabilities_2026-07-15',
'$.formal_splus_model', true,
'$.official_doc_url', 'https://kling.ai/document-api/api/video/3-0-omni/video-omni',
'$.reference_video_default_type', 'base',
'$.reference_video_character_policy', 'reject_with_reference_video',
'$.custom_multi_shot_omits_top_prompt', true
),
`updated_at` = NOW(3)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN (
'kling-v3-omni-native-audio-720p-video',
'kling-v3-omni-native-audio-1080p-video',
'kling-v3-omni-native-audio-4k-video'
);
UPDATE `provider_configs`
SET
`config_json` = JSON_SET(
COALESCE(`config_json`, JSON_OBJECT()),
'$.formal_splus_model', false,
'$.splus_usage', 'legacy_or_experiment_only'
),
`updated_at` = NOW(3)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` LIKE 'kling%'
AND `provider_code` NOT IN (
'kling-v3-native-audio-720p-video',
'kling-v3-native-audio-video',
'kling-v3-native-audio-4k-video',
'kling-v3-omni-native-audio-720p-video',
'kling-v3-omni-native-audio-1080p-video',
'kling-v3-omni-native-audio-4k-video'
);
UPDATE `system_configs`
SET
`config_value` = JSON_SET(
COALESCE(`config_value`, JSON_OBJECT()),
'$.splus_live_action_video',
JSON_OBJECT(
'capability_version', 'kling_video_capabilities_2026-07-15',
'formal_provider_codes', JSON_ARRAY(
'kling-v3-omni-native-audio-720p-video',
'kling-v3-omni-native-audio-1080p-video',
'kling-v3-omni-native-audio-4k-video',
'kling-v3-native-audio-720p-video',
'kling-v3-native-audio-video',
'kling-v3-native-audio-4k-video'
),
'zh-CN', JSON_OBJECT(
'thresholds', JSON_OBJECT('premium_importance_gt', 7, 'premium_action_gt', 5),
'normal', JSON_OBJECT(
'provider_code', 'kling-v3-omni-native-audio-1080p-video',
'fallback_chain', JSON_ARRAY('kling-v3-omni-native-audio-1080p-video', 'kling-v3-native-audio-video')
),
'premium', JSON_OBJECT(
'provider_code', 'kling-v3-omni-native-audio-1080p-video',
'fallback_chain', JSON_ARRAY('kling-v3-omni-native-audio-1080p-video', 'kling-v3-native-audio-video')
)
)
)
),
`updated_at` = NOW(3)
WHERE `config_key` = 'ai.router.v1';
@@ -0,0 +1,15 @@
UPDATE `provider_configs`
SET
`cost_rule_json` = JSON_SET(
COALESCE(`cost_rule_json`, JSON_OBJECT()),
'$.price_per_second', 0.42,
'$.currency', 'USD',
'$.cny_fx_rate', 6.79,
'$.max_cost_per_call', 7,
'$.daily_cost_limit', 100,
'$.estimated_seconds', 10,
'$.note', 'Kling v3 原生音频4K $0.42/s10s约 $4.20 / ¥28.52;最终以Kling账单为准。'
),
`updated_at` = NOW(3)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` = 'kling-v3-native-audio-4k-video';
@@ -0,0 +1,64 @@
CREATE TABLE `shot_generation_plans` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`episode_id` BIGINT NOT NULL,
`shot_id` BIGINT NOT NULL,
`revision` INTEGER NOT NULL,
`plan_version` VARCHAR(80) NOT NULL,
`plan_hash` VARCHAR(64) NOT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'frozen',
`source_engine_version` VARCHAR(50) NOT NULL,
`provider_code` VARCHAR(100) NOT NULL,
`provider_id` BIGINT NULL,
`model_name` VARCHAR(160) NULL,
`endpoint` VARCHAR(255) NULL,
`capability_version` VARCHAR(100) NULL,
`route_tier` VARCHAR(50) NULL,
`effective_mode` VARCHAR(30) NULL,
`resolution_recommendation` VARCHAR(30) NULL,
`effective_generation_resolution` VARCHAR(30) NULL,
`aspect_ratio` VARCHAR(30) NULL,
`duration` DECIMAL(6, 2) NULL,
`sound_enabled` BOOLEAN NOT NULL DEFAULT true,
`multi_shot` BOOLEAN NOT NULL DEFAULT false,
`native_4k_candidate` BOOLEAN NOT NULL DEFAULT false,
`project_native_4k_enabled` BOOLEAN NOT NULL DEFAULT false,
`required_assets_json` JSON NULL,
`element_plan_json` JSON NULL,
`voice_plan_json` JSON NULL,
`keyframe_plan_json` JSON NULL,
`video_request_json` JSON NULL,
`router_decision_json` JSON NOT NULL,
`quality_policy_json` JSON NOT NULL,
`retry_policy_json` JSON NOT NULL,
`fallback_chain_json` JSON NOT NULL,
`cost_policy_json` JSON NULL,
`provider_snapshot_json` JSON NULL,
`prompt_snapshot_json` JSON NULL,
`frozen_by_user_id` BIGINT NULL,
`frozen_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `sgp_shot_revision_key` (`shot_id`, `revision`),
UNIQUE INDEX `sgp_shot_hash_key` (`shot_id`, `plan_hash`),
INDEX `sgp_project_episode_shot_idx` (`project_id`, `episode_id`, `shot_id`),
INDEX `sgp_provider_capability_idx` (`provider_code`, `capability_version`),
INDEX `sgp_status_created_idx` (`status`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `storyboard_shots`
ADD COLUMN `active_generation_plan_id` BIGINT NULL,
ADD INDEX `ss_active_generation_plan_idx` (`active_generation_plan_id`);
ALTER TABLE `shot_images`
ADD COLUMN `generation_plan_id` BIGINT NULL,
ADD INDEX `si_generation_plan_idx` (`generation_plan_id`);
ALTER TABLE `video_clips`
ADD COLUMN `generation_plan_id` BIGINT NULL,
ADD INDEX `vc_generation_plan_idx` (`generation_plan_id`);
ALTER TABLE `render_tasks`
ADD COLUMN `generation_plan_id` BIGINT NULL,
ADD INDEX `rt_generation_plan_idx` (`generation_plan_id`);
@@ -0,0 +1,71 @@
ALTER TABLE `shot_generation_plans`
ADD COLUMN `capability_registry_version_id` BIGINT NULL AFTER `provider_id`,
ADD COLUMN `parameter_schema_version_id` BIGINT NULL AFTER `capability_registry_version_id`,
ADD COLUMN `pricing_version_id` BIGINT NULL AFTER `parameter_schema_version_id`;
CREATE INDEX `sgp_capability_registry_idx`
ON `shot_generation_plans`(`capability_registry_version_id`);
CREATE INDEX `sgp_parameter_schema_idx`
ON `shot_generation_plans`(`parameter_schema_version_id`);
CREATE INDEX `sgp_pricing_version_idx`
ON `shot_generation_plans`(`pricing_version_id`);
CREATE TABLE `model_capability_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`provider_type` VARCHAR(80) NOT NULL,
`provider_code` VARCHAR(100) NOT NULL,
`model_name` VARCHAR(160) NULL,
`revision` INTEGER NOT NULL,
`version_key` VARCHAR(160) NOT NULL,
`content_hash` VARCHAR(64) NOT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'published',
`capability_json` JSON NOT NULL,
`source_json` JSON NULL,
`effective_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `mcv_provider_revision_key`(`provider_type`, `provider_code`, `revision`),
UNIQUE INDEX `mcv_provider_hash_key`(`provider_type`, `provider_code`, `content_hash`),
INDEX `mcv_provider_status_effective_idx`(`provider_code`, `status`, `effective_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `model_parameter_schema_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`provider_type` VARCHAR(80) NOT NULL,
`provider_code` VARCHAR(100) NOT NULL,
`model_name` VARCHAR(160) NULL,
`revision` INTEGER NOT NULL,
`version_key` VARCHAR(160) NOT NULL,
`content_hash` VARCHAR(64) NOT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'published',
`schema_json` JSON NOT NULL,
`source_json` JSON NULL,
`effective_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `mpsv_provider_revision_key`(`provider_type`, `provider_code`, `revision`),
UNIQUE INDEX `mpsv_provider_hash_key`(`provider_type`, `provider_code`, `content_hash`),
INDEX `mpsv_provider_status_effective_idx`(`provider_code`, `status`, `effective_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `model_pricing_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`provider_type` VARCHAR(80) NOT NULL,
`provider_code` VARCHAR(100) NOT NULL,
`model_name` VARCHAR(160) NULL,
`revision` INTEGER NOT NULL,
`version_key` VARCHAR(160) NOT NULL,
`content_hash` VARCHAR(64) NOT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'published',
`pricing_json` JSON NOT NULL,
`source_json` JSON NULL,
`effective_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `mpv_provider_revision_key`(`provider_type`, `provider_code`, `revision`),
UNIQUE INDEX `mpv_provider_hash_key`(`provider_type`, `provider_code`, `content_hash`),
INDEX `mpv_provider_status_effective_idx`(`provider_code`, `status`, `effective_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,60 @@
CREATE TABLE `character_provider_bindings` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`global_character_id` BIGINT NOT NULL,
`look_version_id` BIGINT NULL,
`source_asset_id` BIGINT NULL,
`provider_code` VARCHAR(100) NOT NULL,
`provider_asset_type` VARCHAR(50) NOT NULL DEFAULT 'video_character_element',
`provider_element_id` VARCHAR(191) NOT NULL,
`element_name` VARCHAR(160) NOT NULL,
`element_description` TEXT NULL,
`source_duration` DECIMAL(5,2) NULL,
`voice_bound` BOOLEAN NOT NULL DEFAULT false,
`voice_id` VARCHAR(160) NULL,
`voice_description` TEXT NULL,
`binding_version` INTEGER NOT NULL DEFAULT 1,
`validation_score` DECIMAL(5,2) NULL,
`validation_report_json` JSON NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'candidate',
`is_primary` BOOLEAN NOT NULL DEFAULT false,
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `cpb_provider_element_key`(`provider_code`, `provider_element_id`),
INDEX `cpb_character_provider_status_idx`(`global_character_id`, `provider_code`, `status`),
INDEX `cpb_look_version_idx`(`look_version_id`),
INDEX `cpb_source_asset_idx`(`source_asset_id`),
INDEX `cpb_primary_status_idx`(`is_primary`, `status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
UPDATE `provider_configs`
SET `config_json` = JSON_SET(
COALESCE(`config_json`, JSON_OBJECT()),
'$.aspect_ratio', '16:9',
'$.allowed_aspect_ratios', JSON_ARRAY('16:9'),
'$.production_aspect_ratio', '16:9'
)
WHERE `provider_type` IN ('ImageProvider', 'VideoProvider');
UPDATE `provider_configs`
SET `config_json` = JSON_SET(`config_json`, '$.size', '2560x1440')
WHERE `provider_type` = 'ImageProvider'
AND JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.size')) = '1440x2560';
UPDATE `provider_configs`
SET `config_json` = JSON_SET(`config_json`, '$.size', '1536x1024')
WHERE `provider_type` = 'ImageProvider'
AND JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.size')) = '1024x1792';
UPDATE `provider_configs`
SET `config_json` = JSON_SET(
`config_json`,
'$.width', CAST(JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.height')) AS UNSIGNED),
'$.height', CAST(JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.width')) AS UNSIGNED)
)
WHERE `provider_type` IN ('ImageProvider', 'VideoProvider')
AND JSON_TYPE(JSON_EXTRACT(`config_json`, '$.width')) IN ('INTEGER', 'DOUBLE')
AND JSON_TYPE(JSON_EXTRACT(`config_json`, '$.height')) IN ('INTEGER', 'DOUBLE')
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.width')) AS UNSIGNED)
< CAST(JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.height')) AS UNSIGNED);
@@ -0,0 +1,45 @@
UPDATE `provider_configs`
SET
`config_json` = JSON_SET(
COALESCE(`config_json`, JSON_OBJECT()),
'$.size', '2560x1440',
'$.quality', 'medium',
'$.allowed_sizes', JSON_ARRAY('1920x1080', '2560x1440', '3840x2160'),
'$.allowed_qualities', JSON_ARRAY('low', 'medium', 'high', 'auto'),
'$.supports_custom_size', true,
'$.supports_4k', true,
'$.max_width', 3840,
'$.max_height', 3840,
'$.max_pixels', 8294400,
'$.max_aspect_ratio', 3,
'$.experimental_above_size', '2560x1440',
'$.official_doc_url', 'https://developers.openai.com/api/docs/guides/image-generation'
),
`cost_rule_json` = JSON_REMOVE(
JSON_SET(
COALESCE(`cost_rule_json`, JSON_OBJECT()),
'$.flat_cost', 0,
'$.unit', 'openai_image_tokens',
'$.currency', 'USD',
'$.cny_fx_rate', 7.2,
'$.pricing_basis', 'official_openai_gpt_image_2_token_formula_2026_07_16',
'$.default_quality', 'medium',
'$.default_size', '2560x1440',
'$.estimated_cost_per_image', 0.05529,
'$.price_per_1m_text_input_tokens', 5,
'$.price_per_1m_cached_text_input_tokens', 1.25,
'$.price_per_1m_image_input_tokens', 8,
'$.price_per_1m_cached_image_input_tokens', 2,
'$.price_per_1m_image_output_tokens', 30,
'$.output_token_formula', 'ceil(long_quality_tokens*round(long_quality_tokens*short_edge/long_edge)*(2000000+width*height)/4000000)',
'$.output_price_examples', JSON_OBJECT(
'2560x1440_high', 0.2211,
'3840x2160_high', 0.40026
),
'$.note', 'GPT Image 2 按文字输入、参考图输入和图片输出 token 计费;2560×1440 High 纯输出约 $0.22113840×2160 High 纯输出约 $0.4003。参考图与提示词输入另计,以 OpenAI usage 和账单为准。'
),
'$.price_per_image_by_quality',
'$.comparable_models'
)
WHERE `provider_type` = 'ImageProvider'
AND `provider_code` = 'openai-image';
@@ -0,0 +1,2 @@
ALTER TABLE `project_pipeline_configs`
ALTER COLUMN `scene_composer_version` SET DEFAULT 'scene_composer_plan_v2';
@@ -0,0 +1,26 @@
UPDATE `project_pipeline_configs`
SET
`scene_composer_version` = 'scene_composer_plan_v2',
`scene_composer_config_json` = JSON_SET(
COALESCE(`scene_composer_config_json`, JSON_OBJECT()),
'$.plan_version', 'scene_composer_plan_v2',
'$.transition_contract_version', 'transition_contract_v2',
'$.picture_edit_policy', 'hard_picture_by_default_explicit_blends_only',
'$.audio_edit_policy', 'independent_j_cut_l_cut_and_audio_bridge',
'$.planned_features', JSON_ARRAY(
'shot_ordering',
'emotion_curve',
'transition_contract_v2',
'boundary_frame_policy',
'screen_vector_continuity',
'hard_picture_cut',
'explicit_visual_blends_only',
'independent_j_cut_l_cut',
'episode_builder',
'bgm_alignment',
'original_music_bible',
'sfx_cue_plan',
'ducking_plan'
),
'$.note', 'Scene Composer Plan V2 keeps picture hard by default, permits visual overlap only for explicit blend contracts, and schedules J-cuts, L-cuts and audio bridges independently.'
);
+1151 -256
View File
File diff suppressed because it is too large Load Diff
+324 -1
View File
@@ -1,4 +1,4 @@
import { PrismaClient } from '@prisma/client';
import { Prisma, PrismaClient } from '@prisma/client';
import { hash } from 'bcryptjs';
import { DEFAULT_AI_ROUTER_CONFIG } from '../src/ai-router/ai-router.types';
@@ -17,6 +17,296 @@ const mockProviders = [
['EmbeddingProvider', 'mock-embedding', 'Mock Embedding Provider', 'mock-embedding-v1']
] as const;
const defaultAgentPrompts = [
{
agent_name: 'NovelIdeaCoachAgent',
provider_type: 'TextProvider',
default_provider_code: 'volcengine-doubao-seed20-mini-text',
system_prompt: '你是网文平台金牌主编、商业化小说策划、听书编导和短剧改编策划。你的用户是不会写小说的运营,你必须把他的白话需求变成可选择的高质量小说方案。',
user_prompt_template: [
'请根据运营输入生成 3 个高商业化小说方向卡。必须严格输出紧凑 JSON,不要 Markdown,不要解释。',
'运营不是专业作家,所以每个方案要短、准、好判断:为什么能火、主角是谁、开篇怎么抓人。',
'本步骤只做方向筛选,不写卷纲和章节蓝图;后续 IP 圣经 Agent 会补完整结构。',
'必须兼顾后续听书和短剧改编:人物关系清楚、场景可视化、每章能有钩子。',
'输出 JSON 字段:proposals。proposals 是数组,每个元素包含:',
'id, title, genre, novel_scale, target_audience, target_words, target_chapters, style_code, logline, opening_hook, main_character, core_conflict, selling_points, risk_notes, adaptation_targets, writing_rules, forbidden_rules, score, operator_reason。',
'要求:',
'1. title 要像真实网文书名,不要泛泛而谈。',
'2. logline 不超过 35 字,opening_hook 不超过 55 字。',
'3. main_character 和 core_conflict 各不超过 35 字。',
'4. selling_points 只给 3 条,每条不超过 10 字;risk_notes 只给 2 条,每条不超过 12 字。',
'5. writing_rules 只给 2 条,forbidden_rules 只给 2 条,每条不超过 12 字。',
'6. target_words 和 target_chapters 必须是数字或 null。',
'7. adaptation_targets 只输出 ["听书","短剧"]operator_reason 不超过 35 字,score 是 0-100。',
'小说长度:{{novel_scale}}',
'目标读者:{{target_audience}}',
'题材:{{genre}}',
'风格代码:{{style_code}}',
'目标字数:{{target_words}}',
'目标章节数:{{target_chapters}}',
'后续用途:{{adaptation_targets}}',
'运营灵感:{{inspiration_text}}',
'必须包含:{{must_have_text}}',
'禁止内容:{{avoid_text}}',
'参考风格/作品:{{reference_titles}}'
].join('\n\n'),
output_schema_json: {
type: 'object',
required: ['proposals'],
properties: {
proposals: {
type: 'array',
items: {
type: 'object',
required: ['title', 'logline', 'opening_hook', 'main_character', 'core_conflict', 'selling_points', 'score'],
properties: {
id: { type: 'string' },
title: { type: 'string' },
genre: { type: 'string' },
novel_scale: { type: 'string' },
target_audience: { type: 'string' },
target_words: { type: 'number' },
target_chapters: { type: 'number' },
style_code: { type: 'string' },
logline: { type: 'string' },
opening_hook: { type: 'string' },
main_character: { type: 'string' },
core_conflict: { type: 'string' },
selling_points: { type: 'array' },
risk_notes: { type: 'array' },
adaptation_targets: { type: 'array' },
writing_rules: { type: 'array' },
forbidden_rules: { type: 'array' },
score: { type: 'number' },
operator_reason: { type: 'string' }
}
}
}
}
},
temperature: 0.72,
max_output_tokens: 1200
},
{
agent_name: 'IPBibleAgent',
provider_type: 'NovelProvider',
default_provider_code: 'openai-responses-novel',
system_prompt: '你是专业长篇小说总策划、网文主编、文学作家、剧作结构师和影视导演。你的任务是建立稳定、可长期创作、可听书、可短剧改编的 IP 圣经。',
user_prompt_template: [
'请根据用户 Brief 生成 IP 圣经。必须严格输出 JSON,不要 Markdown。',
'JSON 字段:core_logline, theme, worldbuilding, main_characters, supporting_characters, antagonist_system, volume_structure, foreshadow_plan, writing_rules, forbidden_rules, adaptation_rules。',
'要求:主线清晰,人设稳定,有禁止改动项,有伏笔规划,考虑听书和 AI 短剧改编。',
'用户 Brief{{brief}}',
'小说类型:{{novel_scale}}',
'题材:{{genre}}',
'风格:{{style}}',
'禁止内容:{{avoid}}'
].join('\n\n'),
output_schema_json: {
type: 'object',
required: ['core_logline', 'theme', 'worldbuilding', 'main_characters', 'volume_structure', 'writing_rules', 'forbidden_rules'],
properties: {
core_logline: { type: 'string' },
theme: { type: 'string' },
worldbuilding: { type: 'object' },
main_characters: { type: 'array' },
supporting_characters: { type: 'array' },
antagonist_system: { type: 'object' },
volume_structure: { type: 'array' },
foreshadow_plan: { type: 'array' },
writing_rules: { type: 'array' },
forbidden_rules: { type: 'array' },
adaptation_rules: { type: 'object' }
}
},
temperature: 0.6,
max_output_tokens: 6000
},
{
agent_name: 'ChapterCardAgent',
provider_type: 'NovelProvider',
default_provider_code: 'openai-responses-novel',
system_prompt: '你是长篇小说章节导演。你的任务是设计下一章章节卡,不写正文,只输出可执行的章节方案。',
user_prompt_template: [
'请生成第 {{chapter_no}} 章章节卡。必须严格输出 JSON,不要 Markdown。',
'本章必须承接上一章,推动主线、人物变化或伏笔进展,不得水剧情。',
'IP 圣经摘要:{{ip_bible_summary}}',
'当前卷纲:{{volume_outline}}',
'最近章节摘要:{{recent_summaries}}',
'人物当前状态:{{character_states}}',
'当前伏笔:{{active_foreshadows}}',
'禁止事项:{{forbidden_rules}}',
'请输出:chapter_no, title, chapter_goal, scenes, must_happen, must_not_happen, foreshadows_to_add, foreshadows_to_advance, foreshadows_to_resolve, ending_hook, adaptation_notes。'
].join('\n\n'),
output_schema_json: {
type: 'object',
required: ['chapter_no', 'title', 'chapter_goal', 'scenes', 'must_happen', 'must_not_happen', 'ending_hook'],
properties: {
chapter_no: { type: 'number' },
title: { type: 'string' },
chapter_goal: { type: 'string' },
scenes: { type: 'array' },
must_happen: { type: 'array' },
must_not_happen: { type: 'array' },
foreshadows_to_add: { type: 'array' },
foreshadows_to_advance: { type: 'array' },
foreshadows_to_resolve: { type: 'array' },
ending_hook: { type: 'string' },
adaptation_notes: { type: 'object' }
}
},
temperature: 0.45,
max_output_tokens: 4000
},
{
agent_name: 'NovelWriterAgent',
provider_type: 'NovelProvider',
default_provider_code: 'anthropic-claude-novel',
system_prompt: '你是专业小说家。你必须严格按章节卡写正文,不改变 IP 圣经,不改变人物状态,不提前揭露秘密。',
user_prompt_template: [
'请创作第 {{chapter_no}} 章正文,只输出正文,不要解释。',
'目标字数:{{target_words}}',
'文风规则:{{style_rules}}',
'IP 圣经摘要:{{ip_bible_summary}}',
'人物档案:{{character_profiles}}',
'人物当前状态:{{character_states}}',
'最近章节摘要:{{recent_summaries}}',
'章节卡:{{chapter_card}}',
'禁止规则:{{forbidden_rules}}',
'要求:画面感强、对白符合身份、每场戏推动剧情/人物/伏笔,不能流水账,不能狗血。'
].join('\n\n'),
output_schema_json: null,
temperature: 0.75,
max_output_tokens: 9000
},
{
agent_name: 'NovelPolishAgent',
provider_type: 'NovelProvider',
default_provider_code: 'anthropic-claude-novel',
system_prompt: '你是小说修辞和节奏编辑。你只负责润色文风、节奏、对白和画面感,不得改变剧情事实。',
user_prompt_template: [
'请润色下面章节,输出完整润色稿,不要解释。',
'目标文风:{{style_rules}}',
'章节卡:{{chapter_card}}',
'禁止改变:{{forbidden_rules}}',
'原稿:{{draft_text}}'
].join('\n\n'),
output_schema_json: null,
temperature: 0.65,
max_output_tokens: 9000
},
{
agent_name: 'ContinuityCheckAgent',
provider_type: 'NovelProvider',
default_provider_code: 'openai-responses-novel',
system_prompt: '你是严苛的小说连续性审稿人,只检查冲突,不负责夸奖。',
user_prompt_template: [
'请检查本章是否违背设定、人设、时间线、地点、伏笔。必须严格输出 JSON,不要 Markdown。',
'IP 圣经:{{ip_bible}}',
'人物状态:{{character_states}}',
'伏笔库:{{active_foreshadows}}',
'章节卡:{{chapter_card}}',
'本章正文:{{chapter_text}}',
'请输出:has_conflict, conflicts, character_drift, timeline_errors, setting_errors, foreshadow_errors, fix_suggestions。'
].join('\n\n'),
output_schema_json: {
type: 'object',
required: ['has_conflict', 'conflicts', 'fix_suggestions'],
properties: {
has_conflict: { type: 'boolean' },
conflicts: { type: 'array' },
character_drift: { type: 'array' },
timeline_errors: { type: 'array' },
setting_errors: { type: 'array' },
foreshadow_errors: { type: 'array' },
fix_suggestions: { type: 'array' }
}
},
temperature: 0.2,
max_output_tokens: 3000
},
{
agent_name: 'QualityCheckAgent',
provider_type: 'NovelProvider',
default_provider_code: 'openai-responses-novel',
system_prompt: '你是严苛的小说主编和质量评分官。你只输出评分、问题和修复策略。',
user_prompt_template: [
'请为本章评分。必须严格输出 JSON,不要 Markdown。',
'评分维度:剧情推进、人物一致性、上下文连续性、文风质量、情绪张力、伏笔管理、场景画面感、听书适配度、短剧适配度。',
'IP 圣经:{{ip_bible}}',
'章节卡:{{chapter_card}}',
'连续性检查:{{continuity_check}}',
'本章正文:{{chapter_text}}',
'请输出:pass, total_score, scores, problems, must_fix, optional_suggestions, rewrite_required, rewrite_strategy。'
].join('\n\n'),
output_schema_json: {
type: 'object',
required: ['pass', 'total_score', 'scores', 'problems', 'rewrite_required'],
properties: {
pass: { type: 'boolean' },
total_score: { type: 'number' },
scores: { type: 'object' },
problems: { type: 'array' },
must_fix: { type: 'array' },
optional_suggestions: { type: 'array' },
rewrite_required: { type: 'boolean' },
rewrite_strategy: { type: 'array' }
}
},
temperature: 0.2,
max_output_tokens: 3000
},
{
agent_name: 'RepairAgent',
provider_type: 'NovelProvider',
default_provider_code: 'anthropic-claude-novel',
system_prompt: '你是小说修稿编辑。你只修复质检指出的问题,不新增无关设定,不破坏上下文。',
user_prompt_template: [
'请根据质检报告修复本章,输出完整修复稿,不要解释。',
'IP 圣经摘要:{{ip_bible_summary}}',
'章节卡:{{chapter_card}}',
'原正文:{{chapter_text}}',
'质检报告:{{quality_report}}',
'连续性检查:{{continuity_check}}'
].join('\n\n'),
output_schema_json: null,
temperature: 0.55,
max_output_tokens: 9000
},
{
agent_name: 'MemoryUpdateAgent',
provider_type: 'NovelProvider',
default_provider_code: 'volcengine-doubao-seed20-pro-novel',
system_prompt: '你是小说连续性档案管理员。你必须准确、简洁,不得加入正文没有发生的内容,不得猜测未来。',
user_prompt_template: [
'请根据本章正文更新记忆库。必须严格输出 JSON,不要 Markdown。',
'已有角色状态:{{character_states}}',
'已有伏笔库:{{active_foreshadows}}',
'本章正文:{{chapter_text}}',
'请输出:chapter_summary, character_updates, relationship_updates, world_updates, timeline_update, location_update, new_foreshadows, updated_foreshadows, resolved_foreshadows, next_chapter_must_continue, forbidden_to_forget。'
].join('\n\n'),
output_schema_json: {
type: 'object',
required: ['chapter_summary', 'character_updates', 'timeline_update', 'next_chapter_must_continue'],
properties: {
chapter_summary: { type: 'string' },
character_updates: { type: 'array' },
relationship_updates: { type: 'array' },
world_updates: { type: 'array' },
timeline_update: { type: 'string' },
location_update: { type: 'string' },
new_foreshadows: { type: 'array' },
updated_foreshadows: { type: 'array' },
resolved_foreshadows: { type: 'array' },
next_chapter_must_continue: { type: 'array' },
forbidden_to_forget: { type: 'array' }
}
},
temperature: 0.25,
max_output_tokens: 3500
}
] as const;
async function main() {
const adminPassword = process.env.SEED_ADMIN_PASSWORD || 'Admin123!';
const adminPasswordHash = await hash(adminPassword, 12);
@@ -84,6 +374,39 @@ async function main() {
});
}
for (const prompt of defaultAgentPrompts) {
await prisma.agentPrompt.upsert({
where: {
agent_name_version: {
agent_name: prompt.agent_name,
version: 1
}
},
update: {
provider_type: prompt.provider_type,
default_provider_code: prompt.default_provider_code ?? null,
system_prompt: prompt.system_prompt,
user_prompt_template: prompt.user_prompt_template,
output_schema_json: prompt.output_schema_json ?? Prisma.JsonNull,
temperature: prompt.temperature,
max_output_tokens: prompt.max_output_tokens,
is_active: true
},
create: {
agent_name: prompt.agent_name,
version: 1,
provider_type: prompt.provider_type,
default_provider_code: prompt.default_provider_code ?? null,
system_prompt: prompt.system_prompt,
user_prompt_template: prompt.user_prompt_template,
output_schema_json: prompt.output_schema_json ?? Prisma.JsonNull,
temperature: prompt.temperature,
max_output_tokens: prompt.max_output_tokens,
is_active: true
}
});
}
await prisma.systemConfig.upsert({
where: { config_key: 'system_a.current_stage' },
update: {
+95
View File
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Inject,
Param,
@@ -14,11 +15,13 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
AdminListAssetsQueryDto,
AdminUpdateAssetReviewStateDto,
AdminListCharactersQueryDto,
AdminListCopyrightRecordsQueryDto,
AdminListGlobalCharactersQueryDto,
AdminListHitAnalysesQueryDto,
AdminListNovelChaptersQueryDto,
AdminExportNovelSourceQueryDto,
AdminListNovelSourcesQueryDto,
AdminListOperationLogsQueryDto,
AdminBindCharacterGlobalDto,
@@ -30,9 +33,14 @@ import {
AdminListUsersQueryDto,
AdminListWorksQueryDto,
AdminListCreativePatternsQueryDto,
AdminBatchImportNovelChaptersDto,
AdminBindNovelVolumeDto,
AdminPromoteHitCasePatternsDto,
AdminResetUserPasswordDto,
AdminSaveNovelChapterDto,
AdminSaveNovelSourceDto,
AdminSaveGlobalCharacterDto,
AdminUpdateNovelSourceIpBibleDto,
AdminUpdateRouterAuditQualityDto,
AdminUpdateCreativePatternDto,
AdminUpdateCreativePatternStatusDto,
@@ -119,6 +127,15 @@ export class AdminController {
return this.adminService.listAssets(user, query);
}
@Patch('assets/:assetId/review-state')
updateAssetReviewState(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Body() dto: AdminUpdateAssetReviewStateDto
) {
return this.adminService.updateAssetReviewState(user, assetId, dto);
}
@Get('novel-sources')
listNovelSources(
@CurrentUser() user: AuthRequestUser,
@@ -127,6 +144,70 @@ export class AdminController {
return this.adminService.listNovelSources(user, query);
}
@Post('novel-sources')
createNovelSource(@CurrentUser() user: AuthRequestUser, @Body() dto: AdminSaveNovelSourceDto) {
return this.adminService.createNovelSource(user, dto);
}
@Patch('novel-sources/:sourceId')
updateNovelSource(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminSaveNovelSourceDto
) {
return this.adminService.updateNovelSource(user, sourceId, dto);
}
@Patch('novel-sources/:sourceId/ip-bible')
updateNovelSourceIpBible(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminUpdateNovelSourceIpBibleDto
) {
return this.adminService.updateNovelSourceIpBible(user, sourceId, dto);
}
@Get('novel-sources/:sourceId/export')
exportNovelSource(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Query() query: AdminExportNovelSourceQueryDto
) {
return this.adminService.exportNovelSource(user, sourceId, query);
}
@Delete('novel-sources/:sourceId')
deleteNovelSource(@CurrentUser() user: AuthRequestUser, @Param('sourceId') sourceId: string) {
return this.adminService.deleteNovelSource(user, sourceId);
}
@Post('novel-sources/:sourceId/chapters/batch')
batchImportNovelChapters(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminBatchImportNovelChaptersDto
) {
return this.adminService.batchImportNovelChapters(user, sourceId, dto);
}
@Patch('novel-sources/:sourceId/chapters/volume')
bindNovelVolume(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminBindNovelVolumeDto
) {
return this.adminService.bindNovelVolume(user, sourceId, dto);
}
@Post('novel-sources/:sourceId/chapters')
createNovelChapter(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminSaveNovelChapterDto
) {
return this.adminService.createNovelChapter(user, sourceId, dto);
}
@Get('novel-chapters')
listNovelChapters(
@CurrentUser() user: AuthRequestUser,
@@ -135,6 +216,20 @@ export class AdminController {
return this.adminService.listNovelChapters(user, query);
}
@Patch('novel-chapters/:chapterId')
updateNovelChapter(
@CurrentUser() user: AuthRequestUser,
@Param('chapterId') chapterId: string,
@Body() dto: AdminSaveNovelChapterDto
) {
return this.adminService.updateNovelChapter(user, chapterId, dto);
}
@Delete('novel-chapters/:chapterId')
deleteNovelChapter(@CurrentUser() user: AuthRequestUser, @Param('chapterId') chapterId: string) {
return this.adminService.deleteNovelChapter(user, chapterId);
}
@Get('characters')
listCharacters(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListCharactersQueryDto) {
return this.adminService.listCharacters(user, query);
+70
View File
@@ -14,26 +14,96 @@ export class AdminListUsersQueryDto {
export class AdminListAssetsQueryDto {
asset_type?: string;
status?: string;
selection_status?: 'all' | 'candidate' | 'selected' | 'rejected';
project_id?: string;
user_id?: string;
q?: string;
shot_no?: string;
shot_start?: string;
shot_end?: string;
page?: string;
limit?: string;
}
export class AdminUpdateAssetReviewStateDto {
display_name?: string | null;
selection_status?: 'candidate' | 'selected' | 'rejected';
selection_note?: string | null;
metadata_json?: unknown;
}
export class AdminListNovelSourcesQueryDto {
project_id?: string;
user_id?: string;
source_type?: string;
parse_status?: string;
page?: string;
limit?: string;
}
export class AdminListNovelChaptersQueryDto {
project_id?: string;
novel_source_id?: string;
volume_no?: string;
status?: string;
page?: string;
limit?: string;
}
export class AdminExportNovelSourceQueryDto {
start_chapter_no?: string;
end_chapter_no?: string;
}
export class AdminSaveNovelSourceDto {
project_id?: string;
title?: string;
author_name?: string;
source_type?: string;
intro_text?: string;
hook_text?: string;
genre?: string;
chapters_per_volume?: number | string | null;
design_text?: string;
design_json?: unknown;
volume_plan_json?: unknown;
status?: string;
}
export class AdminUpdateNovelSourceIpBibleDto {
ip_bible?: unknown;
}
export class AdminSaveNovelChapterDto {
novel_source_id?: string;
volume_no?: number | string | null;
volume_title?: string | null;
chapter_no?: number | string;
title?: string;
content?: string;
summary?: string;
visual_summary?: string;
outline_json?: unknown;
analysis_json?: unknown;
status?: string;
}
export class AdminBatchImportNovelChaptersDto {
text?: string;
volume_no?: number | string | null;
volume_title?: string | null;
start_chapter_no?: number | string;
overwrite?: boolean;
status?: string;
}
export class AdminBindNovelVolumeDto {
volume_no?: number | string | null;
volume_title?: string | null;
start_chapter_no?: number | string;
end_chapter_no?: number | string;
}
export class AdminListCharactersQueryDto {
project_id?: string;
global_character_id?: string;
+358 -1
View File
@@ -69,6 +69,44 @@ function createProject(overrides: Record<string, unknown> = {}) {
};
}
function createNovelSource(overrides: Record<string, unknown> = {}) {
return {
id: 50n,
project_id: 10n,
source_type: 'gpt_web',
title: '她签了,但没认输',
author_name: null,
raw_asset_id: null,
raw_text: null,
clean_text: null,
word_count: 0,
chapter_count: 0,
parse_status: 'parsed',
parse_report: {},
created_at: now,
...overrides
};
}
function createNovelChapter(overrides: Record<string, unknown> = {}) {
return {
id: 51n,
project_id: 10n,
novel_source_id: 50n,
volume_no: null,
volume_title: null,
chapter_no: 1,
title: '第1章:她签了,但没认输',
content: '她签下名字,抬头看向众人。',
summary: '她签下名字,抬头看向众人。',
visual_summary: '她签下名字,抬头看向众人。',
word_count: 14,
status: 'parsed',
created_at: now,
...overrides
};
}
function createEpisode(overrides: Record<string, unknown> = {}) {
return {
id: 11n,
@@ -426,6 +464,7 @@ describe('AdminService', () => {
beforeEach(() => {
prisma = {
$transaction: vi.fn(async (callback: (tx: any) => unknown) => callback(prisma)),
user: {
count: vi.fn().mockResolvedValue(2),
findUnique: vi.fn().mockResolvedValue(createUser()),
@@ -486,9 +525,19 @@ describe('AdminService', () => {
findMany: vi.fn().mockResolvedValue([])
},
novelSource: {
findMany: vi.fn().mockResolvedValue([])
findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn().mockResolvedValue(createNovelSource()),
count: vi.fn().mockResolvedValue(0),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
createNovelSource({ ...data })
)
},
novelChapter: {
createMany: vi.fn().mockResolvedValue({ count: 0 }),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(createNovelChapter()),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0)
},
@@ -592,6 +641,314 @@ describe('AdminService', () => {
expect(result.projects[0].latest_task?.task_type).toBe('video_render');
});
it('lists assets with generation provider and model details', async () => {
prisma.asset.findMany.mockResolvedValue([createAsset({ id: 31n })]);
prisma.renderTask.findMany.mockResolvedValue([
createTask({
id: 15n,
provider_id: 13n,
task_type: 'live_action_video_clip_generate',
output_asset_id: 31n,
provider_request_id: 'kling-task-1',
cost_actual: new Prisma.Decimal(0.35)
})
]);
prisma.providerLog.findMany.mockResolvedValue([
createProviderLog({
task_id: 15n,
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1'
})
]);
prisma.providerConfig.findMany.mockResolvedValue([
createProviderConfig({
id: 13n,
provider_code: 'kling-image-to-video',
display_name: '可灵图生视频',
model_name: 'kling-v2-1'
})
]);
const result = await service.listAssets(admin, { limit: '10' });
expect(result.assets[0].asset.generation).toEqual(
expect.objectContaining({
provider_name: '可灵图生视频',
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1',
task_id: '15',
provider_request_id: 'kling-task-1'
})
);
});
it('paginates assets, novel sources and novel chapters', async () => {
prisma.asset.findMany.mockResolvedValue([createAsset({ id: 31n })]);
prisma.asset.count.mockResolvedValue(101);
prisma.novelSource.findMany.mockResolvedValue([createNovelSource({ id: 50n })]);
prisma.novelSource.count.mockResolvedValue(41);
prisma.novelChapter.findMany.mockResolvedValue([createNovelChapter({ id: 51n })]);
prisma.novelChapter.count.mockResolvedValue(77);
const assetResult = await service.listAssets(admin, { page: '3', limit: '10' });
const sourceResult = await service.listNovelSources(admin, { page: '2', limit: '20' });
const chapterResult = await service.listNovelChapters(admin, { page: '4', limit: '15' });
expect(prisma.asset.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 20, take: 10 }));
expect(prisma.novelSource.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 20, take: 20 }));
expect(prisma.novelChapter.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 45, take: 15 }));
expect(assetResult).toEqual(expect.objectContaining({ page: 3, limit: 10, total: 101, total_pages: 11 }));
expect(sourceResult).toEqual(expect.objectContaining({ page: 2, limit: 20, total: 41, total_pages: 3 }));
expect(chapterResult).toEqual(expect.objectContaining({ page: 4, limit: 15, total: 77, total_pages: 6 }));
});
it('batch imports GPT markdown chapter headings as separate novel chapters', async () => {
const savedChapters = Array.from({ length: 5 }, (_, index) =>
createNovelChapter({
id: BigInt(51 + index),
chapter_no: index + 1,
title: `${index + 1}章:测试章节${index + 1}`,
content: `${index + 1}章正文`
})
);
prisma.novelChapter.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
const result = await service.batchImportNovelChapters(admin, '50', {
start_chapter_no: '1',
status: 'parsed',
text: [
'# 第一卷:离婚夜,她不装了',
'采用按卷推进,每次5章正文。',
'## 第1章—第5章正文',
'---',
'# 第1章:她签了,但没认输',
'第一章正文。',
'',
'# 第2章:她签了,但没认输',
'第二章正文。',
'',
'## 第3章:她没有回头',
'第三章正文。',
'',
'### 第4章:雨夜来客',
'第四章正文。',
'',
'# 第5章:旧账翻开',
'第五章正文。'
].join('\n')
});
const createManyArg = prisma.novelChapter.createMany.mock.calls[0][0];
expect(createManyArg.data).toHaveLength(5);
expect(createManyArg.data.map((chapter: Record<string, unknown>) => chapter.chapter_no)).toEqual([1, 2, 3, 4, 5]);
expect(createManyArg.data.map((chapter: Record<string, unknown>) => chapter.volume_title)).toEqual([
'第一卷:离婚夜,她不装了',
'第一卷:离婚夜,她不装了',
'第一卷:离婚夜,她不装了',
'第一卷:离婚夜,她不装了',
'第一卷:离婚夜,她不装了'
]);
expect(createManyArg.data[1]).toEqual(
expect.objectContaining({
title: '第2章:她签了,但没认输',
content: '第二章正文。'
})
);
expect(result.imported_count).toBe(5);
});
it('keeps volume headings as chapter metadata during batch import', async () => {
const savedChapters = [
createNovelChapter({
id: 71n,
chapter_no: 1,
volume_no: 1,
volume_title: '第一卷:离婚夜',
title: '第1章:她签了',
content: '第一章正文。'
}),
createNovelChapter({
id: 72n,
chapter_no: 2,
volume_no: 2,
volume_title: '第二卷:反击',
title: '第2章:她反击',
content: '第二章正文。'
})
];
prisma.novelChapter.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
await service.batchImportNovelChapters(admin, '50', {
start_chapter_no: '1',
status: 'parsed',
text: [
'第一卷:离婚夜',
'第1章:她签了',
'第一章正文。',
'',
'第二卷:反击',
'第2章:她反击',
'第二章正文。'
].join('\n')
});
const createManyArg = prisma.novelChapter.createMany.mock.calls[0][0];
expect(createManyArg.data).toHaveLength(2);
expect(createManyArg.data).toEqual([
expect.objectContaining({
volume_no: 1,
volume_title: '第一卷:离婚夜',
chapter_no: 1,
title: '第1章:她签了'
}),
expect.objectContaining({
volume_no: 2,
volume_title: '第二卷:反击',
chapter_no: 2,
title: '第2章:她反击'
})
]);
});
it('auto assigns volumes from the source chapters-per-volume rule during batch import', async () => {
const source = createNovelSource({ parse_report: { chapters_per_volume: 30 } });
const savedChapters = [
createNovelChapter({ id: 91n, chapter_no: 31, volume_no: 2, volume_title: '第二卷' }),
createNovelChapter({ id: 92n, chapter_no: 32, volume_no: 2, volume_title: '第二卷' })
];
prisma.novelSource.findUnique.mockResolvedValue(source);
prisma.novelChapter.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
const result = await service.batchImportNovelChapters(admin, '50', {
start_chapter_no: '31',
status: 'parsed',
text: [
'===== 第31章:第二卷开场 =====',
'新的危机开始。',
'',
'===== 第32章:她再下一局 =====',
'她把底牌压到最后。'
].join('\n')
});
const createManyArg = prisma.novelChapter.createMany.mock.calls[0][0];
expect(createManyArg.data).toEqual([
expect.objectContaining({
chapter_no: 31,
volume_no: 2,
volume_title: '第二卷'
}),
expect.objectContaining({
chapter_no: 32,
volume_no: 2,
volume_title: '第二卷'
})
]);
expect(result.imported_count).toBe(2);
});
it('binds a chapter range to a novel volume', async () => {
const savedChapters = [
createNovelChapter({ id: 81n, chapter_no: 1, volume_no: 1, volume_title: '第一卷:离婚夜' }),
createNovelChapter({ id: 82n, chapter_no: 2, volume_no: 1, volume_title: '第一卷:离婚夜' })
];
prisma.novelChapter.updateMany.mockResolvedValueOnce({ count: 2 });
prisma.novelChapter.findMany
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
const result = await service.bindNovelVolume(admin, '50', {
volume_no: '1',
volume_title: '第一卷:离婚夜',
start_chapter_no: '1',
end_chapter_no: '2'
});
expect(prisma.novelChapter.updateMany).toHaveBeenCalledWith({
where: {
novel_source_id: 50n,
chapter_no: { gte: 1, lte: 2 }
},
data: {
volume_no: 1,
volume_title: '第一卷:离婚夜'
}
});
expect(result.updated_count).toBe(2);
expect(result.chapters[0]).toMatchObject({
volume_no: 1,
volume_title: '第一卷:离婚夜'
});
});
it('batch imports chapters split by standalone GPT divider headings', async () => {
const savedChapters = Array.from({ length: 5 }, (_, index) =>
createNovelChapter({
id: BigInt(61 + index),
chapter_no: index + 6,
title: `${index + 6}章:测试章节${index + 6}`,
content: `${index + 6}章正文`
})
);
prisma.novelChapter.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
const result = await service.batchImportNovelChapters(admin, '50', {
start_chapter_no: '6',
status: 'parsed',
text: [
'后续我统一用这一行作为自动切割标识:',
'===== 第X章:章节标题 =====',
'',
'本次继续 第6章—第10章正文。',
'已思考 6m 51s',
'',
'===== 第6章:直播里的耳光 =====',
'顾氏股价崩了。',
'',
'###===第7章:她把证据甩上桌===###',
'她抬手投屏,会议室里鸦雀无声。',
'',
'===== 第8章:旧账翻开 =====',
'旧合同被重新翻出。',
'',
'===== 第9章:深夜来电 =====',
'电话那头只剩急促呼吸。',
'',
'===== 第10章:她没有回头 =====',
'她走出大楼,没有再回头。'
].join('\n')
});
const createManyArg = prisma.novelChapter.createMany.mock.calls[0][0];
expect(createManyArg.data).toHaveLength(5);
expect(createManyArg.data.map((chapter: Record<string, unknown>) => chapter.chapter_no)).toEqual([6, 7, 8, 9, 10]);
expect(createManyArg.data[0]).toEqual(
expect.objectContaining({
title: '第6章:直播里的耳光',
content: '顾氏股价崩了。'
})
);
expect(createManyArg.data[1]).toEqual(
expect.objectContaining({
title: '第7章:她把证据甩上桌',
content: '她抬手投屏,会议室里鸦雀无声。'
})
);
expect(result.imported_count).toBe(5);
});
it('lists router quality audit rows with routing, repair and cost details', async () => {
prisma.project.findMany.mockResolvedValue([createProject({ output_mode: 'live_action_ai' })]);
prisma.episode.findMany.mockResolvedValue([createEpisode()]);
File diff suppressed because it is too large Load Diff
+23
View File
@@ -21,23 +21,46 @@ export function toSafeNovelSource(source: NovelSource) {
chapter_count: source.chapter_count,
parse_status: source.parse_status,
parse_report: source.parse_report,
ip_bible_json: sourceIpBibleFromReport(source.parse_report),
design_json: source.design_json,
volume_plan_json: source.volume_plan_json,
ai_provider_code: source.ai_provider_code,
ai_model_name: source.ai_model_name,
ai_cost_estimate: source.ai_cost_estimate ? Number(source.ai_cost_estimate.toString()) : null,
ai_cost_actual: source.ai_cost_actual ? Number(source.ai_cost_actual.toString()) : null,
text_preview: createTextPreview(source.clean_text || source.raw_text),
created_at: source.created_at.toISOString()
};
}
function sourceIpBibleFromReport(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const ipBible = (value as Record<string, unknown>).ip_bible_json;
return ipBible && typeof ipBible === 'object' && !Array.isArray(ipBible) ? ipBible : null;
}
export function toSafeNovelChapter(chapter: NovelChapter) {
return {
id: chapter.id.toString(),
project_id: chapter.project_id.toString(),
novel_source_id: chapter.novel_source_id?.toString() ?? null,
volume_no: chapter.volume_no,
volume_title: chapter.volume_title,
chapter_no: chapter.chapter_no,
title: chapter.title,
summary: chapter.summary,
visual_summary: chapter.visual_summary,
content: chapter.content,
content_preview: createTextPreview(chapter.content, 3000),
word_count: chapter.word_count,
outline_json: chapter.outline_json,
analysis_json: chapter.analysis_json,
status: chapter.status,
ai_provider_code: chapter.ai_provider_code,
ai_model_name: chapter.ai_model_name,
ai_cost_estimate: chapter.ai_cost_estimate ? Number(chapter.ai_cost_estimate.toString()) : null,
ai_cost_actual: chapter.ai_cost_actual ? Number(chapter.ai_cost_actual.toString()) : null,
created_at: chapter.created_at.toISOString()
};
}
@@ -180,6 +180,16 @@ describe('AiRouterService', () => {
mode: 'real',
is_enabled: false
}),
createProvider({
provider_code: 'volcengine_seedance_20_fast',
mode: 'real',
is_enabled: false
}),
createProvider({
provider_code: 'volcengine_seedance_20',
mode: 'real',
is_enabled: false
}),
createProvider({
provider_code: 'jimeng_seedance',
mode: 'real',
@@ -196,12 +206,87 @@ describe('AiRouterService', () => {
expect(decision.provider_code).toBe('mock-video');
expect(decision.candidates.map((candidate) => candidate.reason)).toEqual([
'provider_disabled',
'provider_disabled',
'provider_disabled',
'provider_disabled',
'auto_normal_route'
]);
});
it('routes splus_v1 projects only through the formal Kling Omni and V3 chain', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
id: 103n,
provider_code: 'kling-v3-omni-native-audio-1080p-video',
display_name: 'Kling v3 Omni 1080P',
mode: 'real',
is_enabled: false
}),
createProvider({
id: 104n,
provider_code: 'kling-v3-native-audio-video',
display_name: 'Kling v3 1080P',
mode: 'real',
is_enabled: true,
cost_rule_json: { unit: 'video_seconds', price_per_second: 0.168, currency: 'USD' }
})
]);
const decision = await service.resolveLiveActionVideoRoute({
project: createProject({ engine_version: 'splus_v1' }),
shot: createShot({ importance_score: 9, action_score: 7 }),
duration: 5
});
expect(decision.provider_code).toBe('kling-v3-native-audio-video');
expect(decision.fallback_chain).toEqual([
'kling-v3-omni-native-audio-1080p-video',
'kling-v3-native-audio-video'
]);
expect(decision.routing_profile).toBe('splus_kling_v1');
expect(decision.capability_version).toBe('kling_video_capabilities_2026-07-15');
expect(decision.candidates.map((candidate) => candidate.reason)).toEqual([
'provider_disabled',
'splus_kling_premium_route'
]);
});
it('rejects non-Kling manual providers for splus_v1 projects', async () => {
await expect(service.resolveLiveActionVideoRoute({
project: createProject({ engine_version: 'splus_v1' }),
shot: createShot(),
duration: 5,
manual_provider_code: 'jimeng_seedance',
allow_manual_override: true
})).rejects.toThrow('AI_ROUTER_SPLUS_PROVIDER_NOT_ALLOWED');
});
it('rejects a misconfigured splus route instead of silently selecting a legacy provider', async () => {
prisma.systemConfig.upsert.mockResolvedValueOnce({
config_key: 'ai.router.v1',
config_value: {
...DEFAULT_AI_ROUTER_CONFIG,
splus_live_action_video: {
'zh-CN': {
normal: {
provider_code: 'minimax_hailuo_23_fast',
fallback_chain: ['minimax_hailuo_23_fast', 'mock-video']
}
}
}
}
});
await expect(service.resolveLiveActionVideoRoute({
project: createProject({ engine_version: 'splus_v1' }),
shot: createShot({ route_tier: 'normal' }),
duration: 5,
language: 'zh-CN'
})).rejects.toThrow('AI_ROUTER_SPLUS_PROVIDER_CHAIN_EMPTY');
expect(prisma.providerConfig.findMany).not.toHaveBeenCalled();
});
it('keeps admin manual override as an explicit router decision', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
+52 -9
View File
@@ -5,6 +5,8 @@ import {
AI_ROUTER_CONFIG_KEY,
AI_ROUTER_DEFAULT_LANGUAGE,
DEFAULT_AI_ROUTER_CONFIG,
KLING_SPLUS_CAPABILITY_VERSION,
KLING_SPLUS_FORMAL_PROVIDER_CODES,
type AiRouteDecision,
type AiRouteTier,
type AiRouterShotScores
@@ -44,27 +46,50 @@ export class AiRouterService {
const scores = this.scoreLiveActionShot(input.shot);
const language = this.normalizeText(input.language) ?? (await this.resolveDefaultLanguage());
const manualProviderCode = this.normalizeText(input.manual_provider_code);
const isSplusProject = input.project.engine_version === 'splus_v1';
if (manualProviderCode && input.allow_manual_override) {
return this.resolveManualVideoProvider(manualProviderCode, language, input.duration, scores);
if (isSplusProject && !KLING_SPLUS_FORMAL_PROVIDER_CODES.includes(
manualProviderCode as (typeof KLING_SPLUS_FORMAL_PROVIDER_CODES)[number]
)) {
throw new BadRequestException('AI_ROUTER_SPLUS_PROVIDER_NOT_ALLOWED');
}
const decision = await this.resolveManualVideoProvider(manualProviderCode, language, input.duration, scores);
return isSplusProject ? this.withSplusMetadata(decision) : decision;
}
if (manualProviderCode && !input.allow_manual_override) {
throw new BadRequestException('AI_ROUTER_MANUAL_OVERRIDE_FORBIDDEN');
}
const config = await this.loadRouterConfig();
const languageConfig = this.resolveLiveActionLanguageConfig(config, language);
const languageConfig = this.resolveLiveActionLanguageConfig(
config,
language,
isSplusProject ? 'splus_live_action_video' : 'live_action_video'
);
const tierConfig = this.jsonObject(languageConfig[scores.route_tier]);
const primaryProviderCode =
this.normalizeText(tierConfig.provider_code) ??
(scores.route_tier === 'premium' ? 'kling-image-to-video' : 'minimax_hailuo_23_fast');
const fallbackChain = this.uniqueStrings([
(isSplusProject
? 'kling-v3-omni-native-audio-1080p-video'
: scores.route_tier === 'premium'
? 'kling-image-to-video'
: 'minimax_hailuo_23_fast');
const configuredFallbackChain = this.uniqueStrings([
primaryProviderCode,
...this.stringArray(tierConfig.fallback_chain),
'mock-video'
...(isSplusProject ? [] : ['mock-video'])
]);
const fallbackChain = isSplusProject
? configuredFallbackChain.filter((providerCode) => KLING_SPLUS_FORMAL_PROVIDER_CODES.includes(
providerCode as (typeof KLING_SPLUS_FORMAL_PROVIDER_CODES)[number]
))
: configuredFallbackChain;
if (isSplusProject && fallbackChain.length === 0) {
throw new BadRequestException('AI_ROUTER_SPLUS_PROVIDER_CHAIN_EMPTY');
}
return this.selectVideoProviderFromCandidates({
const decision = await this.selectVideoProviderFromCandidates({
language,
duration: input.duration,
scores,
@@ -72,8 +97,11 @@ export class AiRouterService {
maxCostPerClip: input.max_cost_per_clip ?? null,
dailyBudget: this.numberFromJson(this.jsonObject(config).daily_budget),
manualOverride: false,
defaultReason: `auto_${scores.route_tier}_route`
defaultReason: isSplusProject
? `splus_kling_${scores.route_tier}_route`
: `auto_${scores.route_tier}_route`
});
return isSplusProject ? this.withSplusMetadata(decision) : decision;
}
private async resolveManualVideoProvider(
@@ -210,8 +238,14 @@ export class AiRouterService {
return this.normalizeText(config.default_language) ?? AI_ROUTER_DEFAULT_LANGUAGE;
}
private resolveLiveActionLanguageConfig(config: Record<string, unknown>, language: string) {
const liveAction = this.jsonObject(config.live_action_video);
private resolveLiveActionLanguageConfig(
config: Record<string, unknown>,
language: string,
profileKey: 'live_action_video' | 'splus_live_action_video'
) {
const configuredProfile = this.jsonObject(config[profileKey]);
const defaultProfile = this.jsonObject(this.jsonObject(DEFAULT_AI_ROUTER_CONFIG)[profileKey]);
const liveAction = Object.keys(configuredProfile).length > 0 ? configuredProfile : defaultProfile;
const current = this.jsonObject(liveAction[language]);
if (Object.keys(current).length > 0) return current;
@@ -219,6 +253,15 @@ export class AiRouterService {
return this.jsonObject(liveAction[AI_ROUTER_DEFAULT_LANGUAGE]);
}
private withSplusMetadata(decision: AiRouteDecision): AiRouteDecision {
return {
...decision,
engine_version: 'splus_v1',
routing_profile: 'splus_kling_v1',
capability_version: KLING_SPLUS_CAPABILITY_VERSION
};
}
private estimateVideoCost(rule: Prisma.JsonValue | null, duration: number) {
const costRule = this.jsonObject(rule);
const flatCost = this.numberFromJson(costRule.flat_cost);
+32 -2
View File
@@ -2,6 +2,15 @@ import type { Prisma } from '@prisma/client';
export const AI_ROUTER_CONFIG_KEY = 'ai.router.v1';
export const AI_ROUTER_DEFAULT_LANGUAGE = 'zh-CN';
export const KLING_SPLUS_CAPABILITY_VERSION = 'kling_video_capabilities_2026-07-15';
export const KLING_SPLUS_FORMAL_PROVIDER_CODES = [
'kling-v3-omni-native-audio-720p-video',
'kling-v3-omni-native-audio-1080p-video',
'kling-v3-omni-native-audio-4k-video',
'kling-v3-native-audio-720p-video',
'kling-v3-native-audio-video',
'kling-v3-native-audio-4k-video'
] as const;
export const DEFAULT_AI_ROUTER_CONFIG = {
version: 1,
@@ -16,11 +25,29 @@ export const DEFAULT_AI_ROUTER_CONFIG = {
},
normal: {
provider_code: 'minimax_hailuo_23_fast',
fallback_chain: ['minimax_hailuo_23_fast', 'jimeng_seedance', 'mock-video']
fallback_chain: ['minimax_hailuo_23_fast', 'volcengine_seedance_20_fast', 'volcengine_seedance_20', 'jimeng_seedance', 'mock-video']
},
premium: {
provider_code: 'kling-image-to-video',
fallback_chain: ['kling-image-to-video', 'minimax_hailuo_23_fast', 'jimeng_seedance', 'mock-video']
fallback_chain: ['kling-image-to-video', 'volcengine_seedance_20', 'minimax_hailuo_23_fast', 'volcengine_seedance_20_fast', 'jimeng_seedance', 'mock-video']
}
}
},
splus_live_action_video: {
capability_version: KLING_SPLUS_CAPABILITY_VERSION,
formal_provider_codes: [...KLING_SPLUS_FORMAL_PROVIDER_CODES],
'zh-CN': {
thresholds: {
premium_importance_gt: 7,
premium_action_gt: 5
},
normal: {
provider_code: 'kling-v3-omni-native-audio-1080p-video',
fallback_chain: ['kling-v3-omni-native-audio-1080p-video', 'kling-v3-native-audio-video']
},
premium: {
provider_code: 'kling-v3-omni-native-audio-1080p-video',
fallback_chain: ['kling-v3-omni-native-audio-1080p-video', 'kling-v3-native-audio-video']
}
}
}
@@ -54,5 +81,8 @@ export interface AiRouteDecision {
decision_reason: string;
estimated_cost: number;
manual_override: boolean;
engine_version?: string;
routing_profile?: string;
capability_version?: string;
scores: AiRouterShotScores;
}
+6
View File
@@ -18,9 +18,12 @@ import { ImagesModule } from './images/images.module';
import { LiveActionModule } from './live-action/live-action.module';
import { MediaModule } from './media/media.module';
import { MemoriesModule } from './memories/memories.module';
import { ModelRegistryModule } from './model-registry/model-registry.module';
import { NovelsModule } from './novels/novels.module';
import { ProjectsModule } from './projects/projects.module';
import { ProviderLabModule } from './provider-lab/provider-lab.module';
import { ProvidersModule } from './providers/providers.module';
import { ProductionKernelModule } from './production-kernel/production-kernel.module';
import { PrismaModule } from './prisma/prisma.module';
import { QueuesModule } from './queues/queues.module';
import { ReviewsModule } from './reviews/reviews.module';
@@ -35,6 +38,9 @@ import { UsersModule } from './users/users.module';
AuthModule,
BillingModule,
ProjectsModule,
ProviderLabModule,
ModelRegistryModule,
ProductionKernelModule,
AssetsModule,
NovelsModule,
StoryBiblesModule,
+29 -2
View File
@@ -9,6 +9,23 @@ export interface StoredObject {
backend: 'local' | 'minio';
}
export interface SafeAssetGeneration {
source: string;
display_name: string | null;
provider_id: string | null;
provider_type: string | null;
provider_code: string | null;
provider_name: string | null;
model_name: string | null;
task_id: string | null;
task_type: string | null;
provider_request_id: string | null;
clip_id: string | null;
status: string | null;
cost_actual: string | null;
created_at: string | null;
}
export interface SafeAsset {
id: string;
user_id: string | null;
@@ -21,12 +38,17 @@ export interface SafeAsset {
duration: string | null;
size: string | null;
hash: string | null;
display_name: string | null;
selection_status: string;
selection_note: string | null;
metadata_json: unknown;
visibility: string;
status: string;
created_at: string;
generation: SafeAssetGeneration | null;
}
export function toSafeAsset(asset: Asset): SafeAsset {
export function toSafeAsset(asset: Asset, generation: SafeAssetGeneration | null = null): SafeAsset {
return {
id: asset.id.toString(),
user_id: asset.user_id?.toString() ?? null,
@@ -39,8 +61,13 @@ export function toSafeAsset(asset: Asset): SafeAsset {
duration: asset.duration?.toString() ?? null,
size: asset.size?.toString() ?? null,
hash: asset.hash,
display_name: asset.display_name ?? generation?.display_name ?? null,
selection_status: asset.selection_status,
selection_note: asset.selection_note,
metadata_json: asset.metadata_json,
visibility: asset.visibility,
status: asset.status,
created_at: asset.created_at.toISOString()
created_at: asset.created_at.toISOString(),
generation
};
}
+36 -8
View File
@@ -3,8 +3,10 @@ import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Patch,
Post,
Req,
Res,
@@ -21,7 +23,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types';
import type { RequestWithApiCrypto } from '../common/api-crypto.service';
import { AssetsService } from './assets.service';
import { UploadAssetDto } from './upload.dto';
import { UpdateAssetReviewStateDto, UploadAssetDto } from './upload.dto';
const DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
const MAX_UPLOAD_BYTES = parseUploadLimitBytes(process.env.MAX_UPLOAD_BYTES, DEFAULT_MAX_UPLOAD_BYTES);
@@ -99,16 +101,30 @@ export class AssetsController {
return this.assetsService.getAssetForUser(user, assetId);
}
@Get('assets/:assetId/preview-url')
getAssetPreviewUrl(@CurrentUser() user: AuthRequestUser, @Param('assetId') assetId: string) {
return this.assetsService.createPreviewUrlForUser(user, assetId);
}
@Patch('assets/:assetId/review-state')
updateAssetReviewState(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Body() dto: UpdateAssetReviewStateDto
) {
return this.assetsService.updateAssetReviewState(user, assetId, dto);
}
@Get('assets/:assetId/download')
async downloadAsset(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Headers('range') range: string | undefined,
@Req() request: RequestWithApiCrypto,
@Res({ passthrough: true }) response: Response
) {
const result = await this.assetsService.downloadAssetForUser(user, assetId);
if (request.apiCrypto) {
const result = await this.assetsService.downloadAssetForUser(user, assetId);
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
return {
filename: result.filename,
@@ -118,15 +134,27 @@ export class AssetsController {
};
}
response.setHeader('Content-Type', result.asset.mime_type || 'application/octet-stream');
response.setHeader('Content-Length', result.buffer.length.toString());
const result = await this.assetsService.streamAssetForUser(user, assetId, range);
const mimeType = result.asset.mime_type || 'application/octet-stream';
const stream = result.stream;
const asciiFilename = result.filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '');
const dispositionType = mimeType.startsWith('video/') || mimeType.startsWith('audio/') ? 'inline' : 'attachment';
response.setHeader('Content-Type', mimeType);
response.setHeader('Accept-Ranges', 'bytes');
response.setHeader('Content-Length', stream.contentLength.toString());
response.setHeader(
'Content-Disposition',
`attachment; filename="${result.filename.replace(/"/g, '')}"`
`${dispositionType}; filename="${asciiFilename}"; filename*=UTF-8''${encodeURIComponent(result.filename)}`
);
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
if (stream.statusCode === 206) {
response.status(206);
response.setHeader('Content-Range', `bytes ${stream.start}-${stream.end}/${stream.size}`);
}
response.setHeader('Cache-Control', 'private, max-age=3600');
response.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(result.buffer);
return new StreamableFile(stream.stream);
}
private fileFromEncryptedBody(body: Record<string, unknown> | undefined) {
+109 -4
View File
@@ -29,10 +29,7 @@ function createFile(overrides: Partial<Express.Multer.File> = {}): Express.Multe
}
describe('AssetsService', () => {
let prisma: {
project: { findUnique: ReturnType<typeof vi.fn> };
asset: { create: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn> };
};
let prisma: any;
let storage: Pick<StorageService, 'storePrivateFile' | 'readPrivateFile'>;
let projectsService: Pick<ProjectsService, 'assertProjectOwner'>;
let service: AssetsService;
@@ -45,6 +42,18 @@ describe('AssetsService', () => {
asset: {
create: vi.fn(),
findUnique: vi.fn()
},
renderTask: {
findMany: vi.fn().mockResolvedValue([])
},
providerLog: {
findMany: vi.fn().mockResolvedValue([])
},
providerConfig: {
findUnique: vi.fn().mockResolvedValue(null)
},
videoClip: {
findMany: vi.fn().mockResolvedValue([])
}
};
storage = {
@@ -148,4 +157,100 @@ describe('AssetsService', () => {
expect(result.filename).toBe('video-300.mp4');
expect(result.buffer.toString()).toBe('video bytes');
});
it('returns generation provider and model for direct asset detail', async () => {
prisma.asset.findUnique.mockResolvedValue({
id: 300n,
user_id: 1n,
project_id: 100n,
asset_type: 'video',
file_path: 'local://videos/final.mp4',
file_url: null,
mime_type: 'video/mp4',
width: 1080,
height: 1920,
duration: 4,
size: 11n,
hash: 'video-hash',
visibility: 'private',
status: 'active',
created_at: new Date('2026-05-31T00:00:00.000Z')
});
prisma.renderTask.findMany.mockResolvedValue([
{
id: 20n,
project_id: 100n,
episode_id: 10n,
shot_id: 8n,
task_type: 'live_action_video_clip_generate',
provider_id: 13n,
status: 'success',
input_json: {},
input_hash: null,
idempotency_key: null,
output_asset_id: 300n,
provider_request_id: 'task-external-1',
retry_count: 0,
max_retry: 0,
cost_estimate: null,
cost_actual: { toString: () => '0.35' },
error_code: null,
error_message: null,
created_at: new Date('2026-05-31T00:00:00.000Z'),
started_at: null,
finished_at: new Date('2026-05-31T00:00:00.000Z')
}
]);
prisma.providerLog.findMany.mockResolvedValue([
{
id: 40n,
provider_id: 13n,
task_id: 20n,
project_id: 100n,
provider_type: 'VideoProvider',
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1',
request_json: {},
response_json: {},
input_size: null,
output_size: null,
cost_estimate: null,
cost_actual: null,
status: 'success',
error_code: null,
error_message: null,
started_at: null,
finished_at: null,
created_at: new Date('2026-05-31T00:00:00.000Z')
}
]);
prisma.providerConfig.findUnique.mockResolvedValue({
id: 13n,
provider_type: 'VideoProvider',
provider_code: 'kling-image-to-video',
display_name: '可灵图生视频',
mode: 'real',
model_name: 'kling-v2-1',
config_json: {},
fallback_provider_id: null,
is_enabled: true,
priority: 10,
rate_limit_json: {},
cost_rule_json: {},
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z')
});
const result = await service.getAssetForUser(user, '300');
expect(result.generation).toEqual(
expect.objectContaining({
provider_name: '可灵图生视频',
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1',
task_id: '20',
provider_request_id: 'task-external-1'
})
);
});
});
+301 -8
View File
@@ -4,12 +4,13 @@ import {
Injectable,
NotFoundException
} from '@nestjs/common';
import type { Asset } from '@prisma/client';
import { Prisma, type Asset, type ProviderConfig, type ProviderLog, type RenderTask, type VideoClip } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { AuthRequestUser } from '../auth/auth.types';
import { ProjectsService } from '../projects/projects.service';
import { toSafeAsset, type AssetType } from './asset.types';
import { toSafeAsset, type AssetType, type SafeAssetGeneration } from './asset.types';
import { StorageService } from './storage.service';
import type { UpdateAssetReviewStateDto } from './upload.dto';
const ALLOWED_NOVEL_MIME_TYPES = new Set([
'text/plain',
@@ -93,20 +94,97 @@ export class AssetsService {
async getAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
return toSafeAsset(asset);
const generation = await this.findAssetGeneration(asset);
return toSafeAsset(asset, generation);
}
async downloadAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
const generation = await this.findAssetGeneration(asset);
const buffer = await this.storage.readPrivateFile(asset.file_path);
return {
asset: toSafeAsset(asset),
asset: toSafeAsset(asset, generation),
buffer,
filename: this.buildDownloadFilename(asset)
filename: this.buildDownloadFilename(asset, generation)
};
}
async streamAssetForUser(user: AuthRequestUser, assetId: string, rangeHeader?: string) {
const asset = await this.findAssetForUser(user, assetId);
const generation = await this.findAssetGeneration(asset);
const stream = await this.storage.streamPrivateFile(
asset.file_path,
asset.mime_type || 'application/octet-stream',
rangeHeader
);
return {
asset: toSafeAsset(asset, generation),
stream,
filename: this.buildDownloadFilename(asset, generation)
};
}
async createPreviewUrlForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
const expiresInSeconds = this.previewUrlExpiresInSeconds(asset);
return {
asset: toSafeAsset(asset),
url: this.storage.createTemporaryPublicUrl({
filePath: asset.file_path,
mimeType: asset.mime_type || 'application/octet-stream',
expiresInSeconds
}),
expires_in_seconds: expiresInSeconds
};
}
async updateAssetReviewState(user: AuthRequestUser, assetId: string, dto: UpdateAssetReviewStateDto) {
const asset = await this.findEditableAssetForUser(user, assetId);
const data: Prisma.AssetUpdateInput = {};
if ('display_name' in dto) {
data.display_name = this.normalizeNullableText(dto.display_name, 255);
}
if ('selection_status' in dto) {
data.selection_status = this.normalizeSelectionStatus(dto.selection_status);
}
if ('selection_note' in dto) {
data.selection_note = this.normalizeNullableText(dto.selection_note, 1000);
}
if ('metadata_json' in dto) {
data.metadata_json = this.normalizeJsonObject(dto.metadata_json);
}
if (Object.keys(data).length === 0) {
return { asset: toSafeAsset(asset, await this.findAssetGeneration(asset)) };
}
const updated = await this.prisma.asset.update({
where: { id: asset.id },
data
});
const generation = await this.findAssetGeneration(updated);
return {
asset: toSafeAsset(updated, generation),
next_step: 'asset_review_state_saved'
};
}
private previewUrlExpiresInSeconds(asset: Asset) {
const mimeType = asset.mime_type || '';
if (mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
return 24 * 60 * 60;
}
return 15 * 60;
}
private async findAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.prisma.asset.findUnique({
where: { id: this.parseId(assetId) }
@@ -116,20 +194,235 @@ export class AssetsService {
throw new NotFoundException('Asset not found');
}
if (asset.user_id?.toString() !== user.id && user.role !== 'admin') {
if (user.role === 'admin' || asset.user_id?.toString() === user.id) {
return asset;
}
if (asset.project_id) {
const project = await this.prisma.project.findUnique({
where: { id: asset.project_id },
select: { user_id: true }
});
if (project?.user_id.toString() === user.id) {
return asset;
}
}
throw new NotFoundException('Asset not found');
}
private async findEditableAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.prisma.asset.findUnique({
where: { id: this.parseId(assetId) }
});
if (!asset) {
throw new NotFoundException('Asset not found');
}
return asset;
if (user.role === 'admin' || asset.user_id?.toString() === user.id) {
return asset;
}
if (asset.project_id) {
const project = await this.prisma.project.findUnique({ where: { id: asset.project_id } });
if (project?.user_id.toString() === user.id) {
return asset;
}
}
throw new NotFoundException('Asset not found');
}
private buildDownloadFilename(asset: Asset) {
private async findAssetGeneration(asset: Asset): Promise<SafeAssetGeneration | null> {
const tasks = await this.prisma.renderTask.findMany({
where: { output_asset_id: asset.id }
});
const task = tasks.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())[0] ?? null;
if (task) {
const logs = await this.prisma.providerLog.findMany({
where: { task_id: task.id }
});
const log = logs.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())[0] ?? null;
const provider = await this.findGenerationProvider(task.provider_id ?? log?.provider_id ?? null);
return this.createAssetGenerationFromTask(task, provider, log);
}
const clips = await this.prisma.videoClip.findMany({
where: { output_asset_id: asset.id }
});
const clip = clips.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())[0] ?? null;
if (!clip) {
return null;
}
const provider = await this.findGenerationProvider(clip.provider_id);
return this.createAssetGenerationFromClip(clip, provider);
}
private async findGenerationProvider(providerId: bigint | null | undefined) {
if (!providerId) return null;
return this.prisma.providerConfig.findUnique({ where: { id: providerId } });
}
private createAssetGenerationFromTask(
task: RenderTask,
provider: ProviderConfig | null,
log: ProviderLog | null
): SafeAssetGeneration {
const response = this.jsonObject(log?.response_json ?? null);
const responseProviderRequestId =
this.stringifyJsonText(response.provider_request_id) ||
this.stringifyJsonText(response.task_id) ||
this.stringifyJsonText(response.id) ||
null;
const providerCode =
log?.provider_code ??
this.providerCodeFromTask(task) ??
provider?.provider_code ??
null;
return {
source: 'render_task',
display_name: this.displayNameFromTaskInput(task),
provider_id: provider?.id.toString() ?? task.provider_id?.toString() ?? log?.provider_id?.toString() ?? null,
provider_type: provider?.provider_type ?? log?.provider_type ?? null,
provider_code: providerCode,
provider_name: provider?.display_name ?? providerCode,
model_name: provider?.model_name ?? log?.model_name ?? this.modelNameFromTaskInput(task),
task_id: task.id.toString(),
task_type: task.task_type,
provider_request_id: task.provider_request_id ?? responseProviderRequestId,
clip_id: null,
status: task.status,
cost_actual: task.cost_actual?.toString() ?? log?.cost_actual?.toString() ?? null,
created_at: task.created_at.toISOString()
};
}
private createAssetGenerationFromClip(
clip: VideoClip,
provider: ProviderConfig | null
): SafeAssetGeneration {
return {
source: 'video_clip',
display_name: null,
provider_id: provider?.id.toString() ?? clip.provider_id?.toString() ?? null,
provider_type: provider?.provider_type ?? 'VideoProvider',
provider_code: provider?.provider_code ?? null,
provider_name: provider?.display_name ?? provider?.provider_code ?? null,
model_name: provider?.model_name ?? null,
task_id: null,
task_type: 'video_clip',
provider_request_id: null,
clip_id: clip.id.toString(),
status: clip.status,
cost_actual: clip.cost_actual?.toString() ?? null,
created_at: clip.created_at.toISOString()
};
}
private providerCodeFromTask(task: RenderTask) {
const inputJson = this.jsonObject(task.input_json ?? null);
const routerDecision = this.jsonObject(inputJson.router_decision ?? null);
const repairContext = this.jsonObject(inputJson.repair_context ?? null);
return (
this.stringifyJsonText(repairContext.provider_code) ||
this.stringifyJsonText(routerDecision.provider_code) ||
this.stringifyJsonText(inputJson.provider) ||
null
);
}
private modelNameFromTaskInput(task: RenderTask) {
const inputJson = this.jsonObject(task.input_json ?? null);
const routerDecision = this.jsonObject(inputJson.router_decision ?? null);
return (
this.stringifyJsonText(inputJson.model_name) ||
this.stringifyJsonText(inputJson.model) ||
this.stringifyJsonText(routerDecision.model_name) ||
this.stringifyJsonText(routerDecision.model) ||
null
);
}
private displayNameFromTaskInput(task: RenderTask) {
const inputJson = this.jsonObject(task.input_json ?? null);
return (
this.stringifyJsonText(inputJson.render_title) ||
this.stringifyJsonText(inputJson.display_name) ||
this.stringifyJsonText(inputJson.title) ||
null
);
}
private jsonObject(value: Prisma.InputJsonValue | Prisma.JsonValue | null | undefined) {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, Prisma.InputJsonValue | Prisma.JsonValue>
: {};
}
private stringifyJsonText(value: unknown) {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return '';
}
private buildDownloadFilename(asset: Asset, generation: SafeAssetGeneration | null = null) {
const extension = this.extensionFromMime(asset.mime_type) || this.extensionFromPath(asset.file_path);
const displayName = this.safeDownloadFilenameStem(asset.display_name ?? generation?.display_name ?? '');
if (displayName) {
return `${displayName}${extension}`;
}
const safeType = asset.asset_type.replace(/[^a-z0-9_-]/gi, '_') || 'asset';
return `${safeType}-${asset.id.toString()}${extension}`;
}
private safeDownloadFilenameStem(value: string) {
return value
.replace(/[\\/:*?"<>|]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 80);
}
private normalizeSelectionStatus(value: unknown) {
const normalized = this.normalizeNullableText(value, 30) ?? 'candidate';
if (!['candidate', 'selected', 'rejected'].includes(normalized)) {
throw new BadRequestException('Invalid selection_status');
}
return normalized;
}
private normalizeNullableText(value: unknown, maxLength: number) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
if (!text) return null;
return text.slice(0, maxLength);
}
private normalizeJsonObject(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return Prisma.JsonNull;
}
return value as Prisma.InputJsonObject;
}
private extensionFromPath(filePath: string) {
const match = /\.([a-z0-9]+)$/i.exec(filePath);
return match ? `.${match[1].toLowerCase()}` : '';
@@ -1,4 +1,4 @@
import { Controller, Get, Inject, Param, Res, StreamableFile } from '@nestjs/common';
import { Controller, Get, Headers, Inject, Param, Res, StreamableFile } from '@nestjs/common';
import type { Response } from 'express';
import { StorageService } from './storage.service';
@@ -9,16 +9,23 @@ export class PublicTempAssetsController {
@Get(':token')
async downloadTemporaryAsset(
@Param('token') token: string,
@Headers('range') range: string | undefined,
@Res({ passthrough: true }) response: Response
) {
const result = await this.storage.readTemporaryPublicFile(token);
const result = await this.storage.streamTemporaryPublicFile(token, range);
response.setHeader('Content-Type', result.mimeType);
response.setHeader('Content-Length', result.buffer.length.toString());
response.setHeader('Accept-Ranges', 'bytes');
response.setHeader('Content-Length', result.contentLength.toString());
if (result.statusCode === 206) {
response.status(206);
response.setHeader('Content-Range', `bytes ${result.start}-${result.end}/${result.size}`);
}
const cacheMaxAge = Math.max(0, Math.min(result.expiresAt - Math.floor(Date.now() / 1000), 24 * 60 * 60));
response.setHeader('Content-Disposition', 'inline');
response.setHeader('Cache-Control', 'private, max-age=0, no-store');
response.setHeader('Cache-Control', `private, max-age=${cacheMaxAge}, immutable`);
response.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(result.buffer);
return new StreamableFile(result.stream);
}
}
+117 -5
View File
@@ -1,5 +1,6 @@
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
import type { Readable } from 'node:stream';
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
@@ -12,6 +13,12 @@ type TemporaryPublicFilePayload = {
expires_at: number;
nonce: string;
};
type ByteRange = {
start: number;
end: number;
contentLength: number;
statusCode: 200 | 206;
};
@Injectable()
export class StorageService {
@@ -43,6 +50,10 @@ export class StorageService {
throw new BadRequestException('Unsupported storage path');
}
async streamPrivateFile(filePath: string, mimeType?: string | null, rangeHeader?: string) {
return this.streamStoredFile(filePath, mimeType || 'application/octet-stream', rangeHeader);
}
createTemporaryPublicUrl(input: {
filePath: string;
mimeType?: string | null;
@@ -74,6 +85,60 @@ export class StorageService {
};
}
async streamTemporaryPublicFile(token: string, rangeHeader?: string) {
const payload = this.verifyTemporaryPublicToken(token);
const result = await this.streamStoredFile(payload.file_path, payload.mime_type, rangeHeader);
return {
...result,
filePath: payload.file_path,
expiresAt: payload.expires_at
};
}
private async streamStoredFile(filePath: string, mimeType: string, rangeHeader?: string) {
if (filePath.startsWith('local://')) {
const fullPath = this.localObjectFullPath(filePath);
const stats = await stat(fullPath);
const range = this.resolveByteRange(rangeHeader, stats.size);
return {
stream: createReadStream(fullPath, { start: range.start, end: range.end }),
mimeType: mimeType || 'application/octet-stream',
size: stats.size,
start: range.start,
end: range.end,
contentLength: range.contentLength,
statusCode: range.statusCode
};
}
if (filePath.startsWith('minio://')) {
const { client, bucket, objectName } = this.minioObject(filePath);
const stats = await client.statObject(bucket, objectName);
const size = Number(stats.size);
const range = this.resolveByteRange(rangeHeader, size);
const stream = range.statusCode === 206
? await (client as unknown as {
getPartialObject: (bucketName: string, object: string, offset: number, length: number) => Promise<Readable>;
}).getPartialObject(bucket, objectName, range.start, range.contentLength)
: await client.getObject(bucket, objectName);
return {
stream,
mimeType: mimeType || 'application/octet-stream',
size,
start: range.start,
end: range.end,
contentLength: range.contentLength,
statusCode: range.statusCode
};
}
throw new BadRequestException('Unsupported storage path');
}
private async storeLocally(
file: Express.Multer.File,
prefix: string
@@ -130,15 +195,25 @@ export class StorageService {
}
private async readLocalObject(filePath: string) {
return readFile(this.localObjectFullPath(filePath));
}
private async readMinioObject(filePath: string) {
const { client, bucket, objectName } = this.minioObject(filePath);
const stream = await client.getObject(bucket, objectName);
return this.streamToBuffer(stream);
}
private localObjectFullPath(filePath: string) {
const objectName = filePath.replace(/^local:\/\//, '');
if (!objectName || objectName.includes('..')) {
throw new BadRequestException('Invalid local storage path');
}
return readFile(join(this.root, 'private', objectName));
return join(this.root, 'private', objectName);
}
private async readMinioObject(filePath: string) {
private minioObject(filePath: string) {
const match = /^minio:\/\/([^/]+)\/(.+)$/.exec(filePath);
if (!match) {
throw new BadRequestException('Invalid MinIO storage path');
@@ -152,8 +227,45 @@ export class StorageService {
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || ''
});
const stream = await client.getObject(bucket, objectName);
return this.streamToBuffer(stream);
return { client, bucket, objectName };
}
private resolveByteRange(rangeHeader: string | undefined, size: number): ByteRange {
if (!rangeHeader) {
return {
start: 0,
end: Math.max(0, size - 1),
contentLength: size,
statusCode: 200
};
}
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
if (!match || size <= 0) {
throw new BadRequestException('Invalid byte range');
}
const [, rawStart, rawEnd] = match;
const suffixLength = rawStart === '' ? Number(rawEnd) : null;
const start = suffixLength !== null
? Math.max(0, size - suffixLength)
: Number(rawStart);
const end = rawEnd && suffixLength === null
? Math.min(size - 1, Number(rawEnd))
: size - 1;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= size) {
throw new BadRequestException('Invalid byte range');
}
return {
start,
end,
contentLength: end - start + 1,
statusCode: 206
};
}
private async streamToBuffer(stream: Readable) {
+7
View File
@@ -4,3 +4,10 @@ export class UploadAssetDto {
asset_type?: AssetType;
project_id?: string;
}
export class UpdateAssetReviewStateDto {
display_name?: string | null;
selection_status?: 'candidate' | 'selected' | 'rejected';
selection_note?: string | null;
metadata_json?: unknown;
}
+287
View File
@@ -2,10 +2,45 @@ import type { CharacterRoleType, CharacterStatus } from './character.types';
export class ExtractCharactersDto {
story_bible_id?: string;
provider_code?: string;
}
export class ExtractProjectIpAssetsDto {
source?: 'story_bible' | 'all' | string;
include_characters?: boolean | string;
refresh_existing?: boolean | string;
}
export class OptimizeProjectIpAssetPromptsDto {
asset_kind?: 'prop' | 'scene' | 'all' | string;
min_quality_score?: number | string | null;
include_pending?: boolean | string;
}
export class ImportCharacterExtractionDto {
raw_text?: string;
source_label?: string;
quality_score?: number;
review_comment?: string;
apply_to_story_bible?: boolean | string;
}
export class UpdateCharacterExtractionReviewDto {
quality_score?: number;
review_comment?: string;
status?: string;
}
export class AutoBindCharacterVoicesDto {
voice_provider_code?: string;
force?: boolean | string;
}
export class CreateCharacterDto {
story_character_id?: string;
global_character_id?: string;
global_character_look_version_id?: string;
global_character_asset_id?: string;
name?: string;
alias_names?: string[];
role_type?: CharacterRoleType;
@@ -30,9 +65,261 @@ export class CreateCharacterDto {
voice_id?: string;
voice_style?: string;
performance_style?: string;
inherit_voice_from_global?: boolean | string;
inherit_digital_human_from_global?: boolean | string;
importance_level?: number;
anchor_asset_id?: string;
}
export class UpdateCharacterDto extends CreateCharacterDto {
status?: CharacterStatus;
}
export class SaveMyGlobalCharacterDto {
name?: string;
display_name?: string;
role_archetype?: CharacterRoleType;
gender_label?: string;
age_group?: string;
identity_desc?: string;
appearance_desc?: string;
face_desc?: string;
hair_desc?: string;
eye_desc?: string;
body_desc?: string;
default_costume_rules?: string;
special_props?: string;
personality_desc?: string;
speech_style?: string;
voice_provider_code?: string;
voice_model?: string;
voice_id?: string;
voice_style?: string;
performance_style?: string;
negative_rules?: string;
anchor_asset_id?: string;
voice_sample_asset_id?: string;
status?: string;
}
export class SetMyGlobalCharacterAssetsDto {
asset_ids?: string[];
look_version_id?: string;
consent_confirmed?: boolean | string;
set_primary_anchor?: boolean | string;
label?: string;
notes?: string;
}
export class SaveGlobalCharacterLookVersionDto {
version_name?: string;
style_type?: string;
appearance_desc?: string;
hair_desc?: string;
makeup_desc?: string;
body_desc?: string;
costume_rules?: string;
color_palette?: string;
key_props?: string;
negative_rules?: string;
main_anchor_asset_id?: string;
status?: string;
quality_score?: number | string | null;
reviewer_comment?: string;
source_project_id?: string;
}
export class ImportGlobalCharacterAssetDto {
asset_id?: string;
look_version_id?: string;
asset_type?: string;
source_type?: string;
source_label?: string;
label?: string;
prompt_text?: string;
negative_prompt?: string;
model_name?: string;
seed?: string;
resolution?: string;
aspect_ratio?: string;
cost_estimate?: number | string | null;
cost_actual?: number | string | null;
quality_score?: number | string | null;
consistency_score?: number | string | null;
face_similarity_score?: number | string | null;
license_status?: string;
commercial_allowed?: boolean | string;
review_status?: string;
reviewer_note?: string;
is_primary?: boolean | string;
set_as_main_anchor?: boolean | string;
metadata_json?: unknown;
}
export class SetGlobalCharacterPrimaryAssetDto {
global_character_asset_id?: string;
scope?: 'character' | 'look_version' | 'both';
}
export class UpdateGlobalCharacterAssetReviewDto {
label?: string;
prompt_text?: string;
negative_prompt?: string;
model_name?: string;
cost_estimate?: number | string | null;
cost_actual?: number | string | null;
quality_score?: number | string | null;
consistency_score?: number | string | null;
face_similarity_score?: number | string | null;
license_status?: string;
commercial_allowed?: boolean | string;
review_status?: string;
reviewer_note?: string;
}
export class PromoteCharacterToGlobalDto {
version_name?: string;
style_type?: string;
source_label?: string;
set_project_binding?: boolean | string;
}
export class BindCharacterIpDto {
global_character_id?: string;
look_version_id?: string;
global_character_asset_id?: string;
inherit_voice?: boolean | string;
inherit_digital_human?: boolean | string;
}
export class CreateCharacterDesignVersionDto {
prompt_text?: string;
negative_prompt?: string;
image_asset_id?: string;
notes?: string;
source?: string;
is_final?: boolean;
}
export class SaveCharacterPromptVersionDto {
layer_code?: string;
channel?: string;
title?: string;
source_type?: string;
source_label?: string;
prompt_engine_version?: string;
prompt_text?: string;
negative_prompt?: string;
model_name?: string;
usage_note?: string;
quality_score?: number | string | null;
review_comment?: string;
is_active?: boolean | string;
status?: string;
look_version_id?: string;
metadata_json?: unknown;
}
export class CreateCharacterStateDto {
state_code?: string;
display_name?: string;
description?: string;
wardrobe_rules?: string;
emotion_rules?: string;
prompt_suffix?: string;
negative_rules?: string;
reference_asset_id?: string;
status?: string;
}
export class TestCharacterAnchorVideoDto {
provider_code?: string;
duration?: number | string;
force?: boolean | string;
purpose?: 'anchor_test' | 'video_character_element_source';
reference_mode?: 'first_frame' | 'omni_reference';
voice_line?: string;
generate_audio?: boolean | string;
source_asset_id?: string;
prompt_override?: string;
negative_prompt?: string;
resolution?: '720p' | '1080p' | '4k';
mode?: 'std' | 'pro' | '4k';
}
export class ImportCharacterProviderBindingDto {
look_version_id?: string;
source_asset_id?: string;
provider_code?: string;
provider_asset_type?: 'video_character_element' | 'multi_image_element';
provider_element_id?: string;
element_name?: string;
element_description?: string;
source_duration?: number | string | null;
voice_bound?: boolean | string;
voice_id?: string;
voice_description?: string;
validation_score?: number | string | null;
validation_report_json?: unknown;
status?: 'candidate' | 'approved' | 'rejected' | 'archived';
is_primary?: boolean | string;
}
export class UpdateCharacterProviderBindingDto {
element_name?: string;
element_description?: string;
voice_bound?: boolean | string;
voice_id?: string;
voice_description?: string;
validation_score?: number | string | null;
validation_report_json?: unknown;
status?: 'candidate' | 'approved' | 'rejected' | 'archived';
is_primary?: boolean | string;
}
export class CreateProjectVisualAssetDto {
asset_id?: string;
asset_kind?: 'character' | 'prop' | 'scene';
asset_type?: string;
name?: string;
label?: string;
ownership_type?:
| 'role_identity'
| 'role_exclusive'
| 'shared_story_asset'
| 'neutral_asset'
| 'scene_lock'
| 'exclusive_character'
| 'shared_story'
| 'neutral';
owner_character_id?: string | null;
exclusive_owner?: string | null;
aliases?: string[];
allowed_roles?: string[];
source_type?: string;
source_label?: string;
detected_from?: string;
importance?: number | string | null;
visual_lock?: string;
key_objects?: string[];
prompt_block?: string;
anchor_prompt?: string;
prompt_text?: string;
negative_prompt?: string;
reference_images?: string[];
anchor_images?: string[];
render_variants?: unknown;
linked_assets?: string[];
reuse_rule?: string;
story_scope?: string;
version_note?: string;
notes?: string;
tags?: string[];
usage_tags?: string[];
quality_score?: number | string | null;
is_primary?: boolean | string;
metadata_json?: unknown;
status?: string;
}
export class UpdateProjectVisualAssetDto extends CreateProjectVisualAssetDto {}
+428 -1
View File
@@ -1,4 +1,17 @@
import type { Character, GlobalCharacter, Prisma } from '@prisma/client';
import type {
Asset,
Character,
CharacterProviderBinding,
CharacterDesignVersion,
CharacterPromptVersion,
CharacterState,
GlobalCharacter,
GlobalCharacterAsset,
GlobalCharacterLookVersion,
Prisma,
ProjectVisualAsset
} from '@prisma/client';
import { toSafeAsset, type SafeAsset } from '../assets/asset.types';
export const CHARACTER_ROLE_TYPES = [
'protagonist',
@@ -22,7 +35,10 @@ export type CharacterStatus = (typeof CHARACTER_STATUSES)[number];
export interface SafeCharacter {
id: string;
project_id: string;
story_character_id: string | null;
global_character_id: string | null;
global_character_look_version_id: string | null;
global_character_asset_id: string | null;
name: string;
alias_names: Prisma.JsonValue | null;
role_type: string;
@@ -48,6 +64,8 @@ export interface SafeCharacter {
voice_id: string | null;
voice_style: string | null;
performance_style: string | null;
inherit_voice_from_global: boolean;
inherit_digital_human_from_global: boolean;
importance_level: number;
status: string;
created_at: string;
@@ -88,11 +106,207 @@ export interface SafeGlobalCharacter {
updated_at: string;
}
export interface SafeGlobalCharacterAsset {
id: string;
global_character_id: string;
look_version_id: string | null;
asset_id: string | null;
asset_type: string;
source_type: string;
source_label: string | null;
label: string | null;
prompt_text: string | null;
negative_prompt: string | null;
model_name: string | null;
seed: string | null;
resolution: string | null;
aspect_ratio: string | null;
cost_estimate: string | null;
cost_actual: string | null;
quality_score: string | null;
consistency_score: string | null;
face_similarity_score: string | null;
license_status: string;
commercial_allowed: boolean;
review_status: string;
reviewer_note: string | null;
is_primary: boolean;
usage_count: number;
metadata_json: Prisma.JsonValue | null;
status: string;
created_at: string;
asset?: SafeAsset | null;
}
export interface SafeCharacterProviderBinding {
id: string;
global_character_id: string;
look_version_id: string | null;
source_asset_id: string | null;
provider_code: string;
provider_asset_type: string;
provider_element_id: string;
element_name: string;
element_description: string | null;
source_duration: string | null;
voice_bound: boolean;
voice_id: string | null;
voice_description: string | null;
binding_version: number;
validation_score: string | null;
validation_report_json: Prisma.JsonValue | null;
status: string;
is_primary: boolean;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
source_asset?: SafeAsset | null;
}
export interface SafeGlobalCharacterLookVersion {
id: string;
global_character_id: string;
version_name: string;
style_type: string | null;
appearance_desc: string | null;
hair_desc: string | null;
makeup_desc: string | null;
body_desc: string | null;
costume_rules: string | null;
color_palette: string | null;
key_props: string | null;
negative_rules: string | null;
main_anchor_asset_id: string | null;
status: string;
quality_score: string | null;
reviewer_comment: string | null;
source_project_id: string | null;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
}
export interface SafeCharacterDesignVersion {
id: string;
project_id: string;
character_id: string;
version_no: number;
prompt_text: string | null;
negative_prompt: string | null;
image_asset_id: string | null;
notes: string | null;
source: string;
is_final: boolean;
created_by_user_id: string | null;
created_at: string;
}
export interface SafeCharacterPromptVersion {
id: string;
project_id: string | null;
character_id: string | null;
global_character_id: string | null;
look_version_id: string | null;
version_no: number;
layer_code: string;
channel: string;
title: string | null;
source_type: string;
source_label: string | null;
prompt_engine_version: string | null;
prompt_text: string;
negative_prompt: string | null;
model_name: string | null;
usage_note: string | null;
quality_score: string | null;
review_comment: string | null;
is_active: boolean;
metadata_json: Prisma.JsonValue | null;
created_by_user_id: string | null;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeCharacterState {
id: string;
project_id: string;
character_id: string;
state_code: string;
display_name: string | null;
description: string | null;
wardrobe_rules: string | null;
emotion_rules: string | null;
prompt_suffix: string | null;
negative_rules: string | null;
reference_asset_id: string | null;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeProjectVisualAsset {
id: string;
project_id: string;
user_id: string;
asset_id: string | null;
asset_kind: string;
asset_type: string;
name: string;
label: string | null;
ownership_type: string;
owner_character_id: string | null;
source_type: string;
source_label: string | null;
aliases_json: Prisma.JsonValue | null;
aliases: string[];
allowed_roles_json: Prisma.JsonValue | null;
allowed_roles: string[];
detected_from: string | null;
importance: number;
visual_lock: string | null;
key_objects_json: Prisma.JsonValue | null;
key_objects: string[];
prompt_block: string | null;
anchor_prompt: string | null;
prompt_text: string | null;
negative_prompt: string | null;
reference_images_json: Prisma.JsonValue | null;
reference_images: string[];
anchor_images_json: Prisma.JsonValue | null;
anchor_images: string[];
render_variants_json: Prisma.JsonValue | null;
linked_assets_json: Prisma.JsonValue | null;
linked_assets: string[];
reuse_rule: string | null;
story_scope: string | null;
version_note: string | null;
notes: string | null;
usage_tags_json: Prisma.JsonValue | null;
usage_tags: string[];
tags: string[];
quality_score: number | null;
is_primary: boolean;
metadata_json: Prisma.JsonValue | null;
status: string;
created_at: string;
updated_at: string;
asset?: SafeAsset | null;
owner_character?: SafeCharacter | null;
}
function jsonStringArray(value: Prisma.JsonValue | null | undefined) {
return Array.isArray(value) ? value.map((item) => String(item)).filter(Boolean) : [];
}
export function toSafeCharacter(character: Character): SafeCharacter {
return {
id: character.id.toString(),
project_id: character.project_id.toString(),
story_character_id: character.story_character_id?.toString() ?? null,
global_character_id: character.global_character_id?.toString() ?? null,
global_character_look_version_id: character.global_character_look_version_id?.toString() ?? null,
global_character_asset_id: character.global_character_asset_id?.toString() ?? null,
name: character.name,
alias_names: character.alias_names,
role_type: character.role_type,
@@ -118,6 +332,8 @@ export function toSafeCharacter(character: Character): SafeCharacter {
voice_id: character.voice_id,
voice_style: character.voice_style,
performance_style: character.performance_style,
inherit_voice_from_global: character.inherit_voice_from_global,
inherit_digital_human_from_global: character.inherit_digital_human_from_global,
importance_level: character.importance_level,
status: character.status,
created_at: character.created_at.toISOString(),
@@ -125,6 +341,60 @@ export function toSafeCharacter(character: Character): SafeCharacter {
};
}
export function toSafeProjectVisualAsset(
row: ProjectVisualAsset & { asset?: Asset | null; owner_character?: Character | null }
): SafeProjectVisualAsset {
return {
id: row.id.toString(),
project_id: row.project_id.toString(),
user_id: row.user_id.toString(),
asset_id: row.asset_id?.toString() ?? null,
asset_kind: row.asset_kind,
asset_type: row.asset_type,
name: row.name,
label: row.label,
ownership_type: row.ownership_type,
owner_character_id: row.owner_character_id?.toString() ?? null,
source_type: row.source_type,
source_label: row.source_label,
aliases_json: row.aliases_json,
aliases: jsonStringArray(row.aliases_json),
allowed_roles_json: row.allowed_roles_json,
allowed_roles: jsonStringArray(row.allowed_roles_json),
detected_from: row.detected_from,
importance: row.importance,
visual_lock: row.visual_lock,
key_objects_json: row.key_objects_json,
key_objects: jsonStringArray(row.key_objects_json),
prompt_block: row.prompt_block,
anchor_prompt: row.anchor_prompt,
prompt_text: row.prompt_text,
negative_prompt: row.negative_prompt,
reference_images_json: row.reference_images_json,
reference_images: jsonStringArray(row.reference_images_json),
anchor_images_json: row.anchor_images_json,
anchor_images: jsonStringArray(row.anchor_images_json),
render_variants_json: row.render_variants_json,
linked_assets_json: row.linked_assets_json,
linked_assets: jsonStringArray(row.linked_assets_json),
reuse_rule: row.reuse_rule,
story_scope: row.story_scope,
version_note: row.version_note,
notes: row.notes,
usage_tags_json: row.usage_tags_json,
usage_tags: jsonStringArray(row.usage_tags_json),
tags: jsonStringArray(row.usage_tags_json),
quality_score: row.quality_score ? Number(row.quality_score.toString()) : null,
is_primary: row.is_primary,
metadata_json: row.metadata_json,
status: row.status,
created_at: row.created_at.toISOString(),
updated_at: row.updated_at.toISOString(),
asset: row.asset ? toSafeAsset(row.asset) : undefined,
owner_character: row.owner_character ? toSafeCharacter(row.owner_character) : undefined
};
}
export function toSafeGlobalCharacter(character: GlobalCharacter): SafeGlobalCharacter {
return {
id: character.id.toString(),
@@ -160,3 +430,160 @@ export function toSafeGlobalCharacter(character: GlobalCharacter): SafeGlobalCha
updated_at: character.updated_at.toISOString()
};
}
export function toSafeGlobalCharacterAsset(
row: GlobalCharacterAsset & { asset?: Asset | null }
): SafeGlobalCharacterAsset {
return {
id: row.id.toString(),
global_character_id: row.global_character_id.toString(),
look_version_id: row.look_version_id?.toString() ?? null,
asset_id: row.asset_id?.toString() ?? null,
asset_type: row.asset_type,
source_type: row.source_type,
source_label: row.source_label,
label: row.label,
prompt_text: row.prompt_text,
negative_prompt: row.negative_prompt,
model_name: row.model_name,
seed: row.seed,
resolution: row.resolution,
aspect_ratio: row.aspect_ratio,
cost_estimate: row.cost_estimate?.toString() ?? null,
cost_actual: row.cost_actual?.toString() ?? null,
quality_score: row.quality_score?.toString() ?? null,
consistency_score: row.consistency_score?.toString() ?? null,
face_similarity_score: row.face_similarity_score?.toString() ?? null,
license_status: row.license_status,
commercial_allowed: row.commercial_allowed,
review_status: row.review_status,
reviewer_note: row.reviewer_note,
is_primary: row.is_primary,
usage_count: row.usage_count,
metadata_json: row.metadata_json,
status: row.status,
created_at: row.created_at.toISOString(),
asset: row.asset ? toSafeAsset(row.asset) : undefined
};
}
export function toSafeCharacterProviderBinding(
row: CharacterProviderBinding & { source_asset?: Asset | null }
): SafeCharacterProviderBinding {
return {
id: row.id.toString(),
global_character_id: row.global_character_id.toString(),
look_version_id: row.look_version_id?.toString() ?? null,
source_asset_id: row.source_asset_id?.toString() ?? null,
provider_code: row.provider_code,
provider_asset_type: row.provider_asset_type,
provider_element_id: row.provider_element_id,
element_name: row.element_name,
element_description: row.element_description,
source_duration: row.source_duration?.toString() ?? null,
voice_bound: row.voice_bound,
voice_id: row.voice_id,
voice_description: row.voice_description,
binding_version: row.binding_version,
validation_score: row.validation_score?.toString() ?? null,
validation_report_json: row.validation_report_json,
status: row.status,
is_primary: row.is_primary,
created_by_user_id: row.created_by_user_id?.toString() ?? null,
created_at: row.created_at.toISOString(),
updated_at: row.updated_at.toISOString(),
source_asset: row.source_asset ? toSafeAsset(row.source_asset) : undefined
};
}
export function toSafeGlobalCharacterLookVersion(
version: GlobalCharacterLookVersion
): SafeGlobalCharacterLookVersion {
return {
id: version.id.toString(),
global_character_id: version.global_character_id.toString(),
version_name: version.version_name,
style_type: version.style_type,
appearance_desc: version.appearance_desc,
hair_desc: version.hair_desc,
makeup_desc: version.makeup_desc,
body_desc: version.body_desc,
costume_rules: version.costume_rules,
color_palette: version.color_palette,
key_props: version.key_props,
negative_rules: version.negative_rules,
main_anchor_asset_id: version.main_anchor_asset_id?.toString() ?? null,
status: version.status,
quality_score: version.quality_score?.toString() ?? null,
reviewer_comment: version.reviewer_comment,
source_project_id: version.source_project_id?.toString() ?? null,
created_by_user_id: version.created_by_user_id?.toString() ?? null,
created_at: version.created_at.toISOString(),
updated_at: version.updated_at.toISOString()
};
}
export function toSafeCharacterDesignVersion(version: CharacterDesignVersion): SafeCharacterDesignVersion {
return {
id: version.id.toString(),
project_id: version.project_id.toString(),
character_id: version.character_id.toString(),
version_no: version.version_no,
prompt_text: version.prompt_text,
negative_prompt: version.negative_prompt,
image_asset_id: version.image_asset_id?.toString() ?? null,
notes: version.notes,
source: version.source,
is_final: version.is_final,
created_by_user_id: version.created_by_user_id?.toString() ?? null,
created_at: version.created_at.toISOString()
};
}
export function toSafeCharacterPromptVersion(version: CharacterPromptVersion): SafeCharacterPromptVersion {
return {
id: version.id.toString(),
project_id: version.project_id?.toString() ?? null,
character_id: version.character_id?.toString() ?? null,
global_character_id: version.global_character_id?.toString() ?? null,
look_version_id: version.look_version_id?.toString() ?? null,
version_no: version.version_no,
layer_code: version.layer_code,
channel: version.channel,
title: version.title,
source_type: version.source_type,
source_label: version.source_label,
prompt_engine_version: version.prompt_engine_version,
prompt_text: version.prompt_text,
negative_prompt: version.negative_prompt,
model_name: version.model_name,
usage_note: version.usage_note,
quality_score: version.quality_score?.toString() ?? null,
review_comment: version.review_comment,
is_active: version.is_active,
metadata_json: version.metadata_json,
created_by_user_id: version.created_by_user_id?.toString() ?? null,
status: version.status,
created_at: version.created_at.toISOString(),
updated_at: version.updated_at.toISOString()
};
}
export function toSafeCharacterState(state: CharacterState): SafeCharacterState {
return {
id: state.id.toString(),
project_id: state.project_id.toString(),
character_id: state.character_id.toString(),
state_code: state.state_code,
display_name: state.display_name,
description: state.description,
wardrobe_rules: state.wardrobe_rules,
emotion_rules: state.emotion_rules,
prompt_suffix: state.prompt_suffix,
negative_rules: state.negative_rules,
reference_asset_id: state.reference_asset_id?.toString() ?? null,
status: state.status,
created_at: state.created_at.toISOString(),
updated_at: state.updated_at.toISOString()
};
}
+415 -1
View File
@@ -13,7 +13,32 @@ import {
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CreateCharacterDto, ExtractCharactersDto, UpdateCharacterDto } from './character.dto';
import {
AutoBindCharacterVoicesDto,
BindCharacterIpDto,
CreateCharacterDesignVersionDto,
CreateCharacterDto,
CreateCharacterStateDto,
CreateProjectVisualAssetDto,
ExtractCharactersDto,
ExtractProjectIpAssetsDto,
ImportGlobalCharacterAssetDto,
ImportCharacterProviderBindingDto,
ImportCharacterExtractionDto,
OptimizeProjectIpAssetPromptsDto,
PromoteCharacterToGlobalDto,
SaveMyGlobalCharacterDto,
SaveGlobalCharacterLookVersionDto,
SaveCharacterPromptVersionDto,
SetGlobalCharacterPrimaryAssetDto,
SetMyGlobalCharacterAssetsDto,
TestCharacterAnchorVideoDto,
UpdateCharacterProviderBindingDto,
UpdateCharacterExtractionReviewDto,
UpdateGlobalCharacterAssetReviewDto,
UpdateCharacterDto,
UpdateProjectVisualAssetDto
} from './character.dto';
import { CharactersService } from './characters.service';
@Controller()
@@ -39,6 +64,41 @@ export class CharactersController {
return this.charactersService.listCharacters(user, projectId, includeDeleted === 'true');
}
@Get('projects/:projectId/characters/anchor-video-tests')
listCharacterAnchorVideoTests(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.charactersService.listCharacterAnchorVideoTests(user, projectId);
}
@Get('projects/:projectId/characters/extraction-versions')
listCharacterExtractionVersions(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.charactersService.listCharacterExtractionVersions(user, projectId);
}
@Post('projects/:projectId/characters/extraction-versions/import')
importCharacterExtraction(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: ImportCharacterExtractionDto
) {
return this.charactersService.importCharacterExtraction(user, projectId, dto);
}
@Patch('projects/:projectId/characters/extraction-versions/:versionId/review')
updateCharacterExtractionReview(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('versionId') versionId: string,
@Body() dto: UpdateCharacterExtractionReviewDto
) {
return this.charactersService.updateCharacterExtractionReview(user, projectId, versionId, dto);
}
@Post('projects/:projectId/characters')
createCharacter(
@CurrentUser() user: AuthRequestUser,
@@ -48,6 +108,149 @@ export class CharactersController {
return this.charactersService.createCharacter(user, projectId, dto);
}
@Get('me/global-characters')
listMyGlobalCharacters(@CurrentUser() user: AuthRequestUser) {
return this.charactersService.listMyGlobalCharacters(user);
}
@Post('me/global-characters')
createMyGlobalCharacter(
@CurrentUser() user: AuthRequestUser,
@Body() dto: SaveMyGlobalCharacterDto
) {
return this.charactersService.createMyGlobalCharacter(user, dto);
}
@Patch('me/global-characters/:globalCharacterId')
updateMyGlobalCharacter(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SaveMyGlobalCharacterDto
) {
return this.charactersService.updateMyGlobalCharacter(user, globalCharacterId, dto);
}
@Get('me/global-characters/:globalCharacterId/provider-bindings')
listMyCharacterProviderBindings(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string
) {
return this.charactersService.listMyCharacterProviderBindings(user, globalCharacterId);
}
@Post('me/global-characters/:globalCharacterId/provider-bindings')
importMyCharacterProviderBinding(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: ImportCharacterProviderBindingDto
) {
return this.charactersService.importMyCharacterProviderBinding(user, globalCharacterId, dto);
}
@Patch('me/global-characters/:globalCharacterId/provider-bindings/:bindingId')
updateMyCharacterProviderBinding(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Param('bindingId') bindingId: string,
@Body() dto: UpdateCharacterProviderBindingDto
) {
return this.charactersService.updateMyCharacterProviderBinding(user, globalCharacterId, bindingId, dto);
}
@Post('me/global-characters/:globalCharacterId/assets')
setMyGlobalCharacterAssets(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SetMyGlobalCharacterAssetsDto
) {
return this.charactersService.setMyGlobalCharacterAssets(user, globalCharacterId, dto);
}
@Post('me/global-characters/:globalCharacterId/look-versions')
createGlobalCharacterLookVersion(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SaveGlobalCharacterLookVersionDto
) {
return this.charactersService.createGlobalCharacterLookVersion(user, globalCharacterId, dto);
}
@Post('me/global-characters/:globalCharacterId/assets/import')
importGlobalCharacterAsset(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: ImportGlobalCharacterAssetDto
) {
return this.charactersService.importGlobalCharacterAsset(user, globalCharacterId, dto);
}
@Post('me/global-characters/:globalCharacterId/assets/set-primary')
setGlobalCharacterPrimaryAsset(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SetGlobalCharacterPrimaryAssetDto
) {
return this.charactersService.setGlobalCharacterPrimaryAsset(user, globalCharacterId, dto);
}
@Get('me/global-characters/:globalCharacterId/visual-anchor-card')
getGlobalCharacterVisualAnchorCard(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Query('look_version_id') lookVersionId?: string
) {
return this.charactersService.getGlobalCharacterVisualAnchorCard(user, globalCharacterId, lookVersionId);
}
@Get('me/global-characters/:globalCharacterId/prompt-versions')
listGlobalCharacterPromptVersions(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string
) {
return this.charactersService.listGlobalCharacterPromptVersions(user, globalCharacterId);
}
@Post('me/global-characters/:globalCharacterId/prompt-versions')
createGlobalCharacterPromptVersion(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SaveCharacterPromptVersionDto
) {
return this.charactersService.createGlobalCharacterPromptVersion(user, globalCharacterId, dto);
}
@Post('me/global-characters/:globalCharacterId/prompt-versions/refresh-system')
refreshGlobalCharacterSystemPrompt(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string
) {
return this.charactersService.refreshGlobalCharacterSystemPrompt(user, globalCharacterId);
}
@Post('me/global-characters/:globalCharacterId/prompt-versions/:versionId/activate')
activateGlobalCharacterPromptVersion(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Param('versionId') versionId: string
) {
return this.charactersService.activateGlobalCharacterPromptVersion(user, globalCharacterId, versionId);
}
@Patch('me/global-characters/:globalCharacterId/assets/:globalCharacterAssetId/review')
updateGlobalCharacterAssetReview(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Param('globalCharacterAssetId') globalCharacterAssetId: string,
@Body() dto: UpdateGlobalCharacterAssetReviewDto
) {
return this.charactersService.updateGlobalCharacterAssetReview(
user,
globalCharacterId,
globalCharacterAssetId,
dto
);
}
@Post('projects/:projectId/characters/confirm')
confirmCharacters(
@CurrentUser() user: AuthRequestUser,
@@ -56,6 +259,96 @@ export class CharactersController {
return this.charactersService.confirmCharacters(user, projectId);
}
@Post('projects/:projectId/characters/voice-profiles/auto-bind')
autoBindCharacterVoices(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: AutoBindCharacterVoicesDto
) {
return this.charactersService.autoBindCharacterVoices(user, projectId, dto);
}
@Get('projects/:projectId/visual-assets')
listProjectVisualAssets(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('asset_kind') assetKind?: string
) {
return this.charactersService.listProjectVisualAssets(user, projectId, assetKind);
}
@Get('projects/:projectId/ops/ip-assets')
listProjectIpAssets(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('asset_kind') assetKind?: string
) {
return this.charactersService.listProjectIpAssets(user, projectId, assetKind);
}
@Get('projects/:projectId/ops/ip-assets/conflicts')
listProjectIpAssetConflicts(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.charactersService.listProjectIpAssetConflicts(user, projectId);
}
@Post('projects/:projectId/ops/ip-assets/extract')
extractProjectIpAssets(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: ExtractProjectIpAssetsDto
) {
return this.charactersService.extractProjectIpAssets(user, projectId, dto);
}
@Post('projects/:projectId/ops/ip-assets/optimize-prompts')
optimizeProjectIpAssetPrompts(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: OptimizeProjectIpAssetPromptsDto
) {
return this.charactersService.optimizeProjectIpAssetPrompts(user, projectId, dto);
}
@Post('projects/:projectId/ops/ip-assets')
createProjectIpAsset(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CreateProjectVisualAssetDto
) {
return this.charactersService.createProjectIpAsset(user, projectId, dto);
}
@Delete('projects/:projectId/ops/ip-assets/:visualAssetId')
deleteProjectIpAsset(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('visualAssetId') visualAssetId: string
) {
return this.charactersService.deleteProjectVisualAsset(user, projectId, visualAssetId);
}
@Post('projects/:projectId/visual-assets')
createProjectVisualAsset(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CreateProjectVisualAssetDto
) {
return this.charactersService.createProjectVisualAsset(user, projectId, dto);
}
@Patch('projects/:projectId/visual-assets/:visualAssetId')
updateProjectVisualAsset(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('visualAssetId') visualAssetId: string,
@Body() dto: UpdateProjectVisualAssetDto
) {
return this.charactersService.updateProjectVisualAsset(user, projectId, visualAssetId, dto);
}
@Patch('characters/:characterId')
updateCharacter(
@CurrentUser() user: AuthRequestUser,
@@ -65,6 +358,127 @@ export class CharactersController {
return this.charactersService.updateCharacter(user, characterId, dto);
}
@Post('characters/:characterId/promote-to-global')
promoteCharacterToGlobal(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: PromoteCharacterToGlobalDto
) {
return this.charactersService.promoteCharacterToGlobal(user, characterId, dto);
}
@Post('characters/:characterId/bind-global-ip')
bindCharacterIp(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: BindCharacterIpDto
) {
return this.charactersService.bindCharacterIp(user, characterId, dto);
}
@Get('characters/:characterId/visual-anchor-card')
getCharacterVisualAnchorCard(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Query('look_version_id') lookVersionId?: string
) {
return this.charactersService.getCharacterVisualAnchorCard(user, characterId, lookVersionId);
}
@Get('characters/:characterId/anchor-video-test')
getCharacterAnchorVideoTest(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.getCharacterAnchorVideoTest(user, characterId);
}
@Post('characters/:characterId/anchor-video-test')
createCharacterAnchorVideoTest(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: TestCharacterAnchorVideoDto
) {
return this.charactersService.createCharacterAnchorVideoTest(user, characterId, dto);
}
@Get('characters/:characterId/prompt-versions')
listCharacterPromptVersions(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.listCharacterPromptVersions(user, characterId);
}
@Post('characters/:characterId/prompt-versions')
createCharacterPromptVersion(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: SaveCharacterPromptVersionDto
) {
return this.charactersService.createCharacterPromptVersion(user, characterId, dto);
}
@Post('characters/:characterId/prompt-versions/refresh-system')
refreshCharacterSystemPrompt(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.refreshCharacterSystemPrompt(user, characterId);
}
@Post('characters/:characterId/prompt-versions/:versionId/activate')
activateCharacterPromptVersion(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Param('versionId') versionId: string
) {
return this.charactersService.activateCharacterPromptVersion(user, characterId, versionId);
}
@Get('characters/:characterId/design-versions')
listCharacterDesignVersions(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.listCharacterDesignVersions(user, characterId);
}
@Post('characters/:characterId/design-versions')
createCharacterDesignVersion(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: CreateCharacterDesignVersionDto
) {
return this.charactersService.createCharacterDesignVersion(user, characterId, dto);
}
@Post('characters/:characterId/design-versions/:versionId/finalize')
finalizeCharacterDesignVersion(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Param('versionId') versionId: string
) {
return this.charactersService.finalizeCharacterDesignVersion(user, characterId, versionId);
}
@Get('characters/:characterId/states')
listCharacterStates(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.listCharacterStates(user, characterId);
}
@Post('characters/:characterId/states')
upsertCharacterState(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: CreateCharacterStateDto
) {
return this.charactersService.upsertCharacterState(user, characterId, dto);
}
@Delete('characters/:characterId')
deleteCharacter(
@CurrentUser() user: AuthRequestUser,
+3 -1
View File
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { AssetsModule } from '../assets/assets.module';
import { AuthModule } from '../auth/auth.module';
import { ProvidersModule } from '../providers/providers.module';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
@Module({
imports: [AuthModule],
imports: [AuthModule, AssetsModule, ProvidersModule],
controllers: [CharactersController],
providers: [CharactersService],
exports: [CharactersService]
@@ -129,6 +129,10 @@ describe('CharactersService', () => {
updateMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
};
providerConfig: {
findUnique: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
characterMemory: { create: ReturnType<typeof vi.fn> };
$transaction: ReturnType<typeof vi.fn>;
};
@@ -177,6 +181,25 @@ describe('CharactersService', () => {
updateMany: vi.fn(),
createMany: vi.fn()
},
providerConfig: {
findUnique: vi.fn().mockResolvedValue({
id: 1n,
provider_type: 'VoiceProvider',
provider_code: 'openai-tts',
display_name: 'OpenAI TTS',
mode: 'real',
model_name: 'gpt-4o-mini-tts',
config_json: { voice: 'coral' },
fallback_provider_id: null,
is_enabled: true,
priority: 100,
rate_limit_json: null,
cost_rule_json: null,
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z')
}),
findFirst: vi.fn().mockResolvedValue(null)
},
characterMemory: {
create: vi.fn().mockResolvedValue({})
},
@@ -291,4 +314,35 @@ describe('CharactersService', () => {
ForbiddenException
);
});
it('keeps clear fictional identity faces while rejecting copied real identities', () => {
const conflictingRules = [
'不要真人照片级清晰正脸',
'不要证件照式正面大头照',
'不要真实演员肖像照',
'不要可识别真人脸特写',
'不要高精真人脸摄影棚写真'
];
const privateService = service as unknown as {
personNegativePromptCn: (extraRules?: string | null) => string;
seedanceSafeCharacterNegativePrompt: (text: string) => string;
};
const genericNegativePrompt = privateService.personNegativePromptCn(
'不得换脸;不要真人照片级清晰正脸'
);
const seedanceNegativePrompt = privateService.seedanceSafeCharacterNegativePrompt(
'不得换脸,不要可识别真人脸特写'
);
for (const prompt of [genericNegativePrompt, seedanceNegativePrompt]) {
expect(prompt).toContain('不要复制现实演员、公众人物或未经授权真人的可识别面貌');
expect(prompt).toContain('不要生活摄影、商业写真或平台肖像模板');
for (const rule of conflictingRules) {
expect(prompt).not.toContain(rule);
}
}
expect(genericNegativePrompt).toContain('不得换脸');
expect(seedanceNegativePrompt).toContain('不得换脸');
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import {
buildCharacterTurnaroundPanelPrompt,
CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION,
type CharacterTurnaroundPanelType
} from './character-turnaround-panel-template';
const profile = {
name: '诸葛亮',
genderLabel: '男',
ageGroup: '中年',
identityDesc: '五丈原时期蜀汉军师',
appearanceDesc: '清癯、沉静、睿智,具有真实中年感',
faceDesc: '长脸,轻微眼袋、法令纹,短髭与山羊胡',
hairDesc: '黑发夹灰白,高道髻',
eyeDesc: '深琥珀色眼睛',
bodyDesc: '身形修长挺拔',
costumeRules: '白色交领军师袍,浅青内层,淡金云纹,深色布靴'
};
describe('character turnaround split-panel prompt', () => {
const cases: Array<[CharacterTurnaroundPanelType, string]> = [
['turnaround_identity_panel', '身份特写'],
['turnaround_front_panel', '严格正面'],
['turnaround_side_panel', '严格90度右向侧面'],
['turnaround_back_panel', '严格180度背面']
];
it.each(cases)('builds one auditable %s contract', (panelType, requiredGeometry) => {
const prompt = buildCharacterTurnaroundPanelPrompt({
profile,
panelType,
referenceImageCount: 2,
liveAction: true
});
expect(prompt).toContain(CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION);
expect(prompt).toContain(requiredGeometry);
expect(prompt).toContain('16:9横版');
expect(prompt).toContain('纯白无缝摄影棚背景');
expect(prompt).toContain('合成安全区');
expect(prompt).toContain('不得在同一张图中生成第二个人');
expect(prompt).toContain('双手空置');
expect(prompt).not.toContain('四个全身');
expect(prompt).not.toContain('两排');
});
it('makes side and back geometry mutually explicit', () => {
const side = buildCharacterTurnaroundPanelPrompt({
profile,
panelType: 'turnaround_side_panel',
referenceImageCount: 2,
liveAction: true
});
const back = buildCharacterTurnaroundPanelPrompt({
profile,
panelType: 'turnaround_back_panel',
referenceImageCount: 3,
liveAction: true
});
expect(side).toContain('禁止45度、三分之二侧面和回头看镜头');
expect(back).toContain('脸部完全不可见');
expect(back).toContain('禁止侧脸、回头、扭腰和三分之二背面');
});
});
@@ -0,0 +1,120 @@
export const CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION =
'character_turnaround_split_panel_v1_1';
export const CHARACTER_TURNAROUND_PANEL_TYPES = [
'turnaround_identity_panel',
'turnaround_front_panel',
'turnaround_side_panel',
'turnaround_back_panel'
] as const;
export type CharacterTurnaroundPanelType =
(typeof CHARACTER_TURNAROUND_PANEL_TYPES)[number];
export interface CharacterTurnaroundPanelProfile {
name: string;
descriptionOverride?: string | null;
roleType?: string | null;
genderLabel?: string | null;
ageGroup?: string | null;
identityDesc?: string | null;
appearanceDesc?: string | null;
faceDesc?: string | null;
hairDesc?: string | null;
eyeDesc?: string | null;
bodyDesc?: string | null;
costumeRules?: string | null;
}
export function isCharacterTurnaroundPanelType(
value: string
): value is CharacterTurnaroundPanelType {
return (CHARACTER_TURNAROUND_PANEL_TYPES as readonly string[]).includes(value);
}
export function characterTurnaroundPanelLabel(type: CharacterTurnaroundPanelType) {
const labels: Record<CharacterTurnaroundPanelType, string> = {
turnaround_identity_panel: '身份特写',
turnaround_front_panel: '严格正面',
turnaround_side_panel: '严格90度侧面',
turnaround_back_panel: '严格180度背面'
};
return labels[type];
}
export function buildCharacterTurnaroundPanelPrompt(input: {
profile: CharacterTurnaroundPanelProfile;
panelType: CharacterTurnaroundPanelType;
referenceImageCount: number;
liveAction: boolean;
}) {
const profile = input.profile;
const profileText = profile.descriptionOverride
? `DESCRIPTION_OVERRIDE_MODE,本次唯一角色设定:${profile.descriptionOverride}`
: [
`角色名:${profile.name}`,
`身份:${profile.identityDesc || profile.roleType || ''}`,
`性别与年龄:${profile.genderLabel || ''}${profile.ageGroup || ''}`,
`外貌气质:${profile.appearanceDesc || ''}`,
`脸部:${profile.faceDesc || ''}`,
`发型与毛发:${profile.hairDesc || ''}`,
`眼睛:${profile.eyeDesc || ''}`,
`体型:${profile.bodyDesc || ''}`,
`基础服装:${profile.costumeRules || ''}`
].join('\n');
const referenceMode = input.referenceImageCount > 0
? `REFERENCE_LOCK_MODE:已附带 ${input.referenceImageCount} 张同角色参考图。参考图是身份、年龄、脸型、发型、体型与基础服装结构的最高真值;保持同一虚构数字演员,禁止换脸和重新设计服装。`
: 'DIRECT_DESIGN_MODE:未附带参考图。只按角色设定建立一个唯一、稳定、可复用的原创数字演员。';
const common = [
CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION,
'S+影视角色母版拆分面板,16:9横版,单人,单一视角,纯白无缝摄影棚背景,均匀柔和棚拍光,低透视畸变,电影级写实与AAA角色资产质感。',
'使用当前模型与API参数允许的最高原生质量,保留适合4K放大、程序裁切和视频身份锁定的结构与微材质。',
'这不是多格设定板,不得在同一张图中生成第二个人、第二个角度、局部小窗、文字、标签、箭头、边框或水印。',
referenceMode,
profileText,
'身份、年龄、五官、发型、胡须、体型、服装版型、领口、腰带、袖口、鞋履和常驻穿戴配饰必须稳定。双手空置,不展示羽扇、武器、书卷、手机或剧情道具。',
'鞋履是跨面板硬锁定结构:必须符合角色所属时代与身份,使用同色软底传统鞋靴,鞋面、鞋底和包边保持深色同材质;禁止白色或浅色橡胶中底、运动鞋弧形鞋底、现代休闲鞋鞋头、拉链和工业胶边。',
'正面、侧面和背面必须保持完全相同的头顶至鞋底高度、肩宽、腰线、四肢长度与服装层级。中年或老年角色在侧面和背面也要通过灰白发分布、后颈、耳部、手部皮肤与克制姿态保持年龄,不得自动年轻化。',
input.liveAction
? '画面达到高预算历史电影官方角色资产母版:真实皮肤年龄纹理、清晰发丝与胡须根部、可辨布料织纹、缝线、刺绣、层叠和自然褶皱;克制写实,不偶像化,不仙侠海报化。'
: '高质量角色资产板,结构清楚,材质可辨,适合后续关键帧与视频一致性锁定。'
];
const panelRules: Record<CharacterTurnaroundPanelType, string[]> = {
turnaround_identity_panel: [
'面板任务:只生成身份特写。角色严格正对镜头,眼睛平视,相机与眼睛等高,中性克制表情,头部端正,不歪头,不做三分之二侧脸。',
'构图为头肩至胸上部特写,完整保留头顶、发髻、双耳、下巴、肩线和基础领口;脸部与肩部全部位于画面中央约27%的合成安全区,左右留出大量纯白空间。',
'两眼清晰、瞳孔方向一致,面部左右结构可信;放大后仍保留皮肤、眼周、法令纹、毛孔、胡须与发丝微细节。年龄必须贴合设定,不得比目标年龄明显年轻,也不得额外老化成高龄角色。'
],
turnaround_front_panel: [
'面板任务:只生成严格正面全身。角色身体、头部、肩线、骨盆和双脚全部正对镜头,左右对称,目视正前方。',
'标准角色建模中性站姿,双臂与躯干略微分开,双手和五指完全分离可见,双腿自然平行;从头顶到鞋底完整入画,同一水平基线。传统鞋靴必须是深色同材质软薄底,不能出现任何浅色现代鞋底边。',
'完整人物必须收纳在画面中央约22%的合成安全区,人物高度约占画面84%,两侧保持大面积纯白,不得裁手、裁脚、裁衣摆。'
],
turnaround_side_panel: [
'面板任务:只生成严格90度右向侧面全身。角色鼻尖、胸口、膝盖和脚尖统一朝画面右侧;只呈现标准侧面轮廓,禁止45度、三分之二侧面和回头看镜头。',
'相机与人物腰部等高,正交角色建模参考视角。胸腔、腰带、衣襟中线和鞋底压缩为真正侧面厚度,不展开任何正面结构;双臂自然下垂且略分离,头顶至鞋底完整入画,服装侧面层次与正面参考一致。传统鞋靴保持深色软薄底,无浅色胶边。',
'完整人物必须收纳在画面中央约22%的合成安全区,人物高度约占画面84%,两侧保持大面积纯白。'
],
turnaround_back_panel: [
'面板任务:只生成严格180度背面全身。角色后脑、双肩、脊柱、骨盆和脚跟正对镜头;脸部完全不可见,禁止侧脸、回头、扭腰和三分之二背面。',
'清晰展示发髻后部、发带、衣领后部、背部中心接缝、腰带后部、袍服垂坠、衣摆和鞋跟;长发自然分束并露出后领中心与腰带拓扑,结构必须能与正面、侧面参考对应。双手五指清晰分离,传统鞋靴保持深色同材质软薄底。',
'完整人物必须收纳在画面中央约22%的合成安全区,人物高度约占画面84%,双臂与身体略微分开,头顶至鞋底完整入画。'
]
};
return [...common, ...panelRules[input.panelType]].join('\n\n');
}
export function buildCharacterTurnaroundPanelNegativePrompt(extraRules?: string | null) {
return [
'multiple people, duplicate person, multiple views, split screen, collage, contact sheet, inset panel, text, label, arrow, watermark, logo',
'identity drift, face drift, age drift, different person, changed hairstyle, changed beard, changed costume, changed body proportion',
'45-degree view, three-quarter view, head turn, looking back, twisted torso, perspective distortion, wide-angle distortion',
'cropped head, cropped hair bun, cropped hands, cropped fingers, cropped feet, cropped shoes, cropped hem, extra limbs, extra fingers, fused hands, malformed anatomy',
'weapon, fan, book, scroll, phone, handheld prop, scene prop, dramatic environment, fantasy poster, glowing magic, aura, smoke, cinematic action pose',
'young idol face, beauty filter, plastic skin, anime, illustration, painterly, low resolution, blur, overexposure, crushed white fabric detail',
'modern sneakers, modern casual shoes, sports shoes, white rubber midsole, contrast sole edge, thick rubber sole, zipper boots, athletic curved toe',
extraRules || ''
].filter(Boolean).join(', ');
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import {
buildCharacterTurnaroundNegativePrompt,
buildCharacterTurnaroundPublicPrompt,
CHARACTER_TURNAROUND_CONFLICTING_RESTRICTIONS,
CHARACTER_TURNAROUND_HARD_FAILURES,
CHARACTER_TURNAROUND_SCORE_DIMENSIONS,
CHARACTER_TURNAROUND_TEMPLATE_VERSION,
isCurrentCharacterTurnaroundPrompt
} from './character-turnaround-template';
const profile = {
name: '测试角色',
roleType: '谋士',
genderLabel: '男',
ageGroup: '48岁',
identityDesc: '古代军师',
appearanceDesc: '沉静克制',
faceDesc: '清癯长脸,眉眼锐利',
hairDesc: '束发,鬓角少量灰白',
eyeDesc: '深色眼睛',
bodyDesc: '修长挺拔',
costumeRules: '多层交领长袍,深色布靴'
};
describe('character turnaround public template', () => {
it('builds the same S+ industrial layout for reference-locked generation', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile,
referenceImageCount: 1,
liveAction: true
});
expect(prompt).toContain(CHARACTER_TURNAROUND_TEMPLATE_VERSION);
expect(prompt).toContain('当前模型与 API 参数允许的最高原生质量');
expect(prompt).toContain('REFERENCE_LOCK_MODE');
expect(prompt).toContain('16:9 横版角色');
expect(prompt).not.toContain('9:16');
expect(prompt).toContain('左侧约38%');
expect(prompt).toContain('严格正面全身、严格90度侧面全身、严格背面全身');
expect(prompt).toContain('中年保留适量额纹、眼周纹、法令纹');
expect(prompt).toContain('服装拓扑');
expect(prompt).toContain('默认双手自然下垂并保持空手');
expect(prompt).toContain('禁止现代运动鞋');
expect(prompt).toContain('浅色和白色服装必须压住高光');
expect(prompt).not.toContain('第一行:四个全身');
expect(prompt).not.toContain('八卦阵');
});
it('supports direct original generation without pretending a main anchor exists', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile,
referenceImageCount: 0,
liveAction: true
});
expect(prompt).toContain('DIRECT_DESIGN_MODE');
expect(prompt).toContain('本次不使用任何参考图片');
expect(prompt).not.toContain('已上传参考图是角色身份');
});
it('uses a direct description as the only character source and removes old profile fields', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile: {
...profile,
descriptionOverride: '62岁女性边关统帅,银灰短发,左眉旧伤,深红鳞甲与黑色战靴。'
},
referenceImageCount: 0,
liveAction: true
});
expect(prompt).toContain('DESCRIPTION_OVERRIDE_MODE');
expect(prompt).toContain('本次唯一角色描述:62岁女性边关统帅');
expect(prompt).toContain('禁止读取、补写、推断或混合角色库历史字段');
expect(prompt).not.toContain('角色类型:谋士');
expect(prompt).not.toContain('真实年龄或年龄段:48岁');
expect(prompt).not.toContain('清癯长脸');
expect(prompt).not.toContain('多层交领长袍');
});
it('exposes an optional-reference public copy mode for every character type', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile: { ...profile, name: '通用角色', roleType: '女侠', genderLabel: '女', ageGroup: '青年' },
referenceImageCount: null,
liveAction: true
});
expect(prompt).toContain('REFERENCE_OPTION_MODE');
expect(prompt).toContain('带主锚点锁定');
expect(prompt).toContain('无主锚点直接原创');
expect(prompt).not.toContain('诸葛亮');
});
it('defines hard blockers, a 100-point rubric and strict negative constraints', () => {
const negative = buildCharacterTurnaroundNegativePrompt('不要改变角色胎记');
expect(CHARACTER_TURNAROUND_HARD_FAILURES).toHaveLength(9);
expect(CHARACTER_TURNAROUND_SCORE_DIMENSIONS).toHaveLength(7);
expect(negative).toContain('45度或三分之二侧面冒充严格90度侧面');
expect(negative).toContain('服装拓扑错误:');
expect(negative).toContain('衣领');
expect(negative).toContain('手持剧情道具');
expect(negative).toContain('现代运动鞋');
expect(negative).toContain('不要改变角色胎记');
expect(negative).toContain('身份错误:');
expect(negative).toContain('摄影与材质错误:');
expect(CHARACTER_TURNAROUND_CONFLICTING_RESTRICTIONS.every((term) => !negative.includes(term))).toBe(true);
});
it('uses a dedicated structural branch for multi-head non-human characters', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile: {
name: '九幽鬼将',
roleType: '召唤实体',
genderLabel: '非人形男性战将意象',
ageGroup: '古老亡灵',
identityDesc: '三头六臂的中国古战场亡灵战将',
appearanceDesc: '百丈体量,腐朽古代重甲',
faceDesc: '中首为主身份头部,左右副首关系固定',
bodyDesc: '三头六臂,六臂关节清晰',
costumeRules: '腐朽中国古代重甲'
},
referenceImageCount: 0,
liveAction: true
});
expect(prompt).toContain('主身份头部超清特写');
expect(prompt).toContain('头部数量、肢体数量、关节结构');
expect(prompt).toContain('原创、稳定、可复用的非人角色资产');
expect(prompt).toContain('16:9 横版角色');
expect(prompt).toContain('photorealistic CGI creature or supernatural character asset');
expect(prompt).not.toContain('同一位原创虚构数字演员的同一套角色定妆');
expect(isCurrentCharacterTurnaroundPrompt(prompt)).toBe(true);
});
it('rejects old versions and face-suppressing conflicts as current templates', () => {
const current = buildCharacterTurnaroundPublicPrompt({
profile,
referenceImageCount: 0,
liveAction: true
});
expect(isCurrentCharacterTurnaroundPrompt(current)).toBe(true);
expect(isCurrentCharacterTurnaroundPrompt(current.replace(CHARACTER_TURNAROUND_TEMPLATE_VERSION, 'character_turnaround_public_v3_s_plus'))).toBe(false);
expect(isCurrentCharacterTurnaroundPrompt(`${current}\n不要真人照片级清晰正脸`)).toBe(false);
});
});
@@ -0,0 +1,185 @@
export const CHARACTER_TURNAROUND_TEMPLATE_VERSION =
'character_turnaround_single_sheet_v5_s_plus_lossless';
export const CHARACTER_TURNAROUND_S_PLUS_THRESHOLD = 96;
export const CHARACTER_TURNAROUND_CONFLICTING_RESTRICTIONS = [
'不要真人照片级清晰正脸',
'不要证件照式正面大头照',
'不要真实演员肖像照',
'不要可识别真人脸特写',
'不要高精真人脸摄影棚写真'
] as const;
export interface CharacterTurnaroundProfile {
name: string;
descriptionOverride?: string | null;
roleType?: string | null;
genderLabel?: string | null;
ageGroup?: string | null;
identityDesc?: string | null;
appearanceDesc?: string | null;
faceDesc?: string | null;
hairDesc?: string | null;
eyeDesc?: string | null;
bodyDesc?: string | null;
costumeRules?: string | null;
}
export interface CharacterTurnaroundPromptInput {
profile: CharacterTurnaroundProfile;
referenceImageCount: number | null;
liveAction?: boolean;
}
export const CHARACTER_TURNAROUND_HARD_FAILURES = [
'左侧身份特写与右侧任一视图不是同一角色',
'缺少、重复或错置正面、严格90度侧面、严格180度背面中的任一全身视图',
'使用45度、三分之二侧身或回头姿势冒充严格侧面或背面',
'任一全身视图的头顶、手、脚、鞋履或主要服装轮廓被裁切',
'跨视图的年龄、体型、发型、胡须、服装拓扑、配饰或鞋履明显漂移',
'皮肤、眼睛、毛发或材质呈现明显塑料CG、涂抹、过曝或伪细节',
'古代或特定时代角色出现现代运动鞋、白色橡胶中底或时代错误鞋型',
'存在严重人体结构错误、多余肢体、重影、文字、Logo或水印'
] as const;
export const CHARACTER_TURNAROUND_SCORE_DIMENSIONS = [
'同一角色身份、脸部骨相、年龄与状态一致性:25分',
'正面、严格90度侧面、严格180度背面几何:20分',
'服装裁剪拓扑、发型、配饰与鞋履对应:15分',
'皮肤或表面、眼睛、毛发与真实年龄微细节:15分',
'织物、刺绣、皮革、金属与旧化材质可信度:10分',
'人体结构、全身完整、统一比例与基线:10分',
'无缝背景、中性棚拍光线与视频母版可用性:5分'
] as const;
function clean(value: string | null | undefined) {
return value?.replace(/\s+/g, ' ').trim() || '';
}
function profileLine(label: string, value: string | null | undefined) {
const normalized = clean(value);
return normalized ? `${label}${normalized}` : null;
}
function isNonHumanProfile(profile: CharacterTurnaroundProfile) {
const source = [
profile.roleType,
profile.genderLabel,
profile.ageGroup,
profile.identityDesc,
profile.appearanceDesc,
profile.faceDesc,
profile.bodyDesc
].map(clean).join(' ');
return /(?:非人|亡灵|鬼将|神兽|妖兽|魔物|怪物|多头|多臂|三头|六臂|机械体|机器人|异形)/.test(source);
}
export function characterTurnaroundPromptConflicts(prompt: string) {
return CHARACTER_TURNAROUND_CONFLICTING_RESTRICTIONS.filter((term) => prompt.includes(term));
}
export function isCurrentCharacterTurnaroundPrompt(prompt: string) {
return prompt.includes(CHARACTER_TURNAROUND_TEMPLATE_VERSION)
&& characterTurnaroundPromptConflicts(prompt).length === 0;
}
export function buildCharacterTurnaroundPublicPrompt(input: CharacterTurnaroundPromptInput) {
const profile = input.profile;
const nonHuman = isNonHumanProfile(profile);
const descriptionOverride = clean(profile.descriptionOverride);
const hasReference = input.referenceImageCount === null
? null
: input.referenceImageCount > 0;
const profileLines = descriptionOverride
? [
profileLine('角色名', profile.name),
profileLine('本次唯一角色描述', descriptionOverride)
].filter((line): line is string => Boolean(line))
: [
profileLine('角色名', profile.name),
profileLine('角色身份', profile.identityDesc || profile.roleType),
profileLine('性别', profile.genderLabel),
profileLine('真实年龄或年龄段', profile.ageGroup),
profileLine('整体气质', profile.appearanceDesc),
profileLine('脸型、五官与稳定识别点', profile.faceDesc),
profileLine('发型、发际线与胡须', profile.hairDesc),
profileLine('眼睛与眼神', profile.eyeDesc),
profileLine('身高、体型与姿态', profile.bodyDesc),
profileLine('完整基础服装、配饰与鞋履', profile.costumeRules)
].filter((line): line is string => Boolean(line));
const sourceRules = descriptionOverride
? [
'DESCRIPTION_OVERRIDE_MODE:仅使用“本次唯一角色描述”建立角色;历史角色描述、旧锚点、旧 Prompt 和角色专属经验全部不参与。'
]
: hasReference === true
? [
`REFERENCE_LOCK_MODE:已附带 ${input.referenceImageCount} 张角色参考图。参考图是身份、脸部骨相、年龄、发型、体型和基础服装的最高视觉真值;文字只补足参考图不可见的侧面和背面结构。`
]
: hasReference === false
? [
'DIRECT_DESIGN_MODE:本次不使用参考图,仅根据下列角色资料在同一张图内建立一个唯一、原创、可重复调用的角色身份。'
]
: [
'REFERENCE_OPTION_MODE:有参考图时以参考图锁定身份;没有参考图时仅依据角色资料建立唯一原创身份。'
];
return [
`S+ 影视角色单张整板母版 / ${CHARACTER_TURNAROUND_TEMPLATE_VERSION}`,
'直接生成一张完整的 16:9 横版角色连续性定妆板。这是后续关键帧、图生视频和视频角色元素的身份母版,不是海报、插画或氛围图。角色身份、严格视图和可用微细节优先。',
'',
'【身份来源】',
...sourceRules,
'',
'【角色唯一设定】',
...profileLines,
'',
'【单张整板版式】',
'一张连续的 16:9 横图,无后期拼接感。纯白或极浅中性灰无缝摄影棚背景,不得出现边框、分割线、标题、标签、尺寸线或文字。',
nonHuman
? '左侧约38%为同一角色的主身份头部特写;右侧约62%依次排列同一角色的严格正面全身、严格90度右向侧面全身、严格180度背面全身。'
: '左侧约38%为同一角色的超清正面头肩身份特写;右侧约62%依次排列同一角色的严格正面全身、严格90度右向侧面全身、严格180度背面全身。',
'右侧只允许三个全身视图;三者等高、同基线、同尺度、同一中性站姿,头顶、手、脚、鞋履和衣摆完整入画。严格侧面不能是45度或三分之二侧身;背面不回头、不露侧脸。',
'',
'【同一角色硬锁定】',
nonHuman
? '左侧特写与右侧三视图必须是同一个角色资产:头部数量、肢体数量、头部结构、眼位、身体比例、表面材质、甲胄拓扑和损伤识别点完全一致。'
: '左侧特写与右侧三视图必须像同一位原创虚构演员在同一次影视服化定妆棚拍中拍摄:颅骨、脸型、五官比例、耳朵、肤色、年龄、发际线、发型、胡须或妆容、疤痕和识别点完全一致。',
'身高、头身比、肩宽、腰线、四肢长度、手脚大小和姿态完全统一。基础服装的衣领、肩线、袖口、内外叠层、腰带、绑带、刺绣、缝线、扣件、配饰、衣摆和鞋履必须在正侧背逐一对应,不换装、不增删、不改款。',
'默认中性站姿、双手空置,只保留不可从身体或基础服装分离的常驻穿戴物;不加入剧情道具、武器、扇子、书卷、手机或法器。鞋履必须符合角色的时代、身份和服装设定;禁止现代运动鞋、休闲鞋、白色橡胶中底、厚底潮鞋和时代错误鞋型。',
'',
'【真实微细节与材质】',
nonHuman
? '使用高预算影视怪物实物特效、服化制作与数字材质扫描的真实质感,结构、腐蚀、破损、内光和材质边界清晰,不糊成烟雾或随机怪脸。'
: '整体是高预算真人影视服化造型部门的角色连续性定妆摄影(live-action costume and character continuity photography),不是游戏CG渲染、蜡像、塑料数字人、概念插画或二次元。',
nonHuman
? '羽毛、毛发、皮肤、鳞片、布料、皮革、木材、金属、腐蚀和破损分别呈现各自真实的微结构与光照反应,不用噪点或过度锐化伪造细节。'
: '脸部保留与年龄匹配的真实皮肤微结构:毛孔尺度自然,细纹、眼周、法令纹、唇纹、肤色起伏与自然不完美可读,眼白、虹膜、睫毛、眉毛、发根、碎发和胡须根部清楚;不磨皮、不美颜、不网红化、不用假毛孔和噪点伪造清晰度。',
'服装材质必须是可放大检查的实物微细节:织物经纬与纱线密度、缝线、锁边、刺绣针脚、金属边缘、皮革纹理、木材纹理、旧化和自然折痕分层清楚,不能用平滑色块或简单线条代替。',
'浅色或白色服装必须保留明度层次与局部微对比:外袍、内衬、缘边、织纹、针脚、刺绣和褶皱不得过曝成纯白平面。不做柔焦、降噪涂抹、插值光滑、过度锐化、高反差边缘或廉价CG材质。',
'',
'【摄影标准】',
'中性全画幅摄影棚质感,85至105mm长焦等效、低透视畸变、相机与人物中心高度对齐;柔和均匀的大面积柔光,中性白平衡,高光不剪切,轮廓边缘与白背景清晰分离,脚下只保留很淡的真实接触阴影。无景深虚化、无戏剧性彩光、无仙气光晕、无烟雾粒子。',
'',
'【最终自检】',
'画面只能包含:左侧一张身份特写 + 右侧严格正面、严格90度侧面、严格180度背面三个完整全身视图。',
nonHuman
? '同一身份、同一头部与肢体数量、同一体型、同一材质与同一甲胄拓扑;严格正侧背;零文字、零Logo、零水印。'
: '同脸、同年龄、同发型、同体型、同服装拓扑和同鞋履;严格正侧背;不换脸、不年轻化、不现代鞋履、不塑料CG化;零文字、零Logo、零水印。',
'使用当前模型与 API 参数允许的最高原生质量。结构稳定和真实有效细节优先于装饰、氛围和锐化。'
].filter(Boolean).join('\n');
}
export function buildCharacterTurnaroundNegativePrompt(extraRules?: string | null) {
const core = [
'不同角色、换脸、年龄漂移、发型漂移、服装换款、体型或比例漂移',
'缺少或重复视图、45度或三分之二侧身、背面回头、头脚或衣摆裁切、人物不等高',
'塑料皮肤、蜡像、游戏CG渲染、插画、动漫、柔焦、降噪涂抹、过曝、噪点伪细节、过度锐化',
'平滑色块布料、画线式刺绣、缝线与织纹消失、现代运动鞋、白色橡胶中底、时代错误鞋履',
'剧情道具、武器、手持物、复杂背景、海报装饰、法阵、光环、烟雾、粒子、边框、分割线、标题、标签、文字、Logo、水印'
];
const extra = clean(extraRules);
return [...core, extra].filter(Boolean).join('');
}
+10
View File
@@ -0,0 +1,10 @@
export const PRODUCTION_ASPECT_RATIO = '16:9' as const;
export const PRODUCTION_VIDEO_WIDTH = 1920;
export const PRODUCTION_VIDEO_HEIGHT = 1080;
export const PRODUCTION_KEYFRAME_WIDTH = 2560;
export const PRODUCTION_KEYFRAME_HEIGHT = 1440;
export const PRODUCTION_VIDEO_RESOLUTION = '1080p' as const;
export const PRODUCTION_FORMAT_LABEL = '16:9 horizontal cinematic video';
export const PRODUCTION_FORMAT_LABEL_ZH = '16:9横屏,1920×1080,电影级横向构图';
+10
View File
@@ -2,6 +2,16 @@ import type { EpisodeStatus } from './episode.types';
export class GenerateEpisodePlanDto {
target_episode_count?: number;
episode_count_mode?: 'fixed' | 'ai_recommend';
force?: boolean;
provider_code?: string;
min_quality_score?: number;
prompt_overrides?: {
overview?: string;
batch?: string;
reconcile?: string;
};
request_params_override?: Record<string, unknown>;
}
export class UpdateEpisodeDto {
@@ -19,6 +19,15 @@ export class EpisodesController {
return this.episodesService.generatePlan(user, projectId, dto);
}
@Post('projects/:projectId/episodes/plan-request-preview')
previewPlanRequest(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: GenerateEpisodePlanDto
) {
return this.episodesService.previewPlanRequest(user, projectId, dto);
}
@Get('projects/:projectId/episodes')
listEpisodes(
@CurrentUser() user: AuthRequestUser,
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { EpisodesController } from './episodes.controller';
import { EpisodesService } from './episodes.service';
@Module({
imports: [AuthModule, PrismaModule],
imports: [AuthModule, PrismaModule, ProvidersModule],
controllers: [EpisodesController],
providers: [EpisodesService],
exports: [EpisodesService]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { GenerationPlanService } from './generation-plan.service';
@Module({
imports: [PrismaModule],
providers: [GenerationPlanService],
exports: [GenerationPlanService]
})
export class GenerationPlanModule {}
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PrismaService } from '../prisma/prisma.service';
import { GenerationPlanService } from './generation-plan.service';
describe('GenerationPlanService', () => {
let service: GenerationPlanService;
let plans: any[];
let activePlanId: bigint | null;
let tx: any;
let prisma: any;
beforeEach(() => {
plans = [];
activePlanId = null;
tx = {
storyboardShot: {
findUnique: vi.fn().mockImplementation(async () => ({
id: 30n,
project_id: 10n,
episode_id: 20n,
active_generation_plan_id: activePlanId
})),
update: vi.fn().mockImplementation(async ({ data }: any) => {
activePlanId = data.active_generation_plan_id;
return { id: 30n, active_generation_plan_id: activePlanId };
})
},
shotGenerationPlan: {
findUnique: vi.fn().mockImplementation(async ({ where }: any) =>
plans.find((plan) => plan.id === where.id) ?? null
),
findFirst: vi.fn().mockImplementation(async ({ where }: any) =>
plans.find((plan) => plan.shot_id === where.shot_id && plan.plan_hash === where.plan_hash) ?? null
),
aggregate: vi.fn().mockImplementation(async () => ({
_max: { revision: plans.length ? Math.max(...plans.map((plan) => plan.revision)) : null }
})),
create: vi.fn().mockImplementation(async ({ data }: any) => {
const plan = {
id: BigInt(plans.length + 1),
...data,
frozen_at: data.frozen_at ?? new Date(),
created_at: new Date()
};
plans.push(plan);
return plan;
})
}
};
prisma = {
$transaction: vi.fn().mockImplementation(async (callback: any) => callback(tx)),
shotGenerationPlan: {
findMany: vi.fn().mockImplementation(async ({ where }: any) => {
const ids = new Set<bigint>(where?.id?.in ?? []);
return plans.filter((plan) =>
plan.project_id === where.project_id &&
plan.episode_id === where.episode_id &&
(!ids.size || ids.has(plan.id))
);
})
}
};
service = new GenerationPlanService(prisma as unknown as PrismaService);
});
const input = (resolution: string) => ({
projectId: 10n,
episodeId: 20n,
shotId: 30n,
sourceEngineVersion: 'splus_v1',
providerCode: 'kling-v3-omni-native-audio-1080p-video',
providerId: 100n,
capabilityRegistryVersionId: 501n,
parameterSchemaVersionId: 502n,
pricingVersionId: 503n,
modelName: 'kling-v3-omni',
endpoint: '/v1/videos/omni-video',
capabilityVersion: 'kling_video_capabilities_2026-07-15',
routeTier: 'premium',
effectiveMode: 'pro',
resolutionRecommendation: resolution,
effectiveGenerationResolution: resolution,
aspectRatio: '16:9',
duration: 6,
soundEnabled: true,
multiShot: false,
native4kCandidate: resolution === '4k',
projectNative4kEnabled: false,
requiredAssets: { character_elements: ['char_1'] },
elementPlan: { selected_assets: [101] },
voicePlan: { speakers: [{ speaker_name: '陈渡', voice: 'voice_1' }] },
keyframePlan: { image_provider_code: 'openai-image' },
videoRequest: { resolution },
routerDecision: { provider_code: 'kling-v3-omni-native-audio-1080p-video' },
qualityPolicy: { target: 'S+', minimum_score: 90 },
retryPolicy: { max_retries: 2 },
fallbackChain: ['kling-v3-native-audio-video'],
costPolicy: { estimated_cost: 2.52 },
providerSnapshot: { price_version: '2026-07-15' },
promptSnapshot: { video_prompt: 'test' },
frozenByUserId: 1n
});
it('creates a new immutable revision when execution inputs change', async () => {
const first = await service.freezeShotPlan(input('1080p'));
const firstHash = first.plan_hash;
const firstResolution = first.effective_generation_resolution;
const second = await service.freezeShotPlan(input('4k'));
expect(first.revision).toBe(1);
expect(second.revision).toBe(2);
expect(second.id).not.toBe(first.id);
expect(plans).toHaveLength(2);
expect(first.plan_hash).toBe(firstHash);
expect(first.effective_generation_resolution).toBe(firstResolution);
expect(activePlanId).toBe(second.id);
});
it('reuses the active frozen revision when the canonical plan is unchanged', async () => {
const first = await service.freezeShotPlan(input('1080p'));
const second = await service.freezeShotPlan(input('1080p'));
expect(second.id).toBe(first.id);
expect(plans).toHaveLength(1);
expect(tx.shotGenerationPlan.create).toHaveBeenCalledTimes(1);
});
it('returns a field-level comparison for two revisions of the same shot', async () => {
const first = await service.freezeShotPlan(input('1080p'));
const second = await service.freezeShotPlan(input('4k'));
const report = await service.compareForEpisode(10n, 20n, first.id, second.id);
expect(report.changed_count).toBeGreaterThan(0);
expect(report.changes).toEqual(expect.arrayContaining([
expect.objectContaining({
path: 'effective_generation_resolution',
category: 'parameters',
before: '1080p',
after: '4k'
})
]));
});
});
@@ -0,0 +1,352 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, type ShotGenerationPlan } from '@prisma/client';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import {
SHOT_GENERATION_PLAN_VERSION,
type FreezeShotGenerationPlanInput,
toSafeShotGenerationPlan
} from './generation-plan.types';
@Injectable()
export class GenerationPlanService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async freezeShotPlan(input: FreezeShotGenerationPlanInput): Promise<ShotGenerationPlan> {
const payload = this.canonicalPayload(input);
const planHash = createHash('sha256').update(this.stableStringify(payload)).digest('hex');
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await this.prisma.$transaction(async (tx) => {
const shot = await tx.storyboardShot.findUnique({
where: { id: input.shotId },
select: {
id: true,
project_id: true,
episode_id: true,
active_generation_plan_id: true
}
});
if (!shot || shot.project_id !== input.projectId || shot.episode_id !== input.episodeId) {
throw new NotFoundException('SHOT_GENERATION_PLAN_SOURCE_SHOT_NOT_FOUND');
}
if (shot.active_generation_plan_id) {
const active = await tx.shotGenerationPlan.findUnique({
where: { id: shot.active_generation_plan_id }
});
if (active?.plan_hash === planHash) {
return active;
}
}
const identical = await tx.shotGenerationPlan.findFirst({
where: { shot_id: input.shotId, plan_hash: planHash }
});
if (identical) {
await tx.storyboardShot.update({
where: { id: input.shotId },
data: { active_generation_plan_id: identical.id }
});
return identical;
}
const latest = await tx.shotGenerationPlan.aggregate({
where: { shot_id: input.shotId },
_max: { revision: true }
});
const plan = await tx.shotGenerationPlan.create({
data: {
project_id: input.projectId,
episode_id: input.episodeId,
shot_id: input.shotId,
revision: (latest._max.revision ?? 0) + 1,
plan_version: SHOT_GENERATION_PLAN_VERSION,
plan_hash: planHash,
status: 'frozen',
source_engine_version: input.sourceEngineVersion,
provider_code: input.providerCode,
provider_id: input.providerId ?? null,
capability_registry_version_id: input.capabilityRegistryVersionId ?? null,
parameter_schema_version_id: input.parameterSchemaVersionId ?? null,
pricing_version_id: input.pricingVersionId ?? null,
model_name: input.modelName ?? null,
endpoint: input.endpoint ?? null,
capability_version: input.capabilityVersion ?? null,
route_tier: input.routeTier ?? null,
effective_mode: input.effectiveMode ?? null,
resolution_recommendation: input.resolutionRecommendation ?? null,
effective_generation_resolution: input.effectiveGenerationResolution ?? null,
aspect_ratio: input.aspectRatio ?? null,
duration: input.duration ?? null,
sound_enabled: input.soundEnabled,
multi_shot: input.multiShot,
native_4k_candidate: input.native4kCandidate,
project_native_4k_enabled: input.projectNative4kEnabled,
required_assets_json: input.requiredAssets,
element_plan_json: input.elementPlan,
voice_plan_json: input.voicePlan,
keyframe_plan_json: input.keyframePlan,
video_request_json: input.videoRequest,
router_decision_json: input.routerDecision,
quality_policy_json: input.qualityPolicy,
retry_policy_json: input.retryPolicy,
fallback_chain_json: input.fallbackChain,
cost_policy_json: input.costPolicy,
provider_snapshot_json: input.providerSnapshot,
prompt_snapshot_json: input.promptSnapshot,
frozen_by_user_id: input.frozenByUserId ?? null,
frozen_at: new Date()
}
});
await tx.storyboardShot.update({
where: { id: input.shotId },
data: { active_generation_plan_id: plan.id }
});
return plan;
});
} catch (error) {
const revisionConflict =
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002';
if (!revisionConflict || attempt === 2) throw error;
}
}
throw new BadRequestException('SHOT_GENERATION_PLAN_FREEZE_CONFLICT');
}
async activeForShot(shotId: bigint) {
const shot = await this.prisma.storyboardShot.findUnique({
where: { id: shotId },
select: { active_generation_plan_id: true }
});
if (!shot?.active_generation_plan_id) return null;
return this.prisma.shotGenerationPlan.findUnique({
where: { id: shot.active_generation_plan_id }
});
}
async requireActiveForShot(shotId: bigint) {
const plan = await this.activeForShot(shotId);
if (!plan || plan.status !== 'frozen') {
throw new BadRequestException('SPLUS_GENERATION_PLAN_REQUIRED');
}
return plan;
}
async listForEpisode(projectId: bigint, episodeId: bigint) {
const [plans, shots] = await Promise.all([
this.prisma.shotGenerationPlan.findMany({
where: { project_id: projectId, episode_id: episodeId },
orderBy: [{ shot_id: 'asc' }, { revision: 'desc' }]
}),
this.prisma.storyboardShot.findMany({
where: { project_id: projectId, episode_id: episodeId },
select: { id: true, shot_no: true, scene_name: true, active_generation_plan_id: true }
})
]);
const shotById = new Map(shots.map((shot) => [shot.id.toString(), shot]));
return plans.map((plan) => {
const shot = shotById.get(plan.shot_id.toString());
return {
...toSafeShotGenerationPlan(plan),
shot_no: shot?.shot_no ?? null,
scene_name: shot?.scene_name ?? null,
is_active: shot?.active_generation_plan_id === plan.id
};
});
}
async compareForEpisode(projectId: bigint, episodeId: bigint, basePlanId: bigint, targetPlanId: bigint) {
if (basePlanId === targetPlanId) {
throw new BadRequestException('GENERATION_PLAN_COMPARE_REQUIRES_TWO_REVISIONS');
}
const plans = await this.prisma.shotGenerationPlan.findMany({
where: {
project_id: projectId,
episode_id: episodeId,
id: { in: [basePlanId, targetPlanId] }
}
});
const base = plans.find((plan) => plan.id === basePlanId);
const target = plans.find((plan) => plan.id === targetPlanId);
if (!base || !target) throw new NotFoundException('GENERATION_PLAN_COMPARE_REVISION_NOT_FOUND');
if (base.shot_id !== target.shot_id) {
throw new BadRequestException('GENERATION_PLAN_COMPARE_REQUIRES_SAME_SHOT');
}
const changes: Array<{
path: string;
category: string;
before: unknown;
after: unknown;
}> = [];
this.collectDiff(this.comparableSnapshot(base), this.comparableSnapshot(target), '', changes);
return {
shot_id: base.shot_id.toString(),
base: toSafeShotGenerationPlan(base),
target: toSafeShotGenerationPlan(target),
changed_count: changes.length,
changes
};
}
snapshot(plan: ShotGenerationPlan): Prisma.InputJsonObject {
return {
id: plan.id.toString(),
revision: plan.revision,
plan_version: plan.plan_version,
plan_hash: plan.plan_hash,
status: plan.status,
source_engine_version: plan.source_engine_version,
provider_code: plan.provider_code,
provider_id: plan.provider_id?.toString() ?? '',
capability_registry_version_id: plan.capability_registry_version_id?.toString() ?? '',
parameter_schema_version_id: plan.parameter_schema_version_id?.toString() ?? '',
pricing_version_id: plan.pricing_version_id?.toString() ?? '',
model_name: plan.model_name ?? '',
endpoint: plan.endpoint ?? '',
capability_version: plan.capability_version ?? '',
route_tier: plan.route_tier ?? '',
effective_mode: plan.effective_mode ?? '',
resolution_recommendation: plan.resolution_recommendation ?? '',
effective_generation_resolution: plan.effective_generation_resolution ?? '',
aspect_ratio: plan.aspect_ratio ?? '',
duration: plan.duration?.toString() ?? '',
sound_enabled: plan.sound_enabled,
multi_shot: plan.multi_shot,
native_4k_candidate: plan.native_4k_candidate,
project_native_4k_enabled: plan.project_native_4k_enabled,
required_assets: plan.required_assets_json ?? {},
element_plan: plan.element_plan_json ?? {},
voice_plan: plan.voice_plan_json ?? {},
keyframe_plan: plan.keyframe_plan_json ?? {},
video_request: plan.video_request_json ?? {},
router_decision: plan.router_decision_json,
quality_policy: plan.quality_policy_json,
retry_policy: plan.retry_policy_json,
fallback_chain: plan.fallback_chain_json,
cost_policy: plan.cost_policy_json ?? {},
provider_snapshot: plan.provider_snapshot_json ?? {},
prompt_snapshot: plan.prompt_snapshot_json ?? {},
frozen_at: plan.frozen_at.toISOString()
};
}
private canonicalPayload(input: FreezeShotGenerationPlanInput) {
return {
plan_version: SHOT_GENERATION_PLAN_VERSION,
project_id: input.projectId.toString(),
episode_id: input.episodeId.toString(),
shot_id: input.shotId.toString(),
source_engine_version: input.sourceEngineVersion,
provider_code: input.providerCode,
provider_id: input.providerId?.toString() ?? null,
capability_registry_version_id: input.capabilityRegistryVersionId?.toString() ?? null,
parameter_schema_version_id: input.parameterSchemaVersionId?.toString() ?? null,
pricing_version_id: input.pricingVersionId?.toString() ?? null,
model_name: input.modelName ?? null,
endpoint: input.endpoint ?? null,
capability_version: input.capabilityVersion ?? null,
route_tier: input.routeTier ?? null,
effective_mode: input.effectiveMode ?? null,
resolution_recommendation: input.resolutionRecommendation ?? null,
effective_generation_resolution: input.effectiveGenerationResolution ?? null,
aspect_ratio: input.aspectRatio ?? null,
duration: input.duration ?? null,
sound_enabled: input.soundEnabled,
multi_shot: input.multiShot,
native_4k_candidate: input.native4kCandidate,
project_native_4k_enabled: input.projectNative4kEnabled,
required_assets: input.requiredAssets ?? null,
element_plan: input.elementPlan ?? null,
voice_plan: input.voicePlan ?? null,
keyframe_plan: input.keyframePlan ?? null,
video_request: input.videoRequest ?? null,
router_decision: input.routerDecision,
quality_policy: input.qualityPolicy,
retry_policy: input.retryPolicy,
fallback_chain: input.fallbackChain,
cost_policy: input.costPolicy ?? null,
provider_snapshot: input.providerSnapshot ?? null,
prompt_snapshot: input.promptSnapshot ?? null
};
}
private collectDiff(
before: unknown,
after: unknown,
path: string,
changes: Array<{ path: string; category: string; before: unknown; after: unknown }>
) {
if (this.stableStringify(before) === this.stableStringify(after)) return;
if (this.isRecord(before) && this.isRecord(after)) {
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
for (const key of keys) {
this.collectDiff(before[key], after[key], path ? `${path}.${key}` : key, changes);
}
return;
}
changes.push({
path: path || 'plan',
category: this.diffCategory(path),
before: before ?? null,
after: after ?? null
});
}
private comparableSnapshot(plan: ShotGenerationPlan) {
const snapshot = this.snapshot(plan) as Record<string, unknown>;
const { id: _id, revision: _revision, plan_hash: _hash, frozen_at: _frozenAt, ...comparable } = snapshot;
return comparable;
}
private diffCategory(path: string) {
if (/pricing_version|cost_policy|estimated_cost|price/i.test(path)) return 'pricing';
if (/parameter_schema|video_request|mode|resolution|duration|aspect_ratio|sound|multi_shot/i.test(path)) return 'parameters';
if (/capability/i.test(path)) return 'capability';
if (/provider|model|endpoint|route|fallback/i.test(path)) return 'model_route';
if (/required_assets|element_plan|actor_lock/i.test(path)) return 'assets';
if (/voice|lip_sync/i.test(path)) return 'voice';
if (/keyframe/i.test(path)) return 'keyframe';
if (/prompt/i.test(path)) return 'prompt';
if (/quality/i.test(path)) return 'quality';
if (/retry/i.test(path)) return 'retry';
return 'other';
}
private isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
}
private stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => this.stableStringify(item)).join(',')}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => left.localeCompare(right));
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${this.stableStringify(item)}`).join(',')}}`;
}
}
@@ -0,0 +1,57 @@
import type { Prisma, ShotGenerationPlan } from '@prisma/client';
export const SHOT_GENERATION_PLAN_VERSION = 'shot_generation_plan_v1';
export type FreezeShotGenerationPlanInput = {
projectId: bigint;
episodeId: bigint;
shotId: bigint;
sourceEngineVersion: string;
providerCode: string;
providerId?: bigint | null;
capabilityRegistryVersionId?: bigint | null;
parameterSchemaVersionId?: bigint | null;
pricingVersionId?: bigint | null;
modelName?: string | null;
endpoint?: string | null;
capabilityVersion?: string | null;
routeTier?: string | null;
effectiveMode?: string | null;
resolutionRecommendation?: string | null;
effectiveGenerationResolution?: string | null;
aspectRatio?: string | null;
duration?: number | null;
soundEnabled: boolean;
multiShot: boolean;
native4kCandidate: boolean;
projectNative4kEnabled: boolean;
requiredAssets?: Prisma.InputJsonValue;
elementPlan?: Prisma.InputJsonValue;
voicePlan?: Prisma.InputJsonValue;
keyframePlan?: Prisma.InputJsonValue;
videoRequest?: Prisma.InputJsonValue;
routerDecision: Prisma.InputJsonValue;
qualityPolicy: Prisma.InputJsonValue;
retryPolicy: Prisma.InputJsonValue;
fallbackChain: Prisma.InputJsonValue;
costPolicy?: Prisma.InputJsonValue;
providerSnapshot?: Prisma.InputJsonValue;
promptSnapshot?: Prisma.InputJsonValue;
frozenByUserId?: bigint | null;
};
export function toSafeShotGenerationPlan(plan: ShotGenerationPlan) {
return {
...plan,
id: plan.id.toString(),
project_id: plan.project_id.toString(),
episode_id: plan.episode_id.toString(),
shot_id: plan.shot_id.toString(),
provider_id: plan.provider_id?.toString() ?? null,
capability_registry_version_id: plan.capability_registry_version_id?.toString() ?? null,
parameter_schema_version_id: plan.parameter_schema_version_id?.toString() ?? null,
pricing_version_id: plan.pricing_version_id?.toString() ?? null,
frozen_by_user_id: plan.frozen_by_user_id?.toString() ?? null,
duration: plan.duration?.toString() ?? null
};
}
@@ -0,0 +1,230 @@
import { Prisma } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthRequestUser } from '../auth/auth.types';
import { CharacterTurnaroundMasterService } from './character-turnaround-master.service';
const user: AuthRequestUser = { id: '1', email: 'user@example.com', role: 'user' };
function panel(imageType: string, id: number, assetId: number) {
return {
id: String(id),
project_id: '22',
character_id: '92',
asset_id: String(assetId),
image_type: imageType,
prompt_text: 'prompt',
negative_prompt: 'negative',
is_anchor: false,
prompt_quality_score: 99,
quality_score: 97,
status: 'quality_passed',
created_at: '2026-07-16T00:00:00.000Z',
visual_review: {
id: String(id + 1000),
score: 97,
grade: 'S+',
threshold_score: 96,
passed: true,
status: 'passed',
strengths: [],
issues: [],
improvement_rules: [],
quality_gate: {},
provider_code: 'openai-responses-text',
model_name: 'gpt-5',
error_message: null,
created_at: '2026-07-16T00:00:00.000Z'
}
};
}
describe('CharacterTurnaroundMasterService', () => {
let prisma: any;
let images: any;
let service: CharacterTurnaroundMasterService;
beforeEach(() => {
prisma = {
character: {
findUnique: vi.fn().mockResolvedValue({
id: 92n,
project_id: 22n,
name: '诸葛亮',
status: 'locked',
anchor_asset_id: null
})
},
project: {
findUnique: vi.fn().mockResolvedValue({ id: 22n, user_id: 1n })
},
characterImage: {
findFirst: vi.fn().mockResolvedValue({
id: 147n,
asset_id: 980n,
image_type: 'turnaround_reference',
quality_score: new Prisma.Decimal(87)
}),
update: vi.fn()
},
characterImageQualityReview: {
findMany: vi.fn().mockResolvedValue([])
},
providerLog: {
findMany: vi.fn().mockResolvedValue([])
},
renderTask: {
findMany: vi.fn().mockResolvedValue([])
}
};
images = {
generateCharacterImages: vi.fn()
};
const storage = {};
service = new CharacterTurnaroundMasterService(prisma, storage as any, images);
});
it('generates identity, front, strict side and back with cumulative identity evidence', async () => {
const outputs = [
panel('turnaround_identity_panel', 201, 1001),
panel('turnaround_front_panel', 202, 1002),
panel('turnaround_side_panel', 203, 1003),
panel('turnaround_back_panel', 204, 1004)
];
images.generateCharacterImages
.mockResolvedValueOnce({ images: [outputs[0]] })
.mockResolvedValueOnce({ images: [outputs[1]] })
.mockResolvedValueOnce({ images: [outputs[2]] })
.mockResolvedValueOnce({ images: [outputs[3]] });
vi.spyOn(service as any, 'composeMaster').mockResolvedValue(
panel('turnaround_reference', 205, 1005)
);
const result = await service.generate(user, '92', {
provider_code: 'openai-image',
output_size: '3840x2160',
enable_visual_quality_review: true
});
expect(images.generateCharacterImages).toHaveBeenCalledTimes(4);
const calls = images.generateCharacterImages.mock.calls;
expect(calls[0][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_identity_panel'],
reference_asset_ids: ['980'],
reference_crop_mode: 'turnaround_face_panel'
}));
expect(calls[1][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_front_panel'],
reference_asset_ids: ['1001', '980']
}));
expect(calls[2][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_side_panel'],
reference_asset_ids: ['1001', '1002', '980']
}));
expect(calls[3][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_back_panel'],
reference_asset_ids: ['1001', '1002', '1003', '980']
}));
expect(calls.every((call: any[]) => call[3]?.approveSplitPanelSystemTemplate === true)).toBe(true);
expect(result.quality_gate.approved_for_s_plus_video).toBe(true);
expect(result.identity_reference).toEqual({
assetIds: ['980'],
cropMode: 'turnaround_face_panel',
source: 'best_existing_turnaround_candidate'
});
});
it('resumes from reusable panels without paying to regenerate completed work', async () => {
const identity = panel('turnaround_identity_panel', 201, 1001);
const front = panel('turnaround_front_panel', 202, 1002);
const side = panel('turnaround_side_panel', 203, 1003);
const back = panel('turnaround_back_panel', 204, 1004);
images.listCharacterImages = vi.fn().mockResolvedValue([front, identity]);
images.generateCharacterImages
.mockResolvedValueOnce({ images: [side] })
.mockResolvedValueOnce({ images: [back] });
vi.spyOn(service as any, 'composeMaster').mockResolvedValue(
panel('turnaround_reference', 205, 1005)
);
const result = await service.generate(user, '92', {
provider_code: 'openai-image',
output_size: '3840x2160',
enable_visual_quality_review: true,
force: false
});
expect(images.generateCharacterImages).toHaveBeenCalledTimes(2);
expect(images.generateCharacterImages.mock.calls[0][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_side_panel'],
reference_asset_ids: ['1001', '1002', '980']
}));
expect(images.generateCharacterImages.mock.calls[1][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_back_panel'],
reference_asset_ids: ['1001', '1002', '1003', '980']
}));
expect(result.panel_images.map((image: any) => image.asset_id)).toEqual([
'1001',
'1002',
'1003',
'1004'
]);
});
it('regenerates only explicitly selected failed panels', async () => {
const identity = panel('turnaround_identity_panel', 201, 1001);
const front = panel('turnaround_front_panel', 202, 1002);
const oldSide = panel('turnaround_side_panel', 203, 1003);
const back = panel('turnaround_back_panel', 204, 1004);
const newSide = panel('turnaround_side_panel', 206, 1006);
images.listCharacterImages = vi.fn().mockResolvedValue([back, oldSide, front, identity]);
images.generateCharacterImages.mockResolvedValueOnce({ images: [newSide] });
vi.spyOn(service as any, 'composeMaster').mockResolvedValue(
panel('turnaround_reference', 205, 1005)
);
const result = await service.generate(user, '92', {
provider_code: 'openai-image',
output_size: '3840x2160',
enable_visual_quality_review: true,
force: false,
regenerate_image_types: ['turnaround_side_panel']
});
expect(images.generateCharacterImages).toHaveBeenCalledTimes(1);
expect(images.generateCharacterImages).toHaveBeenCalledWith(
user,
'92',
expect.objectContaining({
image_types: ['turnaround_side_panel'],
force: true,
reference_asset_ids: ['1001', '1002', '980']
}),
expect.any(Object)
);
expect(result.panel_images.map((image: any) => image.asset_id)).toEqual([
'1001',
'1002',
'1006',
'1004'
]);
});
it('composes four independent image buffers into one deterministic master', async () => {
const colors = ['#dbeafe', '#dcfce7', '#fef3c7', '#fee2e2'];
const sources = colors.map((color) => ({
asset: { mime_type: 'image/svg+xml' },
buffer: Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="640" height="360" fill="${color}"/></svg>`
)
}));
const result = await (service as any).composePanelBuffers(
sources,
[200, 120, 120, 120],
315
);
expect(result.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(result.length).toBeGreaterThan(1000);
});
});
@@ -0,0 +1,558 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { execFile } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { readFile, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { extname, join } from 'node:path';
import { promisify } from 'node:util';
import type { AuthRequestUser } from '../auth/auth.types';
import { StorageService } from '../assets/storage.service';
import {
CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION,
CHARACTER_TURNAROUND_PANEL_TYPES,
characterTurnaroundPanelLabel,
type CharacterTurnaroundPanelType
} from '../common/character-turnaround-panel-template';
import { PrismaService } from '../prisma/prisma.service';
import type { GenerateCharacterTurnaroundMasterDto } from './image.dto';
import type { SafeCharacterImage } from './image.types';
import { ImagesService } from './images.service';
const execFileAsync = promisify(execFile);
const MASTER_TEMPLATE_VERSION = 'character_turnaround_programmatic_master_v1_0';
@Injectable()
export class CharacterTurnaroundMasterService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(StorageService) private readonly storage: StorageService,
@Inject(ImagesService) private readonly imagesService: ImagesService
) {}
async generate(
user: AuthRequestUser,
characterId: string,
dto: GenerateCharacterTurnaroundMasterDto
) {
const characterIdValue = this.parseId(characterId, 'Invalid character id');
const character = await this.prisma.character.findUnique({ where: { id: characterIdValue } });
if (!character) throw new NotFoundException('Character not found');
if (character.status !== 'locked') {
throw new BadRequestException('Locked character is required before image generation');
}
const project = await this.prisma.project.findUnique({ where: { id: character.project_id } });
if (!project) throw new NotFoundException('Project not found');
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project access denied');
}
const outputSize = this.normalizeOutputSize(dto.output_size);
const descriptionOverride = this.normalizeDescriptionOverride(dto.character_description_override);
const baseReference = descriptionOverride
? { assetIds: [] as string[], cropMode: 'full' as const, source: 'description_override' }
: await this.resolveIdentityReference(character.id, character.anchor_asset_id, dto.reference_asset_ids, project.id, user);
const panelImages: SafeCharacterImage[] = [];
const panelReferenceMap: Record<string, string[]> = {};
const regenerateTypes = this.normalizeRegenerateTypes(dto.regenerate_image_types);
const existingImages = dto.force === false
? await this.imagesService.listCharacterImages(user, characterId)
: [];
for (const panelType of CHARACTER_TURNAROUND_PANEL_TYPES) {
const reusablePanel = [...existingImages]
.reverse()
.find((image) => image.image_type === panelType
&& image.asset_id
&& image.status !== 'deleted'
&& !regenerateTypes.has(panelType));
if (reusablePanel) {
panelImages.push(reusablePanel);
panelReferenceMap[panelType] = this.panelReferenceAssetIds(
panelType,
panelImages.slice(0, -1),
baseReference.assetIds
);
continue;
}
const referenceAssetIds = this.panelReferenceAssetIds(
panelType,
panelImages,
baseReference.assetIds
);
panelReferenceMap[panelType] = referenceAssetIds;
const response = await this.imagesService.generateCharacterImages(
user,
characterId,
{
image_types: [panelType],
count_per_type: 1,
force: dto.force !== false || regenerateTypes.has(panelType),
set_first_as_anchor: false,
use_reference_images: referenceAssetIds.length > 0,
reference_asset_ids: referenceAssetIds,
reference_crop_mode: panelType === 'turnaround_identity_panel'
? baseReference.cropMode
: 'full',
character_description_override: descriptionOverride || undefined,
output_size: outputSize,
enable_visual_quality_review: dto.enable_visual_quality_review !== false,
provider_code: dto.provider_code,
prompt_review_provider_code: dto.prompt_review_provider_code
},
{ approveSplitPanelSystemTemplate: true }
);
const panel = response.images[0] as SafeCharacterImage | undefined;
if (!panel?.asset_id) {
throw new BadRequestException(`${characterTurnaroundPanelLabel(panelType)}生成后没有可用素材`);
}
panelImages.push(panel);
}
const master = await this.composeMaster({
user,
projectId: project.id,
characterId: character.id,
characterName: character.name,
descriptionOverride,
outputSize,
panelImages,
panelReferenceMap,
providerCode: dto.prompt_review_provider_code,
enableVisualReview: dto.enable_visual_quality_review !== false
});
const panelGatePassed = panelImages.every((image) => image.visual_review?.passed === true);
const masterGatePassed = master.visual_review?.passed === true;
const approved = panelGatePassed && masterGatePassed;
if (!approved && master.status === 'quality_passed') {
await this.prisma.characterImage.update({
where: { id: BigInt(master.id) },
data: { status: 'needs_optimization' }
});
master.status = 'needs_optimization';
}
const assetIds = [...panelImages, master]
.map((image) => image.asset_id)
.filter((assetId): assetId is string => Boolean(assetId));
const tasks = await this.prisma.renderTask.findMany({
where: { output_asset_id: { in: assetIds.map((assetId) => BigInt(assetId)) } },
orderBy: { created_at: 'asc' }
});
const totalCost = tasks.reduce(
(sum, task) => sum + Number(task.cost_actual?.toString() ?? 0),
0
);
const imageIds = [...panelImages, master].map((image) => BigInt(image.id));
const reviewRows = await this.prisma.characterImageQualityReview.findMany({
where: {
character_image_id: { in: imageIds },
provider_log_id: { not: null }
},
select: { provider_log_id: true }
});
const reviewProviderLogIds = Array.from(new Set(
reviewRows
.map((review) => review.provider_log_id?.toString())
.filter((id): id is string => Boolean(id))
)).map((id) => BigInt(id));
const reviewProviderLogs = reviewProviderLogIds.length
? await this.prisma.providerLog.findMany({
where: { id: { in: reviewProviderLogIds } },
select: { id: true, cost_actual: true }
})
: [];
const visualQcCost = reviewProviderLogs.reduce(
(sum, log) => sum + Number(log.cost_actual?.toString() ?? 0),
0
);
const overallCost = totalCost + visualQcCost;
return {
workflow: 'split_panels_then_programmatic_master',
template_version: CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION,
master_template_version: MASTER_TEMPLATE_VERSION,
character_id: character.id.toString(),
output_size: outputSize,
identity_reference: baseReference,
panel_reference_map: panelReferenceMap,
panel_images: panelImages,
master_image: master,
quality_gate: {
s_plus_threshold: 96,
panel_gate_passed: panelGatePassed,
master_gate_passed: masterGatePassed,
approved_for_s_plus_video: approved,
panel_scores: panelImages.map((image) => ({
image_type: image.image_type,
score: image.visual_review?.score ?? image.quality_score,
passed: image.visual_review?.passed ?? false
})),
master_score: master.visual_review?.score ?? master.quality_score
},
execution_evidence: {
task_ids: tasks.map((task) => task.id.toString()),
visual_qc_provider_log_ids: reviewProviderLogs.map((log) => log.id.toString()),
source_asset_ids: panelImages.map((image) => image.asset_id),
master_asset_id: master.asset_id,
image_generation_cost_actual: Math.round(totalCost * 1_000_000) / 1_000_000,
visual_qc_cost_actual: Math.round(visualQcCost * 1_000_000) / 1_000_000,
programmatic_compose_cost_actual: 0,
total_cost_actual: Math.round(overallCost * 1_000_000) / 1_000_000
},
next_step: approved
? 'approved_for_video_identity_lock'
: 'review_failed_panels_and_regenerate_only_failed_panel'
};
}
private async resolveIdentityReference(
characterId: bigint,
anchorAssetId: bigint | null,
requestedAssetIds: string[] | undefined,
projectId: bigint,
user: AuthRequestUser
) {
const requested = this.uniqueIds(requestedAssetIds);
if (requested.length) {
await this.assertProjectAssets(requested, projectId, user);
return {
assetIds: requested,
cropMode: await this.referenceNeedsFaceCrop(requested[0], characterId)
? 'turnaround_face_panel' as const
: 'full' as const,
source: 'explicit_reference'
};
}
if (anchorAssetId) {
return {
assetIds: [anchorAssetId.toString()],
cropMode: 'full' as const,
source: 'character_anchor'
};
}
const bestTurnaround = await this.prisma.characterImage.findFirst({
where: {
character_id: characterId,
image_type: 'turnaround_reference',
asset_id: { not: null },
status: { not: 'deleted' }
},
orderBy: [{ quality_score: 'desc' }, { created_at: 'desc' }]
});
return bestTurnaround?.asset_id
? {
assetIds: [bestTurnaround.asset_id.toString()],
cropMode: 'turnaround_face_panel' as const,
source: 'best_existing_turnaround_candidate'
}
: { assetIds: [], cropMode: 'full' as const, source: 'character_profile_only' };
}
private panelReferenceAssetIds(
panelType: CharacterTurnaroundPanelType,
panels: SafeCharacterImage[],
baseReferenceAssetIds: string[]
) {
const generated = Object.fromEntries(
panels.map((image) => [image.image_type, image.asset_id]).filter((entry) => Boolean(entry[1]))
) as Record<string, string>;
if (panelType === 'turnaround_identity_panel') return baseReferenceAssetIds.slice(0, 1);
if (panelType === 'turnaround_front_panel') {
return this.uniqueIds([generated.turnaround_identity_panel, ...baseReferenceAssetIds]).slice(0, 3);
}
if (panelType === 'turnaround_side_panel') {
return this.uniqueIds([
generated.turnaround_identity_panel,
generated.turnaround_front_panel,
...baseReferenceAssetIds
]).slice(0, 4);
}
return this.uniqueIds([
generated.turnaround_identity_panel,
generated.turnaround_front_panel,
generated.turnaround_side_panel,
...baseReferenceAssetIds
]).slice(0, 4);
}
private async composeMaster(input: {
user: AuthRequestUser;
projectId: bigint;
characterId: bigint;
characterName: string;
descriptionOverride: string | null;
outputSize: '2560x1440' | '3840x2160';
panelImages: SafeCharacterImage[];
panelReferenceMap: Record<string, string[]>;
providerCode?: string;
enableVisualReview: boolean;
}) {
const [width, height] = input.outputSize.split('x').map(Number);
const identityWidth = Math.round(width * 0.3125);
const remaining = width - identityWidth;
const frontWidth = Math.floor(remaining / 3);
const sideWidth = Math.floor(remaining / 3);
const backWidth = remaining - frontWidth - sideWidth;
const sourceAssetIds = input.panelImages.map((image) => image.asset_id as string);
const composition = {
layout: 'identity_front_side_back',
output_size: input.outputSize,
widths: [identityWidth, frontWidth, sideWidth, backWidth],
source_asset_ids: sourceAssetIds,
source_image_ids: input.panelImages.map((image) => image.id),
source_scores: input.panelImages.map((image) => image.visual_review?.score ?? image.quality_score),
panel_reference_map: input.panelReferenceMap
} as Prisma.InputJsonObject;
const taskInput = {
target_type: 'character_turnaround_master',
character_id: input.characterId.toString(),
composition,
template_version: MASTER_TEMPLATE_VERSION
} as Prisma.InputJsonObject;
const inputHash = createHash('sha256').update(JSON.stringify(taskInput)).digest('hex');
const task = await this.prisma.renderTask.create({
data: {
project_id: input.projectId,
task_type: 'character_turnaround_compose',
status: 'running',
input_json: taskInput,
input_hash: inputHash,
idempotency_key: `character_turnaround_compose:${input.projectId.toString()}:${input.characterId.toString()}:${inputHash}:${Date.now()}`,
retry_count: 0,
max_retry: 0,
started_at: new Date()
}
});
try {
const buffers = [];
for (const assetId of sourceAssetIds) {
const asset = await this.prisma.asset.findUnique({ where: { id: BigInt(assetId) } });
if (!asset) throw new NotFoundException(`Source asset ${assetId} not found`);
buffers.push({ asset, buffer: await this.storage.readPrivateFile(asset.file_path) });
}
const outputBuffer = await this.composePanelBuffers(
buffers,
[identityWidth, frontWidth, sideWidth, backWidth],
height
);
const file = {
originalname: `${input.characterName}-turnaround-master-${randomUUID()}.png`,
mimetype: 'image/png',
size: outputBuffer.length,
buffer: outputBuffer
} as Express.Multer.File;
const stored = await this.storage.storePrivateFile(file, 'character-turnaround-masters');
const asset = await this.prisma.asset.create({
data: {
user_id: BigInt(input.user.id),
project_id: input.projectId,
asset_type: 'image',
file_path: stored.file_path,
file_url: null,
mime_type: 'image/png',
width,
height,
size: stored.size,
hash: stored.hash,
display_name: `${input.characterName} S+四面板人物母版`,
selection_status: 'candidate',
metadata_json: {
...composition,
workflow: 'split_panels_then_programmatic_master',
template_version: MASTER_TEMPLATE_VERSION
} as Prisma.InputJsonObject,
visibility: 'private',
status: 'active'
}
});
const promptText = [
MASTER_TEMPLATE_VERSION,
input.descriptionOverride
? `DESCRIPTION_OVERRIDE_MODE${input.descriptionOverride}`
: `角色:${input.characterName}`,
'程序合成母版:左侧身份特写,右侧严格正面、严格90度侧面、严格180度背面;四栏来自独立生成并独立质检的源图。',
`源素材:${sourceAssetIds.join(', ')}`
].join('\n');
const image = await this.prisma.characterImage.create({
data: {
project_id: input.projectId,
character_id: input.characterId,
asset_id: asset.id,
image_type: 'turnaround_reference',
prompt_text: promptText,
negative_prompt: null,
is_anchor: false,
prompt_quality_score: new Prisma.Decimal(100),
quality_score: null,
status: 'generated'
}
});
await this.prisma.renderTask.update({
where: { id: task.id },
data: { status: 'success', output_asset_id: asset.id, finished_at: new Date() }
});
if (input.enableVisualReview) {
return this.imagesService.reviewCharacterImage(
input.user,
input.characterId.toString(),
image.id.toString(),
{ provider_code: input.providerCode }
);
}
const allImages = await this.imagesService.listCharacterImages(
input.user,
input.characterId.toString()
);
return allImages.find((item) => item.id === image.id.toString()) as SafeCharacterImage;
} catch (error) {
await this.prisma.renderTask.update({
where: { id: task.id },
data: {
status: 'failed',
error_code: 'TURNAROUND_MASTER_COMPOSE_FAILED',
error_message: error instanceof Error ? error.message : String(error),
finished_at: new Date()
}
});
throw error;
}
}
private async composePanelBuffers(
sources: Array<{ asset: { mime_type: string | null }; buffer: Buffer }>,
widths: number[],
height: number
) {
const token = randomUUID();
const inputPaths = sources.map((source, index) => join(
tmpdir(),
`turnaround-panel-${token}-${index}${this.extensionForMime(source.asset.mime_type)}`
));
const outputPath = join(tmpdir(), `turnaround-master-${token}.png`);
try {
await Promise.all(sources.map((source, index) => writeFile(inputPaths[index], source.buffer)));
const filters = widths.map((panelWidth, index) =>
`[${index}:v]scale=${widths.reduce((sum, value) => sum + value, 0)}:${height}:force_original_aspect_ratio=increase,` +
`crop=${panelWidth}:${height}:(iw-${panelWidth})/2:(ih-${height})/2,setsar=1[p${index}]`
);
filters.push('[p0][p1][p2][p3]hstack=inputs=4,format=rgb24[out]');
const args = ['-y', '-hide_banner', '-loglevel', 'error'];
for (const inputPath of inputPaths) args.push('-i', inputPath);
args.push(
'-filter_complex',
filters.join(';'),
'-map',
'[out]',
'-frames:v',
'1',
outputPath
);
await execFileAsync('ffmpeg', args, { timeout: 120_000, maxBuffer: 4 * 1024 * 1024 });
const result = await readFile(outputPath);
if (!result.length) throw new Error('Turnaround master output is empty');
return result;
} finally {
await Promise.all([...inputPaths, outputPath].map((path) => unlink(path).catch(() => undefined)));
}
}
private async assertProjectAssets(assetIds: string[], projectId: bigint, user: AuthRequestUser) {
for (const assetId of assetIds) {
const asset = await this.prisma.asset.findUnique({ where: { id: BigInt(assetId) } });
if (!asset || asset.status !== 'active') throw new NotFoundException(`Reference asset ${assetId} not found`);
if (asset.project_id && asset.project_id !== projectId) {
throw new ForbiddenException(`Reference asset ${assetId} belongs to another project`);
}
if (asset.user_id?.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException(`Reference asset ${assetId} is private`);
}
if (asset.asset_type !== 'image' || !asset.mime_type?.startsWith('image/')) {
throw new BadRequestException(`Reference asset ${assetId} is not an image`);
}
}
}
private async referenceNeedsFaceCrop(assetId: string, characterId: bigint) {
const image = await this.prisma.characterImage.findFirst({
where: { character_id: characterId, asset_id: BigInt(assetId) }
});
return image?.image_type === 'turnaround_reference';
}
private normalizeOutputSize(value: unknown): '2560x1440' | '3840x2160' {
const normalized = String(value || '3840x2160').trim().toLowerCase();
if (normalized !== '2560x1440' && normalized !== '3840x2160') {
throw new BadRequestException('output_size must be 2560x1440 or 3840x2160');
}
return normalized;
}
private normalizeDescriptionOverride(value: unknown) {
const normalized = typeof value === 'string' ? value.replace(/\r\n/g, '\n').trim() : '';
if (normalized.length > 5000) {
throw new BadRequestException('character_description_override must be at most 5000 characters');
}
return normalized || null;
}
private normalizeRegenerateTypes(values: string[] | undefined) {
const normalized = new Set<CharacterTurnaroundPanelType>();
for (const value of values || []) {
if (!(CHARACTER_TURNAROUND_PANEL_TYPES as readonly string[]).includes(value)) {
throw new BadRequestException(`Unsupported turnaround panel type: ${value}`);
}
normalized.add(value as CharacterTurnaroundPanelType);
}
return normalized;
}
private uniqueIds(values: Array<string | null | undefined> | undefined) {
return Array.from(new Set((values || [])
.map((value) => String(value || '').trim())
.filter((value) => /^\d+$/.test(value) && BigInt(value) > 0n)));
}
private extensionForMime(mimeType: string | null) {
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') return '.jpg';
if (mimeType === 'image/webp') return '.webp';
if (mimeType === 'image/svg+xml') return '.svg';
return extname(mimeType || '') || '.png';
}
private parseId(value: string, message: string) {
try {
const id = BigInt(value);
if (id <= 0n) throw new Error('ID must be positive');
return id;
} catch {
throw new BadRequestException(message);
}
}
}
+39
View File
@@ -3,6 +3,29 @@ export class GenerateCharacterImagesDto {
count_per_type?: number;
force?: boolean;
set_first_as_anchor?: boolean;
use_reference_images?: boolean;
reference_asset_ids?: string[];
reference_crop_mode?: string;
character_description_override?: string;
output_size?: string;
enable_visual_quality_review?: boolean;
provider_code?: string;
prompt_review_provider_code?: string;
}
export class ReviewCharacterImageDto {
provider_code?: string;
}
export class GenerateCharacterTurnaroundMasterDto {
provider_code?: string;
prompt_review_provider_code?: string;
reference_asset_ids?: string[];
character_description_override?: string;
output_size?: string;
enable_visual_quality_review?: boolean;
force?: boolean;
regenerate_image_types?: string[];
}
export class SetCharacterAnchorDto {
@@ -10,13 +33,29 @@ export class SetCharacterAnchorDto {
asset_id?: string;
}
export class ImportCharacterImageDto {
asset_id?: string;
image_type?: string;
set_as_anchor?: boolean | string;
prompt_text?: string;
}
export class SetCharacterFacePackDto {
asset_ids?: string[];
consent_confirmed?: boolean | string;
set_first_as_anchor?: boolean | string;
notes?: string;
}
export class GenerateShotImageDto {
image_type?: string;
force?: boolean;
provider_code?: string;
}
export class GenerateEpisodeShotImagesDto {
image_type?: string;
only_missing?: boolean;
limit?: number;
provider_code?: string;
}
+28
View File
@@ -1,13 +1,19 @@
import type { Asset, CharacterImage, ShotImage } from '@prisma/client';
import { toSafeAsset, type SafeAsset } from '../assets/asset.types';
import { CHARACTER_TURNAROUND_PANEL_TYPES } from '../common/character-turnaround-panel-template';
export const CHARACTER_IMAGE_TYPES = [
'front_reference',
'side_reference',
'expression_pack',
'costume_default',
'costume_special',
'turnaround_reference',
...CHARACTER_TURNAROUND_PANEL_TYPES,
'prop_anchor',
'anchor',
'face_reference',
'scene_variant'
] as const;
@@ -16,6 +22,23 @@ export const SHOT_IMAGE_TYPES = ['preview', 'final'] as const;
export type CharacterImageType = (typeof CHARACTER_IMAGE_TYPES)[number];
export type ShotImageType = (typeof SHOT_IMAGE_TYPES)[number];
export interface SafeCharacterImageQualityReview {
id: string;
score: number | null;
grade: string | null;
threshold_score: number;
passed: boolean;
status: string;
strengths: string[];
issues: string[];
improvement_rules: string[];
quality_gate: unknown;
provider_code: string | null;
model_name: string | null;
error_message: string | null;
created_at: string;
}
export interface SafeCharacterImage {
id: string;
project_id: string;
@@ -25,10 +48,12 @@ export interface SafeCharacterImage {
prompt_text: string | null;
negative_prompt: string | null;
is_anchor: boolean;
prompt_quality_score: number | null;
quality_score: number | null;
status: string;
created_at: string;
asset?: SafeAsset | null;
visual_review?: SafeCharacterImageQualityReview | null;
}
export interface SafeShotImage {
@@ -58,6 +83,9 @@ export function toSafeCharacterImage(
prompt_text: image.prompt_text,
negative_prompt: image.negative_prompt,
is_anchor: image.is_anchor,
prompt_quality_score: image.prompt_quality_score
? Number(image.prompt_quality_score.toString())
: null,
quality_score: image.quality_score ? Number(image.quality_score.toString()) : null,
status: image.status,
created_at: image.created_at.toISOString(),
+48 -2
View File
@@ -12,16 +12,25 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types';
import {
GenerateCharacterImagesDto,
GenerateCharacterTurnaroundMasterDto,
GenerateEpisodeShotImagesDto,
GenerateShotImageDto,
SetCharacterAnchorDto
ImportCharacterImageDto,
ReviewCharacterImageDto,
SetCharacterAnchorDto,
SetCharacterFacePackDto
} from './image.dto';
import { CharacterTurnaroundMasterService } from './character-turnaround-master.service';
import { ImagesService } from './images.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class ImagesController {
constructor(@Inject(ImagesService) private readonly imagesService: ImagesService) {}
constructor(
@Inject(ImagesService) private readonly imagesService: ImagesService,
@Inject(CharacterTurnaroundMasterService)
private readonly turnaroundMasterService: CharacterTurnaroundMasterService
) {}
@Post('characters/:characterId/generate-images')
generateCharacterImages(
@@ -32,6 +41,15 @@ export class ImagesController {
return this.imagesService.generateCharacterImages(user, characterId, dto);
}
@Post('characters/:characterId/turnaround-master/generate')
generateCharacterTurnaroundMaster(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: GenerateCharacterTurnaroundMasterDto
) {
return this.turnaroundMasterService.generate(user, characterId, dto);
}
@Get('characters/:characterId/images')
listCharacterImages(
@CurrentUser() user: AuthRequestUser,
@@ -40,6 +58,16 @@ export class ImagesController {
return this.imagesService.listCharacterImages(user, characterId);
}
@Post('characters/:characterId/images/:imageId/review-visual')
reviewCharacterImage(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Param('imageId') imageId: string,
@Body() dto: ReviewCharacterImageDto
) {
return this.imagesService.reviewCharacterImage(user, characterId, imageId, dto);
}
@Post('characters/:characterId/set-anchor')
setCharacterAnchor(
@CurrentUser() user: AuthRequestUser,
@@ -49,6 +77,24 @@ export class ImagesController {
return this.imagesService.setCharacterAnchor(user, characterId, dto);
}
@Post('characters/:characterId/import-image')
importCharacterImage(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: ImportCharacterImageDto
) {
return this.imagesService.importCharacterImage(user, characterId, dto);
}
@Post('characters/:characterId/face-pack')
setCharacterFacePack(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: SetCharacterFacePackDto
) {
return this.imagesService.setCharacterFacePack(user, characterId, dto);
}
@Post('storyboard-shots/:shotId/images/generate')
generateShotImage(
@CurrentUser() user: AuthRequestUser,
+2 -1
View File
@@ -4,12 +4,13 @@ import { AssetsModule } from '../assets/assets.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { ImagesController } from './images.controller';
import { CharacterTurnaroundMasterService } from './character-turnaround-master.service';
import { ImagesService } from './images.service';
@Module({
imports: [AuthModule, AssetsModule, PrismaModule, ProvidersModule],
controllers: [ImagesController],
providers: [ImagesService],
providers: [ImagesService, CharacterTurnaroundMasterService],
exports: [ImagesService]
})
export class ImagesModule {}
+613 -6
View File
@@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
Asset,
ActorProfile,
Character,
CharacterImage,
Episode,
@@ -15,6 +16,7 @@ import type { AuthRequestUser } from '../auth/auth.types';
import type { StorageService } from '../assets/storage.service';
import type { PrismaService } from '../prisma/prisma.service';
import type { ProvidersService } from '../providers/providers.service';
import { CHARACTER_TURNAROUND_TEMPLATE_VERSION } from '../common/character-turnaround-template';
import { ImagesService } from './images.service';
const user: AuthRequestUser = {
@@ -177,6 +179,7 @@ function createCharacterImage(overrides: Partial<CharacterImage> = {}): Characte
prompt_text: 'prompt',
negative_prompt: 'negative',
is_anchor: false,
prompt_quality_score: new Prisma.Decimal(98),
quality_score: new Prisma.Decimal(92),
status: 'generated',
created_at: now,
@@ -184,6 +187,25 @@ function createCharacterImage(overrides: Partial<CharacterImage> = {}): Characte
};
}
function createActorProfile(overrides: Partial<ActorProfile> = {}): ActorProfile {
return {
id: 90n,
project_id: 10n,
character_id: 20n,
actor_desc: '眼神坚定,气质冷静',
appearance_rules: '精致鹅蛋脸;深色中长发',
wardrobe_rules: '现代都市通勤装',
performance_style: null,
voice_style: null,
reference_asset_ids: ['50'],
anchor_asset_id: 50n,
status: 'locked',
created_at: now,
updated_at: now,
...overrides
};
}
function createShotImage(overrides: Partial<ShotImage> = {}): ShotImage {
return {
id: 70n,
@@ -238,11 +260,17 @@ describe('ImagesService', () => {
beforeEach(() => {
tx = {
characterImage: {
findFirst: vi.fn().mockResolvedValue(null),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
update: vi.fn().mockResolvedValue(createCharacterImage({ is_anchor: true, status: 'selected' }))
update: vi.fn().mockResolvedValue(createCharacterImage({ is_anchor: true, status: 'selected' })),
create: vi.fn().mockResolvedValue(createCharacterImage({ image_type: 'face_reference' }))
},
character: {
update: vi.fn().mockResolvedValue(createCharacter({ anchor_asset_id: 50n }))
},
actorProfile: {
findUnique: vi.fn().mockResolvedValue(createActorProfile()),
upsert: vi.fn().mockResolvedValue(createActorProfile())
}
};
prisma = {
@@ -258,7 +286,33 @@ describe('ImagesService', () => {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(createCharacterImage()),
findMany: vi.fn().mockResolvedValue([createCharacterImage()]),
create: vi.fn().mockResolvedValue(createCharacterImage())
create: vi.fn().mockResolvedValue(createCharacterImage()),
update: vi.fn().mockResolvedValue(createCharacterImage())
},
characterImageQualityReview: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn()
},
characterPromptVersion: {
findFirst: vi.fn().mockResolvedValue({
id: 101n,
prompt_text: 'FACE_IDENTITY_LOCK professional character reference image,自然美颜但不能换脸',
negative_prompt: 'identity drift, excessive beauty filter',
source_type: 'external_web',
source_label: 'test',
layer_code: 'main_anchor',
channel: 'chatgpt_web',
quality_score: new Prisma.Decimal(98)
})
},
characterPromptOptimizationLesson: {
findMany: vi.fn().mockResolvedValue([])
},
characterPromptReview: {
create: vi.fn().mockResolvedValue({ id: 102n })
},
actorProfile: {
findUnique: vi.fn().mockResolvedValue(null)
},
storyboardShot: {
findUnique: vi.fn().mockResolvedValue(createShot()),
@@ -278,12 +332,15 @@ describe('ImagesService', () => {
update: vi.fn().mockResolvedValue(createTask({ status: 'success', output_asset_id: 50n }))
},
asset: {
create: vi.fn().mockResolvedValue(createAsset()),
findUnique: vi.fn().mockResolvedValue(createAsset())
create: vi.fn().mockImplementation(({ data }: any) => createAsset({ status: data.status })),
findUnique: vi.fn().mockResolvedValue(createAsset()),
findFirst: vi.fn().mockResolvedValue(createAsset())
},
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
};
storage = {
readPrivateFile: vi.fn().mockResolvedValue(Buffer.from('face-reference-image')),
createTemporaryPublicUrl: vi.fn().mockReturnValue('https://example.com/temporary-image.png'),
storePrivateFile: vi.fn().mockResolvedValue({
file_path: 'local://generated-images/mock.svg',
size: 1024n,
@@ -313,6 +370,266 @@ describe('ImagesService', () => {
);
});
it('uses provider-compatible flagship landscape specs for character turnarounds', () => {
const renderSpec = (service as any).characterImageRenderSpec.bind(service);
const isFlagshipPrompt = (service as any).isFlagshipCharacterTurnaroundPrompt.bind(service);
expect(renderSpec('turnaround_reference', 'openai-image')).toEqual({
width: 2560,
height: 1440,
size: '2560x1440',
aspectRatio: '16:9',
quality: 'high'
});
expect(renderSpec('turnaround_reference', undefined)).toEqual({
width: 2560,
height: 1440,
size: '2560x1440',
aspectRatio: '16:9',
quality: 'high'
});
expect(renderSpec('turnaround_reference', 'openai-image', '3840x2160')).toEqual({
width: 3840,
height: 2160,
size: '3840x2160',
aspectRatio: '16:9',
quality: 'high'
});
expect(renderSpec('turnaround_reference', 'volcengine-seedream-50-image')).toEqual({
width: 2560,
height: 1440,
size: '2560x1440',
aspectRatio: '16:9',
quality: 'high'
});
expect(isFlagshipPrompt(
'横版纯白背景,左侧脸部特写,右侧严格正面、90度侧面、背面三个全身视图。'
)).toBe(true);
expect(isFlagshipPrompt(
'横版纯白背景,左侧脸部特写;第一行四个全身视图,第二行面部三视图,包含正面、侧面、背面。'
)).toBe(false);
expect(isFlagshipPrompt('白底普通人物三视图。')).toBe(false);
const directPrompt = (service as any).buildCharacterTurnaroundPrompt(
createProject({ output_mode: 'live_action_ai' }),
createCharacter(),
'turnaround_reference',
0,
0
);
expect(directPrompt).toContain('本次不使用参考图');
expect(directPrompt).toContain('DIRECT_DESIGN_MODE');
expect(directPrompt).toContain('左侧约38%');
expect(directPrompt).toContain('严格90度右向侧面全身');
expect(directPrompt).toContain('服装拓扑');
expect(directPrompt).toContain('默认中性站姿、双手空置');
expect(directPrompt).toContain('角色连续性定妆摄影');
expect(directPrompt).toContain('禁止现代运动鞋');
expect(directPrompt).not.toContain('第一行:四个全身');
expect(directPrompt).not.toContain('基于已上传并确认的主锚点图严格生成');
const overridePrompt = (service as any).buildCharacterTurnaroundPrompt(
createProject({ output_mode: 'live_action_ai' }),
createCharacter(),
'turnaround_reference',
0,
0,
'62岁女性边关统帅,银灰短发,左眉旧伤,深红鳞甲与黑色战靴。'
);
expect(overridePrompt).toContain('DESCRIPTION_OVERRIDE_MODE');
expect(overridePrompt).toContain('本次唯一角色描述:62岁女性边关统帅');
expect(overridePrompt).not.toContain('精致鹅蛋脸');
expect(overridePrompt).not.toContain('现代都市通勤装');
const reviewPrompt = (service as any).buildCharacterImagePromptReviewPrompt({
character: createCharacter(),
imageType: 'turnaround_reference',
draftPrompt: overridePrompt,
currentPrompt: overridePrompt,
negativePrompt: '无文字,无水印',
referenceImageCount: 0,
round: 1,
lessons: [],
characterDescriptionOverride: '62岁女性边关统帅,银灰短发,左眉旧伤,深红鳞甲与黑色战靴。'
});
expect(reviewPrompt).toContain('DESCRIPTION_OVERRIDE_MODE');
expect(reviewPrompt).toContain('本次唯一角色描述=62岁女性边关统帅');
expect(reviewPrompt).not.toContain('face=精致鹅蛋脸');
expect(reviewPrompt).not.toContain('costume=现代都市通勤装');
expect(reviewPrompt).not.toContain('单独负面约束:');
expect(reviewPrompt).not.toContain('无文字,无水印');
const referenceReviewPrompt = (service as any).buildCharacterImagePromptReviewPrompt({
character: createCharacter(),
imageType: 'turnaround_reference',
draftPrompt: directPrompt,
currentPrompt: directPrompt,
negativePrompt: 'unique-negative-sentinel',
referenceImageCount: 1,
round: 1,
lessons: [],
characterDescriptionOverride: null
});
expect(referenceReviewPrompt).toContain('只采用 REFERENCE_LOCK_MODE');
expect(referenceReviewPrompt).not.toContain('unique-negative-sentinel');
});
it('treats critical visual defects as S+ hard failures', () => {
const hardFailures = (service as any).characterImageVisualHardFailures({
hard_failures: ['严格侧面缺失'],
issues: [
{
category: '身份一致性',
severity: 'critical',
region: '背面视图',
evidence: '发型和体型变成另一人',
fix: '锁定同一角色身份'
},
{
category: '材质',
severity: 'minor',
evidence: '布料略软'
},
{
category: '鞋履时代适配',
severity: 'major',
evidence: '侧面出现现代厚底靴轮廓'
}
]
});
expect(hardFailures).toContain('严格侧面缺失');
expect(hardFailures.some((item: string) => item.includes('背面视图'))).toBe(true);
expect(hardFailures.some((item: string) => item.includes('鞋履时代错误'))).toBe(true);
expect(hardFailures.some((item: string) => item.includes('布料略软'))).toBe(false);
});
it('keeps turnaround microdetail rules free from the old no-pore wrapper', () => {
const ensureQuality = (service as any).ensureCharacterTurnaroundQualityPrompt.bind(service);
const prompt = ensureQuality('公共三视图角色母版');
expect(prompt).toContain('当前模型与 API 参数允许的最高原生质量');
expect(prompt).toContain('适合后续4K放大与视频锁定');
expect(prompt).toContain('皮肤或表面');
expect(prompt).not.toContain('非真实毛孔结构');
});
it('normalizes legacy 8K marketing claims into executable quality language', () => {
const normalize = (service as any).normalizeSavedPromptForImageGeneration.bind(service);
const normalized = normalize([
'8K超高清画质 / 4K可用细节的角色资产。',
'同一8K CG数字角色保持一致。',
'8K / 4K-detail semi-realistic CGI character.'
].join('\n'));
expect(normalized).toContain('当前模型最高原生质量与后续4K放大准备');
expect(normalized).toContain('最高原生质量的原创虚构CG数字角色');
expect(normalized).toContain('highest-native-quality, 4K-upscaling-ready');
expect(normalized).not.toContain('8K');
});
it('rejects a high-scoring legacy turnaround prompt and only trusts the current clean template', () => {
const shouldTrust = (service as any).shouldTrustActiveCharacterImagePrompt.bind(service);
const base = {
version_id: '103',
negativePrompt: 'identity drift, modern shoes',
source_type: 'external_web',
source_label: 'turnaround',
layer_code: 'turnaround_reference',
channel: 'chatgpt_web',
quality_score: 98
};
const layout = 'DIRECT_DESIGN_MODE,横版16:9纯白背景,左侧面部特写,右侧依次为正面全身、严格90度侧面全身、背面全身。';
expect(shouldTrust({ ...base, prompt: layout }, 'turnaround_reference')).toBe(false);
expect(shouldTrust({
...base,
prompt: `${CHARACTER_TURNAROUND_TEMPLATE_VERSION}\n${layout}`
}, 'turnaround_reference')).toBe(true);
expect(shouldTrust({
...base,
prompt: `${CHARACTER_TURNAROUND_TEMPLATE_VERSION}\n${layout}\n不要真人照片级清晰正脸`
}, 'turnaround_reference')).toBe(false);
});
it('filters project-specific, portrait-suppressing and inapplicable turnaround lessons', () => {
const isReusable = (service as any).isReusableCharacterPromptLesson.bind(service);
const human = createCharacter({
name: '测试军师',
age_group: '约48岁,中年',
identity_desc: '三国时期古代军师',
costume_rules: '白色交领长袍、浅青内层、深色布靴'
});
const nonHuman = createCharacter({
name: '九幽鬼将',
gender_label: '非人形男性战将意象',
age_group: '古老亡灵',
identity_desc: '三头六臂的亡灵鬼将',
face_desc: '中首为主身份头部',
body_desc: '三头六臂,关节结构清楚',
costume_rules: '腐朽古代重甲'
});
expect(isReusable(
'正面、严格90度侧面、背面必须同尺度、同基线、同焦距。',
'turnaround_anchor',
human,
'global'
)).toBe(true);
expect(isReusable(
'测试军师的羽扇必须在全部视图保持同一只手持握。',
'turnaround_anchor',
human,
'global'
)).toBe(false);
expect(isReusable(
'9:16竖屏,不要真人照片级清晰正脸。',
'turnaround_anchor',
human,
'global'
)).toBe(false);
expect(isReusable(
'中老年角色必须保留法令纹、眼袋与胡须根部。',
'turnaround_anchor',
nonHuman,
'global'
)).toBe(false);
expect(isReusable(
'多头角色必须锁定头部与肢体数量,禁止随机增减肢体。',
'turnaround_anchor',
nonHuman,
'global'
)).toBe(true);
const filterNegative = (service as any).filterCharacterTurnaroundNegativeRules.bind(service);
const filtered = filterNegative(
'不要换脸,复制诸葛亮或司马懿面孔,9:16竖屏,不要真人照片级清晰正脸'
);
expect(filtered).toContain('复制其他角色的面孔、脸型或五官');
expect(filtered).not.toContain('诸葛亮');
expect(filtered).not.toContain('司马懿');
expect(filtered).not.toContain('9:16');
expect(filtered).not.toContain('不要真人照片级清晰正脸');
});
it('reuses a trusted 98-point prompt even after visual lessons are recorded', async () => {
prisma.characterPromptOptimizationLesson.findMany.mockResolvedValue([
{ rule_text: '全身小脸必须继承主特写年龄纹理。' }
]);
await service.generateCharacterImages(user, '20', {
image_types: ['front_reference'],
set_first_as_anchor: false,
enable_visual_quality_review: false
});
expect(providers.executeProvider).toHaveBeenCalledTimes(1);
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
provider_type: 'ImageProvider'
}));
});
it('generates locked character reference images through ImageProvider', async () => {
const result = await service.generateCharacterImages(user, '20', {
image_types: ['front_reference'],
@@ -325,8 +642,9 @@ describe('ImagesService', () => {
task_id: '80',
allow_fallback: false,
input_json: expect.objectContaining({
width: 1080,
height: 1920
width: 1920,
height: 1080,
aspect_ratio: '16:9'
})
})
);
@@ -350,7 +668,213 @@ describe('ImagesService', () => {
expect(result.next_step).toBe('shot_image_generate');
});
it('does not promote a turnaround sheet to the character main anchor', async () => {
prisma.characterPromptVersion.findFirst.mockResolvedValueOnce({
id: 103n,
prompt_text: `${CHARACTER_TURNAROUND_TEMPLATE_VERSION}\nDIRECT_DESIGN_MODE,横版16:9纯白背景,左侧面部特写,右侧依次为正面全身、严格90度侧面全身、背面全身。`,
negative_prompt: 'identity drift, modern shoes',
source_type: 'external_web',
source_label: 'turnaround-v4',
layer_code: 'turnaround_reference',
channel: 'chatgpt_web',
quality_score: new Prisma.Decimal(98)
}).mockResolvedValueOnce(null);
prisma.characterImage.create.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
const result = await service.generateCharacterImages(user, '20', {
image_types: ['turnaround_reference'],
force: true,
use_reference_images: true,
enable_visual_quality_review: false
});
expect(result.anchor).toBeNull();
expect(tx.character.update).not.toHaveBeenCalled();
});
it('passes an explicit 4K turnaround size to GPT Image 2', async () => {
prisma.characterPromptVersion.findFirst.mockResolvedValueOnce({
id: 103n,
prompt_text: `${CHARACTER_TURNAROUND_TEMPLATE_VERSION}\nDIRECT_DESIGN_MODE,横版16:9纯白背景,左侧面部特写,右侧依次为正面全身、严格90度侧面全身、背面全身。`,
negative_prompt: 'identity drift, modern shoes',
source_type: 'external_web',
source_label: 'turnaround-v4',
layer_code: 'turnaround_reference',
channel: 'chatgpt_web',
quality_score: new Prisma.Decimal(98)
}).mockResolvedValueOnce(null);
prisma.characterImage.create.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
await service.generateCharacterImages(user, '20', {
image_types: ['turnaround_reference'],
force: true,
set_first_as_anchor: false,
use_reference_images: true,
enable_visual_quality_review: false,
provider_code: 'openai-image',
output_size: '3840x2160'
});
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
preferred_provider_code: 'openai-image',
input_json: expect.objectContaining({
width: 3840,
height: 2160,
size: '3840x2160',
aspect_ratio: '16:9',
quality: 'high',
output_format: 'png'
})
}));
});
it('uses explicit prior candidates first when refining a character turnaround', async () => {
const cropSpy = vi.spyOn(service as any, 'cropTurnaroundFacePanel').mockResolvedValue(
Buffer.from('cropped-face-panel')
);
vi.spyOn(service as any, 'reviewAndOptimizeCharacterImagePrompt').mockImplementation(
async (input: any) => ({
status: 'approved',
review_id: '104',
prompt_version_id: null,
draft_prompt: input.draftPrompt,
approved_prompt: input.draftPrompt,
score: 98,
threshold: 98,
passed: true,
issues: [],
suggestions: [],
reusable_rules: [],
quality_gate: null,
provider_log_id: null,
provider_code: 'openai-responses-text',
model_name: 'gpt-5',
requested_provider_code: null,
raw_text: null,
error_message: null
})
);
prisma.asset.findUnique.mockImplementation(async ({ where }: { where: { id: bigint } }) =>
createAsset({
id: where.id,
file_path: `local://turnaround/${where.id.toString()}.jpg`,
mime_type: 'image/jpeg',
width: 2560,
height: 1440
})
);
prisma.characterImage.findMany.mockResolvedValueOnce([]);
prisma.characterImage.create.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
await service.generateCharacterImages(user, '20', {
image_types: ['turnaround_reference'],
force: true,
set_first_as_anchor: false,
use_reference_images: true,
reference_asset_ids: ['51'],
reference_crop_mode: 'turnaround_face_panel',
enable_visual_quality_review: false,
provider_code: 'volcengine-seedream-50-image'
});
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
input_json: expect.objectContaining({
prompt: expect.stringContaining('REFERENCE_LOCK_MODE'),
reference_images: [expect.stringContaining('data:image/png;base64,')]
})
}));
expect(cropSpy).toHaveBeenCalledOnce();
expect(prisma.renderTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({
input_json: expect.objectContaining({
reference_mode: 'explicit_iteration_reference',
explicit_reference_asset_ids: ['51'],
reference_crop_mode: 'turnaround_face_panel',
face_reference_asset_ids: ['51']
})
})
});
});
it('passes uploaded face pack references when generating a live-action character anchor', async () => {
prisma.project.findUnique.mockResolvedValue(createProject({ output_mode: 'live_action_ai' }));
prisma.characterImage.findMany.mockResolvedValueOnce([
createCharacterImage({
id: 61n,
image_type: 'face_reference',
asset_id: 51n,
is_anchor: false
})
]);
prisma.actorProfile.findUnique.mockResolvedValueOnce(createActorProfile({
reference_asset_ids: ['52'],
anchor_asset_id: null
}));
prisma.asset.findUnique.mockImplementation(async ({ where }: { where: { id: bigint } }) =>
createAsset({
id: where.id,
file_path: `local://face-pack/${where.id.toString()}.jpg`,
mime_type: 'image/jpeg',
file_url: null
})
);
prisma.characterImage.create.mockResolvedValueOnce(createCharacterImage({
image_type: 'anchor'
}));
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'anchor'
}));
await service.generateCharacterImages(user, '20', {
image_types: ['anchor'],
count_per_type: 1,
force: true,
provider_code: 'volcengine-seedream-50-image'
});
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
preferred_provider_code: 'volcengine-seedream-50-image',
input_json: expect.objectContaining({
prompt: expect.stringContaining('FACE_IDENTITY_LOCK'),
reference_images: [
expect.stringContaining('data:image/jpeg;base64,'),
expect.stringContaining('data:image/jpeg;base64,')
]
})
}));
const request = providers.executeProvider.mock.calls[0][0];
expect(request.input_json.prompt).toContain('自然美颜');
expect(request.input_json.prompt).toContain('不能换脸');
expect(request.input_json.negative_prompt).toContain('excessive beauty filter');
expect(storage.createTemporaryPublicUrl).not.toHaveBeenCalled();
});
it('sets a character anchor image and updates the character anchor asset', async () => {
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'anchor'
}));
const result = await service.setCharacterAnchor(user, '20', {
character_image_id: '60'
});
@@ -373,6 +897,89 @@ describe('ImagesService', () => {
expect(result.anchor_asset_id).toBe('50');
});
it('rejects setting uploaded face references as character anchors', async () => {
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'face_reference'
}));
await expect(service.setCharacterAnchor(user, '20', {
character_image_id: '60'
})).rejects.toBeInstanceOf(BadRequestException);
expect(tx.character.update).not.toHaveBeenCalled();
});
it('attaches a consented face pack and syncs the actor profile references', async () => {
prisma.asset.findUnique
.mockResolvedValueOnce(createAsset({
id: 51n,
file_path: 'local://uploads/face.png',
file_url: '/assets/face.png',
mime_type: 'image/png'
}))
.mockResolvedValueOnce(createAsset({
id: 51n,
file_path: 'local://uploads/face.png',
file_url: '/assets/face.png',
mime_type: 'image/png'
}));
tx.characterImage.create.mockResolvedValueOnce(createCharacterImage({
id: 61n,
asset_id: 51n,
image_type: 'face_reference',
is_anchor: false,
status: 'generated'
}));
tx.character.update.mockResolvedValueOnce(createCharacter({ anchor_asset_id: 51n }));
tx.actorProfile.findUnique.mockResolvedValueOnce(createActorProfile({
reference_asset_ids: ['50'],
anchor_asset_id: 50n
}));
tx.actorProfile.upsert.mockResolvedValueOnce(createActorProfile({
reference_asset_ids: ['50', '51'],
anchor_asset_id: 50n
}));
const result = await service.setCharacterFacePack(user, '20', {
asset_ids: ['51'],
consent_confirmed: true
});
expect(tx.characterImage.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
character_id: 20n,
asset_id: 51n,
image_type: 'face_reference',
is_anchor: false,
status: 'generated'
})
});
expect(tx.character.update).not.toHaveBeenCalled();
expect(tx.actorProfile.upsert).toHaveBeenCalledWith({
where: {
project_id_character_id: {
project_id: 10n,
character_id: 20n
}
},
update: expect.objectContaining({
reference_asset_ids: ['50', '51'],
anchor_asset_id: 50n,
status: 'locked'
}),
create: expect.objectContaining({
project_id: 10n,
character_id: 20n,
reference_asset_ids: ['50', '51'],
anchor_asset_id: 50n,
status: 'locked'
})
});
expect(result.face_reference_count).toBe(1);
expect(result.anchor_asset_id).toBeNull();
expect(result.next_step).toBe('generate_character_anchor');
});
it('generates a preview image for a confirmed storyboard shot', async () => {
const result = await service.generateShotImage(user, '40', {
image_type: 'preview'
File diff suppressed because it is too large Load Diff
@@ -277,6 +277,17 @@ async function runProviderAcceptance(input: {
force: input.config.forceRegenerate,
max_cost_per_clip: input.config.maxCostPerClip
});
if (!generated.video_clip) {
return finishRow(
baseRow,
'skipped',
generated.pending_task
? `Provider task is still running: ${generated.pending_task.id}`
: 'Provider task is still running'
);
}
baseRow.clip_id = generated.video_clip.id;
baseRow.output_asset_id = generated.video_clip.output_asset_id;
baseRow.cost_actual = generated.video_clip.cost_actual;
@@ -8,7 +8,9 @@ import {
LiveActionGenerateDto,
LiveActionManualReviewDto,
LiveActionPreflightQueryDto,
LiveActionQualityCheckDto
LiveActionQualityCheckDto,
LiveActionShotAssetPlanQueryDto,
LiveActionUpdateShotPromptDto
} from './live-action.dto';
import { LiveActionService } from './live-action.service';
@@ -36,6 +38,33 @@ export class LiveActionController {
return this.liveActionService.listLiveActionShots(user, episodeId);
}
@Get('episodes/:episodeId/live-action/shot-asset-plans')
planLiveActionShotAssets(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Query() query: LiveActionShotAssetPlanQueryDto
) {
return this.liveActionService.planLiveActionShotAssets(user, episodeId, query);
}
@Get('episodes/:episodeId/live-action/generation-plans')
listGenerationPlans(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string
) {
return this.liveActionService.listGenerationPlans(user, episodeId);
}
@Get('episodes/:episodeId/live-action/generation-plans/compare')
compareGenerationPlans(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Query('base_plan_id') basePlanId: string,
@Query('target_plan_id') targetPlanId: string
) {
return this.liveActionService.compareGenerationPlans(user, episodeId, basePlanId, targetPlanId);
}
@Post('episodes/:episodeId/live-action/shots/prepare')
prepareLiveActionShots(
@CurrentUser() user: AuthRequestUser,
@@ -92,6 +121,46 @@ export class LiveActionController {
return this.liveActionService.attachShotKeyframe(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/keyframe/generate')
generateShotKeyframe(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.generateShotKeyframe(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/keyframe/approve')
approveShotKeyframe(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.approveShotKeyframe(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/generation-plan/freeze')
freezeShotGenerationPlan(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.freezeShotGenerationPlan(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/prompt')
updateShotPrompt(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionUpdateShotPromptDto
) {
return this.liveActionService.updateShotPrompt(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/video-clip/generate')
generateShotVideoClip(
@CurrentUser() user: AuthRequestUser,
@@ -151,4 +220,13 @@ export class LiveActionController {
) {
return this.liveActionService.renderLiveActionEpisode(user, episodeId, dto);
}
@Post('episodes/:episodeId/live-action/music/generate')
generateOriginalMusicPackage(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.generateOriginalMusicPackage(user, episodeId, dto);
}
}
+35 -1
View File
@@ -2,32 +2,52 @@ export class LiveActionGenerateDto {
force?: boolean;
only_missing?: boolean;
provider_code?: string;
image_provider_code?: string;
image_quality?: 'low' | 'medium' | 'high' | string;
text_provider_code?: string;
render_title?: string;
resolution?: string;
aspect_ratio?: string;
confirm_real_video?: boolean;
allow_seedance_real_person_test?: boolean | string;
allow_character_reference_fallback?: boolean | string;
generate_audio?: boolean | string;
max_cost_per_clip?: number | string | null;
candidate_count?: number | string | null;
shot_id?: string;
include_source_audio?: boolean | string;
include_audio?: boolean;
include_subtitle?: boolean;
include_bgm?: boolean;
include_sfx?: boolean;
include_ambient_sfx?: boolean | string;
fallback_sfx_when_no_source_audio?: boolean | string;
include_lip_sync?: boolean;
audio_text_mode?: 'auto' | 'dialogue' | 'title';
lip_sync_max_seconds?: number | string | null;
voice?: string;
voice_provider_code?: string;
lip_sync_provider_code?: string;
subtitle_mode?: 'dialogue' | 'shot';
music_provider_code?: string;
subtitle_mode?: 'auto' | 'all' | 'dialogue' | 'shot' | 'title';
max_chars_per_line?: number | string | null;
bgm_asset_id?: string;
bgm_volume?: number | string | null;
sfx_volume?: number | string | null;
action_beat_mode?: boolean | string;
action_beat_count?: number | string | null;
reference_image_limit?: number | string | null;
soft_stitch?: boolean | string;
previous_shot_tail_reference?: boolean | string;
chain_previous_shot_tail?: boolean | string;
}
export class LiveActionQualityCheckDto {
auto_repair?: boolean;
min_quality_score?: number | string | null;
confirm_real_video?: boolean;
allow_seedance_real_person_test?: boolean | string;
allow_character_reference_fallback?: boolean | string;
max_cost_per_clip?: number | string | null;
}
@@ -35,9 +55,18 @@ export class LiveActionCostEstimateQueryDto {
provider_code?: string;
}
export class LiveActionShotAssetPlanQueryDto {
provider_code?: string;
}
export class LiveActionPreflightQueryDto {
provider_code?: string;
resolution?: string;
aspect_ratio?: string;
confirm_real_video?: boolean | string;
allow_seedance_real_person_test?: boolean | string;
allow_character_reference_fallback?: boolean | string;
generate_audio?: boolean | string;
max_cost_per_clip?: number | string | null;
shot_id?: string;
action_beat_mode?: boolean | string;
@@ -48,6 +77,11 @@ export class LiveActionAttachKeyframeDto {
asset_id?: string;
}
export class LiveActionUpdateShotPromptDto {
live_action_desc?: string;
video_prompt?: string;
}
export class LiveActionManualReviewDto {
result_status?: string;
reason?: string;
@@ -2,6 +2,8 @@ import { forwardRef, Module } from '@nestjs/common';
import { AiRouterModule } from '../ai-router/ai-router.module';
import { AuthModule } from '../auth/auth.module';
import { AssetsModule } from '../assets/assets.module';
import { GenerationPlanModule } from '../generation-plans/generation-plan.module';
import { ModelRegistryModule } from '../model-registry/model-registry.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { QueuesModule } from '../queues/queues.module';
@@ -10,7 +12,7 @@ import { LiveActionService } from './live-action.service';
import { LiveActionPromptBuilderService } from './prompt-builder.service';
@Module({
imports: [AiRouterModule, AuthModule, AssetsModule, PrismaModule, ProvidersModule, forwardRef(() => QueuesModule)],
imports: [AiRouterModule, AuthModule, AssetsModule, GenerationPlanModule, ModelRegistryModule, PrismaModule, ProvidersModule, forwardRef(() => QueuesModule)],
controllers: [LiveActionController],
providers: [LiveActionService, LiveActionPromptBuilderService],
exports: [LiveActionService, LiveActionPromptBuilderService]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+68 -1
View File
@@ -1,4 +1,4 @@
import type { ActorProfile, Prisma, StoryboardShot, VideoClip } from '@prisma/client';
import type { ActorProfile, Prisma, ProviderConfig, StoryboardShot, VideoClip } from '@prisma/client';
export interface SafeActorProfile {
id: string;
@@ -21,16 +21,22 @@ export interface SafeLiveActionShot {
project_id: string;
episode_id: string;
shot_no: number;
active_generation_plan_id: string | null;
scene_name: string | null;
live_action_desc: string | null;
actor_action: string | null;
camera_instruction: string | null;
performance_instruction: string | null;
transition_to_next: string | null;
transition_to_next_duration: string | null;
transition_to_next_reason: string | null;
scene_type: string | null;
importance_score: number | null;
emotion_score: number | null;
action_score: number | null;
route_tier: string | null;
generation_strategy_mode: 'text_only' | 'first_frame' | 'first_last_frame' | 'multi_reference' | null;
scene_geography_version_id: string | null;
video_prompt: string | null;
keyframe_asset_id: string | null;
video_clip_asset_id: string | null;
@@ -45,17 +51,28 @@ export interface SafeVideoClip {
project_id: string;
episode_id: string;
shot_id: string;
generation_plan_id: string | null;
provider_id: string | null;
provider_code: string | null;
provider_name: string | null;
model_name: string | null;
input_asset_id: string | null;
output_asset_id: string | null;
duration: string | null;
prompt_text: string | null;
status: string;
cost_estimate: number | null;
cost_currency: string | null;
cost_actual: number | null;
retry_count: number;
quality_status: string | null;
quality_score: number | null;
quality_issues: Prisma.JsonValue | null;
engine_version: string | null;
asset_lock_json: Prisma.JsonValue | null;
motion_control_json: Prisma.JsonValue | null;
camera_control_json: Prisma.JsonValue | null;
composer_usage_json: Prisma.JsonValue | null;
created_at: string;
updated_at: string;
}
@@ -79,21 +96,51 @@ export function toSafeActorProfile(profile: ActorProfile): SafeActorProfile {
}
export function toSafeLiveActionShot(shot: StoryboardShot): SafeLiveActionShot {
let generationStrategyMode: SafeLiveActionShot['generation_strategy_mode'] = null;
let sceneGeographyVersionId: string | null = null;
if (shot.prompt_text) {
try {
const parsed = JSON.parse(shot.prompt_text) as Record<string, unknown>;
const spec = parsed.shot_execution_spec && typeof parsed.shot_execution_spec === 'object'
? parsed.shot_execution_spec as Record<string, unknown>
: null;
const strategy = spec?.generation_strategy && typeof spec.generation_strategy === 'object'
? spec.generation_strategy as Record<string, unknown>
: null;
const mode = typeof strategy?.mode === 'string' ? strategy.mode : '';
if (['text_only', 'first_frame', 'first_last_frame', 'multi_reference'].includes(mode)) {
generationStrategyMode = mode as SafeLiveActionShot['generation_strategy_mode'];
}
sceneGeographyVersionId = typeof spec?.scene_geography_version_id === 'string'
? spec.scene_geography_version_id
: null;
} catch {
generationStrategyMode = null;
sceneGeographyVersionId = null;
}
}
return {
id: shot.id.toString(),
project_id: shot.project_id.toString(),
episode_id: shot.episode_id.toString(),
shot_no: shot.shot_no,
active_generation_plan_id: shot.active_generation_plan_id?.toString() ?? null,
scene_name: shot.scene_name,
live_action_desc: shot.live_action_desc,
actor_action: shot.actor_action,
camera_instruction: shot.camera_instruction,
performance_instruction: shot.performance_instruction,
transition_to_next: shot.transition_to_next,
transition_to_next_duration: shot.transition_to_next_duration?.toString() ?? null,
transition_to_next_reason: shot.transition_to_next_reason,
scene_type: shot.scene_type,
importance_score: shot.importance_score,
emotion_score: shot.emotion_score,
action_score: shot.action_score,
route_tier: shot.route_tier,
generation_strategy_mode: generationStrategyMode,
scene_geography_version_id: sceneGeographyVersionId,
video_prompt: shot.video_prompt,
keyframe_asset_id: shot.keyframe_asset_id?.toString() ?? null,
video_clip_asset_id: shot.video_clip_asset_id?.toString() ?? null,
@@ -105,22 +152,42 @@ export function toSafeLiveActionShot(shot: StoryboardShot): SafeLiveActionShot {
}
export function toSafeVideoClip(clip: VideoClip): SafeVideoClip {
return toSafeVideoClipWithMetadata(clip, null, null, null);
}
export function toSafeVideoClipWithMetadata(
clip: VideoClip,
provider?: Pick<ProviderConfig, 'provider_code' | 'display_name' | 'model_name'> | null,
costEstimate?: number | null,
costCurrency?: string | null
): SafeVideoClip {
return {
id: clip.id.toString(),
project_id: clip.project_id.toString(),
episode_id: clip.episode_id.toString(),
shot_id: clip.shot_id.toString(),
generation_plan_id: clip.generation_plan_id?.toString() ?? null,
provider_id: clip.provider_id?.toString() ?? null,
provider_code: provider?.provider_code ?? null,
provider_name: provider?.display_name ?? null,
model_name: provider?.model_name ?? null,
input_asset_id: clip.input_asset_id?.toString() ?? null,
output_asset_id: clip.output_asset_id?.toString() ?? null,
duration: clip.duration?.toString() ?? null,
prompt_text: clip.prompt_text,
status: clip.status,
cost_estimate: costEstimate ?? null,
cost_currency: costCurrency ?? null,
cost_actual: clip.cost_actual ? Number(clip.cost_actual.toString()) : null,
retry_count: clip.retry_count,
quality_status: clip.quality_status,
quality_score: clip.quality_score ? Number(clip.quality_score.toString()) : null,
quality_issues: clip.quality_issues,
engine_version: clip.engine_version,
asset_lock_json: clip.asset_lock_json,
motion_control_json: clip.motion_control_json,
camera_control_json: clip.camera_control_json,
composer_usage_json: clip.composer_usage_json,
created_at: clip.created_at.toISOString(),
updated_at: clip.updated_at.toISOString()
};
@@ -48,23 +48,57 @@ describe('LiveActionPromptBuilderService', () => {
});
expect(result.provider_profile).toBe('hailuo');
expect(result.prompt).toContain('[推进]');
expect(result.prompt).toContain('导演分镜');
expect(result.prompt).toContain('剪辑目的');
expect(result.prompt).toContain('后期音效提示');
expect(result.prompt.length).toBeLessThanOrEqual(1800);
expect(result.prompt).toContain('精品短剧图生视频模板');
expect(result.prompt).toContain('核心动作');
expect(result.prompt).toContain('硬性禁止');
expect(result.prompt).not.toContain('导演分镜');
expect(result.prompt).not.toContain('剪辑目的');
expect(result.prompt.length).toBeLessThanOrEqual(1200);
expect(result.components.sound_cue).toContain('digital shimmer');
expect(result.components).toEqual(
expect.objectContaining({
prompt_version: 'live-action-prompt-engine-v1',
prompt_version: 'live-action-prompt-engine-v5-performance-chain',
provider_profile: 'hailuo',
scene_type: 'dimensional_break',
template_ids: expect.arrayContaining(['scene:dimensional_break:v2']),
rules_applied: expect.arrayContaining([
'course_rule:one_main_action_per_clip',
'provider_rule:hailuo_single_action_simple_camera'
]),
route_tier: 'premium',
camera_tag: '[推进]'
})
);
expect(result.prompt_version).toBe('live-action-prompt-engine-v5-performance-chain');
expect(result.template_ids).toContain('scene:dimensional_break:v2');
expect(result.rules_applied).toContain('motion_rule:split_or_simplify_complex_action');
expect(result.negative_prompt).toContain('anime style');
});
it('maps course urban short-drama scenes into premium scene templates and audit rules', () => {
const result = builder.buildLiveActionVideoPrompt({
providerCode: 'minimax_hailuo_23_fast',
sceneType: 'rich_arrival',
routeTier: 'premium',
durationSeconds: 6,
characters: '顾辰,黑色西装,保持同一张脸',
location: '雨夜酒店门口,一辆黑色迈巴赫停在门前',
action: '管家打开车门,顾辰冷静下车,所有人转头看向他',
scores: {
importance_score: 9,
emotion_score: 7,
action_score: 3,
route_tier: 'premium'
}
});
expect(result.components.scene_type).toBe('rich_arrival');
expect(result.components.template_ids).toContain('scene:rich_arrival:v2');
expect(result.components.rules_applied).toContain('urban_short_drama_rule:premium_asset_or_reveal_beat');
expect(result.prompt).toContain('迈巴赫');
expect(result.negative_prompt).toContain('cheap luxury prop');
});
it('keeps high-risk dialogue away from frontal mouth close-ups when lip-sync falls back to TTS subtitles', () => {
const result = builder.buildLiveActionVideoPrompt({
providerCode: 'kling_21',
@@ -85,8 +119,7 @@ describe('LiveActionPromptBuilderService', () => {
});
expect(result.provider_profile).toBe('kling');
expect(result.prompt).toContain('medium shot, three-quarter angle');
expect(result.prompt).toContain('post_tts_subtitle_light_mouth');
expect(result.prompt).toContain('不要正面嘴部大特写');
expect(result.negative_prompt).toContain('frontal mouth close-up');
});
@@ -109,12 +142,10 @@ describe('LiveActionPromptBuilderService', () => {
});
expect(result.provider_profile).toBe('hailuo');
expect(result.prompt).toContain('动作导演');
expect(result.prompt).toContain('时间节奏');
expect(result.prompt).toContain('结印手法必须清楚');
expect(result.prompt).toContain('食指中指并拢');
expect(result.prompt).toContain('lotus seal');
expect(result.prompt).toContain('紫色光球');
expect(result.prompt).toContain('核心动作');
expect(result.prompt).toContain('硬性禁止');
expect(result.components.motion_director?.vfx_timing).toContain('紫色光球');
expect(result.negative_prompt).toContain('random hand waving');
expect(result.components.motion_director).toEqual(
expect.objectContaining({
@@ -138,10 +169,9 @@ describe('LiveActionPromptBuilderService', () => {
effectType: '法相天地 千臂法身 重低音轰鸣 碎石粉化'
});
expect(result.prompt).toContain('Motion director');
expect(result.prompt).toContain('One action only');
expect(result.prompt).toContain('千臂法身');
expect(result.prompt).toContain('巨手依次结出不同仙印');
expect(result.prompt).toContain('贴地低角度仰拍');
expect(result.prompt).toContain('Hard avoid');
expect(result.negative_prompt).toContain('tiny dharma body');
expect(result.components.motion_director?.time_beats.join(' ')).toContain('千只巨手');
});
@@ -165,15 +195,51 @@ describe('LiveActionPromptBuilderService', () => {
});
expect(result.provider_profile).toBe('hailuo');
expect(result.prompt).toContain('10秒');
expect(result.prompt).toContain('一镜到底动作');
expect(result.prompt).toContain('0.0-2.0s');
expect(result.prompt).toContain('3.0-5.0s');
expect(result.prompt).toContain('8.0-10.0s');
expect(result.prompt).toContain('双手在胸前清晰结印');
expect(result.prompt).toContain('千臂法身完全展开');
expect(result.prompt.length).toBeLessThanOrEqual(2400);
expect(result.components.duration_seconds).toBe(10);
expect(result.prompt).toContain('核心动作');
expect(result.prompt).toContain('千臂法身升起');
expect(result.prompt.length).toBeLessThanOrEqual(1600);
expect(result.negative_prompt).toContain('multi-shot montage');
expect(result.negative_prompt).toContain('character identity drift');
});
it('keeps Seedance native-audio prompts scoped to the current shot without old template pollution', () => {
const result = builder.buildLiveActionVideoPrompt({
projectTitle: '草船借箭:永远差一箭',
episodeNo: 1,
episodeTitle: '系统绑定',
shotNo: 1,
providerCode: 'volcengine_seedance_20_mini',
sceneType: 'dialog',
routeTier: 'premium',
durationSeconds: 10,
characters: '周瑜、诸葛亮',
actorConsistencyRules: '诸葛亮保持羽扇纶巾、白袍、同一张脸;周瑜保持红黑将军甲、同一张脸',
location: '东吴军帐,木案、令箭、烛火、帐帘和古代军事地图',
action: '周瑜拍桌下军令状;诸葛亮听到系统绑定提示后看向画面中上方留白位置,眼神一亮,强行压住笑意,用羽扇半遮嘴角,最后淡定回应周瑜',
visualDescription: '轻喜剧三国短剧,系统弹窗和进度条全部后期合成,画面只留干净构图空区',
cameraMotion: '中景稳定推进到诸葛亮压笑反应',
performanceInstruction: '周瑜杀气腾腾,诸葛亮先愣一下再强行装淡定,眼神发亮但嘴角憋笑',
dialogueText: '周瑜:诸葛亮!三天十万支箭,少一支,军法处置! 系统电子提示:叮!拼夕夕借箭系统绑定成功! 诸葛亮:都督放心,三天太久,今晚足矣。',
effectType: '后期系统弹窗,轻喜剧提示音',
nativeAudioDialogue: true
});
expect(result.provider_profile).toBe('seedance');
expect(result.prompt).toContain('豆包 Seedance 2.0 Mini 图生视频');
expect(result.prompt).toContain('原生对白顺序');
expect(result.prompt).toContain('周瑜:');
expect(result.prompt).toContain('系统电子提示:');
expect(result.prompt).toContain('诸葛亮:');
expect(result.prompt).toContain('不要合并到同一个角色');
expect(result.prompt).toContain('中文普通话对白');
expect(result.prompt).toContain('干净构图空区');
expect(result.prompt).not.toContain('Seedance 2.0 Pro');
expect(result.prompt).not.toContain('直播灯');
expect(result.prompt).not.toContain('宴会现场空间感');
expect(result.prompt).not.toContain('宴会厅');
expect(result.prompt).not.toContain('手机震动');
expect(result.prompt).not.toContain('medium shot');
expect(result.prompt).not.toContain('over-the-shoulder');
});
});
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,6 +1,7 @@
export class GenerateEpisodeAudioDto {
voice?: string;
narration_voice?: string;
voice_provider_code?: string;
dialogue_mode?: 'mixed' | 'narration';
max_segments?: number;
force?: boolean;
@@ -10,6 +11,7 @@ export class RetryEpisodeAudioSegmentDto {
voice?: string;
voice_style?: string;
speech_speed?: number | string;
voice_provider_code?: string;
}
export class GenerateEpisodeSubtitleDto {
+34 -10
View File
@@ -19,6 +19,7 @@ import type {
import { Prisma as PrismaNamespace } from '@prisma/client';
import { createHash } from 'node:crypto';
import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -26,6 +27,7 @@ import { promisify } from 'node:util';
import type { AuthRequestUser } from '../auth/auth.types';
import { StorageService } from '../assets/storage.service';
import { toSafeAsset } from '../assets/asset.types';
import { PRODUCTION_VIDEO_HEIGHT, PRODUCTION_VIDEO_WIDTH } from '../common/production-format';
import { BillingService } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service';
import { ProvidersService } from '../providers/providers.service';
@@ -40,8 +42,15 @@ import { toSafeMediaTaskResult, type SrtCue } from './media.types';
const execFileAsync = promisify(execFile);
const DEFAULT_AUDIO_VOICE = 'coral';
const VIDEO_WIDTH = 1080;
const VIDEO_HEIGHT = 1920;
const VIDEO_WIDTH = PRODUCTION_VIDEO_WIDTH;
const VIDEO_HEIGHT = PRODUCTION_VIDEO_HEIGHT;
const SUBTITLE_FONT_DIR_CANDIDATES = [
process.env.SUBTITLE_FONT_DIR,
'/usr/share/fonts/google-noto-cjk',
'/usr/share/fonts/opentype/noto',
'/usr/share/fonts/truetype/noto',
'/usr/share/fonts'
].filter((value): value is string => Boolean(value));
interface AudioDialogueSegment {
index: number;
@@ -186,6 +195,7 @@ export class MediaService {
},
[
{
preferred_provider_code: dto.voice_provider_code?.trim() || undefined,
purpose: `episode-${episode.id.toString()}-tts`,
input_json: this.createAudioSegmentProviderInput(narrationSegment)
}
@@ -472,7 +482,8 @@ export class MediaService {
...targetSegment,
voice: dto.voice?.trim() || targetSegment.voice,
voice_style: dto.voice_style?.trim() || targetSegment.voice_style,
speech_speed: this.normalizeSpeechSpeed(dto.speech_speed, targetSegment.speech_speed)
speech_speed: this.normalizeSpeechSpeed(dto.speech_speed, targetSegment.speech_speed),
voice_provider_code: dto.voice_provider_code?.trim() || targetSegment.voice_provider_code
};
const task = await this.createRenderTask(
project.id,
@@ -733,7 +744,16 @@ export class MediaService {
const tasks = await this.prisma.renderTask.findMany({
where: {
episode_id: episode.id,
task_type: { in: ['audio_generate', 'subtitle_generate', 'video_render'] },
task_type: {
in: [
'audio_generate',
'subtitle_generate',
'video_render',
'live_action_audio_generate',
'live_action_subtitle_generate',
'live_action_video_render'
]
},
output_asset_id: { not: null }
},
orderBy: { created_at: 'desc' }
@@ -761,7 +781,7 @@ export class MediaService {
}
private async createMediaTimeline(task: RenderTask, asset: Asset) {
if (task.task_type === 'audio_generate') {
if (task.task_type === 'audio_generate' || task.task_type === 'live_action_audio_generate') {
const input = this.jsonObject(task.input_json);
return {
@@ -771,7 +791,7 @@ export class MediaService {
};
}
if (task.task_type === 'subtitle_generate') {
if (task.task_type === 'subtitle_generate' || task.task_type === 'live_action_subtitle_generate') {
return {
type: 'subtitle',
cues: await this.readSubtitleCuesFromAsset(asset)
@@ -784,7 +804,7 @@ export class MediaService {
private createMediaTaskStats(task: RenderTask, asset: Asset) {
const input = this.jsonObject(task.input_json);
if (task.task_type === 'audio_generate') {
if (task.task_type === 'audio_generate' || task.task_type === 'live_action_audio_generate') {
const segments = this.readTaskAudioSegments(input);
const totalCharacters =
this.numberFromUnknown(input.estimated_tts_characters) ||
@@ -811,7 +831,7 @@ export class MediaService {
};
}
if (task.task_type === 'subtitle_generate') {
if (task.task_type === 'subtitle_generate' || task.task_type === 'live_action_subtitle_generate') {
return {
subtitle_mode: this.stringifyText(input.subtitle_mode) || 'shot',
cue_count: this.numberFromUnknown(input.cue_count),
@@ -1343,7 +1363,7 @@ export class MediaService {
speaker_name: speakerName,
text: cleanedText.slice(0, 800),
voice,
voice_provider_code: character?.voice_provider_code ?? null,
voice_provider_code: (dto.voice_provider_code?.trim() || character?.voice_provider_code) ?? null,
voice_model: character?.voice_model ?? null,
voice_style: voiceStyle,
speech_speed: speechSpeed,
@@ -2510,10 +2530,14 @@ export class MediaService {
private subtitleFilter(subtitlePath: string) {
return [
`subtitles=${this.escapeFfmpegFilterPath(subtitlePath)}`,
'fontsdir=/usr/share/fonts/google-noto-cjk'
`fontsdir=${this.escapeFfmpegFilterPath(this.subtitleFontDir())}`
].join(':');
}
private subtitleFontDir() {
return SUBTITLE_FONT_DIR_CANDIDATES.find((dir) => existsSync(dir)) ?? '/usr/share/fonts';
}
private async writeAssSubtitleForFfmpeg(tempDir: string, subtitlePath: string) {
const content = await readFile(subtitlePath, 'utf8');
const cues = this.parseSrtContent(content);
+9 -1
View File
@@ -48,7 +48,7 @@ export class MemoriesController {
@Param('projectId') projectId: string,
@Body() dto: GeneratePlotMemoriesDto
) {
return this.memoriesService.generatePlotMemories(user, projectId, dto);
return this.memoriesService.submitPlotMemoryGeneration(user, projectId, dto);
}
@Post('projects/:projectId/plot-memories')
@@ -86,6 +86,14 @@ export class MemoriesController {
return this.memoriesService.listCharacterMemories(user, characterId);
}
@Get('projects/:projectId/character-memories')
listProjectCharacterMemories(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.memoriesService.listProjectCharacterMemories(user, projectId);
}
@Get('projects/:projectId/plot-threads')
listPlotThreads(
@CurrentUser() user: AuthRequestUser,
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { MemoriesController } from './memories.controller';
import { MemoriesService } from './memories.service';
@Module({
imports: [AuthModule, PrismaModule],
imports: [AuthModule, PrismaModule, ProvidersModule],
controllers: [MemoriesController],
providers: [MemoriesService],
exports: [MemoriesService]
File diff suppressed because it is too large Load Diff
+3
View File
@@ -8,6 +8,9 @@ import type {
export class GeneratePlotMemoriesDto {
episode_id?: string;
chapter_id?: string;
provider_code?: string;
replace_existing?: boolean;
min_quality_score?: number | string;
}
export class CreatePlotMemoryDto {
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Inject, Post, Query, UseGuards } from '@nestjs/common';
import type { AuthRequestUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ModelRegistryService } from './model-registry.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class ModelRegistryController {
constructor(@Inject(ModelRegistryService) private readonly registry: ModelRegistryService) {}
@Get('model-registry')
list(@CurrentUser() user: AuthRequestUser, @Query('provider_code') providerCode?: string) {
return this.registry.listVersions(user, providerCode);
}
@Post('admin/model-registry/sync')
sync(
@CurrentUser() user: AuthRequestUser,
@Body() body: { provider_code?: string }
) {
return this.registry.syncProviders(user, body?.provider_code);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ModelRegistryController } from './model-registry.controller';
import { ModelRegistryService } from './model-registry.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [ModelRegistryController],
providers: [ModelRegistryService],
exports: [ModelRegistryService]
})
export class ModelRegistryModule {}
@@ -0,0 +1,156 @@
import { type ProviderConfig } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PrismaService } from '../prisma/prisma.service';
import { ModelRegistryService } from './model-registry.service';
const now = new Date('2026-07-15T00:00:00.000Z');
function provider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
return {
id: 100n,
provider_type: 'VideoProvider',
provider_code: 'kling-v3-omni-test',
display_name: 'Kling Omni Test',
mode: 'real',
model_name: 'kling-v3-omni',
config_json: {
driver: 'kling_omni_video',
allowed_modes: ['std', 'pro', '4k'],
allowed_resolutions: ['720p', '1080p', '4k'],
allowed_durations: [3, 5, 10, 15],
max_prompt_length: 2500,
max_image_inputs: 7,
max_image_inputs_with_video: 4,
max_video_inputs: 1,
supports_audio: true,
supports_4k: true,
official_doc_url: 'https://kling.ai/document-api/api/video/3-0-omni/video-omni'
},
fallback_provider_id: null,
is_enabled: true,
priority: 100,
rate_limit_json: {},
cost_rule_json: { currency: 'CNY', unit: 'second', price_per_second: 0.25 },
created_at: now,
updated_at: now,
...overrides
};
}
function versionDelegate(jsonField: string) {
const rows: any[] = [];
return {
rows,
findFirst: vi.fn(async ({ where }: any) => rows.find((row) =>
row.provider_type === where.provider_type &&
row.provider_code === where.provider_code &&
row.content_hash === where.content_hash
) ?? null),
aggregate: vi.fn(async () => ({
_max: { revision: rows.length ? Math.max(...rows.map((row) => row.revision)) : null }
})),
create: vi.fn(async ({ data }: any) => {
const row = {
id: BigInt(rows.length + 1),
status: 'published',
effective_at: now,
created_at: now,
...data,
[jsonField]: data[jsonField]
};
rows.push(row);
return row;
}),
findMany: vi.fn(async () => rows)
};
}
describe('ModelRegistryService', () => {
let service: ModelRegistryService;
let capability: ReturnType<typeof versionDelegate>;
let schema: ReturnType<typeof versionDelegate>;
let pricing: ReturnType<typeof versionDelegate>;
beforeEach(() => {
capability = versionDelegate('capability_json');
schema = versionDelegate('schema_json');
pricing = versionDelegate('pricing_json');
const prisma = {
modelCapabilityVersion: capability,
modelParameterSchemaVersion: schema,
modelPricingVersion: pricing
};
service = new ModelRegistryService(prisma as unknown as PrismaService);
});
it('publishes immutable registry versions once and reuses identical content', async () => {
const first = await service.resolveOrPublish(provider(), 1n);
const second = await service.resolveOrPublish(provider(), 1n);
expect(first.capability.id).toBe(second.capability.id);
expect(first.parameterSchema.id).toBe(second.parameterSchema.id);
expect(first.pricing.id).toBe(second.pricing.id);
expect(capability.create).toHaveBeenCalledTimes(1);
expect(schema.create).toHaveBeenCalledTimes(1);
expect(pricing.create).toHaveBeenCalledTimes(1);
expect((first.parameterSchema.schema_json as any).properties.image_list.maxItems).toBe(7);
});
it('rejects Omni reference-video requests that keep native sound on', async () => {
const registry = await service.resolveOrPublish(provider(), 1n);
const result = service.validateRequest(registry.parameterSchema.schema_json, {
prompt: '镜头测试',
mode: 'pro',
duration: 5,
sound: 'on',
video_list: [{ video_url: 'https://example.com/reference.mp4' }],
image_list: []
});
expect(result.valid).toBe(false);
expect(result.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ code: 'VIDEO_REFERENCE_REQUIRES_SOUND_OFF' })
]));
});
it('accepts a compatible 4K image-reference request', async () => {
const registry = await service.resolveOrPublish(provider(), 1n);
const result = service.validateRequest(registry.parameterSchema.schema_json, {
prompt: '史诗人物特写',
mode: '4k',
resolution: '4k',
duration: 10,
sound: 'on',
image_list: [{ image_url: 'https://example.com/one.png' }]
});
expect(result).toEqual({ valid: true, issues: [] });
});
it('versions GPT Image 2 4K sizes and quality parameters', async () => {
const registry = await service.resolveOrPublish(provider({
provider_type: 'ImageProvider',
provider_code: 'openai-image',
model_name: 'gpt-image-2',
config_json: {
driver: 'openai_image_generation',
allowed_sizes: ['2560x1440', '3840x2160'],
allowed_qualities: ['low', 'medium', 'high', 'auto'],
supports_4k: true,
max_width: 3840,
max_height: 3840
}
}), 1n);
const result = service.validateRequest(registry.parameterSchema.schema_json, {
prompt: '电影级人物三视图',
size: '3840x2160',
quality: 'high'
});
expect(result).toEqual({ valid: true, issues: [] });
expect((registry.parameterSchema.schema_json as any).properties.size.enum).toEqual([
'2560x1440',
'3840x2160'
]);
});
});
@@ -0,0 +1,512 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
Prisma,
type ModelCapabilityVersion,
type ModelParameterSchemaVersion,
type ModelPricingVersion,
type ProviderConfig
} from '@prisma/client';
import { createHash } from 'node:crypto';
import type { AuthRequestUser } from '../auth/auth.types';
import { assertPermission } from '../auth/rbac';
import { PrismaService } from '../prisma/prisma.service';
import {
MODEL_CAPABILITY_SCHEMA_VERSION,
MODEL_PARAMETER_SCHEMA_VERSION,
MODEL_PRICING_SCHEMA_VERSION,
type ModelConflictRule,
type ModelParameterProperty,
type ModelParameterSchema,
type ModelParameterValidationIssue,
type ModelParameterValidationResult,
type ModelRegistryBundle,
jsonRecord,
toSafeCapabilityVersion,
toSafeParameterSchemaVersion,
toSafePricingVersion
} from './model-registry.types';
@Injectable()
export class ModelRegistryService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async resolveOrPublish(provider: ProviderConfig, createdByUserId?: bigint | null): Promise<ModelRegistryBundle> {
const [capability, parameterSchema, pricing] = await Promise.all([
this.publishCapability(provider, createdByUserId),
this.publishParameterSchema(provider, createdByUserId),
this.publishPricing(provider, createdByUserId)
]);
return { capability, parameterSchema, pricing };
}
async syncProviders(user: AuthRequestUser, providerCode?: string) {
assertPermission(user, 'providers:write');
const normalizedCode = this.normalizeProviderCode(providerCode);
const providers = await this.prisma.providerConfig.findMany({
where: normalizedCode ? { provider_code: normalizedCode } : {},
orderBy: [{ provider_type: 'asc' }, { priority: 'desc' }, { provider_code: 'asc' }]
});
if (normalizedCode && providers.length === 0) {
throw new NotFoundException('MODEL_REGISTRY_PROVIDER_NOT_FOUND');
}
const createdBy = this.toBigIntOrNull(user.id);
const bundles = [];
for (const provider of providers) {
const bundle = await this.resolveOrPublish(provider, createdBy);
bundles.push(this.safeBundle(bundle));
}
return {
provider_count: providers.length,
versions: bundles
};
}
async listVersions(user: AuthRequestUser, providerCode?: string) {
assertPermission(user, 'providers:read');
const normalizedCode = this.normalizeProviderCode(providerCode);
const where = normalizedCode ? { provider_code: normalizedCode } : {};
const [capabilities, parameterSchemas, pricing] = await Promise.all([
this.prisma.modelCapabilityVersion.findMany({ where, orderBy: [{ provider_code: 'asc' }, { revision: 'desc' }] }),
this.prisma.modelParameterSchemaVersion.findMany({ where, orderBy: [{ provider_code: 'asc' }, { revision: 'desc' }] }),
this.prisma.modelPricingVersion.findMany({ where, orderBy: [{ provider_code: 'asc' }, { revision: 'desc' }] })
]);
return {
capabilities: capabilities.map(toSafeCapabilityVersion),
parameter_schemas: parameterSchemas.map(toSafeParameterSchemaVersion),
pricing: pricing.map(toSafePricingVersion)
};
}
validateRequest(
schemaValue: Prisma.JsonValue | null | undefined,
request: Record<string, unknown>
): ModelParameterValidationResult {
const schema = jsonRecord(schemaValue);
const properties = jsonRecord(schema.properties as Prisma.JsonValue | undefined);
const required = Array.isArray(schema.required)
? schema.required.filter((item): item is string => typeof item === 'string')
: [];
const issues: ModelParameterValidationIssue[] = [];
for (const path of required) {
const value = this.valueAtPath(request, path);
if (value === undefined || value === null || value === '') {
issues.push({ path, code: 'REQUIRED', message: `${path} is required` });
}
}
for (const [path, rawProperty] of Object.entries(properties)) {
const value = this.valueAtPath(request, path);
if (value === undefined || value === null) continue;
this.validateProperty(path, value, jsonRecord(rawProperty as Prisma.JsonValue), issues);
}
const conflictRules = Array.isArray(schema.x_conflict_rules)
? schema.x_conflict_rules.filter((item): item is ModelConflictRule => Boolean(item && typeof item === 'object'))
: [];
for (const rule of conflictRules) {
this.validateConflictRule(rule, request, issues);
}
return { valid: issues.length === 0, issues };
}
assertRequestCompatible(
schemaValue: Prisma.JsonValue | null | undefined,
request: Record<string, unknown>
) {
const result = this.validateRequest(schemaValue, request);
if (!result.valid) {
throw new BadRequestException({
code: 'MODEL_PARAMETER_SCHEMA_VALIDATION_FAILED',
message: 'Generation request does not match the frozen model parameter schema',
issues: result.issues
});
}
return result;
}
safeBundle(bundle: ModelRegistryBundle) {
return {
capability: toSafeCapabilityVersion(bundle.capability),
parameter_schema: toSafeParameterSchemaVersion(bundle.parameterSchema),
pricing: toSafePricingVersion(bundle.pricing)
};
}
private async publishCapability(provider: ProviderConfig, createdByUserId?: bigint | null) {
const payload = this.buildCapability(provider);
const hash = this.hash(payload);
const existing = await this.prisma.modelCapabilityVersion.findFirst({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code, content_hash: hash }
});
if (existing) return existing;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const latest = await this.prisma.modelCapabilityVersion.aggregate({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code },
_max: { revision: true }
});
const revision = (latest._max.revision ?? 0) + 1;
return await this.prisma.modelCapabilityVersion.create({
data: {
provider_type: provider.provider_type,
provider_code: provider.provider_code,
model_name: provider.model_name,
revision,
version_key: this.versionKey(provider.provider_code, 'capability', revision, hash),
content_hash: hash,
capability_json: this.toJson(payload),
source_json: this.sourceSnapshot(provider),
created_by_user_id: createdByUserId ?? null
}
});
} catch (error) {
if (!this.isUniqueConflict(error) || attempt === 2) throw error;
const row = await this.prisma.modelCapabilityVersion.findFirst({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code, content_hash: hash }
});
if (row) return row;
}
}
throw new BadRequestException('MODEL_CAPABILITY_VERSION_PUBLISH_CONFLICT');
}
private async publishParameterSchema(provider: ProviderConfig, createdByUserId?: bigint | null) {
const payload = this.buildParameterSchema(provider);
const hash = this.hash(payload);
const existing = await this.prisma.modelParameterSchemaVersion.findFirst({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code, content_hash: hash }
});
if (existing) return existing;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const latest = await this.prisma.modelParameterSchemaVersion.aggregate({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code },
_max: { revision: true }
});
const revision = (latest._max.revision ?? 0) + 1;
return await this.prisma.modelParameterSchemaVersion.create({
data: {
provider_type: provider.provider_type,
provider_code: provider.provider_code,
model_name: provider.model_name,
revision,
version_key: this.versionKey(provider.provider_code, 'schema', revision, hash),
content_hash: hash,
schema_json: this.toJson(payload),
source_json: this.sourceSnapshot(provider),
created_by_user_id: createdByUserId ?? null
}
});
} catch (error) {
if (!this.isUniqueConflict(error) || attempt === 2) throw error;
const row = await this.prisma.modelParameterSchemaVersion.findFirst({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code, content_hash: hash }
});
if (row) return row;
}
}
throw new BadRequestException('MODEL_PARAMETER_SCHEMA_VERSION_PUBLISH_CONFLICT');
}
private async publishPricing(provider: ProviderConfig, createdByUserId?: bigint | null) {
const payload = this.buildPricing(provider);
const hash = this.hash(payload);
const existing = await this.prisma.modelPricingVersion.findFirst({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code, content_hash: hash }
});
if (existing) return existing;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const latest = await this.prisma.modelPricingVersion.aggregate({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code },
_max: { revision: true }
});
const revision = (latest._max.revision ?? 0) + 1;
return await this.prisma.modelPricingVersion.create({
data: {
provider_type: provider.provider_type,
provider_code: provider.provider_code,
model_name: provider.model_name,
revision,
version_key: this.versionKey(provider.provider_code, 'pricing', revision, hash),
content_hash: hash,
pricing_json: this.toJson(payload),
source_json: this.sourceSnapshot(provider),
created_by_user_id: createdByUserId ?? null
}
});
} catch (error) {
if (!this.isUniqueConflict(error) || attempt === 2) throw error;
const row = await this.prisma.modelPricingVersion.findFirst({
where: { provider_type: provider.provider_type, provider_code: provider.provider_code, content_hash: hash }
});
if (row) return row;
}
}
throw new BadRequestException('MODEL_PRICING_VERSION_PUBLISH_CONFLICT');
}
private buildCapability(provider: ProviderConfig) {
const config = jsonRecord(provider.config_json);
const selected: Record<string, unknown> = {};
const exactKeys = new Set([
'driver', 'capability_version', 'official_doc_url', 'formal_splus_model', 'resolution',
'duration', 'aspect_ratio', 'mode', 'video_modes', 'reference_image_limit'
]);
const prefixes = ['supports_', 'max_', 'allowed_', 'input_', 'video_input_'];
for (const [key, value] of Object.entries(config)) {
if (exactKeys.has(key) || prefixes.some((prefix) => key.startsWith(prefix))) {
selected[key] = value;
}
}
return {
schema_version: MODEL_CAPABILITY_SCHEMA_VERSION,
provider_type: provider.provider_type,
provider_code: provider.provider_code,
model_name: provider.model_name ?? '',
capabilities: selected
};
}
private buildParameterSchema(provider: ProviderConfig): ModelParameterSchema {
const config = jsonRecord(provider.config_json);
const properties: Record<string, ModelParameterProperty> = {
prompt: { type: 'string', maxLength: this.positiveInt(config.max_prompt_length) ?? 12000 },
negative_prompt: { type: 'string', maxLength: this.positiveInt(config.max_prompt_length) ?? 12000 },
duration: { type: 'number', minimum: 1, maximum: 60 },
aspect_ratio: { type: 'string' },
size: { type: 'string' },
width: { type: 'number', minimum: 16 },
height: { type: 'number', minimum: 16 },
quality: { type: 'string' },
output_format: { type: 'string' },
mode: { type: 'string' },
resolution: { type: 'string' },
sound: { type: 'string', enum: ['on', 'off'] },
multi_shot: { type: 'boolean' },
image: { type: 'string' },
image_tail: { type: 'string' },
image_list: { type: 'array', items: { type: 'object' } },
video_list: { type: 'array', items: { type: 'object' } },
element_list: { type: 'array', items: { type: 'object' } },
voice_list: { type: 'array', items: { type: 'object' } },
multi_prompt: { type: 'array', items: { type: 'object' } }
};
this.applyEnum(properties.duration, config.allowed_durations);
this.applyEnum(properties.size, config.allowed_sizes);
this.applyEnum(properties.quality, config.allowed_qualities);
this.applyEnum(properties.output_format, config.allowed_output_formats);
this.applyEnum(properties.mode, config.allowed_modes);
this.applyEnum(properties.resolution, config.allowed_resolutions);
this.applyEnum(properties.aspect_ratio, config.allowed_aspect_ratios);
this.applyMaxItems(properties.image_list, config.max_image_inputs);
this.applyMaxItems(properties.video_list, config.max_video_inputs);
this.applyMaxItems(
properties.element_list,
config.max_elements ?? config.max_elements_with_start_end_frames
);
this.applyMaxItems(properties.voice_list, config.max_voices);
this.applyMaxItems(properties.multi_prompt, config.max_multi_shots);
const rules: ModelConflictRule[] = [];
if (this.positiveInt(config.max_video_inputs)) {
rules.push({
code: 'VIDEO_REFERENCE_REQUIRES_SOUND_OFF',
message: 'Reference video requests must disable native sound',
when: { field: 'video_list', operator: 'non_empty' },
require: { field: 'sound', operator: 'equals', value: 'off' }
});
}
const imageLimitWithVideo = this.positiveInt(config.max_image_inputs_with_video);
if (imageLimitWithVideo) {
rules.push({
code: 'VIDEO_REFERENCE_IMAGE_LIMIT',
message: `Reference video requests allow at most ${imageLimitWithVideo} images`,
when: { field: 'video_list', operator: 'non_empty' },
require: { field: 'image_list', operator: 'max_items', value: imageLimitWithVideo }
});
}
return {
schema_version: MODEL_PARAMETER_SCHEMA_VERSION,
type: 'object',
provider_type: provider.provider_type,
provider_code: provider.provider_code,
model_name: provider.model_name ?? '',
required: provider.provider_type === 'VideoProvider' ? ['prompt'] : [],
properties,
x_conflict_rules: rules
};
}
private buildPricing(provider: ProviderConfig) {
const rule = jsonRecord(provider.cost_rule_json);
return {
schema_version: MODEL_PRICING_SCHEMA_VERSION,
provider_type: provider.provider_type,
provider_code: provider.provider_code,
model_name: provider.model_name ?? '',
currency: typeof rule.currency === 'string' ? rule.currency : 'UNSPECIFIED',
unit: typeof rule.unit === 'string' ? rule.unit : 'unspecified',
pricing_rule: rule
};
}
private validateProperty(
path: string,
value: unknown,
property: Record<string, unknown>,
issues: ModelParameterValidationIssue[]
) {
const expectedType = typeof property.type === 'string' ? property.type : '';
const actualType = Array.isArray(value) ? 'array' : typeof value;
if (expectedType && actualType !== expectedType) {
issues.push({ path, code: 'TYPE', message: `${path} must be ${expectedType}`, expected: expectedType, actual: actualType });
return;
}
const allowed = Array.isArray(property.enum) ? property.enum : null;
if (allowed && !allowed.some((item) => item === value)) {
issues.push({ path, code: 'ENUM', message: `${path} is not an allowed value`, expected: allowed, actual: value });
}
if (typeof value === 'number') {
const minimum = this.finiteNumber(property.minimum);
const maximum = this.finiteNumber(property.maximum);
if (minimum !== null && value < minimum) issues.push({ path, code: 'MINIMUM', message: `${path} is below minimum`, expected: minimum, actual: value });
if (maximum !== null && value > maximum) issues.push({ path, code: 'MAXIMUM', message: `${path} exceeds maximum`, expected: maximum, actual: value });
}
if (typeof value === 'string') {
const maxLength = this.positiveInt(property.maxLength);
if (maxLength && value.length > maxLength) issues.push({ path, code: 'MAX_LENGTH', message: `${path} is too long`, expected: maxLength, actual: value.length });
}
if (Array.isArray(value)) {
const maxItems = this.positiveInt(property.maxItems);
if (maxItems && value.length > maxItems) issues.push({ path, code: 'MAX_ITEMS', message: `${path} has too many items`, expected: maxItems, actual: value.length });
}
}
private validateConflictRule(
rule: ModelConflictRule,
request: Record<string, unknown>,
issues: ModelParameterValidationIssue[]
) {
if (!rule?.when || !rule?.require) return;
const whenValue = this.valueAtPath(request, rule.when.field);
if (!this.matchesOperator(whenValue, rule.when.operator, rule.when.value)) return;
const requiredValue = this.valueAtPath(request, rule.require.field);
let valid = true;
if (rule.require.operator === 'equals') valid = requiredValue === rule.require.value;
if (rule.require.operator === 'absent') valid = requiredValue === undefined || requiredValue === null;
if (rule.require.operator === 'max_items') {
const maximum = this.positiveInt(rule.require.value) ?? 0;
valid = !Array.isArray(requiredValue) || requiredValue.length <= maximum;
}
if (!valid) {
issues.push({
path: rule.require.field,
code: rule.code,
message: rule.message,
expected: rule.require.value,
actual: requiredValue
});
}
}
private matchesOperator(value: unknown, operator: ModelConflictRule['when']['operator'], expected: unknown) {
if (operator === 'equals') return value === expected;
if (operator === 'present') return value !== undefined && value !== null;
return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== '';
}
private valueAtPath(value: Record<string, unknown>, path: string): unknown {
return path.split('.').reduce<unknown>((current, key) => {
if (!current || Array.isArray(current) || typeof current !== 'object') return undefined;
return (current as Record<string, unknown>)[key];
}, value);
}
private applyEnum(property: ModelParameterProperty, value: unknown) {
if (!Array.isArray(value)) return;
const allowed = value.filter((item): item is string | number | boolean =>
typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean'
);
if (allowed.length) property.enum = allowed;
}
private applyMaxItems(property: ModelParameterProperty, value: unknown) {
const maximum = this.positiveInt(value);
if (maximum) property.maxItems = maximum;
}
private sourceSnapshot(provider: ProviderConfig): Prisma.InputJsonObject {
const config = jsonRecord(provider.config_json);
return {
provider_config_id: provider.id.toString(),
provider_updated_at: provider.updated_at.toISOString(),
source_capability_version: typeof config.capability_version === 'string' ? config.capability_version : ''
};
}
private versionKey(providerCode: string, kind: string, revision: number, hash: string) {
return `${providerCode}:${kind}:r${revision}:${hash.slice(0, 12)}`;
}
private hash(value: unknown) {
return createHash('sha256').update(this.stableStringify(value)).digest('hex');
}
private stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map((item) => this.stableStringify(item)).join(',')}]`;
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => left.localeCompare(right));
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${this.stableStringify(item)}`).join(',')}}`;
}
private toJson(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
private isUniqueConflict(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}
private positiveInt(value: unknown) {
const numberValue = this.finiteNumber(value);
return numberValue !== null && numberValue > 0 ? Math.floor(numberValue) : null;
}
private finiteNumber(value: unknown) {
const numberValue = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
return Number.isFinite(numberValue) ? numberValue : null;
}
private normalizeProviderCode(value?: string) {
const normalized = value?.trim();
if (!normalized) return undefined;
if (normalized.length > 100) throw new BadRequestException('INVALID_PROVIDER_CODE');
return normalized;
}
private toBigIntOrNull(value: string) {
try {
return BigInt(value);
} catch {
return null;
}
}
}
@@ -0,0 +1,94 @@
import type {
ModelCapabilityVersion,
ModelParameterSchemaVersion,
ModelPricingVersion,
Prisma
} from '@prisma/client';
export const MODEL_CAPABILITY_SCHEMA_VERSION = 'model_capability_v1';
export const MODEL_PARAMETER_SCHEMA_VERSION = 'model_parameter_schema_v1';
export const MODEL_PRICING_SCHEMA_VERSION = 'model_pricing_v1';
export type ModelParameterProperty = {
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
enum?: Array<string | number | boolean>;
minimum?: number;
maximum?: number;
maxLength?: number;
maxItems?: number;
items?: { type: 'string' | 'number' | 'boolean' | 'object' };
};
export type ModelConflictRule = {
code: string;
message: string;
when: {
field: string;
operator: 'present' | 'non_empty' | 'equals';
value?: unknown;
};
require: {
field: string;
operator: 'equals' | 'absent' | 'max_items';
value?: unknown;
};
};
export type ModelParameterSchema = {
schema_version: string;
type: 'object';
provider_type: string;
provider_code: string;
model_name: string;
required: string[];
properties: Record<string, ModelParameterProperty>;
x_conflict_rules: ModelConflictRule[];
};
export type ModelRegistryBundle = {
capability: ModelCapabilityVersion;
parameterSchema: ModelParameterSchemaVersion;
pricing: ModelPricingVersion;
};
export type ModelParameterValidationIssue = {
path: string;
code: string;
message: string;
expected?: unknown;
actual?: unknown;
};
export type ModelParameterValidationResult = {
valid: boolean;
issues: ModelParameterValidationIssue[];
};
export function toSafeCapabilityVersion(row: ModelCapabilityVersion) {
return {
...row,
id: row.id.toString(),
created_by_user_id: row.created_by_user_id?.toString() ?? null
};
}
export function toSafeParameterSchemaVersion(row: ModelParameterSchemaVersion) {
return {
...row,
id: row.id.toString(),
created_by_user_id: row.created_by_user_id?.toString() ?? null
};
}
export function toSafePricingVersion(row: ModelPricingVersion) {
return {
...row,
id: row.id.toString(),
created_by_user_id: row.created_by_user_id?.toString() ?? null
};
}
export function jsonRecord(value: Prisma.JsonValue | null | undefined): Record<string, unknown> {
if (!value || Array.isArray(value) || typeof value !== 'object') return {};
return value as Record<string, unknown>;
}

Some files were not shown because too many files have changed in this diff Show More