Content Protection (Anti-capture Watermark) — Technical Reference
DynamicWatermark itself remains a purely client-side, presentation-layer component (Fase D.3 / roadmap 4.11) — no backend or GraphQL change backs it. It overlays a forensic watermark on protected content: the current viewer's @username plus a live timestamp, tiled across the media so it survives cropping and can't be cleanly removed from a screenshot. Fixed this session: its integration had previously been pulled out of the exclusive-post view and the paid-message chat bubble, leaving it mounted only on non-public live streams; it's now wired back into unlocked exclusive-post media and unlocked paid message media too (see the integration table below).
A separate, opt-in server-side/baked-in watermark (below) now exists alongside it — it burns a static "@creator-username" mark into the real image's pixels at upload time, rather than overlaying the current viewer's identity at render time. The two are independent and solve different problems (see "Baked-in vs. dynamic" below).
Server-side/baked-in watermark (opt-in, off by default)
services/post-media-processing.service.js, wired into post.manager.js#createPost(). For a post with coinPrice > 0 (the same trigger condition as the blurred preview, see exclusive-posts.md), each image item gets a tiled "@username" SVG composited directly into its pixels via sharp().composite(); the resulting file replaces PostMedia.mediaUrl (the pre-watermark original is left in storage, unreferenced — see "Known simplification" below). Applies to the REAL media (both isPreview and locked items) — this is what a paying viewer sees, so it protects against them screenshotting/redistributing the raw file, which the client-side DynamicWatermark overlay above cannot do (it never touches the underlying file).
Toggle: BAKE_IN_WATERMARK env var, off by default — deliberately conservative (unlike the blurred preview, this changes what gets served) until product/design signs off on watermarking every paid post's real media. Video media is skipped (same reasoning as the blurred preview's video gap).
Baked-in vs. dynamic — which one runs when
DynamicWatermark (existing) | Baked-in watermark (new) | |
|---|---|---|
| Where it lives | Client-side React component | Server-side, at upload time (sharp) |
| What it shows | Current viewer's @username + live timestamp | The creator's @username (static) |
| Touches the file? | No — overlay only, raw file is unwatermarked | Yes — burned into the pixels, replaces mediaUrl |
| Today's integration | Non-public live streams, unlocked exclusive posts, unlocked paid messages (table below) | Paid post images, opt-in via BAKE_IN_WATERMARK |
| Purpose | Forensic — traces a leak back to the viewer who captured it | Preventive-ish — every copy of the file itself carries the creator's mark, even off-platform |
Known simplification
The pre-watermark original upload is not deleted from storage after the watermarked replacement is uploaded — it just becomes unreferenced (no PostMedia row points at it anymore). Deleting it was left out of this pass to avoid a race with anything else that might still read the original key mid-request; a cleanup pass could delete it once nothing references it.
Where this lives
apps/frontend-nextjs/src/components/common/DynamicWatermark.tsx— the reusable overlay component. Reads the signed-in user fromAuthContext(or takes alabeloverride), renders a rotated, tiled grid of@username • YYYY-MM-DD HH:MMat low opacity,pointer-events-noneso it never intercepts clicks / video controls / carousel navigation. The timestamp refreshes every 30s viasetInterval, so a capture always encodes roughly when it was taken, not just when the page loaded. Drop it inside anyposition: relativemedia container.
Integration points
| Surface | File | Condition |
|---|---|---|
| Non-public live stream video | LiveRoomPage.tsx | !isOwner && connected && liveStream.visibility !== 'public' — the broadcaster isn't watermarked, only viewers of a private / followers-only / close-friends stream |
| Unlocked exclusive-post media (feed/profile grid) | PostCard.tsx | !!displayPost.isPaid && !!displayPost.hasPostAccess && authUser?.id !== displayPost.userId — exclusive posts render inline in the normal "posts" grid (post.isPaid / post.coinPrice, see exclusive-posts.md); the overlay only shows once the viewer has purchased access, and never to the post's own owner |
| Unlocked exclusive-post media (post detail) | PostModal.tsx | !!post?.isPaid && !!post?.hasPostAccess && authUser?.id !== post?.user?.id — same gating as PostCard.tsx, applied where a post opens full-screen (hasPostAccess, purchasePostAccess) |
| Unlocked paid message image/video (recipient's view) | MessageBubble.tsx | isPaidMessage && !isMe, where isPaidMessage = Boolean(msg.isPaid) — the recipient only ever receives real mediaUrls once the paid message is unlocked, so reaching this render branch already implies access; never shown to the sender |
Fixed this session: PostCard.tsx, PostModal.tsx, and MessageBubble.tsx had all been wired up to DynamicWatermark at one point and then had the import/usage removed, leaving only the live-stream integration above. All three are re-wired now, each gated on paid + access-granted + not-the-owner/sender, matching the pattern the live-stream integration already used.
Design notes
- Forensic, not preventive. The goal is traceability, not DRM. A determined user can still capture the screen — but the capture identifies them. This matches the product decision to use a dynamic watermark rather than (unreliable, UX-breaking) screenshot-blocking.
- Current viewer, not creator. The watermark always shows whoever is looking right now, so a leaked capture points at the leaker. That's why it reads identity from
AuthContextat render time rather than being baked into the media by the creator. - Client-side overlay. It protects the in-app viewing experience. It does not modify the underlying media file, so someone who obtains the raw media URL directly wouldn't get a watermarked file — the opt-in server-side/baked-in watermark described above closes that specific gap for paid post images when
BAKE_IN_WATERMARKis enabled. - Owner exclusion. The live-stream broadcaster is not watermarked on their own stream, since watermarking a creator's own view serves no tracing purpose.