import { extractRichTextPlainText, renderRichTextToReact } from '@kkk/richtext' import { MapPinIcon, MusicNoteIcon, PlayIcon, UserPlusIcon, UsersIcon, UsersThreeIcon } from '@phosphor-icons/react' import { format, formatDistanceToNow, fromUnixTime } from 'date-fns' import { zhCN } from 'date-fns/locale' import { Clock3, Hash } from 'lucide-react' import React from 'react' import { isDark } from '../../../../utils/theme' import { AmbientCover } from '../../../components/AmbientCover' import { DefaultLayout } from '../../../components/DefaultLayout' import { GlowImage, GlowText } from '../../../components/GlowImage' import { QRCodeWithAvatar } from '../../../components/QRCodeWithAvatar' import type { PosterProps } from '../../../types/ctx' import { DouyinCommentIcon, DouyinFavoriteIcon, DouyinLikeIcon, DouyinShareIcon } from '../../components/Icons' import type { DouyinVideoWorkData } from './types' const getTitleClassName = (titleLength: number): string => { if (titleLength > 96) return 'text-[42px] leading-[1.34]' if (titleLength > 72) return 'text-[48px] leading-[1.3]' if (titleLength > 48) return 'text-[54px] leading-[1.26]' return 'text-[62px] leading-tight' } function formatDuration(duration?: number): string | undefined { if (typeof duration !== 'number' || !Number.isFinite(duration) || duration < 0) return undefined const totalSeconds = Math.floor(duration / 1000) const hours = Math.floor(totalSeconds / 3600) const minutes = Math.floor((totalSeconds % 3600) / 60) const seconds = totalSeconds % 60 const pad = (value: number) => value.toString().padStart(2, '0') if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}` return `${pad(minutes)}:${pad(seconds)}` } /** * 多段透明度停靠点用于模拟 smoothstep,让封面从环境柔光进入清晰主体时没有明显的线性渐变拐点。 */ const ambientCoverMask = 'linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.08) 6%, rgba(0,0,0,0.32) 16%, rgba(0,0,0,0.68) 28%, black 42%, black 58%, rgba(0,0,0,0.68) 72%, rgba(0,0,0,0.32) 84%, rgba(0,0,0,0.08) 94%, transparent 100%)' const foregroundCoverMask = 'linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.1) 4%, rgba(0,0,0,0.35) 10%, rgba(0,0,0,0.68) 17%, rgba(0,0,0,0.9) 23%, black 30%, black 70%, rgba(0,0,0,0.9) 77%, rgba(0,0,0,0.68) 83%, rgba(0,0,0,0.35) 90%, rgba(0,0,0,0.1) 96%, transparent 100%)' const DouyinDiffuseBackground: React.FC> = ({ data, ctx }) => (
) const DouyinPosterHeader: React.FC> = ({ data, ctx }) => { const { avater_url, username, create_time, ip_location } = data const publishTime = formatDistanceToNow(fromUnixTime(create_time), { addSuffix: true, locale: zhCN }) return (
头像
{username}
{publishTime} {ip_location && ( {ip_location} )}
抖音
) } const DouyinVideoCover: React.FC> = (props) => { const durationText = formatDuration(props.data.duration) return (
视频封面 {/* 顶部信息行:时长(左)与分辨率(右)同一水平线对齐 */}
{durationText && 时长: {durationText}} {props.data.resolution && (
{/* 第一行:分辨率名称 + (如果有 HDR 则追加 " · HDR",深色模式下发光) */} {props.data.resolution.name} {props.data.is_HDR && (isDark(props.ctx) ? ( {` · HDR`} ) : ( ` · HDR` ))} {/* 第二行:宽 x 高 px */} {props.data.resolution.width} × {props.data.resolution.height}
)}
{props.data.music && (
{props.data.music.cover ? (
BGM封面
) : ( )}
{props.data.music.title}
{props.data.music.author}
)}
) } const DouyinPosterTitle: React.FC> = ({ data }) => { const { title, desc } = data const hasTitle = Boolean(title?.nodes.length) const hasDesc = Boolean(desc?.nodes.length) const titleLength = title ? extractRichTextPlainText(title).length : 0 const titleClassName = getTitleClassName(titleLength) const richTextOptions = { hashtag: { className: 'text-inherit opacity-60' }, mention: { className: 'text-inherit' } } if (!hasTitle && !hasDesc) { return

抖音视频作品

} return (
{hasTitle && title && (

{renderRichTextToReact(title, richTextOptions)}

)} {hasDesc && (
{renderRichTextToReact(desc, richTextOptions)}
)}
) } const DouyinSignalLine: React.FC> = ({ data }) => { const { dianzan, pinglun, shouchang, share, suggest_word } = data const renderTime = format(new Date(), 'yyyy-MM-dd HH:mm:ss') const stats = [ { icon: DouyinLikeIcon, value: dianzan, label: '点赞' }, { icon: DouyinCommentIcon, value: pinglun, label: '评论' }, { icon: DouyinFavoriteIcon, value: shouchang, label: '收藏' }, { icon: DouyinShareIcon, value: share, label: '分享' } ] return (
{stats.map((stat) => { const Icon = stat.icon return (
{stat.label}
{stat.value}
) })}
{suggest_word && (
{suggest_word.hint_text} {suggest_word.word}
)}
图片生成于: {renderTime}
) } const DouyinCoCreatorList: React.FC & { coCreatorCount: number }> = ({ data, coCreatorCount }) => { const subscriberNickname = data.username const allCreators = data.cooperation_info?.co_creators ?? [] const creators = allCreators.filter((creator) => creator.nickname !== subscriberNickname) if (creators.length === 0) return null const visibleCreators = creators.slice(0, 8) const remainingCount = Math.max(coCreatorCount - visibleCreators.length - 1, 0) return (
共 {coCreatorCount} 人共创
{visibleCreators.map((creator, index) => (
共创者头像
{creator.nickname || '未提供'}
{creator.role_title || '共创者'}
))} {remainingCount > 0 &&
剩余 {remainingCount} 个...
}
) } const DouyinPosterFooter: React.FC> = ({ data, ctx }) => { const { avater_url, username, 抖音号, 获赞, 关注, 粉丝, share_url } = data const stats = [ { icon: DouyinLikeIcon, iconSize: 26, label: '获赞', value: 获赞 }, { icon: UserPlusIcon, iconSize: 32, label: '关注', value: 关注 }, { icon: UsersThreeIcon, iconSize: 32, label: '粉丝', value: 粉丝 } ] return (
头像
{username}
抖音号: {抖音号}
{stats.map((stat) => { const Icon = stat.icon return (
{stat.label}
{stat.value}
) })}
扫码查看作品详情
) } export const DouyinVideoWork: React.FC> = React.memo((props) => { const coCreatorCount = props.data.cooperation_info?.co_creator_nums ?? props.data.cooperation_info?.co_creators?.length ?? undefined const hasCoCreators = !!coCreatorCount && coCreatorCount > 0 return (
{hasCoCreators && }
) }) DouyinVideoWork.displayName = 'DouyinVideoWork' export default DouyinVideoWork