/** 脚本设定卡的选项体系:表现形式 × 视频结构 × 时长(二期第2段)。 * * 这里的枚举、禁用组合、时长区间必须跟后端 apps/ai/script_agent.py 的常量保持一致 —— * 后端会 coerce 兜底,但前端先挡住能避免用户选了一个又被悄悄改掉。 */ export type PresentationFormat = "oral" | "drama" | "vlog"; export type VideoStructure = "pain" | "contrast" | "review" | "scene" | "promo" | "knowledge"; export const PRESENTATION_FORMATS: Record = { oral: "口播", drama: "短剧", vlog: "Vlog", }; export const VIDEO_STRUCTURES: Record = { pain: "痛点解决", contrast: "前后对比", review: "测评验证", scene: "场景种草", promo: "促销抢购", knowledge: "知识分享", }; export const PRESENTATION_KEYS = Object.keys(PRESENTATION_FORMATS) as PresentationFormat[]; export const STRUCTURE_KEYS = Object.keys(VIDEO_STRUCTURES) as VideoStructure[]; const FORMAT_KEY_BY_LABEL: Record = Object.fromEntries( (Object.entries(PRESENTATION_FORMATS) as Array<[PresentationFormat, string]>).map(([key, label]) => [label, key]) ); const STRUCTURE_KEY_BY_LABEL: Record = Object.fromEntries( (Object.entries(VIDEO_STRUCTURES) as Array<[VideoStructure, string]>).map(([key, label]) => [label, key]) ); /** 脚本落库存的是中文标签(「短剧」),设定卡下拉用的是 key(drama)。两边都认。 */ export function coercePresentationFormat(value: unknown, fallback: PresentationFormat = "oral"): PresentationFormat { if (typeof value !== "string" || !value) return fallback; if (value in PRESENTATION_FORMATS) return value as PresentationFormat; return FORMAT_KEY_BY_LABEL[value] || fallback; } export function coerceVideoStructure(value: unknown, fallback: VideoStructure = "pain"): VideoStructure { if (typeof value !== "string" || !value) return fallback; if (value in VIDEO_STRUCTURES) return value as VideoStructure; return STRUCTURE_KEY_BY_LABEL[value] || fallback; } /** 一句话说清每个选项是什么,挂在下拉旁边当说明,别让用户猜。 */ export const PRESENTATION_HINT: Record = { oral: "一个人对镜头说,信息密度最高、最省制作", drama: "有角色有冲突的微型剧,完播率最高", vlog: "第一人称生活记录,广告感最低", }; export const STRUCTURE_HINT: Record = { pain: "先戳痛点再给解法,适用面最广", contrast: "用前后差距说话,视觉冲击最强", review: "当场举证验证,适合高客单价", scene: "把商品放进向往的场景里种草", promo: "优惠机制清楚时,直接讲怎么买最划算", knowledge: "先教用户怎么判断,再自然带出商品", }; /** 唯一禁用组合:短剧 × 测评验证 —— 演出来的实测没有可信度。 */ const FORBIDDEN: ReadonlyArray<[PresentationFormat, VideoStructure]> = [["drama", "review"]]; export function isForbidden(format: PresentationFormat, structure: VideoStructure): boolean { return FORBIDDEN.some(([f, s]) => f === format && s === structure); } /** 1.8 组合联动:选定表现形式后,视频结构只剩这些可选。 */ export function allowedStructures(format: PresentationFormat): VideoStructure[] { return STRUCTURE_KEYS.filter((key) => !isForbidden(format, key)); } // ── 时长:总时长 15/30/45/60;主流程每镜固定 15 秒 ── export const SEGMENT_DURATION_MIN = 4; export const SEGMENT_DURATION_MAX = 15; export const TOTAL_DURATION_MIN = 15; export const TOTAL_DURATION_MAX = 60; export const TOTAL_DURATION_STEP = 15; export const DURATION_OPTIONS: number[] = Array.from( { length: (TOTAL_DURATION_MAX - TOTAL_DURATION_MIN) / TOTAL_DURATION_STEP + 1 }, (_, i) => TOTAL_DURATION_MIN + i * TOTAL_DURATION_STEP ); /** 表现形式推荐的默认总时长:口播短平快 / 短剧要装下三幕 / Vlog 要铺氛围。 */ const FORMAT_DEFAULT_DURATION: Record = { oral: 30, drama: 45, vlog: 30 }; /** 各结构能压到的最短总时长 —— 低于它证据或氛围就不成立了。须落在 15 秒步进上。 */ const STRUCTURE_MIN_DURATION: Record = { pain: 15, contrast: 15, review: 30, scene: 30, promo: 15, knowledge: 30 }; export function recommendDuration(format: PresentationFormat, structure: VideoStructure): number { return Math.max(FORMAT_DEFAULT_DURATION[format], STRUCTURE_MIN_DURATION[structure]); } export function clampDuration(seconds: number): number { if (!Number.isFinite(seconds)) return FORMAT_DEFAULT_DURATION.oral; const stepped = Math.round(seconds / TOTAL_DURATION_STEP) * TOTAL_DURATION_STEP; return Math.min(TOTAL_DURATION_MAX, Math.max(TOTAL_DURATION_MIN, stepped)); } /** 时长低于该结构的最短可用值时给一句提醒(不拦,只提示)。 */ export function durationWarning(structure: VideoStructure, seconds: number): string { const min = STRUCTURE_MIN_DURATION[structure]; if (seconds >= min) return ""; return `${VIDEO_STRUCTURES[structure]}低于 ${min} 秒会压不住,建议调长`; } // ── 2.3 按商品分类推荐默认组合 ── type Combo = { format: PresentationFormat; structure: VideoStructure; persona: string }; /** 与商品库品类一一对应。改这里时同步 backend script_agent.recommend_script_setup。 */ const CATEGORY_COMBOS: Record = { 美妆个护: { format: "oral", structure: "contrast", persona: "bestie" }, 食品饮料: { format: "oral", structure: "scene", persona: "bestie" }, 服饰鞋包: { format: "oral", structure: "scene", persona: "urban" }, 家居日用: { format: "oral", structure: "pain", persona: "mom" }, 数码家电: { format: "oral", structure: "review", persona: "reviewer" }, 母婴玩具: { format: "oral", structure: "pain", persona: "mom" }, 运动健康: { format: "oral", structure: "scene", persona: "urban" }, 珠宝配饰: { format: "oral", structure: "scene", persona: "ceo" }, 汽车用品: { format: "oral", structure: "review", persona: "reviewer" }, 宠物用品: { format: "oral", structure: "pain", persona: "mom" }, 图书文教: { format: "oral", structure: "knowledge", persona: "urban" }, 五金农资: { format: "oral", structure: "review", persona: "reviewer" }, 其他商品: { format: "oral", structure: "pain", persona: "urban" }, }; /** 旧品类名 / 标题关键词兜底。命中第一条即用。 */ const CATEGORY_RULES: ReadonlyArray<{ keywords: string[]; combo: Combo }> = [ { keywords: ["美妆", "护肤", "彩妆", "面膜", "精华", "洗护", "个护"], combo: CATEGORY_COMBOS["美妆个护"] }, { keywords: ["保健", "营养", "膳食", "益生菌", "维生素"], combo: { format: "oral", structure: "review", persona: "reviewer" } }, { keywords: ["数码", "3c", "电子", "电器", "手机", "耳机", "相机", "工具", "家电"], combo: CATEGORY_COMBOS["数码家电"] }, { keywords: ["食品", "零食", "饮料", "咖啡", "茶", "生鲜", "酒"], combo: CATEGORY_COMBOS["食品饮料"] }, { keywords: ["服饰", "女装", "男装", "服装", "鞋", "包", "配饰", "内衣"], combo: CATEGORY_COMBOS["服饰鞋包"] }, { keywords: ["家居", "家纺", "收纳", "厨具", "清洁", "日用"], combo: CATEGORY_COMBOS["家居日用"] }, { keywords: ["母婴", "宝宝", "儿童", "玩具"], combo: CATEGORY_COMBOS["母婴玩具"] }, { keywords: ["宠物"], combo: CATEGORY_COMBOS["宠物用品"] }, { keywords: ["户外", "露营", "运动", "健身"], combo: CATEGORY_COMBOS["运动健康"] }, ]; const FALLBACK_COMBO: Combo = { format: "oral", structure: "pain", persona: "urban" }; /** 按商品品类推荐「表现形式 × 视频结构 × 人物 × 时长」。用户随时可改。 */ export function recommendSetup(input: { category?: string; title?: string }): Combo & { duration: number; reason: string; } { const category = (input.category || "").trim(); const haystack = [category, input.title] .filter(Boolean) .join(" ") .toLowerCase(); let combo = FALLBACK_COMBO; let reason = "商品信息不足,先给一组最通用的"; if (category && CATEGORY_COMBOS[category]) { combo = CATEGORY_COMBOS[category]; reason = `按「${category}」品类推荐`; } else { for (const rule of CATEGORY_RULES) { const hit = rule.keywords.find((word) => haystack.includes(word.toLowerCase())); if (hit) { combo = rule.combo; reason = `按「${hit}」品类推荐`; break; } } } // 自动推荐只给口播;Vlog / 短剧留给用户自己选。 const format: PresentationFormat = "oral"; const structure = isForbidden(format, combo.structure) ? allowedStructures(format)[0] : combo.structure; return { format, structure, persona: combo.persona, duration: recommendDuration(format, structure), reason, }; }