@playkit-js/dynamic-watermark
Version:
kaltura-player-js dynamic watermark
496 lines (421 loc) • 12 kB
text/typescript
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;
};