feat: expose image quality and pixel sizes
This commit is contained in:
@@ -61,18 +61,35 @@
|
||||
</div>
|
||||
|
||||
<!-- Size selector | 尺寸选择 -->
|
||||
<div v-if="hasSizeOptions" class="flex items-center justify-between">
|
||||
<span class="text-xs text-[var(--text-secondary)]">尺寸</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<n-dropdown :options="sizeOptions" @select="handleSizeSelect">
|
||||
<button
|
||||
class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||
{{ displaySize }}
|
||||
<n-icon :size="12">
|
||||
<ChevronForwardOutline />
|
||||
</n-icon>
|
||||
</button>
|
||||
</n-dropdown>
|
||||
<div v-if="hasSizeOptions" class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-[var(--text-secondary)] flex-shrink-0">尺寸</span>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<n-dropdown :options="sizeOptions" @select="handleSizeSelect">
|
||||
<button
|
||||
class="flex items-center justify-end gap-1 text-sm text-right text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||
{{ displaySize }}
|
||||
<n-icon :size="12">
|
||||
<ChevronForwardOutline />
|
||||
</n-icon>
|
||||
</button>
|
||||
</n-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<input
|
||||
v-model="customSizeInput"
|
||||
@keydown.enter.prevent="applyCustomSize"
|
||||
class="flex-1 min-w-0 rounded-md border border-[var(--border-color)] bg-[var(--bg-primary)] px-2 py-1 text-xs text-[var(--text-primary)] outline-none focus:border-[var(--accent-color)]"
|
||||
placeholder="自定义 1088x1920"
|
||||
/>
|
||||
<button
|
||||
@click="applyCustomSize"
|
||||
class="flex-shrink-0 rounded-md border border-[var(--border-color)] px-2 py-1 text-xs text-[var(--text-secondary)] hover:border-[var(--accent-color)] hover:text-[var(--accent-color)]"
|
||||
>应用</button>
|
||||
</div>
|
||||
<div v-if="customSizeError" class="text-[10px] leading-tight text-red-500">
|
||||
{{ customSizeError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +183,7 @@ import { useImageGeneration } from '../../hooks'
|
||||
import { updateNode, addNode, addEdge, nodes, edges, duplicateNode, removeNode } from '../../stores/canvas'
|
||||
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||
import { useModelStore } from '../../stores/pinia'
|
||||
import { getModelSizeOptions, getModelQualityOptions, getModelConfig, DEFAULT_IMAGE_MODEL, DEFAULT_IMAGE_SIZE } from '../../stores/models'
|
||||
import { getModelSizeOptions, getModelQualityOptions, getModelConfig, DEFAULT_IMAGE_MODEL, DEFAULT_IMAGE_SIZE, DEFAULT_IMAGE_QUALITY } from '../../stores/models'
|
||||
import { parseMentions } from '../../hooks/useNodeRef'
|
||||
|
||||
// 使用 Pinia store 获取模型选项(根据渠道过滤)
|
||||
@@ -187,10 +204,16 @@ const isConfigured = computed(() => !!modelStore.currentApiKey)
|
||||
const { loading, error, images: generatedImages, generate } = useImageGeneration()
|
||||
|
||||
// Local state | 本地状态
|
||||
const normalizeQualityKey = (quality) => {
|
||||
if (quality === 'standard' || quality === 'hd') return 'high'
|
||||
return quality || DEFAULT_IMAGE_QUALITY
|
||||
}
|
||||
const showHandleMenu = ref(false)
|
||||
const localModel = ref(props.data?.model || DEFAULT_IMAGE_MODEL)
|
||||
const localSize = ref(props.data?.size || DEFAULT_IMAGE_SIZE)
|
||||
const localQuality = ref(props.data?.quality || 'standard')
|
||||
const localQuality = ref(normalizeQualityKey(props.data?.quality))
|
||||
const customSizeInput = ref(localSize.value)
|
||||
const customSizeError = ref('')
|
||||
|
||||
// Label editing state | Label 编辑状态
|
||||
const isEditingLabel = ref(false)
|
||||
@@ -260,7 +283,7 @@ const hasQualityOptions = computed(() => {
|
||||
// Display quality | 显示画质
|
||||
const displayQuality = computed(() => {
|
||||
const option = qualityOptions.value.find(o => o.key === localQuality.value)
|
||||
return option?.label || '标准画质'
|
||||
return option?.label || '高 · 最终稿'
|
||||
})
|
||||
|
||||
// Size options based on model and quality | 基于模型和画质的尺寸选项
|
||||
@@ -277,9 +300,36 @@ const hasSizeOptions = computed(() => {
|
||||
// Display size with label | 显示尺寸(带标签)
|
||||
const displaySize = computed(() => {
|
||||
const option = sizeOptions.value.find(o => o.key === localSize.value)
|
||||
return option?.label || localSize.value
|
||||
return option?.label || formatSizeLabel(localSize.value)
|
||||
})
|
||||
|
||||
const parseImageSize = (value) => {
|
||||
const normalized = String(value || '').trim().toLowerCase().replace('×', 'x')
|
||||
const match = normalized.match(/^(\d{3,4})\s*x\s*(\d{3,4})$/)
|
||||
if (!match) return null
|
||||
return { width: Number(match[1]), height: Number(match[2]), key: `${Number(match[1])}x${Number(match[2])}` }
|
||||
}
|
||||
|
||||
const formatSizeLabel = (value) => {
|
||||
if (value === 'auto') return '自动 · 生成后显示实际像素'
|
||||
const parsed = parseImageSize(value)
|
||||
return parsed ? `自定义 · ${parsed.width}×${parsed.height}` : value
|
||||
}
|
||||
|
||||
const validateImageSize = (value) => {
|
||||
const parsed = parseImageSize(value)
|
||||
if (!parsed) return { ok: false, message: '格式用 1088x1920' }
|
||||
const { width, height } = parsed
|
||||
const pixels = width * height
|
||||
const longEdge = Math.max(width, height)
|
||||
const shortEdge = Math.min(width, height)
|
||||
if (width % 16 !== 0 || height % 16 !== 0) return { ok: false, message: '宽高需为 16 的倍数' }
|
||||
if (longEdge > 3840) return { ok: false, message: '最长边不能超过 3840px' }
|
||||
if (longEdge / shortEdge > 3) return { ok: false, message: '比例不能超过 3:1' }
|
||||
if (pixels < 655360 || pixels > 8294400) return { ok: false, message: '总像素超出模型范围' }
|
||||
return { ok: true, key: parsed.key }
|
||||
}
|
||||
|
||||
// Initialize on mount | 挂载时初始化
|
||||
onMounted(() => {
|
||||
// 检查当前模型是否在可用模型列表中
|
||||
@@ -292,6 +342,9 @@ onMounted(() => {
|
||||
localModel.value = selected || availableModels[0]?.key || DEFAULT_IMAGE_MODEL
|
||||
updateNode(props.id, { model: localModel.value })
|
||||
}
|
||||
if (props.data?.quality !== localQuality.value) {
|
||||
updateNode(props.id, { quality: localQuality.value })
|
||||
}
|
||||
})
|
||||
|
||||
// 解析 textNode 内容中的 @ 引用,转换为简短引用(如 图 1)并收集图片
|
||||
@@ -493,6 +546,8 @@ const handleModelSelect = (key) => {
|
||||
}
|
||||
|
||||
localSize.value = defaultSize
|
||||
customSizeInput.value = defaultSize === 'auto' ? '' : defaultSize
|
||||
customSizeError.value = ''
|
||||
|
||||
// 更新节点数据
|
||||
updateNode(props.id, {
|
||||
@@ -505,20 +560,14 @@ const handleModelSelect = (key) => {
|
||||
// Handle quality selection | 处理画质选择
|
||||
const handleQualitySelect = (quality) => {
|
||||
localQuality.value = quality
|
||||
// Update size to first option of new quality | 更新尺寸为新画质的第一个选项
|
||||
const newSizeOptions = getModelSizeOptions(localModel.value, quality)
|
||||
if (newSizeOptions.length > 0) {
|
||||
const defaultSize = newSizeOptions.find(o => o.key === DEFAULT_IMAGE_SIZE)?.key
|
||||
localSize.value = defaultSize || newSizeOptions[0].key
|
||||
updateNode(props.id, { quality, size: localSize.value })
|
||||
} else {
|
||||
updateNode(props.id, { quality })
|
||||
}
|
||||
updateNode(props.id, { quality })
|
||||
}
|
||||
|
||||
// Handle size selection | 处理尺寸选择
|
||||
const handleSizeSelect = (size) => {
|
||||
localSize.value = size
|
||||
customSizeInput.value = size === 'auto' ? '' : size
|
||||
customSizeError.value = ''
|
||||
updateNode(props.id, { size })
|
||||
}
|
||||
|
||||
@@ -527,6 +576,18 @@ const updateSize = () => {
|
||||
updateNode(props.id, { size: localSize.value })
|
||||
}
|
||||
|
||||
const applyCustomSize = () => {
|
||||
const result = validateImageSize(customSizeInput.value)
|
||||
if (!result.ok) {
|
||||
customSizeError.value = result.message
|
||||
return
|
||||
}
|
||||
customSizeError.value = ''
|
||||
localSize.value = result.key
|
||||
customSizeInput.value = result.key
|
||||
updateNode(props.id, { size: result.key })
|
||||
}
|
||||
|
||||
// Created image node ID | 创建的图片节点 ID
|
||||
const createdImageNodeId = ref(null)
|
||||
|
||||
@@ -596,7 +657,16 @@ const handleGenerate = async (mode = 'auto') => {
|
||||
// Replace mode: find any connected image node | 替换模式:查找任意连接的图片节点
|
||||
imageNodeId = findConnectedOutputImageNode(false)
|
||||
if (imageNodeId) {
|
||||
updateNode(imageNodeId, { loading: true, url: '' })
|
||||
updateNode(imageNodeId, {
|
||||
loading: true,
|
||||
url: '',
|
||||
error: '',
|
||||
model: localModel.value,
|
||||
size: localSize.value,
|
||||
quality: localQuality.value,
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
}
|
||||
} else if (mode === 'new') {
|
||||
// New mode: always create new node | 新建模式:始终创建新节点
|
||||
@@ -605,7 +675,15 @@ const handleGenerate = async (mode = 'auto') => {
|
||||
// Auto mode: check for empty connected node first | 自动模式:先检查空白连接节点
|
||||
imageNodeId = findConnectedOutputImageNode(true)
|
||||
if (imageNodeId) {
|
||||
updateNode(imageNodeId, { loading: true })
|
||||
updateNode(imageNodeId, {
|
||||
loading: true,
|
||||
error: '',
|
||||
model: localModel.value,
|
||||
size: localSize.value,
|
||||
quality: localQuality.value,
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,7 +704,10 @@ const handleGenerate = async (mode = 'auto') => {
|
||||
imageNodeId = addNode('image', { x: nodeX + 400, y: nodeY + yOffset }, {
|
||||
url: '',
|
||||
loading: true,
|
||||
label: '图像生成结果'
|
||||
label: '图像生成结果',
|
||||
model: localModel.value,
|
||||
size: localSize.value,
|
||||
quality: localQuality.value
|
||||
})
|
||||
|
||||
// Auto-connect imageConfig → image | 自动连接 生图配置 → 图片
|
||||
@@ -668,7 +749,11 @@ const handleGenerate = async (mode = 'auto') => {
|
||||
url: result[0].url,
|
||||
loading: false,
|
||||
label: '文生图',
|
||||
model: localModel.value,
|
||||
model: result[0].model || localModel.value,
|
||||
size: result[0].size || localSize.value,
|
||||
quality: result[0].quality || localQuality.value,
|
||||
width: result[0].width || 0,
|
||||
height: result[0].height || 0,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
|
||||
@@ -742,10 +827,25 @@ watch(() => props.data?.model, (newModel) => {
|
||||
// 同步 Size
|
||||
if (config?.defaultParams?.size) {
|
||||
localSize.value = config.defaultParams.size
|
||||
customSizeInput.value = localSize.value === 'auto' ? '' : localSize.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.data?.size, (newSize) => {
|
||||
if (newSize && newSize !== localSize.value) {
|
||||
localSize.value = newSize
|
||||
customSizeInput.value = newSize === 'auto' ? '' : newSize
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.data?.quality, (newQuality) => {
|
||||
const normalized = normalizeQualityKey(newQuality)
|
||||
if (normalized !== localQuality.value) {
|
||||
localQuality.value = normalized
|
||||
}
|
||||
})
|
||||
|
||||
// 修复 Vue Flow visibility: hidden 问题
|
||||
watch(() => props.data, () => {
|
||||
nextTick(() => {
|
||||
|
||||
Reference in New Issue
Block a user