diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e485930..539b5a13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,13 @@ jobs: id-token: 'write' steps: - - name: Setup ffmpeg - uses: FedericoCarboni/setup-ffmpeg@v2 + - name: Setup ffmpeg built with libsvtav1 + # https://www.reddit.com/r/ffmpeg/comments/tb5frl/comment/i0glb7d/ + run: | + wget https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n6.1-latest-linux64-gpl-6.1.tar.xz && + tar Jxvf ffmpeg-n6.1-latest-linux64-gpl-6.1.tar.xz && + sudo cp -r ffmpeg-n6.1-latest-linux64-gpl-6.1/bin/* /usr/local/bin/ + - run: ffmpeg -encoders - uses: actions/checkout@v3 with: submodules: true diff --git a/package-lock.json b/package-lock.json index d8141dd0..17e6a020 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@cofacts/media-manager": "^0.3.0", + "@cofacts/media-manager": "^0.3.1", "@elastic/elasticsearch": "^6.8.6", "@google-cloud/bigquery": "^6.2.0", "@google-cloud/vision": "^3.1.4", @@ -2136,9 +2136,9 @@ "integrity": "sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==" }, "node_modules/@cofacts/media-manager": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@cofacts/media-manager/-/media-manager-0.3.0.tgz", - "integrity": "sha512-SAY5bEElxjNaBKA+c7EIrNB65r1PcoUS1PQkLBppkU3bxePJiHYK56ztQlBcVbv/WsWuJkjzHVIhaleBj26Jiw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@cofacts/media-manager/-/media-manager-0.3.1.tgz", + "integrity": "sha512-q34QXZy6ohGqbC2D+UAm5FLT2zyoEKbutC7XJ8AmiUnJcQm4eVddobWn0MO3ujv3ijM0kHwop3r/YVea/G1ZLQ==", "dependencies": { "@google-cloud/storage": "^5.19.4", "content-type": "^1.0.4", @@ -17847,9 +17847,9 @@ "integrity": "sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==" }, "@cofacts/media-manager": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@cofacts/media-manager/-/media-manager-0.3.0.tgz", - "integrity": "sha512-SAY5bEElxjNaBKA+c7EIrNB65r1PcoUS1PQkLBppkU3bxePJiHYK56ztQlBcVbv/WsWuJkjzHVIhaleBj26Jiw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@cofacts/media-manager/-/media-manager-0.3.1.tgz", + "integrity": "sha512-q34QXZy6ohGqbC2D+UAm5FLT2zyoEKbutC7XJ8AmiUnJcQm4eVddobWn0MO3ujv3ijM0kHwop3r/YVea/G1ZLQ==", "requires": { "@google-cloud/storage": "^5.19.4", "content-type": "^1.0.4", diff --git a/package.json b/package.json index 83d5ab37..ea9c15af 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "rumors-db:install": "cd test/rumors-db && npm i" }, "dependencies": { - "@cofacts/media-manager": "^0.3.0", + "@cofacts/media-manager": "^0.3.1", "@elastic/elasticsearch": "^6.8.6", "@google-cloud/bigquery": "^6.2.0", "@google-cloud/vision": "^3.1.4", diff --git a/src/graphql/__fixtures__/media-integration/video.mp4 b/src/graphql/__fixtures__/media-integration/video.mp4 new file mode 100644 index 00000000..ba6055f3 Binary files /dev/null and b/src/graphql/__fixtures__/media-integration/video.mp4 differ diff --git a/src/graphql/__tests__/media-integration.js b/src/graphql/__tests__/media-integration.js index 25b9f122..a4bec3bf 100644 --- a/src/graphql/__tests__/media-integration.js +++ b/src/graphql/__tests__/media-integration.js @@ -7,8 +7,10 @@ import fetch from 'node-fetch'; import gql from 'util/GraphQL'; import client from 'util/client'; import delayForMs from 'util/delayForMs'; +import { uploadMedia } from 'graphql/mutations/CreateMediaArticle'; import { getReplyRequestId } from 'graphql/mutations/CreateOrUpdateReplyRequest'; import replaceMedia from 'scripts/replaceMedia'; +import { VIDEO_PREVIEW } from 'util/mediaManager'; if (process.env.GCS_CREDENTIALS && process.env.GCS_BUCKET_NAME) { // File server serving test input file in __fixtures__/media-integration @@ -165,6 +167,38 @@ if (process.env.GCS_CREDENTIALS && process.env.GCS_BUCKET_NAME) { 15000 ); + it( + 'uploads video and can get media entry', + async () => { + const mediaEntry = await uploadMedia({ + // Video credit: Hitokage Production https://hitokageproduction.com/article/83 + // + // Re-encoded with the following ffmpeg options + // `-c:a aac -c:v libx264 -vf "scale=-1:720,fps=30,format=yuv420p" -crf 30 -movflags +faststart` + mediaUrl: `${serverUrl}/video.mp4`, + articleType: 'VIDEO', + }); + + expect(mediaEntry.variants).toMatchInlineSnapshot(` + Array [ + "original", + "av1-240p5s", + "av1-0.75x30s", + ] + `); + + let previewCacheControlHeader; + while (!previewCacheControlHeader) { + await delayForMs(1000); // Wait for upload to finish + const file = mediaEntry.getFile(VIDEO_PREVIEW); + if (!(await file.exists())[0]) continue; + previewCacheControlHeader = (await file.getMetadata())[0].cacheControl; + } + console.log('previewCacheControlHeader', previewCacheControlHeader); + }, + 15000 + ); + it( 'can replace media for article', async () => { diff --git a/src/graphql/models/Article.js b/src/graphql/models/Article.js index 8164a7f7..e7289d14 100644 --- a/src/graphql/models/Article.js +++ b/src/graphql/models/Article.js @@ -32,7 +32,9 @@ import ArticleReference from 'graphql/models/ArticleReference'; import User, { userFieldResolver } from 'graphql/models/User'; import mediaManager, { IMAGE_PREVIEW, + VIDEO_PREVIEW, IMAGE_THUMBNAIL, + VIDEO_THUMBNAIL, } from 'util/mediaManager'; import ArticleReplyStatusEnum from './ArticleReplyStatusEnum'; import ArticleReply from './ArticleReply'; @@ -528,17 +530,18 @@ const Article = new GraphQLObjectType({ if (!attachmentHash) return null; let variant = 'original'; - switch (variantArg) { - case 'PREVIEW': - if (articleType === 'IMAGE') { - variant = IMAGE_PREVIEW; - } + switch (`${articleType}_${variantArg}`) { + case 'IMAGE_PREVIEW': + variant = IMAGE_PREVIEW; break; - - case 'THUMBNAIL': - if (articleType === 'IMAGE') { - variant = IMAGE_THUMBNAIL; - } + case 'VIDEO_PREVIEW': + variant = VIDEO_PREVIEW; + break; + case 'IMAGE_THUMBNAIL': + variant = IMAGE_THUMBNAIL; + break; + case 'VIDEO_THUMBNAIL': + variant = VIDEO_THUMBNAIL; break; } diff --git a/src/graphql/mutations/CreateMediaArticle.js b/src/graphql/mutations/CreateMediaArticle.js index f3454356..15c0a827 100644 --- a/src/graphql/mutations/CreateMediaArticle.js +++ b/src/graphql/mutations/CreateMediaArticle.js @@ -1,9 +1,13 @@ +import { PassThrough, Duplex } from 'stream'; import { GraphQLString, GraphQLNonNull } from 'graphql'; import sharp from 'sharp'; +import ffmpeg from 'fluent-ffmpeg'; import { MediaType, variants } from '@cofacts/media-manager'; import mediaManager, { IMAGE_PREVIEW, IMAGE_THUMBNAIL, + VIDEO_PREVIEW, + VIDEO_THUMBNAIL, } from 'util/mediaManager'; import { assertUser, getContentDefaultStatus } from 'util/user'; import client from 'util/client'; @@ -34,6 +38,23 @@ const VALID_ARTICLE_TYPE_TO_MEDIA_TYPE = { AUDIO: MediaType.audio, }; +/** + * @param {function} setupFn - A function to setup the ffmpeg command + * @returns A transform stream + */ +function getFFMpegTransform(setupFn = cmd => cmd) { + const input = new PassThrough(); + const output = setupFn( + ffmpeg(input) + // .on('stderr', function(stderrLine) { + // console.log('[ffmpeg]', stderrLine); + // }) + .inputFormat('mp4') + ).pipe(); + + return Duplex.from({ readable: output, writable: input }); +} + /** * Upload media of specified article type from the given mediaUrl * @@ -77,6 +98,48 @@ export async function uploadMedia({ mediaUrl, articleType }) { }, ]; + case MediaType.video: + return [ + variants.original(contentType), + { + name: VIDEO_THUMBNAIL, + contentType: 'video/mp4', + transform: getFFMpegTransform(cmd => + cmd + .size('?x240') + .videoCodec('libsvtav1') + .videoFilters('setpts=0.5*PTS') // 2x speed + .noAudio() + .duration(5) + .outputOptions([ + // https://github.com/fluent-ffmpeg/node-fluent-ffmpeg/issues/346#issuecomment-67299526 + '-movflags dash', + // Smaller file size + '-crf 40', + ]) + .format('mp4') + ), + }, + { + name: VIDEO_PREVIEW, + contentType: 'video/mp4', + transform: getFFMpegTransform(cmd => + cmd + .videoCodec('libsvtav1') + .size('75%') + .audioCodec('copy') + .duration(30) + .outputOptions([ + // https://github.com/fluent-ffmpeg/node-fluent-ffmpeg/issues/346#issuecomment-67299526 + '-movflags dash', + // Slightly better quality + '-crf 38', + ]) + .format('mp4') + ), + }, + ]; + default: return variants.defaultGetVariantSettings(options); } diff --git a/src/util/mediaManager.js b/src/util/mediaManager.js index 3de7f3d9..2d36bc54 100644 --- a/src/util/mediaManager.js +++ b/src/util/mediaManager.js @@ -3,6 +3,9 @@ import { MediaManager } from '@cofacts/media-manager'; export const IMAGE_PREVIEW = 'webp600w'; export const IMAGE_THUMBNAIL = 'jpg240h'; +export const VIDEO_PREVIEW = 'av1-0.75x30s'; +export const VIDEO_THUMBNAIL = 'av1-240p5s'; + const mediaManager = new MediaManager({ bucketName: process.env.GCS_BUCKET_NAME, credentialsJSON: process.env.GCS_CREDENTIALS,