mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-05 12:44:23 +00:00
Compare commits
18 Commits
fix/dropdo
...
pysssss/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e079b933 | ||
|
|
dc3bdb9dc0 | ||
|
|
be3154abcd | ||
|
|
2f1d2e437b | ||
|
|
cac65ebb96 | ||
|
|
20a50817af | ||
|
|
d986dcab3a | ||
|
|
ab4a00d6dc | ||
|
|
4492e3f2d6 | ||
|
|
70b8e660bf | ||
|
|
fb8684e218 | ||
|
|
d8ac560229 | ||
|
|
e298998732 | ||
|
|
ff3b43ac39 | ||
|
|
fd4782f37d | ||
|
|
206e07225a | ||
|
|
6342a4378e | ||
|
|
55bd9410eb |
156
browser_tests/assets/execution/image_compare_only.json
Normal file
156
browser_tests/assets/execution/image_compare_only.json
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 10,
|
||||||
|
"last_link_id": 9,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": [413, 389],
|
||||||
|
"size": [425, 180],
|
||||||
|
"flags": {},
|
||||||
|
"order": 3,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "clip", "type": "CLIP", "link": 5 }],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": [6],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": ["text, watermark"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": [415, 186],
|
||||||
|
"size": [422, 164],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "clip", "type": "CLIP", "link": 3 }],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": [4],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": ["beautiful scenery"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "EmptyLatentImage",
|
||||||
|
"pos": [473, 609],
|
||||||
|
"size": [315, 106],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"outputs": [
|
||||||
|
{ "name": "LATENT", "type": "LATENT", "links": [2], "slot_index": 0 }
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [512, 512, 1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": [863, 186],
|
||||||
|
"size": [315, 262],
|
||||||
|
"flags": {},
|
||||||
|
"order": 4,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "model", "type": "MODEL", "link": 1 },
|
||||||
|
{ "name": "positive", "type": "CONDITIONING", "link": 4 },
|
||||||
|
{ "name": "negative", "type": "CONDITIONING", "link": 6 },
|
||||||
|
{ "name": "latent_image", "type": "LATENT", "link": 2 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0 }
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [156680208700286, true, 20, 8, "euler", "normal", 1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "VAEDecode",
|
||||||
|
"pos": [1209, 188],
|
||||||
|
"size": [210, 46],
|
||||||
|
"flags": {},
|
||||||
|
"order": 5,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "samples", "type": "LATENT", "link": 7 },
|
||||||
|
{ "name": "vae", "type": "VAE", "link": 8 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": [9],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"type": "ImageCompare",
|
||||||
|
"pos": [1451, 189],
|
||||||
|
"size": [400, 350],
|
||||||
|
"flags": {},
|
||||||
|
"order": 6,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "a_images", "type": "IMAGE", "link": 9 },
|
||||||
|
{ "name": "b_images", "type": "IMAGE", "link": null }
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "ImageCompare"
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "CheckpointLoaderSimple",
|
||||||
|
"pos": [26, 474],
|
||||||
|
"size": [315, 98],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"outputs": [
|
||||||
|
{ "name": "MODEL", "type": "MODEL", "links": [1], "slot_index": 0 },
|
||||||
|
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
|
||||||
|
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[1, 4, 0, 3, 0, "MODEL"],
|
||||||
|
[2, 5, 0, 3, 3, "LATENT"],
|
||||||
|
[3, 4, 1, 6, 0, "CLIP"],
|
||||||
|
[4, 6, 0, 3, 1, "CONDITIONING"],
|
||||||
|
[5, 4, 1, 7, 0, "CLIP"],
|
||||||
|
[6, 7, 0, 3, 2, "CONDITIONING"],
|
||||||
|
[7, 3, 0, 8, 0, "LATENT"],
|
||||||
|
[8, 4, 2, 8, 1, "VAE"],
|
||||||
|
[9, 8, 0, 10, 0, "IMAGE"]
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"offset": [0, 0],
|
||||||
|
"scale": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
168
browser_tests/assets/execution/image_compare_save.json
Normal file
168
browser_tests/assets/execution/image_compare_save.json
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 10,
|
||||||
|
"last_link_id": 10,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": [413, 389],
|
||||||
|
"size": [425, 180],
|
||||||
|
"flags": {},
|
||||||
|
"order": 3,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "clip", "type": "CLIP", "link": 5 }],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": [6],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": ["text, watermark"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": [415, 186],
|
||||||
|
"size": [422, 164],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "clip", "type": "CLIP", "link": 3 }],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": [4],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": ["beautiful scenery"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "EmptyLatentImage",
|
||||||
|
"pos": [473, 609],
|
||||||
|
"size": [315, 106],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"outputs": [
|
||||||
|
{ "name": "LATENT", "type": "LATENT", "links": [2], "slot_index": 0 }
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [512, 512, 1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": [863, 186],
|
||||||
|
"size": [315, 262],
|
||||||
|
"flags": {},
|
||||||
|
"order": 4,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "model", "type": "MODEL", "link": 1 },
|
||||||
|
{ "name": "positive", "type": "CONDITIONING", "link": 4 },
|
||||||
|
{ "name": "negative", "type": "CONDITIONING", "link": 6 },
|
||||||
|
{ "name": "latent_image", "type": "LATENT", "link": 2 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{ "name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0 }
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [156680208700286, true, 20, 8, "euler", "normal", 1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "VAEDecode",
|
||||||
|
"pos": [1209, 188],
|
||||||
|
"size": [210, 46],
|
||||||
|
"flags": {},
|
||||||
|
"order": 5,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "samples", "type": "LATENT", "link": 7 },
|
||||||
|
{ "name": "vae", "type": "VAE", "link": 8 }
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": [9, 10],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"type": "SaveImage",
|
||||||
|
"pos": [1451, 189],
|
||||||
|
"size": [210, 26],
|
||||||
|
"flags": {},
|
||||||
|
"order": 6,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "images", "type": "IMAGE", "link": 9 }],
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"type": "ImageCompare",
|
||||||
|
"pos": [1451, 350],
|
||||||
|
"size": [400, 350],
|
||||||
|
"flags": {},
|
||||||
|
"order": 7,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "a_images", "type": "IMAGE", "link": 10 },
|
||||||
|
{ "name": "b_images", "type": "IMAGE", "link": null }
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "ImageCompare"
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "CheckpointLoaderSimple",
|
||||||
|
"pos": [26, 474],
|
||||||
|
"size": [315, 98],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"outputs": [
|
||||||
|
{ "name": "MODEL", "type": "MODEL", "links": [1], "slot_index": 0 },
|
||||||
|
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
|
||||||
|
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[1, 4, 0, 3, 0, "MODEL"],
|
||||||
|
[2, 5, 0, 3, 3, "LATENT"],
|
||||||
|
[3, 4, 1, 6, 0, "CLIP"],
|
||||||
|
[4, 6, 0, 3, 1, "CONDITIONING"],
|
||||||
|
[5, 4, 1, 7, 0, "CLIP"],
|
||||||
|
[6, 7, 0, 3, 2, "CONDITIONING"],
|
||||||
|
[7, 3, 0, 8, 0, "LATENT"],
|
||||||
|
[8, 4, 2, 8, 1, "VAE"],
|
||||||
|
[9, 8, 0, 9, 0, "IMAGE"],
|
||||||
|
[10, 8, 0, 10, 0, "IMAGE"]
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"offset": [0, 0],
|
||||||
|
"scale": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
127
browser_tests/fixtures/components/OutputHistory.ts
Normal file
127
browser_tests/fixtures/components/OutputHistory.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
|
import { TestIds } from '@e2e/fixtures/selectors'
|
||||||
|
|
||||||
|
const ids = TestIds.outputHistory
|
||||||
|
|
||||||
|
export class OutputHistoryComponent {
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
get outputs(): Locator {
|
||||||
|
return this.page.getByTestId(ids.outputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
get welcome(): Locator {
|
||||||
|
return this.page.getByTestId(ids.welcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
get outputInfo(): Locator {
|
||||||
|
return this.page.getByTestId(ids.outputInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeQueue(): Locator {
|
||||||
|
return this.page.getByTestId(ids.activeQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
get queueBadge(): Locator {
|
||||||
|
return this.page.getByTestId(ids.queueBadge)
|
||||||
|
}
|
||||||
|
|
||||||
|
get inProgressItems(): Locator {
|
||||||
|
return this.page.getByTestId(ids.inProgressItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
get historyItems(): Locator {
|
||||||
|
return this.page.getByTestId(ids.historyItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
get skeletons(): Locator {
|
||||||
|
return this.page.getByTestId(ids.skeleton)
|
||||||
|
}
|
||||||
|
|
||||||
|
get latentPreviews(): Locator {
|
||||||
|
return this.page.getByTestId(ids.latentPreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
get imageOutputs(): Locator {
|
||||||
|
return this.page.getByTestId(ids.imageOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
get videoOutputs(): Locator {
|
||||||
|
return this.page.getByTestId(ids.videoOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
get compareOutputs(): Locator {
|
||||||
|
return this.page.getByTestId(ids.compareOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
get comparePreview(): Locator {
|
||||||
|
return this.page.getByTestId(ids.comparePreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
get compareSlider(): Locator {
|
||||||
|
return this.page.getByTestId(ids.compareSlider)
|
||||||
|
}
|
||||||
|
|
||||||
|
get batchNav(): Locator {
|
||||||
|
return this.page.getByTestId(ids.batchNav)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timeline items that are non-asset outputs (e.g. image compare). */
|
||||||
|
get nonAssetItems(): Locator {
|
||||||
|
return this.page.locator(
|
||||||
|
`[data-testid="${ids.historyItem}"][data-item-kind="nonAsset"]`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The currently selected non-asset timeline item. */
|
||||||
|
get selectedNonAssetItem(): Locator {
|
||||||
|
return this.page.locator(
|
||||||
|
`[data-testid="${ids.historyItem}"][data-item-kind="nonAsset"][data-state="checked"]`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The currently selected (checked) in-progress item. */
|
||||||
|
get selectedInProgressItem(): Locator {
|
||||||
|
return this.page.locator(
|
||||||
|
`[data-testid="${ids.inProgressItem}"][data-state="checked"]`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The currently selected (checked) history item. */
|
||||||
|
get selectedHistoryItem(): Locator {
|
||||||
|
return this.page.locator(
|
||||||
|
`[data-testid="${ids.historyItem}"][data-state="checked"]`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The header-level progress bar. */
|
||||||
|
get headerProgressBar(): Locator {
|
||||||
|
return this.page.getByTestId(ids.headerProgressBar)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The in-progress item's progress bar (inside the thumbnail). */
|
||||||
|
get itemProgressBar(): Locator {
|
||||||
|
return this.inProgressItems.first().getByTestId(ids.itemProgressBar)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overall progress in the header bar. */
|
||||||
|
get headerOverallProgress(): Locator {
|
||||||
|
return this.headerProgressBar.getByTestId(ids.progressOverall)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node progress in the header bar. */
|
||||||
|
get headerNodeProgress(): Locator {
|
||||||
|
return this.headerProgressBar.getByTestId(ids.progressNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overall progress in the in-progress item bar. */
|
||||||
|
get itemOverallProgress(): Locator {
|
||||||
|
return this.itemProgressBar.getByTestId(ids.progressOverall)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node progress in the in-progress item bar. */
|
||||||
|
get itemNodeProgress(): Locator {
|
||||||
|
return this.itemProgressBar.getByTestId(ids.progressNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'
|
|||||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||||
import { TestIds } from '@e2e/fixtures/selectors'
|
import { TestIds } from '@e2e/fixtures/selectors'
|
||||||
|
|
||||||
|
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
|
||||||
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
|
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
|
||||||
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
|
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
|
||||||
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
||||||
@@ -14,6 +15,7 @@ export class AppModeHelper {
|
|||||||
readonly footer: BuilderFooterHelper
|
readonly footer: BuilderFooterHelper
|
||||||
readonly saveAs: BuilderSaveAsHelper
|
readonly saveAs: BuilderSaveAsHelper
|
||||||
readonly select: BuilderSelectHelper
|
readonly select: BuilderSelectHelper
|
||||||
|
readonly outputHistory: OutputHistoryComponent
|
||||||
readonly widgets: AppModeWidgetHelper
|
readonly widgets: AppModeWidgetHelper
|
||||||
|
|
||||||
constructor(private readonly comfyPage: ComfyPage) {
|
constructor(private readonly comfyPage: ComfyPage) {
|
||||||
@@ -21,6 +23,7 @@ export class AppModeHelper {
|
|||||||
this.footer = new BuilderFooterHelper(comfyPage)
|
this.footer = new BuilderFooterHelper(comfyPage)
|
||||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||||
this.select = new BuilderSelectHelper(comfyPage)
|
this.select = new BuilderSelectHelper(comfyPage)
|
||||||
|
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,33 +69,44 @@ export class AppModeHelper {
|
|||||||
* picks up the data via its activeWorkflow watcher.
|
* picks up the data via its activeWorkflow watcher.
|
||||||
*
|
*
|
||||||
* @param inputs - Widget selections as [nodeId, widgetName] tuples
|
* @param inputs - Widget selections as [nodeId, widgetName] tuples
|
||||||
|
* @param outputs - Explicit output node IDs. When omitted, auto-detects
|
||||||
|
* SaveImage/PreviewImage nodes.
|
||||||
*/
|
*/
|
||||||
async enterAppModeWithInputs(inputs: [string, string][]) {
|
async enterAppModeWithInputs(inputs: [string, string][], outputs?: string[]) {
|
||||||
await this.page.evaluate(async (inputTuples) => {
|
await this.page.evaluate(
|
||||||
const graph = window.app!.graph
|
async ({ inputTuples, explicitOutputs }) => {
|
||||||
if (!graph) return
|
const graph = window.app!.graph
|
||||||
|
if (!graph) return
|
||||||
|
|
||||||
const outputNodeIds = graph.nodes
|
const outputNodeIds =
|
||||||
.filter(
|
explicitOutputs ??
|
||||||
(n: { type?: string }) =>
|
graph.nodes
|
||||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
.filter(
|
||||||
|
(n: { type?: string }) =>
|
||||||
|
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||||
|
)
|
||||||
|
.map((n: { id: number | string }) => String(n.id))
|
||||||
|
|
||||||
|
const workflow = graph.serialize() as unknown as Record<string, unknown>
|
||||||
|
const extra = (workflow.extra ?? {}) as Record<string, unknown>
|
||||||
|
extra.linearData = { inputs: inputTuples, outputs: outputNodeIds }
|
||||||
|
workflow.extra = extra
|
||||||
|
await window.app!.loadGraphData(
|
||||||
|
workflow as unknown as Parameters<
|
||||||
|
NonNullable<typeof window.app>['loadGraphData']
|
||||||
|
>[0]
|
||||||
)
|
)
|
||||||
.map((n: { id: number | string }) => String(n.id))
|
},
|
||||||
|
{ inputTuples: inputs, explicitOutputs: outputs }
|
||||||
const workflow = graph.serialize() as unknown as Record<string, unknown>
|
)
|
||||||
const extra = (workflow.extra ?? {}) as Record<string, unknown>
|
|
||||||
extra.linearData = { inputs: inputTuples, outputs: outputNodeIds }
|
|
||||||
workflow.extra = extra
|
|
||||||
await window.app!.loadGraphData(
|
|
||||||
workflow as unknown as Parameters<
|
|
||||||
NonNullable<typeof window.app>['loadGraphData']
|
|
||||||
>[0]
|
|
||||||
)
|
|
||||||
}, inputs)
|
|
||||||
await this.comfyPage.nextFrame()
|
await this.comfyPage.nextFrame()
|
||||||
await this.toggleAppMode()
|
await this.toggleAppMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get cancelRunButton(): Locator {
|
||||||
|
return this.page.getByTestId(TestIds.outputHistory.cancelRun)
|
||||||
|
}
|
||||||
|
|
||||||
/** The "Connect an output" popover shown when saving without outputs. */
|
/** The "Connect an output" popover shown when saving without outputs. */
|
||||||
get connectOutputPopover(): Locator {
|
get connectOutputPopover(): Locator {
|
||||||
return this.page.getByTestId(TestIds.builder.connectOutputPopover)
|
return this.page.getByTestId(TestIds.builder.connectOutputPopover)
|
||||||
|
|||||||
234
browser_tests/fixtures/helpers/ExecutionHelper.ts
Normal file
234
browser_tests/fixtures/helpers/ExecutionHelper.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import type { WebSocketRoute } from '@playwright/test'
|
||||||
|
|
||||||
|
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||||
|
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for simulating prompt execution in e2e tests.
|
||||||
|
*/
|
||||||
|
export class ExecutionHelper {
|
||||||
|
private jobCounter = 0
|
||||||
|
private readonly completedJobs: RawJobListItem[] = []
|
||||||
|
private readonly page: ComfyPage['page']
|
||||||
|
private readonly command: ComfyPage['command']
|
||||||
|
private readonly assets: ComfyPage['assets']
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
comfyPage: ComfyPage,
|
||||||
|
private readonly ws: WebSocketRoute
|
||||||
|
) {
|
||||||
|
this.page = comfyPage.page
|
||||||
|
this.command = comfyPage.command
|
||||||
|
this.assets = comfyPage.assets
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
|
||||||
|
* the synthetic job ID.
|
||||||
|
*
|
||||||
|
* The app receives a valid PromptResponse so storeJob() fires
|
||||||
|
* and registers the job against the active workflow path.
|
||||||
|
*/
|
||||||
|
async run(): Promise<string> {
|
||||||
|
const jobId = `test-job-${++this.jobCounter}`
|
||||||
|
|
||||||
|
let fulfilled!: () => void
|
||||||
|
const prompted = new Promise<void>((r) => {
|
||||||
|
fulfilled = r
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.page.route(
|
||||||
|
'**/api/prompt',
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt_id: jobId,
|
||||||
|
node_errors: {}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
fulfilled()
|
||||||
|
},
|
||||||
|
{ times: 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.command.executeCommand('Comfy.QueuePrompt')
|
||||||
|
await prompted
|
||||||
|
|
||||||
|
return jobId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a binary `b_preview_with_metadata` WS message (type 4).
|
||||||
|
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
|
||||||
|
*/
|
||||||
|
latentPreview(jobId: string, nodeId: string): void {
|
||||||
|
const metadata = JSON.stringify({
|
||||||
|
node_id: nodeId,
|
||||||
|
display_node_id: nodeId,
|
||||||
|
parent_node_id: nodeId,
|
||||||
|
real_node_id: nodeId,
|
||||||
|
prompt_id: jobId,
|
||||||
|
image_type: 'image/png'
|
||||||
|
})
|
||||||
|
const metadataBytes = new TextEncoder().encode(metadata)
|
||||||
|
|
||||||
|
// 1x1 red PNG
|
||||||
|
const png = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==',
|
||||||
|
'base64'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Binary format: [type:uint32][metadataLength:uint32][metadata][imageData]
|
||||||
|
const buf = new ArrayBuffer(8 + metadataBytes.length + png.length)
|
||||||
|
const view = new DataView(buf)
|
||||||
|
view.setUint32(0, 4) // type 4 = PREVIEW_IMAGE_WITH_METADATA
|
||||||
|
view.setUint32(4, metadataBytes.length)
|
||||||
|
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
|
||||||
|
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
|
||||||
|
|
||||||
|
this.ws.send(Buffer.from(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `execution_start` WS event. */
|
||||||
|
executionStart(jobId: string): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'execution_start',
|
||||||
|
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `executing` WS event to signal which node is currently running. */
|
||||||
|
executing(jobId: string, nodeId: string | null): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'executing',
|
||||||
|
data: { prompt_id: jobId, node: nodeId }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `executed` WS event with node output. */
|
||||||
|
executed(
|
||||||
|
jobId: string,
|
||||||
|
nodeId: string,
|
||||||
|
output: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'executed',
|
||||||
|
data: {
|
||||||
|
prompt_id: jobId,
|
||||||
|
node: nodeId,
|
||||||
|
display_node: nodeId,
|
||||||
|
output
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `execution_success` WS event. */
|
||||||
|
executionSuccess(jobId: string): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'execution_success',
|
||||||
|
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `execution_error` WS event. */
|
||||||
|
executionError(jobId: string, nodeId: string, message: string): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'execution_error',
|
||||||
|
data: {
|
||||||
|
prompt_id: jobId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
node_id: nodeId,
|
||||||
|
node_type: 'Unknown',
|
||||||
|
exception_message: message,
|
||||||
|
exception_type: 'RuntimeError',
|
||||||
|
traceback: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `progress` WS event. */
|
||||||
|
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'progress',
|
||||||
|
data: { prompt_id: jobId, node: nodeId, value, max }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a job by adding it to mock history, sending execution_success,
|
||||||
|
* and triggering a history refresh via a status event.
|
||||||
|
*
|
||||||
|
* Requires an {@link AssetsHelper} to be passed in the constructor.
|
||||||
|
*/
|
||||||
|
async completeWithHistory(
|
||||||
|
jobId: string,
|
||||||
|
nodeId: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<void> {
|
||||||
|
this.completedJobs.push(
|
||||||
|
createMockJob({
|
||||||
|
id: jobId,
|
||||||
|
preview_output: {
|
||||||
|
filename,
|
||||||
|
subfolder: '',
|
||||||
|
type: 'output',
|
||||||
|
nodeId,
|
||||||
|
mediaType: 'images'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.assets.mockOutputHistory(this.completedJobs)
|
||||||
|
this.executionSuccess(jobId)
|
||||||
|
// Trigger queue/history refresh
|
||||||
|
this.status(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send `executed` WS event with image compare output (a_images + b_images).
|
||||||
|
* Each filename array is converted to the result item format.
|
||||||
|
*/
|
||||||
|
executedImageCompare(
|
||||||
|
jobId: string,
|
||||||
|
nodeId: string,
|
||||||
|
aImages: string[],
|
||||||
|
bImages: string[]
|
||||||
|
): void {
|
||||||
|
const toItems = (filenames: string[]) =>
|
||||||
|
filenames.map((filename) => ({
|
||||||
|
filename,
|
||||||
|
subfolder: '',
|
||||||
|
type: 'output'
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.executed(jobId, nodeId, {
|
||||||
|
a_images: toItems(aImages),
|
||||||
|
b_images: toItems(bImages)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send `status` WS event to update queue count. */
|
||||||
|
status(queueRemaining: number): void {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'status',
|
||||||
|
data: { status: { exec_info: { queue_remaining: queueRemaining } } }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,6 +129,28 @@ export const TestIds = {
|
|||||||
outputPlaceholder: 'builder-output-placeholder',
|
outputPlaceholder: 'builder-output-placeholder',
|
||||||
connectOutputPopover: 'builder-connect-output-popover'
|
connectOutputPopover: 'builder-connect-output-popover'
|
||||||
},
|
},
|
||||||
|
outputHistory: {
|
||||||
|
outputs: 'linear-outputs',
|
||||||
|
welcome: 'linear-welcome',
|
||||||
|
outputInfo: 'linear-output-info',
|
||||||
|
activeQueue: 'linear-job',
|
||||||
|
queueBadge: 'linear-job-badge',
|
||||||
|
inProgressItem: 'linear-in-progress-item',
|
||||||
|
historyItem: 'linear-history-item',
|
||||||
|
skeleton: 'linear-skeleton',
|
||||||
|
latentPreview: 'linear-latent-preview',
|
||||||
|
imageOutput: 'linear-image-output',
|
||||||
|
videoOutput: 'linear-video-output',
|
||||||
|
compareOutput: 'linear-compare-output',
|
||||||
|
comparePreview: 'image-compare-preview',
|
||||||
|
compareSlider: 'image-compare-slider',
|
||||||
|
batchNav: 'batch-nav',
|
||||||
|
cancelRun: 'linear-cancel-run',
|
||||||
|
headerProgressBar: 'linear-header-progress-bar',
|
||||||
|
itemProgressBar: 'linear-item-progress-bar',
|
||||||
|
progressOverall: 'linear-progress-overall',
|
||||||
|
progressNode: 'linear-progress-node'
|
||||||
|
},
|
||||||
appMode: {
|
appMode: {
|
||||||
widgetItem: 'app-mode-widget-item',
|
widgetItem: 'app-mode-widget-item',
|
||||||
welcome: 'linear-welcome',
|
welcome: 'linear-welcome',
|
||||||
@@ -173,6 +195,7 @@ export type TestIdValue =
|
|||||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||||
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
|
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
|
||||||
|
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
|
||||||
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
|
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
|
||||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||||
| Exclude<
|
| Exclude<
|
||||||
|
|||||||
@@ -1,53 +1,31 @@
|
|||||||
import { test as base } from '@playwright/test'
|
import { test as base } from '@playwright/test'
|
||||||
|
import type { WebSocketRoute } from '@playwright/test'
|
||||||
|
|
||||||
export const webSocketFixture = base.extend<{
|
export const webSocketFixture = base.extend<{
|
||||||
ws: { trigger(data: unknown, url?: string): Promise<void> }
|
getWebSocket: () => Promise<WebSocketRoute>
|
||||||
}>({
|
}>({
|
||||||
ws: [
|
getWebSocket: [
|
||||||
async ({ page }, use) => {
|
async ({ context }, use) => {
|
||||||
// Each time a page loads, to catch navigations
|
let latest: WebSocketRoute | undefined
|
||||||
page.on('load', async () => {
|
let resolve: ((ws: WebSocketRoute) => void) | undefined
|
||||||
await page.evaluate(function () {
|
|
||||||
// Create a wrapper for WebSocket that stores them globally
|
await context.routeWebSocket(/\/ws/, (ws) => {
|
||||||
// so we can look it up to trigger messages
|
const server = ws.connectToServer()
|
||||||
const store: Record<string, WebSocket> = (window.__ws__ = {})
|
server.onMessage((message) => {
|
||||||
window.WebSocket = class extends window.WebSocket {
|
ws.send(message)
|
||||||
constructor(
|
|
||||||
...rest: ConstructorParameters<typeof window.WebSocket>
|
|
||||||
) {
|
|
||||||
super(...rest)
|
|
||||||
store[this.url] = this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
latest = ws
|
||||||
|
resolve?.(ws)
|
||||||
})
|
})
|
||||||
|
|
||||||
await use({
|
await use(() => {
|
||||||
async trigger(data, url) {
|
if (latest) return Promise.resolve(latest)
|
||||||
// Trigger a websocket event on the page
|
return new Promise<WebSocketRoute>((r) => {
|
||||||
await page.evaluate(
|
resolve = r
|
||||||
function ([data, url]) {
|
})
|
||||||
if (!url) {
|
|
||||||
// If no URL specified, use page URL
|
|
||||||
const u = new URL(window.location.href)
|
|
||||||
u.hash = ''
|
|
||||||
u.protocol = 'ws:'
|
|
||||||
u.pathname = '/'
|
|
||||||
url = u.toString() + 'ws'
|
|
||||||
}
|
|
||||||
const ws: WebSocket = window.__ws__![url]
|
|
||||||
ws.dispatchEvent(
|
|
||||||
new MessageEvent('message', {
|
|
||||||
data
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[JSON.stringify(data), url]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
// We need this to run automatically as the first thing so it adds handlers as soon as the page loads
|
|
||||||
{ auto: true }
|
{ auto: true }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Response } from '@playwright/test'
|
import type { Response } from '@playwright/test'
|
||||||
import { expect, mergeTests } from '@playwright/test'
|
import { expect, mergeTests } from '@playwright/test'
|
||||||
|
|
||||||
import type { StatusWsMessage } from '@/schemas/apiSchema'
|
|
||||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||||
@@ -18,8 +17,10 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
|||||||
*/
|
*/
|
||||||
test('Does not auto-queue multiple changes at a time', async ({
|
test('Does not auto-queue multiple changes at a time', async ({
|
||||||
comfyPage,
|
comfyPage,
|
||||||
ws
|
getWebSocket
|
||||||
}) => {
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
|
||||||
// Enable change auto-queue mode
|
// Enable change auto-queue mode
|
||||||
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
|
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
|
||||||
expect(await queueOpts.getMode()).toBe('disabled')
|
expect(await queueOpts.getMode()).toBe('disabled')
|
||||||
@@ -62,17 +63,19 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trigger a status websocket message
|
// Trigger a status websocket message
|
||||||
const triggerStatus = async (queueSize: number) => {
|
const triggerStatus = (queueSize: number) => {
|
||||||
await ws.trigger({
|
ws.send(
|
||||||
type: 'status',
|
JSON.stringify({
|
||||||
data: {
|
type: 'status',
|
||||||
status: {
|
data: {
|
||||||
exec_info: {
|
status: {
|
||||||
queue_remaining: queueSize
|
exec_info: {
|
||||||
|
queue_remaining: queueSize
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
} as StatusWsMessage)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the width from the queue response
|
// Extract the width from the queue response
|
||||||
@@ -104,8 +107,8 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
|||||||
).toBe(1)
|
).toBe(1)
|
||||||
|
|
||||||
// Trigger a status update so auto-queue re-runs
|
// Trigger a status update so auto-queue re-runs
|
||||||
await triggerStatus(1)
|
triggerStatus(1)
|
||||||
await triggerStatus(0)
|
triggerStatus(0)
|
||||||
|
|
||||||
// Ensure the queued width is the last queued value
|
// Ensure the queued width is the last queued value
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
comfyPageFixture as test,
|
comfyPageFixture as test,
|
||||||
comfyExpect as expect
|
comfyExpect as expect
|
||||||
} from '../fixtures/ComfyPage'
|
} from '@e2e/fixtures/ComfyPage'
|
||||||
|
|
||||||
test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||||
test.beforeEach(async ({ comfyPage }) => {
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
|||||||
752
browser_tests/tests/outputHistory.spec.ts
Normal file
752
browser_tests/tests/outputHistory.spec.ts
Normal file
@@ -0,0 +1,752 @@
|
|||||||
|
import type { WebSocketRoute } from '@playwright/test'
|
||||||
|
import { mergeTests } from '@playwright/test'
|
||||||
|
|
||||||
|
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
|
import {
|
||||||
|
comfyPageFixture,
|
||||||
|
comfyExpect as expect
|
||||||
|
} from '@e2e/fixtures/ComfyPage'
|
||||||
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||||
|
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||||
|
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||||
|
|
||||||
|
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||||
|
|
||||||
|
// Node IDs from the default workflow (browser_tests/assets/default.json, 7 nodes)
|
||||||
|
const SAVE_IMAGE_NODE = '9'
|
||||||
|
const KSAMPLER_NODE = '3'
|
||||||
|
const ALL_NODE_IDS = ['4', '6', '7', '5', KSAMPLER_NODE, '8', SAVE_IMAGE_NODE]
|
||||||
|
// Node ID from execution/image_compare_save.json
|
||||||
|
const IMAGE_COMPARE_NODE = '10'
|
||||||
|
|
||||||
|
/** Queue a prompt, intercept it, and send execution_start. */
|
||||||
|
async function startExecution(
|
||||||
|
comfyPage: ComfyPage,
|
||||||
|
ws: WebSocketRoute,
|
||||||
|
exec?: ExecutionHelper
|
||||||
|
) {
|
||||||
|
exec ??= new ExecutionHelper(comfyPage, ws)
|
||||||
|
const jobId = await exec.run()
|
||||||
|
// Allow storeJob() to complete before sending WS events
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
exec.executionStart(jobId)
|
||||||
|
return { exec, jobId }
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageOutput(...filenames: string[]) {
|
||||||
|
return {
|
||||||
|
images: filenames.map((filename) => ({
|
||||||
|
filename,
|
||||||
|
subfolder: '',
|
||||||
|
type: 'output'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Output History', { tag: '@ui' }, () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Default workflow', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
|
||||||
|
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Skeleton appears on execution start', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.skeletons.first()
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Latent preview replaces skeleton', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.skeletons.first()
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.latentPreviews.first()
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Image output replaces skeleton on executed', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('test_output.png'))
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.imageOutputs.first()
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Multiple outputs from single execution', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
exec.executed(
|
||||||
|
jobId,
|
||||||
|
SAVE_IMAGE_NODE,
|
||||||
|
imageOutput('output_001.png', 'output_002.png', 'output_003.png')
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Video output renders video element', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
exec.executed(jobId, SAVE_IMAGE_NODE, {
|
||||||
|
gifs: [{ filename: 'output.mp4', subfolder: '', type: 'output' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.videoOutputs.first()
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Cancel button sends interrupt during execution', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
const job: RawJobListItem = {
|
||||||
|
id: jobId,
|
||||||
|
status: 'in_progress',
|
||||||
|
create_time: Date.now() / 1000,
|
||||||
|
priority: 0
|
||||||
|
}
|
||||||
|
await comfyPage.page.route(
|
||||||
|
/\/api\/jobs\?status=in_progress/,
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
jobs: [job],
|
||||||
|
pagination: { offset: 0, limit: 200, total: 1, has_more: false }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ times: 1 }
|
||||||
|
)
|
||||||
|
// Trigger queue refresh
|
||||||
|
exec.status(1)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
await expect(comfyPage.appMode.cancelRunButton).toBeVisible()
|
||||||
|
|
||||||
|
await comfyPage.page.route('**/interrupt', (route) =>
|
||||||
|
route.fulfill({ status: 200 })
|
||||||
|
)
|
||||||
|
const interruptRequest = comfyPage.page.waitForRequest('**/interrupt')
|
||||||
|
await comfyPage.appMode.cancelRunButton.click()
|
||||||
|
await interruptRequest
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Full execution lifecycle cleans up in-progress items', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
// Skeleton appears
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.skeletons.first()
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
// Latent preview replaces skeleton
|
||||||
|
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.latentPreviews.first()
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
// Image output replaces latent
|
||||||
|
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('lifecycle_out.png'))
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.imageOutputs.first()
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
// Job completes with history mock - in-progress items fully resolved
|
||||||
|
await exec.completeWithHistory(
|
||||||
|
jobId,
|
||||||
|
SAVE_IMAGE_NODE,
|
||||||
|
'lifecycle_out.png'
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(
|
||||||
|
0
|
||||||
|
)
|
||||||
|
// Output now appears as a history item
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.historyItems.first()
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Auto-selection follows latest in-progress item', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
// Skeleton is auto-selected
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.selectedInProgressItem
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
// First image is auto-selected
|
||||||
|
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('first.png'))
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
|
||||||
|
'linear-image-output'
|
||||||
|
)
|
||||||
|
).toHaveAttribute('src', /first\.png/)
|
||||||
|
|
||||||
|
// Second image arrives - selection auto-follows without user click
|
||||||
|
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('second.png'))
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
|
||||||
|
'linear-image-output'
|
||||||
|
)
|
||||||
|
).toHaveAttribute('src', /second\.png/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('In-progress items always auto-follow to latest output', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
// Send first image
|
||||||
|
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('first.png'))
|
||||||
|
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(1)
|
||||||
|
|
||||||
|
// Click the first in-progress image
|
||||||
|
await comfyPage.appMode.outputHistory.inProgressItems.first().click()
|
||||||
|
|
||||||
|
// Send second image - selection should follow because slot items
|
||||||
|
// always auto-follow to keep the user on the latest output
|
||||||
|
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('second.png'))
|
||||||
|
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(2)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
|
||||||
|
'linear-image-output'
|
||||||
|
)
|
||||||
|
).toHaveAttribute('src', /second\.png/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Non-output node executed events are filtered', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
// KSampler is not an output node - should be filtered
|
||||||
|
exec.executed(jobId, KSAMPLER_NODE, imageOutput('ksampler_out.png'))
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// KSampler output should not create image outputs
|
||||||
|
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(0)
|
||||||
|
|
||||||
|
// Now send from the actual output node (SaveImage)
|
||||||
|
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('save_image_out.png'))
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.imageOutputs.first()
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('In-progress items are outside the scrollable area', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
|
||||||
|
// Complete one execution with 100 image outputs
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
exec.executed(
|
||||||
|
jobId,
|
||||||
|
SAVE_IMAGE_NODE,
|
||||||
|
imageOutput(
|
||||||
|
...Array.from(
|
||||||
|
{ length: 100 },
|
||||||
|
(_, i) => `image_${String(i).padStart(3, '0')}.png`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, 'image_000.png')
|
||||||
|
|
||||||
|
await expect(comfyPage.appMode.outputHistory.historyItems).toHaveCount(
|
||||||
|
100
|
||||||
|
)
|
||||||
|
|
||||||
|
// First history item is visible before scrolling
|
||||||
|
const firstItem = comfyPage.appMode.outputHistory.historyItems.first()
|
||||||
|
await expect(firstItem).toBeInViewport()
|
||||||
|
|
||||||
|
// Scroll the history feed all the way to the right
|
||||||
|
await comfyPage.appMode.outputHistory.outputs.evaluate((el) => {
|
||||||
|
el.scrollLeft = el.scrollWidth
|
||||||
|
})
|
||||||
|
|
||||||
|
// First history item is now off-screen
|
||||||
|
await expect(firstItem).not.toBeInViewport()
|
||||||
|
|
||||||
|
// Start a new execution to get an in-progress item
|
||||||
|
await startExecution(comfyPage, ws, exec)
|
||||||
|
|
||||||
|
// In-progress item is visible despite scrolling
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||||
|
).toBeInViewport()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Execution error cleans up in-progress items', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
exec.executionError(jobId, KSAMPLER_NODE, 'Test error')
|
||||||
|
|
||||||
|
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(
|
||||||
|
0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Progress bars update for both node and overall progress', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
const {
|
||||||
|
inProgressItems,
|
||||||
|
headerOverallProgress,
|
||||||
|
headerNodeProgress,
|
||||||
|
itemOverallProgress,
|
||||||
|
itemNodeProgress
|
||||||
|
} = comfyPage.appMode.outputHistory
|
||||||
|
|
||||||
|
await expect(inProgressItems.first()).toBeVisible()
|
||||||
|
|
||||||
|
// Initially both bars are at 0%
|
||||||
|
await expect(headerOverallProgress).toHaveAttribute(
|
||||||
|
'style',
|
||||||
|
/width:\s*0%/
|
||||||
|
)
|
||||||
|
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*0%/)
|
||||||
|
|
||||||
|
// KSampler starts executing - node progress at 50%
|
||||||
|
exec.executing(jobId, KSAMPLER_NODE)
|
||||||
|
exec.progress(jobId, KSAMPLER_NODE, 5, 10)
|
||||||
|
|
||||||
|
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*50%/)
|
||||||
|
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*50%/)
|
||||||
|
// Overall still 0% - no nodes completed yet
|
||||||
|
await expect(headerOverallProgress).toHaveAttribute(
|
||||||
|
'style',
|
||||||
|
/width:\s*0%/
|
||||||
|
)
|
||||||
|
|
||||||
|
// KSampler finishes - overall advances (1 of 7 nodes)
|
||||||
|
exec.executed(jobId, KSAMPLER_NODE, {})
|
||||||
|
|
||||||
|
const oneNodePercent = Math.round((1 / ALL_NODE_IDS.length) * 100)
|
||||||
|
const pct = new RegExp(`width:\\s*${oneNodePercent}%`)
|
||||||
|
await expect(headerOverallProgress).toHaveAttribute('style', pct)
|
||||||
|
await expect(itemOverallProgress).toHaveAttribute('style', pct)
|
||||||
|
|
||||||
|
// Node progress reaches 100%
|
||||||
|
exec.progress(jobId, KSAMPLER_NODE, 10, 10)
|
||||||
|
|
||||||
|
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*100%/)
|
||||||
|
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*100%/)
|
||||||
|
|
||||||
|
// Complete remaining nodes - overall reaches 100%
|
||||||
|
const remainingNodes = ALL_NODE_IDS.filter((id) => id !== KSAMPLER_NODE)
|
||||||
|
for (const nodeId of remainingNodes) {
|
||||||
|
exec.executing(jobId, nodeId)
|
||||||
|
exec.executed(jobId, nodeId, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(headerOverallProgress).toHaveAttribute(
|
||||||
|
'style',
|
||||||
|
/width:\s*100%/
|
||||||
|
)
|
||||||
|
await expect(itemOverallProgress).toHaveAttribute(
|
||||||
|
'style',
|
||||||
|
/width:\s*100%/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Image Compare', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow('execution/image_compare_save')
|
||||||
|
await comfyPage.appMode.enterAppModeWithInputs(
|
||||||
|
[[KSAMPLER_NODE, 'seed']],
|
||||||
|
[SAVE_IMAGE_NODE, IMAGE_COMPARE_NODE]
|
||||||
|
)
|
||||||
|
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Run a complete compare + save execution lifecycle. */
|
||||||
|
async function runCompareExecution(
|
||||||
|
exec: ExecutionHelper,
|
||||||
|
comfyPage: ComfyPage,
|
||||||
|
idx: string,
|
||||||
|
savedFilename: string
|
||||||
|
) {
|
||||||
|
const jobId = await exec.run()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
exec.executionStart(jobId)
|
||||||
|
exec.executedImageCompare(
|
||||||
|
jobId,
|
||||||
|
IMAGE_COMPARE_NODE,
|
||||||
|
[`before${idx}.png`],
|
||||||
|
[`after${idx}.png`]
|
||||||
|
)
|
||||||
|
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput(savedFilename))
|
||||||
|
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, savedFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run two executions that each produce a compare + image output. */
|
||||||
|
async function runTwoCompareExecutions(
|
||||||
|
exec: ExecutionHelper,
|
||||||
|
comfyPage: ComfyPage
|
||||||
|
) {
|
||||||
|
await runCompareExecution(exec, comfyPage, '1', 'saved1.png')
|
||||||
|
await runCompareExecution(exec, comfyPage, '2', 'saved2.png')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Split thumbnail and slider preview', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
exec.executedImageCompare(
|
||||||
|
jobId,
|
||||||
|
IMAGE_COMPARE_NODE,
|
||||||
|
['before.png'],
|
||||||
|
['after.png']
|
||||||
|
)
|
||||||
|
|
||||||
|
// Split thumbnail appears in the output strip
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.compareOutputs.first()
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
// Auto-selected - full preview with slider is shown
|
||||||
|
await expect(comfyPage.appMode.outputHistory.comparePreview).toBeVisible()
|
||||||
|
await expect(comfyPage.appMode.outputHistory.compareSlider).toBeVisible()
|
||||||
|
|
||||||
|
// Single image per side - no batch navigation
|
||||||
|
await expect(comfyPage.appMode.outputHistory.batchNav).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Slider moves on pointer interaction', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
exec.executedImageCompare(
|
||||||
|
jobId,
|
||||||
|
IMAGE_COMPARE_NODE,
|
||||||
|
['before.png'],
|
||||||
|
['after.png']
|
||||||
|
)
|
||||||
|
|
||||||
|
const preview = comfyPage.appMode.outputHistory.comparePreview
|
||||||
|
await expect(preview).toBeVisible()
|
||||||
|
|
||||||
|
// Slider starts at 50%
|
||||||
|
const slider = comfyPage.appMode.outputHistory.compareSlider
|
||||||
|
await expect(slider).toHaveAttribute('style', /left:\s*50%/)
|
||||||
|
|
||||||
|
// Move pointer to the left quarter of the preview
|
||||||
|
const box = await preview.boundingBox()
|
||||||
|
expect(box).toBeTruthy()
|
||||||
|
await comfyPage.page.mouse.move(
|
||||||
|
box!.x + box!.width * 0.25,
|
||||||
|
box!.y + box!.height / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Slider should move to ~25%
|
||||||
|
await expect(slider).toHaveAttribute('style', /left:\s*2[0-9](\.\d+)?%/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Single-sided compare renders without slider', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
exec.executed(jobId, IMAGE_COMPARE_NODE, {
|
||||||
|
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.compareOutputs.first()
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(comfyPage.appMode.outputHistory.compareSlider).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Compare output becomes non-asset after completion', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
exec.executedImageCompare(
|
||||||
|
jobId,
|
||||||
|
IMAGE_COMPARE_NODE,
|
||||||
|
['before.png'],
|
||||||
|
['after.png']
|
||||||
|
)
|
||||||
|
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('saved.png'))
|
||||||
|
|
||||||
|
// Both visible as in-progress
|
||||||
|
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(
|
||||||
|
2
|
||||||
|
)
|
||||||
|
await expect(comfyPage.appMode.outputHistory.compareOutputs).toHaveCount(
|
||||||
|
1
|
||||||
|
)
|
||||||
|
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(1)
|
||||||
|
|
||||||
|
// After completion: compare → non-asset, image → history
|
||||||
|
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, 'saved.png')
|
||||||
|
|
||||||
|
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(
|
||||||
|
0
|
||||||
|
)
|
||||||
|
await expect(comfyPage.appMode.outputHistory.nonAssetItems).toHaveCount(1)
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.nonAssetItems
|
||||||
|
.first()
|
||||||
|
.getByTestId('linear-compare-output')
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Compare output persists when no SaveImage node exists', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow('execution/image_compare_only')
|
||||||
|
await comfyPage.appMode.enterAppModeWithInputs(
|
||||||
|
[[KSAMPLER_NODE, 'seed']],
|
||||||
|
[IMAGE_COMPARE_NODE]
|
||||||
|
)
|
||||||
|
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
exec.executedImageCompare(
|
||||||
|
jobId,
|
||||||
|
IMAGE_COMPARE_NODE,
|
||||||
|
['before.png'],
|
||||||
|
['after.png']
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.compareOutputs.first()
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
exec.executionSuccess(jobId)
|
||||||
|
exec.status(0)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
await expect(comfyPage.appMode.outputHistory.nonAssetItems).toHaveCount(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Multiple executions accumulate non-asset outputs', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const exec = new ExecutionHelper(comfyPage, ws)
|
||||||
|
|
||||||
|
await runTwoCompareExecutions(exec, comfyPage)
|
||||||
|
|
||||||
|
await expect(comfyPage.appMode.outputHistory.nonAssetItems).toHaveCount(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Latest non-asset auto-follows to new execution', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const exec = new ExecutionHelper(comfyPage, ws)
|
||||||
|
|
||||||
|
const jobId1 = await exec.run()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
exec.executionStart(jobId1)
|
||||||
|
exec.executedImageCompare(
|
||||||
|
jobId1,
|
||||||
|
IMAGE_COMPARE_NODE,
|
||||||
|
['before.png'],
|
||||||
|
['after.png']
|
||||||
|
)
|
||||||
|
exec.executed(jobId1, SAVE_IMAGE_NODE, imageOutput('saved.png'))
|
||||||
|
await exec.completeWithHistory(jobId1, SAVE_IMAGE_NODE, 'saved.png')
|
||||||
|
|
||||||
|
// Latest non-asset should be auto-selected
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.selectedNonAssetItem
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
// New execution - auto-follow kicks in
|
||||||
|
await startExecution(comfyPage, ws, exec)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.selectedInProgressItem
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.selectedNonAssetItem
|
||||||
|
).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Older non-asset keeps selection when new execution produces compare output', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const exec = new ExecutionHelper(comfyPage, ws)
|
||||||
|
|
||||||
|
await runTwoCompareExecutions(exec, comfyPage)
|
||||||
|
|
||||||
|
// Click the older (last) non-asset
|
||||||
|
await comfyPage.appMode.outputHistory.nonAssetItems.last().click()
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.selectedNonAssetItem
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
// New execution - selection should NOT move
|
||||||
|
const { jobId: jobId3 } = await startExecution(comfyPage, ws, exec)
|
||||||
|
exec.executedImageCompare(
|
||||||
|
jobId3,
|
||||||
|
IMAGE_COMPARE_NODE,
|
||||||
|
['before3.png'],
|
||||||
|
['after3.png']
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.selectedNonAssetItem
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Older non-asset keeps selection when new execution completes with history', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const exec = new ExecutionHelper(comfyPage, ws)
|
||||||
|
|
||||||
|
await runTwoCompareExecutions(exec, comfyPage)
|
||||||
|
|
||||||
|
// Select the older non-asset
|
||||||
|
await comfyPage.appMode.outputHistory.nonAssetItems.last().click()
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.selectedNonAssetItem
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
// New run adds history - selection should NOT move
|
||||||
|
const jobId3 = await exec.run()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
exec.executionStart(jobId3)
|
||||||
|
exec.executed(jobId3, SAVE_IMAGE_NODE, imageOutput('saved3.png'))
|
||||||
|
await exec.completeWithHistory(jobId3, SAVE_IMAGE_NODE, 'saved3.png')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.selectedNonAssetItem
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Batch navigation visible with multiple images', async ({
|
||||||
|
comfyPage,
|
||||||
|
getWebSocket
|
||||||
|
}) => {
|
||||||
|
const ws = await getWebSocket()
|
||||||
|
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||||
|
|
||||||
|
exec.executedImageCompare(
|
||||||
|
jobId,
|
||||||
|
IMAGE_COMPARE_NODE,
|
||||||
|
['before1.png', 'before2.png'],
|
||||||
|
['after1.png']
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.appMode.outputHistory.compareOutputs.first()
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(comfyPage.appMode.outputHistory.batchNav).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1920,7 +1920,9 @@
|
|||||||
"imageCompare": {
|
"imageCompare": {
|
||||||
"noImages": "No images to compare",
|
"noImages": "No images to compare",
|
||||||
"batchLabelA": "A:",
|
"batchLabelA": "A:",
|
||||||
"batchLabelB": "B:"
|
"batchLabelB": "B:",
|
||||||
|
"altBefore": "Before image",
|
||||||
|
"altAfter": "After image"
|
||||||
},
|
},
|
||||||
"batch": {
|
"batch": {
|
||||||
"index": "{current} / {total}"
|
"index": "{current} / {total}"
|
||||||
|
|||||||
212
src/renderer/extensions/linearMode/ImageComparePreview.test.ts
Normal file
212
src/renderer/extensions/linearMode/ImageComparePreview.test.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||||
|
|
||||||
|
import ImageComparePreview from '@/renderer/extensions/linearMode/ImageComparePreview.vue'
|
||||||
|
import { makeResultItem } from '@/renderer/extensions/linearMode/__fixtures__/testResultItemFactory'
|
||||||
|
import type { CompareImages } from '@/stores/queueStore'
|
||||||
|
|
||||||
|
function makeCompareImages(
|
||||||
|
beforeFiles: string[],
|
||||||
|
afterFiles: string[]
|
||||||
|
): CompareImages {
|
||||||
|
return {
|
||||||
|
before: beforeFiles.map((f) => makeResultItem({ filename: f })),
|
||||||
|
after: afterFiles.map((f) => makeResultItem({ filename: f }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountComponent(
|
||||||
|
compareImages: CompareImages,
|
||||||
|
{ attachTo }: { attachTo?: HTMLElement } = {}
|
||||||
|
) {
|
||||||
|
return mount(ImageComparePreview, {
|
||||||
|
attachTo,
|
||||||
|
global: {
|
||||||
|
mocks: {
|
||||||
|
$t: (key: string, params?: Record<string, unknown>) => {
|
||||||
|
if (key === 'batch.index' && params) {
|
||||||
|
return `${params.current} / ${params.total}`
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
props: { compareImages }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountAttached(compareImages: CompareImages) {
|
||||||
|
const host = document.createElement('div')
|
||||||
|
document.body.appendChild(host)
|
||||||
|
const wrapper = mountComponent(compareImages, { attachTo: host })
|
||||||
|
onTestFinished(() => {
|
||||||
|
wrapper.unmount()
|
||||||
|
host.remove()
|
||||||
|
})
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ImageComparePreview', () => {
|
||||||
|
it('renders split view with slider when both sides have images', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
makeCompareImages(['before.png'], ['after.png'])
|
||||||
|
)
|
||||||
|
|
||||||
|
const images = wrapper.findAll('img')
|
||||||
|
expect(images).toHaveLength(2)
|
||||||
|
expect(images[0].attributes('alt')).toBe('imageCompare.altAfter')
|
||||||
|
expect(images[1].attributes('alt')).toBe('imageCompare.altBefore')
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="image-compare-slider"]').exists()).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-testid="batch-nav"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders single image without slider when only one side has images', () => {
|
||||||
|
const before = mountComponent(makeCompareImages(['before.png'], []))
|
||||||
|
expect(before.findAll('img')).toHaveLength(1)
|
||||||
|
expect(before.find('img').attributes('alt')).toBe('imageCompare.altBefore')
|
||||||
|
expect(before.find('[data-testid="image-compare-slider"]').exists()).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
const after = mountComponent(makeCompareImages([], ['after.png']))
|
||||||
|
expect(after.findAll('img')).toHaveLength(1)
|
||||||
|
expect(after.find('img').attributes('alt')).toBe('imageCompare.altAfter')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no-images message when both arrays are empty', () => {
|
||||||
|
const wrapper = mountComponent(makeCompareImages([], []))
|
||||||
|
|
||||||
|
expect(wrapper.findAll('img')).toHaveLength(0)
|
||||||
|
expect(wrapper.text()).toContain('imageCompare.noImages')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows batch nav and navigates when multiple images on a side', async () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
makeCompareImages(['a1.png', 'a2.png', 'a3.png'], ['b1.png'])
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="batch-nav"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
const beforeBatch = wrapper.find('[data-testid="before-batch"]')
|
||||||
|
await beforeBatch.find('[data-testid="batch-next"]').trigger('click')
|
||||||
|
|
||||||
|
expect(beforeBatch.find('[data-testid="batch-counter"]').text()).toBe(
|
||||||
|
'2 / 3'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets slider and aspect ratio when compareImages changes', async () => {
|
||||||
|
const wrapper = mountAttached(makeCompareImages(['a.png'], ['b.png']))
|
||||||
|
|
||||||
|
const container = wrapper.find('[data-testid="image-compare-preview"]')
|
||||||
|
const el = container.element as HTMLElement
|
||||||
|
el.getBoundingClientRect = () =>
|
||||||
|
DOMRect.fromRect({ x: 0, y: 0, width: 200, height: 100 })
|
||||||
|
|
||||||
|
// Set aspect ratio via load event
|
||||||
|
const img = wrapper.find('img')
|
||||||
|
Object.defineProperty(img.element, 'naturalWidth', { value: 800 })
|
||||||
|
Object.defineProperty(img.element, 'naturalHeight', { value: 600 })
|
||||||
|
await img.trigger('load')
|
||||||
|
expect(container.attributes('style')).toContain('800 / 600')
|
||||||
|
|
||||||
|
el.dispatchEvent(new PointerEvent('pointermove', { clientX: 150 }))
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(
|
||||||
|
wrapper.find('[data-testid="image-compare-slider"]').attributes('style')
|
||||||
|
).toContain('left: 75%')
|
||||||
|
|
||||||
|
// Change props — both slider and aspect should reset
|
||||||
|
await wrapper.setProps({
|
||||||
|
compareImages: makeCompareImages(['c.png'], ['d.png'])
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.find('[data-testid="image-compare-slider"]').attributes('style')
|
||||||
|
).toContain('50%')
|
||||||
|
expect(
|
||||||
|
wrapper.find('[data-testid="image-compare-preview"]').attributes('style')
|
||||||
|
).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves slider on pointermove and clamps to 0-100 range', async () => {
|
||||||
|
const wrapper = mountAttached(
|
||||||
|
makeCompareImages(['before.png'], ['after.png'])
|
||||||
|
)
|
||||||
|
|
||||||
|
const container = wrapper.find('[data-testid="image-compare-preview"]')
|
||||||
|
const el = container.element as HTMLElement
|
||||||
|
el.getBoundingClientRect = () =>
|
||||||
|
DOMRect.fromRect({ x: 100, y: 0, width: 200, height: 100 })
|
||||||
|
|
||||||
|
// Wait for component's pointermove listener to be registered
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
// Pointer at 150 → (150-100)/200 = 25%
|
||||||
|
el.dispatchEvent(new PointerEvent('pointermove', { clientX: 150 }))
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const slider = wrapper.find('[data-testid="image-compare-slider"]')
|
||||||
|
expect(slider.attributes('style')).toContain('left: 25%')
|
||||||
|
|
||||||
|
// Pointer before left edge → clamps to 0%
|
||||||
|
el.dispatchEvent(new PointerEvent('pointermove', { clientX: 50 }))
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(slider.attributes('style')).toContain('left: 0%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aspect ratio from image natural dimensions on load', async () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
makeCompareImages(['before.png'], ['after.png'])
|
||||||
|
)
|
||||||
|
|
||||||
|
const img = wrapper.find('img')
|
||||||
|
Object.defineProperty(img.element, 'naturalWidth', { value: 800 })
|
||||||
|
Object.defineProperty(img.element, 'naturalHeight', { value: 600 })
|
||||||
|
await img.trigger('load')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.find('[data-testid="image-compare-preview"]').attributes('style')
|
||||||
|
).toContain('800 / 600')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not set aspect ratio when natural dimensions are zero', async () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
makeCompareImages(['before.png'], ['after.png'])
|
||||||
|
)
|
||||||
|
|
||||||
|
const img = wrapper.find('img')
|
||||||
|
Object.defineProperty(img.element, 'naturalWidth', { value: 0 })
|
||||||
|
Object.defineProperty(img.element, 'naturalHeight', { value: 0 })
|
||||||
|
await img.trigger('load')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.find('[data-testid="image-compare-preview"]').attributes('style')
|
||||||
|
).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps beforeIndex when compareImages shrinks', async () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
makeCompareImages(['a1.png', 'a2.png', 'a3.png'], ['b1.png'])
|
||||||
|
)
|
||||||
|
|
||||||
|
const beforeBatch = wrapper.find('[data-testid="before-batch"]')
|
||||||
|
await beforeBatch.find('[data-testid="batch-next"]').trigger('click')
|
||||||
|
await beforeBatch.find('[data-testid="batch-next"]').trigger('click')
|
||||||
|
expect(beforeBatch.find('[data-testid="batch-counter"]').text()).toBe(
|
||||||
|
'3 / 3'
|
||||||
|
)
|
||||||
|
|
||||||
|
await wrapper.setProps({
|
||||||
|
compareImages: makeCompareImages(['x.png'], ['b1.png'])
|
||||||
|
})
|
||||||
|
|
||||||
|
const beforeImg = wrapper
|
||||||
|
.findAll('img')
|
||||||
|
.find((img) => img.attributes('alt') === 'imageCompare.altBefore')
|
||||||
|
expect(beforeImg?.attributes('src')).toContain('x.png')
|
||||||
|
})
|
||||||
|
})
|
||||||
144
src/renderer/extensions/linearMode/ImageComparePreview.vue
Normal file
144
src/renderer/extensions/linearMode/ImageComparePreview.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useEventListener } from '@vueuse/core'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import BatchNavigation from '@/renderer/extensions/vueNodes/widgets/components/BatchNavigation.vue'
|
||||||
|
import type { CompareImages } from '@/stores/queueStore'
|
||||||
|
|
||||||
|
const { compareImages } = defineProps<{
|
||||||
|
compareImages: CompareImages
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
const beforeIndex = ref(0)
|
||||||
|
const afterIndex = ref(0)
|
||||||
|
const imageAspect = ref('')
|
||||||
|
|
||||||
|
function onImageLoad(e: Event) {
|
||||||
|
const img = e.target as HTMLImageElement
|
||||||
|
if (img.naturalWidth && img.naturalHeight) {
|
||||||
|
imageAspect.value = `${img.naturalWidth} / ${img.naturalHeight}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => compareImages,
|
||||||
|
() => {
|
||||||
|
beforeIndex.value = 0
|
||||||
|
afterIndex.value = 0
|
||||||
|
sliderPosition.value = 50
|
||||||
|
imageAspect.value = ''
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const sliderPosition = ref(50)
|
||||||
|
|
||||||
|
function updateSlider(e: PointerEvent) {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) return
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
sliderPosition.value = Math.max(0, Math.min(100, (x / rect.width) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEventListener(containerRef, 'pointermove', updateSlider)
|
||||||
|
useEventListener(containerRef, 'pointerleave', updateSlider)
|
||||||
|
|
||||||
|
const showBatchNav = computed(
|
||||||
|
() => compareImages.before.length > 1 || compareImages.after.length > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
const beforeUrl = computed(() => {
|
||||||
|
const idx = Math.min(beforeIndex.value, compareImages.before.length - 1)
|
||||||
|
return compareImages.before[Math.max(0, idx)]?.url ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const afterUrl = computed(() => {
|
||||||
|
const idx = Math.min(afterIndex.value, compareImages.after.length - 1)
|
||||||
|
return compareImages.after[Math.max(0, idx)]?.url ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasCompareImages = computed(() =>
|
||||||
|
Boolean(beforeUrl.value && afterUrl.value)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex size-full flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
v-if="beforeUrl || afterUrl"
|
||||||
|
class="flex min-h-0 flex-1 items-center justify-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
data-testid="image-compare-preview"
|
||||||
|
class="relative h-full max-w-full cursor-col-resize"
|
||||||
|
:style="imageAspect ? { aspectRatio: imageAspect } : undefined"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="afterUrl || beforeUrl"
|
||||||
|
:alt="
|
||||||
|
afterUrl
|
||||||
|
? $t('imageCompare.altAfter')
|
||||||
|
: $t('imageCompare.altBefore')
|
||||||
|
"
|
||||||
|
draggable="false"
|
||||||
|
class="block size-full"
|
||||||
|
@load="onImageLoad"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-if="hasCompareImages"
|
||||||
|
:src="beforeUrl"
|
||||||
|
:alt="$t('imageCompare.altBefore')"
|
||||||
|
draggable="false"
|
||||||
|
class="absolute inset-0 size-full object-cover"
|
||||||
|
:style="{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="hasCompareImages"
|
||||||
|
class="pointer-events-none absolute top-0 z-10 h-full w-0.5 -translate-x-1/2 bg-white/80"
|
||||||
|
:style="{ left: `${sliderPosition}%` }"
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="hasCompareImages"
|
||||||
|
data-testid="image-compare-slider"
|
||||||
|
class="pointer-events-none absolute top-1/2 z-10 size-6 -translate-1/2 rounded-full border-2 border-white bg-white/30 shadow-lg backdrop-blur-sm"
|
||||||
|
:style="{ left: `${sliderPosition}%` }"
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ $t('imageCompare.noImages') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showBatchNav"
|
||||||
|
class="flex shrink-0 justify-between px-4 py-2 text-xs"
|
||||||
|
data-testid="batch-nav"
|
||||||
|
>
|
||||||
|
<BatchNavigation
|
||||||
|
v-model="beforeIndex"
|
||||||
|
:count="compareImages.before.length"
|
||||||
|
data-testid="before-batch"
|
||||||
|
>
|
||||||
|
<template #label>{{ $t('imageCompare.batchLabelA') }}</template>
|
||||||
|
</BatchNavigation>
|
||||||
|
|
||||||
|
<BatchNavigation
|
||||||
|
v-model="afterIndex"
|
||||||
|
:count="compareImages.after.length"
|
||||||
|
data-testid="after-batch"
|
||||||
|
>
|
||||||
|
<template #label>{{ $t('imageCompare.batchLabelB') }}</template>
|
||||||
|
</BatchNavigation>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -5,7 +5,7 @@ import { storeToRefs } from 'pinia'
|
|||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useAppMode } from '@/composables/useAppMode'
|
import { useAppMode } from '@/composables/useAppMode'
|
||||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
import { parseNodeOutput } from '@/stores/resultItemParsing'
|
||||||
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
|
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
|
||||||
import { useAppModeStore } from '@/stores/appModeStore'
|
import { useAppModeStore } from '@/stores/appModeStore'
|
||||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||||
@@ -23,7 +23,7 @@ const existingOutput = computed(() => {
|
|||||||
const locatorId = nodeIdToNodeLocatorId(nodeId)
|
const locatorId = nodeIdToNodeLocatorId(nodeId)
|
||||||
const nodeOutput = nodeOutputStore.nodeOutputs[locatorId]
|
const nodeOutput = nodeOutputStore.nodeOutputs[locatorId]
|
||||||
if (!nodeOutput) continue
|
if (!nodeOutput) continue
|
||||||
const results = flattenNodeOutput([nodeId, nodeOutput])
|
const results = parseNodeOutput(nodeId, nodeOutput)
|
||||||
if (results.length > 0) return results[0]
|
if (results.length > 0) return results[0]
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ function handleSelection(sel: OutputSelection) {
|
|||||||
showSkeleton.value = sel.showSkeleton ?? false
|
showSkeleton.value = sel.showSkeleton ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadOutput(output?: ResultItemImpl) {
|
||||||
|
if (!output) return
|
||||||
|
if (output.compareImages) {
|
||||||
|
for (const img of [
|
||||||
|
...output.compareImages.before,
|
||||||
|
...output.compareImages.after
|
||||||
|
]) {
|
||||||
|
downloadFile(img.url, img.filename)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (output.url) downloadFile(output.url, output.filename)
|
||||||
|
}
|
||||||
|
|
||||||
function downloadAsset(item?: AssetItem) {
|
function downloadAsset(item?: AssetItem) {
|
||||||
for (const output of allOutputs(item))
|
for (const output of allOutputs(item))
|
||||||
downloadFile(output.url, output.filename)
|
downloadFile(output.url, output.filename)
|
||||||
@@ -93,16 +107,13 @@ async function rerun(e: Event) {
|
|||||||
v-tooltip.top="t('g.download')"
|
v-tooltip.top="t('g.download')"
|
||||||
size="icon"
|
size="icon"
|
||||||
:aria-label="t('g.download')"
|
:aria-label="t('g.download')"
|
||||||
@click="
|
@click="() => downloadOutput(selectedOutput)"
|
||||||
() => {
|
|
||||||
if (selectedOutput?.url) downloadFile(selectedOutput.url)
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--download]" />
|
<i class="icon-[lucide--download]" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="isWorkflowActive && !selectedItem"
|
v-if="isWorkflowActive && !selectedItem"
|
||||||
|
data-testid="linear-cancel-run"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@click="cancelActiveWorkflowJobs()"
|
@click="cancelActiveWorkflowJobs()"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const executionStore = useExecutionStore()
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
v-bind="$attrs"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'relative h-2 bg-secondary-background transition-opacity',
|
'relative h-2 bg-secondary-background transition-opacity',
|
||||||
@@ -32,11 +33,13 @@ const executionStore = useExecutionStore()
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
data-testid="linear-progress-overall"
|
||||||
class="absolute inset-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
|
class="absolute inset-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
|
||||||
:class="cn(rounded && 'rounded-sm')"
|
:class="cn(rounded && 'rounded-sm')"
|
||||||
:style="{ width: `${totalPercent}%`, opacity: overallOpacity }"
|
:style="{ width: `${totalPercent}%`, opacity: overallOpacity }"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
data-testid="linear-progress-node"
|
||||||
class="absolute inset-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
|
class="absolute inset-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
|
||||||
:class="cn(rounded && 'rounded-sm')"
|
:class="cn(rounded && 'rounded-sm')"
|
||||||
:style="{ width: `${currentNodePercent}%`, opacity: activeOpacity }"
|
:style="{ width: `${currentNodePercent}%`, opacity: activeOpacity }"
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ const hasNodes = ref(false)
|
|||||||
const hasOutputs = ref(false)
|
const hasOutputs = ref(false)
|
||||||
const enterBuilder = vi.fn()
|
const enterBuilder = vi.fn()
|
||||||
|
|
||||||
|
const { setModeFn, showFn } = vi.hoisted(() => ({
|
||||||
|
setModeFn: vi.fn(),
|
||||||
|
showFn: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useAppMode', () => ({
|
vi.mock('@/composables/useAppMode', () => ({
|
||||||
useAppMode: () => ({ setMode: vi.fn() })
|
useAppMode: () => ({ setMode: setModeFn })
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
|
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
|
||||||
useWorkflowTemplateSelectorDialog: () => ({ show: vi.fn() })
|
useWorkflowTemplateSelectorDialog: () => ({ show: showFn })
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores/appModeStore', () => ({
|
vi.mock('@/stores/appModeStore', () => ({
|
||||||
@@ -47,7 +52,7 @@ describe('LinearWelcome', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hasNodes.value = false
|
hasNodes.value = false
|
||||||
hasOutputs.value = false
|
hasOutputs.value = false
|
||||||
vi.clearAllMocks()
|
vi.resetAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows empty workflow text when there are no nodes', () => {
|
it('shows empty workflow text when there are no nodes', () => {
|
||||||
@@ -77,4 +82,29 @@ describe('LinearWelcome', () => {
|
|||||||
.trigger('click')
|
.trigger('click')
|
||||||
expect(enterBuilder).toHaveBeenCalled()
|
expect(enterBuilder).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows getStarted content when hasOutputs is true', () => {
|
||||||
|
const wrapper = mountComponent({ hasOutputs: true })
|
||||||
|
expect(wrapper.text()).toContain('linearMode.welcome.getStarted')
|
||||||
|
expect(
|
||||||
|
wrapper.find('[data-testid="linear-welcome-back-to-workflow"]').exists()
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows load template button when hasNodes is false and clicking calls show', async () => {
|
||||||
|
const wrapper = mountComponent({ hasNodes: false })
|
||||||
|
const loadBtn = wrapper.find('[data-testid="linear-welcome-load-template"]')
|
||||||
|
expect(loadBtn.exists()).toBe(true)
|
||||||
|
|
||||||
|
await loadBtn.trigger('click')
|
||||||
|
expect(showFn).toHaveBeenCalledWith('appbuilder')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('back-to-workflow button calls setMode with graph', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
await wrapper
|
||||||
|
.find('[data-testid="linear-welcome-back-to-workflow"]')
|
||||||
|
.trigger('click')
|
||||||
|
expect(setModeFn).toHaveBeenCalledWith('graph')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineAsyncComponent, useAttrs } from 'vue'
|
import { computed, defineAsyncComponent, useAttrs } from 'vue'
|
||||||
|
|
||||||
|
import ImageComparePreview from '@/renderer/extensions/linearMode/ImageComparePreview.vue'
|
||||||
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||||
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
||||||
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
|
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||||
@@ -25,7 +26,12 @@ const outputLabel = computed(
|
|||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<template v-if="mediaType === 'images' || mediaType === 'video'">
|
<ImageComparePreview
|
||||||
|
v-if="mediaType === 'image_compare' && output.compareImages"
|
||||||
|
:class="cn('flex-1', attrs.class as string)"
|
||||||
|
:compare-images="output.compareImages"
|
||||||
|
/>
|
||||||
|
<template v-else-if="mediaType === 'images' || mediaType === 'video'">
|
||||||
<ImagePreview
|
<ImagePreview
|
||||||
v-if="mediaType === 'images'"
|
v-if="mediaType === 'images'"
|
||||||
:class="attrs.class as string"
|
:class="attrs.class as string"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
nextTick,
|
nextTick,
|
||||||
ref,
|
ref,
|
||||||
toValue,
|
|
||||||
useTemplateRef,
|
useTemplateRef,
|
||||||
watch,
|
watch,
|
||||||
watchEffect
|
watchEffect
|
||||||
@@ -31,8 +30,13 @@ import { useAppModeStore } from '@/stores/appModeStore'
|
|||||||
import { useQueueStore } from '@/stores/queueStore'
|
import { useQueueStore } from '@/stores/queueStore'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
|
const {
|
||||||
useOutputHistory()
|
outputs,
|
||||||
|
allOutputs,
|
||||||
|
timeline,
|
||||||
|
selectFirstHistory,
|
||||||
|
mayBeActiveWorkflowPending
|
||||||
|
} = useOutputHistory()
|
||||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||||
const queueStore = useQueueStore()
|
const queueStore = useQueueStore()
|
||||||
const store = useLinearOutputStore()
|
const store = useLinearOutputStore()
|
||||||
@@ -55,10 +59,6 @@ const hasActiveContent = computed(
|
|||||||
() => store.activeWorkflowInProgressItems.length > 0
|
() => store.activeWorkflowInProgressItems.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
const visibleHistory = computed(() =>
|
|
||||||
outputs.media.value.filter((a) => allOutputs(a).length > 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
const selectableItems = computed(() => {
|
const selectableItems = computed(() => {
|
||||||
const items: SelectionValue[] = []
|
const items: SelectionValue[] = []
|
||||||
if (mayBeActiveWorkflowPending.value) {
|
if (mayBeActiveWorkflowPending.value) {
|
||||||
@@ -71,16 +71,8 @@ const selectableItems = computed(() => {
|
|||||||
itemId: item.id
|
itemId: item.id
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for (const asset of outputs.media.value) {
|
for (const entry of timeline.value) {
|
||||||
const outs = allOutputs(asset)
|
items.push(entry.selectionValue)
|
||||||
for (let k = 0; k < outs.length; k++) {
|
|
||||||
items.push({
|
|
||||||
id: `history:${asset.id}:${k}`,
|
|
||||||
kind: 'history',
|
|
||||||
assetId: asset.id,
|
|
||||||
key: k
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
@@ -137,6 +129,16 @@ function doEmit() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (sel.kind === 'nonAsset') {
|
||||||
|
const entry = store.activeWorkflowNonAssetOutputs.find(
|
||||||
|
(e) => e.id === sel.itemId
|
||||||
|
)
|
||||||
|
emit('updateSelection', {
|
||||||
|
output: entry?.output,
|
||||||
|
canShowPreview: true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
const asset = outputs.media.value.find((a) => a.id === sel.assetId)
|
const asset = outputs.media.value.find((a) => a.id === sel.assetId)
|
||||||
const output = asset ? allOutputs(asset)[sel.key] : undefined
|
const output = asset ? allOutputs(asset)[sel.key] : undefined
|
||||||
const isFirst = outputs.media.value[0]?.id === sel.assetId
|
const isFirst = outputs.media.value[0]?.id === sel.assetId
|
||||||
@@ -184,14 +186,24 @@ watch(
|
|||||||
? selectionMap.value.get(store.selectedId)
|
? selectionMap.value.get(store.selectedId)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
if (!sv || sv.kind !== 'history') {
|
if (!sv || (sv.kind !== 'history' && sv.kind !== 'nonAsset')) {
|
||||||
|
if (hasOutputs.value) selectFirstHistory()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-asset selections are stable — don't override them
|
||||||
|
if (sv.kind === 'nonAsset') return
|
||||||
|
|
||||||
|
const assetGone = !newAssets.some((a) => a.id === sv.assetId)
|
||||||
|
if (assetGone) {
|
||||||
if (hasOutputs.value) selectFirstHistory()
|
if (hasOutputs.value) selectFirstHistory()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const wasFirst = sv.assetId === oldAssets[0]?.id
|
const wasFirst = sv.assetId === oldAssets[0]?.id
|
||||||
if (wasFirst || !newAssets.some((a) => a.id === sv.assetId)) {
|
if (wasFirst && hasOutputs.value) {
|
||||||
if (hasOutputs.value) selectFirstHistory()
|
const firstId = `history:${newAssets[0].id}:0`
|
||||||
|
store.autoSelectLatest(firstId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -204,7 +216,11 @@ useResizeObserver(outputsRef, () => {
|
|||||||
lastScrollWidth = outputsRef.value?.scrollWidth ?? 0
|
lastScrollWidth = outputsRef.value?.scrollWidth ?? 0
|
||||||
})
|
})
|
||||||
watch(
|
watch(
|
||||||
() => visibleHistory.value[0]?.id,
|
[
|
||||||
|
() => store.activeWorkflowInProgressItems.length,
|
||||||
|
() => timeline.value[0]?.id,
|
||||||
|
queueCount
|
||||||
|
],
|
||||||
() => {
|
() => {
|
||||||
const el = outputsRef.value
|
const el = outputsRef.value
|
||||||
if (!el || el.scrollLeft === 0) {
|
if (!el || el.scrollLeft === 0) {
|
||||||
@@ -327,6 +343,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
|||||||
:key="`${item.id}-${item.state}`"
|
:key="`${item.id}-${item.state}`"
|
||||||
:ref="selectedRef(`slot:${item.id}`)"
|
:ref="selectedRef(`slot:${item.id}`)"
|
||||||
v-bind="itemAttrs(`slot:${item.id}`)"
|
v-bind="itemAttrs(`slot:${item.id}`)"
|
||||||
|
data-testid="linear-in-progress-item"
|
||||||
:class="itemClass"
|
:class="itemClass"
|
||||||
@click="store.select(`slot:${item.id}`)"
|
@click="store.select(`slot:${item.id}`)"
|
||||||
>
|
>
|
||||||
@@ -338,7 +355,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="hasActiveContent && visibleHistory.length > 0"
|
v-if="hasActiveContent && timeline.length > 0"
|
||||||
class="mx-4 h-12 shrink-0 border-l border-border-default"
|
class="mx-4 h-12 shrink-0 border-l border-border-default"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,20 +366,20 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
|||||||
class="min-w-0 overflow-x-auto overflow-y-clip"
|
class="min-w-0 overflow-x-auto overflow-y-clip"
|
||||||
>
|
>
|
||||||
<div class="flex h-15 w-fit items-start gap-0.5">
|
<div class="flex h-15 w-fit items-start gap-0.5">
|
||||||
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">
|
<template v-for="(item, idx) in timeline" :key="item.id">
|
||||||
<div
|
<div
|
||||||
v-if="aIdx > 0"
|
v-if="idx > 0 && item.groupKey !== timeline[idx - 1].groupKey"
|
||||||
class="mx-4 h-12 shrink-0 border-l border-border-default"
|
class="mx-4 h-12 shrink-0 border-l border-border-default"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-for="(output, key) in toValue(allOutputs(asset))"
|
:ref="selectedRef(item.id)"
|
||||||
:key
|
v-bind="itemAttrs(item.id)"
|
||||||
:ref="selectedRef(`history:${asset.id}:${key}`)"
|
data-testid="linear-history-item"
|
||||||
v-bind="itemAttrs(`history:${asset.id}:${key}`)"
|
:data-item-kind="item.selectionValue.kind"
|
||||||
:class="itemClass"
|
:class="itemClass"
|
||||||
@click="store.select(`history:${asset.id}:${key}`)"
|
@click="store.select(item.id)"
|
||||||
>
|
>
|
||||||
<OutputHistoryItem :output="output" />
|
<OutputHistoryItem :output="item.output" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,44 +1,122 @@
|
|||||||
import { createTestingPinia } from '@pinia/testing'
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
import { shallowMount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { setActivePinia } from 'pinia'
|
import { setActivePinia } from 'pinia'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Loader from '@/components/loader/Loader.vue'
|
||||||
|
|
||||||
import OutputHistoryActiveQueueItem from './OutputHistoryActiveQueueItem.vue'
|
import OutputHistoryActiveQueueItem from './OutputHistoryActiveQueueItem.vue'
|
||||||
|
|
||||||
const i18n = createI18n({ legacy: false, locale: 'en' })
|
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
|
||||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
||||||
|
const { executeFn, runningTasksRef } = vi.hoisted(() => ({
|
||||||
|
executeFn: vi.fn(),
|
||||||
|
runningTasksRef: { value: [] as Array<{ jobId: string }> }
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores/commandStore', () => ({
|
vi.mock('@/stores/commandStore', () => ({
|
||||||
useCommandStore: () => ({
|
useCommandStore: () => ({
|
||||||
execute: vi.fn()
|
execute: executeFn
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/queueStore', () => ({
|
||||||
|
useQueueStore: () => ({
|
||||||
|
get runningTasks() {
|
||||||
|
return runningTasksRef.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const closeFn = vi.fn()
|
||||||
|
|
||||||
|
// Stub Popover to render both slots inline (no portal) so we can test content
|
||||||
|
const PopoverStub = {
|
||||||
|
setup() {
|
||||||
|
return { closeFn }
|
||||||
|
},
|
||||||
|
template: `<div>
|
||||||
|
<slot name="button" />
|
||||||
|
<slot :close="closeFn" />
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
function mountComponent(queueCount: number) {
|
function mountComponent(queueCount: number) {
|
||||||
return shallowMount(OutputHistoryActiveQueueItem, {
|
return mount(OutputHistoryActiveQueueItem, {
|
||||||
props: { queueCount },
|
props: { queueCount },
|
||||||
global: { plugins: [i18n] }
|
global: {
|
||||||
|
plugins: [i18n],
|
||||||
|
directives: { tooltip: {} },
|
||||||
|
stubs: { Popover: PopoverStub }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('OutputHistoryActiveQueueItem', () => {
|
describe('OutputHistoryActiveQueueItem', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||||
|
runningTasksRef.value = []
|
||||||
|
vi.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
it('hides badge when queueCount is 1', () => {
|
it('hides badge when queueCount is 1', () => {
|
||||||
const wrapper = mountComponent(1)
|
const wrapper = mountComponent(1)
|
||||||
const badge = wrapper.find('[aria-hidden="true"]')
|
const badge = wrapper.find('[data-testid="linear-job-badge"]')
|
||||||
expect(badge.exists()).toBe(false)
|
expect(badge.exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows badge with correct count when queueCount is 3', () => {
|
it('shows badge with correct count when queueCount is 3', () => {
|
||||||
const wrapper = mountComponent(3)
|
const wrapper = mountComponent(3)
|
||||||
const badge = wrapper.find('[aria-hidden="true"]')
|
const badge = wrapper.find('[data-testid="linear-job-badge"]')
|
||||||
expect(badge.exists()).toBe(true)
|
expect(badge.exists()).toBe(true)
|
||||||
expect(badge.text()).toBe('3')
|
expect(badge.text()).toBe('3')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders Loader with loader-circle variant when running tasks exist', () => {
|
||||||
|
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||||
|
const wrapper = mountComponent(1)
|
||||||
|
|
||||||
|
const loader = wrapper.findComponent(Loader)
|
||||||
|
expect(loader.exists()).toBe(true)
|
||||||
|
expect(loader.props('variant')).toBe('loader-circle')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Loader with loader variant when no running tasks', () => {
|
||||||
|
runningTasksRef.value = []
|
||||||
|
const wrapper = mountComponent(1)
|
||||||
|
|
||||||
|
const loader = wrapper.findComponent(Loader)
|
||||||
|
expect(loader.exists()).toBe(true)
|
||||||
|
expect(loader.props('variant')).toBe('loader')
|
||||||
|
})
|
||||||
|
|
||||||
it('hides badge when queueCount is 0', () => {
|
it('hides badge when queueCount is 0', () => {
|
||||||
const wrapper = mountComponent(0)
|
const wrapper = mountComponent(0)
|
||||||
const badge = wrapper.find('[aria-hidden="true"]')
|
const badge = wrapper.find('[data-testid="linear-job-badge"]')
|
||||||
expect(badge.exists()).toBe(false)
|
expect(badge.exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clicking clear button calls ClearPendingTasks command', async () => {
|
||||||
|
const wrapper = mountComponent(3)
|
||||||
|
|
||||||
|
const clearButton = wrapper.find(
|
||||||
|
'[data-testid="linear-queue-clear-button"]'
|
||||||
|
)
|
||||||
|
expect(clearButton.exists()).toBe(true)
|
||||||
|
await clearButton.trigger('click')
|
||||||
|
|
||||||
|
expect(executeFn).toHaveBeenCalledWith('Comfy.ClearPendingTasks')
|
||||||
|
expect(closeFn).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clear button is disabled when queueCount is 0', () => {
|
||||||
|
const wrapper = mountComponent(0)
|
||||||
|
|
||||||
|
const clearButton = wrapper.find(
|
||||||
|
'[data-testid="linear-queue-clear-button"]'
|
||||||
|
)
|
||||||
|
expect(clearButton.exists()).toBe(true)
|
||||||
|
expect(clearButton.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function clearQueue(close: () => void) {
|
|||||||
:disabled="queueCount === 0"
|
:disabled="queueCount === 0"
|
||||||
variant="textonly"
|
variant="textonly"
|
||||||
class="px-4 text-sm text-destructive-background"
|
class="px-4 text-sm text-destructive-background"
|
||||||
|
data-testid="linear-queue-clear-button"
|
||||||
@click="clearQueue(close)"
|
@click="clearQueue(close)"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--list-x]" />
|
<i class="icon-[lucide--list-x]" />
|
||||||
@@ -58,6 +59,7 @@ function clearQueue(close: () => void) {
|
|||||||
<div
|
<div
|
||||||
v-if="queueCount > 1"
|
v-if="queueCount > 1"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
data-testid="linear-job-badge"
|
||||||
class="absolute top-0 right-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary-background text-xs text-text-primary"
|
class="absolute top-0 right-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary-background text-xs text-text-primary"
|
||||||
v-text="queueCount"
|
v-text="queueCount"
|
||||||
/>
|
/>
|
||||||
|
|||||||
104
src/renderer/extensions/linearMode/OutputHistoryItem.test.ts
Normal file
104
src/renderer/extensions/linearMode/OutputHistoryItem.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
|
||||||
|
import { makeResultItem } from '@/renderer/extensions/linearMode/__fixtures__/testResultItemFactory'
|
||||||
|
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
|
function mountComponent(output: ResultItemImpl) {
|
||||||
|
return mount(OutputHistoryItem, {
|
||||||
|
props: { output }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OutputHistoryItem', () => {
|
||||||
|
it('renders split 50/50 thumbnail for image_compare items', () => {
|
||||||
|
const before = [makeResultItem({ filename: 'before.png' })]
|
||||||
|
const after = [makeResultItem({ filename: 'after.png' })]
|
||||||
|
const output = makeResultItem({
|
||||||
|
mediaType: 'image_compare',
|
||||||
|
compareImages: { before, after }
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mountComponent(output)
|
||||||
|
|
||||||
|
const images = wrapper.findAll('img')
|
||||||
|
expect(images).toHaveLength(2)
|
||||||
|
expect(images[0].attributes('src')).toContain('before.png')
|
||||||
|
expect(images[1].attributes('src')).toContain('after.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders image thumbnail for regular image items', () => {
|
||||||
|
const output = makeResultItem({ filename: 'photo.png' })
|
||||||
|
|
||||||
|
const wrapper = mountComponent(output)
|
||||||
|
|
||||||
|
const img = wrapper.find('img')
|
||||||
|
expect(img.exists()).toBe(true)
|
||||||
|
expect(img.attributes('src')).toContain('photo.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders video element for video output', () => {
|
||||||
|
const output = makeResultItem({ filename: 'clip.mp4', mediaType: 'video' })
|
||||||
|
|
||||||
|
const wrapper = mountComponent(output)
|
||||||
|
|
||||||
|
const video = wrapper.find('[data-testid="linear-video-output"]')
|
||||||
|
expect(video.exists()).toBe(true)
|
||||||
|
expect(video.element.tagName).toBe('VIDEO')
|
||||||
|
expect(video.attributes('src')).toContain('clip.mp4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders fallback icon when image_compare has no compareImages', () => {
|
||||||
|
const output = makeResultItem({ mediaType: 'image_compare' })
|
||||||
|
|
||||||
|
const wrapper = mountComponent(output)
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="linear-compare-output"]').exists()).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
const icon = wrapper.find('i')
|
||||||
|
expect(icon.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders single image when only before side of compare has data', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
makeResultItem({
|
||||||
|
mediaType: 'image_compare',
|
||||||
|
compareImages: {
|
||||||
|
before: [makeResultItem({ filename: 'before.png' })],
|
||||||
|
after: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(wrapper.findAll('img')).toHaveLength(1)
|
||||||
|
expect(wrapper.find('img').attributes('src')).toContain('before.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders single image when only after side of compare has data', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
makeResultItem({
|
||||||
|
mediaType: 'image_compare',
|
||||||
|
compareImages: {
|
||||||
|
before: [],
|
||||||
|
after: [makeResultItem({ filename: 'after.png' })]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(wrapper.findAll('img')).toHaveLength(1)
|
||||||
|
expect(wrapper.find('img').attributes('src')).toContain('after.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.for([
|
||||||
|
{ mediaType: 'audio', filename: 'sound.mp3' },
|
||||||
|
{ mediaType: 'text', filename: 'notes.txt' },
|
||||||
|
{ mediaType: '3d', filename: 'model.glb' }
|
||||||
|
])(
|
||||||
|
'renders fallback icon for $mediaType media type',
|
||||||
|
({ mediaType, filename }) => {
|
||||||
|
const wrapper = mountComponent(makeResultItem({ filename, mediaType }))
|
||||||
|
const icon = wrapper.find('i')
|
||||||
|
expect(icon.exists()).toBe(true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getMediaType,
|
getMediaType,
|
||||||
mediaTypes
|
mediaTypes
|
||||||
@@ -11,18 +13,42 @@ import VideoPlayOverlay from '@/platform/assets/components/VideoPlayOverlay.vue'
|
|||||||
const { output } = defineProps<{
|
const { output } = defineProps<{
|
||||||
output: ResultItemImpl
|
output: ResultItemImpl
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const mediaType = computed(() => getMediaType(output))
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="mediaType === 'image_compare' && output.compareImages"
|
||||||
|
data-testid="linear-compare-output"
|
||||||
|
class="relative block size-10 overflow-hidden rounded-sm bg-secondary-background"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="output.compareImages.before[0]"
|
||||||
|
class="absolute inset-0 size-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
:src="output.compareImages.before[0].url"
|
||||||
|
:style="{ clipPath: 'inset(0 50% 0 0)' }"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="output.compareImages.after[0]"
|
||||||
|
class="absolute inset-0 size-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
:src="output.compareImages.after[0].url"
|
||||||
|
:style="{ clipPath: 'inset(0 0 0 50%)' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<img
|
<img
|
||||||
v-if="getMediaType(output) === 'images'"
|
v-else-if="mediaType === 'images'"
|
||||||
|
data-testid="linear-image-output"
|
||||||
class="block size-10 rounded-sm bg-secondary-background object-cover"
|
class="block size-10 rounded-sm bg-secondary-background object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
width="40"
|
width="40"
|
||||||
height="40"
|
height="40"
|
||||||
:src="output.url"
|
:src="output.url"
|
||||||
/>
|
/>
|
||||||
<template v-else-if="getMediaType(output) === 'video'">
|
<template v-else-if="mediaType === 'video'">
|
||||||
<video
|
<video
|
||||||
|
data-testid="linear-video-output"
|
||||||
class="pointer-events-none block size-10 rounded-sm bg-secondary-background object-cover"
|
class="pointer-events-none block size-10 rounded-sm bg-secondary-background object-cover"
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
width="40"
|
width="40"
|
||||||
@@ -31,8 +57,5 @@ const { output } = defineProps<{
|
|||||||
/>
|
/>
|
||||||
<VideoPlayOverlay size="sm" />
|
<VideoPlayOverlay size="sm" />
|
||||||
</template>
|
</template>
|
||||||
<i
|
<i v-else :class="cn(mediaTypes[mediaType]?.iconClass, 'block size-10')" />
|
||||||
v-else
|
|
||||||
:class="cn(mediaTypes[getMediaType(output)]?.iconClass, 'block size-10')"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,10 +9,19 @@ const { latentPreview } = defineProps<{
|
|||||||
<div class="w-10">
|
<div class="w-10">
|
||||||
<img
|
<img
|
||||||
v-if="latentPreview"
|
v-if="latentPreview"
|
||||||
|
data-testid="linear-latent-preview"
|
||||||
class="block size-10 rounded-sm object-cover"
|
class="block size-10 rounded-sm object-cover"
|
||||||
:src="latentPreview"
|
:src="latentPreview"
|
||||||
/>
|
/>
|
||||||
<div v-else class="skeleton-shimmer size-10 rounded-sm" />
|
<div
|
||||||
<LinearProgressBar class="mt-1 h-1 w-10" rounded />
|
v-else
|
||||||
|
data-testid="linear-skeleton"
|
||||||
|
class="skeleton-shimmer size-10 rounded-sm"
|
||||||
|
/>
|
||||||
|
<LinearProgressBar
|
||||||
|
data-testid="linear-item-progress-bar"
|
||||||
|
class="mt-1 h-1 w-10"
|
||||||
|
rounded
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||||
|
import type { CompareImages } from '@/stores/queueStore'
|
||||||
|
import { ResultItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
|
export function makeResultItem(opts: {
|
||||||
|
filename?: string
|
||||||
|
mediaType?: string
|
||||||
|
nodeId?: string
|
||||||
|
subfolder?: string
|
||||||
|
type?: ResultItemType
|
||||||
|
compareImages?: CompareImages
|
||||||
|
}): ResultItemImpl {
|
||||||
|
return new ResultItemImpl({
|
||||||
|
filename: opts.filename ?? '',
|
||||||
|
subfolder: opts.subfolder ?? '',
|
||||||
|
type: opts.type ?? 'output',
|
||||||
|
mediaType: opts.mediaType ?? 'images',
|
||||||
|
nodeId: opts.nodeId ?? '1',
|
||||||
|
compareImages: opts.compareImages
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { fromPartial } from '@total-typescript/shoehorn'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
|
|
||||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
|
||||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
|
||||||
|
|
||||||
function makeOutput(
|
|
||||||
overrides: Partial<NodeExecutionOutput> = {}
|
|
||||||
): NodeExecutionOutput {
|
|
||||||
return { ...overrides }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe(flattenNodeOutput, () => {
|
|
||||||
it('returns empty array for output with no known media types', () => {
|
|
||||||
const result = flattenNodeOutput(['1', makeOutput({ unknown: 'hello' })])
|
|
||||||
expect(result).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('flattens images into ResultItemImpl instances', () => {
|
|
||||||
const output = makeOutput({
|
|
||||||
images: [
|
|
||||||
{ filename: 'a.png', subfolder: '', type: 'output' },
|
|
||||||
{ filename: 'b.png', subfolder: 'sub', type: 'output' }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = flattenNodeOutput(['42', output])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[0].filename).toBe('a.png')
|
|
||||||
expect(result[0].nodeId).toBe('42')
|
|
||||||
expect(result[0].mediaType).toBe('images')
|
|
||||||
expect(result[1].filename).toBe('b.png')
|
|
||||||
expect(result[1].subfolder).toBe('sub')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('flattens audio outputs', () => {
|
|
||||||
const output = makeOutput({
|
|
||||||
audio: [{ filename: 'sound.wav', subfolder: '', type: 'output' }]
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = flattenNodeOutput([7, output])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0].mediaType).toBe('audio')
|
|
||||||
expect(result[0].nodeId).toBe(7)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('flattens multiple media types in a single output', () => {
|
|
||||||
const output = makeOutput({
|
|
||||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
|
||||||
video: [{ filename: 'vid.mp4', subfolder: '', type: 'output' }]
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = flattenNodeOutput(['1', output])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
const types = result.map((r) => r.mediaType)
|
|
||||||
expect(types).toContain('images')
|
|
||||||
expect(types).toContain('video')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles gifs and 3d output types', () => {
|
|
||||||
const output = makeOutput({
|
|
||||||
gifs: [
|
|
||||||
{ filename: 'anim.gif', subfolder: '', type: 'output' }
|
|
||||||
] as NodeExecutionOutput['gifs'],
|
|
||||||
'3d': [
|
|
||||||
{ filename: 'model.glb', subfolder: '', type: 'output' }
|
|
||||||
] as NodeExecutionOutput['3d']
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = flattenNodeOutput(['5', output])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
const types = result.map((r) => r.mediaType)
|
|
||||||
expect(types).toContain('gifs')
|
|
||||||
expect(types).toContain('3d')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignores empty arrays', () => {
|
|
||||||
const output = makeOutput({ images: [], audio: [] })
|
|
||||||
const result = flattenNodeOutput(['1', output])
|
|
||||||
expect(result).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('flattens non-standard output keys with ResultItem-like values', () => {
|
|
||||||
const output = makeOutput(
|
|
||||||
fromPartial<NodeExecutionOutput>({
|
|
||||||
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
|
|
||||||
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = flattenNodeOutput(['10', output])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result.map((r) => r.filename)).toContain('before.png')
|
|
||||||
expect(result.map((r) => r.filename)).toContain('after.png')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('excludes animated key', () => {
|
|
||||||
const output = makeOutput({
|
|
||||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
|
||||||
animated: [true]
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = flattenNodeOutput(['1', output])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0].mediaType).toBe('images')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('excludes non-ResultItem array items', () => {
|
|
||||||
const output = fromPartial<NodeExecutionOutput>({
|
|
||||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
|
||||||
custom_data: [{ randomKey: 123 }]
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = flattenNodeOutput(['1', output])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0].mediaType).toBe('images')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts items with filename but no subfolder', () => {
|
|
||||||
const output = fromPartial<NodeExecutionOutput>({
|
|
||||||
images: [
|
|
||||||
{ filename: 'valid.png', subfolder: '', type: 'output' },
|
|
||||||
{ filename: 'no-subfolder.png' }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = flattenNodeOutput(['1', output])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[0].filename).toBe('valid.png')
|
|
||||||
expect(result[1].filename).toBe('no-subfolder.png')
|
|
||||||
expect(result[1].subfolder).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('excludes items missing filename', () => {
|
|
||||||
const output = fromPartial<NodeExecutionOutput>({
|
|
||||||
images: [
|
|
||||||
{ filename: 'valid.png', subfolder: '', type: 'output' },
|
|
||||||
{ subfolder: '', type: 'output' }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = flattenNodeOutput(['1', output])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0].filename).toBe('valid.png')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
|
||||||
import { parseNodeOutput } from '@/stores/resultItemParsing'
|
|
||||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
|
||||||
|
|
||||||
export function flattenNodeOutput([nodeId, nodeOutput]: [
|
|
||||||
string | number,
|
|
||||||
NodeExecutionOutput
|
|
||||||
]): ResultItemImpl[] {
|
|
||||||
return parseNodeOutput(nodeId, nodeOutput)
|
|
||||||
}
|
|
||||||
@@ -20,3 +20,17 @@ export interface OutputSelection {
|
|||||||
export type SelectionValue =
|
export type SelectionValue =
|
||||||
| { id: string; kind: 'inProgress'; itemId: string }
|
| { id: string; kind: 'inProgress'; itemId: string }
|
||||||
| { id: string; kind: 'history'; assetId: string; key: number }
|
| { id: string; kind: 'history'; assetId: string; key: number }
|
||||||
|
| { id: string; kind: 'nonAsset'; itemId: string }
|
||||||
|
|
||||||
|
export interface TimelineItem {
|
||||||
|
id: string
|
||||||
|
output: ResultItemImpl
|
||||||
|
groupKey: string
|
||||||
|
selectionValue: SelectionValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NonAssetEntry {
|
||||||
|
id: string
|
||||||
|
jobId: string
|
||||||
|
output: ResultItemImpl
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { ref } from 'vue'
|
|||||||
|
|
||||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||||
import { ResultItemImpl } from '@/stores/queueStore'
|
|
||||||
|
|
||||||
const activeJobIdRef = ref<string | null>(null)
|
const activeJobIdRef = ref<string | null>(null)
|
||||||
const previewsRef = ref<Record<string, { url: string; nodeId?: string }>>({})
|
const previewsRef = ref<Record<string, { url: string; nodeId?: string }>>({})
|
||||||
@@ -64,23 +63,6 @@ vi.mock('@/scripts/api', () => ({
|
|||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
|
|
||||||
flattenNodeOutput: ([nodeId, output]: [
|
|
||||||
string | number,
|
|
||||||
Record<string, unknown>
|
|
||||||
]) => {
|
|
||||||
if (!output.images) return []
|
|
||||||
return (output.images as Array<Record<string, string>>).map(
|
|
||||||
(img) =>
|
|
||||||
new ResultItemImpl({
|
|
||||||
...img,
|
|
||||||
nodeId: String(nodeId),
|
|
||||||
mediaType: 'images'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
function setJobWorkflowPath(jobId: string, path: string) {
|
function setJobWorkflowPath(jobId: string, path: string) {
|
||||||
const next = new Map(jobIdToWorkflowPathRef.value)
|
const next = new Map(jobIdToWorkflowPathRef.value)
|
||||||
next.set(jobId, path)
|
next.set(jobId, path)
|
||||||
@@ -102,6 +84,23 @@ function makeExecutedDetail(
|
|||||||
} as ExecutedWsMessage
|
} as ExecutedWsMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeImageCompareDetail(
|
||||||
|
promptId: string,
|
||||||
|
aFilename = 'before.png',
|
||||||
|
bFilename = 'after.png',
|
||||||
|
nodeId = '2'
|
||||||
|
): ExecutedWsMessage {
|
||||||
|
return {
|
||||||
|
prompt_id: promptId,
|
||||||
|
node: nodeId,
|
||||||
|
display_node: nodeId,
|
||||||
|
output: {
|
||||||
|
a_images: [{ filename: aFilename, subfolder: '', type: 'temp' }],
|
||||||
|
b_images: [{ filename: bFilename, subfolder: '', type: 'temp' }]
|
||||||
|
}
|
||||||
|
} as ExecutedWsMessage
|
||||||
|
}
|
||||||
|
|
||||||
describe('linearOutputStore', () => {
|
describe('linearOutputStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
@@ -1404,4 +1403,168 @@ describe('linearOutputStore', () => {
|
|||||||
expect(store.inProgressItems).toHaveLength(0)
|
expect(store.inProgressItems).toHaveLength(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('autoSelectLatest', () => {
|
||||||
|
it('does not override selection when user is browsing', () => {
|
||||||
|
const store = useLinearOutputStore()
|
||||||
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||||
|
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||||
|
|
||||||
|
// Run 1 completes, auto-selects latest
|
||||||
|
store.selectAsLatest('nonasset:compare-1')
|
||||||
|
|
||||||
|
// User manually selects a different item (browsing)
|
||||||
|
store.select('history:run1-asset:1')
|
||||||
|
expect(store.selectedId).toBe('history:run1-asset:1')
|
||||||
|
|
||||||
|
// Run 2 completes, new history appears — media watcher would
|
||||||
|
// call autoSelectLatest to follow the latest history item
|
||||||
|
store.autoSelectLatest('history:run2-asset:0')
|
||||||
|
|
||||||
|
// Should NOT have changed — user was browsing
|
||||||
|
expect(store.selectedId).toBe('history:run1-asset:1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates selection when user is following latest', () => {
|
||||||
|
const store = useLinearOutputStore()
|
||||||
|
|
||||||
|
store.selectAsLatest('history:asset-old:0')
|
||||||
|
|
||||||
|
// New history arrives — autoSelectLatest called
|
||||||
|
store.autoSelectLatest('history:asset-new:0')
|
||||||
|
|
||||||
|
// Should update — user was following
|
||||||
|
expect(store.selectedId).toBe('history:asset-new:0')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-selects new run when viewing the latest non-asset output', () => {
|
||||||
|
const store = useLinearOutputStore()
|
||||||
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||||
|
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||||
|
|
||||||
|
// Run 1 produces a compare output
|
||||||
|
store.onJobStart('job-1')
|
||||||
|
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
|
||||||
|
store.onJobComplete('job-1')
|
||||||
|
|
||||||
|
// User clicks the (latest) compare output
|
||||||
|
const latest = store.activeWorkflowNonAssetOutputs[0]
|
||||||
|
store.select(`nonasset:${latest.id}`)
|
||||||
|
|
||||||
|
// New run starts
|
||||||
|
store.onJobStart('job-2')
|
||||||
|
|
||||||
|
// Should auto-select — the selected nonasset is the latest item
|
||||||
|
expect(store.selectedId?.startsWith('slot:')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not auto-select new run when viewing the latest non-asset output if browsing history before', () => {
|
||||||
|
const store = useLinearOutputStore()
|
||||||
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||||
|
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||||
|
|
||||||
|
// Run 1 produces assets + compare
|
||||||
|
store.onJobStart('job-1')
|
||||||
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||||
|
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
|
||||||
|
store.onJobComplete('job-1')
|
||||||
|
|
||||||
|
// User selects a history asset (browsing, isFollowing=false)
|
||||||
|
store.select('history:asset-1:0')
|
||||||
|
|
||||||
|
// New run starts — should NOT auto-select
|
||||||
|
store.onJobStart('job-2')
|
||||||
|
expect(store.selectedId).toBe('history:asset-1:0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not auto-select new run when viewing an older non-asset output', () => {
|
||||||
|
const store = useLinearOutputStore()
|
||||||
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||||
|
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||||
|
setJobWorkflowPath('job-3', 'workflows/test-workflow.json')
|
||||||
|
|
||||||
|
// Two compare outputs exist (job-2 is latest, job-1 is older)
|
||||||
|
store.onJobStart('job-1')
|
||||||
|
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
|
||||||
|
store.onJobComplete('job-1')
|
||||||
|
|
||||||
|
store.onJobStart('job-2')
|
||||||
|
store.onNodeExecuted('job-2', makeImageCompareDetail('job-2'))
|
||||||
|
store.onJobComplete('job-2')
|
||||||
|
|
||||||
|
// User clicks the OLDER compare output (browsing)
|
||||||
|
const olderEntry = store.activeWorkflowNonAssetOutputs.find(
|
||||||
|
(e) => e.jobId === 'job-1'
|
||||||
|
)!
|
||||||
|
store.select(`nonasset:${olderEntry.id}`)
|
||||||
|
|
||||||
|
// New run starts
|
||||||
|
store.onJobStart('job-3')
|
||||||
|
|
||||||
|
// Should NOT auto-select — user was browsing an older item
|
||||||
|
expect(store.selectedId).toBe(`nonasset:${olderEntry.id}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('image compare outputs', () => {
|
||||||
|
it('stores standalone image_compare outputs in activeWorkflowNonAssetOutputs', () => {
|
||||||
|
const store = useLinearOutputStore()
|
||||||
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||||
|
store.onJobStart('job-1')
|
||||||
|
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
|
||||||
|
store.onJobComplete('job-1')
|
||||||
|
|
||||||
|
expect(store.activeWorkflowNonAssetOutputs).toHaveLength(1)
|
||||||
|
expect(store.activeWorkflowNonAssetOutputs[0].output.isImageCompare).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(store.inProgressItems).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('separates image_compare to nonAssetOutputs and asset images to pendingResolve', () => {
|
||||||
|
const store = useLinearOutputStore()
|
||||||
|
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||||
|
store.onJobStart('job-1')
|
||||||
|
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||||
|
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
|
||||||
|
store.onJobComplete('job-1')
|
||||||
|
|
||||||
|
// Asset images stay in pendingResolve
|
||||||
|
expect(store.pendingResolve.has('job-1')).toBe(true)
|
||||||
|
const remaining = store.inProgressItems.filter((i) => i.jobId === 'job-1')
|
||||||
|
expect(remaining).toHaveLength(1)
|
||||||
|
expect(remaining[0].output?.mediaType).toBe('images')
|
||||||
|
|
||||||
|
// Image compare moved to nonAssetOutputs
|
||||||
|
expect(store.activeWorkflowNonAssetOutputs).toHaveLength(1)
|
||||||
|
expect(store.activeWorkflowNonAssetOutputs[0].output.isImageCompare).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(store.activeWorkflowNonAssetOutputs[0].jobId).toBe('job-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scopes non-asset outputs to the active workflow', () => {
|
||||||
|
const store = useLinearOutputStore()
|
||||||
|
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
||||||
|
setJobWorkflowPath('job-2', 'workflows/app-b.json')
|
||||||
|
|
||||||
|
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||||
|
store.onJobStart('job-1')
|
||||||
|
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
|
||||||
|
store.onJobComplete('job-1')
|
||||||
|
|
||||||
|
store.onJobStart('job-2')
|
||||||
|
store.onNodeExecuted('job-2', makeImageCompareDetail('job-2'))
|
||||||
|
store.onJobComplete('job-2')
|
||||||
|
|
||||||
|
// Active workflow is app-a — only job-1 visible
|
||||||
|
expect(store.activeWorkflowNonAssetOutputs).toHaveLength(1)
|
||||||
|
expect(store.activeWorkflowNonAssetOutputs[0].jobId).toBe('job-1')
|
||||||
|
|
||||||
|
// Switch to app-b — only job-2 visible
|
||||||
|
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||||
|
expect(store.activeWorkflowNonAssetOutputs).toHaveLength(1)
|
||||||
|
expect(store.activeWorkflowNonAssetOutputs[0].jobId).toBe('job-2')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { computed, ref, shallowRef, watch } from 'vue'
|
|||||||
|
|
||||||
import { useAppMode } from '@/composables/useAppMode'
|
import { useAppMode } from '@/composables/useAppMode'
|
||||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
import { parseNodeOutput } from '@/stores/resultItemParsing'
|
||||||
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
import type {
|
||||||
|
InProgressItem,
|
||||||
|
NonAssetEntry
|
||||||
|
} from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
@@ -12,6 +15,8 @@ import { useAppModeStore } from '@/stores/appModeStore'
|
|||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||||
|
|
||||||
|
const MAX_NON_ASSET_OUTPUTS = 100
|
||||||
|
|
||||||
export const useLinearOutputStore = defineStore('linearOutput', () => {
|
export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||||
const { isAppMode } = useAppMode()
|
const { isAppMode } = useAppMode()
|
||||||
const appModeStore = useAppModeStore()
|
const appModeStore = useAppModeStore()
|
||||||
@@ -20,6 +25,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
|||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
|
|
||||||
const inProgressItems = ref<InProgressItem[]>([])
|
const inProgressItems = ref<InProgressItem[]>([])
|
||||||
|
const completedNonAssetOutputs = shallowRef<NonAssetEntry[]>([])
|
||||||
const resolvedOutputsCache = new Map<string, ResultItemImpl[]>()
|
const resolvedOutputsCache = new Map<string, ResultItemImpl[]>()
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const isFollowing = ref(true)
|
const isFollowing = ref(true)
|
||||||
@@ -30,12 +36,19 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
|||||||
const activeWorkflowInProgressItems = computed(() => {
|
const activeWorkflowInProgressItems = computed(() => {
|
||||||
const path = workflowStore.activeWorkflow?.path
|
const path = workflowStore.activeWorkflow?.path
|
||||||
if (!path) return []
|
if (!path) return []
|
||||||
const all = inProgressItems.value
|
return inProgressItems.value.filter(
|
||||||
return all.filter(
|
|
||||||
(i) => executionStore.jobIdToSessionWorkflowPath.get(i.jobId) === path
|
(i) => executionStore.jobIdToSessionWorkflowPath.get(i.jobId) === path
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const activeWorkflowNonAssetOutputs = computed(() => {
|
||||||
|
const path = workflowStore.activeWorkflow?.path
|
||||||
|
if (!path) return []
|
||||||
|
return completedNonAssetOutputs.value.filter(
|
||||||
|
(e) => executionStore.jobIdToSessionWorkflowPath.get(e.jobId) === path
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
let nextSeq = 0
|
let nextSeq = 0
|
||||||
|
|
||||||
function makeItemId(jobId: string): string {
|
function makeItemId(jobId: string): string {
|
||||||
@@ -114,7 +127,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
|||||||
cancelAnimationFrame(raf)
|
cancelAnimationFrame(raf)
|
||||||
raf = null
|
raf = null
|
||||||
}
|
}
|
||||||
const newOutputs = flattenNodeOutput([nodeId, detail.output])
|
const newOutputs = parseNodeOutput(nodeId, detail.output)
|
||||||
if (newOutputs.length === 0) return
|
if (newOutputs.length === 0) return
|
||||||
|
|
||||||
// Skip output items for nodes not flagged as output nodes
|
// Skip output items for nodes not flagged as output nodes
|
||||||
@@ -153,8 +166,15 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// No skeleton — create image items directly (only for tracked job)
|
// No skeleton — create image items directly.
|
||||||
if (jobId !== trackedJobId.value) return
|
// handleExecuted already verified jobId === activeJobId, so start
|
||||||
|
// tracking if we haven't yet (covers nodes that fire before
|
||||||
|
// onJobStart, e.g. ImageCompare with no SaveImage in the workflow).
|
||||||
|
if (!trackedJobId.value) {
|
||||||
|
trackedJobId.value = jobId
|
||||||
|
} else if (jobId !== trackedJobId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const newItems: InProgressItem[] = newOutputs.map((o) => ({
|
const newItems: InProgressItem[] = newOutputs.map((o) => ({
|
||||||
id: makeItemId(jobId),
|
id: makeItemId(jobId),
|
||||||
@@ -184,14 +204,31 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
|||||||
trackedJobId.value = null
|
trackedJobId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasImages = inProgressItems.value.some(
|
const jobImageItems = inProgressItems.value.filter(
|
||||||
(i) => i.jobId === jobId && i.state === 'image'
|
(i) => i.jobId === jobId && i.state === 'image'
|
||||||
)
|
)
|
||||||
|
|
||||||
if (hasImages) {
|
// Move non-asset outputs (e.g. image_compare) to their own collection
|
||||||
// Remove non-image items (skeletons, latents), keep images for absorption
|
// since they won't appear in history.
|
||||||
|
const nonAssetItems = jobImageItems.filter((i) => i.output?.isImageCompare)
|
||||||
|
if (nonAssetItems.length > 0) {
|
||||||
|
completedNonAssetOutputs.value = [
|
||||||
|
...nonAssetItems.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
jobId,
|
||||||
|
output: i.output!
|
||||||
|
})),
|
||||||
|
...completedNonAssetOutputs.value
|
||||||
|
].slice(0, MAX_NON_ASSET_OUTPUTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only asset images for history absorption, remove everything else.
|
||||||
|
const hasAssetOutputs = jobImageItems.some((i) => !i.output?.isImageCompare)
|
||||||
|
if (hasAssetOutputs) {
|
||||||
inProgressItems.value = inProgressItems.value.filter(
|
inProgressItems.value = inProgressItems.value.filter(
|
||||||
(i) => i.jobId !== jobId || i.state === 'image'
|
(i) =>
|
||||||
|
i.jobId !== jobId ||
|
||||||
|
(i.state === 'image' && !i.output?.isImageCompare)
|
||||||
)
|
)
|
||||||
pendingResolve.value = new Set([...pendingResolve.value, jobId])
|
pendingResolve.value = new Set([...pendingResolve.value, jobId])
|
||||||
} else {
|
} else {
|
||||||
@@ -234,6 +271,11 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
|||||||
isFollowing.value = true
|
isFollowing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function autoSelectLatest(id: string | null) {
|
||||||
|
if (!isFollowing.value) return
|
||||||
|
selectedId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
function isJobForActiveWorkflow(jobId: string): boolean {
|
function isJobForActiveWorkflow(jobId: string): boolean {
|
||||||
return (
|
return (
|
||||||
executionStore.jobIdToSessionWorkflowPath.get(jobId) ===
|
executionStore.jobIdToSessionWorkflowPath.get(jobId) ===
|
||||||
@@ -246,7 +288,16 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
|||||||
if (!isJobForActiveWorkflow(jobId)) return
|
if (!isJobForActiveWorkflow(jobId)) return
|
||||||
|
|
||||||
const sel = selectedId.value
|
const sel = selectedId.value
|
||||||
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
|
const isLatestNonAsset =
|
||||||
|
sel?.startsWith('nonasset:') &&
|
||||||
|
activeWorkflowNonAssetOutputs.value[0] &&
|
||||||
|
sel === `nonasset:${activeWorkflowNonAssetOutputs.value[0].id}`
|
||||||
|
if (
|
||||||
|
!sel ||
|
||||||
|
sel.startsWith('slot:') ||
|
||||||
|
isLatestNonAsset ||
|
||||||
|
isFollowing.value
|
||||||
|
) {
|
||||||
selectedId.value = slotId
|
selectedId.value = slotId
|
||||||
isFollowing.value = true
|
isFollowing.value = true
|
||||||
return
|
return
|
||||||
@@ -367,11 +418,13 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
activeWorkflowInProgressItems,
|
activeWorkflowInProgressItems,
|
||||||
|
activeWorkflowNonAssetOutputs,
|
||||||
resolvedOutputsCache,
|
resolvedOutputsCache,
|
||||||
selectedId,
|
selectedId,
|
||||||
pendingResolve,
|
pendingResolve,
|
||||||
select,
|
select,
|
||||||
selectAsLatest,
|
selectAsLatest,
|
||||||
|
autoSelectLatest,
|
||||||
resolveIfReady,
|
resolveIfReady,
|
||||||
inProgressItems,
|
inProgressItems,
|
||||||
onJobStart,
|
onJobStart,
|
||||||
|
|||||||
37
src/renderer/extensions/linearMode/mediaTypes.test.ts
Normal file
37
src/renderer/extensions/linearMode/mediaTypes.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||||
|
import { makeResultItem } from '@/renderer/extensions/linearMode/__fixtures__/testResultItemFactory'
|
||||||
|
|
||||||
|
describe('getMediaType', () => {
|
||||||
|
it('returns empty string for undefined output', () => {
|
||||||
|
expect(getMediaType()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prioritises video suffix over mediaType', () => {
|
||||||
|
expect(
|
||||||
|
getMediaType(
|
||||||
|
makeResultItem({ filename: 'clip.mp4', mediaType: 'images' })
|
||||||
|
)
|
||||||
|
).toBe('video')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prioritises image suffix over mediaType', () => {
|
||||||
|
expect(
|
||||||
|
getMediaType(
|
||||||
|
makeResultItem({ filename: 'photo.png', mediaType: 'video' })
|
||||||
|
)
|
||||||
|
).toBe('images')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.for([
|
||||||
|
{ mediaType: 'image_compare', expected: 'image_compare' },
|
||||||
|
{ mediaType: 'audio', expected: 'audio' },
|
||||||
|
{ mediaType: '3d', expected: '3d' }
|
||||||
|
])(
|
||||||
|
'falls back to raw mediaType for $mediaType',
|
||||||
|
({ mediaType, expected }) => {
|
||||||
|
expect(getMediaType(makeResultItem({ mediaType }))).toBe(expected)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -4,7 +4,11 @@ import { nextTick, ref } from 'vue'
|
|||||||
|
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
import { makeResultItem } from '@/renderer/extensions/linearMode/__fixtures__/testResultItemFactory'
|
||||||
|
import {
|
||||||
|
buildTimeline,
|
||||||
|
useOutputHistory
|
||||||
|
} from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||||
import { useAppModeStore } from '@/stores/appModeStore'
|
import { useAppModeStore } from '@/stores/appModeStore'
|
||||||
import { ResultItemImpl } from '@/stores/queueStore'
|
import { ResultItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
@@ -16,6 +20,9 @@ const selectedIdRef = ref<string | null>(null)
|
|||||||
const activeWorkflowPathRef = ref<string>('workflows/test.json')
|
const activeWorkflowPathRef = ref<string>('workflows/test.json')
|
||||||
const jobIdToPathRef = ref(new Map<string, string>())
|
const jobIdToPathRef = ref(new Map<string, string>())
|
||||||
const isActiveWorkflowRunningRef = ref(false)
|
const isActiveWorkflowRunningRef = ref(false)
|
||||||
|
const activeWorkflowNonAssetOutputsRef = ref<
|
||||||
|
{ id: string; jobId: string; output: ResultItemImpl }[]
|
||||||
|
>([])
|
||||||
const runningTasksRef = ref<Array<{ jobId: string }>>([])
|
const runningTasksRef = ref<Array<{ jobId: string }>>([])
|
||||||
const pendingTasksRef = ref<Array<{ jobId: string }>>([])
|
const pendingTasksRef = ref<Array<{ jobId: string }>>([])
|
||||||
|
|
||||||
@@ -47,6 +54,9 @@ vi.mock('@/renderer/extensions/linearMode/linearOutputStore', () => ({
|
|||||||
get activeWorkflowInProgressItems() {
|
get activeWorkflowInProgressItems() {
|
||||||
return activeWorkflowInProgressItemsRef.value
|
return activeWorkflowInProgressItemsRef.value
|
||||||
},
|
},
|
||||||
|
get activeWorkflowNonAssetOutputs() {
|
||||||
|
return activeWorkflowNonAssetOutputsRef.value
|
||||||
|
},
|
||||||
get selectedId() {
|
get selectedId() {
|
||||||
return selectedIdRef.value
|
return selectedIdRef.value
|
||||||
},
|
},
|
||||||
@@ -89,8 +99,27 @@ vi.mock('@/stores/queueStore', async (importOriginal) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const { jobDetailResults } = vi.hoisted(() => ({
|
const { jobDetailResults, commandExecuteFn, apiDeleteItemFn } = vi.hoisted(
|
||||||
jobDetailResults: new Map<string, unknown>()
|
() => ({
|
||||||
|
jobDetailResults: new Map<string, unknown>(),
|
||||||
|
commandExecuteFn: vi.fn(),
|
||||||
|
apiDeleteItemFn: vi.fn().mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
vi.mock('@/stores/commandStore', () => ({
|
||||||
|
useCommandStore: () => ({
|
||||||
|
execute: commandExecuteFn
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/scripts/api', () => ({
|
||||||
|
api: {
|
||||||
|
apiURL: (path: string) => path,
|
||||||
|
deleteItem: apiDeleteItemFn,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn()
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/services/jobOutputCache', () => ({
|
vi.mock('@/services/jobOutputCache', () => ({
|
||||||
@@ -98,11 +127,11 @@ vi.mock('@/services/jobOutputCache', () => ({
|
|||||||
Promise.resolve(jobDetailResults.get(jobId) ?? undefined)
|
Promise.resolve(jobDetailResults.get(jobId) ?? undefined)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
|
vi.mock('@/stores/resultItemParsing', () => ({
|
||||||
flattenNodeOutput: ([nodeId, output]: [
|
parseNodeOutput: (
|
||||||
string | number,
|
nodeId: string | number,
|
||||||
Record<string, unknown>
|
output: Record<string, unknown>
|
||||||
]) => {
|
) => {
|
||||||
if (!output.images) return []
|
if (!output.images) return []
|
||||||
return (output.images as Array<Record<string, string>>).map(
|
return (output.images as Array<Record<string, string>>).map(
|
||||||
(img) =>
|
(img) =>
|
||||||
@@ -137,16 +166,6 @@ function makeAsset(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeResult(filename: string, nodeId: string = '1'): ResultItemImpl {
|
|
||||||
return new ResultItemImpl({
|
|
||||||
filename,
|
|
||||||
subfolder: '',
|
|
||||||
type: 'output',
|
|
||||||
nodeId,
|
|
||||||
mediaType: 'images'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe(useOutputHistory, () => {
|
describe(useOutputHistory, () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
@@ -154,6 +173,7 @@ describe(useOutputHistory, () => {
|
|||||||
pendingResolveRef.value = new Set()
|
pendingResolveRef.value = new Set()
|
||||||
inProgressItemsRef.value = []
|
inProgressItemsRef.value = []
|
||||||
activeWorkflowInProgressItemsRef.value = []
|
activeWorkflowInProgressItemsRef.value = []
|
||||||
|
activeWorkflowNonAssetOutputsRef.value = []
|
||||||
selectedIdRef.value = null
|
selectedIdRef.value = null
|
||||||
activeWorkflowPathRef.value = 'workflows/test.json'
|
activeWorkflowPathRef.value = 'workflows/test.json'
|
||||||
jobIdToPathRef.value = new Map()
|
jobIdToPathRef.value = new Map()
|
||||||
@@ -162,8 +182,8 @@ describe(useOutputHistory, () => {
|
|||||||
pendingTasksRef.value = []
|
pendingTasksRef.value = []
|
||||||
resolvedOutputsCacheRef.clear()
|
resolvedOutputsCacheRef.clear()
|
||||||
jobDetailResults.clear()
|
jobDetailResults.clear()
|
||||||
selectAsLatestFn.mockReset()
|
vi.resetAllMocks()
|
||||||
resolveIfReadyFn.mockReset()
|
apiDeleteItemFn.mockResolvedValue(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sessionMedia filtering', () => {
|
describe('sessionMedia filtering', () => {
|
||||||
@@ -221,7 +241,10 @@ describe(useOutputHistory, () => {
|
|||||||
|
|
||||||
it('returns outputs from metadata allOutputs when count matches', () => {
|
it('returns outputs from metadata allOutputs when count matches', () => {
|
||||||
useAppModeStore().selectedOutputs.push('1')
|
useAppModeStore().selectedOutputs.push('1')
|
||||||
const results = [makeResult('a.png'), makeResult('b.png')]
|
const results = [
|
||||||
|
makeResultItem({ filename: 'a.png' }),
|
||||||
|
makeResultItem({ filename: 'b.png' })
|
||||||
|
]
|
||||||
const asset = makeAsset('a1', 'job-1', {
|
const asset = makeAsset('a1', 'job-1', {
|
||||||
allOutputs: results,
|
allOutputs: results,
|
||||||
outputCount: 2
|
outputCount: 2
|
||||||
@@ -238,9 +261,9 @@ describe(useOutputHistory, () => {
|
|||||||
|
|
||||||
it('filters outputs to selected output nodes only', () => {
|
it('filters outputs to selected output nodes only', () => {
|
||||||
const results = [
|
const results = [
|
||||||
makeResult('a.png', '1'),
|
makeResultItem({ filename: 'a.png', nodeId: '1' }),
|
||||||
makeResult('b.png', '2'),
|
makeResultItem({ filename: 'b.png', nodeId: '2' }),
|
||||||
makeResult('c.png', '3')
|
makeResultItem({ filename: 'c.png', nodeId: '3' })
|
||||||
]
|
]
|
||||||
const asset = makeAsset('a1', 'job-1', {
|
const asset = makeAsset('a1', 'job-1', {
|
||||||
allOutputs: results,
|
allOutputs: results,
|
||||||
@@ -258,7 +281,10 @@ describe(useOutputHistory, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns empty when no output nodes are selected', () => {
|
it('returns empty when no output nodes are selected', () => {
|
||||||
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
|
const results = [
|
||||||
|
makeResultItem({ filename: 'a.png', nodeId: '1' }),
|
||||||
|
makeResultItem({ filename: 'b.png', nodeId: '2' })
|
||||||
|
]
|
||||||
const asset = makeAsset('a1', 'job-1', {
|
const asset = makeAsset('a1', 'job-1', {
|
||||||
allOutputs: results,
|
allOutputs: results,
|
||||||
outputCount: 2
|
outputCount: 2
|
||||||
@@ -271,7 +297,10 @@ describe(useOutputHistory, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns consistent filtered outputs across repeated calls', () => {
|
it('returns consistent filtered outputs across repeated calls', () => {
|
||||||
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
|
const results = [
|
||||||
|
makeResultItem({ filename: 'a.png', nodeId: '1' }),
|
||||||
|
makeResultItem({ filename: 'b.png', nodeId: '2' })
|
||||||
|
]
|
||||||
const asset = makeAsset('a1', 'job-1', {
|
const asset = makeAsset('a1', 'job-1', {
|
||||||
allOutputs: results,
|
allOutputs: results,
|
||||||
outputCount: 2
|
outputCount: 2
|
||||||
@@ -297,13 +326,13 @@ describe(useOutputHistory, () => {
|
|||||||
id: 'item-1',
|
id: 'item-1',
|
||||||
jobId: 'job-1',
|
jobId: 'job-1',
|
||||||
state: 'image',
|
state: 'image',
|
||||||
output: makeResult('a.png')
|
output: makeResultItem({ filename: 'a.png' })
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'item-2',
|
id: 'item-2',
|
||||||
jobId: 'job-1',
|
jobId: 'job-1',
|
||||||
state: 'image',
|
state: 'image',
|
||||||
output: makeResult('b.png')
|
output: makeResultItem({ filename: 'b.png' })
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const asset = makeAsset('a1', 'job-1')
|
const asset = makeAsset('a1', 'job-1')
|
||||||
@@ -347,7 +376,7 @@ describe(useOutputHistory, () => {
|
|||||||
describe('watchEffect resolve loop', () => {
|
describe('watchEffect resolve loop', () => {
|
||||||
it('resolves pending jobs when history outputs load', async () => {
|
it('resolves pending jobs when history outputs load', async () => {
|
||||||
useAppModeStore().selectedOutputs.push('1')
|
useAppModeStore().selectedOutputs.push('1')
|
||||||
const results = [makeResult('a.png')]
|
const results = [makeResultItem({ filename: 'a.png' })]
|
||||||
const asset = makeAsset('a1', 'job-1', {
|
const asset = makeAsset('a1', 'job-1', {
|
||||||
allOutputs: results,
|
allOutputs: results,
|
||||||
outputCount: 1
|
outputCount: 1
|
||||||
@@ -366,7 +395,7 @@ describe(useOutputHistory, () => {
|
|||||||
|
|
||||||
it('does not select first history when a selection exists', async () => {
|
it('does not select first history when a selection exists', async () => {
|
||||||
useAppModeStore().selectedOutputs.push('1')
|
useAppModeStore().selectedOutputs.push('1')
|
||||||
const results = [makeResult('a.png')]
|
const results = [makeResultItem({ filename: 'a.png' })]
|
||||||
const asset = makeAsset('a1', 'job-1', {
|
const asset = makeAsset('a1', 'job-1', {
|
||||||
allOutputs: results,
|
allOutputs: results,
|
||||||
outputCount: 1
|
outputCount: 1
|
||||||
@@ -392,6 +421,38 @@ describe(useOutputHistory, () => {
|
|||||||
|
|
||||||
expect(resolveIfReadyFn).not.toHaveBeenCalled()
|
expect(resolveIfReadyFn).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('selects non-asset output from resolved job instead of first history', async () => {
|
||||||
|
useAppModeStore().selectedOutputs.push('1')
|
||||||
|
const results = [
|
||||||
|
makeResultItem({ filename: 'a.png' }),
|
||||||
|
makeResultItem({ filename: 'b.png' })
|
||||||
|
]
|
||||||
|
const asset = makeAsset('a1', 'job-1', {
|
||||||
|
allOutputs: results,
|
||||||
|
outputCount: 2
|
||||||
|
})
|
||||||
|
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||||
|
pendingResolveRef.value = new Set(['job-1'])
|
||||||
|
mediaRef.value = [asset]
|
||||||
|
selectedIdRef.value = null
|
||||||
|
|
||||||
|
// Job also produced a compare output now in nonAssetOutputs
|
||||||
|
activeWorkflowNonAssetOutputsRef.value = [
|
||||||
|
{
|
||||||
|
id: 'compare-1',
|
||||||
|
jobId: 'job-1',
|
||||||
|
output: makeResultItem({ filename: 'compare.png', nodeId: '2' })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
useOutputHistory()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(resolveIfReadyFn).toHaveBeenCalledWith('job-1', true)
|
||||||
|
// Should select the non-asset output, not the history asset
|
||||||
|
expect(selectAsLatestFn).toHaveBeenCalledWith('nonasset:compare-1')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('selectFirstHistory', () => {
|
describe('selectFirstHistory', () => {
|
||||||
@@ -462,4 +523,116 @@ describe(useOutputHistory, () => {
|
|||||||
expect(mayBeActiveWorkflowPending.value).toBe(false)
|
expect(mayBeActiveWorkflowPending.value).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('cancelActiveWorkflowJobs', () => {
|
||||||
|
it('interrupts running job when it matches active workflow', async () => {
|
||||||
|
activeWorkflowPathRef.value = 'workflows/test.json'
|
||||||
|
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||||
|
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||||
|
|
||||||
|
const { cancelActiveWorkflowJobs } = useOutputHistory()
|
||||||
|
await cancelActiveWorkflowJobs()
|
||||||
|
|
||||||
|
expect(commandExecuteFn).toHaveBeenCalledWith('Comfy.Interrupt')
|
||||||
|
expect(apiDeleteItemFn).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes only the first matching pending job when no running job matches', async () => {
|
||||||
|
activeWorkflowPathRef.value = 'workflows/test.json'
|
||||||
|
runningTasksRef.value = []
|
||||||
|
pendingTasksRef.value = [{ jobId: 'job-2' }, { jobId: 'job-3' }]
|
||||||
|
jobIdToPathRef.value = new Map([
|
||||||
|
['job-2', 'workflows/test.json'],
|
||||||
|
['job-3', 'workflows/test.json']
|
||||||
|
])
|
||||||
|
|
||||||
|
const { cancelActiveWorkflowJobs } = useOutputHistory()
|
||||||
|
await cancelActiveWorkflowJobs()
|
||||||
|
|
||||||
|
expect(commandExecuteFn).not.toHaveBeenCalled()
|
||||||
|
expect(apiDeleteItemFn).toHaveBeenCalledOnce()
|
||||||
|
expect(apiDeleteItemFn).toHaveBeenCalledWith('queue', 'job-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls through to pending when running job belongs to a different workflow', async () => {
|
||||||
|
activeWorkflowPathRef.value = 'workflows/test.json'
|
||||||
|
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||||
|
pendingTasksRef.value = [{ jobId: 'job-2' }]
|
||||||
|
jobIdToPathRef.value = new Map([
|
||||||
|
['job-1', 'workflows/other.json'],
|
||||||
|
['job-2', 'workflows/test.json']
|
||||||
|
])
|
||||||
|
|
||||||
|
const { cancelActiveWorkflowJobs } = useOutputHistory()
|
||||||
|
await cancelActiveWorkflowJobs()
|
||||||
|
|
||||||
|
expect(commandExecuteFn).not.toHaveBeenCalled()
|
||||||
|
expect(apiDeleteItemFn).toHaveBeenCalledOnce()
|
||||||
|
expect(apiDeleteItemFn).toHaveBeenCalledWith('queue', 'job-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing when no workflow path is set', async () => {
|
||||||
|
activeWorkflowPathRef.value = ''
|
||||||
|
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||||
|
|
||||||
|
const { cancelActiveWorkflowJobs } = useOutputHistory()
|
||||||
|
await cancelActiveWorkflowJobs()
|
||||||
|
|
||||||
|
expect(commandExecuteFn).not.toHaveBeenCalled()
|
||||||
|
expect(apiDeleteItemFn).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(buildTimeline, () => {
|
||||||
|
function makeAssetWithJobId(id: string, jobId: string): AssetItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `${id}.png`,
|
||||||
|
tags: [],
|
||||||
|
preview_url: `/view?filename=${id}.png`,
|
||||||
|
user_metadata: { jobId, nodeId: '1', subfolder: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeOutput(filename: string): ResultItemImpl {
|
||||||
|
return makeResultItem({ filename })
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns empty for no history and no non-asset outputs', () => {
|
||||||
|
expect(buildTimeline([], [], () => [])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places orphan non-asset outputs first', () => {
|
||||||
|
const entry = { id: 'c1', jobId: 'job-1', output: makeOutput('cmp.png') }
|
||||||
|
const items = buildTimeline([], [entry], () => [])
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
expect(items[0].id).toBe('nonasset:c1')
|
||||||
|
expect(items[0].groupKey).toBe('orphans')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pairs non-asset outputs with matching history by jobId', () => {
|
||||||
|
const asset = makeAssetWithJobId('a1', 'job-1')
|
||||||
|
const entry = { id: 'c1', jobId: 'job-1', output: makeOutput('cmp.png') }
|
||||||
|
const historyOutput = makeOutput('saved.png')
|
||||||
|
|
||||||
|
const items = buildTimeline([asset], [entry], () => [historyOutput])
|
||||||
|
|
||||||
|
expect(items).toHaveLength(2)
|
||||||
|
expect(items[0].id).toBe('nonasset:c1')
|
||||||
|
expect(items[0].groupKey).toBe('a1')
|
||||||
|
expect(items[1].id).toBe('history:a1:0')
|
||||||
|
expect(items[1].groupKey).toBe('a1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps non-asset outputs as orphans when jobId has no history match', () => {
|
||||||
|
const asset = makeAssetWithJobId('a1', 'job-1')
|
||||||
|
const entry = { id: 'c1', jobId: 'job-2', output: makeOutput('cmp.png') }
|
||||||
|
|
||||||
|
const items = buildTimeline([asset], [entry], () => [makeOutput('s.png')])
|
||||||
|
|
||||||
|
expect(items[0].groupKey).toBe('orphans')
|
||||||
|
expect(items[1].groupKey).toBe('a1')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAsse
|
|||||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
import { parseNodeOutput } from '@/stores/resultItemParsing'
|
||||||
|
import type {
|
||||||
|
NonAssetEntry,
|
||||||
|
TimelineItem
|
||||||
|
} from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||||
import { getJobDetail } from '@/services/jobOutputCache'
|
import { getJobDetail } from '@/services/jobOutputCache'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
@@ -17,9 +21,83 @@ import { useExecutionStore } from '@/stores/executionStore'
|
|||||||
import { useQueueStore } from '@/stores/queueStore'
|
import { useQueueStore } from '@/stores/queueStore'
|
||||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
|
function makeNonAssetItem(
|
||||||
|
entry: NonAssetEntry,
|
||||||
|
groupKey: string
|
||||||
|
): TimelineItem {
|
||||||
|
return {
|
||||||
|
id: `nonasset:${entry.id}`,
|
||||||
|
output: entry.output,
|
||||||
|
groupKey,
|
||||||
|
selectionValue: {
|
||||||
|
id: `nonasset:${entry.id}`,
|
||||||
|
kind: 'nonAsset',
|
||||||
|
itemId: entry.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexByJobId(entries: NonAssetEntry[]): Map<string, NonAssetEntry[]> {
|
||||||
|
const map = new Map<string, NonAssetEntry[]>()
|
||||||
|
for (const entry of entries) {
|
||||||
|
const list = map.get(entry.jobId) ?? []
|
||||||
|
list.push(entry)
|
||||||
|
map.set(entry.jobId, list)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimeline(
|
||||||
|
visibleHistory: AssetItem[],
|
||||||
|
nonAssetOutputs: NonAssetEntry[],
|
||||||
|
allOutputsFn: (asset: AssetItem) => ResultItemImpl[]
|
||||||
|
): TimelineItem[] {
|
||||||
|
const items: TimelineItem[] = []
|
||||||
|
const nonAssetByJobId = indexByJobId(nonAssetOutputs)
|
||||||
|
|
||||||
|
// Collect jobIds that have a matching history entry
|
||||||
|
const pairedJobIds = new Set<string>()
|
||||||
|
for (const asset of visibleHistory) {
|
||||||
|
const m = getOutputAssetMetadata(asset.user_metadata)
|
||||||
|
if (m?.jobId && nonAssetByJobId.has(m.jobId)) pairedJobIds.add(m.jobId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orphan compare outputs whose job has no loaded history entry yet
|
||||||
|
for (const entry of nonAssetOutputs) {
|
||||||
|
if (pairedJobIds.has(entry.jobId)) continue
|
||||||
|
items.push(makeNonAssetItem(entry, 'orphans'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// History groups with compare outputs placed before their saved outputs
|
||||||
|
for (const asset of visibleHistory) {
|
||||||
|
const m = getOutputAssetMetadata(asset.user_metadata)
|
||||||
|
const siblings = m?.jobId ? (nonAssetByJobId.get(m.jobId) ?? []) : []
|
||||||
|
for (const entry of siblings) {
|
||||||
|
items.push(makeNonAssetItem(entry, asset.id))
|
||||||
|
}
|
||||||
|
const outs = allOutputsFn(asset)
|
||||||
|
for (let k = 0; k < outs.length; k++) {
|
||||||
|
items.push({
|
||||||
|
id: `history:${asset.id}:${k}`,
|
||||||
|
output: outs[k],
|
||||||
|
groupKey: asset.id,
|
||||||
|
selectionValue: {
|
||||||
|
id: `history:${asset.id}:${k}`,
|
||||||
|
kind: 'history',
|
||||||
|
assetId: asset.id,
|
||||||
|
key: k
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
export function useOutputHistory(): {
|
export function useOutputHistory(): {
|
||||||
outputs: IAssetsProvider
|
outputs: IAssetsProvider
|
||||||
allOutputs: (item?: AssetItem) => ResultItemImpl[]
|
allOutputs: (item?: AssetItem) => ResultItemImpl[]
|
||||||
|
timeline: ComputedRef<TimelineItem[]>
|
||||||
selectFirstHistory: () => void
|
selectFirstHistory: () => void
|
||||||
mayBeActiveWorkflowPending: ComputedRef<boolean>
|
mayBeActiveWorkflowPending: ComputedRef<boolean>
|
||||||
isWorkflowActive: ComputedRef<boolean>
|
isWorkflowActive: ComputedRef<boolean>
|
||||||
@@ -139,7 +217,7 @@ export function useOutputHistory(): {
|
|||||||
getJobDetail(user_metadata.jobId).then((jobDetail) => {
|
getJobDetail(user_metadata.jobId).then((jobDetail) => {
|
||||||
if (!jobDetail?.outputs) return []
|
if (!jobDetail?.outputs) return []
|
||||||
const results = Object.entries(jobDetail.outputs)
|
const results = Object.entries(jobDetail.outputs)
|
||||||
.flatMap(flattenNodeOutput)
|
.flatMap(([nodeId, output]) => parseNodeOutput(nodeId, output))
|
||||||
.toReversed()
|
.toReversed()
|
||||||
resolvedCache.set(itemId, results)
|
resolvedCache.set(itemId, results)
|
||||||
return results
|
return results
|
||||||
@@ -150,6 +228,18 @@ export function useOutputHistory(): {
|
|||||||
return filterByOutputNodes(outputRef.value)
|
return filterByOutputNodes(outputRef.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibleHistory = computed(() =>
|
||||||
|
outputs.media.value.filter((a) => allOutputs(a).length > 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const timeline = computed(() =>
|
||||||
|
buildTimeline(
|
||||||
|
visibleHistory.value,
|
||||||
|
linearStore.activeWorkflowNonAssetOutputs,
|
||||||
|
allOutputs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
function selectFirstHistory() {
|
function selectFirstHistory() {
|
||||||
const first = outputs.media.value[0]
|
const first = outputs.media.value[0]
|
||||||
if (first) {
|
if (first) {
|
||||||
@@ -171,7 +261,16 @@ export function useOutputHistory(): {
|
|||||||
const loaded = allOutputs(asset).length > 0
|
const loaded = allOutputs(asset).length > 0
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
linearStore.resolveIfReady(jobId, true)
|
linearStore.resolveIfReady(jobId, true)
|
||||||
if (!linearStore.selectedId) selectFirstHistory()
|
if (!linearStore.selectedId) {
|
||||||
|
const nonAsset = linearStore.activeWorkflowNonAssetOutputs.find(
|
||||||
|
(e) => e.jobId === jobId
|
||||||
|
)
|
||||||
|
if (nonAsset) {
|
||||||
|
linearStore.selectAsLatest(`nonasset:${nonAsset.id}`)
|
||||||
|
} else {
|
||||||
|
selectFirstHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -196,6 +295,7 @@ export function useOutputHistory(): {
|
|||||||
return {
|
return {
|
||||||
outputs,
|
outputs,
|
||||||
allOutputs,
|
allOutputs,
|
||||||
|
timeline,
|
||||||
selectFirstHistory,
|
selectFirstHistory,
|
||||||
mayBeActiveWorkflowPending,
|
mayBeActiveWorkflowPending,
|
||||||
isWorkflowActive,
|
isWorkflowActive,
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ enum TaskItemDisplayStatus {
|
|||||||
Cancelled = 'Cancelled'
|
Cancelled = 'Cancelled'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompareImages {
|
||||||
|
before: readonly ResultItemImpl[]
|
||||||
|
after: readonly ResultItemImpl[]
|
||||||
|
}
|
||||||
|
|
||||||
interface ResultItemInit extends ResultItem {
|
interface ResultItemInit extends ResultItem {
|
||||||
nodeId: NodeId
|
nodeId: NodeId
|
||||||
mediaType: string
|
mediaType: string
|
||||||
@@ -39,6 +44,7 @@ interface ResultItemInit extends ResultItem {
|
|||||||
frame_rate?: number
|
frame_rate?: number
|
||||||
display_name?: string
|
display_name?: string
|
||||||
content?: string
|
content?: string
|
||||||
|
compareImages?: CompareImages
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ResultItemImpl {
|
export class ResultItemImpl {
|
||||||
@@ -59,6 +65,8 @@ export class ResultItemImpl {
|
|||||||
// text specific field
|
// text specific field
|
||||||
content?: string
|
content?: string
|
||||||
|
|
||||||
|
compareImages?: CompareImages
|
||||||
|
|
||||||
constructor(obj: ResultItemInit) {
|
constructor(obj: ResultItemInit) {
|
||||||
this.filename = obj.filename ?? ''
|
this.filename = obj.filename ?? ''
|
||||||
this.subfolder = obj.subfolder ?? ''
|
this.subfolder = obj.subfolder ?? ''
|
||||||
@@ -72,6 +80,7 @@ export class ResultItemImpl {
|
|||||||
this.format = obj.format
|
this.format = obj.format
|
||||||
this.frame_rate = obj.frame_rate
|
this.frame_rate = obj.frame_rate
|
||||||
this.content = obj.content
|
this.content = obj.content
|
||||||
|
this.compareImages = obj.compareImages
|
||||||
}
|
}
|
||||||
|
|
||||||
get urlParams(): URLSearchParams {
|
get urlParams(): URLSearchParams {
|
||||||
@@ -226,6 +235,10 @@ export class ResultItemImpl {
|
|||||||
return this.mediaType === 'text'
|
return this.mediaType === 'text'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isImageCompare(): boolean {
|
||||||
|
return this.mediaType === 'image_compare'
|
||||||
|
}
|
||||||
|
|
||||||
get supportsPreview(): boolean {
|
get supportsPreview(): boolean {
|
||||||
return (
|
return (
|
||||||
this.isImage || this.isVideo || this.isAudio || this.is3D || this.isText
|
this.isImage || this.isVideo || this.isAudio || this.is3D || this.isText
|
||||||
|
|||||||
@@ -149,6 +149,85 @@ describe(parseNodeOutput, () => {
|
|||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0].filename).toBe('valid.png')
|
expect(result[0].filename).toBe('valid.png')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('image compare outputs', () => {
|
||||||
|
it('produces a single image_compare item from a_images and b_images', () => {
|
||||||
|
const output = fromPartial<NodeExecutionOutput>({
|
||||||
|
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
|
||||||
|
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = parseNodeOutput('10', output)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].mediaType).toBe('image_compare')
|
||||||
|
expect(result[0].nodeId).toBe('10')
|
||||||
|
expect(result[0].filename).toBe('')
|
||||||
|
expect(result[0].compareImages).toBeDefined()
|
||||||
|
expect(result[0].compareImages!.before).toHaveLength(1)
|
||||||
|
expect(result[0].compareImages!.after).toHaveLength(1)
|
||||||
|
expect(result[0].compareImages!.before[0].filename).toBe('before.png')
|
||||||
|
expect(result[0].compareImages!.after[0].filename).toBe('after.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles multiple batch images in a_images and b_images', () => {
|
||||||
|
const output = fromPartial<NodeExecutionOutput>({
|
||||||
|
a_images: [
|
||||||
|
{ filename: 'a1.png', subfolder: '', type: 'output' },
|
||||||
|
{ filename: 'a2.png', subfolder: '', type: 'output' }
|
||||||
|
],
|
||||||
|
b_images: [
|
||||||
|
{ filename: 'b1.png', subfolder: '', type: 'output' },
|
||||||
|
{ filename: 'b2.png', subfolder: '', type: 'output' },
|
||||||
|
{ filename: 'b3.png', subfolder: '', type: 'output' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = parseNodeOutput('5', output)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].compareImages!.before).toHaveLength(2)
|
||||||
|
expect(result[0].compareImages!.after).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles only a_images present', () => {
|
||||||
|
const output = fromPartial<NodeExecutionOutput>({
|
||||||
|
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = parseNodeOutput('1', output)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].mediaType).toBe('image_compare')
|
||||||
|
expect(result[0].compareImages!.before).toHaveLength(1)
|
||||||
|
expect(result[0].compareImages!.after).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles only b_images present', () => {
|
||||||
|
const output = fromPartial<NodeExecutionOutput>({
|
||||||
|
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = parseNodeOutput('1', output)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].mediaType).toBe('image_compare')
|
||||||
|
expect(result[0].compareImages!.before).toHaveLength(0)
|
||||||
|
expect(result[0].compareImages!.after).toHaveLength(1)
|
||||||
|
expect(result[0].filename).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips image compare when both a_images and b_images are empty', () => {
|
||||||
|
const output = fromPartial<NodeExecutionOutput>({
|
||||||
|
a_images: [],
|
||||||
|
b_images: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = parseNodeOutput('1', output)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe(parseTaskOutput, () => {
|
describe(parseTaskOutput, () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
|
|||||||
import { resultItemType } from '@/schemas/apiSchema'
|
import { resultItemType } from '@/schemas/apiSchema'
|
||||||
import { ResultItemImpl } from '@/stores/queueStore'
|
import { ResultItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
const METADATA_KEYS = new Set(['animated', 'text'])
|
const EXCLUDED_KEYS = new Set(['animated', 'text', 'a_images', 'b_images'])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that an unknown value is a well-formed ResultItem.
|
* Validates that an unknown value is a well-formed ResultItem.
|
||||||
@@ -27,17 +27,54 @@ function isResultItem(item: unknown): item is ResultItem {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toResultItems(
|
||||||
|
items: unknown[],
|
||||||
|
mediaType: string,
|
||||||
|
nodeId: string | number
|
||||||
|
): ResultItemImpl[] {
|
||||||
|
return items
|
||||||
|
.filter(isResultItem)
|
||||||
|
.map((item) => new ResultItemImpl({ ...item, mediaType, nodeId }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseImageCompare(
|
||||||
|
nodeOutput: NodeExecutionOutput,
|
||||||
|
nodeId: string | number
|
||||||
|
): ResultItemImpl | null {
|
||||||
|
const aImages = nodeOutput.a_images
|
||||||
|
const bImages = nodeOutput.b_images
|
||||||
|
if (!Array.isArray(aImages) && !Array.isArray(bImages)) return null
|
||||||
|
|
||||||
|
const before = Array.isArray(aImages)
|
||||||
|
? toResultItems(aImages, 'images', nodeId)
|
||||||
|
: []
|
||||||
|
const after = Array.isArray(bImages)
|
||||||
|
? toResultItems(bImages, 'images', nodeId)
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (before.length === 0 && after.length === 0) return null
|
||||||
|
|
||||||
|
return new ResultItemImpl({
|
||||||
|
filename: '',
|
||||||
|
subfolder: '',
|
||||||
|
type: 'output',
|
||||||
|
mediaType: 'image_compare',
|
||||||
|
nodeId,
|
||||||
|
compareImages: { before, after }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function parseNodeOutput(
|
export function parseNodeOutput(
|
||||||
nodeId: string | number,
|
nodeId: string | number,
|
||||||
nodeOutput: NodeExecutionOutput
|
nodeOutput: NodeExecutionOutput
|
||||||
): ResultItemImpl[] {
|
): ResultItemImpl[] {
|
||||||
return Object.entries(nodeOutput)
|
const regularItems = Object.entries(nodeOutput)
|
||||||
.filter(([key, value]) => !METADATA_KEYS.has(key) && Array.isArray(value))
|
.filter(([key, value]) => !EXCLUDED_KEYS.has(key) && Array.isArray(value))
|
||||||
.flatMap(([mediaType, items]) =>
|
.flatMap(([mediaType, items]) =>
|
||||||
(items as unknown[])
|
toResultItems(items as unknown[], mediaType, nodeId)
|
||||||
.filter(isResultItem)
|
|
||||||
.map((item) => new ResultItemImpl({ ...item, mediaType, nodeId }))
|
|
||||||
)
|
)
|
||||||
|
const compareItem = parseImageCompare(nodeOutput, nodeId)
|
||||||
|
return compareItem ? [compareItem, ...regularItems] : regularItems
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseTaskOutput(
|
export function parseTaskOutput(
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ function dragDrop(e: DragEvent) {
|
|||||||
@drop="dragDrop"
|
@drop="dragDrop"
|
||||||
>
|
>
|
||||||
<LinearProgressBar
|
<LinearProgressBar
|
||||||
|
data-testid="linear-header-progress-bar"
|
||||||
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
|
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
|
||||||
/>
|
/>
|
||||||
<LinearPreview
|
<LinearPreview
|
||||||
|
|||||||
Reference in New Issue
Block a user