Fix prop confirmation bug, add Wan 2.7 model, refine multiple UI details, improve prop generation quality and aspect ratio, remove text overlays from Asset Center created images, and optimize prop filtering logic

This commit is contained in:
saturn
2026-04-03 22:36:41 +08:00
parent 854b932e67
commit 78b93331b4
136 changed files with 3393 additions and 875 deletions

View File

@@ -262,8 +262,7 @@ export function ProviderAdvancedFields({
<div className="glass-surface-soft rounded-xl p-2">
<div
className="glass-provider-model-scroll h-[280px] overflow-y-auto pr-1"
style={{ scrollbarGutter: 'stable' }}
className="app-scrollbar h-[280px] overflow-y-auto pr-1"
>
<div className="space-y-2">
{currentModels.map((model, index) => (

View File

@@ -146,6 +146,7 @@ export const PRESET_MODELS: PresetModel[] = [
{ modelId: 'veo-3.0-fast-generate-001', name: 'Veo 3.0 Fast', type: 'video', provider: 'google' },
{ modelId: 'veo-2.0-generate-001', name: 'Veo 2.0', type: 'video', provider: 'google' },
// 阿里云百炼图生视频模型
{ modelId: 'wan2.7-i2v', name: 'Wan2.7 I2V', type: 'video', provider: 'bailian' },
{ modelId: 'wan2.6-i2v-flash', name: 'Wan2.6 I2V Flash', type: 'video', provider: 'bailian' },
{ modelId: 'wan2.6-i2v', name: 'Wan2.6 I2V', type: 'video', provider: 'bailian' },
{ modelId: 'wan2.5-i2v-preview', name: 'Wan2.5 I2V Preview', type: 'video', provider: 'bailian' },

View File

@@ -463,7 +463,7 @@ export default function AssetsStage({
onRegenerateGroup={handleRegenerateLocationGroup}
onUndo={handleUndoLocation}
onImageClick={setPreviewImage}
onImageEdit={(locId, imgIdx) => handleOpenLocationImageEdit(locId, imgIdx)}
onImageEdit={(locId, imgIdx) => handleOpenLocationImageEdit(locId, imgIdx, 'location')}
onCopyFromGlobal={handleCopyLocationFromGlobal}
filterIds={episodeAssetIds?.locIds ?? null}
/>
@@ -488,7 +488,7 @@ export default function AssetsStage({
void propAssetActions.revertRender({ id: propId }).catch(() => undefined)
}}
onImageClick={setPreviewImage}
onImageEdit={() => undefined}
onImageEdit={(propId, imgIdx) => handleOpenLocationImageEdit(propId, imgIdx, 'prop')}
onCopyFromGlobal={handleCopyPropFromGlobal}
filterIds={episodeAssetIds?.propIds ?? null}
/>

View File

@@ -57,7 +57,7 @@ export default function WorkspaceAssetLibraryModal({
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar" data-asset-scroll-container="1">
<div className="flex-1 overflow-y-auto p-6 app-scrollbar" data-asset-scroll-container="1">
{assetsLoading && !hasCharacters && !hasLocations && (
<div className="flex flex-col items-center justify-center h-64 text-[var(--glass-text-tertiary)] animate-pulse">
<TaskStatusInline state={assetsLoadingState} className="text-base [&>span]:text-base" />

View File

@@ -133,8 +133,8 @@ export default function AddLocationModal({
return (
<div className="fixed inset-0 bg-[var(--glass-overlay)] flex items-center justify-center z-50 p-4">
<div className="bg-[var(--glass-bg-surface)] rounded-xl shadow-xl max-w-2xl w-full max-h-[85vh] overflow-y-auto">
<div className="p-6">
<div className="bg-[var(--glass-bg-surface)] rounded-xl shadow-xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col">
<div className="p-6 overflow-y-auto app-scrollbar flex-1 min-h-0">
{/* 标题 */}
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">

View File

@@ -35,10 +35,12 @@ interface EditingPropState {
propId: string
propName: string
summary: string
description: string
variantId?: string
}
interface LocationImageEditModalState {
assetType: 'location' | 'prop'
locationName: string
}
@@ -140,7 +142,7 @@ export default function AssetsStageModals({
{imageEditModal && (
<ImageEditModal
type="location"
type={imageEditModal.assetType}
name={imageEditModal.locationName}
onClose={closeImageEditModal}
onConfirm={handleLocationImageEdit}
@@ -238,6 +240,7 @@ export default function AssetsStageModals({
propId={editingProp.propId}
propName={editingProp.propName}
summary={editingProp.summary}
description={editingProp.description}
variantId={editingProp.variantId}
projectId={projectId}
onClose={closeEditingProp}

View File

@@ -454,6 +454,7 @@ export default function CharacterCard({
mode="single"
characterName={character.name}
changeReason={appearance.changeReason}
aspectClassName="aspect-[3/2]"
currentImageUrl={currentImageUrl}
selectedIndex={selectedIndex}
hasMultipleImages={hasMultipleImages}

View File

@@ -101,11 +101,11 @@ export default function CharacterProfileDialog({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--glass-overlay)]" onClick={onClose}>
<div
className="bg-[var(--glass-bg-surface)] rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto m-4"
className="bg-[var(--glass-bg-surface)] rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col m-4"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="sticky top-0 bg-[var(--glass-bg-surface)] border-b border-[var(--glass-stroke-base)] px-6 py-4 flex items-center justify-between">
<div className="bg-[var(--glass-bg-surface)] border-b border-[var(--glass-stroke-base)] px-6 py-4 flex items-center justify-between shrink-0">
<h2 className="text-xl font-semibold text-[var(--glass-text-primary)]">{t('characterProfile.editDialogTitle', { name: characterName })}</h2>
<button
onClick={onClose}
@@ -116,7 +116,7 @@ export default function CharacterProfileDialog({
</div>
{/* 表单内容 */}
<div className="p-6 space-y-4">
<div className="p-6 space-y-4 overflow-y-auto app-scrollbar flex-1 min-h-0">
{/* 角色层级 */}
<div>
<label className="block text-sm font-medium text-[var(--glass-text-secondary)] mb-2">{t('characterProfile.importanceLevel')}</label>
@@ -261,7 +261,7 @@ export default function CharacterProfileDialog({
</div>
{/* 底部按钮 */}
<div className="sticky bottom-0 bg-[var(--glass-bg-surface)] border-t border-[var(--glass-stroke-base)] px-6 py-4 flex gap-3 justify-end">
<div className="bg-[var(--glass-bg-surface)] border-t border-[var(--glass-stroke-base)] px-6 py-4 flex gap-3 justify-end shrink-0">
<button
onClick={onClose}
disabled={isSaving}

View File

@@ -142,7 +142,7 @@ export default function CharacterSection({
if (!element) return
const scrollContainer = (element.closest('[data-asset-scroll-container="1"]') ||
document.querySelector('[data-asset-scroll-container="1"]') ||
element.closest('.custom-scrollbar')) as HTMLElement | null
element.closest('.app-scrollbar')) as HTMLElement | null
if (scrollAnimationRef.current !== null) {
window.cancelAnimationFrame(scrollAnimationRef.current)
@@ -287,18 +287,18 @@ export default function CharacterSection({
{t("character.assetCount", { count: sortedAppearances.length })}
</span>
</div>
<div className="flex items-center gap-2">
{/* 从资产中心复制按钮 */}
<div className="flex flex-col items-end gap-1.5">
{/* 从资产中心导入按钮 */}
<button
onClick={() => onCopyFromGlobal(character.id)}
className="text-xs text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-tone-info-fg)] flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-[var(--glass-tone-info-bg)] transition-colors"
>
<AppIcon name="copy" className="w-4 h-4" />
<AppIcon name="arrowDownCircle" className="w-4 h-4" />
{t("character.copyFromGlobal")}
</button>
<button
onClick={() => onDeleteCharacter(character.id)}
className="text-xs text-[var(--glass-tone-danger-fg)] hover:text-[var(--glass-tone-danger-fg)] flex items-center gap-1"
className="text-xs text-[var(--glass-tone-danger-fg)] hover:text-[var(--glass-tone-danger-fg)] flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-[var(--glass-tone-danger-bg)] transition-colors"
>
<AppIcon name="trash" className="w-4 h-4" />
{t("character.delete")}

View File

@@ -11,7 +11,7 @@ import { useState, useRef } from 'react'
import { AppIcon } from '@/components/ui/icons'
interface ImageEditModalProps {
type: 'character' | 'location'
type: 'character' | 'location' | 'prop'
name: string
onClose: () => void
onConfirm: (modifyPrompt: string, extraImageUrls?: string[]) => void
@@ -28,10 +28,16 @@ export default function ImageEditModal({
const [editImages, setEditImages] = useState<string[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const title = type === 'character' ? t('imageEdit.editCharacterImage') : t('imageEdit.editLocationImage')
const title = type === 'character'
? t('imageEdit.editCharacterImage')
: type === 'prop'
? t('imageEdit.editPropImage')
: t('imageEdit.editLocationImage')
const subtitle = type === 'character'
? t('imageEdit.characterLabel', { name })
: t('imageEdit.locationLabel', { name })
: type === 'prop'
? t('imageEdit.propLabel', { name })
: t('imageEdit.locationLabel', { name })
const handleSubmit = () => {
if (!modifyPrompt.trim()) {
@@ -88,14 +94,14 @@ export default function ImageEditModal({
return (
<div className="fixed inset-0 bg-[var(--glass-overlay)] z-50 flex items-center justify-center p-4">
<div
className="bg-[var(--glass-bg-surface)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"
className="bg-[var(--glass-bg-surface)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"
onPaste={handlePaste}
>
<div className="p-6 border-b">
<div className="p-6 border-b shrink-0">
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">{title}</h3>
<p className="text-sm text-[var(--glass-text-tertiary)] mt-1">{subtitle} · {t('imageEdit.subtitle')}</p>
</div>
<div className="p-6 space-y-4">
<div className="p-6 space-y-4 overflow-y-auto app-scrollbar flex-1 min-h-0">
<div>
<label className="block text-sm font-medium text-[var(--glass-text-secondary)] mb-2">{t('imageEdit.editInstruction')}</label>
<textarea
@@ -103,6 +109,8 @@ export default function ImageEditModal({
onChange={(e) => setModifyPrompt(e.target.value)}
placeholder={type === 'character'
? t('imageEdit.characterPlaceholder')
: type === 'prop'
? t('imageEdit.propPlaceholder')
: t('imageEdit.locationPlaceholder')
}
className="w-full h-24 px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)] resize-none"

View File

@@ -20,6 +20,8 @@ import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
import { countGeneratedImageSlots, resolveDisplayImageSlots } from '@/lib/image-generation/slot-state'
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'
import { canGenerateLocationBackedAsset } from './location-backed-asset'
interface LocationCardProps {
@@ -37,7 +39,7 @@ interface LocationCardProps {
activeTaskKeys?: Set<string>
onClearTaskKey?: (key: string) => void
projectId: string
onConfirmSelection?: (locationId: string) => void
onConfirmSelection?: (locationId: string) => Promise<void> | void
}
export default function LocationCard({
@@ -179,6 +181,7 @@ export default function LocationCard({
const hasPreviousVersion = location.images?.some(img => img.previousImageUrl) || false
const showSelectionMode = displaySlotCount > 1
const singleImageAspectClassName = assetType === 'prop' ? 'aspect-[3/2]' : 'aspect-square'
// 选择模式:显示名字在上,三张图片在下
if (showSelectionMode) {
@@ -271,7 +274,9 @@ export default function LocationCard({
onConfirmSelection={selectedIndex !== null && onConfirmSelection
? () => {
setIsConfirmingSelection(true)
onConfirmSelection(location.id)
void Promise.resolve(onConfirmSelection(location.id)).finally(() => {
setIsConfirmingSelection(false)
})
}
: undefined}
/>
@@ -297,11 +302,10 @@ export default function LocationCard({
{!isTaskRunning && currentImageUrl && onImageEdit && (
<button
onClick={() => onImageEdit(location.id, currentImageIndex)}
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
@@ -340,7 +344,7 @@ export default function LocationCard({
className="flex-shrink-0 w-5 h-5 rounded hover:bg-[var(--glass-tone-info-bg)] flex items-center justify-center transition-colors"
title={t('character.copyFromGlobal')}
>
<AppIcon name="copy" className="w-3.5 h-3.5 text-[var(--glass-tone-info-fg)]" />
<AppIcon name="arrowDownCircle" className="w-3.5 h-3.5 text-[var(--glass-tone-info-fg)]" />
</button>
)}
<button
@@ -361,7 +365,7 @@ export default function LocationCard({
)
const firstImage = location.images?.[0]
const canGenerate = canGenerateLocationBackedAsset(location)
const canGenerate = canGenerateLocationBackedAsset(location, assetType)
return (
<div className="flex flex-col gap-2 glass-surface-elevated p-3">
@@ -376,6 +380,7 @@ export default function LocationCard({
<LocationImageList
mode="single"
locationName={location.name}
aspectClassName={singleImageAspectClassName}
currentImageUrl={currentImageUrl}
selectedIndex={selectedIndex}
hasMultipleImages={hasMultipleImages}

View File

@@ -29,7 +29,7 @@ interface LocationSectionProps {
// 🔥 V6.6 重构:重命名为 handleGenerateImage
handleGenerateImage: (type: 'character' | 'location' | 'prop', id: string, appearanceId?: string, count?: number) => Promise<void>
onSelectImage: (locationId: string, imageIndex: number | null) => void
onConfirmSelection: (locationId: string) => void
onConfirmSelection: (locationId: string) => Promise<void> | void
onRegenerateSingle: (locationId: string, imageIndex: number) => Promise<void>
onRegenerateGroup: (locationId: string, count?: number) => Promise<void>
onUndo: (locationId: string) => void
@@ -149,7 +149,7 @@ export default function LocationSection({
activeTaskKeys={activeTaskKeys}
onClearTaskKey={onClearTaskKey}
projectId={projectId}
onConfirmSelection={assetType === 'location' ? onConfirmSelection : undefined}
onConfirmSelection={onConfirmSelection}
/>
))}
</div>

View File

@@ -26,6 +26,7 @@ type CharacterCardGalleryProps =
mode: 'single'
characterName: string
changeReason: string
aspectClassName: string
currentImageUrl: string | null | undefined
selectedIndex: number | null
hasMultipleImages: boolean
@@ -105,14 +106,14 @@ export default function CharacterCardGallery(props: CharacterCardGalleryProps) {
})
return (
<div className="rounded-lg overflow-hidden border-2 border-[var(--glass-stroke-base)] relative">
<div className={`relative overflow-hidden rounded-lg border-2 border-[var(--glass-stroke-base)] ${props.aspectClassName}`}>
{props.currentImageUrl ? (
<div className="relative w-full">
<div className="relative h-full w-full">
<MediaImageWithLoading
src={props.currentImageUrl}
alt={`${props.characterName} - ${props.changeReason}`}
containerClassName="w-full min-h-[120px]"
className="w-full h-auto object-contain cursor-pointer hover:opacity-90 transition-opacity"
containerClassName="h-full w-full"
className="h-full w-full object-contain cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => props.onImageClick(props.currentImageUrl!)}
/>
{props.selectedIndex !== null && props.hasMultipleImages && (
@@ -122,7 +123,7 @@ export default function CharacterCardGallery(props: CharacterCardGalleryProps) {
)}
</div>
) : (
<div className="w-full h-full bg-[var(--glass-bg-muted)] flex items-center justify-center">
<div className="flex h-full w-full items-center justify-center bg-[var(--glass-bg-muted)]">
{appearanceErrorDisplay && !props.isAppearanceTaskRunning ? (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<AppIcon name="alert" className="w-8 h-8 text-[var(--glass-tone-danger-fg)] mb-2" />

View File

@@ -31,10 +31,12 @@ interface EditingProp {
propId: string
propName: string
summary: string
description: string
variantId?: string
}
interface ImageEditModal {
assetType: 'location' | 'prop'
locationId: string
imageIndex: number
locationName: string
@@ -137,20 +139,24 @@ export function useAssetModals({
}
const handleEditProp = (prop: Prop) => {
const firstImage = prop.images?.[0]
setEditingProp({
propId: prop.id,
propName: prop.name,
summary: prop.summary || prop.images?.[0]?.description || '',
variantId: prop.images?.[0]?.id,
summary: prop.summary || '',
description: firstImage?.description || prop.summary || '',
variantId: firstImage?.id,
})
}
// 打开场景图片编辑弹窗
const handleOpenLocationImageEdit = (locationId: string, imageIndex: number) => {
const location = locations.find(l => l.id === locationId)
const handleOpenLocationImageEdit = (locationId: string, imageIndex: number, assetType: 'location' | 'prop' = 'location') => {
const assetsOfType = assetType === 'prop' ? props : locations
const location = assetsOfType.find(l => l.id === locationId)
if (!location) return
setImageEditModal({
assetType,
locationId,
imageIndex,
locationName: location.name

View File

@@ -4,8 +4,8 @@ import { useCallback } from 'react'
import { logInfo as _ulogInfo } from '@/lib/logging/core'
import { isAbortError } from '@/lib/error-utils'
import {
useAssetActions,
useModifyProjectCharacterImage,
useModifyProjectLocationImage,
useUndoProjectCharacterImage,
useUndoProjectLocationImage,
useUpdateProjectAppearanceDescription,
@@ -29,6 +29,7 @@ interface EditingLocationState {
}
interface LocationImageEditState {
assetType: 'location' | 'prop'
locationId: string
imageIndex: number
locationName: string
@@ -73,7 +74,8 @@ export function useAssetsImageEdit({
closeCharacterImageEditModal,
}: UseAssetsImageEditParams) {
const modifyCharacterImage = useModifyProjectCharacterImage(projectId)
const modifyLocationImage = useModifyProjectLocationImage(projectId)
const locationAssetActions = useAssetActions({ scope: 'project', projectId, kind: 'location' })
const propAssetActions = useAssetActions({ scope: 'project', projectId, kind: 'prop' })
const undoCharacterImage = useUndoProjectCharacterImage(projectId)
const undoLocationImage = useUndoProjectLocationImage(projectId)
const updateAppearanceDescription = useUpdateProjectAppearanceDescription(projectId)
@@ -111,29 +113,31 @@ export function useAssetsImageEdit({
const handleLocationImageEdit = useCallback(async (modifyPrompt: string, extraImageUrls?: string[]) => {
if (!imageEditModal) return
const { locationId, imageIndex, locationName } = imageEditModal
const { assetType, locationId, imageIndex, locationName } = imageEditModal
closeImageEditModal()
_ulogInfo(`[场景编辑] 开始编辑 ${locationName}, locationId=${locationId}, imageIndex=${imageIndex}`)
const assetLabel = assetType === 'prop' ? '道具' : '场景'
const editAction = assetType === 'prop' ? propAssetActions : locationAssetActions
modifyLocationImage.mutate(
{ locationId, imageIndex, modifyPrompt, extraImageUrls },
{
onSuccess: (data) => {
const result = (data || {}) as { descriptionUpdated?: boolean }
_ulogInfo(`[场景编辑] ✅ 完成: ${locationName}`)
const descNote = result.descriptionUpdated ? t('stage.updateSuccess') : ''
showToast(`${locationName} ${t('image.editSuccess')}${descNote}`, 'success')
},
onError: (error: unknown) => {
_ulogInfo(`[场景编辑] ❌ 失败: ${locationName}`, error)
if (isAbortError(error)) return
showToast(`${t('image.editFailed')}: ${getErrorMessage(error)}`, 'error')
},
},
)
}, [closeImageEditModal, imageEditModal, modifyLocationImage, showToast, t])
_ulogInfo(`[${assetLabel}编辑] 开始编辑 ${locationName}, locationId=${locationId}, imageIndex=${imageIndex}`)
void editAction.modifyRender({
id: locationId,
imageIndex,
modifyPrompt,
extraImageUrls,
}).then((data) => {
const result = (data || {}) as { descriptionUpdated?: boolean }
_ulogInfo(`[${assetLabel}编辑] ✅ 完成: ${locationName}`)
const descNote = result.descriptionUpdated ? t('stage.updateSuccess') : ''
showToast(`${locationName} ${t('image.editSuccess')}${descNote}`, 'success')
}).catch((error: unknown) => {
_ulogInfo(`[${assetLabel}编辑] ❌ 失败: ${locationName}`, error)
if (isAbortError(error)) return
showToast(`${t('image.editFailed')}: ${getErrorMessage(error)}`, 'error')
})
}, [closeImageEditModal, imageEditModal, locationAssetActions, propAssetActions, showToast, t])
const handleCharacterImageEdit = useCallback(async (modifyPrompt: string, extraImageUrls?: string[]) => {
if (!characterImageEditModal) return

View File

@@ -58,7 +58,7 @@ export function useLocationActions({
const regenerateGroup = useRegenerateLocationGroup(projectId)
const deleteLocationMutation = useDeleteProjectLocation(projectId)
const selectLocationImageMutation = useSelectProjectLocationImage(projectId)
const confirmLocationSelectionMutation = useConfirmProjectLocationSelection(projectId)
const confirmLocationSelectionMutation = useConfirmProjectLocationSelection(projectId, assetType)
const updateLocationDescriptionMutation = useUpdateProjectLocationDescription(projectId)
// 删除场景
@@ -96,9 +96,6 @@ export function useLocationActions({
// 确认选择并删除其他候选图片
const handleConfirmLocationSelection = useCallback(async (locationId: string) => {
if (assetType === 'prop') {
return
}
try {
await confirmLocationSelectionMutation.mutateAsync({ locationId })
showToast?.(`${t('image.confirmSuccess')}`, 'success')

View File

@@ -1,7 +1,10 @@
import type { Location, Prop } from '@/types/project'
export function canGenerateLocationBackedAsset(asset: Location | Prop): boolean {
if (asset.summary && asset.summary.trim().length > 0) {
export function canGenerateLocationBackedAsset(
asset: Location | Prop,
assetType: 'location' | 'prop',
): boolean {
if (assetType === 'location' && asset.summary && asset.summary.trim().length > 0) {
return true
}

View File

@@ -39,6 +39,7 @@ type LocationImageListProps =
| {
mode: 'single'
locationName: string
aspectClassName: string
currentImageUrl: string | null | undefined
selectedIndex: number | null
hasMultipleImages: boolean
@@ -161,14 +162,14 @@ export default function LocationImageList(props: LocationImageListProps) {
})
return (
<div className="rounded-lg overflow-hidden border-2 border-[var(--glass-stroke-base)] relative">
<div className={`relative overflow-hidden rounded-lg border-2 border-[var(--glass-stroke-base)] ${props.aspectClassName}`}>
{props.currentImageUrl ? (
<div className="relative w-full">
<div className="relative h-full w-full">
<MediaImageWithLoading
src={props.currentImageUrl}
alt={props.locationName}
containerClassName="w-full min-h-[120px]"
className="w-full h-auto object-contain cursor-pointer hover:opacity-90 transition-opacity"
containerClassName="h-full w-full"
className="h-full w-full object-contain cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => props.onImageClick(props.currentImageUrl!)}
/>
{props.selectedIndex !== null && props.hasMultipleImages && (
@@ -178,7 +179,7 @@ export default function LocationImageList(props: LocationImageListProps) {
)}
</div>
) : (
<div className="w-full h-full bg-[var(--glass-bg-muted)] flex items-center justify-center">
<div className="flex h-full w-full items-center justify-center bg-[var(--glass-bg-muted)]">
{locationErrorDisplay && !props.isTaskRunning ? (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<AppIcon name="alert" className="w-8 h-8 text-[var(--glass-tone-danger-fg)] mb-2" />
@@ -186,7 +187,7 @@ export default function LocationImageList(props: LocationImageListProps) {
<div className="text-[var(--glass-tone-danger-fg)] text-xs max-w-full break-words">{locationErrorDisplay.message}</div>
</div>
) : (
<AppIcon name="globe2" className="w-8 h-8 text-[var(--glass-text-tertiary)]" />
<AppIcon name="image" className="w-8 h-8 text-[var(--glass-text-tertiary)]" />
)}
</div>
)}

View File

@@ -462,7 +462,7 @@ export default function ScriptViewAssetsPanel({
</div>
<div className="relative z-10 flex-1 min-h-0 glass-surface-modal overflow-hidden p-4 pr-3">
<div className="flex h-full flex-col gap-6 overflow-y-auto pr-1 custom-scrollbar">
<div className="flex h-full flex-col gap-6 overflow-y-auto pr-1 app-scrollbar">
{assetsLoading && characters.length === 0 && locations.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)] animate-pulse">
<TaskStatusInline state={assetsLoadingState} />
@@ -490,7 +490,7 @@ export default function ScriptViewAssetsPanel({
{showAddChar && mounted && createPortal(
<div ref={charEditorPopoverRef} className="fixed right-4 bottom-4 z-[80] glass-surface-modal w-[min(24rem,calc(100vw-2rem))] h-[min(560px,calc(100vh-2rem))] p-3 animate-fadeIn flex flex-col shadow-2xl">
<div className="shrink-0 text-xs text-[var(--glass-text-tertiary)]">{tCommon('edit')} · {tScript('asset.activeCharacters')}</div>
<div className="mt-3 flex-1 min-h-0 space-y-4 overflow-y-auto pr-1 custom-scrollbar">
<div className="mt-3 flex-1 min-h-0 space-y-4 overflow-y-auto pr-1 app-scrollbar">
{isAllClipsMode && (
<div className="rounded-lg border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]/40 p-2 text-[11px] text-[var(--glass-text-tertiary)]">
@@ -646,7 +646,7 @@ export default function ScriptViewAssetsPanel({
{showAddLoc && mounted && createPortal(
<div ref={locEditorPopoverRef} className="fixed right-4 bottom-4 z-[80] glass-surface-modal w-[min(24rem,calc(100vw-2rem))] h-[min(560px,calc(100vh-2rem))] p-3 animate-fadeIn flex flex-col shadow-2xl">
<div className="shrink-0 text-xs text-[var(--glass-text-tertiary)]">{tCommon('edit')} · {tScript('asset.activeLocations')}</div>
<div className="mt-3 flex-1 min-h-0 overflow-y-auto pr-1 custom-scrollbar">
<div className="mt-3 flex-1 min-h-0 overflow-y-auto pr-1 app-scrollbar">
{isAllClipsMode && (
<div className="mb-3 rounded-lg border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]/40 p-2 text-[11px] text-[var(--glass-text-tertiary)]">
@@ -775,7 +775,7 @@ export default function ScriptViewAssetsPanel({
{showAddProp && mounted && createPortal(
<div ref={propEditorPopoverRef} className="fixed right-4 bottom-4 z-[80] glass-surface-modal w-[min(24rem,calc(100vw-2rem))] h-[min(560px,calc(100vh-2rem))] p-3 animate-fadeIn flex flex-col shadow-2xl">
<div className="shrink-0 text-xs text-[var(--glass-text-tertiary)]">{tCommon('edit')} · </div>
<div className="mt-3 flex-1 min-h-0 overflow-y-auto pr-1 custom-scrollbar">
<div className="mt-3 flex-1 min-h-0 overflow-y-auto pr-1 app-scrollbar">
<div className="grid grid-cols-2 gap-2">
{props.map((prop) => {
const isSelected = pendingPropIds.has(prop.id)

View File

@@ -141,7 +141,7 @@ export default function ScriptViewScriptPanel({
</div>
<div className="flex-1 glass-surface-elevated overflow-hidden flex flex-col relative w-full min-h-[300px]">
<div className="lg:absolute lg:inset-0 overflow-y-auto p-6 space-y-4 custom-scrollbar">
<div className="lg:absolute lg:inset-0 overflow-y-auto p-6 space-y-4 app-scrollbar">
{clips.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-[var(--glass-text-tertiary)]">
<AppIcon name="fileFold" className="h-10 w-10 mb-2" />

View File

@@ -95,15 +95,15 @@ export default function ImageEditModal({
return (
<div className="fixed inset-0 bg-[var(--glass-overlay)] z-50 flex items-center justify-center p-4">
<div
className="bg-[var(--glass-bg-surface)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"
className="bg-[var(--glass-bg-surface)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"
onPaste={handlePaste}
>
<div className="p-6 border-b">
<div className="p-6 border-b shrink-0">
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">{t('imageEdit.title')}</h3>
<p className="text-sm text-[var(--glass-text-tertiary)] mt-1">{t('imageEdit.subtitle')}</p>
</div>
<div className="p-6 space-y-4">
<div className="p-6 space-y-4 overflow-y-auto app-scrollbar flex-1 min-h-0">
<div>
<label className="block text-sm font-medium text-[var(--glass-text-secondary)] mb-2">{t('prompts.aiInstruction')}</label>
<textarea

View File

@@ -26,16 +26,16 @@ export default function VideoPromptModal({
return (
<div className="fixed inset-0 bg-[var(--glass-overlay)] flex items-center justify-center z-50" onClick={onCancel}>
<div className="bg-[var(--glass-bg-surface)] rounded-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="bg-[var(--glass-bg-surface)] rounded-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}>
{/* 标题栏 */}
<div className="sticky top-0 bg-[var(--glass-bg-surface)] border-b px-6 py-4 flex items-center justify-between">
<div className="bg-[var(--glass-bg-surface)] border-b px-6 py-4 flex items-center justify-between shrink-0">
<h3 className="text-lg font-bold">{t('promptModal.title', { number: panelIndex + 1 })}</h3>
<button onClick={onCancel} className="text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]">
<AppIcon name="close" className="w-6 h-6" />
</button>
</div>
<div className="p-6 space-y-4">
<div className="p-6 space-y-4 overflow-y-auto app-scrollbar flex-1 min-h-0">
{/* 镜头信息 */}
<div className="p-3 bg-[var(--glass-bg-muted)] rounded-lg text-sm space-y-1">
<div className="flex items-center gap-2">

View File

@@ -93,8 +93,8 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
return (
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
<div className="glass-surface-modal max-w-lg w-full max-h-[85vh] overflow-y-auto">
<div className="p-6">
<div className="glass-surface-modal max-w-lg w-full max-h-[85vh] overflow-hidden flex flex-col">
<div className="p-6 overflow-y-auto app-scrollbar flex-1 min-h-0">
{/* 标题 */}
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">

View File

@@ -23,7 +23,7 @@ interface AssetGridProps {
isDownloading?: boolean
selectedFolderId: string | null
onImageClick?: (url: string) => void
onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => void
onImageEdit?: (type: 'character' | 'location' | 'prop', id: string, name: string, imageIndex: number, appearanceIndex?: number) => void
onVoiceDesign?: (characterId: string, characterName: string) => void
onCharacterEdit?: (character: unknown, appearance: unknown) => void
onLocationEdit?: (location: unknown, imageIndex: number) => void

View File

@@ -391,14 +391,14 @@ export function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDes
<input ref={voiceInputRef} type="file" accept="audio/*" onChange={handleUploadVoice} className="hidden" />
{/* 图片区域 */}
<div className="relative bg-[var(--glass-bg-muted)] min-h-[100px]">
<div className="relative aspect-[3/2] bg-[var(--glass-bg-muted)]">
{displayImageUrl ? (
<>
<MediaImageWithLoading
src={displayImageUrl}
alt={character.name}
containerClassName="w-full min-h-[120px]"
className="w-full h-auto object-contain cursor-zoom-in"
containerClassName="h-full w-full"
className="h-full w-full object-contain cursor-zoom-in"
onClick={() => onImageClick?.(displayImageUrl)}
/>
{/* 操作按钮 - 非生成时显示 */}
@@ -422,7 +422,7 @@ export function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDes
)}
</>
) : (
<div className="flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)]">
<div className="flex h-full flex-col items-center justify-center px-4 py-6 text-[var(--glass-text-tertiary)]">
<AppIcon name="image" className="w-12 h-12 mb-3" />
<ImageGenerationInlineCountButton
prefix={<span>{tAssets('image.generateCountPrefix')}</span>}

View File

@@ -172,8 +172,8 @@ export function CharacterEditModal({
return (
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
<div className="glass-surface-modal max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div className="p-6 space-y-4">
<div className="glass-surface-modal max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col">
<div className="p-6 space-y-4 overflow-y-auto app-scrollbar flex-1 min-h-0">
{/* 标题 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">

View File

@@ -24,6 +24,8 @@ import {
resolveDisplayImageSlots,
} from '@/lib/image-generation/slot-state'
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 LocationImage {
id: string
@@ -50,7 +52,7 @@ interface LocationCardProps {
location: Location
assetType?: 'location' | 'prop'
onImageClick?: (url: string) => void
onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number) => void
onImageEdit?: (type: 'character' | 'location' | 'prop', id: string, name: string, imageIndex: number) => void
onEdit?: (location: Location, imageIndex: number) => void
}
@@ -98,6 +100,7 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
})
const displaySlotCount = displaySelectionImages.length
const hasMultipleImages = generatedImageCount > 1
const singleImageAspectClassName = assetType === 'prop' ? 'aspect-[3/2]' : 'aspect-square'
const displayTaskPresentation = isTaskRunning
? resolveTaskPresentationState({
phase: 'processing',
@@ -380,14 +383,14 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" />
{/* 图片区域 */}
<div className="relative bg-[var(--glass-bg-muted)] min-h-[100px]">
<div className={`relative bg-[var(--glass-bg-muted)] ${singleImageAspectClassName}`}>
{displayImageUrl ? (
<>
<MediaImageWithLoading
src={displayImageUrl}
alt={location.name}
containerClassName="w-full min-h-[120px]"
className="w-full h-auto object-contain cursor-zoom-in"
containerClassName="h-full w-full"
className="h-full w-full object-contain cursor-zoom-in"
onClick={() => onImageClick?.(displayImageUrl)}
/>
{/* 操作按钮 - 非生成时显示 */}
@@ -396,8 +399,11 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
<button onClick={() => fileInputRef.current?.click()} disabled={uploadImage.isPending} className="glass-btn-base glass-btn-secondary h-7 w-7 rounded-full">
<AppIcon name="upload" className="w-4 h-4 text-[var(--glass-tone-success-fg)]" />
</button>
<button onClick={() => onImageEdit?.('location', location.id, location.name, currentImageIndex)} className="glass-btn-base glass-btn-tone-info h-7 w-7 rounded-full">
<AppIcon name="edit" className="w-4 h-4" />
<button
onClick={() => onImageEdit?.(assetType === 'prop' ? 'prop' : 'location', location.id, location.name, currentImageIndex)}
className={`h-7 w-7 rounded-full flex items-center justify-center transition-all active:scale-95 ${AI_EDIT_BUTTON_CLASS}`}
>
<AISparklesIcon className={`w-4 h-4 ${AI_EDIT_ICON_CLASS}`} />
</button>
<button onClick={() => handleGenerate()} className="glass-btn-base glass-btn-secondary h-7 w-7 rounded-full">
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
@@ -411,8 +417,8 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
)}
</>
) : (
<div className="flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)]">
<AppIcon name="globe2" className="w-12 h-12 mb-3" />
<div className="flex h-full flex-col items-center justify-center px-4 py-6 text-[var(--glass-text-tertiary)]">
<AppIcon name="image" className="w-12 h-12 mb-3" />
<ImageGenerationInlineCountButton
prefix={<span>{tAssets('image.generateCountPrefix')}</span>}
suffix={<span>{tAssets('image.generateCountSuffix')}</span>}

View File

@@ -163,8 +163,8 @@ export function LocationEditModal({
return (
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
<div className="glass-surface-modal max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div className="p-6 space-y-4">
<div className="glass-surface-modal max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col">
<div className="p-6 space-y-4 overflow-y-auto app-scrollbar flex-1 min-h-0">
{/* 标题 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">

View File

@@ -45,6 +45,7 @@ export default function AssetHubPage() {
})
const characterActions = useAssetActions({ scope: 'global', kind: 'character' })
const locationActions = useAssetActions({ scope: 'global', kind: 'location' })
const propActions = useAssetActions({ scope: 'global', kind: 'prop' })
const refreshAssets = useRefreshAssets({ scope: 'global' })
const loading = foldersLoading || assetsLoading
@@ -58,7 +59,7 @@ export default function AssetHubPage() {
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null)
const [previewImage, setPreviewImage] = useState<string | null>(null)
const [imageEditModal, setImageEditModal] = useState<{
type: 'character' | 'location'
type: 'character' | 'location' | 'prop'
id: string
name: string
imageIndex: number
@@ -101,6 +102,7 @@ export default function AssetHubPage() {
propId: string
propName: string
summary: string
description: string
variantId?: string
} | null>(null)
@@ -159,7 +161,7 @@ export default function AssetHubPage() {
}
// 打开图片编辑弹窗
const handleOpenImageEdit = (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => {
const handleOpenImageEdit = (type: 'character' | 'location' | 'prop', id: string, name: string, imageIndex: number, appearanceIndex?: number) => {
setImageEditModal({ type, id, name, imageIndex, appearanceIndex })
}
@@ -189,6 +191,15 @@ export default function AssetHubPage() {
}).catch(() => {
alert(t('editFailed'))
})
} else if (type === 'prop') {
void propActions.modifyRender({
id,
imageIndex,
modifyPrompt,
extraImageUrls,
}).catch(() => {
alert(t('editFailed'))
})
}
}
@@ -290,13 +301,14 @@ export default function AssetHubPage() {
id: string
name: string
summary: string | null
images: Array<{ id: string; imageIndex: number }>
images: Array<{ id: string; imageIndex: number; description: string | null }>
}
const variant = typedProp.images.find((image) => image.imageIndex === imageIndex)
setPropEditModal({
propId: typedProp.id,
propName: typedProp.name,
summary: typedProp.summary || '',
description: variant?.description || typedProp.summary || '',
variantId: variant?.id,
})
}
@@ -615,6 +627,7 @@ export default function AssetHubPage() {
propId={propEditModal.propId}
propName={propEditModal.propName}
summary={propEditModal.summary}
description={propEditModal.description}
variantId={propEditModal.variantId}
onClose={() => setPropEditModal(null)}
onRefresh={refreshAssets}

View File

@@ -0,0 +1,58 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { requireUserAuth, isErrorResponse } from '@/lib/api-auth'
import { apiHandler, ApiError } from '@/lib/api-errors'
import { TASK_TYPE } from '@/lib/task/types'
import { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'
export const POST = apiHandler(async (request: NextRequest) => {
const authResult = await requireUserAuth()
if (isErrorResponse(authResult)) return authResult
const { session } = authResult
const payload = await request.json().catch(() => ({}))
const propId = typeof payload?.propId === 'string' ? payload.propId.trim() : ''
const variantId = typeof payload?.variantId === 'string' ? payload.variantId.trim() : ''
const currentDescription = typeof payload?.currentDescription === 'string' ? payload.currentDescription.trim() : ''
const modifyInstruction = typeof payload?.modifyInstruction === 'string' ? payload.modifyInstruction.trim() : ''
if (!propId || !currentDescription || !modifyInstruction) {
throw new ApiError('INVALID_PARAMS')
}
const prop = await prisma.globalLocation.findFirst({
where: {
id: propId,
userId: session.user.id,
assetKind: 'prop',
},
select: {
id: true,
name: true,
},
})
if (!prop) {
throw new ApiError('NOT_FOUND')
}
const asyncTaskResponse = await maybeSubmitLLMTask({
request,
userId: session.user.id,
projectId: 'global-asset-hub',
type: TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP,
targetType: 'GlobalLocation',
targetId: variantId || propId,
routePath: '/api/asset-hub/ai-modify-prop',
body: {
propId,
propName: prop.name,
variantId: variantId || undefined,
currentDescription,
modifyInstruction,
},
dedupeKey: `asset_hub_ai_modify_prop:${propId}:${variantId || 'default'}`,
})
if (asyncTaskResponse) return asyncTaskResponse
throw new ApiError('INVALID_PARAMS')
})

View File

@@ -1,11 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'
import { NextRequest } from 'next/server'
import { requireUserAuth, isErrorResponse } from '@/lib/api-auth'
import { apiHandler, ApiError } from '@/lib/api-errors'
import { updateAssetRenderLabel } from '@/lib/assets/services/asset-label'
/**
* POST /api/asset-hub/update-asset-label
* 更新资产中心图片上的黑边标识符(修改名字后调用)
* 资产中心不再支持图片黑边标识更新
*/
export const POST = apiHandler(async (request: NextRequest) => {
const authResult = await requireUserAuth()
@@ -21,15 +20,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
void appearanceIndex
if (type === 'character' || type === 'location') {
await updateAssetRenderLabel({
scope: 'global',
kind: type,
assetId: id,
newName,
})
return NextResponse.json({ success: true })
if (type !== 'character' && type !== 'location') {
throw new ApiError('INVALID_PARAMS')
}
throw new ApiError('INVALID_PARAMS')
throw new ApiError('INVALID_PARAMS', {
code: 'GLOBAL_ASSET_LABEL_UPDATES_DISABLED',
message: 'Global asset images no longer support label updates',
})
})

View File

@@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { uploadObject, generateUniqueKey } from '@/lib/storage'
import sharp from 'sharp'
import { initializeFonts, createLabelSVG } from '@/lib/fonts'
import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'
import { requireUserAuth, isErrorResponse } from '@/lib/api-auth'
import { apiHandler, ApiError } from '@/lib/api-errors'
@@ -45,7 +44,6 @@ interface AssetHubUploadDb {
* 上传用户自定义图片作为角色或场景资产
*/
export const POST = apiHandler(async (request: NextRequest) => {
await initializeFonts()
const db = prisma as unknown as AssetHubUploadDb
// 🔐 统一权限验证
@@ -59,9 +57,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const id = formData.get('id') as string
const appearanceIndex = formData.get('appearanceIndex') as string | null
const imageIndex = formData.get('imageIndex') as string | null
const labelText = formData.get('labelText') as string
const labelTextValue = formData.get('labelText')
const labelText = typeof labelTextValue === 'string' ? labelTextValue : ''
if (!file || !type || !id || !labelText) {
if (!file || !type || !id || (type === 'location' && !labelText.trim())) {
throw new ApiError('INVALID_PARAMS')
}
@@ -69,18 +68,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const meta = await sharp(buffer).metadata()
const w = meta.width || 2160
const h = meta.height || 2160
const fontSize = Math.floor(h * 0.04)
const pad = Math.floor(fontSize * 0.5)
const barH = fontSize + pad * 2
const svg = await createLabelSVG(w, barH, fontSize, pad, labelText)
const processed = await sharp(buffer)
.extend({ top: barH, bottom: 0, left: 0, right: 0, background: { r: 0, g: 0, b: 0, alpha: 1 } })
.composite([{ input: svg, top: 0, left: 0 }])
.jpeg({ quality: 90, mozjpeg: true })
.toBuffer()

View File

@@ -0,0 +1,70 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'
import { apiHandler, ApiError } from '@/lib/api-errors'
import { TASK_TYPE } from '@/lib/task/types'
import { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'
export const POST = apiHandler(async (
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
) => {
const { projectId } = await context.params
const authResult = await requireProjectAuth(projectId)
if (isErrorResponse(authResult)) return authResult
const { session } = authResult
const body = await request.json().catch(() => ({}))
const propId = typeof body?.propId === 'string' ? body.propId.trim() : ''
const variantId = typeof body?.variantId === 'string' ? body.variantId.trim() : ''
const currentDescription = typeof body?.currentDescription === 'string' ? body.currentDescription.trim() : ''
const modifyInstruction = typeof body?.modifyInstruction === 'string' ? body.modifyInstruction.trim() : ''
if (!propId || !currentDescription || !modifyInstruction) {
throw new ApiError('INVALID_PARAMS')
}
const novelProject = await prisma.novelPromotionProject.findUnique({
where: { projectId },
select: { id: true },
})
if (!novelProject) {
throw new ApiError('NOT_FOUND')
}
const prop = await prisma.novelPromotionLocation.findFirst({
where: {
id: propId,
novelPromotionProjectId: novelProject.id,
assetKind: 'prop',
},
select: {
id: true,
name: true,
},
})
if (!prop) {
throw new ApiError('NOT_FOUND')
}
const asyncTaskResponse = await maybeSubmitLLMTask({
request,
userId: session.user.id,
projectId,
type: TASK_TYPE.AI_MODIFY_PROP,
targetType: 'NovelPromotionLocation',
targetId: variantId || propId,
routePath: `/api/novel-promotion/${projectId}/ai-modify-prop`,
body: {
propId,
propName: prop.name,
variantId: variantId || undefined,
currentDescription,
modifyInstruction,
},
dedupeKey: `ai_modify_prop:${propId}:${variantId || 'default'}`,
})
if (asyncTaskResponse) return asyncTaskResponse
throw new ApiError('INVALID_PARAMS')
})

View File

@@ -86,7 +86,7 @@ export default function AiWriteModal({
value={promptText}
onChange={(e) => setPromptText(e.target.value)}
placeholder={t('placeholder')}
className="glass-textarea-base custom-scrollbar h-36 px-4 py-3 text-sm resize-none placeholder:text-[var(--glass-text-tertiary)]"
className="glass-textarea-base app-scrollbar h-36 px-4 py-3 text-sm resize-none placeholder:text-[var(--glass-text-tertiary)]"
disabled={loading}
autoFocus
/>

View File

@@ -144,7 +144,7 @@ export function RatioSelector({
{isOpen && typeof document !== 'undefined' && createPortal(
<div
ref={panelRef}
className="glass-surface-modal z-[9999] p-3 overflow-y-auto custom-scrollbar"
className="glass-surface-modal z-[9999] p-3 overflow-y-auto app-scrollbar"
style={panelStyle}
>
<div className="grid grid-cols-5 gap-2">

View File

@@ -0,0 +1,138 @@
'use client'
import { useCallback, useState } from 'react'
import { AppIcon } from '@/components/ui/icons'
import TaskStatusInline from '@/components/task/TaskStatusInline'
import type { TaskPresentationState } from '@/lib/task/presentation'
import GlassModalShell from '@/components/ui/primitives/GlassModalShell'
interface AiModifyDescriptionFieldProps {
label: string
description: string
onDescriptionChange: (value: string) => void
descriptionPlaceholder: string
descriptionHeightClassName?: string
aiInstruction: string
onAiInstructionChange: (value: string) => void
aiInstructionPlaceholder: string
onAiModify: () => Promise<boolean> | boolean
isAiModifying: boolean
aiModifyingState: TaskPresentationState | null
actionLabel: string
cancelLabel: string
}
export function AiModifyDescriptionField({
label,
description,
onDescriptionChange,
descriptionPlaceholder,
descriptionHeightClassName = 'h-48',
aiInstruction,
onAiInstructionChange,
aiInstructionPlaceholder,
onAiModify,
isAiModifying,
aiModifyingState,
actionLabel,
cancelLabel,
}: AiModifyDescriptionFieldProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const handleCloseModal = useCallback(() => {
if (isAiModifying) return
setIsModalOpen(false)
}, [isAiModifying])
const handleConfirmModify = useCallback(async () => {
const didModify = await Promise.resolve(onAiModify())
if (didModify) {
setIsModalOpen(false)
}
}, [onAiModify])
return (
<div className="space-y-2">
<label className="glass-field-label block">
{label}
</label>
<div className="relative overflow-hidden rounded-2xl border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] transition-[border-color,box-shadow,background-color] hover:border-[var(--glass-stroke-strong)] focus-within:border-[var(--glass-stroke-focus)] focus-within:bg-[var(--glass-bg-surface-strong)] focus-within:shadow-[0_0_0_3px_var(--glass-focus-ring)]">
<textarea
value={description}
onChange={(event) => onDescriptionChange(event.target.value)}
className={`app-scrollbar w-full resize-none border-0 bg-transparent px-4 py-3 pb-16 text-sm leading-6 text-[var(--glass-text-primary)] outline-none placeholder:text-[var(--glass-text-tertiary)] ${descriptionHeightClassName}`}
placeholder={descriptionPlaceholder}
disabled={isAiModifying}
/>
<div className="pointer-events-none absolute bottom-4 right-4">
<button
type="button"
onClick={() => setIsModalOpen(true)}
disabled={isAiModifying}
className="glass-btn-base pointer-events-auto flex h-10 flex-shrink-0 items-center gap-1.5 border border-[var(--glass-stroke-strong)] bg-[var(--glass-bg-surface)] px-3 text-sm transition-all hover:border-[var(--glass-tone-info-fg)]/40 disabled:cursor-not-allowed disabled:opacity-50"
>
{isAiModifying ? (
<TaskStatusInline state={aiModifyingState} className="text-[var(--glass-tone-info-fg)] [&>span]:text-[var(--glass-tone-info-fg)] [&_svg]:text-[var(--glass-tone-info-fg)]" />
) : (
<>
<AppIcon name="sparkles" className="h-4 w-4 text-[#7c3aed]" />
<span
className="font-medium"
style={{
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{actionLabel}
</span>
</>
)}
</button>
</div>
</div>
<GlassModalShell
open={isModalOpen}
onClose={handleCloseModal}
title={actionLabel}
description={label}
size="sm"
footer={(
<div className="flex justify-end gap-3">
<button
type="button"
onClick={handleCloseModal}
disabled={isAiModifying}
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
>
{cancelLabel}
</button>
<button
type="button"
onClick={() => void handleConfirmModify()}
disabled={isAiModifying || !aiInstruction.trim()}
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg disabled:cursor-not-allowed disabled:opacity-50 flex items-center gap-2"
>
{isAiModifying ? (
<TaskStatusInline state={aiModifyingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
) : (
actionLabel
)}
</button>
</div>
)}
>
<div className="space-y-4">
<textarea
value={aiInstruction}
onChange={(event) => onAiInstructionChange(event.target.value)}
placeholder={aiInstructionPlaceholder}
className="glass-textarea-base app-scrollbar h-32 w-full resize-none px-4 py-3 text-sm"
disabled={isAiModifying}
autoFocus
/>
</div>
</GlassModalShell>
</div>
)
}

View File

@@ -15,6 +15,7 @@ import {
useUpdateProjectCharacterIntroduction,
useUpdateProjectCharacterName,
} from '@/lib/query/hooks'
import { AiModifyDescriptionField } from './AiModifyDescriptionField'
export interface CharacterEditModalProps {
mode: 'asset-hub' | 'project'
@@ -151,7 +152,7 @@ export function CharacterEditModal({
}
const handleAiModify = async () => {
if (!aiModifyInstruction.trim()) return
if (!aiModifyInstruction.trim()) return false
try {
setIsAiModifying(true)
@@ -167,8 +168,9 @@ export function CharacterEditModal({
setEditingDescription(data.modifiedDescription)
onUpdate?.(data.modifiedDescription)
setAiModifyInstruction('')
return true
}
return
return false
}
if (!appearanceId) throw new Error('Missing appearanceId')
@@ -182,11 +184,14 @@ export function CharacterEditModal({
setEditingDescription(data.modifiedDescription)
onUpdate?.(data.modifiedDescription)
setAiModifyInstruction('')
return true
}
return false
} catch (error: unknown) {
if (shouldShowError(error)) {
alert(`${t('modal.modifyFailed')}: ${getErrorMessage(error, t('errors.failed'))}`)
}
return false
} finally {
setIsAiModifying(false)
}
@@ -313,58 +318,21 @@ export function CharacterEditModal({
</div>
)}
<div className="space-y-2 glass-surface-soft p-4 rounded-lg border border-[var(--glass-stroke-base)]">
<label className="block text-sm font-medium text-[var(--glass-tone-info-fg)] flex items-center gap-2">
<AppIcon name="bolt" className="w-4 h-4" />
{t('modal.smartModify')}
</label>
<div className="flex gap-2">
<input
type="text"
value={aiModifyInstruction}
onChange={(e) => setAiModifyInstruction(e.target.value)}
placeholder={t('modal.modifyPlaceholderCharacter')}
className="glass-input-base flex-1 px-3 py-2"
disabled={isAiModifying}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAiModify()
}
}}
/>
<button
onClick={handleAiModify}
disabled={isAiModifying || !aiModifyInstruction.trim()}
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
>
{isAiModifying ? (
<TaskStatusInline state={aiModifyingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
) : (
<>
<AppIcon name="bolt" className="w-4 h-4" />
{t('modal.smartModify')}
</>
)}
</button>
</div>
<p className="glass-field-hint">
{t('modal.aiTipSub')}
</p>
</div>
<div className="space-y-2">
<label className="glass-field-label block">
{t('modal.appearancePrompt')}
</label>
<textarea
value={editingDescription}
onChange={(e) => setEditingDescription(e.target.value)}
className="glass-textarea-base w-full h-64 px-3 py-2 resize-none"
placeholder={t('modal.descPlaceholder')}
disabled={isAiModifying}
/>
</div>
<AiModifyDescriptionField
label={t('modal.appearancePrompt')}
description={editingDescription}
onDescriptionChange={setEditingDescription}
descriptionPlaceholder={t('modal.descPlaceholder')}
descriptionHeightClassName="h-64"
aiInstruction={aiModifyInstruction}
onAiInstructionChange={setAiModifyInstruction}
aiInstructionPlaceholder={t('modal.modifyPlaceholderCharacter')}
onAiModify={handleAiModify}
isAiModifying={isAiModifying}
aiModifyingState={aiModifyingState}
actionLabel={t('modal.modifyDescription')}
cancelLabel={t('common.cancel')}
/>
</div>
<div className="flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-lg flex-shrink-0">

View File

@@ -8,6 +8,13 @@ import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'
import { AppIcon } from '@/components/ui/icons'
import { apiFetch } from '@/lib/api-fetch'
import type {
AssetSummary,
CharacterAssetSummary,
LocationAssetSummary,
PropAssetSummary,
VoiceAssetSummary,
} from '@/lib/assets/contracts'
interface GlobalAssetPickerProps {
isOpen: boolean
@@ -17,67 +24,30 @@ interface GlobalAssetPickerProps {
loading?: boolean
}
interface GlobalCharacterAppearance {
id: string
imageUrl: string | null
imageUrls: string[]
selectedIndex: number | null
}
interface GlobalCharacter {
id: string
name: string
folderId: string | null
customVoiceUrl: string | null
appearances: GlobalCharacterAppearance[]
}
interface GlobalLocationImage {
id: string
imageIndex: number
imageUrl: string | null
isSelected: boolean
}
interface GlobalLocation {
id: string
name: string
summary: string | null
folderId: string | null
images: GlobalLocationImage[]
}
type GlobalProp = GlobalLocation
interface GlobalVoice {
id: string
name: string
description: string | null
folderId: string | null
customVoiceUrl: string | null
voiceId: string | null
voiceType: string
voicePrompt: string | null
gender: string | null
language: string
}
/** 从 appearances 中提取预览图 URL */
function getCharacterPreview(char: GlobalCharacter): string | null {
const first = char.appearances?.[0]
if (!first) return null
// 优先使用 selectedIndex 指向的图
if (first.selectedIndex != null && first.imageUrls?.[first.selectedIndex]) {
return first.imageUrls[first.selectedIndex]
}
return first.imageUrl || first.imageUrls?.[0] || null
function getCharacterPreview(char: CharacterAssetSummary): string | null {
const primaryVariant = char.variants.find((variant) => variant.index === 0) || char.variants[0]
if (!primaryVariant) return null
const selectedRenderIndex = primaryVariant.selectionState.selectedRenderIndex
const selectedRender = selectedRenderIndex !== null
? primaryVariant.renders.find((render) => render.index === selectedRenderIndex)
: null
return selectedRender?.imageUrl
|| primaryVariant.renders.find((render) => render.isSelected)?.imageUrl
|| primaryVariant.renders[0]?.imageUrl
|| null
}
/** 从 images 中提取预览图 URL */
function getLocationPreview(loc: GlobalLocation): string | null {
const selected = loc.images?.find(img => img.isSelected)
if (selected?.imageUrl) return selected.imageUrl
return loc.images?.[0]?.imageUrl || null
/** 从 variants/renders 中提取预览图 URL */
function getVisualAssetPreview(asset: LocationAssetSummary | PropAssetSummary): string | null {
const selectedVariant = asset.selectedVariantId
? asset.variants.find((variant) => variant.id === asset.selectedVariantId)
: null
const targetVariant = selectedVariant || asset.variants[0]
if (!targetVariant) return null
return targetVariant.renders.find((render) => render.isSelected)?.imageUrl
|| targetVariant.renders[0]?.imageUrl
|| null
}
// 内联 SVG 图标组件
@@ -122,7 +92,7 @@ export default function GlobalAssetPicker({
const res = await apiFetch('/api/assets?scope=global&kind=character')
if (!res.ok) throw new Error('Failed to fetch characters')
const data = await res.json()
return data.assets as GlobalCharacter[]
return data.assets as CharacterAssetSummary[]
},
enabled: type === 'character',
})
@@ -132,7 +102,7 @@ export default function GlobalAssetPicker({
const res = await apiFetch('/api/assets?scope=global&kind=location')
if (!res.ok) throw new Error('Failed to fetch locations')
const data = await res.json()
return data.assets as GlobalLocation[]
return data.assets as LocationAssetSummary[]
},
enabled: type === 'location',
})
@@ -142,7 +112,7 @@ export default function GlobalAssetPicker({
const res = await apiFetch('/api/assets?scope=global&kind=prop')
if (!res.ok) throw new Error('Failed to fetch props')
const data = await res.json()
return data.assets as GlobalProp[]
return data.assets as PropAssetSummary[]
},
enabled: type === 'prop',
})
@@ -152,15 +122,15 @@ export default function GlobalAssetPicker({
const res = await apiFetch('/api/assets?scope=global&kind=voice')
if (!res.ok) throw new Error('Failed to fetch voices')
const data = await res.json()
return data.assets as GlobalVoice[]
return data.assets as VoiceAssetSummary[]
},
enabled: type === 'voice',
})
const characters = (charactersQuery.data || []) as GlobalCharacter[]
const locations = (locationsQuery.data || []) as GlobalLocation[]
const props = (propsQuery.data || []) as GlobalProp[]
const voices = (voicesQuery.data || []) as GlobalVoice[]
const characters = (charactersQuery.data || []) as CharacterAssetSummary[]
const locations = (locationsQuery.data || []) as LocationAssetSummary[]
const props = (propsQuery.data || []) as PropAssetSummary[]
const voices = (voicesQuery.data || []) as VoiceAssetSummary[]
const isLoading = type === 'character'
? charactersQuery.isFetching
: type === 'location'
@@ -249,7 +219,7 @@ export default function GlobalAssetPicker({
const filteredVoices = voices.filter(v =>
v.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(v.description && v.description.toLowerCase().includes(searchQuery.toLowerCase()))
(v.voiceMeta.description && v.voiceMeta.description.toLowerCase().includes(searchQuery.toLowerCase()))
)
// 播放/暂停音频预览
@@ -302,9 +272,9 @@ export default function GlobalAssetPicker({
return (
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50">
<div className="glass-surface-modal w-[600px] max-h-[80vh] flex flex-col">
<div className="glass-surface-modal w-[600px] max-h-[80vh] flex flex-col">
{/* 头部 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--glass-stroke-base)]">
<div className="flex items-center justify-between px-6 py-4">
<h2 className="text-lg font-semibold text-[var(--glass-text-primary)]">
{type === 'character' ? t('selectCharacter') : type === 'location' ? t('selectLocation') : type === 'prop' ? t('selectProp') : t('selectVoice')}
</h2>
@@ -314,7 +284,7 @@ export default function GlobalAssetPicker({
</div>
{/* 搜索栏 */}
<div className="px-6 py-3 border-b border-[var(--glass-stroke-base)]">
<div className="px-6 pb-3">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--glass-text-tertiary)]" />
<input
@@ -369,13 +339,13 @@ export default function GlobalAssetPicker({
)}
{/* 预览图 */}
<div className="aspect-square rounded-lg overflow-hidden bg-[var(--glass-bg-muted)] mb-2 relative">
<div className="aspect-[3/2] rounded-lg overflow-hidden bg-[var(--glass-bg-muted)] mb-2 relative">
{charPreview ? (
<MediaImageWithLoading
src={charPreview}
alt={char.name}
containerClassName="w-full h-full"
className="w-full h-full object-cover cursor-zoom-in"
className="w-full h-full object-contain cursor-zoom-in"
onClick={(e) => {
e.stopPropagation()
setPreviewImage(charPreview)
@@ -383,7 +353,7 @@ export default function GlobalAssetPicker({
/>
) : (
<div className="w-full h-full flex items-center justify-center text-[var(--glass-text-tertiary)]">
<UserIcon className="w-12 h-12" />
<PhotoIcon className="w-12 h-12" />
</div>
)}
</div>
@@ -391,17 +361,13 @@ export default function GlobalAssetPicker({
{/* 名称 */}
<div className="text-center">
<p className="font-medium text-sm text-[var(--glass-text-primary)] truncate">{char.name}</p>
<p className="text-xs text-[var(--glass-text-secondary)] mt-1">
{char.appearances?.length || 0} {t('appearances')}
{char.customVoiceUrl && ' · Voice'}
</p>
</div>
</div>
)
})
) : type === 'location' ? (
filteredLocations.map((loc) => {
const locPreview = getLocationPreview(loc)
const locPreview = getVisualAssetPreview(loc)
return (
<div
key={loc.id}
@@ -440,7 +406,7 @@ export default function GlobalAssetPicker({
<div className="text-center">
<p className="font-medium text-sm text-[var(--glass-text-primary)] truncate">{loc.name}</p>
<p className="text-xs text-[var(--glass-text-secondary)] mt-1">
{loc.images?.length || 0} {t('images')}
{loc.variants.length} {t('images')}
</p>
</div>
</div>
@@ -448,7 +414,7 @@ export default function GlobalAssetPicker({
})
) : type === 'prop' ? (
filteredProps.map((prop) => {
const propPreview = getLocationPreview(prop)
const propPreview = getVisualAssetPreview(prop)
return (
<div
key={prop.id}
@@ -482,7 +448,7 @@ export default function GlobalAssetPicker({
<div className="text-center">
<p className="font-medium text-sm text-[var(--glass-text-primary)] truncate">{prop.name}</p>
<p className="text-xs text-[var(--glass-text-secondary)] mt-1">
{prop.images?.length || 0} {t('images')}
{prop.variants.length} {t('images')}
</p>
</div>
</div>
@@ -491,8 +457,9 @@ export default function GlobalAssetPicker({
) : (
// 音色列表渲染 - 与资产中心 VoiceCard 风格统一
filteredVoices.map((voice) => {
const genderIcon = voice.gender === 'male' ? 'M' : voice.gender === 'female' ? 'F' : ''
const isVoicePlaying = previewAudio === voice.customVoiceUrl && isPlayingAudio
const genderIcon = voice.voiceMeta.gender === 'male' ? 'M' : voice.voiceMeta.gender === 'female' ? 'F' : ''
const previewVoiceUrl = voice.voiceMeta.customVoiceUrl
const isVoicePlaying = previewAudio === previewVoiceUrl && isPlayingAudio
return (
<div
key={voice.id}
@@ -523,9 +490,9 @@ export default function GlobalAssetPicker({
)}
{/* 试听按钮 - 圆形,与 VoiceCard 统一 */}
{voice.customVoiceUrl && (
{previewVoiceUrl && (
<button
onClick={(e) => handlePlayAudio(voice.customVoiceUrl!, e)}
onClick={(e) => handlePlayAudio(previewVoiceUrl, e)}
className={`absolute bottom-2 right-2 w-10 h-10 rounded-full glass-btn-base flex items-center justify-center transition-all ${isVoicePlaying
? 'glass-btn-tone-info animate-pulse'
: 'glass-btn-secondary text-[var(--glass-tone-info-fg)]'
@@ -543,11 +510,11 @@ export default function GlobalAssetPicker({
{/* 信息区域 */}
<div className="p-3">
<h3 className="font-medium text-[var(--glass-text-primary)] text-sm truncate">{voice.name}</h3>
{voice.description && (
<p className="mt-1 text-xs text-[var(--glass-text-secondary)] line-clamp-2">{voice.description}</p>
{voice.voiceMeta.description && (
<p className="mt-1 text-xs text-[var(--glass-text-secondary)] line-clamp-2">{voice.voiceMeta.description}</p>
)}
{voice.voicePrompt && !voice.description && (
<p className="mt-1 text-xs text-[var(--glass-text-tertiary)] line-clamp-2 italic">{voice.voicePrompt}</p>
{voice.voiceMeta.voicePrompt && !voice.voiceMeta.description && (
<p className="mt-1 text-xs text-[var(--glass-text-tertiary)] line-clamp-2 italic">{voice.voiceMeta.voicePrompt}</p>
)}
</div>
</div>

View File

@@ -15,6 +15,7 @@ import {
useUpdateProjectLocationName,
} from '@/lib/query/hooks'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
import { AiModifyDescriptionField } from './AiModifyDescriptionField'
export interface LocationEditModalProps {
mode: 'asset-hub' | 'project'
@@ -129,7 +130,7 @@ export function LocationEditModal({
}
const handleAiModify = async () => {
if (!aiModifyInstruction.trim()) return
if (!aiModifyInstruction.trim()) return false
try {
setIsAiModifying(true)
@@ -146,8 +147,9 @@ export function LocationEditModal({
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
onUpdate?.(data.modifiedDescription)
setAiModifyInstruction('')
return true
}
return
return false
}
const data = await aiModifyProject.mutateAsync({
@@ -162,11 +164,14 @@ export function LocationEditModal({
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
onUpdate?.(nextDescription)
setAiModifyInstruction('')
return true
}
return false
} catch (error: unknown) {
if (shouldShowError(error)) {
alert(`${t('modal.modifyFailed')}: ${getErrorMessage(error, t('errors.failed'))}`)
}
return false
} finally {
setIsAiModifying(false)
}
@@ -262,58 +267,20 @@ export function LocationEditModal({
</div>
</div>
<div className="space-y-2 glass-surface-soft p-4 rounded-lg border border-[var(--glass-stroke-base)]">
<label className="block text-sm font-medium text-[var(--glass-tone-info-fg)] flex items-center gap-2">
<AppIcon name="bolt" className="w-4 h-4" />
{t('modal.smartModify')}
</label>
<div className="flex gap-2">
<input
type="text"
value={aiModifyInstruction}
onChange={(e) => setAiModifyInstruction(e.target.value)}
placeholder={t('modal.modifyPlaceholder')}
className="glass-input-base flex-1 px-3 py-2"
disabled={isAiModifying}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAiModify()
}
}}
/>
<button
onClick={handleAiModify}
disabled={isAiModifying || !aiModifyInstruction.trim()}
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
>
{isAiModifying ? (
<TaskStatusInline state={aiModifyingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
) : (
<>
<AppIcon name="bolt" className="w-4 h-4" />
{t('modal.smartModify')}
</>
)}
</button>
</div>
<p className="glass-field-hint">
{t('modal.aiLocationTip')}
</p>
</div>
<div className="space-y-2">
<label className="glass-field-label block">
{t('location.description')}
</label>
<textarea
value={editingDescription}
onChange={(e) => setEditingDescription(e.target.value)}
className="glass-textarea-base w-full h-48 px-3 py-2 resize-none"
placeholder={t('modal.descPlaceholder')}
disabled={isAiModifying}
/>
</div>
<AiModifyDescriptionField
label={t('location.description')}
description={editingDescription}
onDescriptionChange={setEditingDescription}
descriptionPlaceholder={t('modal.descPlaceholder')}
aiInstruction={aiModifyInstruction}
onAiInstructionChange={setAiModifyInstruction}
aiInstructionPlaceholder={t('modal.modifyPlaceholder')}
onAiModify={handleAiModify}
isAiModifying={isAiModifying}
aiModifyingState={aiModifyingState}
actionLabel={t('modal.modifyDescription')}
cancelLabel={t('common.cancel')}
/>
</div>
<div className="flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-lg flex-shrink-0">

View File

@@ -34,6 +34,7 @@ export function PropCreationModal({
const { count, setCount } = useImageGenerationCount('location')
const [name, setName] = useState('')
const [summary, setSummary] = useState('')
const [description, setDescription] = useState('')
const [artStyle, setArtStyle] = useState('american-comic')
const [isSubmitting, setIsSubmitting] = useState(false)
const submittingState = isSubmitting
@@ -56,12 +57,13 @@ export function PropCreationModal({
}, [isSubmitting, onClose])
const handleSubmit = async (generateAfterCreate: boolean) => {
if (!name.trim() || !summary.trim()) return
if (!name.trim() || !summary.trim() || !description.trim()) return
try {
setIsSubmitting(true)
const result = await actions.create({
name: name.trim(),
summary: summary.trim(),
description: description.trim(),
folderId,
artStyle,
}) as { assetId?: string }
@@ -112,9 +114,9 @@ export function PropCreationModal({
/>
</div>
<div className="space-y-2">
<label className="glass-field-label block">
{t('prop.summary')} <span className="text-[var(--glass-tone-danger-fg)]">*</span>
<div className="space-y-2">
<label className="glass-field-label block">
{t('prop.summary')} <span className="text-[var(--glass-tone-danger-fg)]">*</span>
</label>
<textarea
value={summary}
@@ -123,6 +125,18 @@ export function PropCreationModal({
className="glass-textarea-base w-full h-36 px-3 py-2 text-sm resize-none"
/>
</div>
<div className="space-y-2">
<label className="glass-field-label block">
{t('prop.description')} <span className="text-[var(--glass-tone-danger-fg)]">*</span>
</label>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder={t('prop.descriptionPlaceholder')}
className="glass-textarea-base w-full h-36 px-3 py-2 text-sm resize-none"
/>
</div>
</div>
</div>
@@ -136,7 +150,7 @@ export function PropCreationModal({
</button>
<button
onClick={() => void handleSubmit(false)}
disabled={isSubmitting || !name.trim() || !summary.trim()}
disabled={isSubmitting || !name.trim() || !summary.trim() || !description.trim()}
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm flex items-center gap-2"
>
{isSubmitting ? (
@@ -152,7 +166,7 @@ export function PropCreationModal({
options={getImageGenerationCountOptions('location')}
onValueChange={setCount}
onClick={() => void handleSubmit(true)}
actionDisabled={!name.trim() || !summary.trim()}
actionDisabled={!name.trim() || !summary.trim() || !description.trim()}
selectDisabled={isSubmitting}
ariaLabel={t('common.selectGenerateCount')}
className="glass-btn-base glass-btn-primary flex items-center justify-center gap-1 rounded-lg px-4 py-2 text-sm disabled:opacity-40 disabled:cursor-not-allowed"

View File

@@ -3,15 +3,22 @@
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { AppIcon } from '@/components/ui/icons'
import { shouldShowError } from '@/lib/error-utils'
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { useAssetActions } from '@/lib/query/hooks'
import {
useAiModifyProjectPropDescription,
useAiModifyPropDescription,
useAssetActions,
} from '@/lib/query/hooks'
import { AiModifyDescriptionField } from './AiModifyDescriptionField'
export interface PropEditModalProps {
mode: 'asset-hub' | 'project'
propId: string
propName: string
summary: string
description: string
variantId?: string
projectId?: string
onClose: () => void
@@ -23,6 +30,7 @@ export function PropEditModal({
propId,
propName,
summary,
description,
variantId,
projectId,
onClose,
@@ -36,7 +44,18 @@ export function PropEditModal({
})
const [editingName, setEditingName] = useState(propName)
const [editingSummary, setEditingSummary] = useState(summary)
const [editingDescription, setEditingDescription] = useState(description)
const [aiModifyInstruction, setAiModifyInstruction] = useState('')
const [isAiModifying, setIsAiModifying] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const aiModifyingState = isAiModifying
? resolveTaskPresentationState({
phase: 'processing',
intent: 'modify',
resource: 'image',
hasOutput: true,
})
: null
const savingState = isSaving
? resolveTaskPresentationState({
phase: 'processing',
@@ -45,6 +64,13 @@ export function PropEditModal({
hasOutput: false,
})
: null
const aiModifyAssetHub = useAiModifyPropDescription()
const aiModifyProject = useAiModifyProjectPropDescription(projectId ?? '')
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message) return error.message
return fallback
}
const persist = async () => {
await actions.update(propId, {
@@ -53,14 +79,49 @@ export function PropEditModal({
})
if (variantId) {
await actions.updateVariant(propId, variantId, {
description: editingSummary.trim(),
description: editingDescription.trim(),
})
}
onRefresh?.()
}
const handleAiModify = async () => {
if (!aiModifyInstruction.trim()) return false
try {
setIsAiModifying(true)
const data = mode === 'asset-hub'
? await aiModifyAssetHub.mutateAsync({
propId,
variantId,
currentDescription: editingDescription,
modifyInstruction: aiModifyInstruction,
})
: await aiModifyProject.mutateAsync({
propId,
variantId,
currentDescription: editingDescription,
modifyInstruction: aiModifyInstruction,
})
if (data?.modifiedDescription) {
setEditingDescription(data.modifiedDescription)
setAiModifyInstruction('')
return true
}
return false
} catch (error: unknown) {
if (shouldShowError(error)) {
alert(`${t('modal.modifyFailed')}: ${getErrorMessage(error, t('errors.failed'))}`)
}
return false
} finally {
setIsAiModifying(false)
}
}
const handleSaveOnly = async () => {
if (!editingName.trim() || !editingSummary.trim()) return
if (!editingName.trim() || !editingSummary.trim() || !editingDescription.trim()) return
try {
setIsSaving(true)
await persist()
@@ -71,7 +132,7 @@ export function PropEditModal({
}
const handleSaveAndGenerate = async () => {
if (!editingName.trim() || !editingSummary.trim()) return
if (!editingName.trim() || !editingSummary.trim() || !editingDescription.trim()) return
try {
setIsSaving(true)
await persist()
@@ -118,10 +179,25 @@ export function PropEditModal({
<textarea
value={editingSummary}
onChange={(event) => setEditingSummary(event.target.value)}
className="glass-textarea-base w-full h-48 px-3 py-2 resize-none"
className="glass-textarea-base h-28 w-full px-3 py-2 resize-none"
placeholder={t('prop.summaryPlaceholder')}
/>
</div>
<AiModifyDescriptionField
label={t('prop.description')}
description={editingDescription}
onDescriptionChange={setEditingDescription}
descriptionPlaceholder={t('prop.descriptionPlaceholder')}
aiInstruction={aiModifyInstruction}
onAiInstructionChange={setAiModifyInstruction}
aiInstructionPlaceholder={t('modal.modifyPlaceholderProp')}
onAiModify={handleAiModify}
isAiModifying={isAiModifying}
aiModifyingState={aiModifyingState}
actionLabel={t('modal.modifyDescription')}
cancelLabel={t('common.cancel')}
/>
</div>
<div className="flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-lg flex-shrink-0">
@@ -134,7 +210,7 @@ export function PropEditModal({
</button>
<button
onClick={() => void handleSaveOnly()}
disabled={isSaving || !editingName.trim() || !editingSummary.trim()}
disabled={isSaving || !editingName.trim() || !editingSummary.trim() || !editingDescription.trim()}
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isSaving ? (
@@ -145,7 +221,7 @@ export function PropEditModal({
</button>
<button
onClick={() => void handleSaveAndGenerate()}
disabled={isSaving || !editingName.trim() || !editingSummary.trim()}
disabled={isSaving || !editingName.trim() || !editingSummary.trim() || !editingDescription.trim()}
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('modal.saveAndGenerate')}

View File

@@ -125,7 +125,7 @@ export default function StoryInputComposer({
placeholder={placeholder}
rows={minRows}
disabled={disabled}
className={`w-full resize-none border-none bg-transparent text-base text-[var(--glass-text-primary)] outline-none placeholder:text-[var(--glass-text-tertiary)] custom-scrollbar ${textareaClassName ?? 'p-5 pb-3'}`}
className={`w-full resize-none border-none bg-transparent text-base text-[var(--glass-text-primary)] outline-none placeholder:text-[var(--glass-text-tertiary)] app-scrollbar ${textareaClassName ?? 'p-5 pb-3'}`}
/>
</div>

View File

@@ -237,7 +237,7 @@ export function EpisodeSelector({
{isOpen && (
<div className="glass-surface-modal absolute left-0 top-full mt-2 w-72 origin-top-left p-2 animate-fadeIn">
<div className="max-h-[300px] overflow-y-auto custom-scrollbar space-y-1">
<div className="max-h-[300px] overflow-y-auto app-scrollbar space-y-1">
{episodes.map(ep => {
const statusColor = ep.status?.visual === 'ready'
? 'bg-[var(--glass-tone-success-fg)]'

View File

@@ -364,7 +364,7 @@ export function SettingsModal({
</div>
</div>
<p className="text-[12px] text-[var(--glass-text-tertiary)] mb-6">{t('subtitle')}</p>
<div className="space-y-5 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
<div className="space-y-5 flex-1 min-h-0 overflow-y-auto app-scrollbar">
<div className="glass-surface-soft p-5 sm:p-6 space-y-4">
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('visualSettings')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">

View File

@@ -286,7 +286,7 @@ export function ModelCapabilityDropdown({
style={panelStyle}
>
{/* Model list */}
<div className="px-2 pb-2 min-h-0 flex-1 overflow-y-auto custom-scrollbar">
<div className="px-2 pb-2 min-h-0 flex-1 overflow-y-auto app-scrollbar">
{(() => {
// Group models by provider
const grouped = new Map<string, ModelCapabilityOption[]>()
@@ -340,7 +340,7 @@ export function ModelCapabilityDropdown({
<div className="text-[10px] font-bold text-[#8e8e93] uppercase tracking-wider mb-2.5">
{t('paramConfig')}
</div>
<div className="max-h-[156px] overflow-y-auto custom-scrollbar pr-1">
<div className="max-h-[156px] overflow-y-auto app-scrollbar pr-1">
<div className="space-y-3">
{visibleCapabilityFields.map((def) => {
const currentVal = capabilityOverrides[def.field] !== undefined

View File

@@ -94,7 +94,7 @@ export function WorldContextModal({ isOpen, onClose, text, onChange }: WorldCont
value={text}
onChange={(event) => handleTextChange(event.target.value)}
placeholder={t('placeholder')}
className="glass-textarea-base flex-1 text-base resize-none leading-relaxed placeholder:text-[var(--glass-text-tertiary)]/70 custom-scrollbar p-4"
className="glass-textarea-base app-scrollbar flex-1 text-base resize-none leading-relaxed placeholder:text-[var(--glass-text-tertiary)]/70 p-4"
/>
</div>

View File

@@ -70,7 +70,7 @@ export function RatioSelector({ value, onChange, options }: RatioSelectorProps)
{isOpen && (
<div
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar"
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto app-scrollbar"
style={{ minWidth: '300px' }}
>
<div className="grid grid-cols-5 gap-2">

View File

@@ -380,7 +380,7 @@ export function ModelDropdownV4(props: ModelDropdownTestProps) {
{/* Top: Models */}
<div className="px-3 pt-3 pb-2 bg-[var(--glass-bg-base)]">
<div className="text-[12px] font-bold text-[var(--glass-text-secondary)] mb-2 px-1"></div>
<div className="overflow-y-auto max-h-[160px] custom-scrollbar space-y-1 pr-1">
<div className="overflow-y-auto max-h-[160px] app-scrollbar space-y-1 pr-1">
{props.models.map(m => {
const active = m.value === props.value
return (

View File

@@ -134,7 +134,7 @@ export function SelectVariantCard({
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-xl border border-[var(--glass-stroke-base)] py-1"
style={panelStyle}
>
<div className="overflow-y-auto custom-scrollbar px-1 py-1 max-h-full">
<div className="overflow-y-auto app-scrollbar px-1 py-1 max-h-full">
{options.map((opt) => {
const isSelected = value === opt.value
return (
@@ -279,7 +279,7 @@ export function SelectVariantMinimal({
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] border border-[var(--glass-stroke-subtle)] py-1 bg-gradient-to-b from-[var(--glass-bg-surface-strong)] to-[var(--glass-bg-surface)] backdrop-blur-md"
style={panelStyle}
>
<div className="overflow-y-auto custom-scrollbar px-1 py-1 max-h-full">
<div className="overflow-y-auto app-scrollbar px-1 py-1 max-h-full">
{options.map((opt) => {
const isSelected = value === opt.value
return (
@@ -411,7 +411,7 @@ export function SelectVariantGhost({
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-lg border border-[var(--glass-stroke-subtle)] py-1"
style={panelStyle}
>
<div className="overflow-y-auto custom-scrollbar p-1 max-h-full space-y-0.5">
<div className="overflow-y-auto app-scrollbar p-1 max-h-full space-y-0.5">
{options.map((opt) => {
const isSelected = value === opt.value
return (

View File

@@ -0,0 +1,27 @@
import { removePropPromptSuffix } from '@/lib/constants'
function normalizeText(value: string | null | undefined): string {
return typeof value === 'string' ? value.trim() : ''
}
export function normalizePropVisualDescription(description: string | null | undefined): string {
return removePropPromptSuffix(normalizeText(description))
}
export function resolvePropVisualDescription(input: {
name: string
summary?: string | null
description?: string | null
}): string {
const explicitDescription = normalizePropVisualDescription(input.description)
if (explicitDescription) {
return explicitDescription
}
const summaryDescription = normalizePropVisualDescription(input.summary)
if (summaryDescription) {
return summaryDescription
}
return normalizeText(input.name)
}

View File

@@ -11,11 +11,11 @@ import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
import { ensureGlobalLocationImageSlots, ensureProjectLocationImageSlots } from '@/lib/image-generation/location-slots'
import { hasCharacterAppearanceOutput, hasGlobalCharacterAppearanceOutput, hasGlobalCharacterOutput, hasGlobalLocationImageOutput, hasGlobalLocationOutput, hasLocationImageOutput } from '@/lib/task/has-output'
import { sanitizeImageInputsForTaskPayload } from '@/lib/media/outbound-image'
import { PRIMARY_APPEARANCE_INDEX, isArtStyleValue, removeLocationPromptSuffix, type ArtStyleValue } from '@/lib/constants'
import { PRIMARY_APPEARANCE_INDEX, isArtStyleValue, removeLocationPromptSuffix, removePropPromptSuffix, type ArtStyleValue } from '@/lib/constants'
import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'
import { deleteObject } from '@/lib/storage'
import { resolveStorageKeyFromMediaValue } from '@/lib/media/service'
import { updateCharacterAppearanceLabels, updateLocationImageLabels } from '@/lib/image-label'
import { createProjectCharacterLabeledCopies, createProjectLocationLabeledCopies } from '@/lib/image-label'
import type { AssetKind, AssetScope } from '@/lib/assets/contracts'
import {
normalizeLocationAvailableSlots,
@@ -28,6 +28,8 @@ import {
deleteProjectLocationBackedAsset,
type LocationBackedAssetKind,
} from '@/lib/assets/services/location-backed-assets'
import { resolvePropVisualDescription } from '@/lib/assets/prop-description'
import { confirmProjectLocationBackedSelection } from '@/lib/assets/services/project-location-backed-selection'
type AssetWriteAccess = {
scope: AssetScope
@@ -162,7 +164,16 @@ async function submitGlobalAssetGenerateTask(input: AssetGenerateInput) {
if (normalizedKind === 'location' && toNumber(input.body.imageIndex) === null) {
const location = await prisma.globalLocation.findFirst({
where: { id: input.assetId, userId: input.access.userId },
select: { name: true, summary: true },
select: {
name: true,
summary: true,
assetKind: true,
images: {
orderBy: { imageIndex: 'asc' },
take: 1,
select: { description: true },
},
},
})
if (!location) {
throw new ApiError('NOT_FOUND')
@@ -170,13 +181,19 @@ async function submitGlobalAssetGenerateTask(input: AssetGenerateInput) {
await ensureGlobalLocationImageSlots({
locationId: input.assetId,
count,
fallbackDescription: location.summary || location.name,
fallbackDescription: location.assetKind === 'prop'
? resolvePropVisualDescription({
name: location.name,
summary: location.summary,
description: location.images[0]?.description ?? null,
})
: location.summary || location.name,
})
}
const payloadBase: Record<string, unknown> = normalizedKind === 'character'
? { ...input.body, id: input.assetId, type: normalizedKind, appearanceIndex, artStyle, count }
: { ...input.body, id: input.assetId, type: normalizedKind, artStyle, count }
? { ...input.body, id: input.assetId, type: input.kind, appearanceIndex, artStyle, count }
: { ...input.body, id: input.assetId, type: input.kind, artStyle, count }
const targetType = normalizedKind === 'character' ? 'GlobalCharacter' : 'GlobalLocation'
const hasOutputAtStart = normalizedKind === 'character'
? await hasGlobalCharacterOutput({
@@ -232,7 +249,16 @@ async function submitProjectAssetGenerateTask(input: AssetGenerateInput) {
if (normalizedKind === 'location' && imageIndex === null) {
const location = await prisma.novelPromotionLocation.findUnique({
where: { id: input.assetId },
select: { name: true, summary: true },
select: {
name: true,
summary: true,
assetKind: true,
images: {
orderBy: { imageIndex: 'asc' },
take: 1,
select: { description: true },
},
},
})
if (!location) {
throw new ApiError('NOT_FOUND')
@@ -240,7 +266,13 @@ async function submitProjectAssetGenerateTask(input: AssetGenerateInput) {
await ensureProjectLocationImageSlots({
locationId: input.assetId,
count,
fallbackDescription: location.summary || location.name,
fallbackDescription: location.assetKind === 'prop'
? resolvePropVisualDescription({
name: location.name,
summary: location.summary,
description: location.images[0]?.description ?? null,
})
: location.summary || location.name,
})
}
@@ -266,8 +298,8 @@ async function submitProjectAssetGenerateTask(input: AssetGenerateInput) {
? projectModelConfig.characterModel
: projectModelConfig.locationModel
const payloadBase = artStyle
? { ...input.body, type: normalizedKind, id: input.assetId, artStyle, count }
: { ...input.body, type: normalizedKind, id: input.assetId, count }
? { ...input.body, type: input.kind, id: input.assetId, artStyle, count }
: { ...input.body, type: input.kind, id: input.assetId, count }
let billingPayload: Record<string, unknown>
try {
@@ -374,7 +406,7 @@ async function submitGlobalAssetModifyTask(input: AssetModifyInput) {
const payload = {
...input.body,
id: input.assetId,
type: normalizedKind,
type: input.kind,
extraImageUrls: extraImageAudit.normalized,
meta: {
...toObject(input.body.meta),
@@ -444,7 +476,7 @@ async function submitProjectAssetModifyTask(input: AssetModifyInput) {
}
const payload = {
...input.body,
type: normalizedKind,
type: input.kind,
characterId: normalizedKind === 'character' ? input.assetId : undefined,
locationId: normalizedKind === 'location' ? input.assetId : undefined,
extraImageUrls: extraImageAudit.normalized,
@@ -607,12 +639,17 @@ async function selectProjectAssetRender(input: AssetSelectInput) {
})
return { success: true }
}
const confirm = input.body.confirm === true
if (confirm) {
return confirmProjectLocationBackedSelection(input.assetId)
}
const selectedIndex = toNumber(input.body.selectedIndex ?? input.body.imageIndex)
const location = await prisma.novelPromotionLocation.findUnique({
where: { id: input.assetId },
include: { images: { orderBy: { imageIndex: 'asc' } } },
})
if (!location) throw new ApiError('NOT_FOUND')
if (selectedIndex !== null) {
const targetImage = location.images.find((image) => image.imageIndex === selectedIndex)
if (!targetImage || !targetImage.imageUrl) {
@@ -786,7 +823,7 @@ async function copyCharacterFromGlobal(input: AssetCopyInput) {
if (projectCharacter.appearances.length > 0) {
await prisma.characterAppearance.deleteMany({ where: { characterId: input.targetId } })
}
const updatedLabels = await updateCharacterAppearanceLabels(
const labeledCopies = await createProjectCharacterLabeledCopies(
globalCharacter.appearances.map((appearance) => ({
imageUrl: appearance.imageUrl,
imageUrls: appearance.imageUrls || encodeImageUrls([]),
@@ -796,7 +833,7 @@ async function copyCharacterFromGlobal(input: AssetCopyInput) {
)
for (let index = 0; index < globalCharacter.appearances.length; index += 1) {
const appearance = globalCharacter.appearances[index]
const labelUpdate = updatedLabels[index]
const labeledCopy = labeledCopies[index]
const originalImageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'globalCharacterAppearance.imageUrls')
await prisma.characterAppearance.create({
data: {
@@ -805,8 +842,8 @@ async function copyCharacterFromGlobal(input: AssetCopyInput) {
changeReason: appearance.changeReason,
description: appearance.description,
descriptions: appearance.descriptions,
imageUrl: labelUpdate?.imageUrl || appearance.imageUrl,
imageUrls: labelUpdate?.imageUrls || encodeImageUrls(originalImageUrls),
imageUrl: labeledCopy?.imageUrl || appearance.imageUrl,
imageUrls: labeledCopy?.imageUrls || encodeImageUrls(originalImageUrls),
previousImageUrls: encodeImageUrls([]),
selectedIndex: appearance.selectedIndex,
},
@@ -840,21 +877,21 @@ async function copyLocationFromGlobal(input: AssetCopyInput) {
if (projectLocation.images.length > 0) {
await prisma.locationImage.deleteMany({ where: { locationId: input.targetId } })
}
const updatedLabels = await updateLocationImageLabels(
const labeledCopies = await createProjectLocationLabeledCopies(
globalLocation.images.map((image) => ({ imageUrl: image.imageUrl })),
projectLocation.name,
)
const copiedImages: Array<{ id: string; imageIndex: number; imageUrl: string | null }> = []
for (let index = 0; index < globalLocation.images.length; index += 1) {
const image = globalLocation.images[index]
const labelUpdate = updatedLabels[index]
const labeledCopy = labeledCopies[index]
const created = await prisma.locationImage.create({
data: {
locationId: input.targetId,
imageIndex: image.imageIndex,
description: image.description,
availableSlots: image.availableSlots,
imageUrl: labelUpdate?.imageUrl || image.imageUrl,
imageUrl: labeledCopy?.imageUrl || image.imageUrl,
isSelected: image.isSelected,
},
})
@@ -1048,7 +1085,7 @@ async function updateGlobalAssetVariant(input: AssetVariantUpdateInput) {
if (input.kind === 'prop') {
const trimmedDescription = normalizeString(input.body.description)
if (!trimmedDescription) throw new ApiError('INVALID_PARAMS')
const cleanDescription = removeLocationPromptSuffix(trimmedDescription)
const cleanDescription = removePropPromptSuffix(trimmedDescription)
const image = await prisma.globalLocationImage.update({
where: { id: input.variantId },
data: { description: cleanDescription },
@@ -1084,6 +1121,16 @@ async function updateProjectAssetVariant(input: AssetVariantUpdateInput) {
})
return { success: true }
}
if (input.kind === 'prop') {
const trimmedDescription = normalizeString(input.body.description)
if (!trimmedDescription) throw new ApiError('INVALID_PARAMS')
const cleanDescription = removePropPromptSuffix(trimmedDescription)
const image = await prisma.locationImage.update({
where: { id: input.variantId },
data: { description: cleanDescription },
})
return { success: true, image }
}
const trimmedDescription = normalizeString(input.body.description)
if (!trimmedDescription) throw new ApiError('INVALID_PARAMS')
const cleanDescription = removeLocationPromptSuffix(trimmedDescription)
@@ -1096,11 +1143,14 @@ async function updateProjectAssetVariant(input: AssetVariantUpdateInput) {
export async function createAsset(input: AssetCreateInput) {
const name = normalizeString(input.body.name)
const kind = requireLocationBackedKind(input.kind)
const summary = normalizeString(input.body.summary || input.body.description)
if (!name || !summary) {
const description = kind === 'prop'
? normalizeString(input.body.description)
: summary
if (!name || !summary || !description) {
throw new ApiError('INVALID_PARAMS')
}
const kind = requireLocationBackedKind(input.kind)
if (input.access.scope === 'global') {
const created = await createGlobalLocationBackedAsset({
@@ -1108,6 +1158,7 @@ export async function createAsset(input: AssetCreateInput) {
folderId: normalizeString(input.body.folderId) || null,
name,
summary,
initialDescription: description,
artStyle: normalizeString(input.body.artStyle) || null,
kind,
})
@@ -1125,6 +1176,7 @@ export async function createAsset(input: AssetCreateInput) {
novelPromotionProjectId: project.id,
name,
summary,
initialDescription: description,
kind,
})
return { success: true, assetId: created.id }

View File

@@ -1,4 +1,5 @@
import { prisma } from '@/lib/prisma'
import { ApiError } from '@/lib/api-errors'
import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'
import { updateImageLabel } from '@/lib/image-label'
import type { AssetKind, AssetScope } from '@/lib/assets/contracts'
@@ -24,77 +25,14 @@ export function renderLabelText(input: {
export async function updateAssetRenderLabel(input: UpdateAssetRenderLabelInput) {
if (input.scope === 'global') {
return updateGlobalAssetRenderLabel(input)
throw new ApiError('INVALID_PARAMS', {
code: 'GLOBAL_ASSET_LABEL_UPDATES_DISABLED',
message: 'Global asset images no longer support label updates',
})
}
return updateProjectAssetRenderLabel(input)
}
async function updateGlobalAssetRenderLabel(input: UpdateAssetRenderLabelInput) {
if (input.kind === 'character') {
const character = await prisma.globalCharacter.findUnique({
where: { id: input.assetId },
include: { appearances: true },
})
if (!character) {
throw new Error('Global character not found')
}
await Promise.all(character.appearances.map(async (appearance) => {
const newImageUrls = await Promise.all(
decodeImageUrlsFromDb(appearance.imageUrls, 'globalCharacterAppearance.imageUrls').map(async (imageUrl) => updateImageLabel(
imageUrl,
renderLabelText({
kind: 'character',
assetName: input.newName,
variantLabel: appearance.changeReason,
}),
{
generateNewKey: true,
keyPrefix: 'asset-label-rename',
},
)),
)
const firstImageUrl = newImageUrls[0] ?? null
await prisma.globalCharacterAppearance.update({
where: { id: appearance.id },
data: {
imageUrls: encodeImageUrls(newImageUrls),
imageUrl: firstImageUrl,
},
})
}))
return
}
const location = await prisma.globalLocation.findUnique({
where: { id: input.assetId },
include: { images: true },
})
if (!location) {
throw new Error('Global location not found')
}
await Promise.all(location.images.map(async (image) => {
if (!image.imageUrl) {
return
}
const nextImageUrl = await updateImageLabel(
image.imageUrl,
renderLabelText({
kind: input.kind === 'prop' ? 'prop' : 'location',
assetName: input.newName,
}),
{
generateNewKey: true,
keyPrefix: 'asset-label-rename',
},
)
await prisma.globalLocationImage.update({
where: { id: image.id },
data: { imageUrl: nextImageUrl },
})
}))
}
async function updateProjectAssetRenderLabel(input: UpdateAssetRenderLabelInput) {
if (!input.projectId) {
throw new Error('projectId is required for project assets')

View File

@@ -191,6 +191,7 @@ export async function createProjectLocationBackedAsset(input: {
novelPromotionProjectId: string
name: string
summary: string
initialDescription?: string
kind: LocationBackedAssetKind
}): Promise<{ id: string }> {
const id = randomUUID()
@@ -219,8 +220,8 @@ export async function createProjectLocationBackedAsset(input: {
`)
await seedProjectLocationBackedImageSlots({
locationId: id,
fallbackDescription: input.summary,
descriptions: [input.summary],
fallbackDescription: input.initialDescription ?? input.summary,
descriptions: [input.initialDescription ?? input.summary],
availableSlots: [],
})
return { id }
@@ -231,6 +232,7 @@ export async function createGlobalLocationBackedAsset(input: {
folderId?: string | null
name: string
summary: string
initialDescription?: string
artStyle?: string | null
kind: LocationBackedAssetKind
}): Promise<{ id: string }> {
@@ -260,8 +262,8 @@ export async function createGlobalLocationBackedAsset(input: {
`)
await seedGlobalLocationBackedImageSlots({
locationId: id,
fallbackDescription: input.summary,
descriptions: [input.summary],
fallbackDescription: input.initialDescription ?? input.summary,
descriptions: [input.initialDescription ?? input.summary],
availableSlots: [],
})
return { id }

View File

@@ -0,0 +1,75 @@
import { ApiError } from '@/lib/api-errors'
import { prisma } from '@/lib/prisma'
import { deleteObject } from '@/lib/storage'
import { resolveStorageKeyFromMediaValue } from '@/lib/media/service'
export async function confirmProjectLocationBackedSelection(assetId: string): Promise<{ success: true }> {
const location = await prisma.novelPromotionLocation.findUnique({
where: { id: assetId },
include: { images: { orderBy: { imageIndex: 'asc' } } },
})
if (!location) {
throw new ApiError('NOT_FOUND')
}
const selectedImage = location.selectedImageId
? location.images.find((image) => image.id === location.selectedImageId)
: location.images.find((image) => image.isSelected)
if (location.images.length <= 1) {
const onlyImage = location.images[0] ?? null
if (onlyImage) {
await prisma.$transaction(async (tx) => {
await tx.locationImage.update({
where: { id: onlyImage.id },
data: {
imageIndex: 0,
isSelected: true,
},
})
await tx.novelPromotionLocation.update({
where: { id: assetId },
data: { selectedImageId: onlyImage.id },
})
})
}
return { success: true }
}
if (!selectedImage || !selectedImage.imageUrl) {
throw new ApiError('INVALID_PARAMS')
}
const imagesToDelete = location.images.filter((image) => image.id !== selectedImage.id)
for (const image of imagesToDelete) {
if (!image.imageUrl) continue
const storageKey = await resolveStorageKeyFromMediaValue(image.imageUrl)
if (!storageKey) continue
try {
await deleteObject(storageKey)
} catch {
}
}
await prisma.$transaction(async (tx) => {
await tx.locationImage.deleteMany({
where: {
locationId: assetId,
id: { not: selectedImage.id },
},
})
await tx.locationImage.update({
where: { id: selectedImage.id },
data: {
imageIndex: 0,
isSelected: true,
},
})
await tx.novelPromotionLocation.update({
where: { id: assetId },
data: { selectedImageId: selectedImage.id },
})
})
return { success: true }
}

View File

@@ -39,6 +39,7 @@ const BILLABLE_TASK_TYPES = new Set<TaskType>([
TASK_TYPE.AI_STORY_EXPAND,
TASK_TYPE.AI_MODIFY_APPEARANCE,
TASK_TYPE.AI_MODIFY_LOCATION,
TASK_TYPE.AI_MODIFY_PROP,
TASK_TYPE.AI_MODIFY_SHOT_PROMPT,
TASK_TYPE.ANALYZE_SHOT_VARIANTS,
TASK_TYPE.AI_CREATE_CHARACTER,
@@ -51,6 +52,7 @@ const BILLABLE_TASK_TYPES = new Set<TaskType>([
TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION,
TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER,
TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION,
TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP,
TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,
])
@@ -284,6 +286,7 @@ export function buildDefaultTaskBillingInfo(taskType: TaskType, payload: AnyPayl
case TASK_TYPE.AI_STORY_EXPAND:
case TASK_TYPE.AI_MODIFY_APPEARANCE:
case TASK_TYPE.AI_MODIFY_LOCATION:
case TASK_TYPE.AI_MODIFY_PROP:
case TASK_TYPE.AI_MODIFY_SHOT_PROMPT:
case TASK_TYPE.ANALYZE_SHOT_VARIANTS:
case TASK_TYPE.AI_CREATE_CHARACTER:
@@ -296,6 +299,7 @@ export function buildDefaultTaskBillingInfo(taskType: TaskType, payload: AnyPayl
case TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION:
case TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER:
case TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION:
case TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP:
case TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER:
return buildTextTaskInfo(taskType, payload)
case TASK_TYPE.PANEL_VARIANT:

View File

@@ -191,15 +191,23 @@ export function getArtStylePrompt(
// 角色形象生成的系统后缀(始终添加到提示词末尾,不显示给用户)- 左侧面部特写+右侧三视图
export const CHARACTER_PROMPT_SUFFIX = '角色设定图画面分为左右两个区域【左侧区域】占约1/3宽度是角色的正面特写如果是人类则展示完整正脸如果是动物/生物则展示最具辨识度的正面形态【右侧区域】占约2/3宽度是角色三视图横向排列从左到右依次为正面全身、侧面全身、背面全身三视图高度一致。纯白色背景无其他元素。'
// 道具图片生成的系统后缀(固定白底三视图资产图)
export const PROP_PROMPT_SUFFIX = '道具设定图画面分为左右两个区域【左侧区域】占约1/3宽度是道具主体的主视图特写【右侧区域】占约2/3宽度是同一道具的三视图横向排列从左到右依次为正面、侧面、背面三视图高度一致。纯白色背景主体居中完整展示无人物、无手部、无桌面陈设、无环境背景、无其他元素。'
// 场景图片生成的系统后缀(已禁用四视图,直接生成单张场景图)
export const LOCATION_PROMPT_SUFFIX = ''
// 角色图生成比例(16:9横版左侧面部特写+右侧全身
export const CHARACTER_IMAGE_RATIO = '16:9'
// 角色资产图生成比例(当前角色设定图实际使用 3:2
export const CHARACTER_ASSET_IMAGE_RATIO = '3:2'
// 历史保留:旧注释中曾写 16:9但当前资产图生成统一以 CHARACTER_ASSET_IMAGE_RATIO 为准
export const CHARACTER_IMAGE_RATIO = CHARACTER_ASSET_IMAGE_RATIO
// 角色图片尺寸用于Seedream API
export const CHARACTER_IMAGE_SIZE = '3840x2160' // 16:9 横版
// 角色图片尺寸用于Banana API
export const CHARACTER_IMAGE_BANANA_RATIO = '3:2'
export const CHARACTER_IMAGE_BANANA_RATIO = CHARACTER_ASSET_IMAGE_RATIO
// 道具图片生成比例(与角色资产图保持一致)
export const PROP_IMAGE_RATIO = CHARACTER_ASSET_IMAGE_RATIO
// 场景图片生成比例1:1 正方形单张场景)
export const LOCATION_IMAGE_RATIO = '1:1'
@@ -221,6 +229,17 @@ export function addCharacterPromptSuffix(prompt: string): string {
return `${cleanPrompt}${cleanPrompt ? '' : ''}${CHARACTER_PROMPT_SUFFIX}`
}
export function removePropPromptSuffix(prompt: string): string {
if (!prompt) return ''
return prompt.replace(PROP_PROMPT_SUFFIX, '').replace(/$/, '').trim()
}
export function addPropPromptSuffix(prompt: string): string {
if (!prompt) return PROP_PROMPT_SUFFIX
const cleanPrompt = removePropPromptSuffix(prompt)
return `${cleanPrompt}${cleanPrompt ? '' : ''}${PROP_PROMPT_SUFFIX}`
}
// 从提示词中移除场景系统后缀(用于显示给用户)
export function removeLocationPromptSuffix(prompt: string): string {
if (!prompt) return ''

View File

@@ -1,171 +1,157 @@
import { logError as _ulogError } from '@/lib/logging/core'
/**
* 图片黑边标签处理工具
* 用于给图片添加/更新顶部的黑边文字标签
*/
import sharp from 'sharp'
import { uploadObject, getSignedUrl, generateUniqueKey, toFetchableUrl } from '@/lib/storage'
import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'
import { resolveStorageKeyFromMediaValue } from '@/lib/media/service'
import { initializeFonts, createLabelSVG } from '@/lib/fonts'
/**
* 更新图片的黑边标签(裁剪旧标签 + 添加新标签)
*
* @param imageUrl - 原始图片 URL 或 COS key
* @param newLabelText - 新的标签文本
* @param options - 可选配置
* @returns 更新后的 COS key
*/
async function downloadImageBuffer(imageUrl: string): Promise<Buffer> {
const storageKey = await resolveStorageKeyFromMediaValue(imageUrl)
if (!storageKey) {
throw new Error(`无法归一化媒体 key: ${imageUrl}`)
}
const signedUrl = getSignedUrl(storageKey, 3600)
const response = await fetch(toFetchableUrl(signedUrl))
if (!response.ok) {
throw new Error(`Failed to download image: ${response.status}`)
}
return Buffer.from(await response.arrayBuffer())
}
async function createLabeledImageBuffer(sourceBuffer: Buffer, labelText: string): Promise<Buffer> {
await initializeFonts()
const meta = await sharp(sourceBuffer).metadata()
const width = meta.width || 2160
const height = meta.height || 2160
const fontSize = Math.floor(height * 0.04)
const pad = Math.floor(fontSize * 0.5)
const barHeight = fontSize + pad * 2
const svg = await createLabelSVG(width, barHeight, fontSize, pad, labelText)
return await sharp(sourceBuffer)
.extend({
top: barHeight,
bottom: 0,
left: 0,
right: 0,
background: { r: 0, g: 0, b: 0, alpha: 1 },
})
.composite([{ input: svg, top: 0, left: 0 }])
.jpeg({ quality: 90, mozjpeg: true })
.toBuffer()
}
export async function updateImageLabel(
imageUrl: string,
newLabelText: string,
options?: {
/** 是否生成新的 key默认覆盖原 key */
generateNewKey?: boolean
/** 新 key 的前缀(仅当 generateNewKey=true 时有效) */
keyPrefix?: string
}
imageUrl: string,
newLabelText: string,
options?: {
generateNewKey?: boolean
keyPrefix?: string
},
): Promise<string> {
await initializeFonts()
const originalKey = await resolveStorageKeyFromMediaValue(imageUrl)
if (!originalKey) {
throw new Error(`无法归一化媒体 key: ${imageUrl}`)
}
const originalKey = await resolveStorageKeyFromMediaValue(imageUrl)
if (!originalKey) {
throw new Error(`无法归一化媒体 key: ${imageUrl}`)
}
const signedUrl = getSignedUrl(originalKey, 3600)
const buffer = await downloadImageBuffer(imageUrl)
const meta = await sharp(buffer).metadata()
const width = meta.width || 2160
const height = meta.height || 2160
const fontSize = Math.floor(height * 0.04)
const pad = Math.floor(fontSize * 0.5)
const barHeight = fontSize + pad * 2
const croppedBuffer = await sharp(buffer)
.extract({ left: 0, top: barHeight, width, height: height - barHeight })
.toBuffer()
const processed = await createLabeledImageBuffer(croppedBuffer, newLabelText)
// 下载图片
const response = await fetch(toFetchableUrl(signedUrl))
if (!response.ok) {
throw new Error(`Failed to download image: ${response.status}`)
}
const buffer = Buffer.from(await response.arrayBuffer())
const finalKey = options?.generateNewKey
? generateUniqueKey(options.keyPrefix || 'labeled-image', 'jpg')
: originalKey
// 获取图片元数据
const meta = await sharp(buffer).metadata()
const w = meta.width || 2160
const h = meta.height || 2160
// 计算标签条高度(与生成时一致:高度的 4%
const fontSize = Math.floor(h * 0.04)
const pad = Math.floor(fontSize * 0.5)
const barH = fontSize + pad * 2
// 裁剪掉顶部的旧标签条
const croppedBuffer = await sharp(buffer)
.extract({ left: 0, top: barH, width: w, height: h - barH })
.toBuffer()
// 创建新的 SVG 标签条
const svg = await createLabelSVG(w, barH, fontSize, pad, newLabelText)
// 添加新标签条到图片顶部
const processed = await sharp(croppedBuffer)
.extend({ top: barH, bottom: 0, left: 0, right: 0, background: { r: 0, g: 0, b: 0, alpha: 1 } })
.composite([{ input: svg, top: 0, left: 0 }])
.jpeg({ quality: 90, mozjpeg: true })
.toBuffer()
// 决定使用原始 key 还是生成新 key
const finalKey = options?.generateNewKey
? generateUniqueKey(options.keyPrefix || 'labeled-image', 'jpg')
: originalKey
await uploadObject(processed, finalKey)
return finalKey
await uploadObject(processed, finalKey)
return finalKey
}
/**
* 批量更新角色形象的标签
* 用于从资产中心复制角色到项目时更新标签
*/
export async function updateCharacterAppearanceLabels(
appearances: Array<{
imageUrl: string | null
imageUrls: string
changeReason: string
}>,
characterName: string
export async function createProjectCharacterLabeledCopies(
appearances: Array<{
imageUrl: string | null
imageUrls: string
changeReason: string
}>,
characterName: string,
): Promise<Array<{ imageUrl: string | null; imageUrls: string }>> {
const results: Array<{ imageUrl: string | null; imageUrls: string }> = []
const results: Array<{ imageUrl: string | null; imageUrls: string }> = []
for (const appearance of appearances) {
try {
// 获取图片 URLs
let imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'appearance.imageUrls')
if (imageUrls.length === 0 && appearance.imageUrl) {
imageUrls = [appearance.imageUrl]
}
for (const appearance of appearances) {
try {
let imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'appearance.imageUrls')
if (imageUrls.length === 0 && appearance.imageUrl) {
imageUrls = [appearance.imageUrl]
}
if (imageUrls.length === 0) {
results.push({ imageUrl: null, imageUrls: encodeImageUrls([]) })
continue
}
if (imageUrls.length === 0) {
results.push({ imageUrl: null, imageUrls: encodeImageUrls([]) })
continue
}
// 更新每张图片的标签
const newLabelText = `${characterName} - ${appearance.changeReason}`
const newImageUrls: string[] = await Promise.all(
imageUrls.map(async (url) => {
if (!url) return ''
try {
// 生成新的 key避免覆盖资产中心的原图
return await updateImageLabel(url, newLabelText, {
generateNewKey: true,
keyPrefix: `project-char-copy`
})
} catch (e) {
_ulogError(`Failed to update label for image:`, e)
return url // 失败时保留原 URL
}
})
)
const labelText = `${characterName} - ${appearance.changeReason}`
const labeledImageUrls = await Promise.all(
imageUrls.map(async (imageUrl) => {
if (!imageUrl) return ''
try {
const sourceBuffer = await downloadImageBuffer(imageUrl)
const processed = await createLabeledImageBuffer(sourceBuffer, labelText)
const newKey = generateUniqueKey('project-char-copy', 'jpg')
await uploadObject(processed, newKey)
return newKey
} catch (error) {
_ulogError('Failed to create project character labeled copy:', error)
return imageUrl
}
}),
)
const firstUrl = newImageUrls.find((u) => !!u) || null
results.push({
imageUrl: firstUrl,
imageUrls: encodeImageUrls(newImageUrls)
})
} catch (e) {
_ulogError('Failed to update appearance labels:', e)
results.push({ imageUrl: appearance.imageUrl, imageUrls: appearance.imageUrls })
}
results.push({
imageUrl: labeledImageUrls.find((url) => !!url) || null,
imageUrls: encodeImageUrls(labeledImageUrls),
})
} catch (error) {
_ulogError('Failed to copy project character images:', error)
results.push({ imageUrl: appearance.imageUrl, imageUrls: appearance.imageUrls })
}
}
return results
return results
}
/**
* 批量更新场景图片的标签
* 用于从资产中心复制场景到项目时更新标签
*/
export async function updateLocationImageLabels(
images: Array<{
imageUrl: string | null
}>,
locationName: string
export async function createProjectLocationLabeledCopies(
images: Array<{ imageUrl: string | null }>,
locationName: string,
): Promise<Array<{ imageUrl: string | null }>> {
const results: Array<{ imageUrl: string | null }> = []
const results: Array<{ imageUrl: string | null }> = []
for (const image of images) {
if (!image.imageUrl) {
results.push({ imageUrl: null })
continue
}
try {
// 生成新的 key避免覆盖资产中心的原图
const newImageUrl = await updateImageLabel(image.imageUrl, locationName, {
generateNewKey: true,
keyPrefix: `project-loc-copy`
})
results.push({ imageUrl: newImageUrl })
} catch (e) {
_ulogError('Failed to update location image label:', e)
results.push({ imageUrl: image.imageUrl })
}
for (const image of images) {
if (!image.imageUrl) {
results.push({ imageUrl: null })
continue
}
return results
try {
const sourceBuffer = await downloadImageBuffer(image.imageUrl)
const processed = await createLabeledImageBuffer(sourceBuffer, locationName)
const newKey = generateUniqueKey('project-location-copy', 'jpg')
await uploadObject(processed, newKey)
results.push({ imageUrl: newKey })
} catch (error) {
_ulogError('Failed to create project location labeled copy:', error)
results.push({ imageUrl: image.imageUrl })
}
}
return results
}

View File

@@ -51,6 +51,7 @@ const POLICY_BY_TASK_TYPE: Partial<Record<TaskType, LLMTaskPolicy>> = {
[TASK_TYPE.AI_STORY_EXPAND]: LLM_STANDARD_POLICY,
[TASK_TYPE.AI_MODIFY_APPEARANCE]: LLM_STANDARD_POLICY,
[TASK_TYPE.AI_MODIFY_LOCATION]: LLM_STANDARD_POLICY,
[TASK_TYPE.AI_MODIFY_PROP]: LLM_STANDARD_POLICY,
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: LLM_STANDARD_POLICY,
[TASK_TYPE.ANALYZE_SHOT_VARIANTS]: LLM_STANDARD_POLICY,
[TASK_TYPE.AI_CREATE_CHARACTER]: LLM_STANDARD_POLICY,
@@ -63,6 +64,7 @@ const POLICY_BY_TASK_TYPE: Partial<Record<TaskType, LLMTaskPolicy>> = {
[TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION]: LLM_STANDARD_POLICY,
[TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER]: LLM_STANDARD_POLICY,
[TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION]: LLM_STANDARD_POLICY,
[TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP]: LLM_STANDARD_POLICY,
[TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER]: LLM_STANDARD_POLICY,
}

View File

@@ -125,6 +125,10 @@ export const PROMPT_CATALOG: Record<PromptId, PromptCatalogEntry> = {
pathStem: 'novel-promotion/location_regenerate',
variableKeys: ['location_name', 'current_descriptions'],
},
[PROMPT_IDS.NP_PROP_DESCRIPTION_UPDATE]: {
pathStem: 'novel-promotion/prop_description_update',
variableKeys: ['prop_name', 'original_description', 'modify_instruction', 'image_context'],
},
[PROMPT_IDS.NP_SCREENPLAY_CONVERSION]: {
pathStem: 'novel-promotion/screenplay_conversion',
variableKeys: ['clip_content', 'locations_lib_name', 'characters_lib_name', 'props_lib_name', 'characters_introduction', 'clip_id'],

View File

@@ -22,6 +22,7 @@ export const PROMPT_IDS = {
NP_LOCATION_DESCRIPTION_UPDATE: 'np_location_description_update',
NP_LOCATION_MODIFY: 'np_location_modify',
NP_LOCATION_REGENERATE: 'np_location_regenerate',
NP_PROP_DESCRIPTION_UPDATE: 'np_prop_description_update',
NP_SCREENPLAY_CONVERSION: 'np_screenplay_conversion',
NP_SELECT_PROP: 'np_select_prop',
NP_SELECT_LOCATION: 'np_select_location',

View File

@@ -0,0 +1,5 @@
export function buildPropImagePromptCore(params: {
description: string
}): string {
return params.description.trim()
}

View File

@@ -8,6 +8,7 @@ const BAILIAN_CATALOG: Readonly<Record<OfficialModelModality, readonly string[]>
],
image: [],
video: [
'wan2.7-i2v',
'wan2.6-i2v-flash',
'wan2.6-i2v',
'wan2.5-i2v-preview',

View File

@@ -26,10 +26,14 @@ function assertRegistered(modelId: string): void {
const BAILIAN_VIDEO_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis'
const BAILIAN_KF2V_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis'
const BAILIAN_KF2V_MODELS = new Set([
const BAILIAN_FIRST_LAST_FRAME_ONLY_MODELS = new Set([
'wan2.2-kf2v-flash',
'wanx2.1-kf2v-plus',
])
const BAILIAN_FIRST_LAST_FRAME_CAPABLE_MODELS = new Set([
...BAILIAN_FIRST_LAST_FRAME_ONLY_MODELS,
'wan2.7-i2v',
])
interface BailianVideoSubmitResponse {
request_id?: string
@@ -71,8 +75,12 @@ function readOptionalPositiveInteger(value: unknown, fieldName: string): number
return value
}
function isKf2vModel(modelId: string): boolean {
return BAILIAN_KF2V_MODELS.has(modelId)
function supportsFirstLastFrame(modelId: string): boolean {
return BAILIAN_FIRST_LAST_FRAME_CAPABLE_MODELS.has(modelId)
}
function isFirstLastFrameOnlyModel(modelId: string): boolean {
return BAILIAN_FIRST_LAST_FRAME_ONLY_MODELS.has(modelId)
}
function assertNoUnsupportedOptions(options: BailianGenerateRequestOptions): void {
@@ -110,12 +118,12 @@ function buildSubmitRequest(params: BailianVideoGenerateParams): {
}
const firstFrameUrl = toFetchableUrl(imageUrl)
const kf2v = isKf2vModel(modelId)
const lastFrameImageUrl = readTrimmedString(params.options.lastFrameImageUrl)
if (kf2v && !lastFrameImageUrl) {
const firstLastFrame = !!lastFrameImageUrl
if (isFirstLastFrameOnlyModel(modelId) && !firstLastFrame) {
throw new Error('BAILIAN_VIDEO_LAST_FRAME_IMAGE_URL_REQUIRED')
}
if (!kf2v && lastFrameImageUrl) {
if (firstLastFrame && !supportsFirstLastFrame(modelId)) {
throw new Error(`BAILIAN_VIDEO_LAST_FRAME_UNSUPPORTED_FOR_MODEL: ${modelId}`)
}
@@ -128,7 +136,7 @@ function buildSubmitRequest(params: BailianVideoGenerateParams): {
const submitBody: BailianVideoSubmitBody = {
model: modelId,
input: kf2v
input: firstLastFrame
? {
first_frame_url: firstFrameUrl,
last_frame_url: toFetchableUrl(lastFrameImageUrl),
@@ -162,7 +170,7 @@ function buildSubmitRequest(params: BailianVideoGenerateParams): {
}
return {
endpoint: kf2v ? BAILIAN_KF2V_ENDPOINT : BAILIAN_VIDEO_ENDPOINT,
endpoint: firstLastFrame ? BAILIAN_KF2V_ENDPOINT : BAILIAN_VIDEO_ENDPOINT,
body: submitBody,
}
}

View File

@@ -52,6 +52,7 @@ export {
useUpdateLocationSummary,
useAiModifyCharacterDescription,
useAiModifyLocationDescription,
useAiModifyPropDescription,
useDesignAssetHubVoice,
useSaveDesignedAssetHubVoice,
useUploadAssetHubVoice,
@@ -98,6 +99,7 @@ export {
useUpdateProjectCharacterIntroduction,
useAiModifyProjectAppearanceDescription,
useAiModifyProjectLocationDescription,
useAiModifyProjectPropDescription,
useAiCreateProjectLocation,
useCreateProjectLocation,
useAiCreateProjectCharacter,

View File

@@ -3,6 +3,7 @@
import { useMemo } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api-fetch'
import { resolveTaskResponse } from '@/lib/task/client'
import { queryKeys } from '@/lib/query/keys'
import { useTaskTargetStateMap } from '@/lib/query/hooks/useTaskTargetStateMap'
import {
@@ -398,8 +399,9 @@ export function useAssetActions(input: AssetActionScopeInput) {
if (!response.ok) {
throw new Error('Failed to modify asset render')
}
const result = await resolveTaskResponse(response)
invalidateScopeQueries(queryClient, input)
return response.json()
return result
}
const copyFromGlobal = async (payload: { targetId: string; globalAssetId: string }) => {

View File

@@ -1,6 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { resolveTaskResponse } from '@/lib/task/client'
import { apiFetch } from '@/lib/api-fetch'
import {
requestJsonWithError,
requestTaskResponseWithError,
@@ -17,7 +16,7 @@ export function useUpdateCharacterName() {
return useMutation({
mutationFn: async ({ characterId, name }: { characterId: string; name: string }) => {
const res = await requestJsonWithError(`/api/assets/${characterId}`, {
return await requestJsonWithError(`/api/assets/${characterId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -26,23 +25,6 @@ export function useUpdateCharacterName() {
name,
}),
}, 'Failed to update character name')
// 等待图片标签更新完成,确保 onSuccess invalidate 后前端能立即看到新标签
try {
await apiFetch(`/api/assets/${characterId}/update-label`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scope: 'global',
kind: 'character',
newName: name,
}),
})
} catch (e) {
console.error('更新图片标签失败:', e)
}
return res
},
onSuccess: invalidateCharacters,
})
@@ -54,7 +36,7 @@ export function useUpdateLocationName() {
return useMutation({
mutationFn: async ({ locationId, name }: { locationId: string; name: string }) => {
const res = await requestJsonWithError(`/api/assets/${locationId}`, {
return await requestJsonWithError(`/api/assets/${locationId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -63,23 +45,6 @@ export function useUpdateLocationName() {
name,
}),
}, 'Failed to update location name')
// 等待图片标签更新完成,确保 onSuccess invalidate 后前端能立即看到新标签
try {
await apiFetch(`/api/assets/${locationId}/update-label`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scope: 'global',
kind: 'location',
newName: name,
}),
})
} catch (e) {
console.error('更新图片标签失败:', e)
}
return res
},
onSuccess: invalidateLocations,
})
@@ -219,3 +184,35 @@ export function useAiModifyLocationDescription() {
},
})
}
export function useAiModifyPropDescription() {
return useMutation({
mutationFn: async ({
propId,
variantId,
currentDescription,
modifyInstruction,
}: {
propId: string
variantId?: string
currentDescription: string
modifyInstruction: string
}) => {
const response = await requestTaskResponseWithError(
'/api/asset-hub/ai-modify-prop',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
propId,
variantId,
currentDescription,
modifyInstruction,
}),
},
'Failed to modify prop description',
)
return resolveTaskResponse<{ modifiedDescription?: string }>(response)
},
})
}

View File

@@ -8,7 +8,9 @@ import {
import {
invalidateQueryTemplates,
requestJsonWithError,
requestTaskResponseWithError,
} from './mutation-shared'
import { resolveTaskResponse } from '@/lib/task/client'
export function useModifyProjectCharacterImage(projectId: string) {
const queryClient = useQueryClient()
@@ -26,7 +28,7 @@ export function useModifyProjectCharacterImage(projectId: string) {
modifyPrompt: string
extraImageUrls?: string[]
}) => {
return await requestJsonWithError(`/api/assets/${params.characterId}/modify-render`, {
const response = await requestTaskResponseWithError(`/api/assets/${params.characterId}/modify-render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -36,6 +38,7 @@ export function useModifyProjectCharacterImage(projectId: string) {
...params,
}),
}, 'Failed to modify image')
return await resolveTaskResponse(response)
},
onMutate: ({ appearanceId }) => {
upsertTaskTargetOverlay(queryClient, {

View File

@@ -29,6 +29,7 @@ export {
useUpdateLocationSummary,
useAiModifyCharacterDescription,
useAiModifyLocationDescription,
useAiModifyPropDescription,
useUploadAssetHubTempMedia,
useAiDesignCharacter,
useExtractAssetHubReferenceCharacterDescription,

View File

@@ -10,7 +10,9 @@ import {
import {
invalidateQueryTemplates,
requestJsonWithError,
requestTaskResponseWithError,
} from './mutation-shared'
import { resolveTaskResponse } from '@/lib/task/client'
interface SelectProjectLocationImageContext {
previousAssets: ProjectAssetsData | undefined
@@ -187,7 +189,7 @@ export function useModifyProjectLocationImage(projectId: string) {
modifyPrompt: string
extraImageUrls?: string[]
}) => {
return await requestJsonWithError(`/api/assets/${params.locationId}/modify-render`, {
const response = await requestTaskResponseWithError(`/api/assets/${params.locationId}/modify-render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -197,6 +199,7 @@ export function useModifyProjectLocationImage(projectId: string) {
...params,
}),
}, 'Failed to modify image')
return await resolveTaskResponse(response)
},
onMutate: ({ locationId }) => {
upsertTaskTargetOverlay(queryClient, {

View File

@@ -208,6 +208,38 @@ export function useAiModifyProjectLocationDescription(projectId: string) {
})
}
export function useAiModifyProjectPropDescription(projectId: string) {
return useMutation({
mutationFn: async ({
propId,
variantId,
currentDescription,
modifyInstruction,
}: {
propId: string
variantId?: string
currentDescription: string
modifyInstruction: string
}) => {
const response = await requestTaskResponseWithError(
`/api/novel-promotion/${projectId}/ai-modify-prop`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
propId,
variantId,
currentDescription,
modifyInstruction,
}),
},
'Failed to modify prop description',
)
return resolveTaskResponse<{ modifiedDescription?: string }>(response)
},
})
}
/**
* AI 设计项目场景描述
*/
@@ -263,18 +295,26 @@ export function useCreateProjectLocation(projectId: string) {
* AI 设计项目角色文案
*/
export function useConfirmProjectLocationSelection(projectId: string) {
export function useConfirmProjectLocationSelection(
projectId: string,
kind: 'location' | 'prop' = 'location',
) {
const queryClient = useQueryClient()
const invalidateProjectAssets = () =>
invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])
return useMutation({
mutationFn: async ({ locationId }: { locationId: string }) =>
await requestJsonWithError(
`/api/novel-promotion/${projectId}/location/confirm-selection`,
`/api/assets/${locationId}/select-render`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locationId }),
body: JSON.stringify({
scope: 'project',
kind,
projectId,
confirm: true,
}),
},
'确认选择失败',
),

View File

@@ -47,6 +47,7 @@ const TASK_INTENT_BY_TYPE: Record<TaskType, TaskIntent> = {
[TASK_TYPE.AI_STORY_EXPAND]: 'generate',
[TASK_TYPE.AI_MODIFY_APPEARANCE]: 'modify',
[TASK_TYPE.AI_MODIFY_LOCATION]: 'modify',
[TASK_TYPE.AI_MODIFY_PROP]: 'modify',
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'modify',
[TASK_TYPE.ANALYZE_SHOT_VARIANTS]: 'analyze',
[TASK_TYPE.AI_CREATE_CHARACTER]: 'generate',
@@ -59,6 +60,7 @@ const TASK_INTENT_BY_TYPE: Record<TaskType, TaskIntent> = {
[TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION]: 'generate',
[TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER]: 'modify',
[TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION]: 'modify',
[TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP]: 'modify',
[TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER]: 'process',
}

View File

@@ -26,6 +26,7 @@ const TASK_TYPE_LABELS: Record<string, string> = {
[TASK_TYPE.AI_STORY_EXPAND]: 'progress.taskType.aiStoryExpand',
[TASK_TYPE.AI_MODIFY_APPEARANCE]: 'progress.taskType.aiModifyAppearance',
[TASK_TYPE.AI_MODIFY_LOCATION]: 'progress.taskType.aiModifyLocation',
[TASK_TYPE.AI_MODIFY_PROP]: 'progress.taskType.aiModifyProp',
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'progress.taskType.aiModifyShotPrompt',
[TASK_TYPE.ANALYZE_SHOT_VARIANTS]: 'progress.taskType.analyzeShotVariants',
[TASK_TYPE.AI_CREATE_CHARACTER]: 'progress.taskType.aiCreateCharacter',
@@ -38,6 +39,7 @@ const TASK_TYPE_LABELS: Record<string, string> = {
[TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION]: 'progress.taskType.assetHubAiDesignLocation',
[TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER]: 'progress.taskType.assetHubAiModifyCharacter',
[TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION]: 'progress.taskType.assetHubAiModifyLocation',
[TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP]: 'progress.taskType.assetHubAiModifyProp',
[TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER]: 'progress.taskType.assetHubReferenceToCharacter',
}

View File

@@ -63,6 +63,7 @@ export const TASK_TYPE = {
AI_STORY_EXPAND: 'ai_story_expand',
AI_MODIFY_APPEARANCE: 'ai_modify_appearance',
AI_MODIFY_LOCATION: 'ai_modify_location',
AI_MODIFY_PROP: 'ai_modify_prop',
AI_MODIFY_SHOT_PROMPT: 'ai_modify_shot_prompt',
ANALYZE_SHOT_VARIANTS: 'analyze_shot_variants',
AI_CREATE_CHARACTER: 'ai_create_character',
@@ -75,6 +76,7 @@ export const TASK_TYPE = {
ASSET_HUB_AI_DESIGN_LOCATION: 'asset_hub_ai_design_location',
ASSET_HUB_AI_MODIFY_CHARACTER: 'asset_hub_ai_modify_character',
ASSET_HUB_AI_MODIFY_LOCATION: 'asset_hub_ai_modify_location',
ASSET_HUB_AI_MODIFY_PROP: 'asset_hub_ai_modify_prop',
ASSET_HUB_REFERENCE_TO_CHARACTER: 'asset_hub_reference_to_character',
} as const

View File

@@ -11,6 +11,7 @@ import {
} from './analyze-global-parse'
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
import { resolvePropVisualDescription } from '@/lib/assets/prop-description'
export type AnalyzeGlobalStats = {
totalChunks: number
@@ -198,7 +199,12 @@ export async function persistAnalyzeGlobalChunk(params: {
for (const prop of params.propsData.props || []) {
const name = readText(prop.name).trim()
const summary = readText(prop.summary).trim()
if (!name || !summary) {
const description = resolvePropVisualDescription({
name,
summary,
description: readText(prop.description).trim(),
})
if (!name || !summary || !description) {
params.stats.skippedProps += 1
continue
}
@@ -220,8 +226,8 @@ export async function persistAnalyzeGlobalChunk(params: {
})
await seedProjectLocationBackedImageSlots({
locationId: created.id,
descriptions: [summary],
fallbackDescription: summary,
descriptions: [description],
fallbackDescription: description,
availableSlots: [],
})
params.existingPropNames.push(name)

View File

@@ -12,6 +12,7 @@ import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
import { resolveAnalysisModel } from './resolve-analysis-model'
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
import { resolvePropVisualDescription } from '@/lib/assets/prop-description'
function readAssetKind(value: Record<string, unknown>): string {
return typeof value.assetKind === 'string' ? value.assetKind : 'location'
@@ -337,7 +338,12 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
for (const item of parsedProps) {
const name = readText(item.name).trim()
const summary = readText(item.summary).trim()
if (!name || !summary) continue
const description = resolvePropVisualDescription({
name,
summary,
description: readText(item.description).trim(),
})
if (!name || !summary || !description) continue
const normalizedName = name.toLowerCase()
if (existingPropNameSet.has(normalizedName)) continue
@@ -353,8 +359,8 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
})
await seedProjectLocationBackedImageSlots({
locationId: created.id,
descriptions: [summary],
fallbackDescription: summary,
descriptions: [description],
fallbackDescription: description,
availableSlots: [],
})
existingPropNameSet.add(normalizedName)

View File

@@ -1,7 +1,7 @@
import type { Job } from 'bullmq'
import { executeAiTextStep } from '@/lib/ai-runtime'
import { getUserModelConfig } from '@/lib/config-service'
import { removeCharacterPromptSuffix, removeLocationPromptSuffix } from '@/lib/constants'
import { removeCharacterPromptSuffix, removeLocationPromptSuffix, removePropPromptSuffix } from '@/lib/constants'
import { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'
import { reportTaskProgress } from '@/lib/workers/shared'
import { assertTaskActive } from '@/lib/workers/utils'
@@ -44,11 +44,12 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
const isCharacter = job.data.type === TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER
const isLocation = job.data.type === TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION
if (!isCharacter && !isLocation) {
const isProp = job.data.type === TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP
if (!isCharacter && !isLocation && !isProp) {
throw new Error(`Unsupported task type: ${job.data.type}`)
}
const targetIdField = isCharacter ? 'characterId' : 'locationId'
const targetIdField = isCharacter ? 'characterId' : isProp ? 'propId' : 'locationId'
const targetId = readRequiredString(payload[targetIdField], targetIdField)
const modifyInstruction = readRequiredString(payload.modifyInstruction, 'modifyInstruction')
const currentDescriptionRaw = readRequiredString(payload.currentDescription, 'currentDescription')
@@ -62,6 +63,17 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
user_input: modifyInstruction,
},
})
: isProp
? buildPrompt({
promptId: PROMPT_IDS.NP_PROP_DESCRIPTION_UPDATE,
locale: job.data.locale,
variables: {
prop_name: readRequiredString(payload.propName || '道具', 'propName'),
original_description: removePropPromptSuffix(currentDescriptionRaw),
modify_instruction: modifyInstruction,
image_context: '',
},
})
: buildPrompt({
promptId: PROMPT_IDS.NP_LOCATION_MODIFY,
locale: job.data.locale,
@@ -79,7 +91,12 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
})
await assertTaskActive(job, 'asset_hub_ai_modify_prepare')
const streamContext = createWorkerLLMStreamContext(job, isCharacter ? 'asset_hub_ai_modify_character' : 'asset_hub_ai_modify_location')
const streamContextKey = isCharacter
? 'asset_hub_ai_modify_character'
: isProp
? 'asset_hub_ai_modify_prop'
: 'asset_hub_ai_modify_location'
const streamContext = createWorkerLLMStreamContext(job, streamContextKey)
const streamCallbacks = createWorkerLLMStreamCallbacks(job, streamContext)
const completion = await withInternalLLMStreamCallbacks(
@@ -91,10 +108,10 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
messages: [{ role: 'user', content: finalPrompt }],
temperature: 0.7,
projectId: 'asset-hub',
action: isCharacter ? 'ai_modify_character' : 'ai_modify_location',
action: isCharacter ? 'ai_modify_character' : isProp ? 'ai_modify_prop' : 'ai_modify_location',
meta: {
stepId: isCharacter ? 'asset_hub_ai_modify_character' : 'asset_hub_ai_modify_location',
stepTitle: isCharacter ? '角色描述修改' : '场景描述修改',
stepId: streamContextKey,
stepTitle: isCharacter ? '角色描述修改' : isProp ? '道具描述修改' : '场景描述修改',
stepIndex: 1,
stepTotal: 1,
},
@@ -110,7 +127,7 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
stageLabel: '资产修改结果已生成',
displayMode: 'detail',
meta: {
targetType: isCharacter ? 'character' : 'location',
targetType: isCharacter ? 'character' : isProp ? 'prop' : 'location',
targetId,
},
})

View File

@@ -1,18 +1,19 @@
import { type Job } from 'bullmq'
import { prisma } from '@/lib/prisma'
import { addCharacterPromptSuffix, addLocationPromptSuffix, getArtStylePrompt } from '@/lib/constants'
import { CHARACTER_ASSET_IMAGE_RATIO, LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO, addCharacterPromptSuffix, addLocationPromptSuffix, addPropPromptSuffix, getArtStylePrompt } from '@/lib/constants'
import { type TaskJobData } from '@/lib/task/types'
import { encodeImageUrls } from '@/lib/contracts/image-urls-contract'
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
import { buildLocationImagePromptCore } from '@/lib/location-image-prompt'
import { buildPropImagePromptCore } from '@/lib/prop-image-prompt'
import {
assertTaskActive,
getUserModels,
} from '../utils'
import {
AnyObj,
generateLabeledImageToCos,
generateCleanImageToStorage,
parseJsonStringArray,
} from './image-task-handler-shared'
@@ -93,19 +94,18 @@ export async function handleAssetHubImageTask(job: Job<TaskJobData>) {
for (let i = 0; i < count; i++) {
const raw = base[i] || base[0]
const prompt = artStyle ? `${addCharacterPromptSuffix(raw)}${artStyle}` : addCharacterPromptSuffix(raw)
const cosKey = await generateLabeledImageToCos({
const imageKey = await generateCleanImageToStorage({
job,
userId,
modelId,
prompt,
label: `${character.name} - ${appearance.changeReason || '形象'}`,
targetId: `${appearance.id}-${i}`,
keyPrefix: 'global-character',
options: {
aspectRatio: '3:2',
aspectRatio: CHARACTER_ASSET_IMAGE_RATIO,
},
})
imageUrls.push(cosKey)
imageUrls.push(imageKey)
}
await assertTaskActive(job, 'persist_global_character_image')
@@ -121,7 +121,7 @@ export async function handleAssetHubImageTask(job: Job<TaskJobData>) {
return { type: payload.type, appearanceId: appearance.id, imageCount: imageUrls.length }
}
if (payload.type === 'location') {
if (payload.type === 'location' || payload.type === 'prop') {
const locationId = typeof payload.id === 'string' ? payload.id : null
if (!locationId) throw new Error('Global location id missing')
@@ -142,30 +142,37 @@ export async function handleAssetHubImageTask(job: Job<TaskJobData>) {
for (const image of targetImages) {
if (!image.description) continue
const promptCore = buildLocationImagePromptCore({
description: image.description,
availableSlotsRaw: image.availableSlots,
locale: job.data.locale === 'en' ? 'en' : 'zh',
})
const prompt = artStyle ? `${addLocationPromptSuffix(promptCore)}${artStyle}` : addLocationPromptSuffix(promptCore)
const promptCore = payload.type === 'prop'
? buildPropImagePromptCore({
description: image.description,
})
: buildLocationImagePromptCore({
description: image.description,
availableSlotsRaw: image.availableSlots,
locale: job.data.locale === 'en' ? 'en' : 'zh',
})
const promptWithSuffix = payload.type === 'prop'
? addPropPromptSuffix(promptCore)
: addLocationPromptSuffix(promptCore)
const prompt = artStyle ? `${promptWithSuffix}${artStyle}` : promptWithSuffix
const aspectRatio = payload.type === 'prop' ? PROP_IMAGE_RATIO : LOCATION_IMAGE_RATIO
const cosKey = await generateLabeledImageToCos({
const imageKey = await generateCleanImageToStorage({
job,
userId,
modelId,
prompt,
label: location.name,
targetId: image.id,
keyPrefix: 'global-location',
options: {
aspectRatio: '1:1',
aspectRatio,
},
})
await assertTaskActive(job, 'persist_global_location_image')
await db.globalLocationImage.update({
where: { id: image.id },
data: { imageUrl: cosKey },
data: { imageUrl: imageKey },
})
}

View File

@@ -1,14 +1,13 @@
import { type Job } from 'bullmq'
import { prisma } from '@/lib/prisma'
import { LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO } from '@/lib/constants'
import { type TaskJobData } from '@/lib/task/types'
import {
assertTaskActive,
getUserModels,
resolveImageSourceFromGeneration,
stripLabelBar,
toSignedUrlIfCos,
uploadImageSourceToCos,
withLabelBar,
} from '../utils'
import {
normalizeReferenceImagesForGeneration,
@@ -124,9 +123,8 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
}
}
}
const requiredReference = await stripLabelBar(currentUrl)
const normalizedExtras = await normalizeReferenceImagesForGeneration(extraReferenceInputs)
const referenceImages = Array.from(new Set([requiredReference, ...normalizedExtras]))
const referenceImages = Array.from(new Set([currentUrl, ...normalizedExtras]))
const currentDescription = readIndexedDescription({
descriptions: appearance.descriptions,
fallbackDescription: appearance.description,
@@ -145,12 +143,10 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
},
})
const label = `${character.name} - ${appearance.changeReason || '形象'}`
const labeled = await withLabelBar(source, label)
const cosKey = await uploadImageSourceToCos(labeled, 'global-character-modify', appearance.id)
const imageKey = await uploadImageSourceToCos(source, 'global-character-modify', appearance.id)
while (imageUrls.length <= targetImageIndex) imageUrls.push('')
imageUrls[targetImageIndex] = cosKey
imageUrls[targetImageIndex] = imageKey
const selectedIndex = appearance.selectedIndex
const shouldUpdateMain = selectedIndex === targetImageIndex || selectedIndex === null || imageUrls.length === 1
@@ -187,15 +183,15 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
previousDescription: appearance.description || null,
previousDescriptions: appearance.descriptions ?? null,
imageUrls: encodeImageUrls(imageUrls),
imageUrl: shouldUpdateMain ? cosKey : appearance.imageUrl,
imageUrl: shouldUpdateMain ? imageKey : appearance.imageUrl,
...(descriptionFields || {}),
},
})
return { type: payload.type, appearanceId: appearance.id, imageUrl: cosKey }
return { type: payload.type, appearanceId: appearance.id, imageUrl: imageKey }
}
if (payload.type === 'location') {
if (payload.type === 'location' || payload.type === 'prop') {
const location = await db.globalLocation.findFirst({
where: { id: payload.id, userId },
include: { images: true },
@@ -217,24 +213,26 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
}
}
}
const requiredReference = await stripLabelBar(currentUrl)
const normalizedExtras = await normalizeReferenceImagesForGeneration(extraReferenceInputs)
const referenceImages = Array.from(new Set([requiredReference, ...normalizedExtras]))
const referenceImages = Array.from(new Set([currentUrl, ...normalizedExtras]))
const prompt = `请根据以下指令修改场景图片,保持整体风格一致:\n${modifyInstruction}`
const isProp = payload.type === 'prop'
const prompt = isProp
? `请根据以下指令修改道具图片,保持道具主体、结构和关键材质一致:\n${modifyInstruction}`
: `请根据以下指令修改场景图片,保持整体风格一致:\n${modifyInstruction}`
const aspectRatio = isProp ? PROP_IMAGE_RATIO : LOCATION_IMAGE_RATIO
const source = await resolveImageSourceFromGeneration(job, {
userId,
modelId: editModel,
prompt,
options: {
referenceImages,
aspectRatio: '1:1',
aspectRatio,
...(resolution ? { resolution } : {}),
},
})
const labeled = await withLabelBar(source, location.name)
const cosKey = await uploadImageSourceToCos(labeled, 'global-location-modify', locationImage.id)
const imageKey = await uploadImageSourceToCos(source, isProp ? 'global-prop-modify' : 'global-location-modify', locationImage.id)
let extractedDescription: {
prompt: string
@@ -246,24 +244,25 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
userId,
model: userModels.analysisModel,
locale: job.data.locale,
type: 'location',
type: isProp ? 'prop' : 'location',
currentDescription: locationImage.description,
modifyInstruction,
referenceImages: normalizedExtras,
locationName: location.name,
propName: isProp ? location.name : undefined,
})
} catch (err) {
logger.warn({ message: '资产库场景描述同步失败', details: { error: String(err) } })
logger.warn({ message: isProp ? '资产库道具描述同步失败' : '资产库场景描述同步失败', details: { error: String(err) } })
}
}
await assertTaskActive(job, 'persist_global_location_modify')
await assertTaskActive(job, isProp ? 'persist_global_prop_modify' : 'persist_global_location_modify')
await db.globalLocationImage.update({
where: { id: locationImage.id },
data: {
previousImageUrl: locationImage.imageUrl,
previousDescription: locationImage.description || null,
imageUrl: cosKey,
imageUrl: imageKey,
...(extractedDescription ? {
description: extractedDescription.prompt,
availableSlots: stringifyLocationAvailableSlots(extractedDescription.availableSlots),
@@ -271,7 +270,7 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
},
})
return { type: payload.type, locationImageId: locationImage.id, imageUrl: cosKey }
return { type: payload.type, locationImageId: locationImage.id, imageUrl: imageKey }
}
throw new Error(`Unsupported asset-hub modify type: ${String(payload.type)}`)

View File

@@ -1,6 +1,6 @@
import { type Job } from 'bullmq'
import { prisma } from '@/lib/prisma'
import { addCharacterPromptSuffix, getArtStylePrompt, isArtStyleValue, PRIMARY_APPEARANCE_INDEX, type ArtStyleValue } from '@/lib/constants'
import { CHARACTER_ASSET_IMAGE_RATIO, addCharacterPromptSuffix, getArtStylePrompt, isArtStyleValue, PRIMARY_APPEARANCE_INDEX, type ArtStyleValue } from '@/lib/constants'
import { type TaskJobData } from '@/lib/task/types'
import { encodeImageUrls } from '@/lib/contracts/image-urls-contract'
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
@@ -13,7 +13,7 @@ import {
import { normalizeReferenceImagesForGeneration } from '@/lib/media/outbound-image'
import {
AnyObj,
generateLabeledImageToCos,
generateProjectLabeledImageToStorage,
parseImageUrls,
parseJsonStringArray,
pickFirstString,
@@ -152,7 +152,7 @@ export async function handleCharacterImageTask(job: Job<TaskJobData>) {
index,
})
const cosKey = await generateLabeledImageToCos({
const imageKey = await generateProjectLabeledImageToStorage({
job,
userId,
modelId,
@@ -162,14 +162,14 @@ export async function handleCharacterImageTask(job: Job<TaskJobData>) {
keyPrefix: 'character',
options: {
referenceImages: primaryReferenceImages.length > 0 ? primaryReferenceImages : undefined,
aspectRatio: '3:2',
aspectRatio: CHARACTER_ASSET_IMAGE_RATIO,
},
})
while (nextImageUrls.length <= index) {
nextImageUrls.push('')
}
nextImageUrls[index] = cosKey
nextImageUrls[index] = imageKey
}
const selectedIndex = appearance.selectedIndex

View File

@@ -95,7 +95,51 @@ export function pickFirstString(...values: unknown[]) {
return null
}
export async function generateLabeledImageToCos(params: {
async function generateImageToStorage(params: {
job: Job<TaskJobData>
userId: string
modelId: string
prompt: string
targetId: string
keyPrefix: string
options?: {
referenceImages?: string[]
aspectRatio?: string
size?: string
}
label?: string
}) {
const source = await resolveImageSourceFromGeneration(params.job, {
userId: params.userId,
modelId: params.modelId,
prompt: params.prompt,
options: params.options,
})
const uploadSource = params.label
? await withLabelBar(source, params.label)
: source
const cosKey = await uploadImageSourceToCos(uploadSource, params.keyPrefix, params.targetId)
return cosKey
}
export async function generateCleanImageToStorage(params: {
job: Job<TaskJobData>
userId: string
modelId: string
prompt: string
targetId: string
keyPrefix: string
options?: {
referenceImages?: string[]
aspectRatio?: string
size?: string
}
}) {
return await generateImageToStorage(params)
}
export async function generateProjectLabeledImageToStorage(params: {
job: Job<TaskJobData>
userId: string
modelId: string
@@ -109,16 +153,7 @@ export async function generateLabeledImageToCos(params: {
size?: string
}
}) {
const source = await resolveImageSourceFromGeneration(params.job, {
userId: params.userId,
modelId: params.modelId,
prompt: params.prompt,
options: params.options,
})
const labeled = await withLabelBar(source, params.label)
const cosKey = await uploadImageSourceToCos(labeled, params.keyPrefix, params.targetId)
return cosKey
return await generateImageToStorage(params)
}
export async function resolveNovelData(projectId: string) {

View File

@@ -1,5 +1,6 @@
import { type Job } from 'bullmq'
import { prisma } from '@/lib/prisma'
import { LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO } from '@/lib/constants'
import { type TaskJobData } from '@/lib/task/types'
import { encodeImageUrls } from '@/lib/contracts/image-urls-contract'
import {
@@ -168,7 +169,7 @@ export async function handleModifyAssetImageTask(job: Job<TaskJobData>) {
return { type, appearanceId: appearance.id, imageIndex, imageUrl: cosKey }
}
if (type === 'location') {
if (type === 'location' || type === 'prop') {
const locationImageId = pickFirstString(payload.locationImageId, payload.targetId, job.data.targetId)
let locationImage: LocationImageRecord | null = locationImageId
? await prisma.locationImage.findUnique({
@@ -204,21 +205,25 @@ export async function handleModifyAssetImageTask(job: Job<TaskJobData>) {
const normalizedExtras = await normalizeReferenceImagesForGeneration(extraReferenceInputs)
const referenceImages = Array.from(new Set([requiredReference, ...normalizedExtras]))
const prompt = `请根据以下指令修改场景图片,保持整体风格一致:\n${modifyInstruction}`
const isProp = type === 'prop'
const prompt = isProp
? `请根据以下指令修改道具图片,保持道具主体、结构和关键材质一致:\n${modifyInstruction}`
: `请根据以下指令修改场景图片,保持整体风格一致:\n${modifyInstruction}`
const aspectRatio = isProp ? PROP_IMAGE_RATIO : LOCATION_IMAGE_RATIO
const source = await resolveImageSourceFromGeneration(job, {
userId: job.data.userId,
modelId: editModel,
prompt,
options: {
referenceImages,
aspectRatio: '1:1',
aspectRatio,
...(resolution ? { resolution } : {}),
},
})
const label = locationImage.location?.name || '场景'
const label = locationImage.location?.name || (isProp ? '道具' : '场景')
const labeled = await withLabelBar(source, label)
const cosKey = await uploadImageSourceToCos(labeled, 'location-modify', locationImage.id)
const cosKey = await uploadImageSourceToCos(labeled, isProp ? 'prop-modify' : 'location-modify', locationImage.id)
let extractedDescription: {
prompt: string
@@ -233,20 +238,21 @@ export async function handleModifyAssetImageTask(job: Job<TaskJobData>) {
userId: job.data.userId,
model: analysisModel,
locale: job.data.locale,
type: 'location',
type: isProp ? 'prop' : 'location',
currentDescription: locationImage.description,
modifyInstruction,
referenceImages: normalizedExtras,
locationName: locationImage.location?.name || '场景',
propName: isProp ? (locationImage.location?.name || '道具') : undefined,
projectId: job.data.projectId,
})
}
} catch (err) {
logger.warn({ message: '项目场景描述同步失败,不影响改图结果', details: { error: String(err) } })
logger.warn({ message: isProp ? '项目道具描述同步失败,不影响改图结果' : '项目场景描述同步失败,不影响改图结果', details: { error: String(err) } })
}
}
await assertTaskActive(job, 'persist_location_modify')
await assertTaskActive(job, isProp ? 'persist_prop_modify' : 'persist_location_modify')
await prisma.locationImage.update({
where: { id: locationImage.id },
data: {

View File

@@ -1,6 +1,6 @@
import { type Job } from 'bullmq'
import { prisma } from '@/lib/prisma'
import { addLocationPromptSuffix, getArtStylePrompt, isArtStyleValue, type ArtStyleValue } from '@/lib/constants'
import { LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO, addLocationPromptSuffix, addPropPromptSuffix, getArtStylePrompt, isArtStyleValue, type ArtStyleValue } from '@/lib/constants'
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
import { type TaskJobData } from '@/lib/task/types'
import { reportTaskProgress } from '../shared'
@@ -10,10 +10,11 @@ import {
} from '../utils'
import {
AnyObj,
generateLabeledImageToCos,
generateProjectLabeledImageToStorage,
pickFirstString,
} from './image-task-handler-shared'
import { buildLocationImagePromptCore } from '@/lib/location-image-prompt'
import { buildPropImagePromptCore } from '@/lib/prop-image-prompt'
function resolvePayloadArtStyle(payload: AnyObj): ArtStyleValue | undefined {
if (!Object.prototype.hasOwnProperty.call(payload, 'artStyle')) return undefined
@@ -67,6 +68,7 @@ export async function handleLocationImageTask(job: Job<TaskJobData>) {
const payloadArtStyle = resolvePayloadArtStyle(payload)
const artStyle = getArtStylePrompt(payloadArtStyle ?? models.artStyle, job.data.locale)
const assetType = payload.type === 'prop' ? 'prop' : 'location'
// targetId may be locationId (group) or locationImageId (single)
const maybeLocationImage = await db.locationImage.findUnique({
@@ -141,19 +143,27 @@ export async function handleLocationImageTask(job: Job<TaskJobData>) {
const name = locationNameMap[item.locationId] || item.location?.name || '场景'
const promptBody = item.description || ''
if (!promptBody) continue
const promptCore = buildLocationImagePromptCore({
description: promptBody,
availableSlotsRaw: item.availableSlots,
locale: job.data.locale === 'en' ? 'en' : 'zh',
})
const promptCore = assetType === 'prop'
? buildPropImagePromptCore({
description: promptBody,
})
: buildLocationImagePromptCore({
description: promptBody,
availableSlotsRaw: item.availableSlots,
locale: job.data.locale === 'en' ? 'en' : 'zh',
})
const prompt = artStyle ? `${addLocationPromptSuffix(promptCore)}${artStyle}` : addLocationPromptSuffix(promptCore)
const promptWithSuffix = assetType === 'prop'
? addPropPromptSuffix(promptCore)
: addLocationPromptSuffix(promptCore)
const prompt = artStyle ? `${promptWithSuffix}${artStyle}` : promptWithSuffix
const aspectRatio = assetType === 'prop' ? PROP_IMAGE_RATIO : LOCATION_IMAGE_RATIO
await reportTaskProgress(job, 20 + Math.floor((i / Math.max(locationImages.length, 1)) * 55), {
stage: 'generate_location_image',
imageId: item.id,
})
const cosKey = await generateLabeledImageToCos({
const imageKey = await generateProjectLabeledImageToStorage({
job,
userId,
modelId,
@@ -162,14 +172,14 @@ export async function handleLocationImageTask(job: Job<TaskJobData>) {
targetId: item.id,
keyPrefix: 'location',
options: {
aspectRatio: '1:1',
aspectRatio,
},
})
await assertTaskActive(job, 'persist_location_image')
await db.locationImage.update({
where: { id: item.id },
data: { imageUrl: cosKey },
data: { imageUrl: imageKey },
})
}

View File

@@ -1,5 +1,5 @@
import { executeAiTextStep, executeAiVisionStep } from '@/lib/ai-runtime'
import { removeCharacterPromptSuffix, removeLocationPromptSuffix } from '@/lib/constants'
import { removeCharacterPromptSuffix, removeLocationPromptSuffix, removePropPromptSuffix } from '@/lib/constants'
import { safeParseJsonObject } from '@/lib/json-repair'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
import type { PromptLocale } from '@/lib/prompt-i18n/types'
@@ -12,7 +12,7 @@ import {
normalizeLocationAvailableSlots,
} from '@/lib/location-available-slots'
export type SyncedAssetType = 'character' | 'location'
export type SyncedAssetType = 'character' | 'location' | 'prop'
function trimText(value: string | null | undefined): string {
return typeof value === 'string' ? value.trim() : ''
@@ -23,6 +23,9 @@ function buildImageContext(type: SyncedAssetType, hasReferenceImages: boolean):
if (type === 'character') {
return '【参考图片】\n请仔细分析参考图片中的服装款式、颜色、材质、配饰等关键视觉特征并将这些特征融入更新后的描述中。'
}
if (type === 'prop') {
return '【参考图片】\n请仔细分析参考图片中的材质、轮廓、比例、装饰细节、配色与表面处理并将这些特征融入更新后的描述中。'
}
return '【参考图片】\n请仔细分析参考图片中的建筑风格、装饰元素、光线氛围、色调等关键视觉特征并将这些特征融入更新后的描述中。'
}
@@ -52,6 +55,7 @@ export async function generateModifiedAssetDescription(params: {
modifyInstruction: string
referenceImages?: string[]
locationName?: string
propName?: string
projectId?: string
}): Promise<{
prompt: string
@@ -68,6 +72,17 @@ export async function generateModifiedAssetDescription(params: {
image_context: buildImageContext('character', hasReferenceImages),
},
})
: params.type === 'prop'
? buildPrompt({
promptId: PROMPT_IDS.NP_PROP_DESCRIPTION_UPDATE,
locale: params.locale,
variables: {
prop_name: trimText(params.propName) || '道具',
original_description: removePropPromptSuffix(params.currentDescription),
modify_instruction: params.modifyInstruction,
image_context: buildImageContext('prop', hasReferenceImages),
},
})
: buildPrompt({
promptId: PROMPT_IDS.NP_LOCATION_DESCRIPTION_UPDATE,
locale: params.locale,
@@ -99,12 +114,16 @@ export async function generateModifiedAssetDescription(params: {
...(params.projectId ? { projectId: params.projectId } : {}),
action: params.type === 'character'
? 'sync_character_description_after_image_modify'
: 'sync_location_description_after_image_modify',
: params.type === 'prop'
? 'sync_prop_description_after_image_modify'
: 'sync_location_description_after_image_modify',
meta: {
stepId: params.type === 'character'
? 'sync_character_description_after_image_modify'
: 'sync_location_description_after_image_modify',
stepTitle: params.type === 'character' ? '同步角色描述' : '同步场景描述',
: params.type === 'prop'
? 'sync_prop_description_after_image_modify'
: 'sync_location_description_after_image_modify',
stepTitle: params.type === 'character' ? '同步角色描述' : params.type === 'prop' ? '同步道具描述' : '同步场景描述',
stepIndex: 1,
stepTotal: 1,
},

View File

@@ -27,7 +27,7 @@ import {
} from './reference-to-character-helpers'
const POLL_MAX_ATTEMPTS = 60
const POLL_INTERVAL_MS = 2000
async function generateLabeledImage(params: {
async function generateReferenceImage(params: {
job: Job<TaskJobData>
imageIndex: number
userId: string
@@ -36,7 +36,7 @@ async function generateLabeledImage(params: {
referenceImages?: string[]
falApiKey?: string | null
keyPrefix: string
labelText: string
labelText?: string
}): Promise<string | null> {
const {
job,
@@ -91,25 +91,30 @@ async function generateLabeledImage(params: {
logPrefix: `[reference-to-character:${imageIndex + 1}]`,
})
const buffer = Buffer.from(await imgRes.arrayBuffer())
const meta = await sharp(buffer).metadata()
const width = meta.width || 2160
const height = meta.height || 2160
const fontSize = Math.floor(height * 0.04)
const pad = Math.floor(fontSize * 0.5)
const barHeight = fontSize + pad * 2
const svg = await createLabelSVG(width, barHeight, fontSize, pad, labelText)
const processed = await sharp(buffer)
.extend({
top: barHeight,
bottom: 0,
left: 0,
right: 0,
background: { r: 0, g: 0, b: 0, alpha: 1 },
})
.composite([{ input: svg, top: 0, left: 0 }])
.jpeg({ quality: 90, mozjpeg: true })
.toBuffer()
const processed = labelText
? await (async () => {
const meta = await sharp(buffer).metadata()
const width = meta.width || 2160
const height = meta.height || 2160
const fontSize = Math.floor(height * 0.04)
const pad = Math.floor(fontSize * 0.5)
const barHeight = fontSize + pad * 2
const svg = await createLabelSVG(width, barHeight, fontSize, pad, labelText)
return await sharp(buffer)
.extend({
top: barHeight,
bottom: 0,
left: 0,
right: 0,
background: { r: 0, g: 0, b: 0, alpha: 1 },
})
.composite([{ input: svg, top: 0, left: 0 }])
.jpeg({ quality: 90, mozjpeg: true })
.toBuffer()
})()
: await sharp(buffer)
.jpeg({ quality: 90, mozjpeg: true })
.toBuffer()
const key = generateUniqueKey(`${keyPrefix}-${Date.now()}-${imageIndex}`, 'jpg')
return await uploadObject(processed, key)
@@ -149,8 +154,9 @@ export async function handleReferenceToCharacterTask(job: Job<TaskJobData>) {
displayMode: 'detail',
})
await assertTaskActive(job, 'reference_to_character_prepare')
await initializeFonts()
if (isProject) {
await initializeFonts()
}
const userConfig = await getUserModelConfig(job.data.userId)
const imageModel = readString(userConfig.characterModel)
@@ -214,7 +220,7 @@ export async function handleReferenceToCharacterTask(job: Job<TaskJobData>) {
})
const imageResults = await Promise.all(Array.from({ length: count }, (_value, index) => index).map(async (index) =>
await generateLabeledImage({
await generateReferenceImage({
job,
imageIndex: index,
userId: job.data.userId,
@@ -223,7 +229,7 @@ export async function handleReferenceToCharacterTask(job: Job<TaskJobData>) {
referenceImages: useReferenceImages ? allReferenceImages : undefined,
falApiKey,
keyPrefix,
labelText: characterName,
...(isProject ? { labelText: characterName } : {}),
}),
))

View File

@@ -0,0 +1,65 @@
import type { Job } from 'bullmq'
import { removePropPromptSuffix } from '@/lib/constants'
import { reportTaskProgress } from '@/lib/workers/shared'
import { assertTaskActive } from '@/lib/workers/utils'
import type { TaskJobData } from '@/lib/task/types'
import { resolveAnalysisModel } from './shot-ai-persist'
import { runShotPromptCompletion } from './shot-ai-prompt-runtime'
import { parseJsonObject, readRequiredString, type AnyObj } from './shot-ai-prompt-utils'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
export async function handleModifyPropTask(job: Job<TaskJobData>, payload: AnyObj) {
const propId = readRequiredString(payload.propId, 'propId')
const variantId = typeof payload.variantId === 'string' ? payload.variantId.trim() : ''
const propName = typeof payload.propName === 'string' && payload.propName.trim() ? payload.propName.trim() : '道具'
const currentDescription = readRequiredString(payload.currentDescription, 'currentDescription')
const modifyInstruction = readRequiredString(payload.modifyInstruction, 'modifyInstruction')
const novelData = await resolveAnalysisModel(job.data.projectId, job.data.userId)
const finalPrompt = buildPrompt({
promptId: PROMPT_IDS.NP_PROP_DESCRIPTION_UPDATE,
locale: job.data.locale,
variables: {
prop_name: propName,
original_description: removePropPromptSuffix(currentDescription),
modify_instruction: modifyInstruction,
image_context: '',
},
})
await reportTaskProgress(job, 22, {
stage: 'ai_modify_prop_prepare',
stageLabel: '准备道具描述修改参数',
displayMode: 'detail',
})
await assertTaskActive(job, 'ai_modify_prop_prepare')
const responseText = await runShotPromptCompletion({
job,
model: novelData.analysisModel,
prompt: finalPrompt,
action: 'ai_modify_prop',
streamContextKey: 'ai_modify_prop',
streamStepId: 'ai_modify_prop',
streamStepTitle: '道具描述修改',
})
await assertTaskActive(job, 'ai_modify_prop_parse')
const parsed = parseJsonObject(responseText)
const prompt = readRequiredString(parsed.prompt, 'prompt')
const modifiedDescription = removePropPromptSuffix(prompt)
await reportTaskProgress(job, 96, {
stage: 'ai_modify_prop_done',
stageLabel: '道具描述修改完成',
displayMode: 'detail',
meta: { propId, variantId: variantId || null },
})
return {
success: true,
modifiedDescription,
originalPrompt: finalPrompt,
rawResponse: responseText,
}
}

View File

@@ -1,4 +1,5 @@
export type { AnyObj } from './shot-ai-prompt-utils'
export { handleModifyAppearanceTask } from './shot-ai-prompt-appearance'
export { handleModifyLocationTask } from './shot-ai-prompt-location'
export { handleModifyPropTask } from './shot-ai-prompt-prop'
export { handleModifyShotPromptTask } from './shot-ai-prompt-shot'

View File

@@ -3,6 +3,7 @@ import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
import {
handleModifyAppearanceTask,
handleModifyLocationTask,
handleModifyPropTask,
handleModifyShotPromptTask,
type AnyObj,
} from './shot-ai-prompt'
@@ -15,6 +16,8 @@ export async function handleShotAITask(job: Job<TaskJobData>) {
return await handleModifyAppearanceTask(job, payload)
case TASK_TYPE.AI_MODIFY_LOCATION:
return await handleModifyLocationTask(job, payload)
case TASK_TYPE.AI_MODIFY_PROP:
return await handleModifyPropTask(job, payload)
case TASK_TYPE.AI_MODIFY_SHOT_PROMPT:
return await handleModifyShotPromptTask(job, payload)
case TASK_TYPE.ANALYZE_SHOT_VARIANTS:

View File

@@ -3,6 +3,7 @@ import { removeLocationPromptSuffix } from '@/lib/constants'
import type { StoryToScriptClipCandidate } from '@/lib/novel-promotion/story-to-script/orchestrator'
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
import { resolvePropVisualDescription } from '@/lib/assets/prop-description'
export type AnyObj = Record<string, unknown>
@@ -165,7 +166,12 @@ export async function persistAnalyzedProps(params: {
for (const item of params.analyzedProps) {
const name = asString(item.name).trim()
const summary = asString(item.summary).trim()
if (!name || !summary) continue
const description = resolvePropVisualDescription({
name,
summary,
description: asString(item.description).trim(),
})
if (!name || !summary || !description) continue
const key = name.toLowerCase()
if (params.existingNames.has(key)) continue
@@ -184,8 +190,8 @@ export async function persistAnalyzedProps(params: {
})
await seedProjectLocationBackedImageSlots({
locationId: prop.id,
descriptions: [summary],
fallbackDescription: summary,
descriptions: [description],
fallbackDescription: description,
availableSlots: [],
locationImageModel: db.locationImage,
})

View File

@@ -679,9 +679,11 @@ async function processTextTask(job: Job<TaskJobData>) {
return await handleAssetHubAIDesignTask(job)
case TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER:
case TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION:
case TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP:
return await handleAssetHubAIModifyTask(job)
case TASK_TYPE.AI_MODIFY_APPEARANCE:
case TASK_TYPE.AI_MODIFY_LOCATION:
case TASK_TYPE.AI_MODIFY_PROP:
case TASK_TYPE.AI_MODIFY_SHOT_PROMPT:
case TASK_TYPE.ANALYZE_SHOT_VARIANTS:
return await handleShotAITask(job)

View File

@@ -83,24 +83,6 @@
animation-delay: 4s;
}
/* Custom Scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
/* Slide in from right animation */
@keyframes slideInRight {
from {
@@ -183,4 +165,4 @@
.animate-shimmer {
background-size: 200% 100%;
animation: shimmer 3s linear infinite;
}
}

View File

@@ -396,22 +396,69 @@
background: var(--glass-accent-from);
}
.glass-provider-model-scroll {
:where(
.app-scrollbar,
.overflow-auto,
.overflow-scroll,
.overflow-x-auto,
.overflow-y-auto,
.overflow-x-scroll,
.overflow-y-scroll
) {
scrollbar-width: thin;
scrollbar-color: var(--glass-stroke-strong) transparent;
scrollbar-gutter: stable;
}
.glass-provider-model-scroll::-webkit-scrollbar {
:where(
.app-scrollbar,
.overflow-auto,
.overflow-scroll,
.overflow-x-auto,
.overflow-y-auto,
.overflow-x-scroll,
.overflow-y-scroll
)::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.glass-provider-model-scroll::-webkit-scrollbar-track {
:where(
.app-scrollbar,
.overflow-auto,
.overflow-scroll,
.overflow-x-auto,
.overflow-y-auto,
.overflow-x-scroll,
.overflow-y-scroll
)::-webkit-scrollbar-track {
background: transparent;
}
.glass-provider-model-scroll::-webkit-scrollbar-thumb {
:where(
.app-scrollbar,
.overflow-auto,
.overflow-scroll,
.overflow-x-auto,
.overflow-y-auto,
.overflow-x-scroll,
.overflow-y-scroll
)::-webkit-scrollbar-thumb {
background: var(--glass-stroke-strong);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
:where(
.app-scrollbar,
.overflow-auto,
.overflow-scroll,
.overflow-x-auto,
.overflow-y-auto,
.overflow-x-scroll,
.overflow-y-scroll
)::-webkit-scrollbar-thumb:hover {
background: var(--glass-stroke-focus);
background-clip: content-box;
}