diff --git a/browser_tests/assets/viewport/default-viewport-saved-offscreen.json b/browser_tests/assets/viewport/default-viewport-saved-offscreen.json new file mode 100644 index 0000000000..348c8aee13 --- /dev/null +++ b/browser_tests/assets/viewport/default-viewport-saved-offscreen.json @@ -0,0 +1,412 @@ +{ + "id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae", + "revision": 0, + "last_node_id": 16, + "last_link_id": 9, + "nodes": [ + { + "id": 4, + "type": "CheckpointLoaderSimple", + "pos": [ + 60, + 200 + ], + "size": [ + 315, + 98 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 1 + ] + }, + { + "name": "CLIP", + "type": "CLIP", + "slot_index": 1, + "links": [ + 3, + 5 + ] + }, + { + "name": "VAE", + "type": "VAE", + "slot_index": 2, + "links": [ + 8 + ] + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple", + "cnr_id": "comfy-core", + "ver": "0.3.65", + "models": [ + { + "name": "v1-5-pruned-emaonly-fp16.safetensors", + "url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true", + "directory": "checkpoints" + } + ] + }, + "widgets_values": [ + "v1-5-pruned-emaonly-fp16.safetensors" + ] + }, + { + "id": 3, + "type": "KSampler", + "pos": [ + 870, + 170 + ], + "size": [ + 315, + 474 + ], + "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", + "slot_index": 0, + "links": [ + 7 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler", + "cnr_id": "comfy-core", + "ver": "0.3.65" + }, + "widgets_values": [ + 685468484323813, + "randomize", + 20, + 8, + "euler", + "normal", + 1 + ] + }, + { + "id": 8, + "type": "VAEDecode", + "pos": [ + 975, + 700 + ], + "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", + "slot_index": 0, + "links": [] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.3.65" + }, + "widgets_values": [] + }, + { + "id": 7, + "type": "CLIPTextEncode", + "pos": [ + 410, + 410 + ], + "size": [ + 425.27801513671875, + 180.6060791015625 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 5 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 6 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.65" + }, + "widgets_values": [ + "text, watermark" + ], + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 5, + "type": "EmptyLatentImage", + "pos": [ + 520, + 690 + ], + "size": [ + 315, + 106 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage", + "cnr_id": "comfy-core", + "ver": "0.3.65" + }, + "widgets_values": [ + 512, + 512, + 1 + ] + }, + { + "id": 6, + "type": "CLIPTextEncode", + "pos": [ + 411.21649169921875, + 203.68695068359375 + ], + "size": [ + 422.84503173828125, + 164.31304931640625 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 3 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 4 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.65" + }, + "widgets_values": [ + "beautiful scenery nature glass bottle landscape, purple galaxy bottle," + ], + "color": "#232", + "bgcolor": "#353" + } + ], + "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" + ] + ], + "groups": [ + { + "id": 1, + "title": "Step 1 - Load model", + "bounding": [ + 50, + 130, + 335, + 181.60000610351562 + ], + "color": "#3f789e", + "font_size": 24, + "flags": {} + }, + { + "id": 2, + "title": "Step 3 - Image size", + "bounding": [ + 510, + 620, + 335, + 189.60000610351562 + ], + "color": "#3f789e", + "font_size": 24, + "flags": {} + }, + { + "id": 3, + "title": "Step 2 - Prompt", + "bounding": [ + 400, + 130, + 445.27801513671875, + 467.2060852050781 + ], + "color": "#3f789e", + "font_size": 24, + "flags": {} + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.44218252181616574, + "offset": [ + -666.5670907104311, + -2227.894644048147 + ] + }, + "frontendVersion": "1.35.3", + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true, + "workflowRendererVersion": "LG" + }, + "version": 0.4 +} \ No newline at end of file diff --git a/browser_tests/tests/viewport.spec.ts b/browser_tests/tests/viewport.spec.ts new file mode 100644 index 0000000000..7bc686a1cf --- /dev/null +++ b/browser_tests/tests/viewport.spec.ts @@ -0,0 +1,20 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +test.describe('Viewport', () => { + test('Fits view to nodes when saved viewport position is offscreen', async ({ + comfyPage + }) => { + await comfyPage.loadWorkflow('viewport/default-viewport-saved-offscreen') + + // Wait a few frames for rendering to stabilize + for (let i = 0; i < 5; i++) { + await comfyPage.nextFrame() + } + + await expect(comfyPage.canvas).toHaveScreenshot( + 'viewport-fits-when-saved-offscreen.png' + ) + }) +}) diff --git a/browser_tests/tests/viewport.spec.ts-snapshots/viewport-fits-when-saved-offscreen-chromium-linux.png b/browser_tests/tests/viewport.spec.ts-snapshots/viewport-fits-when-saved-offscreen-chromium-linux.png new file mode 100644 index 0000000000..9d1cb0604c Binary files /dev/null and b/browser_tests/tests/viewport.spec.ts-snapshots/viewport-fits-when-saved-offscreen-chromium-linux.png differ diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 7201fb68ca..b9335b353e 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -64,6 +64,7 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy' import { type ExtensionManager } from '@/types/extensionTypes' import type { NodeExecutionId } from '@/types/nodeIdentification' import { graphToPrompt } from '@/utils/executionUtil' +import { anyItemOverlapsRect } from '@/utils/mathUtil' import { collectAllNodes, forEachNode } from '@/utils/graphTraversalUtil' import { getNodeByExecutionId, @@ -1222,13 +1223,19 @@ export class ComfyApp { if (graphData.extra?.ds) { this.canvas.ds.offset = graphData.extra.ds.offset this.canvas.ds.scale = graphData.extra.ds.scale + + // Fit view if no nodes visible in restored viewport + this.canvas.ds.computeVisibleArea(this.canvas.viewport) + if ( + !anyItemOverlapsRect( + this.rootGraph._nodes, + this.canvas.visible_area + ) + ) { + requestAnimationFrame(() => useLitegraphService().fitView()) + } } else { - // @note: Set view after the graph has been rendered once. fitView uses - // boundingRect on nodes to calculate the view bounds, which only become - // available after the first render. - requestAnimationFrame(() => { - useLitegraphService().fitView() - }) + requestAnimationFrame(() => useLitegraphService().fitView()) } } } catch (error) { diff --git a/src/utils/mathUtil.ts b/src/utils/mathUtil.ts index 4dad40df54..e13d26fe91 100644 --- a/src/utils/mathUtil.ts +++ b/src/utils/mathUtil.ts @@ -1,6 +1,9 @@ import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces' import type { Bounds } from '@/renderer/core/layout/types' +/** Simple 2D point or size as [x, y] or [width, height] */ +type Vec2 = readonly [number, number] + /** * Finds the greatest common divisor (GCD) for two numbers using iterative * Euclidean algorithm. Uses iteration instead of recursion to avoid stack @@ -91,3 +94,28 @@ export function computeUnionBounds( height: maxY - minY } } + +/** + * Checks if any item with pos/size overlaps a rectangle (AABB test). + * @param items Items with pos [x, y] and size [width, height] + * @param rect Rectangle as [x, y, width, height] + * @returns `true` if any item overlaps the rect + */ +export function anyItemOverlapsRect( + items: Iterable<{ pos: Vec2; size: Vec2 }>, + rect: ReadOnlyRect +): boolean { + const rectRight = rect[0] + rect[2] + const rectBottom = rect[1] + rect[3] + + for (const item of items) { + const overlaps = + item.pos[0] < rectRight && + item.pos[0] + item.size[0] > rect[0] && + item.pos[1] < rectBottom && + item.pos[1] + item.size[1] > rect[1] + + if (overlaps) return true + } + return false +}