From 28588db94e04eb44a9ca6f11747bf346f594c0a3 Mon Sep 17 00:00:00 2001 From: Nicholas Cook Date: Wed, 12 Jan 2022 13:42:24 -0800 Subject: [PATCH] feat(samples): add samples and tests for adding captions to videos (#143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(samples): add samples and tests for adding captions to videos * 🦉 Updates from OwlBot See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md Co-authored-by: Owl Bot --- .../createJobWithEmbeddedCaptions.js | 144 ++++++++++++++++++ .../createJobWithStandaloneCaptions.js | 140 +++++++++++++++++ media/transcoder/test/transcoder.test.js | 109 ++++++++++++- media/transcoder/testdata/caption.srt | 32 ++++ 4 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 media/transcoder/createJobWithEmbeddedCaptions.js create mode 100644 media/transcoder/createJobWithStandaloneCaptions.js create mode 100644 media/transcoder/testdata/caption.srt diff --git a/media/transcoder/createJobWithEmbeddedCaptions.js b/media/transcoder/createJobWithEmbeddedCaptions.js new file mode 100644 index 0000000000..dc53906d0e --- /dev/null +++ b/media/transcoder/createJobWithEmbeddedCaptions.js @@ -0,0 +1,144 @@ +/** + * Copyright 2022, Google, Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +function main(projectId, location, inputVideoUri, inputCaptionsUri, outputUri) { + // [START transcoder_create_job_with_embedded_captions] + /** + * TODO(developer): Uncomment these variables before running the sample. + */ + // projectId = 'my-project-id'; + // location = 'us-central1'; + // inputVideoUri = 'gs://my-bucket/my-video-file'; + // inputCaptionsUri = 'gs://my-bucket/my-captions-file'; + // outputUri = 'gs://my-bucket/my-output-folder/'; + + // Imports the Transcoder library + const {TranscoderServiceClient} = + require('@google-cloud/video-transcoder').v1; + + // Instantiates a client + const transcoderServiceClient = new TranscoderServiceClient(); + + async function createJobWithEmbeddedCaptions() { + // Construct request + const request = { + parent: transcoderServiceClient.locationPath(projectId, location), + job: { + outputUri: outputUri, + config: { + inputs: [ + { + key: 'input0', + uri: inputVideoUri, + }, + { + key: 'caption_input0', + uri: inputCaptionsUri, + }, + ], + editList: [ + { + key: 'atom0', + inputs: ['input0', 'caption_input0'], + }, + ], + elementaryStreams: [ + { + key: 'video-stream0', + videoStream: { + h264: { + heightPixels: 360, + widthPixels: 640, + bitrateBps: 550000, + frameRate: 60, + }, + }, + }, + { + key: 'audio-stream0', + audioStream: { + codec: 'aac', + bitrateBps: 64000, + }, + }, + { + key: 'cea-stream0', + textStream: { + codec: 'cea608', + mapping: [ + { + atomKey: 'atom0', + inputKey: 'caption_input0', + inputTrack: 0, + }, + ], + }, + }, + ], + muxStreams: [ + { + key: 'sd', + container: 'mp4', + elementaryStreams: ['video-stream0', 'audio-stream0'], + }, + { + key: 'sd-hls', + container: 'ts', + elementaryStreams: ['video-stream0', 'audio-stream0'], + }, + { + key: 'sd-dash', + container: 'fmp4', + elementaryStreams: ['video-stream0'], + }, + { + key: 'audio-dash', + container: 'fmp4', + elementaryStreams: ['audio-stream0'], + }, + ], + manifests: [ + { + fileName: 'manifest.m3u8', + type: 'HLS', + muxStreams: ['sd-hls'], + }, + { + fileName: 'manifest.mpd', + type: 'DASH', + muxStreams: ['sd-dash', 'audio-dash'], + }, + ], + }, + }, + }; + + // Run request + const [response] = await transcoderServiceClient.createJob(request); + console.log(`Job: ${response.name}`); + } + + createJobWithEmbeddedCaptions(); + // [END transcoder_create_job_with_embedded_captions] +} + +// node createJobWithEmbeddedCaptions.js +process.on('unhandledRejection', err => { + console.error(err.message); + process.exitCode = 1; +}); +main(...process.argv.slice(2)); diff --git a/media/transcoder/createJobWithStandaloneCaptions.js b/media/transcoder/createJobWithStandaloneCaptions.js new file mode 100644 index 0000000000..1ffb6bd1cc --- /dev/null +++ b/media/transcoder/createJobWithStandaloneCaptions.js @@ -0,0 +1,140 @@ +/** + * Copyright 2022, Google, Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +function main(projectId, location, inputVideoUri, inputCaptionsUri, outputUri) { + // [START transcoder_create_job_with_standalone_captions] + /** + * TODO(developer): Uncomment these variables before running the sample. + */ + // projectId = 'my-project-id'; + // location = 'us-central1'; + // inputVideoUri = 'gs://my-bucket/my-video-file'; + // inputCaptionsUri = 'gs://my-bucket/my-captions-file'; + // outputUri = 'gs://my-bucket/my-output-folder/'; + + // Imports the Transcoder library + const {TranscoderServiceClient} = + require('@google-cloud/video-transcoder').v1; + + // Instantiates a client + const transcoderServiceClient = new TranscoderServiceClient(); + + async function createJobWithStandaloneCaptions() { + // Construct request + const request = { + parent: transcoderServiceClient.locationPath(projectId, location), + job: { + outputUri: outputUri, + config: { + inputs: [ + { + key: 'input0', + uri: inputVideoUri, + }, + { + key: 'caption_input0', + uri: inputCaptionsUri, + }, + ], + editList: [ + { + key: 'atom0', + inputs: ['input0', 'caption_input0'], + }, + ], + elementaryStreams: [ + { + key: 'video-stream0', + videoStream: { + h264: { + heightPixels: 360, + widthPixels: 640, + bitrateBps: 550000, + frameRate: 60, + }, + }, + }, + { + key: 'audio-stream0', + audioStream: { + codec: 'aac', + bitrateBps: 64000, + }, + }, + { + key: 'vtt-stream0', + textStream: { + codec: 'webvtt', + mapping: [ + { + atomKey: 'atom0', + inputKey: 'caption_input0', + inputTrack: 0, + }, + ], + }, + }, + ], + muxStreams: [ + { + key: 'sd-hls-fmp4', + container: 'fmp4', + elementaryStreams: ['video-stream0'], + }, + { + key: 'audio-hls-fmp4', + container: 'fmp4', + elementaryStreams: ['audio-stream0'], + }, + { + key: 'text-vtt', + container: 'vtt', + elementaryStreams: ['vtt-stream0'], + segmentSettings: { + segmentDuration: { + seconds: 6, + }, + individualSegments: true, + }, + }, + ], + manifests: [ + { + fileName: 'manifest.m3u8', + type: 'HLS', + muxStreams: ['sd-hls-fmp4', 'audio-hls-fmp4', 'text-vtt'], + }, + ], + }, + }, + }; + + // Run request + const [response] = await transcoderServiceClient.createJob(request); + console.log(`Job: ${response.name}`); + } + + createJobWithStandaloneCaptions(); + // [END transcoder_create_job_with_standalone_captions] +} + +// node createJobWithStandaloneCaptions.js +process.on('unhandledRejection', err => { + console.error(err.message); + process.exitCode = 1; +}); +main(...process.argv.slice(2)); diff --git a/media/transcoder/test/transcoder.test.js b/media/transcoder/test/transcoder.test.js index 4292a30a7d..f8399b61b0 100644 --- a/media/transcoder/test/transcoder.test.js +++ b/media/transcoder/test/transcoder.test.js @@ -37,11 +37,12 @@ const testFileName = 'ChromeCast.mp4'; const testOverlayFileName = 'overlay.jpg'; const testConcat1FileName = 'ForBiggerEscapes.mp4'; const testConcat2FileName = 'ForBiggerJoyrides.mp4'; - +const testCaptionFileName = 'caption.srt'; const inputUri = `gs://${bucketName}/${testFileName}`; const overlayUri = `gs://${bucketName}/${testOverlayFileName}`; const concat1Uri = `gs://${bucketName}/${testConcat1FileName}`; const concat2Uri = `gs://${bucketName}/${testConcat2FileName}`; +const captionsUri = `gs://${bucketName}/${testCaptionFileName}`; const outputUriForPreset = `gs://${bucketName}/test-output-preset/`; const outputUriForTemplate = `gs://${bucketName}/test-output-template/`; const outputUriForAdHoc = `gs://${bucketName}/test-output-adhoc/`; @@ -58,12 +59,15 @@ const outputUriForPeriodicImagesSpritesheet = `gs://${bucketName}/${outputDirFor const smallSpriteSheetFileName = 'small-sprite-sheet0000000000.jpeg'; const largeSpriteSheetFileName = 'large-sprite-sheet0000000000.jpeg'; const outputUriForConcatenated = `gs://${bucketName}/test-output-concat/`; +const outputUriForEmbeddedCaptions = `gs://${bucketName}/test-output-embedded-captions/`; +const outputUriForStandaloneCaptions = `gs://${bucketName}/test-output-standalone-captions/`; const cwd = path.join(__dirname, '..'); const videoFile = `testdata/${testFileName}`; const overlayFile = `testdata/${testOverlayFileName}`; const concat1File = `testdata/${testConcat1FileName}`; const concat2File = `testdata/${testConcat2FileName}`; +const captionFile = `testdata/${testCaptionFileName}`; const delay = async (test, addMs) => { const retries = test.currentRetry(); @@ -107,6 +111,7 @@ before(async () => { await storage.bucket(bucketName).upload(overlayFile); await storage.bucket(bucketName).upload(concat1File); await storage.bucket(bucketName).upload(concat2File); + await storage.bucket(bucketName).upload(captionFile); }); after(async () => { @@ -651,3 +656,105 @@ describe('Job with concatenated inputs functions', () => { assert.ok(false); }); }); + +describe('Job with embedded captions', () => { + before(function () { + const output = execSync( + `node createJobWithEmbeddedCaptions.js ${projectId} ${location} ${inputUri} ${captionsUri} ${outputUriForEmbeddedCaptions}`, + {cwd} + ); + assert.ok( + output.includes(`projects/${projectNumber}/locations/${location}/jobs/`) + ); + this.embeddedCaptionsJobId = output.toString().split('/').pop(); + }); + + after(function () { + const output = execSync( + `node deleteJob.js ${projectId} ${location} ${this.embeddedCaptionsJobId}`, + {cwd} + ); + assert.ok(output.includes('Deleted job')); + }); + + it('should get a job', function () { + const output = execSync( + `node getJob.js ${projectId} ${location} ${this.embeddedCaptionsJobId}`, + {cwd} + ); + const jobName = `projects/${projectNumber}/locations/${location}/jobs/${this.embeddedCaptionsJobId}`; + assert.ok(output.includes(jobName)); + }); + + it('should check that the job succeeded', async function () { + this.retries(5); + await delay(this.test, 30000); + + let getAttempts = 0; + while (getAttempts < 5) { + const ms = Math.pow(2, getAttempts + 1) * 10000 + Math.random() * 1000; + await wait(ms); + const output = execSync( + `node getJobState.js ${projectId} ${location} ${this.embeddedCaptionsJobId}`, + {cwd} + ); + if (output.includes('Job state: SUCCEEDED')) { + assert.ok(true); + return; + } + getAttempts++; + } + assert.ok(false); + }); +}); + +describe('Job with standalone captions', () => { + before(function () { + const output = execSync( + `node createJobWithStandaloneCaptions.js ${projectId} ${location} ${inputUri} ${captionsUri} ${outputUriForStandaloneCaptions}`, + {cwd} + ); + assert.ok( + output.includes(`projects/${projectNumber}/locations/${location}/jobs/`) + ); + this.standaloneCaptionsJobId = output.toString().split('/').pop(); + }); + + after(function () { + const output = execSync( + `node deleteJob.js ${projectId} ${location} ${this.standaloneCaptionsJobId}`, + {cwd} + ); + assert.ok(output.includes('Deleted job')); + }); + + it('should get a job', function () { + const output = execSync( + `node getJob.js ${projectId} ${location} ${this.standaloneCaptionsJobId}`, + {cwd} + ); + const jobName = `projects/${projectNumber}/locations/${location}/jobs/${this.standaloneCaptionsJobId}`; + assert.ok(output.includes(jobName)); + }); + + it('should check that the job succeeded', async function () { + this.retries(5); + await delay(this.test, 30000); + + let getAttempts = 0; + while (getAttempts < 5) { + const ms = Math.pow(2, getAttempts + 1) * 10000 + Math.random() * 1000; + await wait(ms); + const output = execSync( + `node getJobState.js ${projectId} ${location} ${this.standaloneCaptionsJobId}`, + {cwd} + ); + if (output.includes('Job state: SUCCEEDED')) { + assert.ok(true); + return; + } + getAttempts++; + } + assert.ok(false); + }); +}); diff --git a/media/transcoder/testdata/caption.srt b/media/transcoder/testdata/caption.srt new file mode 100644 index 0000000000..fcd2b64a09 --- /dev/null +++ b/media/transcoder/testdata/caption.srt @@ -0,0 +1,32 @@ +1 +00:00:00,000 --> 00:00:06,500 +[MUSIC PLAYING] + +2 +00:00:06,500 --> 00:00:08,500 +LITTLE GIRL: Tada. + +3 +00:00:09,200 --> 00:00:10,500 +FATHER: Woah! + +4 +00:00:11,500 --> 00:00:13,000 +MOVIE FAN: Showtime. + +5 +00:00:14,000 --> 00:00:17,000 +MOVIE FAN: Arghhh - did you see this, +did you see this. + +6 +00:00:19,000 --> 00:00:20,000 +ALL: Ohh! + +7 +00:00:20,400 --> 00:00:22,500 +BUSTER: I'm a MONSTER! + +8 +00:00:24,000 --> 00:00:28,000 +[MUSIC CONTINUES] \ No newline at end of file