owpengram-server/internal/app/files/photos.go
2026-08-05 02:34:24 +03:00

1225 lines
43 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package files
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"fmt"
"image"
"image/color"
stddraw "image/draw"
"image/jpeg"
"image/png"
"io"
"math"
"strings"
"time"
"telesrv/internal/domain"
"go.uber.org/zap"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp" // 注册 webp Decode用于 custom emoji / sticker 静态缩略图合成
)
// 头像使用真实的 's'(≤150)/'a'(≤160)/'c'(原图) rendition图片消息使用
// 'm' 缩略与 'x' 大图。每个头像 location_key 的元数据尺寸必须与实际 blob 一致。
// UploadProfilePhoto 把已上传文件组装成头像 Photo落 blob/photos/profile_photos并设为当前头像。
func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) {
return s.UploadProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, file, date)
}
// UploadProfilePhotoKind stores a profile or fallback photo and makes it current for that kind.
func (s *Service) UploadProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, file domain.UploadedFileRef, date int) (domain.Photo, error) {
if date == 0 {
date = int(time.Now().Unix())
}
photo, err := s.CreateAvatarFromUpload(ctx, file)
if err != nil {
return domain.Photo{}, err
}
if err := s.media.AddProfilePhotoKind(ctx, ownerType, ownerID, kind, photo.ID, date); err != nil {
return domain.Photo{}, err
}
return photo, nil
}
// CreatePhotoFromUpload 把已上传文件组装成 Photo不绑定 profile_photos用于频道头像 / 图片消息。
func (s *Service) CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) {
intentHash, err := uploadedMediaIntentHash(domain.UploadedMediaPhoto, file, nil)
if err != nil {
return domain.Photo{}, err
}
if photo, found, err := s.replayUploadedPhoto(ctx, file, intentHash); err != nil || found {
return photo, err
}
data, err := s.readUploadBytes(ctx, file.OwnerUserID, file.FileID, file.Parts)
if err != nil {
return domain.Photo{}, err
}
if len(data) == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
photo, err := s.createPhoto(ctx, data, photoSizeSpecsForMessage(data), file.OwnerUserID)
if err != nil {
return domain.Photo{}, err
}
receipt, err := s.commitUploadedMediaReceipt(ctx, file, domain.UploadedMediaPhoto, intentHash, photo.ID)
if err != nil {
return domain.Photo{}, err
}
if receipt.MediaID != photo.ID {
winner, found, err := s.media.GetPhoto(ctx, receipt.MediaID)
if err != nil {
return domain.Photo{}, err
}
if !found {
return domain.Photo{}, fmt.Errorf("concurrent upload receipt references missing photo %d", receipt.MediaID)
}
photo = winner
}
s.cleanupMaterializedUpload(ctx, file, "photo materialized")
return photo, nil
}
// CreatePhotoFromBytes stores already-fetched image bytes as a message Photo.
// There is no uploader (e.g. a server-fetched webpage preview image), so the
// photo is attributed to the system (owner_user_id 0) for storage accounting.
func (s *Service) CreatePhotoFromBytes(ctx context.Context, data []byte) (domain.Photo, error) {
if len(data) == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
return s.createPhoto(ctx, data, photoSizeSpecsForMessage(data), 0)
}
// GetPhoto 按 id 返回已存储照片。
func (s *Service) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) {
return s.media.GetPhoto(ctx, id)
}
type photoBatchStore interface {
GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error)
}
// GetPhotos loads immutable photo metadata in caller order without requiring
// one storage round-trip per requested-peer response. PostgreSQL implements the
// optional batch primitive; lightweight stores retain a bounded fallback.
func (s *Service) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) {
if s == nil || s.media == nil || len(ids) == 0 {
return nil, nil
}
if batch, ok := s.media.(photoBatchStore); ok {
return batch.GetPhotos(ctx, ids)
}
seen := make(map[int64]struct{}, len(ids))
out := make([]domain.Photo, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
photo, found, err := s.media.GetPhoto(ctx, id)
if err != nil {
return nil, err
}
if found {
out = append(out, photo)
}
}
return out, nil
}
// GetDocument 按 id 返回已存储文档(贴纸 / 文件)。
func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) {
return s.media.GetDocument(ctx, id)
}
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo's'/'a'/'c' 尺寸,'a'/'c' 匹配
// InputPeerPhotoFileLocation big/small 与 channelFull 下载路径),不绑定 profile_photos。用于频道 editPhoto。
func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) {
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
if err != nil {
return domain.Photo{}, err
}
if len(data) == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
return s.createAvatarPhoto(ctx, data, file.OwnerUserID)
}
// CreateAvatarVideoFromUpload stores an animated profile video as photo.video_sizes.
func (s *Service) CreateAvatarVideoFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64) (domain.Photo, error) {
return s.createAvatarVideoFromUpload(ctx, file, videoStartTs, nil)
}
// CreateAvatarVideoMarkupFromUpload stores Android-style generated avatar video plus its emoji/sticker markup.
func (s *Service) CreateAvatarVideoMarkupFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64, markup domain.PhotoSize) (domain.Photo, error) {
if err := validateAvatarMarkupSize(markup); err != nil {
return domain.Photo{}, err
}
return s.createAvatarVideoFromUpload(ctx, file, videoStartTs, []domain.PhotoSize{markup})
}
func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64, extraSizes []domain.PhotoSize) (domain.Photo, error) {
body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts)
if err != nil {
return domain.Photo{}, err
}
if body.Size == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
photoID := randomID()
blob := domain.FileBlob{
LocationKey: fmt.Sprintf("photo:%d:u", photoID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: body.ObjectKey,
Size: body.Size,
SHA256: body.SHA256,
MimeType: "video/mp4",
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return domain.Photo{}, err
}
s.blobCache.put(blob.LocationKey, blob)
stillBytes := s.avatarVideoStill(ctx, body, extraSizes)
sizes, err := s.putAvatarStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
if err != nil {
return domain.Photo{}, err
}
sizes = append(sizes, domain.PhotoSize{
Kind: domain.PhotoSizeKindVideo,
Type: "u",
W: 640,
H: 640,
Size: int(body.Size),
VideoStartTs: videoStartTs,
})
sizes = append(sizes, extraSizes...)
photo := domain.Photo{
ID: photoID,
AccessHash: randomID(),
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
DCID: s.dc,
Sizes: sizes,
OwnerUserID: file.OwnerUserID,
}
if err := s.media.PutPhoto(ctx, photo); err != nil {
return domain.Photo{}, err
}
if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil {
s.log.Warn("cleanup assembled avatar video upload parts failed",
zap.Int64("owner_user_id", file.OwnerUserID),
zap.Int64("file_id", file.FileID),
zap.Int64("photo_id", photoID),
zap.Error(err))
}
return photo, nil
}
// CreateAvatarMarkup stores an emoji/sticker animated profile markup as photo.video_sizes.
func (s *Service) CreateAvatarMarkup(ctx context.Context, size domain.PhotoSize) (domain.Photo, error) {
if err := validateAvatarMarkupSize(size); err != nil {
return domain.Photo{}, err
}
photoID := randomID()
stillBytes := s.generatedAvatarStill(ctx, size)
sizes, err := s.putAvatarStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
if err != nil {
return domain.Photo{}, err
}
sizes = append(sizes, size)
photo := domain.Photo{
ID: photoID,
AccessHash: randomID(),
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
DCID: s.dc,
Sizes: sizes,
}
if err := s.media.PutPhoto(ctx, photo); err != nil {
return domain.Photo{}, err
}
return photo, nil
}
func validateAvatarMarkupSize(size domain.PhotoSize) error {
switch size.Kind {
case domain.PhotoSizeKindVideoEmojiMarkup:
if size.EmojiID == 0 || len(size.BackgroundColors) == 0 {
return domain.ErrPhotoInvalid
}
case domain.PhotoSizeKindVideoStickerMarkup:
if size.StickerID == 0 || len(size.BackgroundColors) == 0 {
return domain.ErrPhotoInvalid
}
default:
return domain.ErrPhotoInvalid
}
return nil
}
// CreateDocumentFromUpload 把已上传文件组装成 Document文件/视频/音频/gif/贴纸消息),落 blob + documents。
func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) {
intentHash, err := uploadedMediaIntentHash(domain.UploadedMediaDocument, file, &spec)
if err != nil {
return domain.Document{}, err
}
if doc, found, err := s.replayUploadedDocument(ctx, file, intentHash); err != nil || found {
return doc, err
}
body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts)
if err != nil {
return domain.Document{}, err
}
if body.Size == 0 {
return domain.Document{}, domain.ErrDocumentInvalid
}
body, spec, err = s.normalizeUploadedGIF(ctx, body, spec)
if err != nil {
return domain.Document{}, err
}
// faststartMP4 视频若 moov 在末尾,搬到文件头以支持流式播放。普通 Telegram 客户端
// 上传前会做这步DrKLO 发 story 视频不转码导致 moov 在末尾TDesktop 流式播放路径
// 无法解复用av_read_frame Invalid data。不转码、保留原编码含 HEVC
body = s.maybeFaststartVideoBlob(ctx, spec.MimeType, body)
docID := randomID()
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", docID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: body.ObjectKey,
Size: body.Size,
SHA256: body.SHA256,
MimeType: spec.MimeType,
}); err != nil {
return domain.Document{}, err
}
doc := domain.Document{
ID: docID,
AccessHash: randomID(),
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
MimeType: spec.MimeType,
Size: body.Size,
DCID: s.dc,
Attributes: spec.Attributes,
OwnerUserID: file.OwnerUserID,
}
thumbMaterialized := false
if spec.Thumb != nil {
thumbData, err := s.readUploadBytes(ctx, spec.Thumb.OwnerUserID, spec.Thumb.FileID, spec.Thumb.Parts)
if err == nil && len(thumbData) > 0 {
if thumb, err := s.putDocumentThumb(ctx, docID, thumbData); err == nil {
doc.Thumbs = []domain.PhotoSize{thumb}
thumbMaterialized = true
}
}
}
if len(doc.Thumbs) == 0 {
if thumb, ok := s.generateVideoThumbFallbackFromBlob(ctx, docID, body.ObjectKey, body.Size, spec); ok {
doc.Thumbs = []domain.PhotoSize{thumb}
}
}
if err := s.media.PutDocument(ctx, doc); err != nil {
return domain.Document{}, err
}
receipt, err := s.commitUploadedMediaReceipt(ctx, file, domain.UploadedMediaDocument, intentHash, doc.ID)
if err != nil {
return domain.Document{}, err
}
if receipt.MediaID != doc.ID {
winner, found, err := s.media.GetDocument(ctx, receipt.MediaID)
if err != nil {
return domain.Document{}, err
}
if !found {
return domain.Document{}, fmt.Errorf("concurrent upload receipt references missing document %d", receipt.MediaID)
}
doc = winner
}
s.cleanupMaterializedUpload(ctx, file, "document materialized")
if spec.Thumb != nil && thumbMaterialized {
s.cleanupMaterializedUpload(ctx, *spec.Thumb, "document thumbnail materialized")
}
return doc, nil
}
func (s *Service) normalizeUploadedGIF(ctx context.Context, body assembledUploadBlob, spec domain.DocumentSpec) (assembledUploadBlob, domain.DocumentSpec, error) {
if !strings.EqualFold(strings.TrimSpace(spec.MimeType), "image/gif") || spec.ForceFile {
return body, spec, nil
}
if s.gifs == nil || body.Size <= 0 || body.Size > gifTranscodeMaxInputBytes {
return assembledUploadBlob{}, domain.DocumentSpec{}, domain.ErrDocumentInvalid
}
data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, gifTranscodeMaxInputBytes+1)
if err != nil || total != body.Size || int64(len(data)) != body.Size {
return assembledUploadBlob{}, domain.DocumentSpec{}, domain.ErrDocumentInvalid
}
started := time.Now()
converted, err := s.gifs.Transcode(ctx, data)
if err != nil || len(converted.Data) == 0 || converted.Width <= 0 || converted.Height <= 0 || converted.Duration <= 0 {
s.log.Warn("GIF to MP4 conversion failed", zap.Int64("input_bytes", body.Size), zap.Error(err))
return assembledUploadBlob{}, domain.DocumentSpec{}, domain.ErrDocumentInvalid
}
objectKey, err := s.blobs.Put(ctx, converted.Data)
if err != nil {
return assembledUploadBlob{}, domain.DocumentSpec{}, err
}
sum := sha256.Sum256(converted.Data)
body = assembledUploadBlob{ObjectKey: objectKey, Size: int64(len(converted.Data)), SHA256: append([]byte(nil), sum[:]...)}
spec.MimeType = "video/mp4"
spec.Attributes = canonicalGIFVideoAttributes(spec.Attributes, converted, spec.NosoundVideo)
s.log.Info("GIF normalized to MP4",
zap.Int64("input_bytes", total), zap.Int("output_bytes", len(converted.Data)),
zap.Int("width", converted.Width), zap.Int("height", converted.Height),
zap.Float64("duration", converted.Duration), zap.Duration("dur", time.Since(started)))
return body, spec, nil
}
func canonicalGIFVideoAttributes(attrs []domain.DocumentAttribute, video GIFVideo, nosoundVideo bool) []domain.DocumentAttribute {
out := make([]domain.DocumentAttribute, 0, 3)
for _, attr := range attrs {
if attr.Kind == domain.DocAttrFilename {
out = append(out, attr)
}
}
if !nosoundVideo {
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated})
}
out = append(out, domain.DocumentAttribute{
Kind: domain.DocAttrVideo, W: video.Width, H: video.Height,
Duration: video.Duration, SupportsStreaming: true, NoSound: true, VideoCodec: "h264",
})
return out
}
// maxFaststartBytes 限制 faststart 一次性载入内存的视频大小;超过则跳过(流式 faststart
// 复杂度高,超大视频是边角)。与缩略图回退路径同样全量读 blob内存模式无新增。
const maxFaststartBytes = 200 << 20
// faststartVideoMimes 是会用 moov/mdat 结构、值得尝试 faststart 的容器 mime。
// 其它 video/*webm 等结构不同faststartMP4 也会自检后 no-op故不在此列以免白读 blob。
var faststartVideoMimes = map[string]bool{
"video/mp4": true,
"video/quicktime": true,
"video/x-m4v": true,
}
// maybeFaststartVideoBlob 对 MP4/MOV 视频上传尝试 faststart。性能考量
// 1. 先只读顶层 box 头(廉价探测)判断是否需要——绝大多数客户端上传的视频本就已 faststart
// 此时只发生几次 16 字节读,不读整段媒体。
// 2. 仅 moov 在末尾时才重写;且优先走流式(仅 ftyp+moov 进内存mdat 大块分块流式拼接),
// 不把整段视频 2× 驻留内存。moov 非末尾的罕见排布回退到全量重排。
//
// 任何不适用/失败都返回原 body绝不让上传失败或损坏数据。
func (s *Service) maybeFaststartVideoBlob(ctx context.Context, mimeType string, body assembledUploadBlob) assembledUploadBlob {
if !faststartVideoMimes[strings.ToLower(strings.TrimSpace(mimeType))] {
return body
}
if body.Size <= 0 || body.Size > maxFaststartBytes {
return body
}
readAt := func(off, n int64) ([]byte, error) {
data, _, err := s.blobs.GetRange(ctx, body.ObjectKey, off, n)
return data, err
}
layout, ok := inspectMP4Layout(body.Size, readAt)
if !ok || !layout.needsFaststart {
return body // 非 MP4 / 已 faststart —— 未读整段媒体
}
var reader io.Reader
if layout.moovIsLast {
// 流式重写:只把 ftyp + moov 读进内存并 patch 偏移mdat 区段分块流式。
ftyp, e1 := readAt(layout.ftypStart, layout.ftypEnd-layout.ftypStart)
moov, e2 := readAt(layout.moovStart, layout.moovEnd-layout.moovStart)
moovSize := layout.moovEnd - layout.moovStart
if e1 != nil || e2 != nil ||
int64(len(ftyp)) != layout.ftypEnd-layout.ftypStart ||
int64(len(moov)) != moovSize ||
!patchChunkOffsets(moov, moovSize) {
return body
}
mid := &blobRangeReader{ctx: ctx, blobs: s.blobs, key: body.ObjectKey, pos: layout.ftypEnd, end: layout.moovStart}
reader = io.MultiReader(bytes.NewReader(ftyp), bytes.NewReader(moov), mid)
} else {
// 罕见moov 非末尾。回退到全量读 + 重排(已测函数)。
data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, body.Size)
if err != nil || total != body.Size || int64(len(data)) != body.Size {
return body
}
out, changed := faststartMP4(data)
if !changed {
return body
}
reader = bytes.NewReader(out)
}
key, size, sum, err := s.blobs.PutReader(ctx, reader)
if err != nil {
s.log.Warn("faststart re-store failed; keeping original blob",
zap.String("mime", mimeType), zap.Int64("size", body.Size), zap.Error(err))
return body
}
if size != body.Size {
// faststart 守恒大小;不等说明流式拼接出错,丢弃新 blob 用原 blob 兜底。
s.log.Warn("faststart size mismatch; keeping original blob",
zap.Int64("orig", body.Size), zap.Int64("got", size))
return body
}
s.log.Info("faststart applied to uploaded video",
zap.String("mime", mimeType), zap.Int64("size", size))
return assembledUploadBlob{ObjectKey: key, Size: size, SHA256: sum}
}
// blobRangeReader 把 blob 的 [pos, end) 区段按 io.Reader 调用方给的缓冲大小分块流式读出,
// 用于 faststart 流式拼接 mdat避免整段媒体驻留内存。
type blobRangeReader struct {
ctx context.Context
blobs BlobBackend
key string
pos int64
end int64
}
func (r *blobRangeReader) Read(p []byte) (int, error) {
if r.pos >= r.end {
return 0, io.EOF
}
want := r.end - r.pos
if want > int64(len(p)) {
want = int64(len(p))
}
data, _, err := r.blobs.GetRange(r.ctx, r.key, r.pos, want)
if err != nil {
return 0, err
}
if len(data) == 0 {
return 0, io.ErrUnexpectedEOF // 区段内不应读到空,避免静默截断
}
n := copy(p, data)
r.pos += int64(n)
return n, nil
}
// CreateDocumentFromBytes stores already-fetched bytes as a message Document.
func (s *Service) CreateDocumentFromBytes(ctx context.Context, data []byte, spec domain.DocumentSpec) (domain.Document, error) {
if len(data) == 0 {
return domain.Document{}, domain.ErrDocumentInvalid
}
if strings.TrimSpace(spec.MimeType) == "" {
spec.MimeType = "application/octet-stream"
}
if strings.EqualFold(strings.TrimSpace(spec.MimeType), "image/gif") && !spec.ForceFile {
if s.gifs == nil || len(data) > gifTranscodeMaxInputBytes {
return domain.Document{}, domain.ErrDocumentInvalid
}
converted, err := s.gifs.Transcode(ctx, data)
if err != nil || len(converted.Data) == 0 || converted.Width <= 0 || converted.Height <= 0 || converted.Duration <= 0 {
return domain.Document{}, domain.ErrDocumentInvalid
}
data = converted.Data
spec.MimeType = "video/mp4"
spec.Attributes = canonicalGIFVideoAttributes(spec.Attributes, converted, spec.NosoundVideo)
}
objectKey, size, sum, err := s.blobs.PutReader(ctx, bytes.NewReader(data))
if err != nil {
return domain.Document{}, err
}
if size == 0 {
return domain.Document{}, domain.ErrDocumentInvalid
}
docID := randomID()
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", docID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: size,
SHA256: sum,
MimeType: spec.MimeType,
}); err != nil {
return domain.Document{}, err
}
doc := domain.Document{
ID: docID,
AccessHash: randomID(),
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
MimeType: spec.MimeType,
Size: size,
DCID: s.dc,
Attributes: spec.Attributes,
}
if thumb, ok := s.generateVideoThumbFallbackFromBlob(ctx, docID, objectKey, size, spec); ok {
doc.Thumbs = []domain.PhotoSize{thumb}
}
if err := s.media.PutDocument(ctx, doc); err != nil {
return domain.Document{}, err
}
return doc, nil
}
// SetCurrentProfilePhoto 把已存在的 photo 设为当前头像updateProfilePhoto 选历史头像)。
func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) {
return s.SetCurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoID, date)
}
// SetCurrentProfilePhotoKind sets an existing photo as current for profile or fallback history.
func (s *Service) SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error) {
photo, ok, err := s.media.GetPhoto(ctx, photoID)
if err != nil || !ok {
return domain.Photo{}, ok, err
}
if date == 0 {
date = int(time.Now().Unix())
}
if err := s.media.AddProfilePhotoKind(ctx, ownerType, ownerID, kind, photoID, date); err != nil {
return domain.Photo{}, false, err
}
return photo, true, nil
}
// CurrentProfilePhoto 返回某 owner 的当前头像 Photo。
func (s *Service) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) {
return s.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile)
}
// CurrentProfilePhotoKind returns the current profile/fallback photo.
func (s *Service) CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error) {
id, ok, err := s.media.CurrentProfilePhotoKind(ctx, ownerType, ownerID, kind)
if err != nil || !ok {
return domain.Photo{}, ok, err
}
return s.media.GetPhoto(ctx, id)
}
// GetProfilePhotos 返回 owner 的头像历史(最新在前)。
func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error) {
return s.GetProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, offset, limit, maxID)
}
// GetProfilePhotosKind returns profile/fallback photo history.
func (s *Service) GetProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) {
return s.media.ListProfilePhotoDetailsKind(ctx, ownerType, ownerID, kind, offset, limit, maxID)
}
// DeleteProfilePhotos 停用指定头像,返回成功停用数量。
func (s *Service) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error) {
return s.DeleteProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoIDs)
}
// DeleteProfilePhotosKind disables profile/fallback photos of the selected kind.
func (s *Service) DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) (int, error) {
deleted, err := s.media.DeleteProfilePhotosKind(ctx, ownerType, ownerID, kind, photoIDs)
if err != nil {
return 0, err
}
return len(deleted), nil
}
// createPhoto 把字节落 blob每个尺寸一个 location_key指向同一内容并写 photos 表。
func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSizeSpec, ownerUserID int64) (domain.Photo, error) {
photoID := randomID()
sizes, err := s.putPhotoStaticSizes(ctx, photoID, data, specs)
if err != nil {
return domain.Photo{}, err
}
photo := domain.Photo{
ID: photoID,
AccessHash: randomID(),
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
DCID: s.dc,
Sizes: sizes,
OwnerUserID: ownerUserID,
}
if err := s.media.PutPhoto(ctx, photo); err != nil {
return domain.Photo{}, err
}
return photo, nil
}
func (s *Service) createAvatarPhoto(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error) {
photoID := randomID()
sizes, err := s.putAvatarStaticSizes(ctx, photoID, data, photoSizeSpecsForAvatar(data))
if err != nil {
return domain.Photo{}, err
}
photo := domain.Photo{
ID: photoID,
AccessHash: randomID(),
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
DCID: s.dc,
Sizes: sizes,
OwnerUserID: ownerUserID,
}
if err := s.media.PutPhoto(ctx, photo); err != nil {
return domain.Photo{}, err
}
return photo, nil
}
func (s *Service) putPhotoStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) {
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
return nil, err
}
mimeType := imageMimeType(data)
sizes := make([]domain.PhotoSize, 0, len(specs))
for _, spec := range specs {
blob := domain.FileBlob{
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(data)),
MimeType: mimeType,
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return nil, err
}
s.blobCache.put(blob.LocationKey, blob)
sizes = append(sizes, domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: spec.Type, W: spec.W, H: spec.H, Size: len(data)})
}
s.prewarmSmallBlob(objectKey, data)
return sizes, nil
}
// putAvatarStaticSizes stores independently rendered avatar sizes. DrKLO uses the
// photos.uploadProfilePhoto response's closest 150px size as an immediate local
// location, while UserProfilePhoto updates synthesize the canonical 'a' location.
// Keeping a real 's' rendition therefore gives those two states distinct keys and,
// more importantly, keeps every advertised size backed by matching image bytes.
func (s *Service) putAvatarStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) {
src, format, err := image.Decode(bytes.NewReader(data))
if err != nil || src.Bounds().Dx() <= 0 || src.Bounds().Dy() <= 0 {
return nil, domain.ErrPhotoInvalid
}
sizes := make([]domain.PhotoSize, 0, len(specs))
for _, spec := range specs {
rendition, err := avatarRendition(data, src, format, spec)
if err != nil {
return nil, err
}
objectKey, err := s.blobs.Put(ctx, rendition)
if err != nil {
return nil, err
}
blob := domain.FileBlob{
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(rendition)),
MimeType: imageMimeType(rendition),
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return nil, err
}
s.blobCache.put(blob.LocationKey, blob)
s.prewarmSmallBlob(objectKey, rendition)
sizes = append(sizes, domain.PhotoSize{
Kind: domain.PhotoSizeKindDefault,
Type: spec.Type,
W: spec.W,
H: spec.H,
Size: len(rendition),
})
}
return sizes, nil
}
func avatarRendition(original []byte, src image.Image, format string, spec photoSizeSpec) ([]byte, error) {
bounds := src.Bounds()
if bounds.Dx() == spec.W && bounds.Dy() == spec.H {
return append([]byte(nil), original...), nil
}
if spec.W <= 0 || spec.H <= 0 {
return nil, domain.ErrPhotoInvalid
}
dst := image.NewRGBA(image.Rect(0, 0, spec.W, spec.H))
srcRect := centerCropRect(bounds, spec.W, spec.H)
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, srcRect, xdraw.Src, nil)
var buf bytes.Buffer
if format == "jpeg" {
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 90}); err != nil {
return nil, err
}
} else if err := png.Encode(&buf, dst); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func centerCropRect(bounds image.Rectangle, targetW, targetH int) image.Rectangle {
sourceW, sourceH := bounds.Dx(), bounds.Dy()
if int64(sourceW)*int64(targetH) > int64(sourceH)*int64(targetW) {
cropW := maxInt(1, sourceH*targetW/targetH)
x := bounds.Min.X + (sourceW-cropW)/2
return image.Rect(x, bounds.Min.Y, x+cropW, bounds.Max.Y)
}
if int64(sourceW)*int64(targetH) < int64(sourceH)*int64(targetW) {
cropH := maxInt(1, sourceW*targetH/targetW)
y := bounds.Min.Y + (sourceH-cropH)/2
return image.Rect(bounds.Min.X, y, bounds.Max.X, y+cropH)
}
return bounds
}
func (s *Service) putDocumentThumb(ctx context.Context, docID int64, thumbData []byte) (domain.PhotoSize, error) {
if len(thumbData) == 0 {
return domain.PhotoSize{}, fmt.Errorf("empty document thumbnail")
}
thumbKey, err := s.blobs.Put(ctx, thumbData)
if err != nil {
return domain.PhotoSize{}, err
}
w, h := imageDimensions(thumbData, 0, 0)
blob := domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d:m", docID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: thumbKey,
Size: int64(len(thumbData)),
MimeType: "image/jpeg",
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return domain.PhotoSize{}, err
}
s.blobCache.put(blob.LocationKey, blob)
s.prewarmSmallBlob(thumbKey, thumbData)
return domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: "m", W: w, H: h, Size: len(thumbData)}, nil
}
func (s *Service) generateVideoThumbFallback(ctx context.Context, docID int64, data []byte, spec domain.DocumentSpec) (domain.PhotoSize, bool) {
if s.thumbs == nil || !documentSpecIsVideo(spec) || len(data) > videoThumbnailMaxInputBytes {
return domain.PhotoSize{}, false
}
thumbData, err := s.thumbs.Extract(ctx, data, spec.MimeType)
if err != nil {
s.log.Warn("server-side video thumbnail fallback failed",
zap.Int64("document_id", docID),
zap.String("mime_type", spec.MimeType),
zap.Int64("bytes", int64(len(data))),
zap.Error(err))
return domain.PhotoSize{}, false
}
thumb, err := s.putDocumentThumb(ctx, docID, thumbData)
if err != nil {
s.log.Warn("store server-side video thumbnail failed",
zap.Int64("document_id", docID),
zap.String("mime_type", spec.MimeType),
zap.Int64("thumb_bytes", int64(len(thumbData))),
zap.Error(err))
return domain.PhotoSize{}, false
}
return thumb, true
}
func (s *Service) generateVideoThumbFallbackFromBlob(ctx context.Context, docID int64, objectKey string, size int64, spec domain.DocumentSpec) (domain.PhotoSize, bool) {
if s.thumbs == nil || !documentSpecIsVideo(spec) || size > videoThumbnailMaxInputBytes {
return domain.PhotoSize{}, false
}
data, total, err := s.blobs.GetRange(ctx, objectKey, 0, size)
if err != nil {
s.log.Warn("read video blob for thumbnail fallback failed",
zap.Int64("document_id", docID),
zap.String("mime_type", spec.MimeType),
zap.Int64("bytes", size),
zap.Error(err))
return domain.PhotoSize{}, false
}
if int64(len(data)) != total || total != size {
s.log.Warn("video blob size mismatch for thumbnail fallback",
zap.Int64("document_id", docID),
zap.Int64("expected_size", size),
zap.Int64("total_size", total),
zap.Int("read_bytes", len(data)))
return domain.PhotoSize{}, false
}
return s.generateVideoThumbFallback(ctx, docID, data, spec)
}
func documentSpecIsVideo(spec domain.DocumentSpec) bool {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(spec.MimeType)), "video/") {
return true
}
for _, attr := range spec.Attributes {
if attr.Kind == domain.DocAttrVideo {
return true
}
}
return false
}
type photoSizeSpec struct {
Type string
W int
H int
}
func photoSizeSpecsForAvatar(data []byte) []photoSizeSpec {
w, h := imageDimensions(data, 640, 640)
shortSide := w
if h < shortSide {
shortSide = h
}
sSize := minInt(shortSide, 150)
aSize := minInt(shortSide, 160)
return []photoSizeSpec{
{Type: "s", W: sSize, H: sSize},
{Type: "a", W: aSize, H: aSize},
{Type: "c", W: w, H: h},
}
}
// photoSizeSpecsForMessage 给图片消息生成下载尺寸('m' 缩略 + 'x'/'y' 大图)。
func photoSizeSpecsForMessage(data []byte) []photoSizeSpec {
w, h := imageDimensions(data, 1280, 1280)
thumbW, thumbH := scaleDown(w, h, 320)
return []photoSizeSpec{
{Type: "m", W: thumbW, H: thumbH},
{Type: "x", W: w, H: h},
}
}
func imageDimensions(data []byte, defW, defH int) (int, int) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
return defW, defH
}
return cfg.Width, cfg.Height
}
func scaleDown(w, h, max int) (int, int) {
if w <= max && h <= max {
return w, h
}
if w >= h {
return max, max * h / w
}
return max * w / h, max
}
const (
avatarStillSize = 640
avatarMarkupScale = 0.70
avatarMarkupMaxSourceBytes = 2 << 20 // emoji/sticker thumb 小对象保护线。
)
// avatarVideoStill 生成动画头像的静态尺寸字节。emoji/sticker markup 能解析到
// 服务端缩略图时优先合成DrKLO 生成的 MP4 第一帧可能只有背景渐变,直接抽第一帧
// 会让静态头像永久缺少 emoji。普通视频或 markup 资源不可用时才回退 ffmpeg 首帧。
func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob, extraSizes []domain.PhotoSize) []byte {
markup := avatarStillMarkup(extraSizes)
if still, ok := s.generatedAvatarMarkupStill(ctx, markup); ok {
return still
}
if s.thumbs != nil && body.Size > 0 && body.Size <= videoThumbnailMaxInputBytes {
data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, body.Size)
if err == nil && int64(len(data)) == total && total == body.Size {
if thumb, err := s.thumbs.Extract(ctx, data, "video/mp4"); err == nil && len(thumb) > 0 {
return thumb
} else if err != nil {
s.log.Debug("extract avatar video first frame failed, falling back to composed still",
zap.String("object_key", body.ObjectKey),
zap.Int64("bytes", body.Size),
zap.Error(err))
}
} else if err != nil {
s.log.Warn("read avatar video blob for still failed",
zap.String("object_key", body.ObjectKey),
zap.Int64("bytes", body.Size),
zap.Error(err))
}
}
return s.generatedAvatarStill(ctx, markup)
}
func (s *Service) generatedAvatarStill(ctx context.Context, markup domain.PhotoSize) []byte {
img := generatedAvatarBackground(markup.BackgroundColors)
if still, ok := s.generatedAvatarMarkupStillOnBackground(ctx, markup, img); ok {
return still
}
return encodeAvatarPNG(img)
}
func (s *Service) generatedAvatarMarkupStill(ctx context.Context, markup domain.PhotoSize) ([]byte, bool) {
return s.generatedAvatarMarkupStillOnBackground(ctx, markup, generatedAvatarBackground(markup.BackgroundColors))
}
func (s *Service) generatedAvatarMarkupStillOnBackground(ctx context.Context, markup domain.PhotoSize, img *image.RGBA) ([]byte, bool) {
overlay, tintWhite, ok := s.avatarMarkupOverlay(ctx, markup)
if !ok {
return nil, false
}
drawAvatarMarkup(img, overlay, tintWhite)
return encodeAvatarPNG(img), true
}
func generatedAvatarBackground(colors []int) *image.RGBA {
if len(colors) == 0 {
colors = []int{0x5b8def, 0x53c6a4}
}
first := rgbColor(colors[0])
last := first
if len(colors) > 1 {
last = rgbColor(colors[len(colors)-1])
}
img := image.NewRGBA(image.Rect(0, 0, avatarStillSize, avatarStillSize))
for y := 0; y < avatarStillSize; y++ {
t := float64(y) / float64(avatarStillSize-1)
row := lerpColor(first, last, t)
for x := 0; x < avatarStillSize; x++ {
img.SetRGBA(x, y, row)
}
}
return img
}
func encodeAvatarPNG(img image.Image) []byte {
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
// avatarMarkupOverlay 加载 markup 引用文档的静态缩略图作为合成贴图。第二个返回值
// 表示是否染白:仅 text_color 的 custom emoji单色、由客户端按文字色适配渲染
// 头像背景上的约定呈现是白色剪影)需要染白,普通彩色 emoji / sticker 保留原色。
func (s *Service) avatarMarkupOverlay(ctx context.Context, markup domain.PhotoSize) (image.Image, bool, bool) {
if s == nil || s.media == nil {
return nil, false, false
}
docID := int64(0)
switch markup.Kind {
case domain.PhotoSizeKindVideoEmojiMarkup:
docID = markup.EmojiID
case domain.PhotoSizeKindVideoStickerMarkup:
docID = markup.StickerID
default:
return nil, false, false
}
if docID == 0 {
return nil, false, false
}
doc, found, err := s.media.GetDocument(ctx, docID)
if err != nil {
s.log.Warn("load avatar markup document failed", zap.Int64("document_id", docID), zap.Error(err))
return nil, false, false
}
if !found {
return nil, false, false
}
data, ok := s.avatarMarkupBytes(ctx, doc)
if !ok {
return nil, false, false
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
s.log.Debug("decode avatar markup thumbnail failed",
zap.Int64("document_id", doc.ID),
zap.String("mime_type", doc.MimeType),
zap.Error(err))
return nil, false, false
}
// Seed 的 1x1 透明图只是“没有可用静态资源”的显式占位,不是可合成
// 内容。把它视为 unavailable交给调用方回退到 ffmpeg 视频首帧。
if img.Bounds().Dx() <= 1 || img.Bounds().Dy() <= 1 {
return nil, false, false
}
return img, documentIsTextColorEmoji(doc), true
}
// documentIsTextColorEmoji 判断文档是否为声明 text_color 的 custom emoji。
func documentIsTextColorEmoji(doc domain.Document) bool {
for _, attr := range doc.Attributes {
if attr.Kind == domain.DocAttrCustomEmoji {
return attr.TextColor
}
}
return false
}
func (s *Service) avatarMarkupBytes(ctx context.Context, doc domain.Document) ([]byte, bool) {
var best []byte
bestScore := -1
for _, thumb := range doc.Thumbs {
score := avatarThumbScore(thumb)
if score <= bestScore {
continue
}
switch thumb.Kind {
case domain.PhotoSizeKindCached:
if len(thumb.Bytes) == 0 || len(thumb.Bytes) > avatarMarkupMaxSourceBytes {
continue
}
best = append([]byte(nil), thumb.Bytes...)
bestScore = score
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive:
if thumb.Type == "" || thumb.Size <= 0 || thumb.Size > avatarMarkupMaxSourceBytes {
continue
}
data, ok := s.readSmallBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type), int64(thumb.Size))
if !ok {
continue
}
best = data
bestScore = score
}
}
if len(best) > 0 {
return best, true
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(doc.MimeType)), "image/") &&
doc.Size > 0 && doc.Size <= avatarMarkupMaxSourceBytes {
return s.readSmallBlob(ctx, fmt.Sprintf("doc:%d", doc.ID), doc.Size)
}
return nil, false
}
func (s *Service) readSmallBlob(ctx context.Context, locationKey string, expectedSize int64) ([]byte, bool) {
if expectedSize <= 0 || expectedSize > avatarMarkupMaxSourceBytes {
return nil, false
}
blob, found, err := s.media.GetFileBlob(ctx, locationKey)
if err != nil {
s.log.Warn("load avatar markup blob metadata failed",
zap.String("location_key", locationKey),
zap.Error(err))
return nil, false
}
if !found || blob.Size <= 0 || blob.Size > avatarMarkupMaxSourceBytes {
return nil, false
}
backend, err := s.backendFor(blob.Backend)
if err != nil {
s.log.Warn("resolve avatar markup blob backend failed", zap.String("location_key", locationKey), zap.Error(err))
return nil, false
}
data, total, err := backend.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
if err != nil {
s.log.Warn("read avatar markup blob failed",
zap.String("location_key", locationKey),
zap.String("object_key", blob.ObjectKey),
zap.Error(err))
return nil, false
}
if int64(len(data)) != total || total != blob.Size {
return nil, false
}
return data, true
}
func avatarThumbScore(size domain.PhotoSize) int {
if size.W > 0 && size.H > 0 {
return size.W * size.H
}
if size.Size > 0 {
return size.Size
}
return len(size.Bytes)
}
// drawAvatarMarkup 把贴图缩放后居中画到背景上。默认保留贴图原色(彩色 emoji /
// sticker 头像),仅 tintWhite 时染成白色剪影。
func drawAvatarMarkup(dst *image.RGBA, src image.Image, tintWhite bool) {
srcBounds := src.Bounds()
sw, sh := srcBounds.Dx(), srcBounds.Dy()
if sw <= 0 || sh <= 0 {
return
}
max := int(math.Round(float64(dst.Bounds().Dx()) * avatarMarkupScale))
if max <= 0 {
return
}
scale := math.Min(float64(max)/float64(sw), float64(max)/float64(sh))
w := maxInt(1, int(math.Round(float64(sw)*scale)))
h := maxInt(1, int(math.Round(float64(sh)*scale)))
rect := image.Rect(
(dst.Bounds().Dx()-w)/2,
(dst.Bounds().Dy()-h)/2,
(dst.Bounds().Dx()+w)/2,
(dst.Bounds().Dy()+h)/2,
)
scaled := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), src, srcBounds, xdraw.Src, nil)
if tintWhite {
tintWhitePremultiplied(scaled)
}
stddraw.Draw(dst, rect, scaled, image.Point{}, stddraw.Over)
}
// tintWhitePremultiplied 把贴图就地染成白色剪影(保留 alpha 形状。image.RGBA 是
// alpha 预乘存储,分量必须满足 R,G,B ≤ A若写入 R=G=B=255 而 A<255 的非法值,
// draw.Over 合成会算术溢出回绕,凡 alpha 不恰为 255 的像素整片输出近黑色。
func tintWhitePremultiplied(img *image.RGBA) {
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ {
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
_, _, _, a := img.At(x, y).RGBA()
v := uint8(a >> 8)
img.SetRGBA(x, y, color.RGBA{R: v, G: v, B: v, A: v})
}
}
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func rgbColor(v int) color.RGBA {
u := uint32(v)
return color.RGBA{R: uint8(u >> 16), G: uint8(u >> 8), B: uint8(u), A: 255}
}
func lerpColor(a, b color.RGBA, t float64) color.RGBA {
lerp := func(x, y uint8) uint8 {
return uint8(float64(x)*(1-t) + float64(y)*t)
}
return color.RGBA{R: lerp(a.R, b.R), G: lerp(a.G, b.G), B: lerp(a.B, b.B), A: 255}
}
func imageMimeType(data []byte) string {
if len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
return "image/png"
}
if len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff {
return "image/jpeg"
}
return "application/octet-stream"
}
func avatarStillMarkup(sizes []domain.PhotoSize) domain.PhotoSize {
for _, size := range sizes {
if size.Kind == domain.PhotoSizeKindVideoEmojiMarkup || size.Kind == domain.PhotoSizeKindVideoStickerMarkup {
return size
}
if len(size.BackgroundColors) > 0 {
return domain.PhotoSize{BackgroundColors: append([]int(nil), size.BackgroundColors...)}
}
}
return domain.PhotoSize{}
}
func randomID() int64 {
var b [8]byte
_, _ = rand.Read(b[:])
v := int64(binary.BigEndian.Uint64(b[:]) >> 1)
if v == 0 {
v = 1
}
return v
}
func randomFileReference() []byte {
b := make([]byte, 16)
_, _ = rand.Read(b)
return b
}