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:
saturn
2026-03-13 17:37:52 +08:00
parent be1853534a
commit eec27fbabf
41 changed files with 977 additions and 187 deletions

View File

@@ -1,6 +1,7 @@
# ==================== 数据库 ====================
# Docker 模式下无需修改,docker-compose.yml 会自动覆盖
DATABASE_URL="mysql://root:waoowaoo123@localhost:3306/waoowaoo"
# 本地开发模式:docker-compose.yml 将 MySQL 映射到宿主机的 13306 端口
# Docker 容器模式docker-compose.yml 会自动覆盖此配置
DATABASE_URL="mysql://root:waoowaoo123@localhost:13306/waoowaoo"
# ==================== 存储 ====================
# minio: S3 兼容对象存储(默认)
@@ -9,7 +10,8 @@ DATABASE_URL="mysql://root:waoowaoo123@localhost:3306/waoowaoo"
STORAGE_TYPE=minio
# MinIO / S3 兼容存储配置
MINIO_ENDPOINT=http://localhost:9000
# 本地开发模式docker-compose.yml 将 MinIO 映射到宿主机的 19000 端口
MINIO_ENDPOINT=http://localhost:19000
MINIO_REGION=us-east-1
MINIO_BUCKET=waoowaoo
MINIO_ACCESS_KEY=minioadmin
@@ -23,18 +25,21 @@ MINIO_FORCE_PATH_STYLE=true
# COS_REGION=
# ==================== 认证 ====================
NEXTAUTH_URL=https://localhost
# 本地开发模式(方式三):使用 http://localhost:3000
# Docker 容器模式(方式一、二):改为 https://localhost配合 Caddy或 http://localhost:13000
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=please-change-this-to-a-random-string
# ==================== 内部密钥 ====================
CRON_SECRET=please-change-this-cron-secret
INTERNAL_TASK_TOKEN=please-change-this-task-token
API_ENCRYPTION_KEY=please-change-this-encryption-key
API_ENCRYPTION_KEY=waoowaoo-opensource-fixed-key-2026
# ==================== Redis ====================
# Docker 模式下无需修改,docker-compose.yml 会自动覆盖
# 本地开发模式:docker-compose.yml 将 Redis 映射到宿主机的 16379 端口
# Docker 容器模式docker-compose.yml 会自动覆盖此配置
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PORT=16379
REDIS_USERNAME=
REDIS_PASSWORD=
REDIS_TLS=

View File

@@ -81,18 +81,28 @@ docker compose down && docker compose up -d --build
```bash
git clone https://github.com/saturndec/waoowaoo.git
cd waoowaoo
# 复制环境变量配置文件(必须在 npm install 之前完成)
cp .env.example .env
# ⚠️ 编辑 .env填入你的 AI API KeyNEXTAUTH_URL 默认已是 http://localhost:3000无需修改
npm install
# 只启动基础设施
# 注意docker-compose.yml 将服务映射到非标准端口,.env.example 已按此预设
mysql:13306 redis:16379 minio:19000
docker compose up mysql redis minio -d
# 运行数据库迁移
# 初始化数据库表结构(首次必须执行,跳过会导致启动后报错)
npx prisma db push
# 启动开发服务器
npm run dev
```
> [!WARNING]
> 跳过 `npx prisma db push` 会导致所有数据库表不存在,启动后报错 `The table 'tasks' does not exist`。请务必先运行此命令再启动开发服务器。
---
访问 [http://localhost:13000](http://localhost:13000)(方式一、二)或 [http://localhost:3000](http://localhost:3000)(方式三)开始使用!

View File

@@ -73,6 +73,11 @@ docker compose down && docker compose up -d --build
```bash
git clone https://github.com/saturndec/waoowaoo.git
cd waoowaoo
# Copy environment config (must be done before npm install)
cp .env.example .env
# ⚠️ Edit .env to fill in your AI API Keys (NEXTAUTH_URL defaults to http://localhost:3000, no change needed)
npm install
# Start infrastructure only

View File

@@ -34,5 +34,6 @@ Rules:
4. Match panel by order + speaker consistency + semantic relevance.
5. If no reliable panel match exists, set "matchedPanel": null.
6. Use canonical names from character library when possible.
7. Return strict JSON only, no markdown.
8. ⚠️ JSON SAFETY: All quotation marks in dialogue (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values.
7. If there is no spoken dialogue that should be voiced, return [].
8. Return strict JSON only, no markdown.
9. ⚠️ JSON SAFETY: All quotation marks in dialogue (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values.

View File

@@ -28,8 +28,10 @@
- 动作描写(描述角色的动作)
- 场景描述(描述环境、画面)
- 章节标题
- 明确设定为无语言、默片、纯画面表达的内容
⚠️ 判断标准:这句话是否需要有人"说出来"?如果只是描述画面动作,不要提取。
⚠️ 如果全文没有任何需要配音的台词,直接返回 []。
2. 【情绪强度 emotionStrength】
根据台词的情绪激烈程度输出0.1-0.5之间的数值(⚠️ 注意最高不超过0.5,保持语音自然平稳):

View File

@@ -13,6 +13,12 @@
"addCharacter": "Add Character",
"addLocation": "Add Location",
"addVoice": "Add Voice",
"downloadAll": "Download All",
"downloadAllTitle": "Download All Image Assets as ZIP",
"downloading": "Packing...",
"downloadSuccess": "Download Complete",
"downloadFailed": "Download Failed",
"downloadEmpty": "No image assets to download",
"newFolder": "New Folder",
"editFolder": "Edit Folder",
"deleteFolder": "Delete Folder",

View File

@@ -85,6 +85,9 @@
"selectCount": "Select generation count",
"generateCountPrefix": "Generate",
"generateCountSuffix": "images",
"regenCountPrefix": "Regenerate",
"regenCountSuffix": "",
"regenCountAriaLabel": "Select regeneration count",
"generatedProgress": "Generated {generated}/{total}",
"generating": "Generating",
"regenerating": "Regenerating",
@@ -188,7 +191,8 @@
"regenerateAll": "Regenerate All",
"regenerateAllConfirm": "Regenerate images for all assets? This will overwrite existing images.",
"noAssetsToGenerate": "No assets available for generation",
"regenerateAllHint": "Regenerate all asset images (overwrite existing)"
"regenerateAllHint": "Regenerate all asset images (overwrite existing)",
"downloadAll": "Download all images as ZIP"
},
"common": {
"actions": "Actions",
@@ -241,7 +245,9 @@
"copySuccessCharacter": "Character appearance copied successfully",
"copySuccessLocation": "Location image copied successfully",
"copySuccessVoice": "Voice copied successfully",
"copyFailed": "Copy failed: {error}"
"copyFailed": "Copy failed: {error}",
"downloadEmpty": "No image assets to download",
"downloadFailed": "Download failed"
},
"tts": {
"voiceDesignSaved": "AI-designed voice has been set for {name}",

View File

@@ -6,6 +6,7 @@
"RATE_LIMIT": "Too many requests. Please retry in {retryAfter} seconds",
"MODEL_NOT_OPEN": "Model permission is not activated. Go to https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=model and click \"Activate all models\" in the top-right of Model Management",
"MODEL_NOT_REGISTERED": "Model is not registered. Add an available model in configuration first",
"MODEL_NOT_CONFIGURED": "No model configured. Please go to Settings and add the required model type before generating.",
"QUOTA_EXCEEDED": "Quota exceeded. Please try again later",
"GENERATION_FAILED": "Generation failed. Please retry",
"GENERATION_TIMEOUT": "Generation timed out. Please retry",

View File

@@ -13,6 +13,12 @@
"addCharacter": "新建角色",
"addLocation": "新建场景",
"addVoice": "新建音色",
"downloadAll": "打包下载",
"downloadAllTitle": "下载全部图片资产",
"downloading": "打包中...",
"downloadSuccess": "下载完成",
"downloadFailed": "下载失败",
"downloadEmpty": "当前没有可下载的图片资产",
"newFolder": "新建文件夹",
"editFolder": "编辑文件夹",
"deleteFolder": "删除文件夹",

View File

@@ -85,6 +85,9 @@
"selectCount": "选择生成数量",
"generateCountPrefix": "生成",
"generateCountSuffix": "张图像",
"regenCountPrefix": "重新生成",
"regenCountSuffix": "张",
"regenCountAriaLabel": "选择重新生成张数",
"generatedProgress": "已生成 {generated}/{total}",
"generating": "生成中",
"regenerating": "重新生成中",
@@ -188,7 +191,8 @@
"regenerateAll": "重新生成全部",
"regenerateAllConfirm": "确定要重新生成所有资产的图片吗?这将覆盖现有图片。",
"noAssetsToGenerate": "没有可生成的资产",
"regenerateAllHint": "重新生成所有资产图片(覆盖现有)"
"regenerateAllHint": "重新生成所有资产图片(覆盖现有)",
"downloadAll": "打包下载全部图片"
},
"common": {
"actions": "操作",
@@ -241,7 +245,9 @@
"copySuccessCharacter": "角色形象复制成功",
"copySuccessLocation": "场景图片复制成功",
"copySuccessVoice": "音色复制成功",
"copyFailed": "复制失败: {error}"
"copyFailed": "复制失败: {error}",
"downloadEmpty": "当前没有可下载的图片资产",
"downloadFailed": "打包下载失败"
},
"tts": {
"voiceDesignSaved": "已为 {name} 设置 AI 设计的声音",

View File

@@ -6,6 +6,7 @@
"RATE_LIMIT": "请求过于频繁,请 {retryAfter} 秒后重试",
"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": "配额已用尽,请稍后重试",
"GENERATION_FAILED": "生成失败,请重试",
"GENERATION_TIMEOUT": "生成超时,请重试",

View File

@@ -5,10 +5,14 @@ import { resolveTaskLocaleFromBody } from '@/lib/task/resolve-locale'
import { markTaskFailed } from '@/lib/task/service'
import { publishTaskEvent } from '@/lib/task/publisher'
import { TASK_EVENT_TYPE, TASK_TYPE, type TaskType } from '@/lib/task/types'
import { cleanupAllProjectLogs } from '@/lib/logging/file-writer'
const INTERVAL_MS = Number.parseInt(process.env.WATCHDOG_INTERVAL_MS || '30000', 10) || 30000
const HEARTBEAT_TIMEOUT_MS = Number.parseInt(process.env.TASK_HEARTBEAT_TIMEOUT_MS || '90000', 10) || 90000
const TASK_TYPE_SET: ReadonlySet<string> = new Set(Object.values(TASK_TYPE))
// 每小时执行一次日志清理
const LOG_CLEANUP_INTERVAL_TICKS = Math.ceil(3600_000 / INTERVAL_MS)
let tickCount = 0
const logger = createScopedLogger({
module: 'watchdog',
action: 'watchdog.tick',
@@ -181,10 +185,15 @@ async function cleanupZombieProcessingTasks() {
}
async function tick() {
tickCount++
const startedAt = Date.now()
try {
await recoverQueuedTasks()
await cleanupZombieProcessingTasks()
// 每小时清理一次日志(过滤 24h 前内容)
if (tickCount % LOG_CLEANUP_INTERVAL_TICKS === 0) {
void cleanupAllProjectLogs()
}
logger.info({
action: 'watchdog.tick.ok',
message: 'watchdog tick completed',

File diff suppressed because one or more lines are too long

View File

@@ -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"

View File

@@ -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>

View File

@@ -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)]" />
<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

View File

@@ -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)]" />
<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"
/>

View File

@@ -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>
)}

View File

@@ -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"

View File

@@ -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}

View File

@@ -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,

View 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 = ''

View 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>
)
}

View File

@@ -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,

View File

@@ -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'
// AccountOverdueErrorARK 欠费 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)

View File

@@ -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: '网络异常,请稍后重试。',

View File

@@ -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 } : {}),
}
}
}

View File

@@ -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 // 错误信息

View File

@@ -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 目录不存在等情况,忽略
}
}

View File

@@ -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())
}
const rawB64 = (first as { b64_json?: unknown }).b64_json
const rawUrl = (first as { url?: unknown }).url
if (firstB64 === null && typeof rawB64 === 'string' && rawB64.trim()) {
firstB64 = rawB64.trim()
}
}
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')
}

View File

@@ -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')
}

View File

@@ -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[]

View File

@@ -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)
if (nextLineIndexes.length === 0) {
await voiceLineModel.deleteMany({
where: {
episodeId,
},
})
} else {
await voiceLineModel.deleteMany({
where: {
episodeId,
lineIndex: {
notIn: nextLineIndexes.length > 0 ? nextLineIndexes : [0],
notIn: nextLineIndexes,
},
},
})
}
return created
}, { timeout: 15000 })

View File

@@ -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

View File

@@ -308,6 +308,13 @@ export async function handleVoiceAnalyzeTask(job: Job<TaskJobData>) {
}
const incomingLineIndexes = new Set<number>(voiceLinesData.map((item) => item.lineIndex))
if (incomingLineIndexes.size === 0) {
await voiceLineModel.deleteMany({
where: {
episodeId,
},
})
} else {
await voiceLineModel.deleteMany({
where: {
episodeId,
@@ -316,6 +323,7 @@ export async function handleVoiceAnalyzeTask(job: Job<TaskJobData>) {
},
},
})
}
return created
})

View File

@@ -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: {

View File

@@ -133,6 +133,36 @@ describe('image provider smoke tests', () => {
})
})
it('Seedream 返回多图时 -> 同时返回 imageUrl 和 imageUrls', async () => {
getProviderConfigMock.mockResolvedValueOnce({
id: 'ark',
apiKey: 'ark-key',
})
arkImageGenerationMock.mockResolvedValueOnce({
data: [
{ url: 'https://seedream.test/image-1.png' },
{ url: 'https://seedream.test/image-2.png' },
],
})
const generator = new ArkSeedreamGenerator()
const result = await generator.generate({
userId: 'user-1',
prompt: 'refine this style',
referenceImages: ['https://example.com/ref.png'],
options: {
modelId: 'doubao-seedream-4-5-251128',
aspectRatio: '3:4',
},
})
expect(result).toEqual({
success: true,
imageUrl: 'https://seedream.test/image-1.png',
imageUrls: ['https://seedream.test/image-1.png', 'https://seedream.test/image-2.png'],
})
})
it('Gemini 兼容层文生图可用 -> 直连 Gemini SDK 协议返回图片', async () => {
getProviderConfigMock.mockResolvedValueOnce({
id: 'gemini-compatible:gm-1',

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const resolveConfigMock = vi.hoisted(() => vi.fn(async () => ({
providerId: 'openai-compatible:test-provider',
baseUrl: 'https://compat.example.com/v1',
apiKey: 'sk-test',
})))
vi.mock('@/lib/model-gateway/openai-compat/common', () => ({
resolveOpenAICompatClientConfig: resolveConfigMock,
}))
import { generateImageViaOpenAICompatTemplate } from '@/lib/model-gateway/openai-compat/template-image'
describe('openai-compat template image output urls', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns all image urls when outputUrlsPath contains multiple values', async () => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
data: [
{ url: 'https://cdn.test/1.png' },
{ url: 'https://cdn.test/2.png' },
],
}), { status: 200 })) as unknown as typeof fetch
const result = await generateImageViaOpenAICompatTemplate({
userId: 'user-1',
providerId: 'openai-compatible:test-provider',
modelId: 'gpt-image-1',
modelKey: 'openai-compatible:test-provider::gpt-image-1',
prompt: 'draw a cat',
profile: 'openai-compatible',
template: {
version: 1,
mediaType: 'image',
mode: 'sync',
create: {
method: 'POST',
path: '/images/generations',
contentType: 'application/json',
bodyTemplate: {
model: '{{model}}',
prompt: '{{prompt}}',
},
},
response: {
outputUrlPath: '$.data[0].url',
outputUrlsPath: '$.data',
},
},
})
expect(result).toEqual({
success: true,
imageUrl: 'https://cdn.test/1.png',
imageUrls: ['https://cdn.test/1.png', 'https://cdn.test/2.png'],
})
})
it('keeps single-url output compatible when outputUrlsPath has only one image', async () => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
data: [{ url: 'https://cdn.test/only.png' }],
}), { status: 200 })) as unknown as typeof fetch
const result = await generateImageViaOpenAICompatTemplate({
userId: 'user-1',
providerId: 'openai-compatible:test-provider',
modelId: 'gpt-image-1',
modelKey: 'openai-compatible:test-provider::gpt-image-1',
prompt: 'draw a cat',
profile: 'openai-compatible',
template: {
version: 1,
mediaType: 'image',
mode: 'sync',
create: {
method: 'POST',
path: '/images/generations',
contentType: 'application/json',
bodyTemplate: {
model: '{{model}}',
prompt: '{{prompt}}',
},
},
response: {
outputUrlsPath: '$.data',
},
},
})
expect(result).toEqual({
success: true,
imageUrl: 'https://cdn.test/only.png',
})
})
})

View File

@@ -76,6 +76,7 @@ const runScriptToStoryboardAtomicRetryMock = vi.hoisted(() => vi.fn())
const txState = vi.hoisted(() => ({
createdRows: [] as Array<Record<string, unknown>>,
deletedWhereClauses: [] as Array<Record<string, unknown>>,
}))
const prismaMock = vi.hoisted(() => ({
@@ -241,6 +242,7 @@ describe('worker script-to-storyboard behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
txState.createdRows = []
txState.deletedWhereClauses = []
parseStoryboardRetryTargetMock.mockReturnValue(null)
runScriptToStoryboardAtomicRetryMock.mockReset()
@@ -274,13 +276,16 @@ describe('worker script-to-storyboard behavior', () => {
prismaMock.$transaction.mockImplementation(async (fn: (tx: {
novelPromotionVoiceLine: {
deleteMany: (args: { where: { episodeId: string } }) => Promise<unknown>
deleteMany: (args: { where: Record<string, unknown> }) => Promise<unknown>
create: (args: { data: Record<string, unknown>; select: { id: boolean } }) => Promise<{ id: string }>
}
}) => Promise<unknown>) => {
const tx = {
novelPromotionVoiceLine: {
deleteMany: async () => undefined,
deleteMany: async (args: { where: Record<string, unknown> }) => {
txState.deletedWhereClauses.push(args.where)
return undefined
},
create: async (args: { data: Record<string, unknown>; select: { id: boolean } }) => {
txState.createdRows.push(args.data)
return { id: `voice-${txState.createdRows.length}` }
@@ -328,6 +333,12 @@ describe('worker script-to-storyboard behavior', () => {
matchedStoryboardId: 'storyboard-1',
matchedPanelIndex: 1,
}))
expect(txState.deletedWhereClauses[0]).toEqual({
episodeId: 'episode-1',
lineIndex: {
notIn: [1],
},
})
})
it('voice 解析失败后会重试一次再成功', async () => {
@@ -371,6 +382,24 @@ describe('worker script-to-storyboard behavior', () => {
)
})
it('空台词数组 -> 成功完成并清空旧台词', async () => {
parseVoiceLinesJsonMock.mockReturnValue([])
const job = buildJob({ episodeId: 'episode-1' })
const result = await handleScriptToStoryboardTask(job)
expect(result).toEqual({
episodeId: 'episode-1',
storyboardCount: 1,
panelCount: 1,
voiceLineCount: 0,
})
expect(txState.createdRows).toEqual([])
expect(txState.deletedWhereClauses[0]).toEqual({
episodeId: 'episode-1',
})
})
it('phase 级重试: 仅执行原子 phase不走整图重跑', async () => {
parseStoryboardRetryTargetMock.mockReturnValue({
stepKey: 'clip_clip-1_phase3_detail',

View File

@@ -4,6 +4,7 @@ import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const txState = vi.hoisted(() => ({
createdRows: [] as Array<Record<string, unknown>>,
deletedWhereClauses: [] as Array<Record<string, unknown>>,
}))
const prismaMock = vi.hoisted(() => ({
@@ -79,6 +80,7 @@ describe('worker voice-analyze behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
txState.createdRows = []
txState.deletedWhereClauses = []
prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1', mode: 'novel-promotion' })
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
@@ -121,7 +123,7 @@ describe('worker voice-analyze behavior', () => {
prismaMock.$transaction.mockImplementation(async (fn: (tx: {
novelPromotionVoiceLine: {
deleteMany: (args: { where: { episodeId: string } }) => Promise<unknown>
deleteMany: (args: { where: Record<string, unknown> }) => Promise<unknown>
create: (args: { data: Record<string, unknown>; select: { id: boolean; speaker: boolean; matchedStoryboardId: boolean } }) => Promise<{
id: string
speaker: string
@@ -131,7 +133,10 @@ describe('worker voice-analyze behavior', () => {
}) => Promise<unknown>) => {
const tx = {
novelPromotionVoiceLine: {
deleteMany: async () => undefined,
deleteMany: async (args: { where: Record<string, unknown> }) => {
txState.deletedWhereClauses.push(args.where)
return undefined
},
create: async (args: { data: Record<string, unknown>; select: { id: boolean; speaker: boolean; matchedStoryboardId: boolean } }) => {
txState.createdRows.push(args.data)
const speaker = typeof args.data.speaker === 'string' ? args.data.speaker : 'unknown'
@@ -178,6 +183,30 @@ describe('worker voice-analyze behavior', () => {
matchedStoryboardId: 'storyboard-1',
matchedPanelIndex: 0,
}))
expect(txState.deletedWhereClauses[0]).toEqual({
episodeId: 'episode-1',
lineIndex: {
notIn: [1, 2],
},
})
})
it('empty voice lines -> success with zero rows and clears existing lines', async () => {
helperMock.parseVoiceLinesJson.mockReturnValue([])
const job = buildJob({ episodeId: 'episode-1' })
const result = await handleVoiceAnalyzeTask(job)
expect(result).toEqual({
episodeId: 'episode-1',
count: 0,
matchedCount: 0,
speakerStats: {},
})
expect(txState.createdRows).toEqual([])
expect(txState.deletedWhereClauses[0]).toEqual({
episodeId: 'episode-1',
})
})
it('line references non-existent storyboard panel -> explicit error', async () => {

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { parseVoiceLinesJson as parseStoryboardVoiceLinesJson } from '@/lib/workers/handlers/script-to-storyboard-helpers'
import { parseVoiceLinesJson as parseStandaloneVoiceLinesJson } from '@/lib/workers/handlers/voice-analyze-helpers'
describe('voice line parse helpers', () => {
it('script-to-storyboard parser accepts explicit empty array', () => {
expect(parseStoryboardVoiceLinesJson('[]')).toEqual([])
})
it('script-to-storyboard parser rejects non-object array payload', () => {
expect(() => parseStoryboardVoiceLinesJson('[1,2]')).toThrow('voice_analyze: invalid payload')
})
it('voice-analyze parser accepts explicit empty array', () => {
expect(parseStandaloneVoiceLinesJson('[]')).toEqual([])
})
it('voice-analyze parser rejects non-object array payload', () => {
expect(() => parseStandaloneVoiceLinesJson('[1,2]')).toThrow('Invalid voice lines data structure')
})
})