Skip to content
Merged
11 changes: 8 additions & 3 deletions lib/collab/convex_strategy_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,17 @@ class ConvexStrategyRepository {
.map(_pageSnapshot);
}

/// [shareToken]: see [fetchShell].
Future<RemoteFullStrategySnapshot> fetchFullSnapshot(
String strategyPublicId,
) async {
String strategyPublicId, {
String? shareToken,
}) async {
return _fullSnapshot(
await _api.strategy
.getFullSnapshot(strategyPublicId: strategyPublicId)
.getFullSnapshot(
strategyPublicId: strategyPublicId,
shareToken: _optional(shareToken),
)
.fetch(),
);
}
Expand Down
1 change: 0 additions & 1 deletion lib/config/platform_policy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ class PlatformPolicy {
desktopOnly: {
PlatformFeature.exportFiles,
PlatformFeature.importFiles,
PlatformFeature.videoExport,
PlatformFeature.fileDrop,
},
// Empty for now; the Beta dialog hides its "Coming" list when it is.
Expand Down
2 changes: 2 additions & 0 deletions lib/services/video_export/browser_video_sink.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export 'browser_video_sink_stub.dart'
if (dart.library.js_interop) 'browser_video_sink_web.dart';
14 changes: 14 additions & 0 deletions lib/services/video_export/browser_video_sink_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import 'package:icarus/services/video_export/video_export_quality.dart';
import 'package:icarus/services/video_export/video_frame_sink.dart';

/// Whether this browser can encode a [totalSeconds] video at [quality].
/// Never, off the web.
Future<bool> browserCanEncodeVideo(
VideoExportQuality quality, {
required double totalSeconds,
}) async =>
false;

/// Encodes in the browser. Off the web there is no browser to encode in.
VideoFrameSink createBrowserVideoSink(VideoExportQuality quality) =>
throw UnsupportedError('Browser video encoding exists only on the web.');
95 changes: 95 additions & 0 deletions lib/services/video_export/browser_video_sink_web.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import 'dart:typed_data';

import 'package:icarus/const/coordinate_system.dart';
import 'package:icarus/services/video_export/video_export_quality.dart';
import 'package:icarus/services/video_export/video_frame_sink.dart';
import 'package:icarus/services/video_export/web_codecs_video_encoder.dart';

/// Whether this browser can encode a [totalSeconds] video at [quality],
/// checked with the exact size and bitrate the export will use, before
/// anything renders.
Future<bool> browserCanEncodeVideo(
VideoExportQuality quality, {
required double totalSeconds,
}) {
final output = _outputFor(quality, totalSeconds);
return WebCodecsMp4Encoder.isSupported(
width: output.width,
height: output.height,
fps: quality.fps,
bitrate: output.bitrate,
);
}

/// The size and bitrate [quality] encodes a [totalSeconds] video at: the
/// desktop presets' (Potato drops to 720p at its bitrate floor; Max has no
/// fixed bitrate).
({int width, int height, int? bitrate}) _outputFor(
VideoExportQuality quality,
double totalSeconds,
) {
const frame = CoordinateSystem.screenShotSize;
final height = quality.outputHeightForDuration(totalSeconds);
return (
width: (frame.width * height / frame.height).round(),
height: height,
bitrate: quality.sizePolicy?.initialVideoBitrate(totalSeconds),
);
}

/// Encodes in the browser with WebCodecs; [VideoFrameSink.finish] returns
/// the finished .mp4 for the caller to download.
VideoFrameSink createBrowserVideoSink(VideoExportQuality quality) =>
_WebCodecsVideoSink(quality);

/// Sizes and bitrates follow the desktop presets: Potato and Social hold the
/// bitrate their size target allows for the video's length (and Potato
/// drops to 720p at the bitrate floor); Max encodes at a high variable
/// bitrate. The browser encodes as frames arrive, so there is no separate
/// encode pass and no second attempt at a smaller size.
class _WebCodecsVideoSink implements VideoFrameSink {
_WebCodecsVideoSink(this.quality);

final VideoExportQuality quality;
final WebCodecsMp4Encoder _encoder = WebCodecsMp4Encoder();

@override
Future<void> start({
required int width,
required int height,
required int totalFrames,
required double totalSeconds,
}) {
final output = _outputFor(quality, totalSeconds);
return _encoder.start(
inputWidth: width,
inputHeight: height,
outputWidth: output.width,
outputHeight: output.height,
fps: quality.fps,
bitrate: output.bitrate,
);
}

@override
Future<void> addFrame(Uint8List rgba, double durationSeconds) =>
_encoder.addFrame(
rgba,
duration: Duration(microseconds: (durationSeconds * 1e6).round()),
);

@override
Future<Uint8List?> finish({
void Function(double fraction)? onProgress,
}) async {
final video = await _encoder.finish();
onProgress?.call(1);
return video;
}

@override
void cancel() => _encoder.cancel();

@override
Future<void> close() async => _encoder.cancel();
}
11 changes: 2 additions & 9 deletions lib/services/video_export/ffmpeg_video_encoder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,11 @@ import 'dart:convert';
import 'dart:developer';
import 'dart:io';

import 'package:icarus/services/video_export/video_export_errors.dart';
import 'package:icarus/services/video_export/video_export_quality.dart';
import 'package:path/path.dart' as p;

class VideoExportCancelled implements Exception {}

class VideoExportException implements Exception {
VideoExportException(this.message);
final String message;

@override
String toString() => 'VideoExportException: $message';
}
export 'package:icarus/services/video_export/video_export_errors.dart';

/// Windows' save dialog does not guarantee that the selected extension is
/// appended, even when FilePicker is restricted to MP4 files.
Expand Down
116 changes: 116 additions & 0 deletions lib/services/video_export/ffmpeg_video_sink.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';

import 'package:icarus/services/video_export/ffmpeg_png_sequence_writer.dart';
import 'package:icarus/services/video_export/ffmpeg_video_encoder.dart';
import 'package:icarus/services/video_export/video_export_quality.dart';
import 'package:icarus/services/video_export/video_frame_sink.dart';
import 'package:path/path.dart' as p;

/// Desktop: frames go to one ffmpeg process as a lossless PNG sequence,
/// listed with their durations in an ffconcat playlist, and a second ffmpeg
/// run encodes the playlist into [outputPath].
class FfmpegVideoSink implements VideoFrameSink {
FfmpegVideoSink({
required this.binary,
required this.outputPath,
required this.quality,
});

final String binary;
final String outputPath;
final VideoExportQuality quality;

final FfmpegVideoEncoder _encoder = FfmpegVideoEncoder();
final List<String> _concatLines = ['ffconcat version 1.0'];
Directory? _tempDir;
FfmpegPngSequenceWriter? _frameWriter;
double _totalSeconds = 0;
int _frameIndex = 0;
String? _lastFrameFile;

@override
Future<void> start({
required int width,
required int height,
required int totalFrames,
required double totalSeconds,
}) async {
_totalSeconds = totalSeconds;
final tempDir =
await Directory.systemTemp.createTemp('icarus_video_export_');
_tempDir = tempDir;
final frameWriter = FfmpegPngSequenceWriter();
_frameWriter = frameWriter;
await frameWriter.start(
binary: binary,
workingDirectory: tempDir.path,
width: width,
height: height,
fps: quality.fps,
totalFrames: totalFrames,
);
}

@override
Future<void> addFrame(Uint8List rgba, double durationSeconds) async {
final fileName = 'frame_${_frameIndex.toString().padLeft(5, '0')}.png';
await _frameWriter!.writeFrame(rgba);
_frameIndex++;
_concatLines
..add("file '$fileName'")
..add('duration ${durationSeconds.toStringAsFixed(6)}');
_lastFrameFile = fileName;
}

@override
Future<Uint8List?> finish(
{void Function(double fraction)? onProgress}) async {
await _frameWriter!.finish();
_frameWriter = null;

// The concat demuxer ignores the duration of the final entry unless the
// last file is repeated.
if (_lastFrameFile != null) {
_concatLines.add("file '$_lastFrameFile'");
}
final tempDir = _tempDir!;
await File(p.join(tempDir.path, 'frames.ffconcat'))
.writeAsString(_concatLines.join('\n'));

await _encoder.encode(
binary: binary,
workingDirectory: tempDir.path,
concatListFileName: 'frames.ffconcat',
outputPath: outputPath,
totalSeconds: _totalSeconds,
quality: quality,
onProgress: onProgress,
);
return null;
}

@override
void cancel() {
// close() awaits the writer's termination; here we only need to
// interrupt the processes.
unawaited(_frameWriter?.cancel());
_encoder.cancel();
}

@override
Future<void> close() async {
// Wait for ffmpeg to exit before deleting the directory it writes into;
// on Windows the delete races a still-exiting process otherwise.
await _frameWriter?.cancel();
_frameWriter = null;
try {
await _tempDir?.delete(recursive: true);
_tempDir = null;
} on Object {
// Leaving orphaned temp frames behind is preferable to masking the
// original export result.
}
}
}
Loading
Loading