import type { CSSProperties } from "react"; import type { VideoBlockProps } from "@/features/editor/state/editorStore"; export type VideoPlatform = "youtube" | "vimeo" | "html5"; export type VideoPlatformInput = VideoPlatform | "auto" | null | undefined; // 비디오 sourceUrl 을 정규화한다: null/undefined 를 빈 문자열로 만들고 양 끝 공백을 제거한다. export function normalizeVideoSourceUrl(source: string | null | undefined): string { if (!source) { return ""; } return source.trim(); } // platform 값과 URL 을 기반으로 최종 비디오 플랫폼을 결정한다. // - platform 이 auto 가 아니면 해당 값을 우선 사용한다. // - auto 인 경우 URL 을 검사해 youtube/vimeo/html5 를 판별한다. export function resolveVideoPlatform(rawUrl: string, platform: VideoPlatformInput): VideoPlatform { if (platform && platform !== "auto") { return platform; } const normalized = normalizeVideoSourceUrl(rawUrl); const lower = normalized.toLowerCase(); if (lower.includes("youtube.com") || lower.includes("youtu.be")) { return "youtube"; } if (lower.includes("vimeo.com")) { return "vimeo"; } return "html5"; } export type BuildEmbedUrlOptions = { enableVimeoEmbed?: boolean; }; // 플랫폼과 URL 을 기반으로 최종 embed URL 을 생성한다. // - YouTube: 항상 watch/short/youtu.be 형태에서 embed URL 로 변환. // - Vimeo: enableVimeoEmbed 가 true 인 경우에만 player.vimeo.com 기반 embed URL 로 변환. // - HTML5: 항상 정규화된 원본 URL 을 그대로 사용. export function buildVideoEmbedUrl( rawUrl: string, platform: VideoPlatform, options?: BuildEmbedUrlOptions, ): string { const normalized = normalizeVideoSourceUrl(rawUrl); if (platform === "youtube") { const idMatch = normalized.match(/[?&]v=([^&]+)/) || normalized.match(/youtu\.be\/([^?&]+)/) || normalized.match(/youtube\.com\/shorts\/([^?&]+)/); const videoId = idMatch && idMatch[1] ? idMatch[1] : ""; if (videoId) { return `https://www.youtube.com/embed/${videoId}`; } return normalized; } if (platform === "vimeo" && options?.enableVimeoEmbed) { const idMatch = normalized.match(/vimeo\.com\/(\d+)/); const videoId = idMatch && idMatch[1] ? idMatch[1] : ""; if (videoId) { return `https://player.vimeo.com/video/${videoId}`; } return normalized; } return normalized; } export interface VideoExportEscapers { escapeAttr: (value: string) => string; } export interface VideoExportTokens { wrapperStyleParts: string[]; videoStyleParts: string[]; outerStyleParts: string[]; aspectClass: string; } export function computeVideoExportTokens( props: VideoBlockProps, escapers: VideoExportEscapers, ): VideoExportTokens { const { escapeAttr } = escapers; const aspect = props.aspectRatio ?? "16:9"; const aspectClass = aspect === "4:3" ? " pb-video-wrapper--4by3" : aspect === "1:1" ? " pb-video-wrapper--1by1" : ""; const wrapperStyleParts: string[] = []; const videoStyleParts: string[] = []; const widthMode = props.widthMode ?? "auto"; if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { const widthEm = pxToEm(props.widthPx); wrapperStyleParts.push(`width:${widthEm}`); videoStyleParts.push(`width:${widthEm}`); } else if (widthMode === "full") { wrapperStyleParts.push("width:100%"); videoStyleParts.push("width:100%"); } if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") { wrapperStyleParts.push(`background-color:${escapeAttr(props.backgroundColorCustom.trim())}`); } if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) { wrapperStyleParts.push(`padding:${pxToEm(props.cardPaddingPx)}`); } if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) { wrapperStyleParts.push(`border-radius:${pxToEm(props.borderRadiusPx)}`); } const align = props.align === "left" ? "flex-start" : props.align === "right" ? "flex-end" : "center"; const outerStyleParts = ["display:flex", `justify-content:${align}`]; return { wrapperStyleParts, videoStyleParts, outerStyleParts, aspectClass, }; } export interface VideoPublicTokens { wrapperStyle: CSSProperties; videoStyle: CSSProperties; justifyClass: string; aspectClass: string; } const pxToEm = (px: number, base = 16) => `${px / base}em`; export function computeVideoPublicTokens(props: VideoBlockProps): VideoPublicTokens { const aspect = props.aspectRatio ?? "16:9"; const aspectClass = aspect === "4:3" ? " pb-video-wrapper--4by3" : aspect === "1:1" ? " pb-video-wrapper--1by1" : ""; const wrapperStyle: CSSProperties = {}; const videoStyle: CSSProperties = {}; const widthMode = props.widthMode ?? "auto"; if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { const widthEm = pxToEm(props.widthPx); wrapperStyle.width = widthEm; videoStyle.width = widthEm; } else if (widthMode === "full") { wrapperStyle.width = "100%"; videoStyle.width = "100%"; } if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") { wrapperStyle.backgroundColor = props.backgroundColorCustom.trim(); } if (typeof (props as any).cardPaddingPx === "number" && (props as any).cardPaddingPx >= 0) { wrapperStyle.padding = pxToEm((props as any).cardPaddingPx as number); } if (typeof (props as any).borderRadiusPx === "number" && (props as any).borderRadiusPx >= 0) { wrapperStyle.borderRadius = pxToEm((props as any).borderRadiusPx as number); } const align = props.align ?? "center"; const justifyClass = align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center"; return { wrapperStyle, videoStyle, justifyClass, aspectClass, }; } export interface VideoEditorTokens { wrapperStyle: CSSProperties; videoStyle: CSSProperties; justifyClass: string; aspectClass: string; } export function computeVideoEditorTokens(props: VideoBlockProps): VideoEditorTokens { const aspect = props.aspectRatio ?? "16:9"; const aspectClass = aspect === "4:3" ? " pb-video-wrapper--4by3" : aspect === "1:1" ? " pb-video-wrapper--1by1" : ""; const wrapperStyle: CSSProperties = {}; const videoStyle: CSSProperties = {}; const widthMode = props.widthMode ?? "auto"; if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { wrapperStyle.width = `${props.widthPx}px`; videoStyle.width = `${props.widthPx}px`; } else if (widthMode === "full") { wrapperStyle.width = "100%"; videoStyle.width = "100%"; } if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") { wrapperStyle.backgroundColor = props.backgroundColorCustom.trim(); } if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) { wrapperStyle.padding = `${props.cardPaddingPx}px`; } if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) { wrapperStyle.borderRadius = `${props.borderRadiusPx}px`; } const align = props.align ?? "center"; const justifyClass = align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center"; return { wrapperStyle, videoStyle, justifyClass, aspectClass, }; }