feat(canvas): 参数全部改下拉,支持生视频模式与视频节点
This commit is contained in:
@@ -24,7 +24,7 @@ const editing = ref(false)
|
|||||||
|
|
||||||
let imgController = null
|
let imgController = null
|
||||||
async function loadSrc() {
|
async function loadSrc() {
|
||||||
if (props.node.type !== 'image' || !props.node.src) return
|
if (!['image', 'video'].includes(props.node.type) || !props.node.src) return
|
||||||
imgController?.abort()
|
imgController?.abort()
|
||||||
imgController = new AbortController()
|
imgController = new AbortController()
|
||||||
imgLoaded.value = false
|
imgLoaded.value = false
|
||||||
@@ -79,6 +79,19 @@ function onImgLoad(e) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function onVidMeta(e) {
|
||||||
|
imgLoaded.value = true
|
||||||
|
imgBroken.value = false
|
||||||
|
const v = e.target
|
||||||
|
if (v.videoWidth && v.videoHeight) {
|
||||||
|
const long = Math.max(v.videoWidth, v.videoHeight)
|
||||||
|
const k = 512 / long
|
||||||
|
emit('imgload', {
|
||||||
|
w: Math.round(v.videoWidth * k),
|
||||||
|
h: Math.round(v.videoHeight * k),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
if (props.node.type !== 'note') return
|
if (props.node.type !== 'note') return
|
||||||
@@ -164,6 +177,23 @@ defineExpose({ startEdit, commitNote })
|
|||||||
<div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div>
|
<div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 视频节点 -->
|
||||||
|
<template v-else-if="node.type === 'video'">
|
||||||
|
<video
|
||||||
|
v-if="!imgBroken && imgBlob"
|
||||||
|
class="cv-node__img"
|
||||||
|
:src="imgBlob"
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsinline
|
||||||
|
@loadedmetadata="onVidMeta"
|
||||||
|
@error="imgBroken = true"
|
||||||
|
/>
|
||||||
|
<div v-else class="cv-node__missing">{{ imgBroken ? '视频不可用' : '加载中…' }}<small v-if="imgStatus">HTTP {{ imgStatus }}</small></div>
|
||||||
|
<div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 笔记节点 -->
|
<!-- 笔记节点 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="node.type === 'note'"
|
v-else-if="node.type === 'note'"
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function normalizeDoc(raw) {
|
|||||||
if (!n || typeof n.id !== 'string' || ids.has(n.id)) continue
|
if (!n || typeof n.id !== 'string' || ids.has(n.id)) continue
|
||||||
if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) continue
|
if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) continue
|
||||||
if (!Number.isFinite(n.w) || n.w <= 0 || !Number.isFinite(n.h) || n.h <= 0) continue
|
if (!Number.isFinite(n.w) || n.w <= 0 || !Number.isFinite(n.h) || n.h <= 0) continue
|
||||||
const type = n.type === 'note' || n.type === 'gen' ? n.type : 'image'
|
const type = ['note', 'gen', 'video'].includes(n.type) ? n.type : 'image'
|
||||||
const node = {
|
const node = {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type,
|
type,
|
||||||
@@ -108,9 +108,9 @@ export function normalizeDoc(raw) {
|
|||||||
z: Number.isFinite(n.z) ? n.z : 0,
|
z: Number.isFinite(n.z) ? n.z : 0,
|
||||||
}
|
}
|
||||||
if (type === 'note') node.text = typeof n.text === 'string' ? n.text.slice(0, 4000) : ''
|
if (type === 'note') node.text = typeof n.text === 'string' ? n.text.slice(0, 4000) : ''
|
||||||
if (type === 'image') {
|
if (type === 'image' || type === 'video') {
|
||||||
node.src = typeof n.src === 'string' ? n.src.slice(0, 500) : ''
|
node.src = typeof n.src === 'string' ? n.src.slice(0, 500) : ''
|
||||||
if (!node.src) continue // 图片节点必须有 src
|
if (!node.src) continue // 媒体节点必须有 src
|
||||||
if (typeof n.prompt === 'string') node.prompt = n.prompt.slice(0, 2000)
|
if (typeof n.prompt === 'string') node.prompt = n.prompt.slice(0, 2000)
|
||||||
if (typeof n.model === 'string') node.model = n.model.slice(0, 120)
|
if (typeof n.model === 'string') node.model = n.model.slice(0, 120)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,27 +26,33 @@ const previewSrc = ref('')
|
|||||||
|
|
||||||
// ---- 生成面板 ----
|
// ---- 生成面板 ----
|
||||||
const allModels = ref([])
|
const allModels = ref([])
|
||||||
const gen = reactive({ modelId: '', prompt: '', ratio: '', resolution: '', count: 1 })
|
const gen = reactive({ kind: 'image', modelId: '', prompt: '', ratio: '', resolution: '', duration: '', count: 1 })
|
||||||
const runningGens = ref(0)
|
const runningGens = ref(0)
|
||||||
const genBusy = computed(() => runningGens.value > 0)
|
const genBusy = computed(() => runningGens.value > 0)
|
||||||
const genMsg = ref('')
|
const genMsg = ref('')
|
||||||
const imageModels = computed(() =>
|
const kindModels = computed(() =>
|
||||||
(allModels.value || []).filter((m) => m.enabled !== false && m.type === 'image'),
|
(allModels.value || []).filter((m) => m.enabled !== false && m.type === gen.kind),
|
||||||
)
|
)
|
||||||
const model = computed(() => imageModels.value.find((m) => m.id === gen.modelId) || null)
|
const model = computed(() => kindModels.value.find((m) => m.id === gen.modelId) || null)
|
||||||
const ratios = computed(() => model.value?.ratios?.length ? model.value.ratios : ['1:1'])
|
const ratios = computed(() => model.value?.ratios?.length ? model.value.ratios : ['1:1'])
|
||||||
const resolutions = computed(() => model.value?.resolutions?.length
|
const resolutions = computed(() => model.value?.resolutions?.length
|
||||||
? model.value.resolutions
|
? model.value.resolutions
|
||||||
: Object.keys(model.value?.prices || {}))
|
: Object.keys(model.value?.prices || {}))
|
||||||
|
const durations = computed(() => model.value?.durations?.length ? model.value.durations : ['5s'])
|
||||||
const maxRefs = computed(() => Math.min(8, Math.max(
|
const maxRefs = computed(() => Math.min(8, Math.max(
|
||||||
Number(model.value?.max_reference_images || 0), model.value?.image_to_image ? 1 : 0,
|
Number(model.value?.max_reference_images || 0), model.value?.image_to_image ? 1 : 0,
|
||||||
)))
|
)))
|
||||||
// 换模型时重置参数默认值(和画图台一致)
|
// 换类型/模型时重置参数默认值(和画图台一致)
|
||||||
|
watch(() => gen.kind, () => {
|
||||||
|
const first = kindModels.value[0]
|
||||||
|
gen.modelId = first?.id || ''
|
||||||
|
})
|
||||||
watch(model, (m) => {
|
watch(model, (m) => {
|
||||||
if (!m) return
|
if (!m) return
|
||||||
gen.ratio = m.ratios?.[0] || '1:1'
|
gen.ratio = m.ratios?.[0] || '1:1'
|
||||||
const res = m.resolutions?.length ? m.resolutions : Object.keys(m.prices || {})
|
const res = m.resolutions?.length ? m.resolutions : Object.keys(m.prices || {})
|
||||||
gen.resolution = res[0] || '1K'
|
gen.resolution = res[0] || '1K'
|
||||||
|
gen.duration = m.durations?.[0] || '5s'
|
||||||
})
|
})
|
||||||
const selectedNotes = computed(() =>
|
const selectedNotes = computed(() =>
|
||||||
selectionIds.value
|
selectionIds.value
|
||||||
@@ -551,7 +557,7 @@ async function generate() {
|
|||||||
}
|
}
|
||||||
genMsg.value = ''
|
genMsg.value = ''
|
||||||
|
|
||||||
const n = Math.max(1, Math.min(4, Number(gen.count) || 1))
|
const n = gen.kind === 'image' ? Math.max(1, Math.min(4, Number(gen.count) || 1)) : 1
|
||||||
const size = ratioToSize(gen.ratio || ratios.value[0])
|
const size = ratioToSize(gen.ratio || ratios.value[0])
|
||||||
const sources = [...selectedNotes.value]
|
const sources = [...selectedNotes.value]
|
||||||
// 落点:参与源节点的包围盒右侧;无源则视口中心。多张时横向排开。
|
// 落点:参与源节点的包围盒右侧;无源则视口中心。多张时横向排开。
|
||||||
@@ -599,6 +605,7 @@ async function runGeneration(node, prompt) {
|
|||||||
model: gen.modelId, prompt, ratio: gen.ratio || ratios.value[0],
|
model: gen.modelId, prompt, ratio: gen.ratio || ratios.value[0],
|
||||||
resolution: gen.resolution || resolutions.value[0] || '1K',
|
resolution: gen.resolution || resolutions.value[0] || '1K',
|
||||||
}
|
}
|
||||||
|
if (gen.kind === 'video') payload.duration = gen.duration || durations.value[0]
|
||||||
if (refImages.value.length) {
|
if (refImages.value.length) {
|
||||||
payload.reference_images = refImages.value.map((r) => r.dataUrl.replace(/^data:[^,]*,/, ''))
|
payload.reference_images = refImages.value.map((r) => r.dataUrl.replace(/^data:[^,]*,/, ''))
|
||||||
}
|
}
|
||||||
@@ -606,7 +613,7 @@ async function runGeneration(node, prompt) {
|
|||||||
opts.headers['Idempotency-Key'] = node.id
|
opts.headers['Idempotency-Key'] = node.id
|
||||||
const r = await api('/generate', opts)
|
const r = await api('/generate', opts)
|
||||||
if (r.ok && r.data?.url) {
|
if (r.ok && r.data?.url) {
|
||||||
node.type = 'image'
|
node.type = gen.kind === 'video' ? 'video' : 'image'
|
||||||
node.src = r.data.url.replace(/^\/images\//, '')
|
node.src = r.data.url.replace(/^\/images\//, '')
|
||||||
node.prompt = prompt
|
node.prompt = prompt
|
||||||
node.model = model.value?.alias || model.value?.name || model.value?.id
|
node.model = model.value?.alias || model.value?.name || model.value?.id
|
||||||
@@ -746,7 +753,7 @@ onMounted(async () => {
|
|||||||
document.addEventListener('visibilitychange', onVisibility)
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
const mm = await api('/managed-models')
|
const mm = await api('/managed-models')
|
||||||
allModels.value = mm.data?.data || []
|
allModels.value = mm.data?.data || []
|
||||||
const first = imageModels.value[0]
|
const first = kindModels.value[0]
|
||||||
if (first) {
|
if (first) {
|
||||||
gen.modelId = first.id
|
gen.modelId = first.id
|
||||||
gen.ratio = (first.ratios && first.ratios[0]) || '1:1'
|
gen.ratio = (first.ratios && first.ratios[0]) || '1:1'
|
||||||
@@ -874,44 +881,54 @@ const saveLabel = computed(() => ({
|
|||||||
placeholder="描述你要生成的画面…(留空则使用选中的笔记)"
|
placeholder="描述你要生成的画面…(留空则使用选中的笔记)"
|
||||||
/>
|
/>
|
||||||
<div class="cv-composer__bar">
|
<div class="cv-composer__bar">
|
||||||
|
<select v-model="gen.kind" class="cv-composer__sel" title="生成类型">
|
||||||
|
<option value="image">生图</option>
|
||||||
|
<option value="video">生视频</option>
|
||||||
|
</select>
|
||||||
<select
|
<select
|
||||||
v-if="imageModels.length"
|
v-if="kindModels.length"
|
||||||
v-model="gen.modelId"
|
v-model="gen.modelId"
|
||||||
class="cv-composer__sel"
|
class="cv-composer__sel cv-composer__sel--model"
|
||||||
title="生成模型"
|
title="生成模型"
|
||||||
>
|
>
|
||||||
<option v-for="m in imageModels" :key="m.id" :value="m.id">
|
<option v-for="m in kindModels" :key="m.id" :value="m.id">
|
||||||
{{ m.alias || m.name || m.id }}
|
{{ m.alias || m.name || m.id }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
v-if="resolutions.length > 1"
|
v-if="model && ratios.length"
|
||||||
|
v-model="gen.ratio"
|
||||||
|
class="cv-composer__sel"
|
||||||
|
title="比例"
|
||||||
|
>
|
||||||
|
<option v-for="r in ratios" :key="r" :value="r">{{ r }}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-if="gen.kind === 'image' && model && resolutions.length"
|
||||||
v-model="gen.resolution"
|
v-model="gen.resolution"
|
||||||
class="cv-composer__sel"
|
class="cv-composer__sel"
|
||||||
title="画质"
|
title="画质"
|
||||||
>
|
>
|
||||||
<option v-for="r in resolutions" :key="r" :value="r">{{ r }}</option>
|
<option v-for="r in resolutions" :key="r" :value="r">{{ r }}</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-if="!imageModels.length" class="cv-nomodels">⚠ 还没有可用的图像模型,请到管理后台添加</div>
|
<select
|
||||||
<div class="cv-ratios" title="比例">
|
v-if="gen.kind === 'video' && model && durations.length"
|
||||||
<button
|
v-model="gen.duration"
|
||||||
v-for="r in ratios"
|
class="cv-composer__sel"
|
||||||
:key="r"
|
title="时长"
|
||||||
type="button"
|
>
|
||||||
class="cv-chip"
|
<option v-for="d in durations" :key="d" :value="d">{{ d }}</option>
|
||||||
:class="{ 'is-on': gen.ratio === r }"
|
</select>
|
||||||
@click="gen.ratio = r"
|
<select
|
||||||
>{{ r }}</button>
|
v-if="gen.kind === 'image'"
|
||||||
</div>
|
v-model.number="gen.count"
|
||||||
<div class="cv-ratios" title="生成张数">
|
class="cv-composer__sel"
|
||||||
<button
|
title="生成张数"
|
||||||
v-for="n in [1, 2, 3, 4]"
|
>
|
||||||
:key="n"
|
<option v-for="n in [1, 2, 3, 4]" :key="n" :value="n">{{ n }}张</option>
|
||||||
type="button"
|
</select>
|
||||||
class="cv-chip"
|
<div v-if="!kindModels.length" class="cv-nomodels">
|
||||||
:class="{ 'is-on': gen.count === n }"
|
⚠ 还没有可用的{{ gen.kind === 'video' ? '视频' : '图像' }}模型
|
||||||
@click="gen.count = n"
|
|
||||||
>{{ n }}张</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="cv-gen" :disabled="genBusy || !model" @click="generate">
|
<button class="cv-gen" :disabled="genBusy || !model" @click="generate">
|
||||||
{{ genBusy ? '生成中…' : '生成' }}
|
{{ genBusy ? '生成中…' : '生成' }}
|
||||||
@@ -1283,6 +1300,10 @@ const saveLabel = computed(() => ({
|
|||||||
.cv-composer__sel:focus {
|
.cv-composer__sel:focus {
|
||||||
border-color: rgb(124 92 252 / 0.6);
|
border-color: rgb(124 92 252 / 0.6);
|
||||||
}
|
}
|
||||||
|
.cv-composer__sel--model {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
.cv-composer__bar .cv-gen {
|
.cv-composer__bar .cv-gen {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 7px 24px;
|
padding: 7px 24px;
|
||||||
|
|||||||
Reference in New Issue
Block a user