Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
Y
yaoai-video
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
姚珂
yaoai-video
Commits
232de8ef
Commit
232de8ef
authored
Jun 03, 2026
by
yaoke.yk
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
客户端设定-道具修改
parent
347882ac
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
384 additions
and
44 deletions
+384
-44
PropsGeneration.tsx
doc/html/src/app/pages/PropsGeneration.tsx
+247
-41
useAssets.ts
doc/html/src/hooks/useAssets.ts
+25
-0
assets.ts
doc/html/src/lib/api/assets.ts
+15
-0
GlobalAssetsController.java
...java/com/yaoai/api/controller/GlobalAssetsController.java
+87
-1
ImageGenPipelineService.java
...a/com/yaoai/pipeline/service/ImageGenPipelineService.java
+2
-0
ImageGenPipelineServiceImpl.java
...ai/pipeline/service/impl/ImageGenPipelineServiceImpl.java
+8
-2
No files found.
doc/html/src/app/pages/PropsGeneration.tsx
View file @
232de8ef
import
{
use
State
}
from
"react"
;
import
{
use
Ref
,
useState
,
type
ChangeEvent
}
from
"react"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
ArrowRight
,
Box
,
Sparkles
,
Users
,
MapPin
,
Plus
,
Edit2
,
Trash2
,
Upload
,
X
,
Check
,
RefreshCw
,
Loader2
,
ArrowRight
,
Box
,
Sparkles
,
Users
,
MapPin
,
Plus
,
Edit2
,
Trash2
,
Upload
,
X
,
Check
,
RefreshCw
,
Loader2
,
}
from
"lucide-react"
;
import
{
useGlobalAssets
,
useCreateAsset
,
useDeleteAsset
}
from
"../../hooks/useAssets"
;
import
{
useGlobalAssets
,
useCreateAsset
,
useUpdateAsset
,
useUploadAssetImage
,
useGenerateAssetImage
,
useDeleteAsset
,
}
from
"../../hooks/useAssets"
;
import
type
{
GlobalAsset
}
from
"../../lib/api/assets"
;
type
SettingsTab
=
"characters"
|
"scenes"
|
"props"
;
...
...
@@ -19,6 +37,19 @@ interface EditForm {
const
CATEGORIES
=
[
"首饰"
,
"文件"
,
"配饰"
,
"文具"
,
"饮品"
,
"电子设备"
,
"武器"
,
"交通工具"
,
"其他"
];
const
emptyForm
=
():
EditForm
=>
({
name
:
""
,
category
:
"配饰"
,
description
:
""
,
imageUrl
:
""
,
});
function
revokeObjectPreview
(
url
:
string
|
null
)
{
if
(
url
?.
startsWith
(
"blob:"
))
{
URL
.
revokeObjectURL
(
url
);
}
}
export
function
PropsGeneration
()
{
const
navigate
=
useNavigate
();
const
{
projectId
}
=
useParams
();
...
...
@@ -26,11 +57,19 @@ export function PropsGeneration() {
const
{
data
:
props
=
[],
isLoading
}
=
useGlobalAssets
(
"prop"
,
"personal"
);
const
createAsset
=
useCreateAsset
(
"prop"
,
"personal"
);
const
updateAsset
=
useUpdateAsset
(
"prop"
,
"personal"
);
const
uploadImage
=
useUploadAssetImage
(
"prop"
,
"personal"
);
const
generateImage
=
useGenerateAssetImage
(
"prop"
,
"personal"
);
const
deleteAsset
=
useDeleteAsset
(
"prop"
,
"personal"
);
const
[
activeTab
,
setActiveTab
]
=
useState
<
SettingsTab
>
(
"props"
);
const
[
showModal
,
setShowModal
]
=
useState
(
false
);
const
[
editForm
,
setEditForm
]
=
useState
<
EditForm
|
null
>
(
null
);
const
[
editForm
,
setEditForm
]
=
useState
<
EditForm
>
(
emptyForm
());
const
[
imagePreview
,
setImagePreview
]
=
useState
<
string
|
null
>
(
null
);
const
[
pendingFile
,
setPendingFile
]
=
useState
<
File
|
null
>
(
null
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
generatingImageId
,
setGeneratingImageId
]
=
useState
<
string
|
null
>
(
null
);
const
fileInputRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
settingsTabs
=
[
{
id
:
"characters"
as
SettingsTab
,
label
:
"角色"
,
icon
:
Users
,
path
:
`/project/
${
projectId
}
/characters`
},
...
...
@@ -38,12 +77,21 @@ export function PropsGeneration() {
{
id
:
"props"
as
SettingsTab
,
label
:
"道具"
,
icon
:
Box
,
path
:
`/project/
${
projectId
}
/props`
},
];
const
resetImageInput
=
()
=>
{
if
(
fileInputRef
.
current
)
fileInputRef
.
current
.
value
=
""
;
};
const
openAdd
=
()
=>
{
setEditForm
({
name
:
""
,
category
:
"配饰"
,
description
:
""
,
imageUrl
:
""
});
revokeObjectPreview
(
imagePreview
);
setEditForm
(
emptyForm
());
setImagePreview
(
null
);
setPendingFile
(
null
);
resetImageInput
();
setShowModal
(
true
);
};
const
openEdit
=
(
p
:
GlobalAsset
)
=>
{
revokeObjectPreview
(
imagePreview
);
setEditForm
({
id
:
p
.
id
,
name
:
p
.
name
,
...
...
@@ -51,23 +99,107 @@ export function PropsGeneration() {
description
:
p
.
description
??
""
,
imageUrl
:
p
.
imageUrl
??
""
,
});
setImagePreview
(
p
.
imageUrl
??
null
);
setPendingFile
(
null
);
resetImageInput
();
setShowModal
(
true
);
};
const
closeModal
=
()
=>
{
setShowModal
(
false
);
setEditForm
(
null
);
};
const
closeModal
=
()
=>
{
revokeObjectPreview
(
imagePreview
);
setShowModal
(
false
);
setEditForm
(
emptyForm
());
setImagePreview
(
null
);
setPendingFile
(
null
);
resetImageInput
();
};
const
handleSave
=
async
()
=>
{
if
(
!
editForm
||
!
editForm
.
name
.
trim
())
return
;
await
createAsset
.
mutateAsync
({
const
handleFileSelect
=
(
event
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
file
=
event
.
target
.
files
?.[
0
];
if
(
!
file
)
return
;
revokeObjectPreview
(
imagePreview
);
setPendingFile
(
file
);
setImagePreview
(
URL
.
createObjectURL
(
file
));
event
.
target
.
value
=
""
;
};
const
clearSelectedImage
=
()
=>
{
revokeObjectPreview
(
imagePreview
);
setPendingFile
(
null
);
setImagePreview
(
editForm
.
imageUrl
||
null
);
resetImageInput
();
};
const
saveAsset
=
async
(
uploadPendingImage
=
true
)
=>
{
if
(
!
editForm
.
name
.
trim
())
return
null
;
const
payload
=
{
assetType
:
"prop"
,
libraryType
:
"personal"
,
name
:
editForm
.
name
,
description
:
editForm
.
description
,
name
:
editForm
.
name
.
trim
()
,
description
:
editForm
.
description
.
trim
()
,
tags
:
editForm
.
category
,
imageUrl
:
editForm
.
imageUrl
||
undefined
,
sourceProjectId
:
numProjectId
||
undefined
,
});
};
let
saved
=
editForm
.
id
?
await
updateAsset
.
mutateAsync
({
id
:
editForm
.
id
,
patch
:
payload
})
:
await
createAsset
.
mutateAsync
(
payload
);
if
(
uploadPendingImage
&&
pendingFile
)
{
saved
=
await
uploadImage
.
mutateAsync
({
id
:
saved
.
id
,
file
:
pendingFile
});
}
return
saved
;
};
const
handleSave
=
async
()
=>
{
if
(
!
editForm
.
name
.
trim
())
return
;
setSaving
(
true
);
try
{
await
saveAsset
(
true
);
closeModal
();
}
catch
(
error
)
{
alert
(
"保存失败: "
+
(
error
as
Error
).
message
);
}
finally
{
setSaving
(
false
);
}
};
const
handleGenerateImage
=
async
(
prop
:
GlobalAsset
)
=>
{
setGeneratingImageId
(
prop
.
id
);
try
{
await
generateImage
.
mutateAsync
(
prop
.
id
);
}
catch
(
error
)
{
alert
(
"道具图生成失败: "
+
(
error
as
Error
).
message
);
}
finally
{
setGeneratingImageId
(
null
);
}
};
const
handleGenerateFromModal
=
async
()
=>
{
if
(
!
editForm
.
name
.
trim
())
return
;
setSaving
(
true
);
setGeneratingImageId
(
editForm
.
id
??
"draft"
);
try
{
const
saved
=
await
saveAsset
(
false
);
if
(
!
saved
)
return
;
const
generated
=
await
generateImage
.
mutateAsync
(
saved
.
id
);
revokeObjectPreview
(
imagePreview
);
setEditForm
({
id
:
generated
.
id
,
name
:
generated
.
name
,
category
:
generated
.
tags
?.[
0
]
??
editForm
.
category
,
description
:
generated
.
description
??
editForm
.
description
,
imageUrl
:
generated
.
imageUrl
??
""
,
});
setImagePreview
(
generated
.
imageUrl
??
null
);
setPendingFile
(
null
);
resetImageInput
();
}
catch
(
error
)
{
alert
(
"道具图生成失败: "
+
(
error
as
Error
).
message
);
}
finally
{
setSaving
(
false
);
setGeneratingImageId
(
null
);
}
};
const
handleDelete
=
async
(
id
:
string
)
=>
{
...
...
@@ -76,6 +208,8 @@ export function PropsGeneration() {
};
const
withImage
=
props
.
filter
((
p
)
=>
p
.
imageUrl
).
length
;
const
isSaving
=
saving
||
createAsset
.
isPending
||
updateAsset
.
isPending
||
uploadImage
.
isPending
;
const
isBusy
=
isSaving
||
generateImage
.
isPending
;
return
(
<
div
className=
"h-full overflow-auto bg-background relative"
>
...
...
@@ -91,7 +225,6 @@ export function PropsGeneration() {
<
p
className=
"text-sm text-muted-foreground"
>
管理角色、场景和道具设定
</
p
>
</
div
>
{
/* Tabs */
}
<
div
className=
"flex items-center justify-between mb-6"
>
<
div
className=
"flex items-center gap-2"
>
{
settingsTabs
.
map
((
tab
)
=>
{
...
...
@@ -121,7 +254,6 @@ export function PropsGeneration() {
</
button
>
</
div
>
{
/* Stats bar */
}
<
div
className=
"flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4"
>
<
div
className=
"flex items-center gap-6"
>
<
div
className=
"flex items-center gap-2"
>
...
...
@@ -145,7 +277,6 @@ export function PropsGeneration() {
</
button
>
</
div
>
{
/* Grid */
}
{
isLoading
?
(
<
div
className=
"flex items-center justify-center py-20"
>
<
Loader2
className=
"w-8 h-8 animate-spin text-primary"
/>
...
...
@@ -153,7 +284,7 @@ export function PropsGeneration() {
)
:
props
.
length
===
0
?
(
<
div
className=
"flex flex-col items-center justify-center py-20 text-center"
>
<
Box
className=
"w-12 h-12 text-muted-foreground/30 mb-4"
/>
<
p
className=
"text-muted-foreground mb-4"
>
暂无道具,点击
"新增道具"
开始添加
</
p
>
<
p
className=
"text-muted-foreground mb-4"
>
暂无道具,点击
“新增道具”
开始添加
</
p
>
<
button
onClick=
{
openAdd
}
className=
"px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm flex items-center gap-2"
>
<
Plus
className=
"w-4 h-4"
/>
新增道具
</
button
>
...
...
@@ -172,12 +303,32 @@ export function PropsGeneration() {
)
}
<
div
className=
"absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1"
>
<
button
onClick=
{
()
=>
handleGenerateImage
(
prop
)
}
disabled=
{
generatingImageId
===
prop
.
id
||
generateImage
.
isPending
}
className=
"p-1.5 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors disabled:opacity-50"
title=
{
prop
.
imageUrl
?
"AI重新生成道具图"
:
"AI生成道具图"
}
>
{
generatingImageId
===
prop
.
id
?
(
<
Loader2
className=
"w-4 h-4 text-white animate-spin"
/>
)
:
prop
.
imageUrl
?
(
<
RefreshCw
className=
"w-4 h-4 text-white"
/>
)
:
(
<
Sparkles
className=
"w-4 h-4 text-white"
/>
)
}
</
button
>
<
button
onClick=
{
()
=>
openEdit
(
prop
)
}
className=
"p-1.5 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors"
>
<
Edit2
className=
"w-4 h-4 text-white"
/>
</
button
>
</
div
>
{
generatingImageId
===
prop
.
id
&&
(
<
div
className=
"absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center gap-2"
>
<
Loader2
className=
"w-6 h-6 text-primary animate-spin"
/>
<
span
className=
"text-xs text-foreground"
>
生成中
</
span
>
</
div
>
)
}
</
div
>
<
div
className=
"p-3"
>
<
div
className=
"flex items-center justify-between mb-1"
>
...
...
@@ -206,7 +357,6 @@ export function PropsGeneration() {
</
div
>
</
div
>
{
/* Floating bottom bar */
}
<
div
className=
"fixed bottom-0 left-0 right-0 border-t border-border bg-card/95 backdrop-blur-sm px-6 py-3 z-10"
>
<
div
className=
"max-w-7xl mx-auto flex items-center justify-end gap-3"
>
<
button
...
...
@@ -219,12 +369,11 @@ export function PropsGeneration() {
</
div
>
</
div
>
{
/* Add / Edit Modal */
}
{
showModal
&&
editForm
&&
(
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"
>
<
div
className=
"bg-card rounded-2xl border border-border p-6 max-w-lg w-full"
>
<
div
className=
"flex items-center justify-between mb-6"
>
<
h2
className=
"text-xl font-semibold text-foreground"
>
{
showModal
&&
(
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6"
>
<
div
className=
"bg-card rounded-2xl border border-border w-full max-w-2xl max-h-[90vh] flex flex-col"
>
<
div
className=
"flex items-center justify-between p-6 border-b border-border flex-shrink-0"
>
<
h2
className=
"text-lg font-semibold text-foreground"
>
{
editForm
.
id
?
"编辑道具"
:
"新增道具"
}
</
h2
>
<
button
onClick=
{
closeModal
}
className=
"p-2 hover:bg-muted rounded-lg transition-colors"
>
...
...
@@ -232,10 +381,78 @@ export function PropsGeneration() {
</
button
>
</
div
>
<
div
className=
"space-y-4"
>
<
div
className=
"overflow-auto p-6 space-y-4 flex-1"
>
<
div
className=
"rounded-xl border border-border bg-muted/20 p-4"
>
<
div
className=
"mb-3 flex items-center justify-between gap-3"
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground"
>
道具图片(可选)
</
label
>
<
p
className=
"mt-1 text-xs text-muted-foreground"
>
可以上传参考图,也可以根据道具名称和描述 AI 生成。
</
p
>
</
div
>
{
pendingFile
&&
(
<
button
onClick=
{
clearSelectedImage
}
className=
"text-xs text-destructive hover:underline"
>
撤销更换
</
button
>
)
}
</
div
>
<
div
className=
"w-full aspect-video rounded-lg border-2 border-dashed border-border bg-background flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden"
onClick=
{
()
=>
fileInputRef
.
current
?.
click
()
}
>
{
imagePreview
?
(
<
img
src=
{
imagePreview
}
alt=
"道具图片预览"
className=
"w-full h-full object-cover"
/>
)
:
(
<
div
className=
"text-center px-4"
>
<
Upload
className=
"w-8 h-8 text-muted-foreground mx-auto mb-2"
/>
<
span
className=
"text-sm text-muted-foreground"
>
点击上传道具参考图
</
span
>
</
div
>
)
}
</
div
>
{
imagePreview
&&
(
<
div
className=
"mt-2 flex items-center gap-2"
>
<
button
type=
"button"
onClick=
{
()
=>
fileInputRef
.
current
?.
click
()
}
className=
"inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted"
>
<
Upload
className=
"h-3.5 w-3.5"
/>
更换图片
</
button
>
<
button
type=
"button"
onClick=
{
handleGenerateFromModal
}
disabled=
{
!
editForm
.
name
.
trim
()
||
isBusy
}
className=
"inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
{
generatingImageId
===
(
editForm
.
id
??
"draft"
)
?
(
<
Loader2
className=
"h-3.5 w-3.5 animate-spin"
/>
)
:
(
<
Sparkles
className=
"h-3.5 w-3.5"
/>
)
}
AI重新生成
</
button
>
</
div
>
)
}
{
!
imagePreview
&&
(
<
button
type=
"button"
onClick=
{
handleGenerateFromModal
}
disabled=
{
!
editForm
.
name
.
trim
()
||
isBusy
}
className=
"mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
{
generatingImageId
===
(
editForm
.
id
??
"draft"
)
?
(
<
Loader2
className=
"h-3.5 w-3.5 animate-spin"
/>
)
:
(
<
Sparkles
className=
"h-3.5 w-3.5"
/>
)
}
{
editForm
.
id
?
"AI生成道具图"
:
"保存并AI生成"
}
</
button
>
)
}
<
input
ref=
{
fileInputRef
}
type=
"file"
accept=
"image/*"
className=
"hidden"
onChange=
{
handleFileSelect
}
/>
</
div
>
<
div
className=
"grid grid-cols-2 gap-4"
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
道具名称
</
label
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
道具名称
<
span
className=
"text-destructive"
>
*
</
span
>
</
label
>
<
input
type=
"text"
value=
{
editForm
.
name
}
...
...
@@ -262,33 +479,22 @@ export function PropsGeneration() {
value=
{
editForm
.
description
}
onChange=
{
(
e
)
=>
setEditForm
({
...
editForm
,
description
:
e
.
target
.
value
})
}
placeholder=
"描述道具的外观、用途和重要性"
rows=
{
3
}
rows=
{
4
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
/>
</
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
图片 URL(可选)
</
label
>
<
input
type=
"text"
value=
{
editForm
.
imageUrl
}
onChange=
{
(
e
)
=>
setEditForm
({
...
editForm
,
imageUrl
:
e
.
target
.
value
})
}
placeholder=
"https://example.com/prop.jpg"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm"
/>
</
div
>
</
div
>
<
div
className=
"flex gap-3
mt-6 pt-6 border-t border-border
"
>
<
div
className=
"flex gap-3
p-6 border-t border-border flex-shrink-0
"
>
<
button
onClick=
{
closeModal
}
className=
"flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
取消
</
button
>
<
button
onClick=
{
handleSave
}
disabled=
{
!
editForm
.
name
.
trim
()
||
createAsset
.
isPending
}
disabled=
{
!
editForm
.
name
.
trim
()
||
isBusy
}
className=
"flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-50"
>
{
createAsset
.
isPending
?
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
:
<
Check
className=
"w-4 h-4"
/>
}
{
isBusy
?
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
:
<
Check
className=
"w-4 h-4"
/>
}
保存
</
button
>
</
div
>
...
...
doc/html/src/hooks/useAssets.ts
View file @
232de8ef
...
...
@@ -27,6 +27,31 @@ export function useToggleFavorite(type: string, library: string) {
});
}
export
function
useUpdateAsset
(
type
:
string
,
library
:
string
)
{
const
qc
=
useQueryClient
();
return
useMutation
({
mutationFn
:
({
id
,
patch
}:
{
id
:
string
;
patch
:
Partial
<
CreateAssetPayload
>
})
=>
assetsApi
.
update
(
id
,
patch
),
onSuccess
:
()
=>
qc
.
invalidateQueries
({
queryKey
:
assetsKey
(
type
,
library
)
}),
});
}
export
function
useUploadAssetImage
(
type
:
string
,
library
:
string
)
{
const
qc
=
useQueryClient
();
return
useMutation
({
mutationFn
:
({
id
,
file
}:
{
id
:
string
;
file
:
File
})
=>
assetsApi
.
uploadImage
(
id
,
file
),
onSuccess
:
()
=>
qc
.
invalidateQueries
({
queryKey
:
assetsKey
(
type
,
library
)
}),
});
}
export
function
useGenerateAssetImage
(
type
:
string
,
library
:
string
)
{
const
qc
=
useQueryClient
();
return
useMutation
({
mutationFn
:
(
id
:
string
)
=>
assetsApi
.
generateImage
(
id
),
onSuccess
:
()
=>
qc
.
invalidateQueries
({
queryKey
:
assetsKey
(
type
,
library
)
}),
});
}
export
function
useDeleteAsset
(
type
:
string
,
library
:
string
)
{
const
qc
=
useQueryClient
();
return
useMutation
({
...
...
doc/html/src/lib/api/assets.ts
View file @
232de8ef
...
...
@@ -42,6 +42,21 @@ export const assetsApi = {
const
r
=
await
apiClient
.
put
(
`/assets/
${
id
}
`
,
patch
);
return
r
.
data
.
data
;
},
uploadImage
:
async
(
id
:
string
,
file
:
File
):
Promise
<
GlobalAsset
>
=>
{
const
form
=
new
FormData
();
form
.
append
(
"file"
,
file
);
const
r
=
await
apiClient
.
post
(
`/assets/
${
id
}
/upload-image`
,
form
,
{
headers
:
{
"Content-Type"
:
"multipart/form-data"
},
timeout
:
600
_000
,
});
return
r
.
data
.
data
;
},
generateImage
:
async
(
id
:
string
):
Promise
<
GlobalAsset
>
=>
{
const
r
=
await
apiClient
.
post
(
`/assets/
${
id
}
/generate-image`
,
undefined
,
{
timeout
:
180
_000
,
});
return
r
.
data
.
data
;
},
delete
:
async
(
id
:
string
):
Promise
<
void
>
=>
{
await
apiClient
.
delete
(
`/assets/
${
id
}
`
);
},
...
...
yaoai-comic-studio/yaoai-api/src/main/java/com/yaoai/api/controller/GlobalAssetsController.java
View file @
232de8ef
...
...
@@ -7,12 +7,17 @@ import com.yaoai.common.exception.ErrorCode;
import
com.yaoai.common.response.ApiResponse
;
import
com.yaoai.domain.entity.GlobalAsset
;
import
com.yaoai.domain.mapper.GlobalAssetMapper
;
import
com.yaoai.pipeline.service.ImageGenPipelineService
;
import
com.yaoai.security.context.TenantContext
;
import
com.yaoai.storage.service.TosService
;
import
io.swagger.v3.oas.annotations.Operation
;
import
io.swagger.v3.oas.annotations.tags.Tag
;
import
lombok.RequiredArgsConstructor
;
import
org.springframework.http.MediaType
;
import
org.springframework.web.bind.annotation.*
;
import
org.springframework.web.multipart.MultipartFile
;
import
java.io.IOException
;
import
java.util.List
;
import
java.util.Map
;
...
...
@@ -23,6 +28,8 @@ import java.util.Map;
public
class
GlobalAssetsController
{
private
final
GlobalAssetMapper
globalAssetMapper
;
private
final
TosService
tosService
;
private
final
ImageGenPipelineService
imageGenPipelineService
;
@Operation
(
summary
=
"获取资产列表"
)
@GetMapping
...
...
@@ -54,7 +61,11 @@ public class GlobalAssetsController {
asset
.
setDescription
((
String
)
body
.
get
(
"description"
));
asset
.
setIsFavorite
(
0
);
Object
srcProject
=
body
.
get
(
"sourceProjectId"
);
if
(
srcProject
instanceof
Number
)
asset
.
setSourceProjectId
(((
Number
)
srcProject
).
longValue
());
if
(
srcProject
instanceof
Number
)
{
asset
.
setSourceProjectId
(((
Number
)
srcProject
).
longValue
());
}
else
if
(
srcProject
instanceof
String
srcProjectText
&&
!
srcProjectText
.
isBlank
())
{
asset
.
setSourceProjectId
(
Long
.
valueOf
(
srcProjectText
));
}
globalAssetMapper
.
insert
(
asset
);
return
ApiResponse
.
success
(
GlobalAssetDTO
.
from
(
asset
));
...
...
@@ -90,6 +101,65 @@ public class GlobalAssetsController {
return
ApiResponse
.
success
(
GlobalAssetDTO
.
from
(
asset
));
}
@Operation
(
summary
=
"AI 生成资产图片"
)
@PostMapping
(
"/{id}/generate-image"
)
public
ApiResponse
<
GlobalAssetDTO
>
generateImage
(
@PathVariable
Long
id
)
{
Long
tenantId
=
TenantContext
.
get
();
Long
userId
=
StpUtil
.
getLoginIdAsLong
();
GlobalAsset
asset
=
globalAssetMapper
.
selectById
(
id
);
if
(
asset
==
null
||
!
tenantId
.
equals
(
asset
.
getTenantId
()))
{
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"资产不存在"
);
}
if
(!
"prop"
.
equals
(
asset
.
getAssetType
()))
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"当前仅支持生成道具图片"
);
}
Long
projectKeyId
=
asset
.
getSourceProjectId
()
!=
null
?
asset
.
getSourceProjectId
()
:
0L
;
String
key
=
imageGenPipelineService
.
generateAndStore
(
tenantId
,
userId
,
projectKeyId
,
"global-assets/"
+
asset
.
getAssetType
(),
buildPropImagePrompt
(
asset
)
);
asset
.
setImageTosKey
(
key
);
asset
.
setImageUrl
(
tosService
.
publicUrl
(
key
));
globalAssetMapper
.
updateById
(
asset
);
return
ApiResponse
.
success
(
GlobalAssetDTO
.
from
(
asset
));
}
@Operation
(
summary
=
"上传资产图片"
)
@PostMapping
(
value
=
"/{id}/upload-image"
,
consumes
=
MediaType
.
MULTIPART_FORM_DATA_VALUE
)
public
ApiResponse
<
GlobalAssetDTO
>
uploadImage
(
@PathVariable
Long
id
,
@RequestPart
(
"file"
)
MultipartFile
file
)
throws
IOException
{
Long
tenantId
=
TenantContext
.
get
();
GlobalAsset
asset
=
globalAssetMapper
.
selectById
(
id
);
if
(
asset
==
null
||
!
tenantId
.
equals
(
asset
.
getTenantId
()))
{
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"资产不存在"
);
}
if
(
file
==
null
||
file
.
isEmpty
())
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"图片文件不能为空"
);
}
String
contentType
=
file
.
getContentType
()
!=
null
?
file
.
getContentType
()
:
"application/octet-stream"
;
if
(!
contentType
.
startsWith
(
"image/"
))
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"请上传图片文件"
);
}
Long
projectKeyId
=
asset
.
getSourceProjectId
()
!=
null
?
asset
.
getSourceProjectId
()
:
0L
;
String
key
=
TosService
.
buildKey
(
tenantId
,
projectKeyId
,
"global-assets/"
+
asset
.
getAssetType
(),
file
.
getOriginalFilename
()
);
tosService
.
upload
(
key
,
file
.
getInputStream
(),
file
.
getSize
(),
contentType
);
asset
.
setImageTosKey
(
key
);
asset
.
setImageUrl
(
tosService
.
publicUrl
(
key
));
globalAssetMapper
.
updateById
(
asset
);
return
ApiResponse
.
success
(
GlobalAssetDTO
.
from
(
asset
));
}
@Operation
(
summary
=
"删除资产"
)
@DeleteMapping
(
"/{id}"
)
public
ApiResponse
<
Void
>
delete
(
@PathVariable
Long
id
)
{
...
...
@@ -101,4 +171,20 @@ public class GlobalAssetsController {
globalAssetMapper
.
deleteById
(
id
);
return
ApiResponse
.
success
();
}
private
String
buildPropImagePrompt
(
GlobalAsset
asset
)
{
String
category
=
asset
.
getTags
()
!=
null
&&
!
asset
.
getTags
().
isBlank
()
?
asset
.
getTags
()
:
"道具"
;
String
description
=
asset
.
getDescription
()
!=
null
&&
!
asset
.
getDescription
().
isBlank
()
?
asset
.
getDescription
()
:
"根据名称和分类设计清晰可识别的影视道具"
;
return
"""
Cinematic AI short-drama prop reference image, single object only.
Create one clear hero reference image for a prop used in video generation.
Neutral clean studio background, three-quarter view, accurate material, scale cues, sharp silhouette, realistic lighting.
No people, no hands, no environment clutter, no text, no labels, no watermark, no logo, no duplicated objects.
Prop name: %s
Prop category: %s
Prop description: %s
"""
.
formatted
(
asset
.
getName
(),
category
,
description
);
}
}
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/service/ImageGenPipelineService.java
View file @
232de8ef
...
...
@@ -13,4 +13,6 @@ public interface ImageGenPipelineService {
String
generateAndStore
(
Long
tenantId
,
Long
projectId
,
String
prompt
);
String
generateAndStore
(
Long
tenantId
,
Long
userId
,
Long
projectId
,
String
prompt
);
String
generateAndStore
(
Long
tenantId
,
Long
userId
,
Long
projectId
,
String
assetType
,
String
prompt
);
}
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/service/impl/ImageGenPipelineServiceImpl.java
View file @
232de8ef
...
...
@@ -39,6 +39,11 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService {
@Override
public
String
generateAndStore
(
Long
tenantId
,
Long
userId
,
Long
projectId
,
String
prompt
)
{
return
generateAndStore
(
tenantId
,
userId
,
projectId
,
"scene"
,
prompt
);
}
@Override
public
String
generateAndStore
(
Long
tenantId
,
Long
userId
,
Long
projectId
,
String
assetType
,
String
prompt
)
{
billingService
.
checkBalance
(
BillingChargeRequest
.
builder
()
.
tenantId
(
tenantId
)
.
userId
(
userId
)
...
...
@@ -60,9 +65,10 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService {
byte
[]
imageBytes
=
downloadImage
(
imageUrl
);
// 3. 上传到 TOS
String
key
=
TosService
.
buildKey
(
tenantId
,
projectId
,
"scene"
,
"scene.jpg"
);
String
normalizedAssetType
=
assetType
!=
null
&&
!
assetType
.
isBlank
()
?
assetType
:
"image"
;
String
key
=
TosService
.
buildKey
(
tenantId
,
projectId
,
normalizedAssetType
,
normalizedAssetType
+
".jpg"
);
tosService
.
upload
(
key
,
new
ByteArrayInputStream
(
imageBytes
),
imageBytes
.
length
,
"image/jpeg"
);
log
.
info
(
"
Scene
image stored: key={}"
,
key
);
log
.
info
(
"
Generated
image stored: key={}"
,
key
);
billingService
.
charge
(
BillingChargeRequest
.
builder
()
.
tenantId
(
tenantId
)
.
userId
(
userId
)
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment