Skip to content

Commit d1825a2

Browse files
committed
Add ImageToImage UI
1 parent e5edab8 commit d1825a2

File tree

4 files changed

+369
-9
lines changed

4 files changed

+369
-9
lines changed
Lines changed: 365 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,368 @@
1+
import { ref, computed, onMounted, inject, watch, nextTick } from "vue"
2+
import { useClient } from "@servicestack/vue"
3+
import { createErrorStatus } from "@servicestack/client"
4+
import { ImageToImage, ActiveMediaModels } from "dtos"
5+
import { UiLayout, ThreadStorage, HistoryTitle, HistoryGroups, useUiLayout, icons } from "../utils.mjs"
6+
import { ArtifactGallery } from "./Artifacts.mjs"
7+
import PromptGenerator from "./PromptGenerator.mjs"
8+
import FileUpload from "./FileUpload.mjs"
9+
110
export default {
2-
template: `
3-
<h3 class="p-2 sm:block text-xl md:text-2xl font-semibold">Image to Image</h3>
11+
components: {
12+
UiLayout,
13+
HistoryTitle,
14+
HistoryGroups,
15+
ArtifactGallery,
16+
PromptGenerator,
17+
FileUpload,
18+
},
19+
template:`
20+
<UiLayout>
21+
<template #main>
22+
<div class="flex flex-1 gap-4 text-base md:gap-5 lg:gap-6">
23+
<form ref="refForm" :disabled="client.loading.value" class="w-full mb-0" @submit.prevent="send">
24+
<div class="relative flex h-full max-w-full flex-1 flex-col">
25+
<div class="flex flex-col w-full items-center">
26+
<fieldset class="w-full">
27+
<ErrorSummary :except="visibleFields" class="mb-4" />
28+
<div class="grid grid-cols-6 gap-4">
29+
<div class="col-span-6">
30+
<FileUpload ref="refImage" id="image" v-model="request.image" required
31+
accept=".webp,.jpg,.jpeg,.png,.gif" @change="renderKey++">
32+
<template #title>
33+
<span class="font-semibold text-green-600">Click to upload</span> or drag and drop
34+
</template>
35+
<template #icon>
36+
<svg class="mb-2 h-12 w-12 text-green-500 inline" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true" data-phx-id="m9-phx-F_34be7KYfTF66Xh">
37+
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
38+
</svg>
39+
</template>
40+
</FileUpload>
41+
</div>
42+
43+
<div class="col-span-6 sm:col-span-2">
44+
<TextInput type="number" id="denoise" v-model="request.denoise" min="0" step="0.01" required />
45+
</div>
46+
<div class="col-span-6 sm:col-span-4">
47+
<TextInput id="negativePrompt" v-model="request.negativePrompt" required placeholder="Negative Prompt" />
48+
</div>
49+
<div class="col-span-6 sm:col-span-1">
50+
<TextInput type="number" id="width" v-model="request.width" min="0" required />
51+
</div>
52+
<div class="col-span-6 sm:col-span-1">
53+
<TextInput type="number" id="height" v-model="request.height" min="0" required />
54+
</div>
55+
<div class="col-span-6 sm:col-span-1">
56+
<TextInput type="number" id="batchSize" label="Image Count" v-model="request.batchSize" min="0" required />
57+
</div>
58+
<div class="col-span-6 sm:col-span-1">
59+
<TextInput type="number" id="seed" v-model="request.seed" min="0" />
60+
</div>
61+
<div class="col-span-6 sm:col-span-2">
62+
<TextInput id="tag" v-model="request.tag" placeholder="Tag" />
63+
</div>
64+
</div>
65+
66+
<div class="mt-4 flex w-full flex-col gap-1.5 rounded-md p-1.5 transition-colors bg-[#f4f4f4] shadow border border-gray-300">
67+
<div class="flex items-center gap-1.5 md:gap-2">
68+
<div class="pl-4 flex min-w-0 flex-1 flex-col">
69+
<textarea ref="refMessage" id="txtMessage" v-model="request.positivePrompt" :disabled="client.loading.value" rows="2"
70+
placeholder="Generate Image Prompt..." spellcheck="false" autocomplete="off"
71+
@keydown.enter.prevent="send"
72+
:class="[{'opacity-50' : client.loading.value},'m-0 resize-none border-0 bg-transparent px-0 text-token-text-primary focus:ring-0 focus-visible:ring-0 max-h-[25dvh] max-h-52']"
73+
style="height:60px; overflow-y: hidden;"></textarea>
74+
</div>
75+
<button :disabled="!validPrompt || client.loading.value" title="Send (Enter)"
76+
class="mb-1 me-2 flex h-8 w-8 items-center justify-center rounded-full bg-black text-white transition-colors hover:opacity-70 focus-visible:outline-none focus-visible:outline-black disabled:bg-[#D7D7D7] disabled:text-[#f4f4f4] disabled:hover:opacity-100 dark:bg-white dark:text-black dark:focus-visible:outline-white disabled:dark:bg-token-text-quaternary dark:disabled:text-token-main-surface-secondary">
77+
<svg v-if="!client.loading.value" class="ml-1 w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M3 3.732a1.5 1.5 0 0 1 2.305-1.265l6.706 4.267a1.5 1.5 0 0 1 0 2.531l-6.706 4.268A1.5 1.5 0 0 1 3 12.267z"/></svg>
78+
<svg v-else class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M8 16h8V8H8zm4 6q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>
79+
</button>
80+
</div>
81+
</div>
82+
</fieldset>
83+
</div>
84+
</div>
85+
</form>
86+
</div>
87+
88+
<PromptGenerator :thread="thread" promptId="comfyui-image-prompter"
89+
@save="saveThread()" @selected="selectPrompt($event)" />
90+
91+
<div class="pb-20">
92+
93+
<div v-if="client.loading.value" class="mt-8 mb-20 flex justify-center">
94+
<Loading class="text-gray-300 font-normal" imageClass="w-7 h-7 mt-1.5">generating images...</Loading>
95+
</div>
96+
97+
<div v-for="result in getThreadResults()" class="w-full ">
98+
<div class="flex items-center justify-between">
99+
<span @click="selectRequest(result.request)" class="cursor-pointer my-4 flex justify-center items-center text-xl hover:underline underline-offset-4" title="New from this">
100+
{{ result.request.positivePrompt }}
101+
</span>
102+
<div class="group flex cursor-pointer" @click="discardResult(result)">
103+
<div class="ml-1 invisible group-hover:visible">discard</div>
104+
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="currentColor" d="M12 12h2v12h-2zm6 0h2v12h-2z"></path><path fill="currentColor" d="M4 6v2h2v20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8h2V6zm4 22V8h16v20zm4-26h8v2h-8z"></path></svg>
105+
</div>
106+
</div>
107+
108+
<ArtifactGallery :results="toArtifacts(result)">
109+
<template #bottom="{ selected }">
110+
<div class="z-40 fixed bottom-0 gap-x-6 w-full flex justify-center p-4 bg-black/20">
111+
<a :href="selected.url + '?download=1'" class="flex text-sm text-gray-300 hover:text-gray-100 hover:drop-shadow">
112+
<svg class="w-5 h-5 mr-0.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 20h12M12 4v12m0 0l3.5-3.5M12 16l-3.5-3.5"></path></svg>
113+
download
114+
</a>
115+
<div @click.stop.prevent="toggleIcon(selected)" class="flex cursor-pointer text-sm text-gray-300 hover:text-gray-100 hover:drop-shadow">
116+
<svg :class="['w-5 h-5 mr-0.5',selected.url == threadRef.icon ? '-rotate-45' : '']" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14 18l-8 8M20.667 4L28 11.333l-6.38 6.076a2 2 0 0 0-.62 1.448v3.729c0 .89-1.077 1.337-1.707.707L8.707 12.707c-.63-.63-.184-1.707.707-1.707h3.729a2 2 0 0 0 1.448-.62z"/></svg>
117+
{{selected.url == threadRef.icon ? 'unpin icon' : 'pin icon' }}
118+
</div>
119+
</div>
120+
</template>
121+
</ArtifactGallery>
122+
</div>
123+
</div>
124+
</template>
125+
126+
<template #sidebar>
127+
<HistoryTitle :prefix="storage.prefix" />
128+
<HistoryGroups :history="history" v-slot="{ item }" @save="saveHistoryItem($event)" @remove="removeHistoryItem($event)">
129+
<Icon class="h-5 w-5 rounded-full flex-shrink-0 mr-1" :src="item.icon ?? icons.image" loading="lazy" :alt="item.model" />
130+
<span :title="item.title">{{item.title}}</span>
131+
</HistoryGroups>
132+
</template>
133+
</UiLayout>
4134
`,
5-
setup() {
6-
return {}
135+
setup(props) {
136+
const client = useClient()
137+
const routes = inject('routes')
138+
const refUi = ref()
139+
const refForm = ref()
140+
const refImage = ref()
141+
const ui = useUiLayout(refUi)
142+
143+
const storage = new ThreadStorage(`img2img`, {
144+
denoise: '',
145+
positivePrompt: "",
146+
negativePrompt: '(nsfw),(explicit),(gore),(violence),(blood)',
147+
width: 1024,
148+
height: 1024,
149+
batchSize: 1,
150+
seed: '',
151+
tag: '',
152+
})
153+
const error = ref()
154+
155+
const prefs = ref(storage.getPrefs())
156+
const history = ref(storage.getHistory())
157+
const thread = ref()
158+
const threadRef = ref()
159+
160+
const validPrompt = computed(() => (request.value.model && request.value.positivePrompt
161+
&& request.value.negativePrompt && request.value.width && request.value.height
162+
&& request.value.batchSize))
163+
const refMessage = ref()
164+
const visibleFields = 'denoise,positivePrompt,negativePrompt,width,height,batchSize,seed,tag'.split(',')
165+
const request = ref(new ImageToImage(prefs.value))
166+
const activeModels = ref([])
167+
168+
function savePrefs() {
169+
storage.savePrefs(Object.assign({}, request.value, { positivePrompt:'' }))
170+
}
171+
function loadHistory() {
172+
history.value = storage.getHistory()
173+
}
174+
function saveHistory() {
175+
storage.saveHistory(history.value)
176+
}
177+
function saveThread() {
178+
if (thread.value) {
179+
storage.saveThread(thread.value)
180+
}
181+
}
182+
183+
async function send() {
184+
if (!validPrompt.value || client.loading.value) return
185+
186+
savePrefs()
187+
console.debug(`${storage.prefix}.request`, Object.assign({}, request.value))
188+
189+
error.value = null
190+
let formData = new FormData(refForm.value)
191+
const image = formData.get('image').name
192+
193+
const api = await client.apiForm(request.value, formData, { jsconfig: 'eccn' })
194+
195+
/** @type {GenerationResponse} */
196+
const r = api.response
197+
if (r) {
198+
console.debug(`${storage.prefix}.response`, r)
199+
200+
if (!r.outputs?.length) {
201+
error.value = createErrorStatus("no results were returned")
202+
} else {
203+
const id = parseInt(routes.id) || storage.createId()
204+
thread.value = thread.value ?? storage.createThread(Object.assign({
205+
id: storage.getThreadId(id),
206+
title: request.value.positivePrompt
207+
}, request.value))
208+
209+
const result = {
210+
id: storage.createId(),
211+
request: Object.assign({}, request.value),
212+
response: r
213+
}
214+
thread.value.results.push(result)
215+
saveThread()
216+
217+
if (!history.value.find(x => x.id === id)) {
218+
history.value.push({
219+
id,
220+
title: thread.value.title,
221+
icon: r.outputs[0].url
222+
})
223+
}
224+
saveHistory()
225+
226+
if (routes.id !== id) {
227+
routes.to({ id })
228+
}
229+
}
230+
231+
} else {
232+
console.error('send.error', api.error)
233+
error.value = api.error
234+
}
235+
}
236+
237+
function getThreadResults() {
238+
const ret = Array.from(thread.value?.results ?? [])
239+
ret.reverse()
240+
return ret
241+
}
242+
243+
function toArtifacts(result) {
244+
return result.response?.outputs?.map(x => ({
245+
width: result.request.width,
246+
height: result.request.height,
247+
url: x.url,
248+
filePath: x.url.substring(x.url.indexOf('/artifacts')),
249+
})) ?? []
250+
}
251+
252+
function selectRequest(req) {
253+
Object.assign(request.value, req)
254+
ui.scrollTop()
255+
}
256+
257+
function selectPrompt(prompt) {
258+
request.value.positivePrompt = prompt
259+
refMessage.value?.focus()
260+
}
261+
262+
function discardResult(result) {
263+
thread.value.results = thread.value.results.filter(x => x.id != result.id)
264+
saveThread()
265+
}
266+
267+
function toggleIcon(item) {
268+
threadRef.value.icon = item.url
269+
saveHistory()
270+
}
271+
272+
function onRouteChange() {
273+
console.log('onRouteChange', routes.id)
274+
loadHistory()
275+
request.value.positivePrompt = ''
276+
if (routes.id) {
277+
const id = parseInt(routes.id)
278+
thread.value = storage.getThread(storage.getThreadId(id))
279+
threadRef.value = history.value.find(x => x.id === parseInt(routes.id))
280+
Object.keys(storage.defaults).forEach(k =>
281+
request.value[k] = thread.value[k] ?? storage.defaults[k])
282+
} else {
283+
thread.value = null
284+
Object.keys(storage.defaults).forEach(k => request.value[k] = storage.defaults[k])
285+
}
286+
if (!request.value.model && activeModels.value) {
287+
request.value.model = activeModels.value[0]
288+
}
289+
}
290+
291+
function updated() {
292+
onRouteChange()
293+
}
294+
295+
function saveHistoryItem(item) {
296+
storage.saveHistory(history.value)
297+
if (thread.value && item.title) {
298+
thread.value.title = item.title
299+
saveThread()
300+
}
301+
}
302+
303+
function removeHistoryItem(item) {
304+
const idx = history.value.findIndex(x => x.id === item.id)
305+
if (idx >= 0) {
306+
history.value.splice(idx, 1)
307+
storage.saveHistory(history.value)
308+
storage.deleteThread(storage.getThreadId(item.id))
309+
if (routes.id == item.id) {
310+
routes.to({ id:undefined })
311+
}
312+
}
313+
}
314+
315+
watch(() => routes.id, updated)
316+
watch(() => [
317+
request.value.model,
318+
request.value.positivePrompt,
319+
request.value.negativePrompt,
320+
request.value.width,
321+
request.value.height,
322+
request.value.batchSize,
323+
request.value.seed,
324+
request.value.tag,
325+
], () => {
326+
if (!thread.value) return
327+
Object.keys(storage.defaults).forEach(k =>
328+
thread.value[k] = request.value[k] ?? storage.defaults[k])
329+
saveThread()
330+
})
331+
332+
onMounted(async () => {
333+
const api = await client.api(new ActiveMediaModels())
334+
if (api.response) {
335+
activeModels.value = api.response.results
336+
}
337+
updated()
338+
})
339+
340+
return {
341+
storage,
342+
routes,
343+
client,
344+
refForm,
345+
refImage,
346+
history,
347+
request,
348+
visibleFields,
349+
validPrompt,
350+
refMessage,
351+
activeModels,
352+
thread,
353+
threadRef,
354+
icons,
355+
send,
356+
saveHistory,
357+
saveThread,
358+
toArtifacts,
359+
toggleIcon,
360+
selectRequest,
361+
selectPrompt,
362+
discardResult,
363+
getThreadResults,
364+
saveHistoryItem,
365+
removeHistoryItem,
366+
}
7367
}
8-
}
368+
}

AiServer/wwwroot/mjs/components/ImageToText.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ref, computed, onMounted, inject, watch, nextTick } from "vue"
22
import { useClient, useFiles } from "@servicestack/vue"
33
import { createErrorStatus } from "@servicestack/client"
44
import { ImageToText, ActiveMediaModels } from "dtos"
5-
import {UiLayout, ThreadStorage, HistoryTitle, HistoryGroups, useUiLayout, icons, Img } from "../utils.mjs"
5+
import { UiLayout, ThreadStorage, HistoryTitle, HistoryGroups, useUiLayout, icons, Img } from "../utils.mjs"
66
import FileUpload from "./FileUpload.mjs"
77

88
export default {
@@ -93,7 +93,7 @@ export default {
9393
const renderKey = ref(0)
9494
const { filePathUri, getExt, extSrc, svgToDataUri } = useFiles()
9595

96-
const storage = new ThreadStorage(`img2txt`, {
96+
const storage = new ThreadStorage(`img2img`, {
9797
tag: '',
9898
})
9999
const error = ref()

0 commit comments

Comments
 (0)