import { extractRichTextPlainText, renderRichTextToReact, type RichTextRenderOptions } from '@kkk/richtext' import { ImageIcon, MapPinIcon, MusicNoteIcon, UserPlusIcon, UsersIcon, UsersThreeIcon, VideoCameraIcon } 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 { DefaultLayout } from '../../../components/DefaultLayout' import { AmbientCover } from '../../../components/AmbientCover' import { GlowImage } from '../../../components/GlowImage' import { QRCodeWithAvatar } from '../../../components/QRCodeWithAvatar' import type { PosterProps } from '../../../types/ctx' import { cn } from '../../../../utils/cn' import { isDark } from '../../../../utils/theme' import { DouyinCommentIcon, DouyinFavoriteIcon, DouyinLikeIcon, DouyinShareIcon } from '../../components/Icons' import type { DouyinImageWorkData } from './types' /** 单张媒体的类型,从总数据类型里逐步取,不再单独导出。 */ type ImageMediaType = DouyinImageWorkData['image_list']['images'][number]['media_type'] const LivePhotoIcon: React.FC<{ size?: number; className?: string; weight?: 'fill' }> = ({ size = 28, className }) => ( ) const imageMediaTypeMeta = { static: { label: '图片', icon: ImageIcon }, live: { label: '实况', icon: LivePhotoIcon }, clip: { label: '短片', icon: VideoCameraIcon } } satisfies Record< ImageMediaType, { label: string icon: React.FC<{ size?: number; className?: string; weight?: 'fill' }> } > 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-[1.18]' } /** * 多段透明度停靠点用于模拟 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 getCoverUrl = (data: DouyinImageWorkData): string | undefined => data.image_list.images[0]?.url const DouyinDiffuseBackground: React.FC> = ({ data, ctx }) => { const coverUrl = getCoverUrl(data) return ( {coverUrl && } ) } const DouyinPosterHeader: React.FC> = ({ data, ctx }) => { const { avater_url, username, create_time, ip_location } = data const dark = isDark(ctx) const publishTime = formatDistanceToNow(fromUnixTime(create_time), { addSuffix: true, locale: zhCN }) return ( {username} {publishTime} {ip_location && ( {ip_location} )} ) } 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: RichTextRenderOptions = { hashtag: { className: cn('text-inherit opacity-60', !hasTitle && 'text-6xl') }, mention: { className: 'text-inherit' } } if (!hasTitle && !hasDesc) { return 抖音图文作品 } return ( {hasTitle && title && ( {renderRichTextToReact(title, richTextOptions)} )} {hasDesc && ( {renderRichTextToReact(desc, richTextOptions)} )} ) } const DouyinImageCover: React.FC> = ({ data }) => { const { image_list, music } = data const cover = image_list.images[0] if (!cover?.url) return null const totalCount = image_list.total_count || image_list.images.length const mediaType = cover.media_type ?? 'static' const mediaMeta = imageMediaTypeMeta[mediaType] const MediaIcon = mediaMeta.icon const previewImages = image_list.images.slice(1, 4).filter((image) => image.url) const remainingPreviewCount = Math.max(totalCount - previewImages.length - 1, 0) return ( {mediaMeta.label} {previewImages.length > 0 && ( {previewImages.map((image, index) => ( ))} {remainingPreviewCount > 0 && ( +{remainingPreviewCount} )} )} 共 {totalCount} 张 {music && ( {music.cover ? ( ) : ( )} {music.title} {music.author} )} ) } 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 allCreators = data.cooperation_info?.co_creators ?? [] const creators = allCreators.filter((creator) => creator.nickname !== data.username) if (creators.length === 0) return null const visibleCreators = creators.slice(0, 8) const hasSubscriberInList = allCreators.some((creator) => creator.nickname === data.username) const remainingCount = Math.max(coCreatorCount - visibleCreators.length - (hasSubscriberInList ? 1 : 0), 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 dark = isDark(ctx) const stats = [ { icon: DouyinLikeIcon, iconSize: 26, label: '获赞', value: 获赞 }, { icon: UserPlusIcon, iconSize: 32, label: '关注', value: 关注 }, { icon: UsersThreeIcon, iconSize: 32, label: '粉丝', value: 粉丝 } ] return ( ) } export const DouyinImageWork: 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 && } ) }) DouyinImageWork.displayName = 'DouyinImageWork' export default DouyinImageWork