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:
19
.env.example
19
.env.example
@@ -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=
|
||||
|
||||
12
README.md
12
README.md
@@ -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 Key(NEXTAUTH_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)(方式三)开始使用!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -28,8 +28,10 @@
|
||||
- 动作描写(描述角色的动作)
|
||||
- 场景描述(描述环境、画面)
|
||||
- 章节标题
|
||||
- 明确设定为无语言、默片、纯画面表达的内容
|
||||
|
||||
⚠️ 判断标准:这句话是否需要有人"说出来"?如果只是描述画面动作,不要提取。
|
||||
⚠️ 如果全文没有任何需要配音的台词,直接返回 []。
|
||||
|
||||
2. 【情绪强度 emotionStrength】
|
||||
根据台词的情绪激烈程度,输出0.1-0.5之间的数值(⚠️ 注意:最高不超过0.5,保持语音自然平稳):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
"addCharacter": "新建角色",
|
||||
"addLocation": "新建场景",
|
||||
"addVoice": "新建音色",
|
||||
"downloadAll": "打包下载",
|
||||
"downloadAllTitle": "下载全部图片资产",
|
||||
"downloading": "打包中...",
|
||||
"downloadSuccess": "下载完成",
|
||||
"downloadFailed": "下载失败",
|
||||
"downloadEmpty": "当前没有可下载的图片资产",
|
||||
"newFolder": "新建文件夹",
|
||||
"editFolder": "编辑文件夹",
|
||||
"deleteFolder": "删除文件夹",
|
||||
|
||||
@@ -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 设计的声音",
|
||||
|
||||
@@ -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": "生成超时,请重试",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
21
tests/unit/worker/voice-line-parse-helpers.test.ts
Normal file
21
tests/unit/worker/voice-line-parse-helpers.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user