feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
import { type Job } from 'bullmq'
import { prisma } from '@/lib/prisma'
import { type TaskJobData } from '@/lib/task/types'
import { decodeImageUrlsFromDb } from '@/lib/contracts/image-urls-contract'
import {
resolveImageSourceFromGeneration,
toSignedUrlIfCos,
uploadImageSourceToCos,
withLabelBar,
} from '../utils'
export type AnyObj = Record<string, unknown>
interface CharacterAppearanceLike {
appearanceIndex?: number
changeReason: string | null
description?: string | null
descriptions?: string | null
imageUrls: string | null
imageUrl: string | null
selectedIndex: number | null
}
interface CharacterLike {
name: string
appearances?: CharacterAppearanceLike[]
}
interface LocationImageLike {
description?: string | null
imageIndex?: number
isSelected: boolean
imageUrl: string | null
}
interface LocationLike {
name: string
images?: LocationImageLike[]
}
interface NovelProjectData {
videoRatio?: string | null
characters?: CharacterLike[]
locations?: LocationLike[]
}
interface PanelLike {
sketchImageUrl?: string | null
characters?: string | null
location?: string | null
}
export interface PanelCharacterReference {
name: string
appearance?: string
}
interface NovelDataDb {
novelPromotionProject: {
findUnique(args: Record<string, unknown>): Promise<NovelProjectData | null>
}
}
export function parseJsonStringArray(value: unknown): string[] {
if (!value) return []
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string')
}
if (typeof value !== 'string') return []
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) return []
return parsed.filter((item): item is string => typeof item === 'string')
} catch {
return []
}
}
export function parseImageUrls(value: string | null | undefined, fieldName: string): string[] {
return decodeImageUrlsFromDb(value, fieldName)
}
export function clampCount(value: unknown, min: number, max: number, fallback: number) {
const n = Number(value)
if (!Number.isFinite(n)) return fallback
return Math.max(min, Math.min(max, Math.floor(n)))
}
export function pickFirstString(...values: unknown[]) {
for (const value of values) {
if (typeof value === 'string' && value.trim()) return value
}
return null
}
export async function generateLabeledImageToCos(params: {
job: Job<TaskJobData>
userId: string
modelId: string
prompt: string
label: string
targetId: string
keyPrefix: string
options?: {
referenceImages?: string[]
aspectRatio?: string
size?: string
}
}) {
const source = await resolveImageSourceFromGeneration(params.job, {
userId: params.userId,
modelId: params.modelId,
prompt: params.prompt,
options: params.options,
})
const labeled = await withLabelBar(source, params.label)
const cosKey = await uploadImageSourceToCos(labeled, params.keyPrefix, params.targetId)
return cosKey
}
export async function resolveNovelData(projectId: string) {
const db = prisma as unknown as NovelDataDb
const data = await db.novelPromotionProject.findUnique({
where: { projectId },
include: {
characters: { include: { appearances: { orderBy: { appearanceIndex: 'asc' } } } },
locations: { include: { images: { orderBy: { imageIndex: 'asc' } } } },
},
})
if (!data) {
throw new Error(`NovelPromotionProject not found: ${projectId}`)
}
return data
}
export function parsePanelCharacterReferences(value: string | null | undefined): PanelCharacterReference[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) return []
return parsed
.map((item: unknown) => {
if (typeof item === 'string') return { name: item }
if (!item || typeof item !== 'object') return null
const candidate = item as { name?: unknown; appearance?: unknown }
if (typeof candidate.name === 'string') {
return {
name: candidate.name,
appearance: typeof candidate.appearance === 'string' ? candidate.appearance : undefined,
}
}
return null
})
.filter(Boolean) as PanelCharacterReference[]
} catch {
return []
}
}
/**
* 按角色名查找角色(支持别名匹配)
* 优先级1. 精确全名匹配 2. 按 '/' 拆分后别名精确匹配
* 例:引用名 "顾娘子" 可匹配角色 "顾娘子/顾盼之"
*/
export function findCharacterByName<T extends { name: string }>(characters: T[], referenceName: string): T | undefined {
const refLower = referenceName.toLowerCase().trim()
if (!refLower) return undefined
// 优先级 1精确全名匹配
const exact = characters.find((c) => c.name.toLowerCase().trim() === refLower)
if (exact) return exact
// 优先级 2别名匹配 — 按 '/' 拆分后任一别名精确匹配
const refAliases = refLower.split('/').map((s) => s.trim()).filter(Boolean)
for (const character of characters) {
const charAliases = character.name.toLowerCase().split('/').map((s) => s.trim()).filter(Boolean)
const hasOverlap = refAliases.some((refAlias) => charAliases.includes(refAlias))
if (hasOverlap) return character
}
return undefined
}
export async function collectPanelReferenceImages(projectData: NovelProjectData, panel: PanelLike) {
const refs: string[] = []
const sketch = toSignedUrlIfCos(panel.sketchImageUrl, 3600)
if (sketch) refs.push(sketch)
const panelCharacters = parsePanelCharacterReferences(panel.characters)
for (const item of panelCharacters) {
const character = findCharacterByName(projectData.characters || [], item.name)
if (!character) continue
const appearances = character.appearances || []
let appearance = appearances[0]
if (item.appearance) {
const matched = appearances.find((a) => (a.changeReason || '').toLowerCase() === item.appearance!.toLowerCase())
if (matched) appearance = matched
}
if (!appearance) continue
const imageUrls = parseImageUrls(appearance.imageUrls, 'characterAppearance.imageUrls')
const selectedIndex = appearance.selectedIndex
const selectedUrl = selectedIndex !== null && selectedIndex !== undefined ? imageUrls[selectedIndex] : null
const key = selectedUrl || imageUrls[0] || appearance.imageUrl
const signed = toSignedUrlIfCos(key, 3600)
if (signed) refs.push(signed)
}
if (panel.location) {
const location = (projectData.locations || []).find((loc) => loc.name.toLowerCase() === panel.location!.toLowerCase())
if (location) {
const images = location.images || []
const selected = images.find((img) => img.isSelected) || images[0]
const signed = toSignedUrlIfCos(selected?.imageUrl, 3600)
if (signed) refs.push(signed)
}
}
return refs
}