feat: implement robustness guards
This commit is contained in:
@@ -11,5 +11,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const location = `/api/storage/sign?key=${encodeURIComponent(key)}&expires=${encodeURIComponent(expires)}`
|
||||
return NextResponse.redirect(location)
|
||||
return NextResponse.redirect(new URL(location, request.url))
|
||||
})
|
||||
|
||||
@@ -1,12 +1,70 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'
|
||||
import { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'
|
||||
import { submitTask } from '@/lib/task/submitter'
|
||||
import { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
import { buildDefaultTaskBillingInfo } from '@/lib/billing'
|
||||
import { getProjectModelConfig, buildImageBillingPayload } from '@/lib/config-service'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { submitTask } from '@/lib/task/submitter'
|
||||
import { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
|
||||
function createPanelVariantId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `panel-variant-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
async function rollbackCreatedVariantPanel(params: {
|
||||
panelId: string
|
||||
storyboardId: string
|
||||
panelIndex: number
|
||||
}) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.novelPromotionPanel.delete({
|
||||
where: { id: params.panelId },
|
||||
})
|
||||
|
||||
const maxPanel = await tx.novelPromotionPanel.findFirst({
|
||||
where: { storyboardId: params.storyboardId },
|
||||
orderBy: { panelIndex: 'desc' },
|
||||
select: { panelIndex: true },
|
||||
})
|
||||
const maxPanelIndex = maxPanel?.panelIndex ?? -1
|
||||
const offset = maxPanelIndex + 1000
|
||||
|
||||
await tx.novelPromotionPanel.updateMany({
|
||||
where: {
|
||||
storyboardId: params.storyboardId,
|
||||
panelIndex: { gt: params.panelIndex },
|
||||
},
|
||||
data: {
|
||||
panelIndex: { increment: offset },
|
||||
panelNumber: { increment: offset },
|
||||
},
|
||||
})
|
||||
|
||||
await tx.novelPromotionPanel.updateMany({
|
||||
where: {
|
||||
storyboardId: params.storyboardId,
|
||||
panelIndex: { gt: params.panelIndex + offset },
|
||||
},
|
||||
data: {
|
||||
panelIndex: { decrement: offset + 1 },
|
||||
panelNumber: { decrement: offset + 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const panelCount = await tx.novelPromotionPanel.count({
|
||||
where: { storyboardId: params.storyboardId },
|
||||
})
|
||||
|
||||
await tx.novelPromotionStoryboard.update({
|
||||
where: { id: params.storyboardId },
|
||||
data: { panelCount },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (
|
||||
request: NextRequest,
|
||||
@@ -33,42 +91,79 @@ export const POST = apiHandler(async (
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
}
|
||||
|
||||
// 在 API route 中同步创建 panel(无图片),确保新 panel 立即存在于数据库,
|
||||
// 避免乐观更新与 worker 之间的状态真空期
|
||||
const sourcePanel = await prisma.novelPromotionPanel.findUnique({ where: { id: sourcePanelId } })
|
||||
if (!sourcePanel) {
|
||||
const storyboard = await prisma.novelPromotionStoryboard.findUnique({
|
||||
where: { id: storyboardId },
|
||||
select: {
|
||||
id: true,
|
||||
episode: {
|
||||
select: {
|
||||
novelPromotionProject: {
|
||||
select: {
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!storyboard || storyboard.episode.novelPromotionProject.projectId !== projectId) {
|
||||
throw new ApiError('NOT_FOUND')
|
||||
}
|
||||
|
||||
const sourcePanel = await prisma.novelPromotionPanel.findUnique({ where: { id: sourcePanelId } })
|
||||
if (!sourcePanel || sourcePanel.storyboardId !== storyboardId) {
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
}
|
||||
|
||||
const insertAfter = await prisma.novelPromotionPanel.findUnique({ where: { id: insertAfterPanelId } })
|
||||
if (!insertAfter) {
|
||||
throw new ApiError('NOT_FOUND')
|
||||
if (!insertAfter || insertAfter.storyboardId !== storyboardId) {
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
}
|
||||
|
||||
const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)
|
||||
const imageModel = projectModelConfig.storyboardModel
|
||||
const createdPanelId = createPanelVariantId()
|
||||
|
||||
let billingPayload: Record<string, unknown>
|
||||
try {
|
||||
billingPayload = await buildImageBillingPayload({
|
||||
projectId,
|
||||
userId: session.user.id,
|
||||
imageModel,
|
||||
basePayload: { ...body, newPanelId: createdPanelId },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Image model capability not configured'
|
||||
throw new ApiError('INVALID_PARAMS', {
|
||||
code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED',
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
const createdPanel = await prisma.$transaction(async (tx) => {
|
||||
// Two-phase reindexing to avoid unique constraint collision on (storyboardId, panelIndex)
|
||||
const affectedPanels = await tx.novelPromotionPanel.findMany({
|
||||
where: { storyboardId, panelIndex: { gt: insertAfter.panelIndex } },
|
||||
select: { id: true, panelIndex: true },
|
||||
orderBy: { panelIndex: 'asc' }
|
||||
orderBy: { panelIndex: 'asc' },
|
||||
})
|
||||
// Phase A: shift to negative indices
|
||||
for (const p of affectedPanels) {
|
||||
|
||||
for (const panel of affectedPanels) {
|
||||
await tx.novelPromotionPanel.update({
|
||||
where: { id: p.id },
|
||||
data: { panelIndex: -(p.panelIndex + 1) }
|
||||
})
|
||||
}
|
||||
// Phase B: set final positive indices
|
||||
for (const p of affectedPanels) {
|
||||
await tx.novelPromotionPanel.update({
|
||||
where: { id: p.id },
|
||||
data: { panelIndex: p.panelIndex + 1 }
|
||||
where: { id: panel.id },
|
||||
data: { panelIndex: -(panel.panelIndex + 1) },
|
||||
})
|
||||
}
|
||||
|
||||
return tx.novelPromotionPanel.create({
|
||||
for (const panel of affectedPanels) {
|
||||
await tx.novelPromotionPanel.update({
|
||||
where: { id: panel.id },
|
||||
data: { panelIndex: panel.panelIndex + 1 },
|
||||
})
|
||||
}
|
||||
|
||||
const created = await tx.novelPromotionPanel.create({
|
||||
data: {
|
||||
id: createdPanelId,
|
||||
storyboardId,
|
||||
panelIndex: insertAfter.panelIndex + 1,
|
||||
panelNumber: insertAfter.panelIndex + 2,
|
||||
@@ -79,39 +174,44 @@ export const POST = apiHandler(async (
|
||||
location: variant.location || sourcePanel.location,
|
||||
characters: variant.characters ? JSON.stringify(variant.characters) : sourcePanel.characters,
|
||||
srtSegment: sourcePanel.srtSegment,
|
||||
duration: sourcePanel.duration
|
||||
}
|
||||
duration: sourcePanel.duration,
|
||||
},
|
||||
})
|
||||
|
||||
const panelCount = await tx.novelPromotionPanel.count({
|
||||
where: { storyboardId },
|
||||
})
|
||||
|
||||
await tx.novelPromotionStoryboard.update({
|
||||
where: { id: storyboardId },
|
||||
data: { panelCount },
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)
|
||||
const imageModel = projectModelConfig.storyboardModel
|
||||
|
||||
let billingPayload: Record<string, unknown>
|
||||
let result: Awaited<ReturnType<typeof submitTask>>
|
||||
try {
|
||||
billingPayload = await buildImageBillingPayload({
|
||||
projectId,
|
||||
result = await submitTask({
|
||||
userId: session.user.id,
|
||||
imageModel,
|
||||
basePayload: { ...body, newPanelId: createdPanel.id },
|
||||
locale,
|
||||
requestId: getRequestId(request),
|
||||
projectId,
|
||||
type: TASK_TYPE.PANEL_VARIANT,
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: createdPanel.id,
|
||||
payload: billingPayload,
|
||||
dedupeKey: `panel_variant:${storyboardId}:${insertAfterPanelId}:${sourcePanelId}`,
|
||||
billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.PANEL_VARIANT, billingPayload),
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Image model capability not configured'
|
||||
throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message })
|
||||
} catch (error) {
|
||||
await rollbackCreatedVariantPanel({
|
||||
panelId: createdPanel.id,
|
||||
storyboardId,
|
||||
panelIndex: createdPanel.panelIndex,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
// Task target 指向新创建的 panel,使 task state 监控系统正确追踪
|
||||
const result = await submitTask({
|
||||
userId: session.user.id,
|
||||
locale,
|
||||
requestId: getRequestId(request),
|
||||
projectId,
|
||||
type: TASK_TYPE.PANEL_VARIANT,
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: createdPanel.id,
|
||||
payload: billingPayload,
|
||||
dedupeKey: `panel_variant:${storyboardId}:${insertAfterPanelId}:${sourcePanelId}`,
|
||||
billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.PANEL_VARIANT, billingPayload)
|
||||
})
|
||||
|
||||
return NextResponse.json({ ...result, panelId: createdPanel.id })
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function Navbar() {
|
||||
const [checkMsg, setCheckMsg] = useState<string | null>(null)
|
||||
const [checkMsgFading, setCheckMsgFading] = useState(false)
|
||||
const [manualChecking, setManualChecking] = useState(false)
|
||||
const downloadLogsHref = '/api/admin/download-logs'
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setCheckMsg(null)
|
||||
@@ -125,7 +126,7 @@ export default function Navbar() {
|
||||
</Link>
|
||||
<LanguageSwitcher />
|
||||
<a
|
||||
href="/api/admin/download-logs"
|
||||
href={downloadLogsHref}
|
||||
download
|
||||
className="text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] font-medium transition-colors flex items-center gap-1"
|
||||
title={t('downloadLogs')}
|
||||
|
||||
@@ -93,6 +93,37 @@ describe('outbound-image normalization', () => {
|
||||
expect(dataUrl).toBe('data:image/png;base64,AQID')
|
||||
})
|
||||
|
||||
it('sniffs png mime when upstream returns application/octet-stream', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/octet-stream' }),
|
||||
arrayBuffer: async () => Uint8Array.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x0d,
|
||||
]).buffer,
|
||||
} as Response)
|
||||
|
||||
const dataUrl = await normalizeToBase64ForGeneration('images/direct.png')
|
||||
expect(dataUrl).toBe('data:image/png;base64,iVBORw0KGgoAAAAN')
|
||||
})
|
||||
|
||||
it('sniffs jpeg mime when upstream returns application/octet-stream', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/octet-stream' }),
|
||||
arrayBuffer: async () => Uint8Array.from([
|
||||
0xff, 0xd8, 0xff, 0xe0,
|
||||
0x00, 0x10, 0x4a, 0x46,
|
||||
0x49, 0x46, 0x00, 0x01,
|
||||
]).buffer,
|
||||
} as Response)
|
||||
|
||||
const dataUrl = await normalizeToBase64ForGeneration('images/direct.jpg')
|
||||
expect(dataUrl).toBe('data:image/jpeg;base64,/9j/4AAQSkZJRgAB')
|
||||
})
|
||||
|
||||
it('normalizes references with dedupe and failure isolation', async () => {
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (String(url).includes('/api/bad.png')) {
|
||||
|
||||
@@ -163,9 +163,115 @@ function toUrlMaybe(value: string): URL | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function guessContentType(input: string, contentTypeHeader: string | null): string {
|
||||
function detectMimeFromBuffer(buffer: Uint8Array): string | null {
|
||||
if (buffer.length >= 8) {
|
||||
const isPng =
|
||||
buffer[0] === 0x89
|
||||
&& buffer[1] === 0x50
|
||||
&& buffer[2] === 0x4e
|
||||
&& buffer[3] === 0x47
|
||||
&& buffer[4] === 0x0d
|
||||
&& buffer[5] === 0x0a
|
||||
&& buffer[6] === 0x1a
|
||||
&& buffer[7] === 0x0a
|
||||
if (isPng) return 'image/png'
|
||||
}
|
||||
|
||||
if (buffer.length >= 3) {
|
||||
const isJpeg = buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff
|
||||
if (isJpeg) return 'image/jpeg'
|
||||
}
|
||||
|
||||
if (buffer.length >= 6) {
|
||||
const isGif87a =
|
||||
buffer[0] === 0x47
|
||||
&& buffer[1] === 0x49
|
||||
&& buffer[2] === 0x46
|
||||
&& buffer[3] === 0x38
|
||||
&& buffer[4] === 0x37
|
||||
&& buffer[5] === 0x61
|
||||
const isGif89a =
|
||||
buffer[0] === 0x47
|
||||
&& buffer[1] === 0x49
|
||||
&& buffer[2] === 0x46
|
||||
&& buffer[3] === 0x38
|
||||
&& buffer[4] === 0x39
|
||||
&& buffer[5] === 0x61
|
||||
if (isGif87a || isGif89a) return 'image/gif'
|
||||
}
|
||||
|
||||
if (buffer.length >= 12) {
|
||||
const isWebp =
|
||||
buffer[0] === 0x52
|
||||
&& buffer[1] === 0x49
|
||||
&& buffer[2] === 0x46
|
||||
&& buffer[3] === 0x46
|
||||
&& buffer[8] === 0x57
|
||||
&& buffer[9] === 0x45
|
||||
&& buffer[10] === 0x42
|
||||
&& buffer[11] === 0x50
|
||||
if (isWebp) return 'image/webp'
|
||||
}
|
||||
|
||||
if (buffer.length >= 12) {
|
||||
const isWav =
|
||||
buffer[0] === 0x52
|
||||
&& buffer[1] === 0x49
|
||||
&& buffer[2] === 0x46
|
||||
&& buffer[3] === 0x46
|
||||
&& buffer[8] === 0x57
|
||||
&& buffer[9] === 0x41
|
||||
&& buffer[10] === 0x56
|
||||
&& buffer[11] === 0x45
|
||||
if (isWav) return 'audio/wav'
|
||||
}
|
||||
|
||||
if (buffer.length >= 4) {
|
||||
const isOgg =
|
||||
buffer[0] === 0x4f
|
||||
&& buffer[1] === 0x67
|
||||
&& buffer[2] === 0x67
|
||||
&& buffer[3] === 0x53
|
||||
if (isOgg) return 'audio/ogg'
|
||||
}
|
||||
|
||||
if (buffer.length >= 3) {
|
||||
const isMp3WithId3 =
|
||||
buffer[0] === 0x49
|
||||
&& buffer[1] === 0x44
|
||||
&& buffer[2] === 0x33
|
||||
const isMp3FrameSync =
|
||||
buffer[0] === 0xff
|
||||
&& (buffer[1] & 0xe0) === 0xe0
|
||||
if (isMp3WithId3 || isMp3FrameSync) return 'audio/mpeg'
|
||||
}
|
||||
|
||||
if (buffer.length >= 12) {
|
||||
const isWebm =
|
||||
buffer[0] === 0x1a
|
||||
&& buffer[1] === 0x45
|
||||
&& buffer[2] === 0xdf
|
||||
&& buffer[3] === 0xa3
|
||||
if (isWebm) return 'video/webm'
|
||||
}
|
||||
|
||||
if (buffer.length >= 8) {
|
||||
const isMp4 =
|
||||
buffer[4] === 0x66
|
||||
&& buffer[5] === 0x74
|
||||
&& buffer[6] === 0x79
|
||||
&& buffer[7] === 0x70
|
||||
if (isMp4) return 'video/mp4'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function guessContentType(input: string, contentTypeHeader: string | null, buffer: Uint8Array): string {
|
||||
const headerType = contentTypeHeader?.split(';')[0]?.trim()
|
||||
if (headerType) return headerType
|
||||
if (headerType && headerType !== DEFAULT_CONTENT_TYPE) return headerType
|
||||
const sniffedType = detectMimeFromBuffer(buffer)
|
||||
if (sniffedType) return sniffedType
|
||||
const parsed = toUrlMaybe(input)
|
||||
const pathname = parsed?.pathname ?? input
|
||||
const ext = path.extname(pathname).toLowerCase()
|
||||
@@ -308,8 +414,8 @@ export async function normalizeToBase64ForGeneration(input: string): Promise<str
|
||||
})
|
||||
}
|
||||
|
||||
const mimeType = guessContentType(normalizedUrl, response.headers.get('content-type'))
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
const mimeType = guessContentType(normalizedUrl, response.headers.get('content-type'), buffer)
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
import { normalizeReferenceImagesForGeneration } from '@/lib/media/outbound-image'
|
||||
import {
|
||||
AnyObj,
|
||||
collectPanelReferenceImages,
|
||||
findCharacterByName,
|
||||
parseImageUrls,
|
||||
parsePanelCharacterReferences,
|
||||
pickFirstString,
|
||||
resolveNovelData,
|
||||
@@ -94,6 +94,72 @@ function buildCharacterAssetsDescription(
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
function buildLocationAssetDescription(params: {
|
||||
includeLocationAsset: boolean
|
||||
locationName: string
|
||||
locale: TaskJobData['locale']
|
||||
}): string {
|
||||
if (params.locationName) {
|
||||
if (params.includeLocationAsset) return `场景:${params.locationName}`
|
||||
return params.locale === 'en' ? 'Location reference disabled' : '未使用场景参考图'
|
||||
}
|
||||
return params.locale === 'en' ? 'No location reference' : '无场景参考'
|
||||
}
|
||||
|
||||
function buildVariantReferenceImages(params: {
|
||||
includeCharacterAssets: boolean
|
||||
includeLocationAsset: boolean
|
||||
newPanel: {
|
||||
characters: string | null
|
||||
location: string | null
|
||||
}
|
||||
sourcePanelImageUrl: string | null
|
||||
projectData: Awaited<ReturnType<typeof resolveNovelData>>
|
||||
}): string[] {
|
||||
const refs: string[] = []
|
||||
if (params.sourcePanelImageUrl) refs.push(params.sourcePanelImageUrl)
|
||||
|
||||
if (params.includeCharacterAssets) {
|
||||
const panelCharacters = parsePanelCharacterReferences(params.newPanel.characters)
|
||||
for (const item of panelCharacters) {
|
||||
const character = findCharacterByName(params.projectData.characters || [], item.name)
|
||||
if (!character) continue
|
||||
|
||||
const appearances = character.appearances || []
|
||||
let appearance = appearances[0]
|
||||
if (item.appearance) {
|
||||
const matched = appearances.find((candidate) => (candidate.changeReason || '').toLowerCase() === item.appearance!.toLowerCase())
|
||||
if (matched) appearance = matched
|
||||
}
|
||||
|
||||
if (!appearance) continue
|
||||
const imageUrls = parseImageUrls((appearance as { imageUrls?: string | null }).imageUrls || null, 'characterAppearance.imageUrls')
|
||||
const selectedIndex = typeof (appearance as { selectedIndex?: number | null }).selectedIndex === 'number'
|
||||
? (appearance as { selectedIndex?: number | null }).selectedIndex
|
||||
: null
|
||||
const selectedUrl = (selectedIndex !== null && selectedIndex !== undefined ? imageUrls[selectedIndex] : null)
|
||||
|| imageUrls[0]
|
||||
|| appearance.imageUrl
|
||||
|| null
|
||||
const signed = toSignedUrlIfCos(selectedUrl, 3600)
|
||||
if (signed) refs.push(signed)
|
||||
}
|
||||
}
|
||||
|
||||
if (params.includeLocationAsset && params.newPanel.location) {
|
||||
const location = (params.projectData.locations || []).find(
|
||||
(item) => item.name.toLowerCase() === params.newPanel.location!.toLowerCase(),
|
||||
)
|
||||
if (location) {
|
||||
const selected = (location.images || []).find((image) => image.isSelected) || location.images?.[0]
|
||||
const signed = toSignedUrlIfCos(selected?.imageUrl, 3600)
|
||||
if (signed) refs.push(signed)
|
||||
}
|
||||
}
|
||||
|
||||
return refs
|
||||
}
|
||||
|
||||
interface PanelVariantPayload {
|
||||
shot_type?: string
|
||||
camera_move?: string
|
||||
@@ -108,6 +174,8 @@ export async function handlePanelVariantTask(job: Job<TaskJobData>) {
|
||||
const payload = (job.data.payload || {}) as AnyObj
|
||||
const newPanelId = pickFirstString(payload.newPanelId)
|
||||
const sourcePanelId = pickFirstString(payload.sourcePanelId)
|
||||
const includeCharacterAssets = payload.includeCharacterAssets !== false
|
||||
const includeLocationAsset = payload.includeLocationAsset !== false
|
||||
const variant: PanelVariantPayload = payload.variant && typeof payload.variant === 'object'
|
||||
? (payload.variant as PanelVariantPayload)
|
||||
: {}
|
||||
@@ -132,16 +200,22 @@ export async function handlePanelVariantTask(job: Job<TaskJobData>) {
|
||||
if (!storyboardModel) throw new Error('Storyboard model not configured')
|
||||
|
||||
// 收集参考图(与 panel-image-task-handler 共用同一链路)
|
||||
const refs = await collectPanelReferenceImages(projectData, newPanel)
|
||||
// 额外加入源镜头图片作为参考
|
||||
const sourcePanelImageUrl = toSignedUrlIfCos(sourcePanel.imageUrl, 3600)
|
||||
if (sourcePanelImageUrl) refs.unshift(sourcePanelImageUrl)
|
||||
const refs = buildVariantReferenceImages({
|
||||
includeCharacterAssets,
|
||||
includeLocationAsset,
|
||||
newPanel,
|
||||
sourcePanelImageUrl,
|
||||
projectData,
|
||||
})
|
||||
const normalizedRefs = await normalizeReferenceImagesForGeneration(refs)
|
||||
|
||||
// 使用 agent_shot_variant_generate.txt 提示词模板
|
||||
const artStyle = getArtStylePrompt(modelConfig.artStyle, job.data.locale)
|
||||
const charactersInfo = buildCharactersInfo(newPanel, projectData)
|
||||
const characterAssetsDesc = buildCharacterAssetsDescription(newPanel, projectData)
|
||||
const characterAssetsDesc = includeCharacterAssets
|
||||
? buildCharacterAssetsDescription(newPanel, projectData)
|
||||
: (job.data.locale === 'en' ? 'Character reference images disabled' : '未使用角色参考图')
|
||||
const locationName = newPanel.location || sourcePanel.location || ''
|
||||
|
||||
const prompt = buildVariantPrompt({
|
||||
@@ -157,7 +231,11 @@ export async function handlePanelVariantTask(job: Job<TaskJobData>) {
|
||||
targetCameraMove: variant.camera_move || sourcePanel.cameraMove || '',
|
||||
videoPrompt: pickFirstString(variant.video_prompt, variant.description) || '',
|
||||
characterAssets: characterAssetsDesc,
|
||||
locationAsset: locationName ? `场景:${locationName}` : '无场景参考',
|
||||
locationAsset: buildLocationAssetDescription({
|
||||
includeLocationAsset,
|
||||
locationName,
|
||||
locale: job.data.locale,
|
||||
}),
|
||||
aspectRatio,
|
||||
style: artStyle || '与参考图风格一致',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user