mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-06 13:40:39 +00:00
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:
1 parent
a8c855fc5c
commit
713ffefe83
1 file changed
+142
+142
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user