Skip to main content

Content Protection (Anti-capture Watermark) — Technical Reference

← Back to Content Protection

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 livesClient-side React componentServer-side, at upload time (sharp)
What it showsCurrent viewer's @username + live timestampThe creator's @username (static)
Touches the file?No — overlay only, raw file is unwatermarkedYes — burned into the pixels, replaces mediaUrl
Today's integrationNon-public live streams, unlocked exclusive posts, unlocked paid messages (table below)Paid post images, opt-in via BAKE_IN_WATERMARK
PurposeForensic — traces a leak back to the viewer who captured itPreventive-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 from AuthContext (or takes a label override), renders a rotated, tiled grid of @username • YYYY-MM-DD HH:MM at low opacity, pointer-events-none so it never intercepts clicks / video controls / carousel navigation. The timestamp refreshes every 30s via setInterval, so a capture always encodes roughly when it was taken, not just when the page loaded. Drop it inside any position: relative media container.

Integration points

SurfaceFileCondition
Non-public live stream videoLiveRoomPage.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.tsxisPaidMessage && !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 AuthContext at 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_WATERMARK is 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.