feat: add asset library download button, fix env ports, update README, optimize semantics, support multi-image reading, and allow voiceover analysis for silent segments
This commit is contained in:
@@ -12,6 +12,9 @@ import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import AssetsStage from './AssetsStage'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { useProjectAssets } from '@/lib/query/hooks'
|
||||
import JSZip from 'jszip'
|
||||
import { logError as _logError } from '@/lib/logging/core'
|
||||
|
||||
interface AssetLibraryProps {
|
||||
projectId: string
|
||||
@@ -23,8 +26,77 @@ export default function AssetLibrary({
|
||||
isAnalyzingAssets
|
||||
}: AssetLibraryProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const t = useTranslations('assets')
|
||||
|
||||
// 获取项目资产数据用于下载
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
const characters = assets?.characters ?? []
|
||||
const locations = assets?.locations ?? []
|
||||
|
||||
// 收集所有有效图片
|
||||
const imageEntries: Array<{ filename: string; url: string }> = []
|
||||
|
||||
// 角色图片
|
||||
for (const character of characters) {
|
||||
for (const appearance of character.appearances ?? []) {
|
||||
const url = appearance.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = character.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = appearance.appearanceIndex === 0
|
||||
? `characters/${safeName}.jpg`
|
||||
: `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`
|
||||
imageEntries.push({ filename, url })
|
||||
}
|
||||
}
|
||||
|
||||
// 场景图片:取已选中的那张
|
||||
for (const location of locations) {
|
||||
const selectedImage = location.images?.find(img => img.isSelected) ?? location.images?.[0]
|
||||
const url = selectedImage?.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = location.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
imageEntries.push({ filename: `locations/${safeName}.jpg`, url })
|
||||
}
|
||||
|
||||
if (imageEntries.length === 0) {
|
||||
alert(t('assetLibrary.downloadEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsDownloading(true)
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
await Promise.all(
|
||||
imageEntries.map(async ({ filename, url }) => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) return
|
||||
const blob = await response.blob()
|
||||
zip.file(filename, blob)
|
||||
} catch {
|
||||
// 单张失败不影响其他
|
||||
}
|
||||
})
|
||||
)
|
||||
const content = await zip.generateAsync({ type: 'blob' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(content)
|
||||
link.download = `assets_${new Date().toISOString().slice(0, 10)}.zip`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
} catch (error) {
|
||||
_logError('打包下载失败:', error)
|
||||
alert(t('assetLibrary.downloadFailed'))
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 触发按钮 - 现代玻璃态风格 */}
|
||||
@@ -48,6 +120,20 @@ export default function AssetLibrary({
|
||||
<AppIcon name="folderCards" className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-[var(--glass-text-primary)]">{t('assetLibrary.title')}</h2>
|
||||
|
||||
{/* 下载按钮 - 紧贴标题 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadAll}
|
||||
disabled={isDownloading}
|
||||
title={t('common.download')}
|
||||
className="w-9 h-9 glass-btn-base glass-btn-secondary flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<AppIcon
|
||||
name={isDownloading ? 'refresh' : 'download'}
|
||||
className={`w-4 h-4${isDownloading ? ' animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRefreshProjectAssets } from '@/lib/query/hooks'
|
||||
import { useRefreshProjectAssets, useProjectAssets, useProjectData } from '@/lib/query/hooks'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import JSZip from 'jszip'
|
||||
import { logError as _logError } from '@/lib/logging/core'
|
||||
|
||||
/**
|
||||
* AssetToolbar - 资产管理工具栏组件
|
||||
@@ -37,9 +40,13 @@ export default function AssetToolbar({
|
||||
onRegenerateAll,
|
||||
onGlobalAnalyze
|
||||
}: AssetToolbarProps) {
|
||||
// 🔥 使用 React Query 刷新
|
||||
const onRefresh = useRefreshProjectAssets(projectId)
|
||||
const t = useTranslations('assets')
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
const { data: projectData } = useProjectData(projectId)
|
||||
const projectName = projectData?.name
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
const assetTaskRunningState = isBatchSubmitting
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
@@ -48,6 +55,72 @@ export default function AssetToolbar({
|
||||
hasOutput: true,
|
||||
})
|
||||
: null
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
const characters = assets?.characters ?? []
|
||||
const locations = assets?.locations ?? []
|
||||
|
||||
const imageEntries: Array<{ filename: string; url: string }> = []
|
||||
|
||||
// 角色图片
|
||||
for (const character of characters) {
|
||||
for (const appearance of character.appearances ?? []) {
|
||||
const url = appearance.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = character.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = appearance.appearanceIndex === 0
|
||||
? `characters/${safeName}.jpg`
|
||||
: `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`
|
||||
imageEntries.push({ filename, url })
|
||||
}
|
||||
}
|
||||
|
||||
// 场景图片:取已选中的那张(或第一张)
|
||||
for (const location of locations) {
|
||||
const selectedImage = location.images?.find((img: { isSelected: boolean; imageUrl: string | null }) => img.isSelected) ?? location.images?.[0]
|
||||
const url = selectedImage?.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = location.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
imageEntries.push({ filename: `locations/${safeName}.jpg`, url })
|
||||
}
|
||||
|
||||
if (imageEntries.length === 0) {
|
||||
alert(t('assetLibrary.downloadEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsDownloading(true)
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
await Promise.all(
|
||||
imageEntries.map(async ({ filename, url }) => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) return
|
||||
const blob = await response.blob()
|
||||
zip.file(filename, blob)
|
||||
} catch {
|
||||
// 单张失败不阻断其他
|
||||
}
|
||||
})
|
||||
)
|
||||
const content = await zip.generateAsync({ type: 'blob' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(content)
|
||||
const safeName = projectName ? projectName.replace(/[/\\:*?"<>|]/g, '_') : 'assets'
|
||||
link.download = `${safeName}_${new Date().toISOString().slice(0, 10)}.zip`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
} catch (error) {
|
||||
_logError('打包下载失败:', error)
|
||||
alert(t('assetLibrary.downloadFailed'))
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-surface p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -106,6 +179,18 @@ export default function AssetToolbar({
|
||||
<AppIcon name="refresh" className="w-4 h-4" />
|
||||
<span>{t("common.refresh")}</span>
|
||||
</button>
|
||||
{/* 打包下载按钮 */}
|
||||
<button
|
||||
onClick={handleDownloadAll}
|
||||
disabled={isDownloading || totalAssets === 0}
|
||||
title={t("toolbar.downloadAll")}
|
||||
className="glass-btn-base glass-btn-secondary flex items-center justify-center w-9 h-9 disabled:opacity-50 disabled:cursor-not-allowed border border-[var(--glass-stroke-base)]"
|
||||
>
|
||||
<AppIcon
|
||||
name={isDownloading ? 'refresh' : 'download'}
|
||||
className={`w-4 h-4 ${isDownloading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,8 @@ import CharacterCardActions from './character-card/CharacterCardActions'
|
||||
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
|
||||
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { AI_EDIT_BUTTON_CLASS, AI_EDIT_ICON_CLASS } from '@/components/ui/ai-edit-style'
|
||||
import AISparklesIcon from '@/components/ui/icons/AISparklesIcon'
|
||||
|
||||
interface CharacterCardProps {
|
||||
character: Character
|
||||
@@ -218,15 +220,18 @@ export default function CharacterCard({
|
||||
prefix={isGroupTaskRunning ? (
|
||||
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
|
||||
) : (
|
||||
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
<>
|
||||
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
|
||||
</>
|
||||
)}
|
||||
suffix={null}
|
||||
suffix={<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{t('image.regenCountSuffix')}</span>}
|
||||
value={generationCount}
|
||||
options={getImageGenerationCountOptions('character')}
|
||||
onValueChange={setGenerationCount}
|
||||
onClick={() => onRegenerate(generationCount)}
|
||||
disabled={isAppearanceTaskRunning || isAnyTaskRunning || uploadImage.isPending}
|
||||
ariaLabel={t('image.selectCount')}
|
||||
ariaLabel={t('image.regenCountAriaLabel')}
|
||||
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
|
||||
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
|
||||
/>
|
||||
@@ -333,11 +338,10 @@ export default function CharacterCard({
|
||||
{!isAppearanceTaskRunning && !isAnyTaskRunning && currentImageUrl && onImageEdit && (
|
||||
<button
|
||||
onClick={() => onImageEdit(character.id, appearance.id, selectedIndex !== null ? selectedIndex : 0)}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center transition-all shadow-sm"
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center transition-all active:scale-95 ${AI_EDIT_BUTTON_CLASS}`}
|
||||
title={t('image.edit')}
|
||||
>
|
||||
<AppIcon name="edit" className="w-4 h-4 text-white" />
|
||||
<AISparklesIcon className={`w-4 h-4 ${AI_EDIT_ICON_CLASS}`} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -190,15 +190,18 @@ export default function LocationCard({
|
||||
prefix={isGroupTaskRunning ? (
|
||||
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
|
||||
) : (
|
||||
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
<>
|
||||
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
|
||||
</>
|
||||
)}
|
||||
suffix={null}
|
||||
suffix={<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{t('image.regenCountSuffix')}</span>}
|
||||
value={generationCount}
|
||||
options={getImageGenerationCountOptions('location')}
|
||||
onValueChange={setGenerationCount}
|
||||
onClick={() => onRegenerate(generationCount)}
|
||||
disabled={isTaskRunning || isAnyTaskRunning || uploadImage.isPending}
|
||||
ariaLabel={t('image.selectCount')}
|
||||
ariaLabel={t('image.regenCountAriaLabel')}
|
||||
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
|
||||
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AppIcon } from '@/components/ui/icons'
|
||||
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
|
||||
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
|
||||
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
||||
import { AI_EDIT_BUTTON_CLASS, AI_EDIT_ICON_CLASS } from '@/components/ui/ai-edit-style'
|
||||
import AISparklesIcon from '@/components/ui/icons/AISparklesIcon'
|
||||
|
||||
interface ImageSectionActionButtonsProps {
|
||||
panelId: string
|
||||
@@ -77,9 +79,10 @@ export default function ImageSectionActionButtons({
|
||||
{imageUrl && (
|
||||
<button
|
||||
onClick={onOpenEditModal}
|
||||
className={`glass-btn-base glass-btn-secondary flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-[10px] transition-all active:scale-95 ${isSubmittingPanelImageTask || isModifying ? 'opacity-75' : ''}`}
|
||||
className={`glass-btn-base h-6 w-6 rounded-full flex items-center justify-center transition-all active:scale-95 ${AI_EDIT_BUTTON_CLASS} ${isSubmittingPanelImageTask || isModifying ? 'opacity-75' : ''}`}
|
||||
title={t('image.editImage')}
|
||||
>
|
||||
<span>{t('image.editImage')}</span>
|
||||
<AISparklesIcon className={`w-2.5 h-2.5 ${AI_EDIT_ICON_CLASS}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { SegmentedControl } from '@/components/ui/SegmentedControl'
|
||||
|
||||
|
||||
|
||||
interface Character {
|
||||
id: string
|
||||
name: string
|
||||
@@ -67,6 +69,8 @@ interface AssetGridProps {
|
||||
onAddCharacter: () => void
|
||||
onAddLocation: () => void
|
||||
onAddVoice: () => void
|
||||
onDownloadAll?: () => void
|
||||
isDownloading?: boolean
|
||||
selectedFolderId: string | null
|
||||
onImageClick?: (url: string) => void
|
||||
onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => void
|
||||
@@ -89,6 +93,8 @@ export function AssetGrid({
|
||||
onAddCharacter,
|
||||
onAddLocation,
|
||||
onAddVoice,
|
||||
onDownloadAll,
|
||||
isDownloading,
|
||||
selectedFolderId: _selectedFolderId,
|
||||
onImageClick,
|
||||
onImageEdit,
|
||||
@@ -192,8 +198,19 @@ export function AssetGrid({
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 右侧新建按钮 */}
|
||||
{/* 右侧操作按钮 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{onDownloadAll && (
|
||||
<button
|
||||
onClick={onDownloadAll}
|
||||
disabled={isDownloading || isEmpty}
|
||||
title={t('downloadAllTitle')}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<AppIcon name={isDownloading ? 'refresh' : 'download'} className={`w-4 h-4 ${isDownloading ? 'animate-spin' : ''}`} />
|
||||
<span>{isDownloading ? t('downloading') : t('downloadAll')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onAddCharacter}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import { logError as _ulogError } from '@/lib/logging/core'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
import JSZip from 'jszip'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
@@ -75,6 +76,8 @@ export default function AssetHubPage() {
|
||||
// 音色库弹窗状态
|
||||
const [showAddVoice, setShowAddVoice] = useState(false)
|
||||
const [voicePickerCharacterId, setVoicePickerCharacterId] = useState<string | null>(null)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
|
||||
// 编辑角色弹窗状态
|
||||
const [characterEditModal, setCharacterEditModal] = useState<{
|
||||
@@ -340,6 +343,74 @@ export default function AssetHubPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 打包下载所有图片资产
|
||||
const handleDownloadAll = async () => {
|
||||
// 收集所有有效图片
|
||||
const imageEntries: Array<{ filename: string; url: string }> = []
|
||||
|
||||
// 角色图片:每个角色每个外貌的当前选中图
|
||||
for (const character of characters) {
|
||||
for (const appearance of character.appearances) {
|
||||
const url = appearance.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = character.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = appearance.appearanceIndex === 0
|
||||
? `characters/${safeName}.jpg`
|
||||
: `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`
|
||||
imageEntries.push({ filename, url })
|
||||
}
|
||||
}
|
||||
|
||||
// 场景图片:每个场景的选中图
|
||||
for (const location of locations) {
|
||||
for (const image of location.images) {
|
||||
const url = image.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = location.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = location.images.length <= 1
|
||||
? `locations/${safeName}.jpg`
|
||||
: `locations/${safeName}_${image.imageIndex + 1}.jpg`
|
||||
imageEntries.push({ filename, url })
|
||||
}
|
||||
}
|
||||
|
||||
if (imageEntries.length === 0) {
|
||||
alert(t('downloadEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsDownloading(true)
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
// 并发 fetch 所有图片
|
||||
await Promise.all(
|
||||
imageEntries.map(async ({ filename, url }) => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) return
|
||||
const blob = await response.blob()
|
||||
zip.file(filename, blob)
|
||||
} catch {
|
||||
// 单张图片失败不阻断整个流程
|
||||
}
|
||||
})
|
||||
)
|
||||
const content = await zip.generateAsync({ type: 'blob' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(content)
|
||||
link.download = `asset-hub_${new Date().toISOString().slice(0, 10)}.zip`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
} catch (error) {
|
||||
_ulogError('打包下载失败:', error)
|
||||
alert(t('downloadFailed'))
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-page min-h-screen">
|
||||
<Navbar />
|
||||
@@ -382,6 +453,8 @@ export default function AssetHubPage() {
|
||||
onAddCharacter={() => setShowAddCharacter(true)}
|
||||
onAddLocation={() => setShowAddLocation(true)}
|
||||
onAddVoice={() => setShowAddVoice(true)}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={isDownloading}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onImageClick={setPreviewImage}
|
||||
onImageEdit={handleOpenImageEdit}
|
||||
|
||||
@@ -22,10 +22,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const where: Record<string, unknown> = { userId: session.user.id }
|
||||
|
||||
// 如果有搜索关键词,搜索名称和描述
|
||||
// 注意:SQLite 不支持 mode: 'insensitive',但 SQLite 的 LIKE 默认即大小写不敏感(ASCII 范围)
|
||||
if (search.trim()) {
|
||||
where.OR = [
|
||||
{ name: { contains: search.trim(), mode: 'insensitive' } },
|
||||
{ description: { contains: search.trim(), mode: 'insensitive' } }
|
||||
{ name: { contains: search.trim() } },
|
||||
{ description: { contains: search.trim() } }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -77,7 +78,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
select: {
|
||||
episodes: true,
|
||||
characters: true,
|
||||
locations: true}
|
||||
locations: true
|
||||
}
|
||||
},
|
||||
episodes: {
|
||||
orderBy: { episodeNumber: 'asc' },
|
||||
@@ -98,7 +100,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
select: {
|
||||
imageUrl: true,
|
||||
videoUrl: true}
|
||||
videoUrl: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +139,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
images: imageCount,
|
||||
videos: videoCount,
|
||||
panels: panelCount,
|
||||
firstEpisodePreview: preview}]
|
||||
firstEpisodePreview: preview
|
||||
}]
|
||||
})
|
||||
)
|
||||
|
||||
@@ -144,7 +148,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const projectsWithStats = projects.map(project => ({
|
||||
...project,
|
||||
totalCost: costMap.get(project.id) ?? 0,
|
||||
stats: statsMap.get(project.id) ?? { episodes: 0, images: 0, videos: 0, panels: 0, firstEpisodePreview: null }}))
|
||||
stats: statsMap.get(project.id) ?? { episodes: 0, images: 0, videos: 0, panels: 0, firstEpisodePreview: null }
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
projects: projectsWithStats,
|
||||
|
||||
3
src/components/ui/ai-edit-style.ts
Normal file
3
src/components/ui/ai-edit-style.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const AI_EDIT_BUTTON_CLASS = 'bg-[var(--glass-bg-surface-strong)] border border-[var(--glass-stroke-base)] shadow-sm hover:bg-[var(--glass-bg-surface)]'
|
||||
|
||||
export const AI_EDIT_ICON_CLASS = ''
|
||||
22
src/components/ui/icons/AISparklesIcon.tsx
Normal file
22
src/components/ui/icons/AISparklesIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { useId } from 'react'
|
||||
|
||||
interface AISparklesIconProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function AISparklesIcon({ className }: AISparklesIconProps) {
|
||||
const gradientId = useId().replace(/:/g, '')
|
||||
|
||||
return (
|
||||
<Sparkles className={className} stroke={`url(#${gradientId})`}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#06b6d4" />
|
||||
<stop offset="52%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#8b5cf6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</Sparkles>
|
||||
)
|
||||
}
|
||||
@@ -87,6 +87,13 @@ export const ERROR_CATALOG = {
|
||||
userMessageKey: 'errors.MODEL_NOT_REGISTERED',
|
||||
defaultMessage: 'Model is not registered',
|
||||
},
|
||||
MODEL_NOT_CONFIGURED: {
|
||||
httpStatus: 400,
|
||||
retryable: false,
|
||||
category: ERROR_CATEGORY.PROVIDER,
|
||||
userMessageKey: 'errors.MODEL_NOT_CONFIGURED',
|
||||
defaultMessage: 'Model is not configured. Please add a model in the settings first.',
|
||||
},
|
||||
QUOTA_EXCEEDED: {
|
||||
httpStatus: 429,
|
||||
retryable: true,
|
||||
|
||||
@@ -60,6 +60,32 @@ function isModelNotRegisteredMessage(message: string): boolean {
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* MODEL_NOT_CONFIGURED: 用户未配置对应类型的模型
|
||||
* 覆盖形式:model_not_found / model_not_configured / no xxx model is enabled
|
||||
*/
|
||||
function isModelNotConfiguredMessage(message: string): boolean {
|
||||
return containsAny(message, [
|
||||
'model_not_found',
|
||||
'model_not_configured',
|
||||
'is not enabled for image',
|
||||
'is not enabled for video',
|
||||
'is not enabled for audio',
|
||||
'is not enabled for lipsync',
|
||||
'is not enabled for llm',
|
||||
'no image model is enabled',
|
||||
'no video model is enabled',
|
||||
'no audio model is enabled',
|
||||
'no lipsync model is enabled',
|
||||
'no llm model is enabled',
|
||||
'multiple image models are enabled',
|
||||
'multiple video models are enabled',
|
||||
'multiple audio models are enabled',
|
||||
'multiple lipsync models are enabled',
|
||||
'multiple llm models are enabled',
|
||||
])
|
||||
}
|
||||
|
||||
function isEmptyResponseMessage(message: string): boolean {
|
||||
return containsAny(message, [
|
||||
'channel:empty_response',
|
||||
@@ -139,10 +165,13 @@ function inferCodeFromMessage(message: string): UnifiedErrorCode | null {
|
||||
|
||||
if (isModelNotOpenMessage(message)) return 'MODEL_NOT_OPEN'
|
||||
if (isModelNotRegisteredMessage(message)) return 'MODEL_NOT_REGISTERED'
|
||||
if (isModelNotConfiguredMessage(message)) return 'MODEL_NOT_CONFIGURED'
|
||||
if (isEmptyResponseMessage(message)) return 'EMPTY_RESPONSE'
|
||||
if (isVideoApiFormatUnsupportedMessage(message)) return 'VIDEO_API_FORMAT_UNSUPPORTED'
|
||||
if (containsAny(message, ['task cancelled', 'canceled by user', 'cancelled by user', '任务已取消'])) return 'CONFLICT'
|
||||
if (containsAny(message, ['unauthorized', 'not authenticated', 'need login', '401'])) return 'UNAUTHORIZED'
|
||||
// AccountOverdueError(ARK 欠费 403)必须在 FORBIDDEN 之前检查
|
||||
if (containsAny(message, ['accountoverdueerror', 'overdue balance', 'overdue', 'account has an overdue'])) return 'INSUFFICIENT_BALANCE'
|
||||
if (containsAny(message, ['forbidden', 'permission denied', '403'])) return 'FORBIDDEN'
|
||||
if (containsAny(message, ['not found', '不存在', 'missing record'])) return 'NOT_FOUND'
|
||||
if (containsAny(message, ['invalid', 'missing', 'required', 'bad request', 'fieldinvalid'])) return 'INVALID_PARAMS'
|
||||
@@ -225,14 +254,22 @@ export function normalizeAnyError(input: unknown, options: NormalizeOptions = {}
|
||||
if (isModelNotRegisteredMessage(lowerMessage)) {
|
||||
return buildNormalizedError('MODEL_NOT_REGISTERED', message, options.details, provider)
|
||||
}
|
||||
|
||||
if (isModelNotConfiguredMessage(lowerMessage)) {
|
||||
return buildNormalizedError('MODEL_NOT_CONFIGURED', message, options.details, provider)
|
||||
}
|
||||
if (isEmptyResponseMessage(lowerMessage)) {
|
||||
return buildNormalizedError('EMPTY_RESPONSE', message, options.details, provider)
|
||||
}
|
||||
|
||||
if (typeof errorLike.status === 'number') {
|
||||
if (errorLike.status === 401) return buildNormalizedError('UNAUTHORIZED', message, options.details, provider)
|
||||
if (errorLike.status === 403) return buildNormalizedError('FORBIDDEN', message, options.details, provider)
|
||||
// 403 可能是欠费(AccountOverdueError),需优先检查消息内容再决定错误码
|
||||
if (errorLike.status === 403) {
|
||||
if (containsAny(lowerMessage, ['accountoverdueerror', 'overdue balance', 'overdue', 'account has an overdue'])) {
|
||||
return buildNormalizedError('INSUFFICIENT_BALANCE', message, options.details, provider)
|
||||
}
|
||||
return buildNormalizedError('FORBIDDEN', message, options.details, provider)
|
||||
}
|
||||
if (errorLike.status === 404) return buildNormalizedError('NOT_FOUND', message, options.details, provider)
|
||||
if (errorLike.status === 409) return buildNormalizedError('CONFLICT', message, options.details, provider)
|
||||
if (errorLike.status === 422) return buildNormalizedError('SENSITIVE_CONTENT', message, options.details, provider)
|
||||
|
||||
@@ -12,6 +12,7 @@ export const USER_ERROR_MESSAGES_ZH: Record<UnifiedErrorCode, string> = {
|
||||
RATE_LIMIT: '请求过于频繁,请稍后重试。',
|
||||
MODEL_NOT_OPEN: '模型权限未开通。请前往 https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=model ,在模型管理页面点击右上角「一键开通所有模型」。',
|
||||
MODEL_NOT_REGISTERED: '模型尚未注册,请先完成模型配置后再试。',
|
||||
MODEL_NOT_CONFIGURED: '未配置可用模型,请先前往设置页面添加对应类型的模型后再试。',
|
||||
QUOTA_EXCEEDED: '额度已用尽,请稍后再试。',
|
||||
EXTERNAL_ERROR: '外部服务暂时不可用,请稍后重试。',
|
||||
NETWORK_ERROR: '网络异常,请稍后重试。',
|
||||
|
||||
@@ -248,7 +248,12 @@ export class ArkImageGenerator extends BaseImageGenerator {
|
||||
logPrefix: '[ARK Image]'
|
||||
})
|
||||
|
||||
const imageUrl = arkData.data?.[0]?.url
|
||||
const imageUrls = Array.isArray(arkData.data)
|
||||
? arkData.data
|
||||
.map((item) => (typeof item?.url === 'string' ? item.url.trim() : ''))
|
||||
.filter((item) => item.length > 0)
|
||||
: []
|
||||
const imageUrl = imageUrls[0]
|
||||
|
||||
if (!imageUrl) {
|
||||
throw new Error('ARK 未返回图片 URL')
|
||||
@@ -256,7 +261,8 @@ export class ArkImageGenerator extends BaseImageGenerator {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
imageUrl
|
||||
imageUrl,
|
||||
...(imageUrls.length > 1 ? { imageUrls } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ export interface GenerateOptions {
|
||||
|
||||
export interface GenerateResult {
|
||||
success: boolean
|
||||
imageUrl?: string // 图片 URL
|
||||
imageBase64?: string // 图片 base64
|
||||
imageUrl?: string // 图片 URL(单图,向后兼容)
|
||||
imageUrls?: string[] // 多图 URL 列表(接口返回多张时填充)
|
||||
imageBase64?: string // 图片 base64(单图,向后兼容)
|
||||
videoUrl?: string // 视频 URL
|
||||
audioUrl?: string // 音频 URL
|
||||
error?: string // 错误信息
|
||||
|
||||
@@ -127,6 +127,8 @@ async function appendLineAsync(filePath: string, line: string): Promise<void> {
|
||||
const dir = modules.path.dirname(filePath)
|
||||
modules.fs.mkdirSync(dir, { recursive: true })
|
||||
modules.fs.appendFileSync(filePath, line + '\n')
|
||||
// 写入后异步检查是否需要清理(fire-and-forget)
|
||||
void maybeCleanupProjectLog(filePath)
|
||||
} catch (err) {
|
||||
// Do not propagate, but surface so file-write failures are visible.
|
||||
console.error('[file-writer] Failed to write log line to', filePath, err)
|
||||
@@ -138,6 +140,50 @@ function buildLogFilePath(modules: NodeModules, prefix: string, projectName: str
|
||||
return modules.path.join(modules.cwd, 'logs', fileName)
|
||||
}
|
||||
|
||||
// ─── 24h cleanup helpers ─────────────────────────────────────────────
|
||||
|
||||
const PROJECT_LOG_MAX_BYTES = 2 * 1024 * 1024 // 2 MB 触发清理
|
||||
const LOG_RETENTION_MS = 24 * 60 * 60 * 1000 // 保留 24 小时
|
||||
|
||||
/**
|
||||
* 从日志内容中过滤掉 24 小时前的行。
|
||||
* 每行是 JSON,通过 "ts" 字段判断时间。
|
||||
*/
|
||||
function filterRecentLines(content: string): string {
|
||||
const cutoff = Date.now() - LOG_RETENTION_MS
|
||||
const lines = content.split('\n')
|
||||
const kept = lines.filter((line) => {
|
||||
if (!line.trim()) return false
|
||||
try {
|
||||
const parsed = JSON.parse(line) as { ts?: string }
|
||||
if (parsed.ts) {
|
||||
return new Date(parsed.ts).getTime() >= cutoff
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 行(如分隔符)保留
|
||||
}
|
||||
return true
|
||||
})
|
||||
return kept.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 若项目日志文件超过阈值,清理 24 小时前的内容。
|
||||
*/
|
||||
async function maybeCleanupProjectLog(filePath: string): Promise<void> {
|
||||
const modules = await getNodeModules()
|
||||
if (!modules) return
|
||||
try {
|
||||
const stat = modules.fs.statSync(filePath)
|
||||
if (stat.size <= PROJECT_LOG_MAX_BYTES) return
|
||||
const content = modules.fs.readFileSync(filePath, 'utf-8')
|
||||
const cleaned = filterRecentLines(content)
|
||||
modules.fs.writeFileSync(filePath, cleaned + '\n')
|
||||
} catch {
|
||||
// 文件不存在或读写失败,忽略
|
||||
}
|
||||
}
|
||||
|
||||
// ─── prefix mapping ──────────────────────────────────────────────────
|
||||
|
||||
function getPrefix(module?: string): string {
|
||||
@@ -318,3 +364,30 @@ export async function readAllLogs(): Promise<string> {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 清理所有项目日志文件中 24 小时前的内容。
|
||||
* 供 watchdog 定期调用(建议每小时一次)。
|
||||
*/
|
||||
export async function cleanupAllProjectLogs(): Promise<void> {
|
||||
if (isEdgeOrBrowser()) return
|
||||
const modules = await getNodeModules()
|
||||
if (!modules) return
|
||||
|
||||
const logsDir = modules.path.join(modules.cwd, 'logs')
|
||||
try {
|
||||
const files = modules.fs.readdirSync(logsDir)
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.log') || f === 'app.log') continue
|
||||
const filePath = modules.path.join(logsDir, f)
|
||||
try {
|
||||
const content = modules.fs.readFileSync(filePath, 'utf-8')
|
||||
const cleaned = filterRecentLines(content)
|
||||
modules.fs.writeFileSync(filePath, cleaned + '\n')
|
||||
} catch {
|
||||
// 单个文件失败不影响其他
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// logs 目录不存在等情况,忽略
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,23 +109,43 @@ function toMimeFromOutputFormat(outputFormat: string | undefined): string {
|
||||
return 'image/png'
|
||||
}
|
||||
|
||||
function readFirstImagePayload(response: unknown): { b64Json: string | null; url: string | null } {
|
||||
interface ImagePayloads {
|
||||
/** 第一张图的 base64(向后兼容) */
|
||||
b64Json: string | null
|
||||
/** 第一张图的 URL(向后兼容) */
|
||||
url: string | null
|
||||
/** 所有图的 URL 列表(接口返回多张时有值) */
|
||||
urls: string[]
|
||||
}
|
||||
|
||||
function readAllImagePayloads(response: unknown): ImagePayloads {
|
||||
if (typeof response !== 'object' || response === null) {
|
||||
return { b64Json: null, url: null }
|
||||
return { b64Json: null, url: null, urls: [] }
|
||||
}
|
||||
const data = (response as { data?: unknown }).data
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return { b64Json: null, url: null }
|
||||
return { b64Json: null, url: null, urls: [] }
|
||||
}
|
||||
const first = data[0]
|
||||
if (typeof first !== 'object' || first === null) {
|
||||
return { b64Json: null, url: null }
|
||||
|
||||
const urls: string[] = []
|
||||
let firstB64: string | null = null
|
||||
|
||||
for (const item of data) {
|
||||
if (typeof item !== 'object' || item === null) continue
|
||||
const rawUrl = (item as { url?: unknown }).url
|
||||
const rawB64 = (item as { b64_json?: unknown }).b64_json
|
||||
if (typeof rawUrl === 'string' && rawUrl.trim()) {
|
||||
urls.push(rawUrl.trim())
|
||||
}
|
||||
if (firstB64 === null && typeof rawB64 === 'string' && rawB64.trim()) {
|
||||
firstB64 = rawB64.trim()
|
||||
}
|
||||
}
|
||||
const rawB64 = (first as { b64_json?: unknown }).b64_json
|
||||
const rawUrl = (first as { url?: unknown }).url
|
||||
|
||||
return {
|
||||
b64Json: typeof rawB64 === 'string' ? rawB64 : null,
|
||||
url: typeof rawUrl === 'string' ? rawUrl : null,
|
||||
b64Json: firstB64,
|
||||
url: urls[0] ?? null,
|
||||
urls,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +181,7 @@ export async function generateImageViaOpenAICompat(request: OpenAICompatImageReq
|
||||
...(size ? { size } : {}),
|
||||
} as unknown as Parameters<typeof client.images.edit>[0])
|
||||
|
||||
const imagePayload = readFirstImagePayload(response)
|
||||
const imagePayload = readAllImagePayloads(response)
|
||||
const imageBase64 = imagePayload.b64Json
|
||||
if (typeof imageBase64 === 'string' && imageBase64.trim().length > 0) {
|
||||
const mimeType = toMimeFromOutputFormat(outputFormat)
|
||||
@@ -173,7 +193,11 @@ export async function generateImageViaOpenAICompat(request: OpenAICompatImageReq
|
||||
}
|
||||
const imageUrl = imagePayload.url
|
||||
if (typeof imageUrl === 'string' && imageUrl.trim().length > 0) {
|
||||
return { success: true, imageUrl }
|
||||
return {
|
||||
success: true,
|
||||
imageUrl,
|
||||
...(imagePayload.urls.length > 1 ? { imageUrls: imagePayload.urls } : {}),
|
||||
}
|
||||
}
|
||||
throw new Error('OPENAI_COMPAT_IMAGE_EMPTY_RESPONSE: no image data returned')
|
||||
}
|
||||
@@ -187,7 +211,7 @@ export async function generateImageViaOpenAICompat(request: OpenAICompatImageReq
|
||||
...(size ? { size } : {}),
|
||||
} as unknown as Parameters<typeof client.images.generate>[0])
|
||||
|
||||
const imagePayload = readFirstImagePayload(response)
|
||||
const imagePayload = readAllImagePayloads(response)
|
||||
const imageBase64 = imagePayload.b64Json
|
||||
if (typeof imageBase64 === 'string' && imageBase64.trim().length > 0) {
|
||||
const mimeType = toMimeFromOutputFormat(outputFormat)
|
||||
@@ -199,7 +223,11 @@ export async function generateImageViaOpenAICompat(request: OpenAICompatImageReq
|
||||
}
|
||||
const imageUrl = imagePayload.url
|
||||
if (typeof imageUrl === 'string' && imageUrl.trim().length > 0) {
|
||||
return { success: true, imageUrl }
|
||||
return {
|
||||
success: true,
|
||||
imageUrl,
|
||||
...(imagePayload.urls.length > 1 ? { imageUrls: imagePayload.urls } : {}),
|
||||
}
|
||||
}
|
||||
throw new Error('OPENAI_COMPAT_IMAGE_EMPTY_RESPONSE: no image data returned')
|
||||
}
|
||||
|
||||
@@ -36,6 +36,23 @@ function resolveModelRef(request: OpenAICompatImageRequest): string {
|
||||
throw new Error('OPENAI_COMPAT_IMAGE_MODEL_REF_REQUIRED')
|
||||
}
|
||||
|
||||
function readTemplateOutputUrls(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const urls: string[] = []
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string' && item.trim()) {
|
||||
urls.push(item.trim())
|
||||
continue
|
||||
}
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
|
||||
const url = (item as { url?: unknown }).url
|
||||
if (typeof url === 'string' && url.trim()) {
|
||||
urls.push(url.trim())
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
export async function generateImageViaOpenAICompatTemplate(
|
||||
request: OpenAICompatImageRequest,
|
||||
): Promise<GenerateResult> {
|
||||
@@ -82,6 +99,18 @@ export async function generateImageViaOpenAICompatTemplate(
|
||||
}
|
||||
|
||||
if (request.template.mode === 'sync') {
|
||||
const outputUrls = readTemplateOutputUrls(
|
||||
readJsonPath(payload, request.template.response.outputUrlsPath),
|
||||
)
|
||||
if (outputUrls.length > 0) {
|
||||
const first = outputUrls[0]
|
||||
return {
|
||||
success: true,
|
||||
imageUrl: first,
|
||||
...(outputUrls.length > 1 ? { imageUrls: outputUrls } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const outputUrl = readJsonPath(payload, request.template.response.outputUrlPath)
|
||||
if (typeof outputUrl === 'string' && outputUrl.trim().length > 0) {
|
||||
return {
|
||||
@@ -89,25 +118,6 @@ export async function generateImageViaOpenAICompatTemplate(
|
||||
imageUrl: outputUrl.trim(),
|
||||
}
|
||||
}
|
||||
const outputUrls = readJsonPath(payload, request.template.response.outputUrlsPath)
|
||||
if (Array.isArray(outputUrls) && outputUrls.length > 0) {
|
||||
const first = outputUrls[0]
|
||||
if (typeof first === 'string' && first.trim()) {
|
||||
return {
|
||||
success: true,
|
||||
imageUrl: first.trim(),
|
||||
}
|
||||
}
|
||||
if (first && typeof first === 'object' && !Array.isArray(first)) {
|
||||
const firstUrl = (first as { url?: unknown }).url
|
||||
if (typeof firstUrl === 'string' && firstUrl.trim()) {
|
||||
return {
|
||||
success: true,
|
||||
imageUrl: firstUrl.trim(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('OPENAI_COMPAT_IMAGE_TEMPLATE_OUTPUT_NOT_FOUND')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { safeParseJsonArray } from '@/lib/json-repair'
|
||||
import { safeParseJson, safeParseJsonArray } from '@/lib/json-repair'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { StoryboardPanel } from '@/lib/storyboard-phases'
|
||||
|
||||
@@ -52,6 +52,10 @@ function parsePanelCharacters(raw: string | null): string[] {
|
||||
export function parseVoiceLinesJson(responseText: string): JsonRecord[] {
|
||||
const rows = safeParseJsonArray(responseText)
|
||||
if (rows.length === 0) {
|
||||
const raw = safeParseJson(responseText)
|
||||
if (Array.isArray(raw) && raw.length === 0) {
|
||||
return []
|
||||
}
|
||||
throw new Error('voice_analyze: invalid payload')
|
||||
}
|
||||
return rows as JsonRecord[]
|
||||
|
||||
@@ -590,14 +590,22 @@ export async function handleScriptToStoryboardTask(job: Job<TaskJobData>) {
|
||||
const nextLineIndexes = voiceLineRows
|
||||
.map((row) => (typeof row.lineIndex === 'number' && Number.isFinite(row.lineIndex) ? Math.floor(row.lineIndex) : -1))
|
||||
.filter((value) => value > 0)
|
||||
await voiceLineModel.deleteMany({
|
||||
where: {
|
||||
episodeId,
|
||||
lineIndex: {
|
||||
notIn: nextLineIndexes.length > 0 ? nextLineIndexes : [0],
|
||||
if (nextLineIndexes.length === 0) {
|
||||
await voiceLineModel.deleteMany({
|
||||
where: {
|
||||
episodeId,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
} else {
|
||||
await voiceLineModel.deleteMany({
|
||||
where: {
|
||||
episodeId,
|
||||
lineIndex: {
|
||||
notIn: nextLineIndexes,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return created
|
||||
}, { timeout: 15000 })
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { safeParseJsonArray } from '@/lib/json-repair'
|
||||
import { safeParseJson, safeParseJsonArray } from '@/lib/json-repair'
|
||||
|
||||
export interface StoryboardPanelLike {
|
||||
panelIndex: number
|
||||
@@ -78,6 +78,10 @@ export function buildStoryboardJson(storyboards: StoryboardLike[]): string {
|
||||
export function parseVoiceLinesJson(responseText: string): VoiceLinePayload[] {
|
||||
const parsed = safeParseJsonArray(responseText)
|
||||
if (parsed.length === 0) {
|
||||
const raw = safeParseJson(responseText)
|
||||
if (Array.isArray(raw) && raw.length === 0) {
|
||||
return []
|
||||
}
|
||||
throw new Error('Invalid voice lines data structure')
|
||||
}
|
||||
const voiceLines = parsed
|
||||
|
||||
@@ -308,14 +308,22 @@ export async function handleVoiceAnalyzeTask(job: Job<TaskJobData>) {
|
||||
}
|
||||
|
||||
const incomingLineIndexes = new Set<number>(voiceLinesData.map((item) => item.lineIndex))
|
||||
await voiceLineModel.deleteMany({
|
||||
where: {
|
||||
episodeId,
|
||||
lineIndex: {
|
||||
notIn: Array.from(incomingLineIndexes),
|
||||
if (incomingLineIndexes.size === 0) {
|
||||
await voiceLineModel.deleteMany({
|
||||
where: {
|
||||
episodeId,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
} else {
|
||||
await voiceLineModel.deleteMany({
|
||||
where: {
|
||||
episodeId,
|
||||
lineIndex: {
|
||||
notIn: Array.from(incomingLineIndexes),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
@@ -276,6 +276,130 @@ export async function resolveImageSourceFromGeneration(
|
||||
return polled.url
|
||||
}
|
||||
|
||||
/**
|
||||
* 多图版本:一次生成调用返回所有图片 URL 数组。
|
||||
*
|
||||
* - 接口返回多张(result.imageUrls)→ 返回完整列表
|
||||
* - 接口只返回单张(result.imageUrl / result.imageBase64)→ 封装成 [url] 保持接口一致
|
||||
* - 异步任务:轮询结果只有一个 URL,封装成 [url]
|
||||
*
|
||||
* 现有代码请继续使用 resolveImageSourceFromGeneration(取第一张),
|
||||
* 只有需要利用多图结果时才调用此函数。
|
||||
*/
|
||||
export async function resolveImageSourcesFromGeneration(
|
||||
job: Job<TaskJobData>,
|
||||
params: {
|
||||
userId: string
|
||||
modelId: string
|
||||
prompt: string
|
||||
options?: {
|
||||
referenceImages?: string[]
|
||||
aspectRatio?: string
|
||||
resolution?: string
|
||||
size?: string
|
||||
provider?: string
|
||||
}
|
||||
allowTaskExternalIdResume?: boolean
|
||||
pollProgress?: { start?: number; end?: number }
|
||||
},
|
||||
): Promise<string[]> {
|
||||
const logger = scopedWorkerUtilLogger(job, 'worker.image.generate_sources')
|
||||
const startedAt = Date.now()
|
||||
const allowTaskExternalIdResume = params.allowTaskExternalIdResume !== false
|
||||
|
||||
// 服务重启续接:若 DB 中已有 externalId,直接恢复轮询(异步只有一张)
|
||||
if (allowTaskExternalIdResume) {
|
||||
const resumeExternalId = await getTaskExistingExternalId(job.data.taskId)
|
||||
if (resumeExternalId) {
|
||||
logger.info({
|
||||
message: 'image sources generation resumed from existing external id',
|
||||
details: { externalId: resumeExternalId },
|
||||
})
|
||||
const polled = await waitExternalResult(job, resumeExternalId, params.userId, {
|
||||
progressStart: params.pollProgress?.start ?? 40,
|
||||
progressEnd: params.pollProgress?.end ?? 92,
|
||||
})
|
||||
return [polled.url]
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({
|
||||
message: 'image sources generation started',
|
||||
provider: params.options?.provider || undefined,
|
||||
details: { model: params.modelId },
|
||||
})
|
||||
|
||||
const runtimeSelections: Record<string, string | number | boolean> = {}
|
||||
if (typeof params.options?.resolution === 'string') {
|
||||
runtimeSelections.resolution = params.options.resolution
|
||||
}
|
||||
|
||||
const capabilityOptions = await resolveProjectModelCapabilityGenerationOptions({
|
||||
projectId: job.data.projectId,
|
||||
userId: params.userId,
|
||||
modelType: 'image',
|
||||
modelKey: params.modelId,
|
||||
runtimeSelections,
|
||||
})
|
||||
|
||||
const result = await withLogContext(
|
||||
{ projectId: job.data.projectId, taskId: job.data.taskId, userId: params.userId },
|
||||
() => generateImage(params.userId, params.modelId, params.prompt, {
|
||||
...params.options,
|
||||
...capabilityOptions,
|
||||
}),
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Image generation failed')
|
||||
}
|
||||
|
||||
// 优先使用多图列表
|
||||
if (result.imageUrls && result.imageUrls.length > 0) {
|
||||
logger.info({
|
||||
message: 'image sources generation completed (multi-image)',
|
||||
provider: params.options?.provider || undefined,
|
||||
durationMs: Date.now() - startedAt,
|
||||
details: { count: result.imageUrls.length },
|
||||
})
|
||||
return result.imageUrls
|
||||
}
|
||||
|
||||
if (result.imageUrl) {
|
||||
logger.info({
|
||||
message: 'image sources generation completed (single url)',
|
||||
provider: params.options?.provider || undefined,
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
return [result.imageUrl]
|
||||
}
|
||||
|
||||
if (result.imageBase64) {
|
||||
logger.info({
|
||||
message: 'image sources generation completed (base64)',
|
||||
provider: params.options?.provider || undefined,
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
return [`data:image/png;base64,${result.imageBase64}`]
|
||||
}
|
||||
|
||||
const externalId = normalizeExternalId(result, 'IMAGE')
|
||||
if (!externalId) {
|
||||
throw new Error('Image generation returned no image and no external id')
|
||||
}
|
||||
|
||||
const polled = await waitExternalResult(job, externalId, params.userId, {
|
||||
progressStart: params.pollProgress?.start ?? 40,
|
||||
progressEnd: params.pollProgress?.end ?? 92,
|
||||
})
|
||||
logger.info({
|
||||
message: 'image sources generation completed (async)',
|
||||
provider: params.options?.provider || undefined,
|
||||
durationMs: Date.now() - startedAt,
|
||||
details: { externalId },
|
||||
})
|
||||
return [polled.url]
|
||||
}
|
||||
|
||||
export async function resolveVideoSourceFromGeneration(
|
||||
job: Job<TaskJobData>,
|
||||
params: {
|
||||
|
||||
Reference in New Issue
Block a user