返回 AiToEarn
media.service.ts
1 import type { AccountType } from '@yikart/common'
2 import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios'
3 import type { Readable } from 'node:stream'
4 import type { PlatformMediaPolicy, PlatformMediaRules, PublishMediaMetadata } from '../platforms/platforms.interface'
5 import type { PublishMediaAdaptationOption } from '../platforms/publish-media-adaptation.schema'
6 import { createReadStream, createWriteStream, openAsBlob } from 'node:fs'
7 import { mkdtemp, rm, stat } from 'node:fs/promises'
8 import { tmpdir } from 'node:os'
9 import { basename, extname, join } from 'node:path'
10 import { pipeline } from 'node:stream/promises'
11 import { Injectable, Logger } from '@nestjs/common'
12 import { AssetsService, VideoMetadataService } from '@yikart/assets'
13 import { AppException, ResponseCode } from '@yikart/common'
14 import { AssetType } from '@yikart/mongodb'
15 import axios from 'axios'
16 import sizeOf from 'image-size'
17 import { lookup, extension as mimeExtension } from 'mime-types'
18 import sharp from 'sharp'
19 import { ChannelPlatformException, PlatformErrorCategory, PlatformErrorCauseType } from '../platforms/platforms.exception'
20 import { PublishMediaType } from '../platforms/platforms.interface'
21 import { isImageFormatAllowedByMediaRules, listAllowedAdaptationImageFormats, normalizeAdaptationImageFormat, PublishMediaAdaptationImageFormat } from '../platforms/publish-media-adaptation.schema'
22 import { PublishValidationField, PublishValidationIssue, PublishValidationIssueCode } from '../platforms/publish.schema'
23
24 export interface MediaHttpInput {
25 platform: AccountType
26 url: string
27 endpoint: string
28 accountId?: string
29 taskId?: string
30 platformWorkId?: string
31 }
32
33 export interface MediaUploadRange {
34 start: number
35 end: number
36 }
37
38 export interface MediaUploadSource {
39 sizeBytes: number
40 contentType?: string
41 filename: string
42 stream: (range?: MediaUploadRange) => Readable
43 blob: (range?: MediaUploadRange) => Promise<Blob>
44 }
45
46 declare module 'axios' {
47 export interface AxiosRequestConfig {
48 channelMedia?: MediaHttpInput
49 }
50 }
51
52 export interface ImageProbe {
53 width: number
54 height: number
55 format: string
56 sizeBytes: number
57 }
58
59 export interface VideoProbe {
60 width: number
61 height: number
62 format: string
63 durationSec: number
64 codec: string
65 sizeBytes: number
66 }
67
68 interface ValidateVideoOptions {
69 allowUnknownFormat?: boolean
70 }
71
72 type PublishMediaProbe
73 = | { type: PublishMediaType.Image, probe: ImageProbe }
74 | { type: PublishMediaType.Video, probe: VideoProbe }
75
76 export interface ConvertedImage {
77 buffer: Buffer
78 width: number
79 height: number
80 format: string
81 sizeBytes: number
82 }
83
84 export interface PublishMediaLike {
85 url: string
86 metadata?: PublishMediaMetadata
87 options?: { adaptation?: PublishMediaAdaptationOption }
88 }
89
90 export interface PublishCoverLike {
91 url: string
92 metadata?: PublishMediaMetadata
93 options?: { adaptation?: PublishMediaAdaptationOption }
94 }
95
96 export interface PublishContentMediaLike {
97 title?: string
98 body?: string
99 media: PublishMediaLike[]
100 cover?: PublishCoverLike
101 }
102
103 export interface PreparedPublishImage {
104 url: string
105 width: number
106 height: number
107 format: string
108 sizeBytes: number
109 }
110
111 export type PublishMediaPreparationCache = Map<string, PreparedPublishImage>
112
113 export interface PreparePublishContentMediaInput<TContent extends PublishContentMediaLike = PublishContentMediaLike> {
114 userId: string
115 content: TContent
116 mediaRules: PlatformMediaRules
117 mediaPolicy?: PlatformMediaPolicy
118 cache?: PublishMediaPreparationCache
119 }
120
121 export interface PreparePublishContentMediaResult<TContent extends PublishContentMediaLike = PublishContentMediaLike> {
122 content: TContent
123 issues: PublishValidationIssue[]
124 }
125
126 @Injectable()
127 export class MediaService {
128 private readonly logger = new Logger(MediaService.name)
129 private readonly imageConversionSourceMaxBytes = 25 * 1024 * 1024
130 private readonly http: AxiosInstance
131
132 constructor(
133 private readonly videoMetadataService: VideoMetadataService,
134 private readonly assetsService: AssetsService,
135 ) {
136 this.http = axios.create({
137 timeout: 30000,
138 maxContentLength: Infinity,
139 maxBodyLength: Infinity,
140 })
141 this.http.interceptors.response.use(
142 response => response,
143 (error: AxiosError) => {
144 const input = error.config?.channelMedia
145 if (!input) {
146 throw error
147 }
148 throw this.fromAxiosError(error, input)
149 },
150 )
151 }
152
153 async getBuffer(input: MediaHttpInput): Promise<Buffer> {
154 return this.downloadBuffer(input.url, input)
155 }
156
157 async getStream(input: MediaHttpInput): Promise<Readable> {
158 const response = await this.http.get(input.url, {
159 responseType: 'stream',
160 channelMedia: input,
161 })
162 return response.data as Readable
163 }
164
165 async withUploadSource<T>(
166 input: MediaHttpInput,
167 handler: (source: MediaUploadSource) => Promise<T>,
168 ): Promise<T> {
169 const tempDir = await mkdtemp(join(tmpdir(), 'aitoearn-media-'))
170 try {
171 const response = await this.http.get(input.url, {
172 responseType: 'stream',
173 channelMedia: input,
174 })
175 const contentType = this.getHeaderString(response.headers['content-type'])
176 const filename = this.getUploadSourceFilename(input.url, contentType)
177 const filePath = join(tempDir, filename)
178
179 await pipeline(response.data as Readable, createWriteStream(filePath))
180 const fileStats = await stat(filePath)
181 const source: MediaUploadSource = {
182 sizeBytes: fileStats.size,
183 contentType,
184 filename,
185 stream: range => createReadStream(filePath, range ? { start: range.start, end: range.end } : {}),
186 blob: async (range) => {
187 const blob = await openAsBlob(filePath, contentType ? { type: contentType } : undefined)
188 return range ? blob.slice(range.start, range.end + 1, blob.type) : blob
189 },
190 }
191 return await handler(source)
192 }
193 finally {
194 await rm(tempDir, { recursive: true, force: true })
195 }
196 }
197
198 async head(input: MediaHttpInput): Promise<AxiosResponse['headers']> {
199 const response = await this.http.head(input.url, {
200 channelMedia: input,
201 })
202 return response.headers
203 }
204
205 async probeImage(url: string): Promise<ImageProbe> {
206 const head = await this.http.head(url, { timeout: 15000 })
207 const { 'content-length': contentLength } = head.headers
208 const sizeBytes = Number(contentLength || 0)
209 const contentType = this.getHeaderString(head.headers['content-type'])
210
211 const buffer = await this.downloadBuffer(url)
212
213 const { width = 0, height = 0, type } = sizeOf(buffer)
214 const format = type || (contentType ? mimeExtension(contentType) || contentType : undefined) || lookup(url) || 'unknown'
215
216 return { width, height, format, sizeBytes }
217 }
218
219 async probeVideo(url: string): Promise<VideoProbe> {
220 const head = await this.http.head(url, { timeout: 15000 })
221 const { 'content-length': contentLength } = head.headers
222 const sizeBytes = Number(contentLength || 0)
223 const contentType = this.getHeaderString(head.headers['content-type'])
224
225 const metadata = await this.videoMetadataService.probeVideoMetadata(url)
226 const format = this.getUrlExtension(url) || contentType || 'unknown'
227
228 return {
229 width: metadata.width,
230 height: metadata.height,
231 format,
232 durationSec: metadata.duration,
233 codec: 'unknown',
234 sizeBytes,
235 }
236 }
237
238 validateImage(probe: ImageProbe, rules: PlatformMediaRules, pathPrefix: Array<string | number>): PublishValidationIssue[] {
239 const issues: PublishValidationIssue[] = []
240
241 if (rules.maxImageSize && probe.sizeBytes > rules.maxImageSize) {
242 issues.push({
243 code: PublishValidationIssueCode.TooBig,
244 path: [...pathPrefix],
245 params: { field: PublishValidationField.Image, current: probe.sizeBytes, maximum: rules.maxImageSize, unit: 'bytes' },
246 })
247 }
248
249 if (rules.imageFormats?.length && !rules.imageFormats.includes(probe.format)) {
250 issues.push({
251 code: PublishValidationIssueCode.UnsupportedFormat,
252 path: [...pathPrefix],
253 params: { field: PublishValidationField.Image, format: probe.format, allowed: rules.imageFormats.join(', ') },
254 })
255 }
256
257 if (rules.minImageWidth && probe.width < rules.minImageWidth) {
258 issues.push({
259 code: PublishValidationIssueCode.TooSmall,
260 path: [...pathPrefix],
261 params: { field: PublishValidationField.Image, dimension: 'width', current: probe.width, minimum: rules.minImageWidth, unit: 'pixels' },
262 })
263 }
264 if (rules.maxImageWidth && probe.width > rules.maxImageWidth) {
265 issues.push({
266 code: PublishValidationIssueCode.TooBig,
267 path: [...pathPrefix],
268 params: { field: PublishValidationField.Image, dimension: 'width', current: probe.width, maximum: rules.maxImageWidth, unit: 'pixels' },
269 })
270 }
271 if (rules.minImageHeight && probe.height < rules.minImageHeight) {
272 issues.push({
273 code: PublishValidationIssueCode.TooSmall,
274 path: [...pathPrefix],
275 params: { field: PublishValidationField.Image, dimension: 'height', current: probe.height, minimum: rules.minImageHeight, unit: 'pixels' },
276 })
277 }
278 if (rules.maxImageHeight && probe.height > rules.maxImageHeight) {
279 issues.push({
280 code: PublishValidationIssueCode.TooBig,
281 path: [...pathPrefix],
282 params: { field: PublishValidationField.Image, dimension: 'height', current: probe.height, maximum: rules.maxImageHeight, unit: 'pixels' },
283 })
284 }
285
286 if (rules.aspectRatio) {
287 const ratio = probe.width / probe.height
288 if (rules.aspectRatio.min && ratio < rules.aspectRatio.min) {
289 issues.push({
290 code: PublishValidationIssueCode.TooSmall,
291 path: [...pathPrefix],
292 params: { field: PublishValidationField.Image, dimension: 'aspectRatio', current: Math.round(ratio * 100) / 100, minimum: rules.aspectRatio.min },
293 })
294 }
295 if (rules.aspectRatio.max && ratio > rules.aspectRatio.max) {
296 issues.push({
297 code: PublishValidationIssueCode.TooBig,
298 path: [...pathPrefix],
299 params: { field: PublishValidationField.Image, dimension: 'aspectRatio', current: Math.round(ratio * 100) / 100, maximum: rules.aspectRatio.max },
300 })
301 }
302 }
303
304 return issues
305 }
306
307 validateVideo(probe: VideoProbe, rules: PlatformMediaRules, pathPrefix: Array<string | number>, options: ValidateVideoOptions = {}): PublishValidationIssue[] {
308 const issues: PublishValidationIssue[] = []
309
310 if (rules.maxVideoSize && probe.sizeBytes > rules.maxVideoSize) {
311 issues.push({
312 code: PublishValidationIssueCode.TooBig,
313 path: [...pathPrefix],
314 params: { field: PublishValidationField.Video, current: probe.sizeBytes, maximum: rules.maxVideoSize, unit: 'bytes' },
315 })
316 }
317
318 const format = this.normalizeVideoFormat(probe.format)
319
320 if (rules.videoFormats?.length && !rules.videoFormats.includes(format) && !(options.allowUnknownFormat && format === 'unknown')) {
321 issues.push({
322 code: PublishValidationIssueCode.UnsupportedFormat,
323 path: [...pathPrefix],
324 params: { field: PublishValidationField.Video, format, allowed: rules.videoFormats.join(', ') },
325 })
326 }
327
328 if (rules.minVideoDuration !== undefined && probe.durationSec < rules.minVideoDuration) {
329 issues.push({
330 code: PublishValidationIssueCode.InvalidDuration,
331 path: [...pathPrefix],
332 params: { field: PublishValidationField.Video, current: probe.durationSec, minimum: rules.minVideoDuration, maximum: rules.maxVideoDuration, unit: 'seconds' },
333 })
334 }
335
336 if (rules.maxVideoDuration !== undefined && probe.durationSec > rules.maxVideoDuration) {
337 issues.push({
338 code: PublishValidationIssueCode.InvalidDuration,
339 path: [...pathPrefix],
340 params: { field: PublishValidationField.Video, current: probe.durationSec, minimum: rules.minVideoDuration, maximum: rules.maxVideoDuration, unit: 'seconds' },
341 })
342 }
343
344 return issues
345 }
346
347 private getUrlExtension(url: string): string | undefined {
348 const pathname = this.getUrlPathname(url)
349 const extension = extname(pathname).replace('.', '').toLowerCase()
350 return extension || undefined
351 }
352
353 private getUrlPathname(url: string): string {
354 try {
355 return new URL(url).pathname
356 }
357 catch {
358 return url.split(/[?#]/)[0]
359 }
360 }
361
362 private normalizeVideoFormat(format: string): string {
363 return (mimeExtension(format) || format).replace('.', '').toLowerCase()
364 }
365
366 async validateMedia(content: PublishContentMediaLike, rules: PlatformMediaRules): Promise<PublishValidationIssue[]> {
367 const issues: PublishValidationIssue[] = []
368
369 const videoExtensions = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'flv', 'wmv', 'rmvb', '3gp']
370 for (const [i, m] of content.media.entries()) {
371 const pathPrefix = ['content', 'media', i]
372 const mediaType = this.getMediaType(m)
373 const urlExtension = this.getUrlExtension(m.url)
374 if (mediaType === PublishMediaType.Video || videoExtensions.includes(urlExtension ?? '')) {
375 try {
376 const probe = await this.probeVideo(m.url)
377 issues.push(...this.validateVideo(probe, rules, pathPrefix, {
378 allowUnknownFormat: mediaType === PublishMediaType.Video && !urlExtension,
379 }))
380 }
381 catch (err) {
382 this.logger.warn(err, `Failed to probe video ${m.url}`)
383 issues.push({
384 code: PublishValidationIssueCode.InvalidUrl,
385 path: pathPrefix,
386 params: { field: PublishValidationField.Video, url: m.url },
387 })
388 }
389 }
390 else {
391 try {
392 const probe = await this.probeImage(m.url)
393 issues.push(...this.validateImage(probe, rules, pathPrefix))
394 }
395 catch (err) {
396 this.logger.warn(err, `Failed to probe image ${m.url}`)
397 issues.push({
398 code: PublishValidationIssueCode.InvalidUrl,
399 path: pathPrefix,
400 params: { field: PublishValidationField.Image, url: m.url },
401 })
402 }
403 }
404 }
405 if (content.cover?.url) {
406 const pathPrefix = ['content', 'cover']
407 try {
408 const probe = await this.probeImage(content.cover.url)
409 issues.push(...this.validateImage(probe, rules, pathPrefix))
410 }
411 catch (err) {
412 this.logger.warn(err, `Failed to probe cover ${content.cover.url}`)
413 issues.push({
414 code: PublishValidationIssueCode.InvalidUrl,
415 path: pathPrefix,
416 params: { field: PublishValidationField.Image, url: content.cover.url },
417 })
418 }
419 }
420
421 return issues
422 }
423
424 async preparePublishContentMedia<TContent extends PublishContentMediaLike>(
425 input: PreparePublishContentMediaInput<TContent>,
426 ): Promise<PreparePublishContentMediaResult<TContent>> {
427 const cache = input.cache ?? new Map<string, PreparedPublishImage>()
428 const issues: PublishValidationIssue[] = []
429 const media: PublishMediaLike[] = []
430
431 for (const [index, item] of input.content.media.entries()) {
432 const path = ['content', 'media', index]
433 const normalized = await this.normalizePublishMedia(item, path)
434 issues.push(...normalized.issues)
435 if (normalized.issues.length || normalized.media?.type !== PublishMediaType.Image) {
436 media.push(this.stripPublishMediaOptions(normalized.item))
437 continue
438 }
439
440 const prepared = await this.preparePublishImage({
441 userId: input.userId,
442 item: normalized.item,
443 probe: normalized.media.probe,
444 path,
445 field: PublishValidationField.Image,
446 mediaRules: input.mediaRules,
447 mediaPolicy: input.mediaPolicy,
448 cache,
449 })
450 issues.push(...prepared.issues)
451 media.push(prepared.item)
452 }
453
454 let cover = input.content.cover
455 if (input.content.cover?.url) {
456 const normalized = await this.normalizePublishCover(input.content.cover, ['content', 'cover'])
457 issues.push(...normalized.issues)
458 if (normalized.issues.length || !normalized.probe) {
459 cover = normalized.item
460 }
461 else {
462 const prepared = await this.preparePublishImage({
463 userId: input.userId,
464 item: normalized.item,
465 probe: normalized.probe,
466 path: ['content', 'cover'],
467 field: PublishValidationField.Cover,
468 mediaRules: input.mediaRules,
469 mediaPolicy: input.mediaPolicy,
470 cache,
471 })
472 issues.push(...prepared.issues)
473 cover = prepared.item
474 }
475 }
476
477 return {
478 content: issues.length > 0
479 ? input.content
480 : {
481 ...input.content,
482 media,
483 cover,
484 } as TContent,
485 issues,
486 }
487 }
488
489 private async normalizePublishMedia(
490 item: PublishMediaLike,
491 path: Array<string | number>,
492 ): Promise<{ item: PublishMediaLike, media?: PublishMediaProbe, issues: PublishValidationIssue[] }> {
493 try {
494 const media = await this.probePublishMedia(item)
495 return {
496 item: {
497 ...item,
498 metadata: this.getProbeMetadata(media),
499 },
500 media,
501 issues: [],
502 }
503 }
504 catch (err) {
505 this.logger.warn(err, `Failed to probe media ${item.url}`)
506 return {
507 item: this.stripPublishMediaOptions(item),
508 issues: [{
509 code: PublishValidationIssueCode.InvalidUrl,
510 path,
511 params: { field: PublishValidationField.Media, url: item.url },
512 }],
513 }
514 }
515 }
516
517 private async normalizePublishCover(
518 item: PublishCoverLike,
519 path: Array<string | number>,
520 ): Promise<{ item: PublishCoverLike, probe?: ImageProbe, issues: PublishValidationIssue[] }> {
521 try {
522 const probe = await this.probeImage(item.url)
523 return {
524 item: {
525 ...item,
526 metadata: this.getImageMetadata(probe),
527 },
528 probe,
529 issues: [],
530 }
531 }
532 catch (err) {
533 this.logger.warn(err, `Failed to probe cover ${item.url}`)
534 return {
535 item: this.stripPublishMediaOptions(item),
536 issues: [{
537 code: PublishValidationIssueCode.InvalidUrl,
538 path,
539 params: { field: PublishValidationField.Cover, url: item.url },
540 }],
541 }
542 }
543 }
544
545 private async probePublishMedia(item: PublishMediaLike): Promise<PublishMediaProbe> {
546 const type = this.getMediaType(item)
547 if (type === PublishMediaType.Video) {
548 return { type: PublishMediaType.Video, probe: await this.probeVideo(item.url) }
549 }
550 if (type === PublishMediaType.Image) {
551 return { type: PublishMediaType.Image, probe: await this.probeImage(item.url) }
552 }
553
554 const extension = this.getUrlExtension(item.url)
555 if (this.isVideoExtension(extension)) {
556 return { type: PublishMediaType.Video, probe: await this.probeVideo(item.url) }
557 }
558 if (this.isImageExtension(extension)) {
559 return { type: PublishMediaType.Image, probe: await this.probeImage(item.url) }
560 }
561
562 const contentType = await this.getRemoteContentType(item.url)
563 if (contentType?.startsWith('video/')) {
564 return { type: PublishMediaType.Video, probe: await this.probeVideo(item.url) }
565 }
566 if (contentType?.startsWith('image/')) {
567 return { type: PublishMediaType.Image, probe: await this.probeImage(item.url) }
568 }
569
570 try {
571 return { type: PublishMediaType.Video, probe: await this.probeVideo(item.url) }
572 }
573 catch {
574 return { type: PublishMediaType.Image, probe: await this.probeImage(item.url) }
575 }
576 }
577
578 async convertImage(url: string, policy: PlatformMediaPolicy, maxSourceBytes?: number): Promise<ConvertedImage | null> {
579 if (!policy.maxImageWidth && !policy.maxImageHeight && !policy.imageConvertFormat) {
580 return null
581 }
582
583 const sourceBuffer = await this.downloadBuffer(url, undefined, maxSourceBytes)
584 let image = sharp(sourceBuffer)
585
586 const metadata = await image.metadata()
587 const currentWidth = metadata.width ?? 0
588 const currentHeight = metadata.height ?? 0
589
590 let targetWidth = currentWidth
591 let targetHeight = currentHeight
592 if (policy.maxImageWidth && currentWidth > policy.maxImageWidth) {
593 const ratio = policy.maxImageWidth / currentWidth
594 targetWidth = policy.maxImageWidth
595 targetHeight = Math.round(currentHeight * ratio)
596 }
597 if (policy.maxImageHeight && targetHeight > policy.maxImageHeight) {
598 const ratio = policy.maxImageHeight / targetHeight
599 targetHeight = policy.maxImageHeight
600 targetWidth = Math.round(targetWidth * ratio)
601 }
602
603 if (targetWidth !== currentWidth || targetHeight !== currentHeight) {
604 image = image.resize(targetWidth, targetHeight, { fit: 'inside', withoutEnlargement: true })
605 }
606
607 const format = policy.imageConvertFormat ?? metadata.format ?? 'jpeg'
608 const quality = policy.imageQuality ?? 90
609 if (format === 'png') {
610 image = image.png({ quality })
611 }
612 else if (format === 'webp') {
613 image = image.webp({ quality })
614 }
615 else {
616 image = image.jpeg({ quality })
617 }
618
619 const convertedBuffer = await image.toBuffer()
620
621 return {
622 buffer: convertedBuffer,
623 width: targetWidth,
624 height: targetHeight,
625 format,
626 sizeBytes: convertedBuffer.length,
627 }
628 }
629
630 private async preparePublishImage<TItem extends PublishMediaLike | PublishCoverLike>(input: {
631 userId: string
632 item: TItem
633 probe: ImageProbe
634 path: Array<string | number>
635 field: PublishValidationField
636 mediaRules: PlatformMediaRules
637 mediaPolicy?: PlatformMediaPolicy
638 cache: PublishMediaPreparationCache
639 }): Promise<{ item: TItem, issues: PublishValidationIssue[] }> {
640 const item = this.stripPublishMediaOptions(input.item)
641 const imageFormat = input.item.options?.adaptation?.imageFormat
642 if (!imageFormat || imageFormat === PublishMediaAdaptationImageFormat.Off) {
643 return { item, issues: [] }
644 }
645
646 if (!this.isHttpUrl(input.item.url)) {
647 return {
648 item,
649 issues: [{
650 code: PublishValidationIssueCode.InvalidUrl,
651 path: input.path,
652 params: { field: input.field, url: input.item.url },
653 }],
654 }
655 }
656
657 const sourceFormat = normalizeAdaptationImageFormat(mimeExtension(input.probe.format) || input.probe.format)
658 const needsResize = Boolean(input.mediaPolicy?.maxImageWidth || input.mediaPolicy?.maxImageHeight)
659 const targetFormat = this.resolveTargetImageFormat(imageFormat, sourceFormat, input.mediaRules, needsResize)
660 if (!targetFormat) {
661 return {
662 item,
663 issues: [{
664 code: PublishValidationIssueCode.InvalidOption,
665 path: [...input.path, 'options', 'adaptation', 'imageFormat'],
666 params: {
667 field: PublishValidationField.Option,
668 current: imageFormat,
669 allowed: listAllowedAdaptationImageFormats(input.mediaRules).join(', '),
670 },
671 }],
672 }
673 }
674 if (sourceFormat === targetFormat && !needsResize) {
675 return { item, issues: [] }
676 }
677
678 const cacheKey = this.getPublishImageCacheKey(input.item.url, targetFormat, input.mediaPolicy)
679 const cached = input.cache.get(cacheKey)
680 if (cached) {
681 const issues = this.validatePreparedImageSize(cached.sizeBytes, input.mediaRules, input.path, input.field)
682 return {
683 item: issues.length ? item : { ...item, url: cached.url, metadata: this.getPreparedImageMetadata(cached) } as TItem,
684 issues,
685 }
686 }
687
688 const sourceSizeBytes = await this.getRemoteContentLength(input.item.url)
689 if (sourceSizeBytes && sourceSizeBytes > this.imageConversionSourceMaxBytes) {
690 return {
691 item,
692 issues: [{
693 code: PublishValidationIssueCode.TooBig,
694 path: input.path,
695 params: { field: input.field, current: sourceSizeBytes, maximum: this.imageConversionSourceMaxBytes, unit: 'bytes' },
696 }],
697 }
698 }
699
700 let converted: ConvertedImage | null
701 try {
702 converted = await this.convertImage(input.item.url, {
703 ...input.mediaPolicy,
704 imageConvertFormat: targetFormat,
705 }, this.imageConversionSourceMaxBytes)
706 }
707 catch (err) {
708 this.logger.warn(err, `Failed to convert image ${input.item.url}`)
709 return {
710 item,
711 issues: [{
712 code: PublishValidationIssueCode.InvalidUrl,
713 path: input.path,
714 params: { field: input.field, url: input.item.url },
715 }],
716 }
717 }
718
719 if (!converted) {
720 return { item, issues: [] }
721 }
722
723 const sizeIssues = this.validatePreparedImageSize(converted.sizeBytes, input.mediaRules, input.path, input.field)
724 if (sizeIssues.length) {
725 return { item, issues: sizeIssues }
726 }
727
728 const upload = await this.assetsService.uploadFromBuffer(input.userId, converted.buffer, {
729 type: AssetType.PublishMedia,
730 mimeType: this.getImageMimeType(targetFormat),
731 metadata: {
732 width: converted.width,
733 height: converted.height,
734 },
735 })
736 const prepared = {
737 url: upload.url,
738 width: converted.width,
739 height: converted.height,
740 format: targetFormat,
741 sizeBytes: converted.sizeBytes,
742 }
743 input.cache.set(cacheKey, prepared)
744
745 return {
746 item: { ...item, url: upload.url, metadata: this.getPreparedImageMetadata(prepared) } as TItem,
747 issues: [],
748 }
749 }
750
751 private resolveTargetImageFormat(
752 imageFormat: PublishMediaAdaptationImageFormat,
753 sourceFormat: string | undefined,
754 rules: PlatformMediaRules,
755 needsResize: boolean,
756 ): PublishMediaAdaptationImageFormat | undefined {
757 if (imageFormat === PublishMediaAdaptationImageFormat.Auto) {
758 if (!needsResize && isImageFormatAllowedByMediaRules(sourceFormat, rules)) {
759 return sourceFormat as PublishMediaAdaptationImageFormat
760 }
761 return listAllowedAdaptationImageFormats(rules)[0]
762 }
763 return listAllowedAdaptationImageFormats(rules).includes(imageFormat)
764 ? imageFormat
765 : undefined
766 }
767
768 private stripPublishMediaOptions<TItem extends PublishMediaLike | PublishCoverLike>(item: TItem): TItem {
769 if (!item.options) {
770 return item
771 }
772 const options = { ...item.options }
773 delete options.adaptation
774 if (Object.keys(options).length) {
775 return { ...item, options } as TItem
776 }
777 const rest = { ...item }
778 delete rest.options
779 return rest as TItem
780 }
781
782 private validatePreparedImageSize(
783 sizeBytes: number,
784 rules: PlatformMediaRules,
785 path: Array<string | number>,
786 field: PublishValidationField,
787 ): PublishValidationIssue[] {
788 if (!rules.maxImageSize || sizeBytes <= rules.maxImageSize) {
789 return []
790 }
791 return [{
792 code: PublishValidationIssueCode.TooBig,
793 path,
794 params: { field, current: sizeBytes, maximum: rules.maxImageSize, unit: 'bytes' },
795 }]
796 }
797
798 private getPublishImageCacheKey(url: string, targetFormat: PublishMediaAdaptationImageFormat, policy?: PlatformMediaPolicy): string {
799 return [
800 url,
801 targetFormat,
802 policy?.maxImageWidth ?? '',
803 policy?.maxImageHeight ?? '',
804 policy?.imageQuality ?? '',
805 ].join('|')
806 }
807
808 private getImageMimeType(format: PublishMediaAdaptationImageFormat): string {
809 if (format === PublishMediaAdaptationImageFormat.Png) {
810 return 'image/png'
811 }
812 if (format === PublishMediaAdaptationImageFormat.Webp) {
813 return 'image/webp'
814 }
815 return 'image/jpeg'
816 }
817
818 private getProbeMetadata(media: PublishMediaProbe): PublishMediaMetadata {
819 return media.type === PublishMediaType.Video
820 ? this.getVideoMetadata(media.probe)
821 : this.getImageMetadata(media.probe)
822 }
823
824 private getImageMetadata(probe: ImageProbe): PublishMediaMetadata {
825 return {
826 type: PublishMediaType.Image,
827 width: probe.width,
828 height: probe.height,
829 format: probe.format,
830 sizeBytes: probe.sizeBytes,
831 }
832 }
833
834 private getVideoMetadata(probe: VideoProbe): PublishMediaMetadata {
835 return {
836 type: PublishMediaType.Video,
837 width: probe.width,
838 height: probe.height,
839 durationSec: probe.durationSec,
840 codec: probe.codec,
841 format: probe.format,
842 sizeBytes: probe.sizeBytes,
843 }
844 }
845
846 private getPreparedImageMetadata(prepared: PreparedPublishImage): PublishMediaMetadata {
847 return {
848 type: PublishMediaType.Image,
849 width: prepared.width,
850 height: prepared.height,
851 format: prepared.format,
852 sizeBytes: prepared.sizeBytes,
853 }
854 }
855
856 private async getRemoteContentType(url: string): Promise<string | undefined> {
857 try {
858 const response = await this.http.head(url, { timeout: 15000 })
859 return this.getHeaderString(response.headers['content-type'])?.toLowerCase()
860 }
861 catch {
862 return undefined
863 }
864 }
865
866 private async getRemoteContentLength(url: string): Promise<number | undefined> {
867 try {
868 const response = await this.http.head(url, { timeout: 15000 })
869 const contentLength = response.headers['content-length']
870 const value = Array.isArray(contentLength) ? contentLength[0] : contentLength
871 const size = Number(value)
872 return Number.isFinite(size) && size > 0 ? size : undefined
873 }
874 catch {
875 return undefined
876 }
877 }
878
879 private isHttpUrl(url: string): boolean {
880 try {
881 const protocol = new URL(url).protocol
882 return protocol === 'http:' || protocol === 'https:'
883 }
884 catch {
885 return false
886 }
887 }
888
889 private isVideoExtension(extension: string | undefined): boolean {
890 return ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'flv', 'wmv', 'rmvb', '3gp'].includes(extension ?? '')
891 }
892
893 private isImageExtension(extension: string | undefined): boolean {
894 return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff'].includes(extension ?? '')
895 }
896
897 private async downloadBuffer(url: string, input?: MediaHttpInput, maxBytes?: number): Promise<Buffer> {
898 const response = await this.http.get<ArrayBuffer | Buffer>(url, {
899 responseType: 'arraybuffer',
900 maxContentLength: maxBytes ?? Infinity,
901 maxBodyLength: maxBytes ?? Infinity,
902 ...(input ? { channelMedia: input } : {}),
903 })
904 const buffer = Buffer.isBuffer(response.data)
905 ? response.data
906 : Buffer.from(response.data)
907 if (maxBytes && buffer.length > maxBytes) {
908 if (!input) {
909 throw new AppException(ResponseCode.ChannelPlatformMediaProcessingFailed, {
910 reasonCode: 'media_exceeds_max_bytes',
911 maxBytes,
912 sizeBytes: buffer.length,
913 })
914 }
915 throw new ChannelPlatformException({
916 code: ResponseCode.ChannelPlatformMediaProcessingFailed,
917 platform: input.platform,
918 category: PlatformErrorCategory.MediaProcessingFailed,
919 context: {
920 endpoint: input.endpoint,
921 taskId: input.taskId,
922 accountId: input.accountId,
923 platformWorkId: input.platformWorkId,
924 },
925 cause: {
926 type: PlatformErrorCauseType.Platform,
927 platformMessage: 'Media exceeds maximum byte size',
928 raw: {
929 reasonCode: 'media_exceeds_max_bytes',
930 maxBytes,
931 sizeBytes: buffer.length,
932 },
933 },
934 retryable: false,
935 })
936 }
937 return buffer
938 }
939
940 private getUploadSourceFilename(url: string, contentType?: string): string {
941 const filename = basename(this.getUrlPathname(url))
942 if (filename && filename !== '.' && filename !== '/') {
943 return filename
944 }
945 const extension = contentType ? mimeExtension(contentType) : undefined
946 return extension ? `media.${extension}` : 'media'
947 }
948
949 private getMediaType(media: { metadata?: { type?: unknown } }): PublishMediaType | undefined {
950 const type = media.metadata?.type
951 return type === PublishMediaType.Image || type === PublishMediaType.Video ? type : undefined
952 }
953
954 private getHeaderString(value: unknown): string | undefined {
955 if (Array.isArray(value)) {
956 return typeof value[0] === 'string' ? value[0] : undefined
957 }
958 return typeof value === 'string' ? value : undefined
959 }
960
961 private fromAxiosError(error: AxiosError, input: MediaHttpInput): ChannelPlatformException {
962 const response = error.response
963
964 return new ChannelPlatformException({
965 code: ResponseCode.ChannelPlatformMediaProcessingFailed,
966 platform: input.platform,
967 category: response ? PlatformErrorCategory.MediaUnavailable : PlatformErrorCategory.Network,
968 context: {
969 endpoint: input.endpoint,
970 taskId: input.taskId,
971 accountId: input.accountId,
972 platformWorkId: input.platformWorkId,
973 metadata: { url: input.url },
974 },
975 cause: {
976 type: response ? PlatformErrorCauseType.Http : PlatformErrorCauseType.Network,
977 httpStatus: response?.status,
978 platformMessage: error.message,
979 raw: response?.data ?? error.toJSON(),
980 },
981 retryable: !response || response.status === 408 || response.status === 429 || response.status >= 500,
982 })
983 }
984 }
985
985 lines TYPESCRIPT