UNPKG

@playkit-js/dynamic-watermark

Version:
496 lines (421 loc) 12 kB
import { core } from '@playkit-js/kaltura-player-js'; import * as v from 'valibot'; import { DYNAMIC_WATERMARK_ERROR, DynamicWatermarkError, } from './dynamic-watermark-error'; import { observeSize } from './services/dom/observeSize'; import { observeVideoAspectRatio } from './services/dom/observeVideoAspectRatio'; import { getIPv4 } from './services/ipify'; import { createWebhook } from './services/webhook'; const fallbackNullish = < const TSchema extends v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>, const TFallback extends v.Fallback<TSchema>, >( schema: TSchema, fallback: TFallback, ) => { return v.fallback(v.nullish(schema, fallback), fallback); }; const LOGGER = core.getLogger('DynamicWatermark:initWatermark'); const OPTS_SCHEMA = v.object({ video: v.instance(HTMLVideoElement), root: v.instance(HTMLElement), userId: v.nullish(v.string()), ipAddress: v.nullish(v.pipe(v.string(), v.ip())), fontSize: fallbackNullish( v.pipe(v.number(), v.minValue(8), v.maxValue(48)), 20, ), fontFamily: fallbackNullish( v.string(), 'Lato,Helvetica Neue,Segoe UI,sans-serif', ), color: fallbackNullish(v.pipe(v.string(), v.hexColor()), '#fff'), moveInterval: fallbackNullish( v.pipe(v.number(), v.integer(), v.minValue(100), v.maxValue(20000)), 500, ), webhookURL: v.nullish(v.pipe(v.string(), v.url())), onInterferenceDetected: v.function(), uiConfId: v.nullish(v.union([v.number(), v.string()])), getCurrentTime: v.function(), }); type OptionsT = v.InferInput<typeof OPTS_SCHEMA>; const CANVAS_STYLE_VALUE = [ 'display: block', 'visibility: visible', 'opacity: 1', 'overflow: visible', 'clip-path: none', 'background-color: transparent', 'position: absolute', 'transform: none', 'top: 0', 'left: 0', 'right: 0', 'bottom: 0', 'inset: 0', 'width: 100%', 'height: 100%', 'margin: 0', 'padding: 0', 'border: none', 'z-index: 2147483647', 'pointer-events: none', 'isolation: isolate', ] .map((rule) => `${rule} !important`) .join(';'); const LINE_HEIGHT = 1.2; const POSITIONS = { TOP_LEFT: 0, TOP_RIGHT: 1, MIDDLE_LEFT: 2, MIDDLE_RIGHT: 3, BOTTOM_LEFT: 4, BOTTOM_RIGHT: 5, } as const; type PositionT = (typeof POSITIONS)[keyof typeof POSITIONS]; const isDefined = <T>(value: T | null | undefined): value is NonNullable<T> => { return value !== null && value !== undefined; }; const get2DContext = (canvas: HTMLCanvasElement) => { const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('2D Context not found'); } return ctx; }; const calculateVideoBox = ({ containerWidth: cw, containerHeight: ch, videoAspectRatio: va, }: { containerWidth: number; containerHeight: number; videoAspectRatio: number | null; }) => { const ca = cw / ch; if (!va || !isFinite(ca)) { return { x: 0, y: 0, width: cw, height: ch }; } if (va >= ca) { return { x: 0, y: (ch - cw / va) / 2, width: cw, height: cw / va, }; } return { x: (cw - ch * va) / 2, y: 0, width: ch * va, height: ch, }; }; const calculateWatermarkPosition = ( { canvas: c, watermark: w, videoAspectRatio, }: { canvas: { width: number; height: number }; watermark: { width: number; height: number }; videoAspectRatio: number | null; }, position: number, ) => { const { x: videoX, y: videoY, width: videoWidth, height: videoHeight, } = calculateVideoBox({ containerWidth: c.width, containerHeight: c.height, videoAspectRatio, }); const watermarkWidth = Math.min(w.width, videoWidth); switch (position) { case POSITIONS.TOP_LEFT: { return { x: videoX + Math.max(0, videoWidth / 3 - watermarkWidth), y: videoY + Math.max(0, videoHeight / 3 - w.height), videoWidth: videoWidth, }; } case POSITIONS.TOP_RIGHT: { return { x: videoX + Math.max(0, videoWidth - Math.max(videoWidth / 3, watermarkWidth)), y: videoY + Math.max(0, videoHeight / 3 - w.height), videoWidth: videoWidth, }; } case POSITIONS.MIDDLE_LEFT: { return { x: videoX + Math.max(0, videoWidth / 3 - watermarkWidth), y: videoY + Math.max(0, (videoHeight - w.height) / 2), videoWidth: videoWidth, }; } case POSITIONS.MIDDLE_RIGHT: { return { x: videoX + Math.max(0, videoWidth - Math.max(videoWidth / 3, watermarkWidth)), y: videoY + Math.max(0, (videoHeight - w.height) / 2), videoWidth: videoWidth, }; } case POSITIONS.BOTTOM_LEFT: { return { x: videoX + Math.max(0, videoWidth / 3 - watermarkWidth), y: videoY + Math.max(0, videoHeight - Math.max(videoHeight / 3, w.height)), videoWidth: videoWidth, }; } case POSITIONS.BOTTOM_RIGHT: { return { x: videoX + Math.max(0, videoWidth - Math.max(videoWidth / 3, watermarkWidth)), y: videoY + Math.max(0, videoHeight - Math.max(videoHeight / 3, w.height)), videoWidth: videoWidth, }; } default: { throw new Error('Unknown position'); } } }; const renderLines = ({ ctx, lines: linesArg, canvasWidthCSS: width, canvasHeightCSS: height, canvasScale: scale, watermarkPosition: position, fontSize, fontFamily, color, videoAspectRatio, }: { ctx: CanvasRenderingContext2D; lines: string[]; canvasWidthCSS: number; canvasHeightCSS: number; canvasScale: number; watermarkPosition: PositionT; fontSize: number; fontFamily: string; color: string; videoAspectRatio: number | null; }) => { const lines = linesArg.map((line) => `** ${line} **`); ctx.scale(scale, scale); ctx.font = `${fontSize}px ${fontFamily}, sans-serif`; const ww = Math.max(...lines.map((line) => ctx.measureText(line).width)); const wh = lines.length * fontSize * LINE_HEIGHT; const { x, y, videoWidth } = calculateWatermarkPosition( { canvas: { width, height }, watermark: { width: ww, height: wh }, videoAspectRatio, }, position, ); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = color; lines.forEach((line, index) => { ctx.fillText( line, x + Math.min(ww, videoWidth) / 2, y + index * fontSize * LINE_HEIGHT, videoWidth, ); }); }; const parseOptions = (options: unknown) => { try { return v.parse(OPTS_SCHEMA, options); } catch (err) { LOGGER.error('Failed to parse watermark options', err); throw new DynamicWatermarkError( DYNAMIC_WATERMARK_ERROR.INVALID_OPTIONS, 'Failed to parse watermark options', ); } }; const getUserIPAddress = async (ipAddress: string | null | undefined) => { try { return ipAddress ?? (await getIPv4()); } catch (err) { LOGGER.error('Cannot obtain user IP address from ipify.org', err); throw new DynamicWatermarkError( DYNAMIC_WATERMARK_ERROR.IP_ADDRESS_NOT_FOUND, 'Cannot obtain IP address from ipify.org', ); } }; export const initWatermark = async (options: OptionsT) => { const { root, video, userId, ipAddress: ipAddressOpt, fontSize, fontFamily, color, moveInterval, webhookURL, uiConfId, getCurrentTime, onInterferenceDetected, } = parseOptions(options); const webhook = webhookURL ? createWebhook(webhookURL) : null; try { await webhook?.get(); } catch (err) { LOGGER.error(`Cannot access report URL: ${webhookURL}`, err); throw new DynamicWatermarkError( DYNAMIC_WATERMARK_ERROR.WEBHOOK_UNACCESSIBLE, `Cannot access report URL: ${webhookURL}`, ); } const ipAddress = await getUserIPAddress(ipAddressOpt); const canvas = document.createElement('canvas'); canvas.setAttribute('style', CANVAS_STYLE_VALUE); root.appendChild(canvas); const getNextPosition = (prev: PositionT | null) => { const newPosition = Math.floor(Math.random() * 6) as PositionT; if (prev === null || newPosition !== prev) { return newPosition; } return ((prev + 1) % 6) as PositionT; }; const getNextLines = () => { const videoTime = v.parse(v.fallback(v.number(), 0), getCurrentTime()); return [ userId, ipAddress, new Date().toISOString(), Intl.DateTimeFormat().resolvedOptions().timeZone, videoTime.toFixed(3), ].filter(isDefined); }; let currentPosition = getNextPosition(null); let currentLines = getNextLines(); let prevCanvasCSSSize: { width: number; height: number } | null = null; let prevCanvasDeviceSize: { width: number; height: number } | null = null; let prevCanvasScale: number | null = null; let prevVideoAspectRatio: number | null = null; const render = () => { if (!prevCanvasCSSSize || !prevCanvasScale) { return; } renderLines({ ctx: get2DContext(canvas), lines: currentLines, canvasWidthCSS: prevCanvasCSSSize.width, canvasHeightCSS: prevCanvasCSSSize.height, canvasScale: prevCanvasScale, watermarkPosition: currentPosition, fontFamily, fontSize, color, videoAspectRatio: prevVideoAspectRatio, }); }; const { initialSize, disconnect } = observeSize( root, ({ css, device, scale }) => { canvas.width = device.width; canvas.height = device.height; prevCanvasCSSSize = css; prevCanvasDeviceSize = device; prevCanvasScale = scale; render(); }, ); const { initial: initialAspectRatio, disconnect: disconnectVideoObserver } = observeVideoAspectRatio(video, (aspectRatio) => { canvas.width = prevCanvasDeviceSize?.width ?? canvas.width; canvas.height = prevCanvasDeviceSize?.height ?? canvas.height; prevVideoAspectRatio = aspectRatio; render(); }); prevCanvasCSSSize = initialSize.css; prevCanvasDeviceSize = initialSize.device; prevCanvasScale = initialSize.scale; prevVideoAspectRatio = initialAspectRatio; let timeoutId = 0; let frameId = 0; const moveWatermark = () => { currentPosition = getNextPosition(currentPosition); currentLines = getNextLines(); clearTimeout(timeoutId); cancelAnimationFrame(frameId); frameId = requestAnimationFrame(() => { canvas.width = prevCanvasDeviceSize?.width ?? canvas.width; canvas.height = prevCanvasDeviceSize?.height ?? canvas.height; render(); timeoutId = window.setTimeout(moveWatermark, moveInterval); }); }; moveWatermark(); const tamperIntervalId = setInterval(async () => { // NOTE: self-destroy if related video element is out of DOM if (!document.body.contains(video)) { destroy(); return; } if (!document.body.contains(canvas) || canvas.parentElement !== root) { LOGGER.warn('remove violation'); root.appendChild(canvas); try { onInterferenceDetected(); await webhook?.reportViolation({ uiConfId, userId, ipAddress, }); } catch (err) { LOGGER.error('failed to report remove violation', err); } return; } if (canvas.getAttribute('style') !== CANVAS_STYLE_VALUE) { LOGGER.warn('style violation'); canvas.setAttribute('style', CANVAS_STYLE_VALUE); try { onInterferenceDetected(); await webhook?.reportViolation({ uiConfId, userId, ipAddress, }); } catch (err) { LOGGER.error('failed to report style violation', err); } return; } }, 1000); const destroy = () => { disconnect(); disconnectVideoObserver(); clearTimeout(timeoutId); cancelAnimationFrame(frameId); clearInterval(tamperIntervalId); canvas.remove(); }; return destroy; };