Premium step 2 — watermarked preview pipeline

PREMIUM_DESIGNS_FEATURE.md §5.4 + §19 item 2. Renders the current
artboard as a low-res, watermarked JPEG suitable for embedding in
the premium publish event so buyers can see what they'd be paying
for. Budget-bounded so the event still fits in standard relay
limits after the encrypted blob is also added.

renderPremiumPreviewDataUrl()
  - Picks an export DPI from the artboard's long-axis inches so the
    initial render lands close to the 720 px cap (no wasted huge
    rasterize for a 20-inch custom art canvas, no upscaling-from-
    tiny for a 4.7-inch disc design).
  - Re-uses the existing render*ToExportCanvas() helpers under a
    new _renderModeToExportCanvas dispatcher so we don't duplicate
    the per-mode draw logic.
  - Downscales the result into a target-size canvas (max 720 px long
    axis), draws the watermark, JPEG-encodes at quality 0.65.
  - Budget loop: if the data URL exceeds 50 KB, shrinks long-axis by
    22% and re-tiles + re-encodes (the same source canvas is reused;
    no extra rasterize). Caps at 4 attempts down to a 360 px floor;
    throws a precise "preview is too large" error otherwise so the
    publish modal can tell the creator to simplify.
  - Pre-checks _exportFailedImages so a CORS-tainted artboard fails
    fast with a clear message instead of producing a broken preview.

_drawPremiumWatermark()
  - Tiles "ARTSTR PREMIUM · UNLOCK TO USE" across the entire image
    at -18°. Uses ctx.measureText so the horizontal step is the
    actual rendered text width + a comfortable gap — adjacent
    copies never overlap and the text is always readable. Rows
    stagger by half a step so the pattern doesn't read as a strict
    grid. White stroke at 45% opacity under a slate fill at 26%
    so it reads on both light and dark designs.

PREMIUM_PREVIEW constants
  - MAX_LONG_AXIS_PX = 720, QUALITY = 0.65, MAX_BYTES = 50*1024,
    MIN_LONG_AXIS_PX = 360. Tunable in one place.

Restricted to modes the JPEG export already supports (cover, disc
sheet, jewel, disc design, customart, slide). Deck + standalone
pixel art remain out-of-scope for v1 premium publishing — they can
be added later when their own export paths grow a watermark hook.

Tested via CDP on three real artboards:
  - Custom art 1920×1080 with color + rich text → 720×405 px,
    20 KB. Watermark reads cleanly across the whole image.
  - Cover (DVD presets, three colored slots + a title) → 718×488
    px, 25 KB. Title still visible under the watermark.
  - Disc design (small circular artboard) → 301×301 px, 12 KB.
    Centre label readable.
  - Stress: 4096×4096 custom art with 40 random color rects →
    720×720 px, 40 KB. Under budget without needing a retry.
  - Regression: soft-gate self-test and existing editor still work.

No publish/feed code touches this yet. Next step (§19 item 3) is
the premium publish event — encrypted envelope + new tag set +
embedded watermarked preview.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-28 20:59:07 -04:00
1 parent a8c855fc5c
commit 713ffefe83
1 file changed
+142
+142
View File
@@ -16633,6 +16633,148 @@
}
}
// ---- Watermarked premium preview (PREMIUM_DESIGNS_FEATURE.md §5.4) -----
// Renders a low-res JPEG of the current artboard with a diagonal
// "ARTSTR PREMIUM · UNLOCK TO USE" watermark tiled across it. Used by
// the publish path for premium designs: the encrypted payload goes in
// event.content, the preview JPEG goes in the envelope so buyers know
// what they'd be paying for. Size-budgeted so the event still fits in
// standard relay limits.
const PREMIUM_PREVIEW = Object.freeze({
MAX_LONG_AXIS_PX: 720,
QUALITY: 0.65,
MAX_BYTES: 50 * 1024,
MIN_LONG_AXIS_PX: 360, // give up below this rather than ship a blob
WATERMARK_TEXT: 'ARTSTR PREMIUM · UNLOCK TO USE',
});
// Long-axis inches for a given mode, so we can pick a DPI that lands
// close to the target pixel size without a wasted huge render.
function _premiumPreviewLongAxisInches(mode) {
if (mode === 'cover') {
return Math.max(state.panelW * 2 + state.spineW + state.bleed * 2, state.trimH + state.bleed * 2);
}
if (mode === 'disc-design') {
return discTemplate.discD; // single disc
}
if (mode === 'customart' || mode === 'slide') {
const w = (state.customArt?.width || 1920) / 96;
const h = (state.customArt?.height || 1080) / 96;
return Math.max(w, h);
}
// disc-sheet, jewel: letter-sized
return 11;
}
async function _renderModeToExportCanvas(mode, dpi) {
if (state.appMode === 'designer' || mode === 'disc-design') return renderDiscDesignToExportCanvas(dpi);
if (mode === 'cover') return renderCoverToExportCanvas(dpi);
if (mode === 'disc') return renderDiscSheetToExportCanvas(dpi);
if (mode === 'jewel') return renderJewelSheetToExportCanvas(dpi);
if (mode === 'customart' || mode === 'slide') return renderCustomArtToExportCanvas(dpi);
throw new Error(`Premium preview not supported for mode "${mode}".`);
}
function _drawPremiumWatermark(ctx, w, h) {
const text = PREMIUM_PREVIEW.WATERMARK_TEXT;
const fontPx = Math.max(12, Math.round(Math.min(w, h) * 0.045));
ctx.save();
ctx.font = `bold ${fontPx}px Helvetica, Arial, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineWidth = Math.max(1, fontPx * 0.06);
// Spacing: stepX = measured text width + a comfortable gap so adjacent
// copies on the same row never overlap. stepY = ~3.5 line-heights so
// rows don't crash into each other after rotation.
const textW = ctx.measureText(text).width;
const stepX = Math.ceil(textW + fontPx * 4);
const stepY = Math.ceil(fontPx * 3.5);
// Centre, then rotate; tile out from there so the rotation doesn't
// leave bare corners.
ctx.translate(w / 2, h / 2);
ctx.rotate(-Math.PI / 10); // ~ -18°
const reach = Math.ceil(Math.hypot(w, h));
const lines = Math.ceil(reach / stepY) + 1;
const cols = Math.ceil(reach / stepX) + 1;
for (let yi = -lines; yi <= lines; yi++) {
const y = yi * stepY;
// Stagger every other row by half a step so the pattern doesn't read
// as a strict grid.
const xOff = (yi & 1) ? stepX / 2 : 0;
for (let xi = -cols; xi <= cols; xi++) {
const x = xi * stepX + xOff;
ctx.strokeStyle = 'rgba(255,255,255,0.45)';
ctx.strokeText(text, x, y);
ctx.fillStyle = 'rgba(15,23,42,0.26)';
ctx.fillText(text, x, y);
}
}
ctx.restore();
}
// Scale `src` into a new canvas whose longest side is `targetLong` (or
// smaller if the source was already smaller). Then draws the watermark
// overlay on top.
function _composeWatermarkedCanvas(src, targetLong) {
const longest = Math.max(src.width, src.height);
const scale = longest > targetLong ? (targetLong / longest) : 1;
const w = Math.max(1, Math.round(src.width * scale));
const h = Math.max(1, Math.round(src.height * scale));
const out = document.createElement('canvas');
out.width = w; out.height = h;
const ctx = out.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(src, 0, 0, w, h);
_drawPremiumWatermark(ctx, w, h);
return out;
}
function _canvasToJpegDataUrl(canvas, quality) {
return canvas.toDataURL('image/jpeg', quality);
}
// Public: render the current artboard as a watermarked JPEG data URL,
// budget-bounded. Throws if the artboard can't be reduced under MAX_BYTES
// even at MIN_LONG_AXIS_PX, so the publish modal can surface a precise
// "preview too large for premium publishing" error.
async function renderPremiumPreviewDataUrl() {
_exportFailedImages.clear();
const mode = state.templateMode;
const longestInches = _premiumPreviewLongAxisInches(mode) || 11;
// Pick a DPI such that long-axis pixels ≈ MAX_LONG_AXIS_PX.
let targetLong = PREMIUM_PREVIEW.MAX_LONG_AXIS_PX;
let lastSize = 0;
let lastSrc = null;
for (let attempt = 0; attempt < 4; attempt++) {
// Render only on the first attempt; subsequent attempts re-tile +
// re-encode the same source at a smaller dimension cap.
if (!lastSrc) {
const dpi = Math.max(36, Math.round(targetLong / longestInches));
lastSrc = await _renderModeToExportCanvas(mode, dpi);
if (_exportFailedImages.size > 0) {
throw new Error('Some images on the artboard could not be embedded (CORS / missing). Make them local or remove them before publishing premium.');
}
}
const watermarked = _composeWatermarkedCanvas(lastSrc, targetLong);
let dataUrl;
try { dataUrl = _canvasToJpegDataUrl(watermarked, PREMIUM_PREVIEW.QUALITY); }
catch (err) { throw new Error('Could not encode preview JPEG: ' + (err?.message || err)); }
const approxBytes = Math.ceil((dataUrl.length - dataUrl.indexOf(',') - 1) * 3 / 4);
lastSize = approxBytes;
if (approxBytes <= PREMIUM_PREVIEW.MAX_BYTES) {
return { dataUrl, bytes: approxBytes, width: watermarked.width, height: watermarked.height };
}
// Shrink and retry.
targetLong = Math.round(targetLong * 0.78);
if (targetLong < PREMIUM_PREVIEW.MIN_LONG_AXIS_PX) break;
}
throw new Error(`Premium preview is too large (${lastSize.toLocaleString()} bytes after retries). Simplify the design (smaller image layers, fewer fills) and try again.`);
}
// ---- end watermarked premium preview -----------------------------------
function jpegFilenameStem() {
const parts = [
filenamePart(state.meta?.title) || 'design',