blob: 4c45cc3d8f664054be80e317254229674d2d85a8 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:html' as html;
import 'dart:typed_data';
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
import 'package:web_engine_tester/golden_tester.dart';
import '../matchers.dart';
import 'common.dart';
import 'test_data.dart';
void main() {
internalBootstrapBrowserTest(() => testMain);
}
void testMain() {
group('CanvasKit Images', () {
setUpCanvasKitTest();
tearDown(() {
debugRestoreHttpRequestFactory();
});
_testCkAnimatedImage();
_testForImageCodecs(useBrowserImageDecoder: false);
if (browserSupportsImageDecoder) {
_testForImageCodecs(useBrowserImageDecoder: true);
_testCkBrowserImageDecoder();
}
test('isAvif', () {
expect(isAvif(Uint8List.fromList(<int>[])), isFalse);
expect(isAvif(Uint8List.fromList(<int>[1, 2, 3])), isFalse);
expect(
isAvif(Uint8List.fromList(<int>[
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70,
0x61, 0x76, 0x69, 0x66, 0x00, 0x00, 0x00, 0x00,
])),
isTrue,
);
expect(
isAvif(Uint8List.fromList(<int>[
0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70,
0x61, 0x76, 0x69, 0x66, 0x00, 0x00, 0x00, 0x00,
])),
isTrue,
);
});
// TODO(hterkelsen): https://github.com/flutter/flutter/issues/60040
}, skip: isIosSafari);
}
void _testForImageCodecs({required bool useBrowserImageDecoder}) {
final String mode = useBrowserImageDecoder ? 'webcodecs' : 'wasm';
group('($mode})', () {
setUp(() {
browserSupportsImageDecoder = useBrowserImageDecoder;
});
tearDown(() {
debugResetBrowserSupportsImageDecoder();
});
test('CkAnimatedImage can be explicitly disposed of', () {
final CkAnimatedImage image = CkAnimatedImage.decodeFromBytes(kTransparentImage, 'test');
expect(image.debugDisposed, isFalse);
image.dispose();
expect(image.debugDisposed, isTrue);
// Disallow usage after disposal
expect(() => image.frameCount, throwsAssertionError);
expect(() => image.repetitionCount, throwsAssertionError);
expect(() => image.getNextFrame(), throwsAssertionError);
// Disallow double-dispose.
expect(() => image.dispose(), throwsAssertionError);
testCollector.collectNow();
});
test('CkAnimatedImage remembers last animation position after resurrection', () async {
browserSupportsFinalizationRegistry = false;
final CkAnimatedImage image = CkAnimatedImage.decodeFromBytes(kAnimatedGif, 'test');
expect(image.frameCount, 3);
expect(image.repetitionCount, -1);
final ui.FrameInfo frame1 = await image.getNextFrame();
await expectFrameData(frame1, <int>[255, 0, 0, 255]);
final ui.FrameInfo frame2 = await image.getNextFrame();
await expectFrameData(frame2, <int>[0, 255, 0, 255]);
// Pretend that the image is temporarily deleted.
image.delete();
image.didDelete();
// Check that we got the 3rd frame after resurrection.
final ui.FrameInfo frame3 = await image.getNextFrame();
await expectFrameData(frame3, <int>[0, 0, 255, 255]);
testCollector.collectNow();
});
test('CkImage toString', () {
final SkImage skImage =
canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage)!
.makeImageAtCurrentFrame();
final CkImage image = CkImage(skImage);
expect(image.toString(), '[1×1]');
image.dispose();
testCollector.collectNow();
});
test('CkImage can be explicitly disposed of', () {
final SkImage skImage =
canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage)!
.makeImageAtCurrentFrame();
final CkImage image = CkImage(skImage);
expect(image.debugDisposed, isFalse);
expect(image.box.isDeletedPermanently, isFalse);
image.dispose();
expect(image.debugDisposed, isTrue);
expect(image.box.isDeletedPermanently, isTrue);
// Disallow double-dispose.
expect(() => image.dispose(), throwsAssertionError);
testCollector.collectNow();
});
test('CkImage can be explicitly disposed of when cloned', () async {
final SkImage skImage =
canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage)!
.makeImageAtCurrentFrame();
final CkImage image = CkImage(skImage);
final SkiaObjectBox<CkImage, SkImage> box = image.box;
expect(box.refCount, 1);
expect(box.debugGetStackTraces().length, 1);
final CkImage clone = image.clone();
expect(box.refCount, 2);
expect(box.debugGetStackTraces().length, 2);
expect(image.isCloneOf(clone), isTrue);
expect(box.isDeletedPermanently, isFalse);
testCollector.collectNow();
expect(skImage.isDeleted(), isFalse);
image.dispose();
expect(box.refCount, 1);
expect(box.isDeletedPermanently, isFalse);
testCollector.collectNow();
expect(skImage.isDeleted(), isFalse);
clone.dispose();
expect(box.refCount, 0);
expect(box.isDeletedPermanently, isTrue);
testCollector.collectNow();
expect(skImage.isDeleted(), isTrue);
expect(box.debugGetStackTraces().length, 0);
testCollector.collectNow();
});
test('CkImage toByteData', () async {
final SkImage skImage =
canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage)!
.makeImageAtCurrentFrame();
final CkImage image = CkImage(skImage);
expect((await image.toByteData()).lengthInBytes, greaterThan(0));
expect((await image.toByteData(format: ui.ImageByteFormat.png)).lengthInBytes, greaterThan(0));
testCollector.collectNow();
});
// Regression test for https://github.com/flutter/flutter/issues/72469
test('CkImage can be resurrected', () {
browserSupportsFinalizationRegistry = false;
final SkImage skImage =
canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage)!
.makeImageAtCurrentFrame();
final CkImage image = CkImage(skImage);
expect(image.box.rawSkiaObject, isNotNull);
// Pretend that the image is temporarily deleted.
image.box.delete();
image.box.didDelete();
expect(image.box.rawSkiaObject, isNull);
// Attempting to access the skia object here would previously throw
// "Stack Overflow" in Safari.
expect(image.box.skiaObject, isNotNull);
testCollector.collectNow();
});
test('skiaInstantiateWebImageCodec loads an image from the network',
() async {
httpRequestFactory = () {
return TestHttpRequest()
..status = 200
..onLoad = Stream<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[
html.ProgressEvent('test progress event'),
])
..response = kTransparentImage.buffer;
};
final ui.Codec codec = await skiaInstantiateWebImageCodec('http://image-server.com/picture.jpg', null);
expect(codec.frameCount, 1);
final ui.Image image = (await codec.getNextFrame()).image;
expect(image.height, 1);
expect(image.width, 1);
testCollector.collectNow();
});
test('instantiateImageCodec respects target image size',
() async {
const List<List<int>> targetSizes = <List<int>>[
<int>[1, 1],
<int>[1, 2],
<int>[2, 3],
<int>[3, 4],
<int>[4, 4],
<int>[10, 20],
];
for (final List<int> targetSize in targetSizes) {
final int targetWidth = targetSize[0];
final int targetHeight = targetSize[1];
final ui.Codec codec = await ui.instantiateImageCodec(
k4x4PngImage,
targetWidth: targetWidth,
targetHeight: targetHeight,
);
final ui.Image image = (await codec.getNextFrame()).image;
// TODO(yjbanov): https://github.com/flutter/flutter/issues/34075
// expect(image.width, targetWidth);
// expect(image.height, targetHeight);
image.dispose();
codec.dispose();
}
testCollector.collectNow();
});
test('skiaInstantiateWebImageCodec throws exception on request error',
() async {
httpRequestFactory = () {
return TestHttpRequest()
..onError = Stream<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[
html.ProgressEvent('test error'),
]);
};
try {
await skiaInstantiateWebImageCodec('url-does-not-matter', null);
fail('Expected to throw');
} on ImageCodecException catch (exception) {
expect(
exception.toString(),
'ImageCodecException: Failed to load network image.\n'
'Image URL: url-does-not-matter\n'
'Trying to load an image from another domain? Find answers at:\n'
'https://flutter.dev/docs/development/platform-integration/web-images',
);
}
testCollector.collectNow();
});
test('skiaInstantiateWebImageCodec throws exception on HTTP error',
() async {
try {
await skiaInstantiateWebImageCodec('/does-not-exist.jpg', null);
fail('Expected to throw');
} on ImageCodecException catch (exception) {
expect(
exception.toString(),
'ImageCodecException: Failed to load network image.\n'
'Image URL: /does-not-exist.jpg\n'
'Server response code: 404',
);
}
testCollector.collectNow();
});
test('skiaInstantiateWebImageCodec includes URL in the error for malformed image',
() async {
httpRequestFactory = () {
return TestHttpRequest()
..status = 200
..onLoad = Stream<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[
html.ProgressEvent('test progress event'),
])
..response = Uint8List(0).buffer;
};
try {
await skiaInstantiateWebImageCodec('http://image-server.com/picture.jpg', null);
fail('Expected to throw');
} on ImageCodecException catch (exception) {
if (!browserSupportsImageDecoder) {
expect(
exception.toString(),
'ImageCodecException: Failed to decode image data.\n'
'Image source: http://image-server.com/picture.jpg',
);
} else {
expect(
exception.toString(),
'ImageCodecException: Failed to detect image file format using the file header.\n'
'File header was empty.\n'
'Image source: http://image-server.com/picture.jpg',
);
}
}
testCollector.collectNow();
});
test('Reports error when failing to decode empty image data', () async {
try {
await ui.instantiateImageCodec(Uint8List(0));
fail('Expected to throw');
} on ImageCodecException catch (exception) {
if (!browserSupportsImageDecoder) {
expect(
exception.toString(),
'ImageCodecException: Failed to decode image data.\n'
'Image source: encoded image bytes',
);
} else {
expect(
exception.toString(),
'ImageCodecException: Failed to detect image file format using the file header.\n'
'File header was empty.\n'
'Image source: encoded image bytes',
);
}
}
});
test('Reports error when failing to decode malformed image data', () async {
try {
await ui.instantiateImageCodec(Uint8List.fromList(<int>[
0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00, 0x00,
]));
fail('Expected to throw');
} on ImageCodecException catch (exception) {
if (!browserSupportsImageDecoder) {
expect(
exception.toString(),
'ImageCodecException: Failed to decode image data.\n'
'Image source: encoded image bytes'
);
} else {
expect(
exception.toString(),
// Browser error message is not checked as it can depend on the
// browser engine and version.
matches(RegExp(
r"ImageCodecException: Failed to decode image using the browser's ImageDecoder API.\n"
r'Image source: encoded image bytes\n'
r'Original browser error: .+'
))
);
}
}
});
test('Includes file header in the error message when fails to detect file type', () async {
try {
await ui.instantiateImageCodec(Uint8List.fromList(<int>[
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x00,
]));
fail('Expected to throw');
} on ImageCodecException catch (exception) {
if (!browserSupportsImageDecoder) {
expect(
exception.toString(),
'ImageCodecException: Failed to decode image data.\n'
'Image source: encoded image bytes'
);
} else {
expect(
exception.toString(),
'ImageCodecException: Failed to detect image file format using the file header.\n'
'File header was [0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x00].\n'
'Image source: encoded image bytes'
);
}
}
});
test('Provides readable error message when image type is unsupported', () async {
addTearDown(() {
debugContentTypeDetector = null;
});
debugContentTypeDetector = (_) {
return 'unsupported/image-type';
};
try {
await ui.instantiateImageCodec(Uint8List.fromList(<int>[
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x00,
]));
fail('Expected to throw');
} on ImageCodecException catch (exception) {
if (!browserSupportsImageDecoder) {
expect(
exception.toString(),
'ImageCodecException: Failed to decode image data.\n'
'Image source: encoded image bytes'
);
} else {
expect(
exception.toString(),
'ImageCodecException: Image file format (unsupported/image-type) is not supported by this browser\'s ImageDecoder API.\n'
'Image source: encoded image bytes'
);
}
}
});
test('decodeImageFromPixels', () async {
Future<ui.Image> _testDecodeFromPixels(int width, int height) async {
final Completer<ui.Image> completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
Uint8List.fromList(List<int>.filled(width * height * 4, 0, growable: false)),
width,
height,
ui.PixelFormat.rgba8888,
(ui.Image image) {
completer.complete(image);
},
);
return completer.future;
}
final ui.Image image1 = await _testDecodeFromPixels(10, 20);
expect(image1, isNotNull);
expect(image1.width, 10);
expect(image1.height, 20);
final ui.Image image2 = await _testDecodeFromPixels(40, 100);
expect(image2, isNotNull);
expect(image2.width, 40);
expect(image2.height, 100);
});
test('Decode test images', () async {
final html.Body listingResponse = await httpFetch('/test_images/');
final List<String> testFiles = (await listingResponse.json() as List<dynamic>).cast<String>();
// Sanity-check the test file list. If suddenly test files are moved or
// deleted, and the test server returns an empty list, or is missing some
// important test files, we want to know.
expect(testFiles, isNotEmpty);
expect(testFiles, contains(matches(RegExp(r'.*\.jpg'))));
expect(testFiles, contains(matches(RegExp(r'.*\.png'))));
expect(testFiles, contains(matches(RegExp(r'.*\.gif'))));
expect(testFiles, contains(matches(RegExp(r'.*\.webp'))));
expect(testFiles, contains(matches(RegExp(r'.*\.bmp'))));
for (final String testFile in testFiles) {
final html.Body imageResponse = await httpFetch('/test_images/$testFile');
final Uint8List imageData = (await imageResponse.arrayBuffer() as ByteBuffer).asUint8List();
final ui.Codec codec = await skiaInstantiateImageCodec(imageData);
expect(codec.frameCount, greaterThan(0));
expect(codec.repetitionCount, isNotNull);
for (int i = 0; i < codec.frameCount; i++) {
final ui.FrameInfo frame = await codec.getNextFrame();
expect(frame.duration, isNotNull);
expect(frame.image, isNotNull);
}
codec.dispose();
}
});
// This is a regression test for the issues with transferring textures from
// one GL context to another, such as:
//
// * https://github.com/flutter/flutter/issues/86809
// * https://github.com/flutter/flutter/issues/91881
test('the same image can be rendered on difference surfaces', () async {
ui.platformViewRegistry.registerViewFactory(
'test-platform-view',
(int viewId) => html.DivElement()..id = 'view-0',
);
await createPlatformView(0, 'test-platform-view');
final EnginePlatformDispatcher dispatcher =
ui.window.platformDispatcher as EnginePlatformDispatcher;
final ui.Codec codec = await ui.instantiateImageCodec(k4x4PngImage);
final CkImage image = (await codec.getNextFrame()).image as CkImage;
final LayerSceneBuilder sb = LayerSceneBuilder();
sb.pushOffset(4, 4);
{
final CkPictureRecorder recorder = CkPictureRecorder();
final CkCanvas canvas = recorder.beginRecording(ui.Rect.largest);
canvas.save();
canvas.scale(16, 16);
canvas.drawImage(image, ui.Offset.zero, CkPaint());
canvas.restore();
canvas.drawParagraph(makeSimpleText('1'), const ui.Offset(4, 4));
sb.addPicture(ui.Offset.zero, recorder.endRecording());
}
sb.addPlatformView(0, width: 100, height: 100);
sb.pushOffset(20, 20);
{
final CkPictureRecorder recorder = CkPictureRecorder();
final CkCanvas canvas = recorder.beginRecording(ui.Rect.largest);
canvas.save();
canvas.scale(16, 16);
canvas.drawImage(image, ui.Offset.zero, CkPaint());
canvas.restore();
canvas.drawParagraph(makeSimpleText('2'), const ui.Offset(2, 2));
sb.addPicture(ui.Offset.zero, recorder.endRecording());
}
dispatcher.rasterizer!.draw(sb.build().layerTree);
await matchGoldenFile(
'canvaskit_cross_gl_context_image_$mode.png',
region: const ui.Rect.fromLTRB(0, 0, 100, 100),
maxDiffRatePercent: 0,
);
await disposePlatformView(0);
});
test('can detect JPEG from just magic number', () async {
expect(
detectContentType(
Uint8List.fromList(<int>[0xff, 0xd8, 0xff, 0xe2, 0x0c, 0x58, 0x49, 0x43, 0x43, 0x5f])),
'image/jpeg');
});
});
}
/// Tests specific to WASM codecs bundled with CanvasKit.
void _testCkAnimatedImage() {
test('ImageDecoder toByteData(PNG)', () async {
final CkAnimatedImage image = CkAnimatedImage.decodeFromBytes(kAnimatedGif, 'test');
final ui.FrameInfo frame = await image.getNextFrame();
final ByteData? png = await frame.image.toByteData(format: ui.ImageByteFormat.png);
expect(png, isNotNull);
// The precise PNG encoding is browser-specific, but we can check the file
// signature.
expect(detectContentType(png!.buffer.asUint8List()), 'image/png');
testCollector.collectNow();
});
test('CkAnimatedImage toByteData(RGBA)', () async {
final CkAnimatedImage image = CkAnimatedImage.decodeFromBytes(kAnimatedGif, 'test');
const List<List<int>> expectedColors = <List<int>>[
<int>[255, 0, 0, 255],
<int>[0, 255, 0, 255],
<int>[0, 0, 255, 255],
];
for (int i = 0; i < image.frameCount; i++) {
final ui.FrameInfo frame = await image.getNextFrame();
final ByteData? rgba = await frame.image.toByteData(format: ui.ImageByteFormat.rawRgba);
expect(rgba, isNotNull);
expect(rgba!.buffer.asUint8List(), expectedColors[i]);
}
testCollector.collectNow();
});
}
/// Tests specific to browser image codecs based functionality.
void _testCkBrowserImageDecoder() {
assert(browserSupportsImageDecoder);
test('ImageDecoder toByteData(PNG)', () async {
final CkBrowserImageDecoder image = await CkBrowserImageDecoder.create(
data: kAnimatedGif,
debugSource: 'test',
);
final ui.FrameInfo frame = await image.getNextFrame();
final ByteData? png = await frame.image.toByteData(format: ui.ImageByteFormat.png);
expect(png, isNotNull);
// The precise PNG encoding is browser-specific, but we can check the file
// signature.
expect(detectContentType(png!.buffer.asUint8List()), 'image/png');
testCollector.collectNow();
});
test('ImageDecoder toByteData(RGBA)', () async {
final CkBrowserImageDecoder image = await CkBrowserImageDecoder.create(
data: kAnimatedGif,
debugSource: 'test',
);
const List<List<int>> expectedColors = <List<int>>[
<int>[255, 0, 0, 255],
<int>[0, 255, 0, 255],
<int>[0, 0, 255, 255],
];
for (int i = 0; i < image.frameCount; i++) {
final ui.FrameInfo frame = await image.getNextFrame();
final ByteData? rgba = await frame.image.toByteData(format: ui.ImageByteFormat.rawRgba);
expect(rgba, isNotNull);
expect(rgba!.buffer.asUint8List(), expectedColors[i]);
}
testCollector.collectNow();
});
test('ImageDecoder expires after inactivity', () async {
const Duration testExpireDuration = Duration(milliseconds: 100);
debugOverrideWebDecoderExpireDuration(testExpireDuration);
final CkBrowserImageDecoder image = await CkBrowserImageDecoder.create(
data: kAnimatedGif,
debugSource: 'test',
);
// ImageDecoder is initialized eagerly to populate `frameCount` and
// `repetitionCount`.
final ImageDecoder? decoder1 = image.debugCachedWebDecoder;
expect(decoder1, isNotNull);
expect(image.frameCount, 3);
expect(image.repetitionCount, double.infinity);
// A frame can be decoded right away.
final ui.FrameInfo frame1 = await image.getNextFrame();
await expectFrameData(frame1, <int>[255, 0, 0, 255]);
expect(frame1, isNotNull);
// The cached decoder should not yet expire.
await Future<void>.delayed(testExpireDuration ~/ 2);
expect(image.debugCachedWebDecoder, same(decoder1));
// Now it expires.
await Future<void>.delayed(testExpireDuration);
expect(image.debugCachedWebDecoder, isNull);
// A new decoder should be created upon the next frame request.
final ui.FrameInfo frame2 = await image.getNextFrame();
// Check that the cached decoder is indeed new.
final ImageDecoder? decoder2 = image.debugCachedWebDecoder;
expect(decoder2, isNot(same(decoder1)));
await expectFrameData(frame2, <int>[0, 255, 0, 255]);
// Check that the new decoder remembers the last frame index.
final ui.FrameInfo frame3 = await image.getNextFrame();
await expectFrameData(frame3, <int>[0, 0, 255, 255]);
testCollector.collectNow();
debugRestoreWebDecoderExpireDuration();
});
}
class TestHttpRequest implements html.HttpRequest {
@override
String responseType = 'invalid';
@override
int? timeout = 10;
@override
bool? withCredentials = false;
@override
void abort() {
throw UnimplementedError();
}
@override
void addEventListener(String type, html.EventListener? listener, [bool? useCapture]) {
throw UnimplementedError();
}
@override
bool dispatchEvent(html.Event event) {
throw UnimplementedError();
}
@override
String getAllResponseHeaders() {
throw UnimplementedError();
}
@override
String getResponseHeader(String name) {
throw UnimplementedError();
}
@override
html.Events get on => throw UnimplementedError();
@override
Stream<html.ProgressEvent> get onAbort => throw UnimplementedError();
@override
Stream<html.ProgressEvent> onError = Stream<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[]);
@override
Stream<html.ProgressEvent> onLoad = Stream<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[]);
@override
Stream<html.ProgressEvent> get onLoadEnd => throw UnimplementedError();
@override
Stream<html.ProgressEvent> get onLoadStart => throw UnimplementedError();
@override
Stream<html.ProgressEvent> get onProgress => throw UnimplementedError();
@override
Stream<html.Event> get onReadyStateChange => throw UnimplementedError();
@override
Stream<html.ProgressEvent> get onTimeout => throw UnimplementedError();
@override
void open(String method, String url, {bool? async, String? user, String? password}) {}
@override
void overrideMimeType(String mime) {
throw UnimplementedError();
}
@override
int get readyState => throw UnimplementedError();
@override
void removeEventListener(String type, html.EventListener? listener, [bool? useCapture]) {
throw UnimplementedError();
}
@override
dynamic response;
@override
Map<String, String> get responseHeaders => throw UnimplementedError();
@override
String get responseText => throw UnimplementedError();
@override
String get responseUrl => throw UnimplementedError();
@override
html.Document get responseXml => throw UnimplementedError();
@override
void send([dynamic bodyOrData]) {
}
@override
void setRequestHeader(String name, String value) {
throw UnimplementedError();
}
@override
int status = -1;
@override
String get statusText => throw UnimplementedError();
@override
html.HttpRequestUpload get upload => throw UnimplementedError();
}
Future<void> expectFrameData(ui.FrameInfo frame, List<int> data) async {
final ByteData frameData = (await frame.image.toByteData())!;
expect(frameData.buffer.asUint8List(), Uint8List.fromList(data));
}