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 ? (
BGM封面
) : ( )}
{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 (
头像
{username}
抖音号: {抖音号}
{stats.map((stat) => { const Icon = stat.icon return (
{stat.label}
{stat.value}
) })}
扫码查看作品详情
) } 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