Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ <h2 id="showcase-title">Proof from the real panel in Photoshop</h2>
</figure>
<figure class="shot-wide">
<img src="assets/v060/upscale-photoshop.webp" alt="OpenLayer Upscale result displayed in Photoshop with generated layers visible in the Layers panel." width="1200" height="778" loading="lazy" decoding="async">
<figcaption><strong>Upscale stays layer-native</strong><span>Local model upscaling returns to the active document as a named layer instead of a detached download.</span></figcaption>
<figcaption><strong>Upscale stays layer-native</strong><span>Local 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.</span></figcaption>
</figure>
<figure class="shot-wide">
<img src="assets/v060/sticky-live-painting.webp" alt="OpenLayer Live Painting with a sticky tool header and preview beside the active canvas in Photoshop." width="1000" height="920" loading="lazy" decoding="async">
Expand Down
3 changes: 2 additions & 1 deletion docs/uxp-smoke-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
130 changes: 129 additions & 1 deletion src/photoshop/photoshopAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<PhotoshopDocument | null | undefined>;
};
constants?: {
NewDocumentMode?: { RGB?: string };
DocumentFill?: { TRANSPARENT?: string };
};
action: {
batchPlay: (commands: unknown[], options: Record<string, unknown>) => Promise<unknown[]>;
Expand Down Expand Up @@ -89,6 +96,16 @@ type PhotoshopDocument = {
save: (name?: string) => Promise<void>;
load: (channel: PhotoshopChannel) => Promise<void>;
};
closeWithoutSaving?: () => Promise<void> | void;
};

type PhotoshopDocumentCreateOptions = {
name: string;
width: number;
height: number;
resolution: number;
mode: string;
fill: string;
};

type PhotoshopChannel = {
Expand Down Expand Up @@ -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<string> {
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) {
Expand Down
42 changes: 42 additions & 0 deletions src/photoshop/upscaleResize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 33 additions & 1 deletion src/ui/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ import {
importImageAlignedToSelectionWithLayerMask,
importOutpaintResultExpandingCanvas,
importUpscaleResultResizingDocument,
importUpscaleResultAsNewDocument,
readLiveSelection
} from "../photoshop/photoshopAdapter";
import {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.";
}
Expand Down
36 changes: 36 additions & 0 deletions tests/photoshop/upscaleResize.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
createUpscaleNewDocumentPlan,
createUpscaleResizePlan,
formatUpscaleScale,
resolveUpscaleDocumentResolution,
validateUpscaleResultDimensions
} from "../../src/photoshop/upscaleResize";

Expand Down Expand Up @@ -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);
});
});
Loading