From f2fc21d73510c917c7e7779743a10a11519724c8 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Thu, 5 Nov 2026 15:25:40 +0330 Subject: [PATCH] Open a layer capture's upscale as a new document at full size Upscaling a captured layer imported the result as a floating layer in the artist's document. Photoshop's default "Resize Image During Place" shrinks anything larger than the canvas to fit, so a 4x result of a 1024 px layer came back at 25% and looked exactly like the layer it came from (G4 smoke run 2026-11-05, finding 2). With the preference off it overflows the canvas on every side instead, which a3ea019 already called a bug. Growing the document, as a canvas capture does, is wrong for a layer: it would resample every other layer by a factor that means nothing to them. So a layer capture's result now opens as a document of its own, made at the result's exact size and the source document's resolution, with the result placed in it at 100%. Nothing in the artist's document changes. The place has nothing to shrink whichever way the preference is set, and a bounds check proves it; on any failure the new document is closed unsaved. Canvas captures, and every other fallback, are unchanged. Smoke step 2.3 and the landing-page Upscale caption now describe what happens. Co-Authored-By: Claude Opus 5.5 --- docs/index.html | 2 +- docs/uxp-smoke-checklist.md | 3 +- src/photoshop/photoshopAdapter.ts | 130 +++++++++++++++++++++++++- src/photoshop/upscaleResize.ts | 42 +++++++++ src/ui/App.ts | 34 ++++++- tests/photoshop/upscaleResize.test.ts | 36 +++++++ 6 files changed, 243 insertions(+), 4 deletions(-) diff --git a/docs/index.html b/docs/index.html index 263b0c1..a4e317a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -281,7 +281,7 @@

Proof from the real panel in Photoshop

OpenLayer Upscale result displayed in Photoshop with generated layers visible in the Layers panel. -
Upscale stays layer-nativeLocal model upscaling returns to the active document as a named layer instead of a detached download.
+
Upscale stays layer-nativeLocal model upscaling comes back as a named layer instead of a detached download: a captured canvas grows its document, a captured layer opens at full size as a new document.
OpenLayer Live Painting with a sticky tool header and preview beside the active canvas in Photoshop. diff --git a/docs/uxp-smoke-checklist.md b/docs/uxp-smoke-checklist.md index 2e16ca6..5d20d55 100644 --- a/docs/uxp-smoke-checklist.md +++ b/docs/uxp-smoke-checklist.md @@ -62,7 +62,8 @@ Layer and canvas capture, a selection, alignment on import. "Selection captured". (Generating is optional here: Inpaint needs heavier models.) - [ ] 2.3 Home › **Upscale** › **Capture Active Layer**, then **Generate Upscale** and **Import to - Layers**. **See:** a larger layer named as an upscale. + Layers**. **See:** a new document at the result's full size (4x the layer with 4x-UltraSharp), + holding one layer named as an upscale, at 100%. The original document is unchanged. ## Block 3: tools that write their own layers (2 minutes) diff --git a/src/photoshop/photoshopAdapter.ts b/src/photoshop/photoshopAdapter.ts index 9eac03c..2689a71 100644 --- a/src/photoshop/photoshopAdapter.ts +++ b/src/photoshop/photoshopAdapter.ts @@ -3,7 +3,7 @@ import { createOpenLayerError, getErrorMessage } from "../utils/errors"; import { encodeRgbaPng, setPngResolution } from "../utils/png"; import { calculatePlacementOffset, createOpaqueGrayscaleMaskPng, PixelDimensions } from "./exactInpaintMask"; import { OutpaintExpansionPlan } from "./outpaintExpansion"; -import { UpscaleResizePlan } from "./upscaleResize"; +import { resolveUpscaleDocumentResolution, UpscaleNewDocumentPlan, UpscaleResizePlan } from "./upscaleResize"; import { classifyPlateSample, isPlateRedundant, @@ -45,6 +45,13 @@ type PhotoshopModule = { app: { activeDocument?: PhotoshopDocument; documents?: PhotoshopDocument[]; + // Only the Upscale layer-capture import makes a document. Called through + // `app` every time: a method pulled off a UXP object loses its `this`. + createDocument?: (options: PhotoshopDocumentCreateOptions) => Promise; + }; + constants?: { + NewDocumentMode?: { RGB?: string }; + DocumentFill?: { TRANSPARENT?: string }; }; action: { batchPlay: (commands: unknown[], options: Record) => Promise; @@ -89,6 +96,16 @@ type PhotoshopDocument = { save: (name?: string) => Promise; load: (channel: PhotoshopChannel) => Promise; }; + closeWithoutSaving?: () => Promise | void; +}; + +type PhotoshopDocumentCreateOptions = { + name: string; + width: number; + height: number; + resolution: number; + mode: string; + fill: string; }; type PhotoshopChannel = { @@ -1426,6 +1443,117 @@ export async function importUpscaleResultResizingDocument( } } +export type UpscaleNewDocumentImportOptions = { + blob: Blob; + originatingDocument: PhotoshopDocumentIdentity | null; + plan: UpscaleNewDocumentPlan; + layerName?: string; + onProgress?: ImportProgress; +}; + +// Opens a layer capture's upscale as a document of its own, at the result's +// true size. Why a new document and not the artist's: see +// `UpscaleNewDocumentPlan` in upscaleResize.ts. In short, their document has no right size for it, and +// Photoshop's "Resize Image During Place" shrank it back to the layer it came +// from. +// +// The document is made the result's exact size, so the place has nothing to +// shrink whichever way that preference is set; the bounds check below proves it +// rather than assuming it. It is transparent so Photoshop's own Place replaces +// its empty starting layer, leaving the result as the only layer. The artist's +// own document is never touched: if anything fails after the new document +// exists, that document is closed unsaved and theirs is exactly as it was. +export async function importUpscaleResultAsNewDocument( + options: UpscaleNewDocumentImportOptions +): Promise { + const photoshop = getPhotoshop(); + const layerName = options.layerName ?? createLayerName("OpenLayer_Upscale"); + let file: UxpFile | undefined; + + try { + assertActiveDocumentMatchesOrigin(photoshop, options.originatingDocument); + + if (!options.blob || options.blob.size === 0) { + throw new Error("The generated upscale image blob is empty."); + } + + if (typeof photoshop.app.createDocument !== "function") { + throw new Error("This version of Photoshop does not let a plugin create a document."); + } + + // Read before the new document exists: saveBlobForPlacement stamps the PNG + // with the active document's resolution, and the new document must match. + const resolution = resolveUpscaleDocumentResolution(getActiveDocument().resolution); + options.onProgress?.("Saving upscale result to a temporary PNG..."); + file = await saveBlobForPlacement(options.blob, `${layerName}.png`); + const uxp = getUxp(); + options.onProgress?.("Creating Photoshop file token..."); + const token = await uxp.storage.localFileSystem.createSessionToken(file); + + options.onProgress?.("Opening the upscale result as a new document..."); + await photoshop.core.executeAsModal( + async () => { + assertActiveDocumentMatchesOrigin(photoshop, options.originatingDocument); + const { width, height } = options.plan; + const created = await photoshop.app.createDocument!({ + name: layerName, + width, + height, + resolution, + mode: photoshop.constants?.NewDocumentMode?.RGB ?? "RGBColorMode", + fill: photoshop.constants?.DocumentFill?.TRANSPARENT ?? "transparent" + }); + const newDocument = created ?? getActiveDocument(); + + try { + const madeWidth = Math.round(Number(newDocument.width ?? 0)); + const madeHeight = Math.round(Number(newDocument.height ?? 0)); + if (madeWidth !== width || madeHeight !== height) { + throw new Error(`Photoshop made a ${madeWidth} x ${madeHeight} document, not ${width} x ${height}.`); + } + + await placeFileAsLayer(photoshop, token); + await renameActiveLayer(photoshop, layerName); + await alignActiveLayerToBounds( + photoshop, + normalizeSelectionBounds({ left: 0, top: 0, right: width, bottom: height }) + ); + + const placedBounds = await readActiveLayerBounds(photoshop); + if ( + placedBounds.left !== 0 || + placedBounds.top !== 0 || + placedBounds.width !== width || + placedBounds.height !== height + ) { + throw new Error( + `The placed upscale layer covers ${placedBounds.width} x ${placedBounds.height} at ${placedBounds.left}, ${placedBounds.top}, not the ${width} x ${height} document.` + ); + } + } catch (caughtError) { + try { + await newDocument.closeWithoutSaving?.(); + } catch { + // The error that got us here is the one worth reporting. + } + throw caughtError; + } + }, + { commandName: "Open OpenLayer Upscale As New Document" } + ); + + options.onProgress?.(`Opened ${layerName} as a new document.`); + return layerName; + } catch (caughtError) { + throw createOpenLayerError( + "PHOTOSHOP_IMPORT_FAILED", + `Could not open the upscale result as a new document. ${getErrorMessage(caughtError)} Your document was not changed.` + ); + } finally { + await deleteTemporaryFileBestEffort(file); + } +} + // Same guard as the outpaint path: the document must still be the size that was // captured, or resampling it would scale the artist's work by the wrong factor. function assertCanvasMatchesUpscalePlan(document: PhotoshopDocument, plan: UpscaleResizePlan) { diff --git a/src/photoshop/upscaleResize.ts b/src/photoshop/upscaleResize.ts index cfb1e5e..03485b5 100644 --- a/src/photoshop/upscaleResize.ts +++ b/src/photoshop/upscaleResize.ts @@ -79,6 +79,48 @@ export function validateUpscaleResultDimensions( return null; } +/** + * A layer capture's upscale opens as a document of its own, at the result's + * true size. + * + * A layer has no relationship to the canvas, so growing the document (above) + * would resample every other layer by a factor that means nothing to them. + * Placing the result into the document does not work either: Photoshop's + * default "Resize Image During Place" shrinks anything larger than the canvas + * to fit, so a 4x result of a full-canvas layer came back at 25% and looked + * exactly like the layer it came from (G4 smoke run, 2026-11-05, finding 2). + * With that preference off it overflowed the canvas on every side instead + * (a3ea019). A document of its own is the one place the artist sees what they + * upscaled whichever way the preference is set, and their own document is left + * untouched. Canvas captures keep growing the document. + */ +export type UpscaleNewDocumentPlan = Readonly<{ width: number; height: number }>; + +/** The new document is exactly the result's size; null when that size is unusable. */ +export function createUpscaleNewDocumentPlan(result: PixelDimensions | null): UpscaleNewDocumentPlan | null { + if (!result) { + return null; + } + + const width = Math.round(result.width); + const height = Math.round(result.height); + + return isPositiveInteger(width) && isPositiveInteger(height) ? { width, height } : null; +} + +// Photoshop's own default for a new document. +const DEFAULT_PIXELS_PER_INCH = 72; + +/** + * The new document takes the source document's resolution. The placed PNG is + * stamped with that same value (saveBlobForPlacement), and a mismatch between + * the two is what made Photoshop scale placed images by the ppi ratio (#156). + */ +export function resolveUpscaleDocumentResolution(sourcePixelsPerInch: number | undefined): number { + const value = Number(sourcePixelsPerInch); + return Number.isFinite(value) && value > 0 ? value : DEFAULT_PIXELS_PER_INCH; +} + // "4x" reads better than "4.0000000001x"; a non-integer factor keeps one // decimal so an unexpected scale is still visible rather than rounded away. export function formatUpscaleScale(scale: number): string { diff --git a/src/ui/App.ts b/src/ui/App.ts index 6dfdd04..867392b 100644 --- a/src/ui/App.ts +++ b/src/ui/App.ts @@ -192,6 +192,7 @@ import { importImageAlignedToSelectionWithLayerMask, importOutpaintResultExpandingCanvas, importUpscaleResultResizingDocument, + importUpscaleResultAsNewDocument, readLiveSelection } from "../photoshop/photoshopAdapter"; import { @@ -232,6 +233,7 @@ import { bindPromptMemory } from "./promptMemory"; import { bindPromptWallet, PROMPT_WALLET_TOOLS, PromptWalletTool } from "./promptWallet"; import { bindPromptRequiredReset, PROMPT_REQUIRED_STATUS } from "./promptRequiredReset"; import { + createUpscaleNewDocumentPlan, createUpscaleResizePlan, formatUpscaleScale, validateUpscaleResultDimensions @@ -5328,7 +5330,37 @@ export function renderApp(rootElement: HTMLElement) { return; } } else if (importContext) { - resizeBlockedReason = "This result was upscaled from a layer capture, which carries no canvas relationship; importing it as a floating layer instead."; + // A layer capture opens as a document of its own at the result's true + // size: placed into theirs, Photoshop shrank it back to the layer it + // came from. See UpscaleNewDocumentPlan. + const documentPlan = createUpscaleNewDocumentPlan(await readImageDimensionsFromBlob(importResultImage.blob)); + + if (documentPlan) { + setUpscaleStatus(elements, "Opening the upscale result as a new document...", "idle"); + const openedLayerName = await importUpscaleResultAsNewDocument({ + blob: importResultImage.blob, + originatingDocument: importResultImage.originatingDocument, + plan: documentPlan, + layerName, + onProgress: (message) => { + setUpscaleStatus(elements, message, "idle"); + setUpscaleDiagnostics(elements, message); + } + }); + setUpscaleStatus(elements, `Opened as a new document: ${openedLayerName}`, "ready"); + flashImported(elements.upscaleStatusText); + markHistoryImported(elements, historyEntries, importResultImage, openedLayerName); + const documentMetadataMessage = await writeMetadataForImportedResult(historyEntries, importResultImage, openedLayerName, (message) => { + setUpscaleDiagnostics(elements, message); + }); + setUpscaleDiagnostics( + elements, + `Opened ${openedLayerName} as a new ${documentPlan.width} x ${documentPlan.height} document, because a layer capture has no canvas to resize. Your original document was not changed. ${documentMetadataMessage}` + ); + return; + } + + resizeBlockedReason = "The size of this upscale result could not be read, so it was imported as a floating layer instead of a new document."; } else { resizeBlockedReason = "This result predates document-resizing support; importing it as a floating layer."; } diff --git a/tests/photoshop/upscaleResize.test.ts b/tests/photoshop/upscaleResize.test.ts index fbdcf35..ee56f45 100644 --- a/tests/photoshop/upscaleResize.test.ts +++ b/tests/photoshop/upscaleResize.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + createUpscaleNewDocumentPlan, createUpscaleResizePlan, formatUpscaleScale, + resolveUpscaleDocumentResolution, validateUpscaleResultDimensions } from "../../src/photoshop/upscaleResize"; @@ -86,3 +88,37 @@ describe("upscale scale formatting", () => { expect(formatUpscaleScale(Number.NaN)).toBe("unknown scale"); }); }); + +// G4 smoke run 2026-11-05, finding 2: a layer capture's 4x result was placed +// into the artist's document, where "Resize Image During Place" shrank it to +// 25%. It now opens as a document of its own at the result's true size. +describe("upscale new-document plan (layer captures)", () => { + it("is exactly the result's size, so the place has nothing to shrink", () => { + expect(createUpscaleNewDocumentPlan({ width: 4096, height: 4096 })).toEqual({ width: 4096, height: 4096 }); + expect(createUpscaleNewDocumentPlan({ width: 2048, height: 1536 })).toEqual({ width: 2048, height: 1536 }); + }); + + it("rounds a fractional reading rather than making a document a pixel short", () => { + expect(createUpscaleNewDocumentPlan({ width: 4095.6, height: 4096.4 })).toEqual({ width: 4096, height: 4096 }); + }); + + it("refuses a size it cannot use, so the import falls back to the floating layer", () => { + expect(createUpscaleNewDocumentPlan(null)).toBeNull(); + expect(createUpscaleNewDocumentPlan({ width: 0, height: 4096 })).toBeNull(); + expect(createUpscaleNewDocumentPlan({ width: 4096, height: -1 })).toBeNull(); + expect(createUpscaleNewDocumentPlan({ width: Number.NaN, height: 4096 })).toBeNull(); + }); +}); + +describe("upscale new-document resolution", () => { + it("keeps the source document's resolution, which the placed PNG is stamped with", () => { + expect(resolveUpscaleDocumentResolution(240)).toBe(240); + expect(resolveUpscaleDocumentResolution(72)).toBe(72); + }); + + it("falls back to Photoshop's 72 ppi when the source reports none", () => { + expect(resolveUpscaleDocumentResolution(undefined)).toBe(72); + expect(resolveUpscaleDocumentResolution(0)).toBe(72); + expect(resolveUpscaleDocumentResolution(Number.NaN)).toBe(72); + }); +});