feat: initial release v0.3.0
This commit is contained in:
226
src/lib/workers/handlers/image-task-handler-shared.ts
Normal file
226
src/lib/workers/handlers/image-task-handler-shared.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user