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:
@@ -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) => (
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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}
|
||||
|
||||
58
src/app/api/asset-hub/ai-modify-prop/route.ts
Normal file
58
src/app/api/asset-hub/ai-modify-prop/route.ts
Normal 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')
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
138
src/components/shared/assets/AiModifyDescriptionField.tsx
Normal file
138
src/components/shared/assets/AiModifyDescriptionField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)]'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
27
src/lib/assets/prop-description.ts
Normal file
27
src/lib/assets/prop-description.ts
Normal 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)
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
|
||||
75
src/lib/assets/services/project-location-backed-selection.ts
Normal file
75
src/lib/assets/services/project-location-backed-selection.ts
Normal 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 }
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
5
src/lib/prop-image-prompt.ts
Normal file
5
src/lib/prop-image-prompt.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function buildPropImagePromptCore(params: {
|
||||
description: string
|
||||
}): string {
|
||||
return params.description.trim()
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export {
|
||||
useUpdateLocationSummary,
|
||||
useAiModifyCharacterDescription,
|
||||
useAiModifyLocationDescription,
|
||||
useAiModifyPropDescription,
|
||||
useDesignAssetHubVoice,
|
||||
useSaveDesignedAssetHubVoice,
|
||||
useUploadAssetHubVoice,
|
||||
@@ -98,6 +99,7 @@ export {
|
||||
useUpdateProjectCharacterIntroduction,
|
||||
useAiModifyProjectAppearanceDescription,
|
||||
useAiModifyProjectLocationDescription,
|
||||
useAiModifyProjectPropDescription,
|
||||
useAiCreateProjectLocation,
|
||||
useCreateProjectLocation,
|
||||
useAiCreateProjectCharacter,
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
useUpdateLocationSummary,
|
||||
useAiModifyCharacterDescription,
|
||||
useAiModifyLocationDescription,
|
||||
useAiModifyPropDescription,
|
||||
useUploadAssetHubTempMedia,
|
||||
useAiDesignCharacter,
|
||||
useExtractAssetHubReferenceCharacterDescription,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
'确认选择失败',
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
))
|
||||
|
||||
|
||||
65
src/lib/workers/handlers/shot-ai-prompt-prop.ts
Normal file
65
src/lib/workers/handlers/shot-ai-prompt-prop.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user