Upload folder via script
Browse files- dataflow/ui/vueflow_canvas.vue +184 -0
- nodes/controller.py +33 -0
- nodes/session.py +15 -1
- nodes/text_to_image.py +28 -4
- nodes/utils.py +6 -1
- services/image/DummyImageGenerator.py +2 -0
- services/image/FalAIImageGenerator.py +5 -0
- services/image/GoogleImageGenerator.py +2 -0
- services/image/ImageGenerator.py +4 -1
- velai_app.py +37 -29
dataflow/ui/vueflow_canvas.vue
CHANGED
|
@@ -122,6 +122,23 @@
|
|
| 122 |
</span>
|
| 123 |
</button>
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
<!-- rest of header (menu) unchanged -->
|
| 126 |
<div class="vf-node-menu" @click.stop>
|
| 127 |
<button
|
|
@@ -146,18 +163,49 @@
|
|
| 146 |
</div>
|
| 147 |
</div>
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
<!-- node level error -->
|
| 150 |
<div v-if="nodeProps.data.values && nodeProps.data.values.error" class="vf-node-error">
|
| 151 |
<span>{{ nodeProps.data.values.error }}</span>
|
| 152 |
</div>
|
| 153 |
|
| 154 |
<div class="vf-node-content">
|
|
|
|
| 155 |
<q-inner-loading
|
| 156 |
v-if="nodeProps.data.executable"
|
| 157 |
:showing="nodeProps.data.processing && (nodeProps.data.progress !== 1 || nodeProps.data.progress !== 0)"
|
| 158 |
>
|
| 159 |
<q-spinner-grid size="50px" color="black" />
|
| 160 |
</q-inner-loading>
|
|
|
|
| 161 |
<template v-if="nodeProps.data.fields && nodeProps.data.fields.length">
|
| 162 |
<div
|
| 163 |
v-for="field in nodeProps.data.fields"
|
|
@@ -418,6 +466,9 @@ export default {
|
|
| 418 |
// which node menu is open
|
| 419 |
nodeMenuFor: null,
|
| 420 |
|
|
|
|
|
|
|
|
|
|
| 421 |
// copy/paste support
|
| 422 |
copiedNodeId: null,
|
| 423 |
mouseFlowPosition: {x: 0, y: 0},
|
|
@@ -498,6 +549,10 @@ export default {
|
|
| 498 |
|
| 499 |
toggleNodeMenu(id) {
|
| 500 |
this.nodeMenuFor = this.nodeMenuFor === id ? null : id;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
},
|
| 502 |
|
| 503 |
isNodeMenuOpen(id) {
|
|
@@ -509,6 +564,32 @@ export default {
|
|
| 509 |
this.emitEvent("node_menu_action", {id: id, action: action});
|
| 510 |
},
|
| 511 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
// public API for Python
|
| 513 |
|
| 514 |
setGraph(payload) {
|
|
@@ -742,6 +823,31 @@ export default {
|
|
| 742 |
});
|
| 743 |
},
|
| 744 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 745 |
resizeAndConvertToWebp(blob, maxSize = 1024) {
|
| 746 |
return new Promise((resolve, reject) => {
|
| 747 |
const img = new Image();
|
|
@@ -1055,6 +1161,7 @@ export default {
|
|
| 1055 |
this.contextMenuVisible = false;
|
| 1056 |
this.contextMenuFlowPosition = null;
|
| 1057 |
this.nodeMenuFor = null;
|
|
|
|
| 1058 |
},
|
| 1059 |
|
| 1060 |
onCreateNodeClick(entry) {
|
|
@@ -1176,6 +1283,7 @@ export default {
|
|
| 1176 |
|
| 1177 |
onPaneClick(event) {
|
| 1178 |
this.hideContextMenu();
|
|
|
|
| 1179 |
this.emitEvent("pane_click", event || {});
|
| 1180 |
},
|
| 1181 |
|
|
@@ -1524,6 +1632,7 @@ export default {
|
|
| 1524 |
flex: 1 1 auto;
|
| 1525 |
padding: 6px 8px 8px 8px;
|
| 1526 |
max-width: 260px;
|
|
|
|
| 1527 |
}
|
| 1528 |
|
| 1529 |
.vf-node-header {
|
|
@@ -1536,12 +1645,18 @@ export default {
|
|
| 1536 |
|
| 1537 |
.vf-node-title {
|
| 1538 |
font-weight: 600;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1539 |
}
|
| 1540 |
|
| 1541 |
.vf-node-header-actions {
|
| 1542 |
display: inline-flex;
|
| 1543 |
align-items: center;
|
| 1544 |
gap: 4px;
|
|
|
|
| 1545 |
}
|
| 1546 |
|
| 1547 |
.vf-node-content {
|
|
@@ -1761,6 +1876,75 @@ export default {
|
|
| 1761 |
font-size: 12px;
|
| 1762 |
opacity: 0.7;
|
| 1763 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1764 |
</style>
|
| 1765 |
|
| 1766 |
<style>
|
|
|
|
| 122 |
</span>
|
| 123 |
</button>
|
| 124 |
|
| 125 |
+
<!-- expansion menu button (settings) -->
|
| 126 |
+
<div
|
| 127 |
+
v-if="hasExpansionMenu(nodeProps)"
|
| 128 |
+
class="vf-expansion-menu-header"
|
| 129 |
+
@click.stop
|
| 130 |
+
>
|
| 131 |
+
<button
|
| 132 |
+
type="button"
|
| 133 |
+
class="vf-exec-button"
|
| 134 |
+
@click.stop="toggleExpansionMenu(nodeProps.id)"
|
| 135 |
+
>
|
| 136 |
+
<span>
|
| 137 |
+
<q-icon :name="isExpansionMenuOpen(nodeProps.id) ? 'close' : 'menu'" />
|
| 138 |
+
</span>
|
| 139 |
+
</button>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
<!-- rest of header (menu) unchanged -->
|
| 143 |
<div class="vf-node-menu" @click.stop>
|
| 144 |
<button
|
|
|
|
| 163 |
</div>
|
| 164 |
</div>
|
| 165 |
|
| 166 |
+
<!-- expansion menu dropdown (positioned relative to node body) -->
|
| 167 |
+
<div
|
| 168 |
+
v-if="hasExpansionMenu(nodeProps) && isExpansionMenuOpen(nodeProps.id)"
|
| 169 |
+
class="vf-expansion-menu-dropdown"
|
| 170 |
+
@click.stop
|
| 171 |
+
>
|
| 172 |
+
<template v-for="field in getExpansionMenuFields(nodeProps)" :key="field.name">
|
| 173 |
+
<template v-if="field.options && field.options.aspect_ratio">
|
| 174 |
+
<div class="vf-expansion-menu-item">
|
| 175 |
+
<label class="vf-expansion-menu-label">
|
| 176 |
+
{{ field.options.aspect_ratio.label || 'Aspect Ratio' }}
|
| 177 |
+
</label>
|
| 178 |
+
<q-select
|
| 179 |
+
:model-value="nodeProps.data.values[field.options.aspect_ratio.name] || '1:1'"
|
| 180 |
+
:options="field.options.aspect_ratio.options"
|
| 181 |
+
option-label="label"
|
| 182 |
+
option-value="value"
|
| 183 |
+
emit-value
|
| 184 |
+
map-options
|
| 185 |
+
dense
|
| 186 |
+
outlined
|
| 187 |
+
class="vf-expansion-menu-select"
|
| 188 |
+
@update:model-value="onAspectRatioChange(nodeProps, field, $event)"
|
| 189 |
+
/>
|
| 190 |
+
</div>
|
| 191 |
+
</template>
|
| 192 |
+
</template>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
<!-- node level error -->
|
| 196 |
<div v-if="nodeProps.data.values && nodeProps.data.values.error" class="vf-node-error">
|
| 197 |
<span>{{ nodeProps.data.values.error }}</span>
|
| 198 |
</div>
|
| 199 |
|
| 200 |
<div class="vf-node-content">
|
| 201 |
+
<!--
|
| 202 |
<q-inner-loading
|
| 203 |
v-if="nodeProps.data.executable"
|
| 204 |
:showing="nodeProps.data.processing && (nodeProps.data.progress !== 1 || nodeProps.data.progress !== 0)"
|
| 205 |
>
|
| 206 |
<q-spinner-grid size="50px" color="black" />
|
| 207 |
</q-inner-loading>
|
| 208 |
+
-->
|
| 209 |
<template v-if="nodeProps.data.fields && nodeProps.data.fields.length">
|
| 210 |
<div
|
| 211 |
v-for="field in nodeProps.data.fields"
|
|
|
|
| 466 |
// which node menu is open
|
| 467 |
nodeMenuFor: null,
|
| 468 |
|
| 469 |
+
// which expansion menu is open
|
| 470 |
+
expansionMenuFor: null,
|
| 471 |
+
|
| 472 |
// copy/paste support
|
| 473 |
copiedNodeId: null,
|
| 474 |
mouseFlowPosition: {x: 0, y: 0},
|
|
|
|
| 549 |
|
| 550 |
toggleNodeMenu(id) {
|
| 551 |
this.nodeMenuFor = this.nodeMenuFor === id ? null : id;
|
| 552 |
+
// Close expansion menu when opening node menu
|
| 553 |
+
if (this.nodeMenuFor === id) {
|
| 554 |
+
this.expansionMenuFor = null;
|
| 555 |
+
}
|
| 556 |
},
|
| 557 |
|
| 558 |
isNodeMenuOpen(id) {
|
|
|
|
| 564 |
this.emitEvent("node_menu_action", {id: id, action: action});
|
| 565 |
},
|
| 566 |
|
| 567 |
+
toggleExpansionMenu(id) {
|
| 568 |
+
this.expansionMenuFor = this.expansionMenuFor === id ? null : id;
|
| 569 |
+
// Close node menu when opening expansion menu
|
| 570 |
+
if (this.expansionMenuFor === id) {
|
| 571 |
+
this.nodeMenuFor = null;
|
| 572 |
+
}
|
| 573 |
+
},
|
| 574 |
+
|
| 575 |
+
isExpansionMenuOpen(id) {
|
| 576 |
+
return this.expansionMenuFor === id;
|
| 577 |
+
},
|
| 578 |
+
|
| 579 |
+
hasExpansionMenu(nodeProps) {
|
| 580 |
+
if (!nodeProps || !nodeProps.data || !nodeProps.data.fields) {
|
| 581 |
+
return false;
|
| 582 |
+
}
|
| 583 |
+
return nodeProps.data.fields.some(field => field.kind === 'expansion_menu');
|
| 584 |
+
},
|
| 585 |
+
|
| 586 |
+
getExpansionMenuFields(nodeProps) {
|
| 587 |
+
if (!nodeProps || !nodeProps.data || !nodeProps.data.fields) {
|
| 588 |
+
return [];
|
| 589 |
+
}
|
| 590 |
+
return nodeProps.data.fields.filter(field => field.kind === 'expansion_menu');
|
| 591 |
+
},
|
| 592 |
+
|
| 593 |
// public API for Python
|
| 594 |
|
| 595 |
setGraph(payload) {
|
|
|
|
| 823 |
});
|
| 824 |
},
|
| 825 |
|
| 826 |
+
onAspectRatioChange(nodeProps, field, value) {
|
| 827 |
+
if (!nodeProps || !nodeProps.data || !field) return;
|
| 828 |
+
|
| 829 |
+
// Update the value in the node data
|
| 830 |
+
if (!nodeProps.data.values) {
|
| 831 |
+
nodeProps.data.values = {};
|
| 832 |
+
}
|
| 833 |
+
nodeProps.data.values.aspect_ratio = value;
|
| 834 |
+
|
| 835 |
+
// Update the node values in Vue Flow
|
| 836 |
+
this.updateNodeValues({
|
| 837 |
+
id: nodeProps.id,
|
| 838 |
+
values: {aspect_ratio: value}
|
| 839 |
+
});
|
| 840 |
+
|
| 841 |
+
// Emit the change event
|
| 842 |
+
this.emitEvent("node_field_changed", {
|
| 843 |
+
id: nodeProps.id,
|
| 844 |
+
field: "aspect_ratio",
|
| 845 |
+
fieldKind: "expansion_menu",
|
| 846 |
+
value: value
|
| 847 |
+
});
|
| 848 |
+
},
|
| 849 |
+
|
| 850 |
+
|
| 851 |
resizeAndConvertToWebp(blob, maxSize = 1024) {
|
| 852 |
return new Promise((resolve, reject) => {
|
| 853 |
const img = new Image();
|
|
|
|
| 1161 |
this.contextMenuVisible = false;
|
| 1162 |
this.contextMenuFlowPosition = null;
|
| 1163 |
this.nodeMenuFor = null;
|
| 1164 |
+
this.expansionMenuFor = null;
|
| 1165 |
},
|
| 1166 |
|
| 1167 |
onCreateNodeClick(entry) {
|
|
|
|
| 1283 |
|
| 1284 |
onPaneClick(event) {
|
| 1285 |
this.hideContextMenu();
|
| 1286 |
+
this.expansionMenuFor = null;
|
| 1287 |
this.emitEvent("pane_click", event || {});
|
| 1288 |
},
|
| 1289 |
|
|
|
|
| 1632 |
flex: 1 1 auto;
|
| 1633 |
padding: 6px 8px 8px 8px;
|
| 1634 |
max-width: 260px;
|
| 1635 |
+
position: relative;
|
| 1636 |
}
|
| 1637 |
|
| 1638 |
.vf-node-header {
|
|
|
|
| 1645 |
|
| 1646 |
.vf-node-title {
|
| 1647 |
font-weight: 600;
|
| 1648 |
+
flex: 1 1 auto;
|
| 1649 |
+
min-width: 0;
|
| 1650 |
+
overflow: hidden;
|
| 1651 |
+
text-overflow: ellipsis;
|
| 1652 |
+
white-space: nowrap;
|
| 1653 |
}
|
| 1654 |
|
| 1655 |
.vf-node-header-actions {
|
| 1656 |
display: inline-flex;
|
| 1657 |
align-items: center;
|
| 1658 |
gap: 4px;
|
| 1659 |
+
flex-shrink: 0;
|
| 1660 |
}
|
| 1661 |
|
| 1662 |
.vf-node-content {
|
|
|
|
| 1876 |
font-size: 12px;
|
| 1877 |
opacity: 0.7;
|
| 1878 |
}
|
| 1879 |
+
|
| 1880 |
+
/* expansion menu styling - header button with dropdown */
|
| 1881 |
+
.vf-expansion-menu-header {
|
| 1882 |
+
position: relative;
|
| 1883 |
+
}
|
| 1884 |
+
|
| 1885 |
+
.vf-expansion-menu-dropdown {
|
| 1886 |
+
position: absolute;
|
| 1887 |
+
left: 0;
|
| 1888 |
+
right: 0;
|
| 1889 |
+
top: 100%;
|
| 1890 |
+
margin-top: 4px;
|
| 1891 |
+
border-radius: 4px;
|
| 1892 |
+
background: #ffffff;
|
| 1893 |
+
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.15);
|
| 1894 |
+
padding: 8px;
|
| 1895 |
+
z-index: 30;
|
| 1896 |
+
width: 100%;
|
| 1897 |
+
box-sizing: border-box;
|
| 1898 |
+
}
|
| 1899 |
+
|
| 1900 |
+
.vf-expansion-menu-item {
|
| 1901 |
+
display: flex;
|
| 1902 |
+
flex-direction: column;
|
| 1903 |
+
gap: 4px;
|
| 1904 |
+
}
|
| 1905 |
+
|
| 1906 |
+
.vf-expansion-menu-label {
|
| 1907 |
+
font-size: 11px;
|
| 1908 |
+
color: #6b7280;
|
| 1909 |
+
font-weight: 500;
|
| 1910 |
+
margin-bottom: 2px;
|
| 1911 |
+
}
|
| 1912 |
+
|
| 1913 |
+
.vf-expansion-menu-select {
|
| 1914 |
+
font-size: 11px;
|
| 1915 |
+
cursor: pointer;
|
| 1916 |
+
width: 100%;
|
| 1917 |
+
}
|
| 1918 |
+
|
| 1919 |
+
.vf-expansion-menu-select :deep(.q-field) {
|
| 1920 |
+
cursor: pointer;
|
| 1921 |
+
}
|
| 1922 |
+
|
| 1923 |
+
.vf-expansion-menu-select :deep(.q-field__control) {
|
| 1924 |
+
min-height: 24px;
|
| 1925 |
+
height: 24px;
|
| 1926 |
+
cursor: pointer;
|
| 1927 |
+
}
|
| 1928 |
+
|
| 1929 |
+
.vf-expansion-menu-select :deep(.q-field__native) {
|
| 1930 |
+
min-height: 24px;
|
| 1931 |
+
padding: 2px 8px;
|
| 1932 |
+
font-size: 11px;
|
| 1933 |
+
cursor: pointer;
|
| 1934 |
+
}
|
| 1935 |
+
|
| 1936 |
+
.vf-expansion-menu-select :deep(.q-field__inner) {
|
| 1937 |
+
cursor: pointer;
|
| 1938 |
+
}
|
| 1939 |
+
|
| 1940 |
+
.vf-expansion-menu-select :deep(.q-field__marginal) {
|
| 1941 |
+
cursor: pointer;
|
| 1942 |
+
}
|
| 1943 |
+
|
| 1944 |
+
.vf-expansion-menu-select :deep(.q-field__bottom) {
|
| 1945 |
+
cursor: pointer;
|
| 1946 |
+
}
|
| 1947 |
+
|
| 1948 |
</style>
|
| 1949 |
|
| 1950 |
<style>
|
nodes/controller.py
CHANGED
|
@@ -181,6 +181,39 @@ class GraphController:
|
|
| 181 |
elif isinstance(node, TextToImageNode) and field == "image":
|
| 182 |
node.image_src = "" if value is None else str(value)
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
def _on_edges_delete(self, edges: list[dict[str, Any]]) -> None:
|
| 185 |
"""Remove matching connections from the DataGraph when edges are deleted in Vue."""
|
| 186 |
if not edges:
|
|
|
|
| 181 |
elif isinstance(node, TextToImageNode) and field == "image":
|
| 182 |
node.image_src = "" if value is None else str(value)
|
| 183 |
|
| 184 |
+
elif isinstance(node, TextToImageNode) and field == "aspect_ratio":
|
| 185 |
+
# Parse aspect_ratio value from the dropdown selection. Thanks Flo!
|
| 186 |
+
aspect_ratio_value = "1:1" # default
|
| 187 |
+
|
| 188 |
+
print(f"[DEBUG controller] Aspect ratio set to {value}, type={type(value)}")
|
| 189 |
+
|
| 190 |
+
if value is not None and isinstance(value, str):
|
| 191 |
+
aspect_ratio_value = value.strip()
|
| 192 |
+
else:
|
| 193 |
+
print(f"[DEBUG controller] aspect_ratio value is None, using default 1:1")
|
| 194 |
+
|
| 195 |
+
# Validate aspect ratio format (should be "W:H")
|
| 196 |
+
# Allow common formats: "1:1", "16:9", "9:16", etc. we could add more later if needed
|
| 197 |
+
if aspect_ratio_value and ":" in aspect_ratio_value:
|
| 198 |
+
parts = aspect_ratio_value.split(":")
|
| 199 |
+
if len(parts) == 2:
|
| 200 |
+
try:
|
| 201 |
+
# Validate that both parts are numeric
|
| 202 |
+
float(parts[0])
|
| 203 |
+
float(parts[1])
|
| 204 |
+
old_ratio = node.aspect_ratio
|
| 205 |
+
node.aspect_ratio = aspect_ratio_value
|
| 206 |
+
|
| 207 |
+
# Clear cached image since aspect ratio changed
|
| 208 |
+
node.image_src = None
|
| 209 |
+
node.decoded_image = None
|
| 210 |
+
|
| 211 |
+
except (ValueError, TypeError):
|
| 212 |
+
# Invalid format, use default
|
| 213 |
+
node.aspect_ratio = "1:1"
|
| 214 |
+
print(f"[DEBUG Controller] Invalid numeric format, using default: {node.aspect_ratio}")
|
| 215 |
+
|
| 216 |
+
|
| 217 |
def _on_edges_delete(self, edges: list[dict[str, Any]]) -> None:
|
| 218 |
"""Remove matching connections from the DataGraph when edges are deleted in Vue."""
|
| 219 |
if not edges:
|
nodes/session.py
CHANGED
|
@@ -549,8 +549,22 @@ class GraphSession:
|
|
| 549 |
return
|
| 550 |
|
| 551 |
if isinstance(node, TextToImageNode):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
buf = io.BytesIO()
|
| 553 |
-
|
| 554 |
buf.seek(0)
|
| 555 |
|
| 556 |
png_bytes = buf.getvalue()
|
|
|
|
| 549 |
return
|
| 550 |
|
| 551 |
if isinstance(node, TextToImageNode):
|
| 552 |
+
# Try to get the image from decoded_image first, then from image_src
|
| 553 |
+
img = node.decoded_image
|
| 554 |
+
if img is None and node.image_src:
|
| 555 |
+
try:
|
| 556 |
+
from . import utils
|
| 557 |
+
img = utils.decode_image(node.image_src)
|
| 558 |
+
except Exception as e:
|
| 559 |
+
ui.notify(f"Failed to decode image: {e}", type="negative")
|
| 560 |
+
return
|
| 561 |
+
|
| 562 |
+
if img is None:
|
| 563 |
+
ui.notify("No image available to download", type="warning")
|
| 564 |
+
return
|
| 565 |
+
|
| 566 |
buf = io.BytesIO()
|
| 567 |
+
img.save(buf, format="PNG")
|
| 568 |
buf.seek(0)
|
| 569 |
|
| 570 |
png_bytes = buf.getvalue()
|
nodes/text_to_image.py
CHANGED
|
@@ -51,6 +51,7 @@ class TextToImageNode(NodeInstance):
|
|
| 51 |
error: str | None = None
|
| 52 |
progress_value: float = 0.0
|
| 53 |
progress_message: str | None = None
|
|
|
|
| 54 |
|
| 55 |
async def process(self) -> None:
|
| 56 |
# reset error message (but is not directly updated)
|
|
@@ -59,12 +60,14 @@ class TextToImageNode(NodeInstance):
|
|
| 59 |
text_input = self.inputs["text"]
|
| 60 |
image_output = self.outputs["image"]
|
| 61 |
|
| 62 |
-
print(f"\n[DEBUG
|
|
|
|
| 63 |
print(f"[DEBUG] Text input value: {text_input.value is not None}")
|
| 64 |
print(f"[DEBUG] Text input state: {text_input.state}")
|
|
|
|
| 65 |
|
| 66 |
# Collect images from ordered input ports (image1, image2, image3, ...)
|
| 67 |
-
# Only include images that are actually connected
|
| 68 |
images: list[Image.Image] = []
|
| 69 |
for i in range(1, NUM_IMAGE_INPUTS + 1):
|
| 70 |
image_port_name = f"image{i}"
|
|
@@ -134,7 +137,7 @@ class TextToImageNode(NodeInstance):
|
|
| 134 |
loop = asyncio.get_running_loop()
|
| 135 |
result = await loop.run_in_executor(
|
| 136 |
None,
|
| 137 |
-
partial(image_service.generate, prompt, images=images_list, progress=on_progress)
|
| 138 |
)
|
| 139 |
|
| 140 |
url = utils.encode_image(result.image)
|
|
@@ -152,6 +155,7 @@ class TextToImageNode(NodeInstance):
|
|
| 152 |
self.error = None
|
| 153 |
self.progress_value = 0.0
|
| 154 |
self.progress_message = None
|
|
|
|
| 155 |
|
| 156 |
# clear output port value and mark it dirty
|
| 157 |
out = self.outputs.get("image") if self.outputs is not None else None
|
|
@@ -177,7 +181,7 @@ class TextToImageNodeRenderable(VueNodeRenderable[TextToImageNode]):
|
|
| 177 |
num_inputs = len(input_ports)
|
| 178 |
if num_inputs > 0:
|
| 179 |
# Start the first input at 30% of the node height, end at 85%, evenly distributed
|
| 180 |
-
# It's eyeballed, so
|
| 181 |
start_percent = 30
|
| 182 |
end_percent = 85
|
| 183 |
if num_inputs == 1:
|
|
@@ -224,6 +228,26 @@ class TextToImageNodeRenderable(VueNodeRenderable[TextToImageNode]):
|
|
| 224 |
if node.error:
|
| 225 |
data.values["error"] = node.error
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
data.executable = True
|
| 228 |
data.processing = False
|
| 229 |
|
|
|
|
| 51 |
error: str | None = None
|
| 52 |
progress_value: float = 0.0
|
| 53 |
progress_message: str | None = None
|
| 54 |
+
aspect_ratio: str = "1:1" # Default aspect ratio
|
| 55 |
|
| 56 |
async def process(self) -> None:
|
| 57 |
# reset error message (but is not directly updated)
|
|
|
|
| 60 |
text_input = self.inputs["text"]
|
| 61 |
image_output = self.outputs["image"]
|
| 62 |
|
| 63 |
+
print(f"\n[DEBUG] ===== GENERATING IMAGE =====")
|
| 64 |
+
print(f"[DEBUG] Node ID: {self.node_id}")
|
| 65 |
print(f"[DEBUG] Text input value: {text_input.value is not None}")
|
| 66 |
print(f"[DEBUG] Text input state: {text_input.state}")
|
| 67 |
+
print(f"[DEBUG] Image aspect ratio selected: {self.aspect_ratio}")
|
| 68 |
|
| 69 |
# Collect images from ordered input ports (image1, image2, image3, ...)
|
| 70 |
+
# But Only include images that are actually connected
|
| 71 |
images: list[Image.Image] = []
|
| 72 |
for i in range(1, NUM_IMAGE_INPUTS + 1):
|
| 73 |
image_port_name = f"image{i}"
|
|
|
|
| 137 |
loop = asyncio.get_running_loop()
|
| 138 |
result = await loop.run_in_executor(
|
| 139 |
None,
|
| 140 |
+
partial(image_service.generate, prompt, images=images_list, progress=on_progress, aspect_ratio=self.aspect_ratio)
|
| 141 |
)
|
| 142 |
|
| 143 |
url = utils.encode_image(result.image)
|
|
|
|
| 155 |
self.error = None
|
| 156 |
self.progress_value = 0.0
|
| 157 |
self.progress_message = None
|
| 158 |
+
# Note: aspect_ratio is not reset, it persists
|
| 159 |
|
| 160 |
# clear output port value and mark it dirty
|
| 161 |
out = self.outputs.get("image") if self.outputs is not None else None
|
|
|
|
| 181 |
num_inputs = len(input_ports)
|
| 182 |
if num_inputs > 0:
|
| 183 |
# Start the first input at 30% of the node height, end at 85%, evenly distributed
|
| 184 |
+
# It's eyeballed, so pretty ugly at this point
|
| 185 |
start_percent = 30
|
| 186 |
end_percent = 85
|
| 187 |
if num_inputs == 1:
|
|
|
|
| 228 |
if node.error:
|
| 229 |
data.values["error"] = node.error
|
| 230 |
|
| 231 |
+
# Add expansion menu with aspect ratio dropdown. Thanks Flo!
|
| 232 |
+
data.fields.append(
|
| 233 |
+
{
|
| 234 |
+
#"name": "Settings",
|
| 235 |
+
"kind": "expansion_menu",
|
| 236 |
+
"options": {
|
| 237 |
+
"aspect_ratio": {
|
| 238 |
+
"name": "aspect_ratio",
|
| 239 |
+
"label": "Aspect Ratio",
|
| 240 |
+
"options": [
|
| 241 |
+
{"label": "1:1 (Square)", "value": "1:1"},
|
| 242 |
+
{"label": "16:9 (Landscape)", "value": "16:9"},
|
| 243 |
+
{"label": "9:16 (Portrait)", "value": "9:16"}
|
| 244 |
+
],
|
| 245 |
+
}
|
| 246 |
+
},
|
| 247 |
+
}
|
| 248 |
+
)
|
| 249 |
+
data.values["aspect_ratio"] = node.aspect_ratio
|
| 250 |
+
|
| 251 |
data.executable = True
|
| 252 |
data.processing = False
|
| 253 |
|
nodes/utils.py
CHANGED
|
@@ -11,12 +11,17 @@ def encode_image(img: Image.Image, image_format: str | None = None) -> str:
|
|
| 11 |
If not provided, fall back to WEBP.
|
| 12 |
"""
|
| 13 |
|
|
|
|
|
|
|
| 14 |
if image_format is None:
|
| 15 |
image_format = "WEBP"
|
| 16 |
|
|
|
|
|
|
|
|
|
|
| 17 |
buffer = io.BytesIO()
|
| 18 |
try:
|
| 19 |
-
img.save(buffer, format=image_format)
|
| 20 |
except Exception as e:
|
| 21 |
raise ValueError(f"Failed to encode image as {image_format}: {e}")
|
| 22 |
|
|
|
|
| 11 |
If not provided, fall back to WEBP.
|
| 12 |
"""
|
| 13 |
|
| 14 |
+
params = {}
|
| 15 |
+
|
| 16 |
if image_format is None:
|
| 17 |
image_format = "WEBP"
|
| 18 |
|
| 19 |
+
if image_format == "WEBP":
|
| 20 |
+
params["quality"] = 90
|
| 21 |
+
|
| 22 |
buffer = io.BytesIO()
|
| 23 |
try:
|
| 24 |
+
img.save(buffer, format=image_format, **params)
|
| 25 |
except Exception as e:
|
| 26 |
raise ValueError(f"Failed to encode image as {image_format}: {e}")
|
| 27 |
|
services/image/DummyImageGenerator.py
CHANGED
|
@@ -33,6 +33,8 @@ class DummyImageGenerator(ImageGenerator):
|
|
| 33 |
images: Sequence[Image.Image] | None = None,
|
| 34 |
*,
|
| 35 |
progress: ProgressCallback | None = None,
|
|
|
|
|
|
|
| 36 |
) -> ImageGenerationResult:
|
| 37 |
call_progress(progress, 0.1, "Starting dummy image request")
|
| 38 |
|
|
|
|
| 33 |
images: Sequence[Image.Image] | None = None,
|
| 34 |
*,
|
| 35 |
progress: ProgressCallback | None = None,
|
| 36 |
+
aspect_ratio: str | None = None,
|
| 37 |
+
**kwargs: Any,
|
| 38 |
) -> ImageGenerationResult:
|
| 39 |
call_progress(progress, 0.1, "Starting dummy image request")
|
| 40 |
|
services/image/FalAIImageGenerator.py
CHANGED
|
@@ -108,8 +108,13 @@ class FalAIImageGenerator(ImageGenerator, ABC):
|
|
| 108 |
images: Sequence[Image.Image] | None = None,
|
| 109 |
*,
|
| 110 |
progress: ProgressCallback | None = None,
|
|
|
|
| 111 |
**kwargs: Any,
|
| 112 |
) -> ImageGenerationResult:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
model = self._select_model(prompt=prompt, images=images, **kwargs)
|
| 114 |
|
| 115 |
call_progress(progress, 0.1, "Encoding inputs for fal.ai image model")
|
|
|
|
| 108 |
images: Sequence[Image.Image] | None = None,
|
| 109 |
*,
|
| 110 |
progress: ProgressCallback | None = None,
|
| 111 |
+
aspect_ratio: str | None = None,
|
| 112 |
**kwargs: Any,
|
| 113 |
) -> ImageGenerationResult:
|
| 114 |
+
# Include aspect_ratio in kwargs if provided
|
| 115 |
+
if aspect_ratio is not None:
|
| 116 |
+
kwargs = {**kwargs, "aspect_ratio": aspect_ratio}
|
| 117 |
+
|
| 118 |
model = self._select_model(prompt=prompt, images=images, **kwargs)
|
| 119 |
|
| 120 |
call_progress(progress, 0.1, "Encoding inputs for fal.ai image model")
|
services/image/GoogleImageGenerator.py
CHANGED
|
@@ -47,6 +47,8 @@ class GoogleImageGenerator(ImageGenerator):
|
|
| 47 |
images: Sequence[Image.Image] | None = None,
|
| 48 |
*,
|
| 49 |
progress: ProgressCallback | None = None,
|
|
|
|
|
|
|
| 50 |
) -> ImageGenerationResult:
|
| 51 |
from google.genai import types # type: ignore[import-not-found]
|
| 52 |
|
|
|
|
| 47 |
images: Sequence[Image.Image] | None = None,
|
| 48 |
*,
|
| 49 |
progress: ProgressCallback | None = None,
|
| 50 |
+
aspect_ratio: str | None = None,
|
| 51 |
+
**kwargs: Any,
|
| 52 |
) -> ImageGenerationResult:
|
| 53 |
from google.genai import types # type: ignore[import-not-found]
|
| 54 |
|
services/image/ImageGenerator.py
CHANGED
|
@@ -20,7 +20,10 @@ class ImageGenerator(GenerationService, ABC):
|
|
| 20 |
images: Sequence[Image.Image] | None = None,
|
| 21 |
*,
|
| 22 |
progress: ProgressCallback | None = None,
|
|
|
|
| 23 |
**kwargs: Any,
|
| 24 |
) -> ImageGenerationResult:
|
| 25 |
-
|
|
|
|
|
|
|
| 26 |
raise NotImplementedError
|
|
|
|
| 20 |
images: Sequence[Image.Image] | None = None,
|
| 21 |
*,
|
| 22 |
progress: ProgressCallback | None = None,
|
| 23 |
+
aspect_ratio: str | None = None,
|
| 24 |
**kwargs: Any,
|
| 25 |
) -> ImageGenerationResult:
|
| 26 |
+
|
| 27 |
+
"""Generate images from a prompt and optional images. """
|
| 28 |
+
|
| 29 |
raise NotImplementedError
|
velai_app.py
CHANGED
|
@@ -22,19 +22,25 @@ class VelaiApp:
|
|
| 22 |
with ui.column().classes("w-full h-screen no-wrap"):
|
| 23 |
# header
|
| 24 |
with ui.row().classes("w-full items-center justify-between px-4 py-2 bg-grey-2"):
|
| 25 |
-
# ui.label("velai").classes("text-lg font-bold")
|
| 26 |
ui.image("assets/logo.png").classes("w-16").style("margin-right: 10px;")
|
| 27 |
with ui.row().classes("items-center gap-2"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
with ui.button("Info", on_click=lambda: dialog.open()).props("color='black'"):
|
| 30 |
-
#show a popup with the information about the app
|
| 31 |
-
with ui.dialog() as dialog:
|
| 32 |
-
with ui.card():
|
| 33 |
-
ui.button(icon='close', on_click=dialog.close).props("color='black'").classes('absolute top-2 right-2')
|
| 34 |
-
ui.label("Welcome to VELAI!").classes("text-lg font-bold")
|
| 35 |
-
ui.label("This is a node-based environment for generating images.\nIt uses the Gemini 2.5 Flash Preview (Nano Banana) and Reve models.")
|
| 36 |
-
ui.label("As it's a work in progress, there might be some bugs and issues. Please report them to us :)")
|
| 37 |
-
|
| 38 |
with ui.dropdown_button("Add Node", auto_close=True).props("color='black'"):
|
| 39 |
kinds = session.creatable_node_types
|
| 40 |
for k in kinds:
|
|
@@ -89,32 +95,34 @@ class VelaiApp:
|
|
| 89 |
session = self.session
|
| 90 |
canvas = self.canvas
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
|
| 107 |
-
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
|
| 115 |
-
|
| 116 |
|
| 117 |
-
|
| 118 |
|
| 119 |
def clear_graph_action(self) -> None:
|
| 120 |
if self.canvas is None:
|
|
|
|
| 22 |
with ui.column().classes("w-full h-screen no-wrap"):
|
| 23 |
# header
|
| 24 |
with ui.row().classes("w-full items-center justify-between px-4 py-2 bg-grey-2"):
|
|
|
|
| 25 |
ui.image("assets/logo.png").classes("w-16").style("margin-right: 10px;")
|
| 26 |
with ui.row().classes("items-center gap-2"):
|
| 27 |
+
# create Info dialog once in the page context
|
| 28 |
+
with ui.dialog() as info_dialog:
|
| 29 |
+
with ui.card():
|
| 30 |
+
ui.button(icon="close", on_click=info_dialog.close).props("color='black'").classes(
|
| 31 |
+
"absolute top-2 right-2")
|
| 32 |
+
ui.label("Welcome to VELAI!").classes("text-lg font-bold")
|
| 33 |
+
ui.label(
|
| 34 |
+
"This is a node-based environment for generating images.\n"
|
| 35 |
+
"It uses the Gemini 2.5 Flash Preview (Nano Banana) and Reve models."
|
| 36 |
+
)
|
| 37 |
+
ui.label(
|
| 38 |
+
"As it's a work in progress, there might be some bugs and issues. "
|
| 39 |
+
"Please report them to us :)"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
ui.button("Info", on_click=info_dialog.open).props("color='black'")
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
with ui.dropdown_button("Add Node", auto_close=True).props("color='black'"):
|
| 45 |
kinds = session.creatable_node_types
|
| 46 |
for k in kinds:
|
|
|
|
| 95 |
session = self.session
|
| 96 |
canvas = self.canvas
|
| 97 |
|
| 98 |
+
# create the dialog in the main page content context
|
| 99 |
+
with ui.context.client.content:
|
| 100 |
+
with ui.dialog() as dialog:
|
| 101 |
+
with ui.card():
|
| 102 |
+
ui.label("Upload graph JSON")
|
| 103 |
|
| 104 |
+
async def handle_upload(e: events.UploadEventArguments) -> None:
|
| 105 |
+
data = await e.file.read()
|
| 106 |
+
content = data.decode("utf-8")
|
| 107 |
|
| 108 |
+
session.load_from_json(content)
|
| 109 |
+
session.save_to_storage()
|
| 110 |
|
| 111 |
+
nodes = session.initial_vue_nodes()
|
| 112 |
+
edges = session.initial_vue_edges()
|
| 113 |
+
canvas.set_graph(nodes, edges)
|
| 114 |
|
| 115 |
+
dialog.close()
|
| 116 |
|
| 117 |
+
ui.upload(
|
| 118 |
+
label="Choose JSON file",
|
| 119 |
+
on_upload=handle_upload,
|
| 120 |
+
max_files=1,
|
| 121 |
+
).props("accept=.json auto-upload")
|
| 122 |
|
| 123 |
+
ui.button("Cancel", on_click=dialog.close).props("color='black'")
|
| 124 |
|
| 125 |
+
dialog.open()
|
| 126 |
|
| 127 |
def clear_graph_action(self) -> None:
|
| 128 |
if self.canvas is None:
|