From 713ffefe83f8cce3393d3fddc73e82713d43ed0e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 28 May 2026 20:59:07 -0400 Subject: [PATCH] =?UTF-8?q?Premium=20step=202=20=E2=80=94=20watermarked=20?= =?UTF-8?q?preview=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/index.html | 142 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/src/index.html b/src/index.html index 47c8126..72408fd 100644 --- a/src/index.html +++ b/src/index.html @@ -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',