Skip to content

Commit

Permalink
Reland "CCA: Add minimum working code for time-lapse recording"
Browse files Browse the repository at this point in the history
This is a reland of commit ac3bc1d

Difference from the original CL: Change video file creation from calling
createVideoFile to calling createPrivateTempVideoFile instead.

Original change's description:
> CCA: Add minimum working code for time-lapse recording
>
> Add minimum code required for time-lapse video recording, with auto
> speed adjustment. The implementations are mainly
> 1. Use MediaStreamTrackProcessor to grab frame from video stream.
> 2. Use VideoEncoder to encode a frame into an encoded chunk.
> 3. Use FFMpeg to mux selected frames into a final file.
>
> This change is supposed to be submitted together with the change which
> introduces UI implementation. Optimizations for time and quality
> performance are also expected to be done.
>
> Bug: b:236800499
> Test: Manually
> Change-Id: I424559d648114eb92592937590f47bf5a2c94f8d
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4274266
> Reviewed-by: Wei Lee <wtlee@chromium.org>
> Cr-Commit-Position: refs/heads/main@{#1116786}

Bug: b:236800499
Change-Id: I8e3017e7d5d2d73c1a24c2fc1ddfde10274eb7f6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4334798
Commit-Queue: Kam Kwankajornkiet <kamchonlathorn@chromium.org>
Reviewed-by: Wei Lee <wtlee@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1117927}
  • Loading branch information
Kam Kwankajornkiet authored and Chromium LUCI CQ committed Mar 16, 2023
1 parent eff353c commit 106f967
Show file tree
Hide file tree
Showing 10 changed files with 312 additions and 16 deletions.
155 changes: 143 additions & 12 deletions ash/webui/camera_app_ui/resources/js/device/mode/video.ts
Expand Up @@ -18,6 +18,7 @@ import {Filenamer} from '../../models/file_namer.js';
import * as loadTimeData from '../../models/load_time_data.js';
import {
GifSaver,
TimeLapseSaver,
VideoSaver,
} from '../../models/video_saver.js';
import {ChromeHelper} from '../../mojo/chrome_helper.js';
Expand Down Expand Up @@ -82,28 +83,65 @@ const MAX_GIF_DURATION_MS = 5000;
*/
const GRAB_GIF_FRAME_RATIO = 2;

/**
* Initial speed for time-lapse recording.
*/
const TIME_LAPSE_INITIAL_SPEED = 5;

/**
* Number of maximum frames recorded in a specific speed time-lapse video. If
* the current number of frames exceeds, the speed must be increasd.
*/
const TIME_LAPSE_MAX_FRAMES = 5 * 30;


/**
* Sets avc1 parameter used in video recording.
*/
export function setAvc1Parameters(params: h264.EncoderParameters|null): void {
avc1Parameters = params;
}

/**
* Generate AVC suffix string from given h264 params.
*
* @return Suffix string for AVC codecs.
*/
function getAVCSuffix(param: h264.EncoderParameters) {
const {profile, level} = param;
return '.' + profile.toString(16).padStart(2, '0') +
level.toString(16).padStart(4, '0');
}

/**
* Gets video recording MIME type. Mkv with AVC1 is the only preferred format.
*
* @return Video recording MIME type.
*/
function getVideoMimeType(param: h264.EncoderParameters|null): string {
let suffix = '';
if (param !== null) {
const {profile, level} = param;
suffix = '.' + profile.toString(16).padStart(2, '0') +
level.toString(16).padStart(4, '0');
}
const suffix = param !== null ? getAVCSuffix(param) : '';
return `video/x-matroska;codecs=avc1${suffix},pcm`;
}

/**
* Gets VideoEncoder's config from current h264 params and resolutions.
*
* @return VideoEncoderConfig.
*/
function getVideoEncoderConfig(
param: h264.EncoderParameters, resolution: Resolution): VideoEncoderConfig {
const suffix = getAVCSuffix(param);
return {
codec: `avc1${suffix}`,
width: resolution.width,
height: resolution.height,
bitrate: param.bitrate,
bitrateMode: 'constant',
avc: {format: 'annexb'},
};
}


/**
* The 'beforeunload' listener which will show confirm dialog when trying to
* close window.
Expand All @@ -128,6 +166,13 @@ export interface GifResult {
duration: number;
}

export interface TimeLapseResult {
duration: number;
resolution: Resolution;
speed: number;
timeLapseSaver: TimeLapseSaver;
}

/**
* Provides functions with external dependency used by video mode and handles
* the captured result video.
Expand All @@ -151,13 +196,16 @@ export interface VideoHandler {
onGifCaptureDone(gifResult: GifResult): Promise<void>;

onVideoCaptureDone(videoResult: VideoResult): Promise<void>;

onTimeLapseCaptureDone(timeLapseResult: TimeLapseResult): Promise<void>;
}

// This is used as an enum.
/* eslint-disable @typescript-eslint/naming-convention */
const RecordType = {
NORMAL: state.State.RECORD_TYPE_NORMAL,
GIF: state.State.RECORD_TYPE_GIF,
TIME_LAPSE: state.State.RECORD_TYPE_TIME_LAPSE,
} as const;
/* eslint-enable @typescript-eslint/naming-convention */

Expand Down Expand Up @@ -490,8 +538,9 @@ export class Video extends ModeBase {
this.recordingImageCapture = new CrosImageCapture(this.getVideoTrack());
}

let param: h264.EncoderParameters|null = null;
try {
const param = this.getEncoderParameters();
param = this.getEncoderParameters();
const mimeType = getVideoMimeType(param);
if (!MediaRecorder.isTypeSupported(mimeType)) {
throw new Error(
Expand Down Expand Up @@ -545,6 +594,25 @@ export class Video extends ModeBase {
resolution: this.captureResolution,
duration: this.gifRecordTime.inMilliseconds(),
})];
} else if (this.recordingType === RecordType.TIME_LAPSE) {
this.recordTime.start({resume: false});
let timeLapseSaver: TimeLapseSaver|null = null;
try {
assert(param !== null);
timeLapseSaver = await this.captureTimeLapse(param);
} finally {
// TODO(b/236800499): Handle pause.
// TODO(b/236800499): Handle video too short.
state.set(state.State.RECORDING, false);
this.recordTime.stop({pause: false});
}

return [this.handler.onTimeLapseCaptureDone({
duration: this.recordTime.inMilliseconds(),
resolution: this.captureResolution,
speed: timeLapseSaver.getSpeed(),
timeLapseSaver,
})];
} else {
this.recordTime.start({resume: false});
let videoSaver: VideoSaver|null = null;
Expand Down Expand Up @@ -599,7 +667,8 @@ export class Video extends ModeBase {
if (!state.get(state.State.RECORDING)) {
return;
}
if (this.recordingType === RecordType.GIF) {
if (this.recordingType === RecordType.GIF ||
this.recordingType === RecordType.TIME_LAPSE) {
state.set(state.State.RECORDING, false);
} else {
sound.cancel(dom.get('#sound-rec-start', HTMLAudioElement));
Expand All @@ -616,8 +685,6 @@ export class Video extends ModeBase {
/**
* Starts recording gif animation and waits for stop recording event triggered
* by stop shutter or time out over 5 seconds.
*
* @return Saves recorded video.
*/
private async captureGif(): Promise<GifSaver> {
// TODO(b/191950622): Grab frames from capture stream when multistream
Expand Down Expand Up @@ -668,11 +735,75 @@ export class Video extends ModeBase {
return gifSaver;
}

/**
* Initial time-lapse saver with specified encoder parameters. Then, Starts
* recording time-lapse and waits for stop recording event.
*/
private async captureTimeLapse(param: h264.EncoderParameters):
Promise<TimeLapseSaver> {
const encoderConfig = getVideoEncoderConfig(param, this.captureResolution);
const saver = await TimeLapseSaver.create(
encoderConfig, this.captureResolution, TIME_LAPSE_INITIAL_SPEED);
const video = this.video.video;

// Handles time-lapse speed adjustment.
let speed = TIME_LAPSE_INITIAL_SPEED;
let speedCheckpoint = speed * TIME_LAPSE_MAX_FRAMES;
function updateSpeed() {
speed = speed * 2;
speedCheckpoint = speed * TIME_LAPSE_MAX_FRAMES;
saver.updateSpeed(speed);
}

// Creates a frame reader from track processor.
const track = this.getVideoTrack() as MediaStreamVideoTrack;
const trackProcessor = new MediaStreamTrackProcessor({track});
const reader = trackProcessor.readable.getReader();

state.set(state.State.RECORDING, true);
const frames = await new Promise<number>((resolve, reject) => {
let frameCount = 0;
let writtenFrameCount = 0;
// TODO(b/236800499): Investigate whether we should use async, or use
// reader.read().then() instead.
async function updateFrame(): Promise<void> {
if (!state.get(state.State.RECORDING)) {
resolve(writtenFrameCount);
return;
}
if (frameCount % speed === 0) {
try {
const {done, value: frame} = await reader.read();
if (done) {
resolve(writtenFrameCount);
return;
}
saver.write(frame, frameCount);
writtenFrameCount++;
frame.close();
if (frameCount >= speedCheckpoint) {
updateSpeed();
}
} catch (e) {
reject(e);
}
}
frameCount++;
video.requestVideoFrameCallback(updateFrame);
}
video.requestVideoFrameCallback(updateFrame);
});

if (frames === 0) {
throw new NoFrameError();
}

return saver;
}

/**
* Starts recording and waits for stop recording event triggered by stop
* shutter.
*
* @return Saves recorded video.
*/
private async captureVideo(): Promise<VideoSaver> {
const saver = await this.handler.createVideoSaver();
Expand Down
4 changes: 2 additions & 2 deletions ash/webui/camera_app_ui/resources/js/gallerybutton.ts
Expand Up @@ -13,7 +13,7 @@ import {
FileAccessEntry,
} from './models/file_system_access_entry.js';
import {ResultSaver} from './models/result_saver.js';
import {VideoSaver} from './models/video_saver.js';
import {TimeLapseSaver, VideoSaver} from './models/video_saver.js';
import {ChromeHelper} from './mojo/chrome_helper.js';
import {extractImageFromBlob} from './thumbnailer.js';
import {
Expand Down Expand Up @@ -247,7 +247,7 @@ export class GalleryButton implements ResultSaver {
return VideoSaver.create(videoRotation);
}

async finishSaveVideo(video: VideoSaver): Promise<void> {
async finishSaveVideo(video: TimeLapseSaver|VideoSaver): Promise<void> {
const file = await video.endWrite();
assert(file !== null);

Expand Down
Expand Up @@ -62,3 +62,30 @@ export function createGifArgs({width, height}: Resolution): VideoProcessorArgs {

return {decoderArgs, encoderArgs, outputExtension: 'gif'};
}

/**
* Creates the command line arguments to ffmpeg for time-lapse recording.
*/
export function createTimeLapseArgs({width, height}: Resolution):
VideoProcessorArgs {
// clang-format off
const decoderArgs = [
// input format
'-f', 'h264',
// force input framerate
'-r', '30',
// specify video size
'-s', `${width}x${height}`,
];

// clang-format formats one argument per line, which makes the list harder
// to read with comments.
// clang-format off
const encoderArgs = [
// disable audio and copy the video stream
'-an', '-c:v', 'copy',
];
// clang-format on

return {decoderArgs, encoderArgs, outputExtension: 'mp4'};
}
4 changes: 2 additions & 2 deletions ash/webui/camera_app_ui/resources/js/models/result_saver.ts
Expand Up @@ -4,7 +4,7 @@

import {Metadata} from '../type.js';

import {VideoSaver} from './video_saver.js';
import {TimeLapseSaver, VideoSaver} from './video_saver.js';

/**
* Handles captured result photos and video.
Expand Down Expand Up @@ -41,5 +41,5 @@ export interface ResultSaver {
*
* @param video Contains the video result to be saved.
*/
finishSaveVideo(video: VideoSaver): Promise<void>;
finishSaveVideo(video: TimeLapseSaver|VideoSaver): Promise<void>;
}

0 comments on commit 106f967

Please sign in to comment.