Files
video-gen/video-gen-admin/src/pages/homeMaterials/WatermarkPreview.tsx
T
2026-06-30 17:10:51 +08:00

364 lines
12 KiB
TypeScript

import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Spin } from 'antd';
import { previewHomeMaterialTextWatermark } from '../../api';
import { apiUrl } from '../../utils/resourceUrl';
import type { HomeMaterialMediaType, HomeMaterialTextWatermarkPayload, HomeMaterialWatermarkConfig } from '../../types';
interface WatermarkPreviewProps {
mediaUrl?: string | null;
mediaType?: HomeMaterialMediaType;
watermarkUrl?: string | null;
config: HomeMaterialWatermarkConfig;
onChange?: (patch: Partial<HomeMaterialWatermarkConfig>) => void;
}
interface Size {
width: number;
height: number;
}
const PREVIEW_MAX_HEIGHT = 360;
const PREVIEW_MIN_HEIGHT = 280;
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
function calcStageSize(containerWidth: number, naturalSize: Size | null): Size {
const maxWidth = Math.max(0, containerWidth);
if (!maxWidth) return { width: 0, height: PREVIEW_MIN_HEIGHT };
if (!naturalSize?.width || !naturalSize?.height) {
return { width: maxWidth, height: PREVIEW_MIN_HEIGHT };
}
const scale = Math.min(maxWidth / naturalSize.width, PREVIEW_MAX_HEIGHT / naturalSize.height);
return {
width: Math.max(1, Math.round(naturalSize.width * scale)),
height: Math.max(1, Math.round(naturalSize.height * scale)),
};
}
function calcWatermarkWidth(config: HomeMaterialWatermarkConfig, stageWidth: number): number {
if (config.sizeMode === 'px' && config.widthPx) {
return clamp(config.widthPx, 1, Math.max(1, stageWidth));
}
const ratio = clamp(config.widthRatio ?? 0.18, 0.01, 1);
return Math.max(1, stageWidth * ratio);
}
function calcPresetPosition(config: HomeMaterialWatermarkConfig): React.CSSProperties {
const marginX = config.marginX ?? 24;
const marginY = config.marginY ?? 24;
switch (config.position) {
case 'top_left':
return { left: marginX, top: marginY };
case 'top_center':
return { left: '50%', top: marginY, transform: 'translateX(-50%)' };
case 'top_right':
return { right: marginX, top: marginY };
case 'middle_left':
return { left: marginX, top: '50%', transform: 'translateY(-50%)' };
case 'center':
return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' };
case 'middle_right':
return { right: marginX, top: '50%', transform: 'translateY(-50%)' };
case 'bottom_left':
return { left: marginX, bottom: marginY };
case 'bottom_center':
return { left: '50%', bottom: marginY, transform: 'translateX(-50%)' };
case 'bottom_right':
default:
return { right: marginX, bottom: marginY };
}
}
function toTextPayload(config: HomeMaterialWatermarkConfig): HomeMaterialTextWatermarkPayload | null {
const text = config.textWatermark;
if (!text || !text.text?.trim()) return null;
return {
text: text.text.trim(),
opacity_level: text.opacityLevel,
font_size_px: text.fontSizePx,
color: text.color,
rotate_deg: text.rotateDeg,
gap_x: text.gapX,
gap_y: text.gapY,
staggered: text.staggered,
};
}
const WatermarkPreview: React.FC<WatermarkPreviewProps> = ({
mediaUrl,
mediaType = 'image',
watermarkUrl,
config,
onChange,
}) => {
const outerRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const watermarkRef = useRef<HTMLImageElement | null>(null);
const dragOffsetRef = useRef({ x: 0, y: 0 });
const previewSeqRef = useRef(0);
const [containerWidth, setContainerWidth] = useState(0);
const [naturalSize, setNaturalSize] = useState<Size | null>(null);
const [watermarkSize, setWatermarkSize] = useState<Size>({ width: 0, height: 0 });
const [dragging, setDragging] = useState(false);
const [textPreviewLayer, setTextPreviewLayer] = useState<string>('');
const [textPreviewLoading, setTextPreviewLoading] = useState(false);
const [textPreviewError, setTextPreviewError] = useState<string>('');
const mediaSrc = useMemo(() => apiUrl(mediaUrl), [mediaUrl]);
const watermarkSrc = useMemo(() => apiUrl(watermarkUrl), [watermarkUrl]);
const stageSize = useMemo(() => calcStageSize(containerWidth, naturalSize), [containerWidth, naturalSize]);
const watermarkWidth = useMemo(() => calcWatermarkWidth(config, stageSize.width), [config, stageSize.width]);
const isRepeatedText = config.watermarkType === 'repeated_text';
useLayoutEffect(() => {
if (!outerRef.current) return;
const update = () => setContainerWidth(outerRef.current?.clientWidth || 0);
update();
const observer = new ResizeObserver(update);
observer.observe(outerRef.current);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
if (!watermarkRef.current) return;
const update = () => {
const rect = watermarkRef.current?.getBoundingClientRect();
if (!rect) return;
setWatermarkSize({ width: rect.width, height: rect.height });
};
update();
const observer = new ResizeObserver(update);
observer.observe(watermarkRef.current);
return () => observer.disconnect();
}, [watermarkSrc, watermarkWidth]);
useEffect(() => {
setNaturalSize(null);
setTextPreviewLayer('');
setTextPreviewError('');
}, [mediaSrc, mediaType]);
useEffect(() => {
if (!isRepeatedText) {
setTextPreviewLayer('');
setTextPreviewLoading(false);
setTextPreviewError('');
return;
}
const textPayload = toTextPayload(config);
if (!naturalSize?.width || !naturalSize.height || !textPayload) {
setTextPreviewLayer('');
return;
}
const seq = ++previewSeqRef.current;
setTextPreviewLoading(true);
setTextPreviewError('');
const timer = window.setTimeout(async () => {
try {
const res = await previewHomeMaterialTextWatermark({
width: naturalSize.width,
height: naturalSize.height,
text_watermark: textPayload,
});
if (previewSeqRef.current === seq) {
setTextPreviewLayer(res.previewLayerDataUrl);
}
} catch (e) {
if (previewSeqRef.current === seq) {
setTextPreviewLayer('');
setTextPreviewError(e instanceof Error ? e.message : '文字水印预览生成失败');
}
} finally {
if (previewSeqRef.current === seq) setTextPreviewLoading(false);
}
}, 300);
return () => window.clearTimeout(timer);
}, [config, isRepeatedText, naturalSize]);
useEffect(() => {
const onMove = (e: PointerEvent) => {
if (!dragging || !stageRef.current || !onChange || isRepeatedText) return;
const rect = stageRef.current.getBoundingClientRect();
const wmWidth = watermarkSize.width || watermarkWidth;
const wmHeight = watermarkSize.height || 1;
const availableX = Math.max(1, rect.width - wmWidth);
const availableY = Math.max(1, rect.height - wmHeight);
const left = clamp(e.clientX - rect.left - dragOffsetRef.current.x, 0, availableX);
const top = clamp(e.clientY - rect.top - dragOffsetRef.current.y, 0, availableY);
onChange({
position: 'custom',
customXRatio: Number((left / availableX).toFixed(4)),
customYRatio: Number((top / availableY).toFixed(4)),
});
};
const onUp = () => setDragging(false);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
}, [dragging, isRepeatedText, onChange, watermarkSize.height, watermarkSize.width, watermarkWidth]);
const watermarkPositionStyle = useMemo<React.CSSProperties>(() => {
if (config.position !== 'custom') return calcPresetPosition(config);
const wmWidth = watermarkSize.width || watermarkWidth;
const wmHeight = watermarkSize.height || 1;
const availableX = Math.max(0, stageSize.width - wmWidth);
const availableY = Math.max(0, stageSize.height - wmHeight);
const xRatio = clamp(config.customXRatio ?? 0.5, 0, 1);
const yRatio = clamp(config.customYRatio ?? 0.5, 0, 1);
return {
left: availableX * xRatio,
top: availableY * yRatio,
transform: 'none',
};
}, [config, stageSize.height, stageSize.width, watermarkSize.height, watermarkSize.width, watermarkWidth]);
return (
<div
ref={outerRef}
style={{
position: 'relative',
width: '100%',
minHeight: Math.max(PREVIEW_MIN_HEIGHT, stageSize.height),
background: '#0f172a',
borderRadius: 12,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#94a3b8',
}}
>
{mediaSrc ? (
<div
ref={stageRef}
style={{
position: 'relative',
width: stageSize.width || '100%',
height: stageSize.height || PREVIEW_MIN_HEIGHT,
maxWidth: '100%',
overflow: 'hidden',
background: '#000',
}}
>
{mediaType === 'video' ? (
<video
src={mediaSrc}
controls
onLoadedMetadata={(e) => {
const target = e.currentTarget;
if (target.videoWidth && target.videoHeight) {
setNaturalSize({ width: target.videoWidth, height: target.videoHeight });
}
}}
style={{ width: '100%', height: '100%', objectFit: 'fill', display: 'block' }}
/>
) : (
<img
src={mediaSrc}
alt="素材预览"
onLoad={(e) => {
const target = e.currentTarget;
if (target.naturalWidth && target.naturalHeight) {
setNaturalSize({ width: target.naturalWidth, height: target.naturalHeight });
}
}}
style={{ width: '100%', height: '100%', objectFit: 'fill', display: 'block' }}
/>
)}
{isRepeatedText && textPreviewLayer && (
<img
src={textPreviewLayer}
alt="重复文字水印预览"
draggable={false}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'fill',
zIndex: 2,
pointerEvents: 'none',
userSelect: 'none',
}}
/>
)}
{isRepeatedText && (textPreviewLoading || textPreviewError) && (
<div
style={{
position: 'absolute',
left: 12,
bottom: 12,
zIndex: 3,
padding: '6px 10px',
borderRadius: 8,
background: 'rgba(15, 23, 42, 0.82)',
color: '#e2e8f0',
fontSize: 12,
}}
>
{textPreviewLoading ? <><Spin size="small" /> 正在生成精准预览...</> : textPreviewError}
</div>
)}
{!isRepeatedText && watermarkSrc && (
<img
ref={watermarkRef}
src={watermarkSrc}
alt="水印预览"
draggable={false}
onLoad={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setWatermarkSize({ width: rect.width, height: rect.height });
}}
onPointerDown={(e) => {
if (!onChange) return;
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
dragOffsetRef.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
setDragging(true);
}}
style={{
position: 'absolute',
zIndex: 2,
cursor: onChange ? 'move' : 'default',
width: watermarkWidth,
opacity: (config.opacityLevel ?? 6) / 10,
userSelect: 'none',
touchAction: 'none',
pointerEvents: onChange ? 'auto' : 'none',
...watermarkPositionStyle,
}}
/>
)}
</div>
) : (
<span>上传素材后可预览水印效果</span>
)}
</div>
);
};
export default WatermarkPreview;