We build an AI virtual staging tool for listing photos. This post is about a small free tool next to it with no AI in it: pick your province or state and your MLS, then a photo, and the browser stamps a "virtually staged" label on it and saves a new JPEG. The photo never leaves the device.
Stamping is the easy part. The harder part is that for 12 of the 32 MLS and regulator rules in the tool's data, the correct output is no label on the photo at all, and for 9 more it is "ask your MLS first".
Why a label needs a rulebook
Virtually staged listing photos have to be disclosed, and the rulebooks disagree about where. Four of the rules the tool quotes:
- The upstate New York Alliance MLSs require a text overlay reading exactly "Virtually Staged." on every staged photo since 1 September 2026; a caption or a line in the remarks alone is not acceptable.
- Florida's Stellar MLS wants the words in the photo description and the public remarks. Words on the photo itself are "strictly prohibited".
- Arizona's ARMLS does require a mark on the photo, but it is the MLS's own "Digitally Altered" watermark, applied in the MLS's listing system, and rule 8.23(e) bans any other text or watermark on the image.
- Tucson's MLSSAZ wants the disclosure "prominently placed along the top of the image".
The last two sit side by side in our Arizona rules article. A plain watermark tool would stamp all four photos and break two of the rules. So this one reads the rule before it touches the canvas.
Rules as data, the decision as code
The rules already existed as data: 32 options across 15 markets, built for our disclosure wording lookup. Each option records one of eight verdicts on words on the photo (required, banned, restricted, not-required, allowed, no-rule, not-addressed, unverified), the words the rule gives, and where each goes: the photo, the photo description field, the public remarks. Every sentence is copied verbatim from our published rules guides, and a separate guard checks 350 of those strings against the articles' text on every run.
The label tool adds no facts. It turns each option into a plan with one of three modes:
const NO_STAMP: PhotoVerdict[] = ["banned", "not-required"];
const CONFIRM: PhotoVerdict[] = ["restricted", "unverified"];
function modeFor(o: DisclosureOption): StampMode {
if (NO_STAMP.includes(o.onPhoto.verdict)) return "no-stamp";
const onPhoto = o.label.copy.filter((c) => c.at.includes("photo"));
// A mark is required on the photo, yet none of the rule's words go there: the MLS stamps its own watermark.
if (o.onPhoto.verdict === "required" && !onPhoto.length) return "no-stamp";
if (CONFIRM.includes(o.onPhoto.verdict)) return "confirm";
return "stamp";
}
/** The one gate between the data and the canvas: a no-stamp plan never stamps; a confirm plan only once confirmed. */
export function mayStamp(plan: LabelPlan, confirmed: boolean): boolean {
if (plan.texts.length === 0) return false;
return plan.mode === "stamp" || (plan.mode === "confirm" && confirmed);
}
The second if in modeFor is the ARMLS case. Its verdict is "required", which a naive mapping would turn into "stamp". But none of its words are placed on the photo: the data puts "Virtually staged" in the photo description field, because the mark on the photo is the MLS's job. Required, yet none of the rule's words on the photo, means refuse. There is no special case by id; it falls out of the data, and the guard names ARMLS in case the data changes shape.
Across the 32 options the split today is 11 stamp, 9 confirm and 12 no-stamp: eight bans, three rules that send the label to the listing fields, and ARMLS.
The words come from the same data, in order of how much say the rule has:
- If the rule fixes exact words for the photo, those and nothing else. The Alliance plan offers only "Virtually Staged.", full stop included.
- Otherwise, any words the data places on the photo. Quebec's plan offers the regulator's example in French and in English; the French one, "Image générée par l'intelligence artificielle", is the longest label in the data at 45 characters.
- Otherwise, terms the rule fixes or lists for other places, then a fallback, then the rest. The fallback, "Virtually staged", comes from our disclosure FAQ: "Where no wording is prescribed, 'Virtually staged' is enough for a badge or a caption." The guard checks that sentence is still there.
The list is deduplicated case-insensitively, so a lowercase "virtually staged" reported for one MLS does not appear as a second choice next to the fallback.
Placement is data too, more crudely. A plan is top-only when one of its quoted rule sentences matches /along the top of the image/i. A regex over prose is brittle, so the guard asserts that exactly one plan, MLSSAZ, is top-only. There the position picker offers only the three top positions, and a bottom choice carried over from another MLS moves to the top.
What refusing looks like
For a no-stamp plan the tool shows the verdict and the rule's reasons, verbatim, then "The tool will not stamp this photo." and where the words go instead: for Stellar, "Virtually staged" in the photo description field and "One or more photo(s) was virtually staged." in the public remarks. The canvas element is not rendered at all.
For a confirm plan (six rules that restrict text on photos, three rulebooks we have not read) there is a checkbox: "I checked with my MLS: a plain label on the photo is allowed." The tick is stored as the id of its plan, so ticking it for Alberta and switching to Texas's NTREIS does not carry it over.
mayStamp is the only gate. The component computes allowed from it once, and both the drawing effect and the save handler return early without it.
The canvas part
The photo is read with URL.createObjectURL into an Image that never enters the DOM, drawn onto a canvas, and saved with canvas.toBlob(cb, "image/jpeg", 0.92) through a temporary download link. No request carries it.
Size cap. Anything above 16,000,000 pixels is scaled by sqrt(16,000,000 / (width × height)), because iOS Safari refuses larger canvases (around 16.7 million pixels, a reported figure we have not probed). A 6000×4000 photo comes out at 4899×3266: 16,000,134 pixels after rounding, a hair over our cap and under the browser's limit, so we left it.
Text size. The label scales with the shorter side of the image, so a portrait and a landscape photo get the same visual weight. Trimmed from drawLabel, with the size lookup pulled out into k:
const base = Math.min(w, h);
const margin = Math.round(base * 0.03);
let px = Math.max(12, Math.round(base * k)); // k = 0.035, 0.05 or 0.07: Small, Medium, Large
ctx.font = FONT.replace("{px}", String(px));
let tw = ctx.measureText(text).width;
const room = w - 2 * margin - 1.2 * px; // the box's side padding is 0.6 * px on each side
if (tw > room && tw > 0) {
px = Math.max(10, Math.floor((px * room) / tw));
ctx.font = FONT.replace("{px}", String(px));
tw = ctx.measureText(text).width;
}
On a 12-megapixel 4032×3024 photo, Medium gives 151 px text and a 91 px margin. The shrink step is for long labels on narrow photos, such as the 45-character Quebec label at Large on a portrait shot. The text is white, semi-bold system-ui, on a black box at 62% alpha, with an opacity slider from 50% to 100%.
EXIF orientation. We wrote no orientation code. The tool relies on the browser applying the EXIF orientation when it decodes the image, so drawImage gets an upright bitmap. We checked rather than assumed: a 300×200 JPEG tagged orientation 6 (rotate 90° clockwise), loaded in Chromium 152, reported 200×300 with the stored top half on the right, and the live tool drew it on a 200×300 canvas. We have not run the probe in Safari or Firefox.
EXIF stripping. A canvas holds pixels, not metadata, so toBlob writes a fresh JPEG. The same probe carried an EXIF block with a made-up camera make; the output started with a JFIF header, had no APP1 segment, and the make string was gone. Camera, location and date live in that segment. The price is a second JPEG generation at quality 0.92, plus the size cap.
What we test
The tool shipped with a guard of 283 checks in our regular QA run, in four groups:
- Nothing invented. One plan per data option, in order; every stamp word is the option's own or the fallback; verdicts and reasons verbatim.
-
Refuses where it must. Each mode recomputed independently from its verdict; the eight bans, ARMLS and CRMLS named one by one; the Alliance's exact "Virtually Staged."; MLSSAZ as the only top-only plan;
mayStampwith and without the tick, and on hand-made plans: a no-stamp plan given words still does not stamp. -
The photo stays on the device. A static scan of the component's source for
fetch,XMLHttpRequest,sendBeacon,WebSocket,EventSource, dynamicimport(), http(s) URLs, analytics calls, workers and form submits; three allowed imports;createObjectURLin,toBlobas JPEG out; no label words typed into the component; the brand suffix off by default. - The page. Title, canonical, structured data without a price, and links from the sitemap, the tools hub and the rules hub article.
The guard ships with a mutation job of 29 deliberate breaks, each named for the harm it would do: drop "banned" from the no-stamp list; let a confirm plan stamp without the tick; post the file to an API route when it is picked; sendBeacon the blob on save; save as PNG; let a confirmation for one MLS carry over to another. At merge, all 29 turned the guard red.
The static scan has a known limit: it reads one file's source. The page, like every page on the site, loads our page-view analytics; the scan only says the photo path calls nothing. So we also checked the live page: choosing a rule and loading a photo added no entries to the browser's resource timing list. Orientation has no guard; it rests on the probe above.
What it does not do
- It is not legal advice. Rules change; the page says the MLS handbook and the broker are the final word.
- It does not apply an MLS's own watermark, fill in listing fields, or upload anything. Where the label belongs in a field, it shows the words and stops.
- It does not handle the unstaged original, which several rules want directly before, after or next to the staged photo.
- One photo at a time, one line of text, JPEG out. Photos above 16 megapixels come back smaller.
- The confirm checkbox is trust, not verification.
- The "AI Flip Room" suffix is opt-in and warns that many MLSs ban company names on photos, but it is not blocked per rule: an MLSSAZ user can still tick it.
- It knows the 15 markets our guides cover, five in Canada and ten in the US. A state outside them is not in the picker.
The tool is at aifliproom.com/tools/virtually-staged-label.
Top comments (0)