- 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.
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);
+ });
+});