Add debug json mechanism for EngineSceneBuilder. (#165821)

I added some debugging mechanisms that can help create a minimal test
case more easily:

* In debug mode, you can press `Alt+F10` and the engine will generate a
JSON representation of the most recently rendered scene and download it.
* Added a script called `generate_scene_test.dart` which can take that
JSON and generate a dart file which renders a scene with the identical
hierarchy and layout. All the pictures are rendered as squares with
random-ish colors.
This commit is contained in:
Jackson Gardner 2025-03-24 17:24:37 -07:00 committed by GitHub
parent 838515b967
commit 355129961c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 528 additions and 5 deletions

View File

@ -0,0 +1,259 @@
// 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.
// This script takes a json file that comes from the `debugJsonOutput` of an
// `EngineScene` object and generates a function that builds a scene with the
// same dimensions and layering. This makes it easier to take a complex scene
// rendered by an app and whittle it down to a minimal test case.
import 'dart:convert';
import 'dart:io';
// A bunch of visually unique colors to cycle through.
const List<String> colorStrings = [
'0xFF7090da',
'0xFF9cb835',
'0xFF7c64d4',
'0xFF5fc250',
'0xFFc853be',
'0xFF4e8d2b',
'0xFFdb3f80',
'0xFF54bd7b',
'0xFFd2404e',
'0xFF53c4ad',
'0xFFd34a2a',
'0xFF46aed7',
'0xFFdc7a2d',
'0xFF6561a8',
'0xFFc0ab39',
'0xFFa44c8c',
'0xFF408147',
'0xFFd18dce',
'0xFF707822',
'0xFFae4462',
'0xFF308870',
'0xFFd99a37',
'0xFF935168',
'0xFF9eb46b',
'0xFFe1808a',
'0xFF627037',
'0xFFda815c',
'0xFF8e6c2b',
'0xFF9f4f30',
'0xFFd4a66f',
];
int colorIndex = 0;
String getNextColor() {
final colorString = colorStrings[colorIndex];
colorIndex++;
colorIndex %= colorStrings.length;
return colorString;
}
String getColorAsString() {
return 'const ui.Color(${getNextColor()})';
}
String offsetAsString(Object? offset) {
offset as Map<String, Object?>?;
offset!;
final num x = offset['x']! as num;
final num y = offset['y']! as num;
if (x == 0 && y == 0) {
return 'ui.Offset.zero';
}
return 'const ui.Offset($x, $y)';
}
String rRectAsString(Object? rRect) {
rRect as Map<String, Object?>?;
rRect!;
return 'ui.RRect.fromLTRBAndCorners('
'${rRect['left']}, '
'${rRect['top']}, '
'${rRect['right']}, '
'${rRect['bottom']}, '
'topLeft: const ui.Radius.elliptical(${rRect['tlRadiusX']}, ${rRect['tlRadiusY']}), '
'topRight: const ui.Radius.elliptical(${rRect['trRadiusX']}, ${rRect['trRadiusY']}), '
'bottomRight: const ui.Radius.elliptical(${rRect['brRadiusX']}, ${rRect['brRadiusY']}), '
'bottomLeft: const ui.Radius.elliptical(${rRect['blRadiusX']}, ${rRect['blRadiusY']}))';
}
String rectAsString(Object? rect) {
rect as Map<String, Object?>?;
rect!;
final num left = rect['left']! as num;
final num top = rect['top']! as num;
final num right = rect['right']! as num;
final num bottom = rect['bottom']! as num;
if (left == 0 && top == 0 && right == 0 && bottom == 0) {
return 'ui.Rect.zero';
}
return 'const ui.Rect.fromLTRB($left, $top, $right, $bottom)';
}
void emitShaderMaskOperation(Map<String, Object?> operation, String indent) {
// TODO(jacksongardner): implement
throw UnimplementedError();
}
void emitTransformOperation(Map<String, Object?> operation, String indent) {
final matrixValues = operation['matrix']! as List<Object?>;
print('${indent}builder.pushTransform(Float64List.fromList([${matrixValues.join(', ')}]));');
}
void emitOpacityOperation(Map<String, Object?> operation, String indent) {
final offset = operation['offset']! as Map<String, Object?>;
print('${indent}builder.pushOpacity(${operation['alpha']}, offset: ${offsetAsString(offset)});');
}
void emitOffsetOperation(Map<String, Object?> operation, String indent) {
final offset = operation['offset']! as Map<String, Object?>;
print('${indent}builder.pushOffset(${offset['x']}, ${offset['y']});');
}
void emitImageFilterOperation(Map<String, Object?> operation, String indent) {
// TODO(jacksongardner): implement
throw UnimplementedError();
}
void emitColorFilterOperation(Map<String, Object?> operation, String indent) {
// TODO(jacksongardner): implement
throw UnimplementedError();
}
void emitClipRRectOperation(Map<String, Object?> operation, String indent) {
print(
'${indent}builder.pushClipRRect(${rRectAsString(operation['rrect'])}, clipBehavior: ui.Clip.${operation['clip']});',
);
}
void emitClipRectOperation(Map<String, Object?> operation, String indent) {
print(
'${indent}builder.pushClipRect(${rectAsString(operation['rect'])}, clipBehavior: ui.Clip.${operation['clip']});',
);
}
void emitClipPathOperation(Map<String, Object?> operation, String indent) {
print(
'${indent}builder.pushClipPath(ui.Path()..addRect(${rectAsString(operation['pathBounds'])}), clipBehavior: ui.Clip.${operation['clip']});',
);
}
void emitBackdropFilterOperation(Map<String, Object?> operation, String indent) {
// TODO(jacksongardner): implement
throw UnimplementedError();
}
void emitOperation(Object? operation, String indent) {
operation as Map<String, Object?>?;
operation!;
switch (operation['type']) {
case 'backdropFilter':
emitBackdropFilterOperation(operation, indent);
case 'clipPath':
emitClipPathOperation(operation, indent);
case 'clipRect':
emitClipRectOperation(operation, indent);
case 'clipRRect':
emitClipRRectOperation(operation, indent);
case 'colorFilter':
emitColorFilterOperation(operation, indent);
case 'imageFilter':
emitImageFilterOperation(operation, indent);
case 'offset':
emitOffsetOperation(operation, indent);
case 'opacity':
emitOpacityOperation(operation, indent);
case 'transform':
emitTransformOperation(operation, indent);
case 'shaderMask':
emitShaderMaskOperation(operation, indent);
default:
throw ArgumentError('invalid operation type: ${operation['type']}');
}
}
void emitLayer(Map<String, Object?> command, String indent) {
final layer = command['layer']! as Map<String, Object?>;
print('$indent{');
final String innerIndent = ' $indent';
emitOperation(layer['operation'], innerIndent);
emitCommands(layer['commands'], innerIndent);
print('${innerIndent}builder.pop();');
print('$indent}');
}
void emitPlatformView(Map<String, Object?> command, String indent) {
final localBounds = command['localBounds']! as Map<String, Object?>;
final left = localBounds['left']! as double;
final top = localBounds['top']! as double;
final right = localBounds['right']! as double;
final bottom = localBounds['bottom']! as double;
print(
'${indent}builder.addPlatformView(1, offset: const ui.Offset($left, $top), width: ${right - left}, height: ${bottom - top});',
);
}
void emitPicture(Map<String, Object?> command, String indent) {
print(
'${indent}builder.addPicture(${offsetAsString(command['offset'])}, drawPicture(${rectAsString(command['localBounds'])}, ${getColorAsString()}));',
);
}
void emitCommands(Object? commands, String indent) {
commands as List<Object?>?;
commands!;
for (final Object? command in commands) {
command as Map<String, Object?>?;
command!;
switch (command['type']) {
case 'picture':
emitPicture(command, indent);
case 'platformView':
emitPlatformView(command, indent);
case 'layer':
emitLayer(command, indent);
}
}
}
int main(List<String> args) {
if (args.length != 1) {
stderr.write('Usage: dart generate_scene_test.dart <path_to_json>.\n');
return 1;
}
final jsonFile = File(args[0]);
if (!jsonFile.existsSync()) {
stderr.write('Json file at path ${jsonFile.path} not found.\n');
}
final fileString = jsonFile.readAsStringSync();
final sceneJson = jsonDecode(fileString) as Map<String, Object?>;
final rootLayer = sceneJson['rootLayer']! as Map<String, Object?>;
print('''
// This file was generated from a JSON file using the `web_ui/dev/generate_scene_test.dart`.
import 'dart:typed_data';
import 'package:ui/ui.dart' as ui;
ui.Picture drawPicture(ui.Rect bounds, ui.Color color) {
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
canvas.drawRect(bounds, ui.Paint()..color = color);
return recorder.endRecording();
}
ui.Scene buildScene() {
final ui.SceneBuilder builder = ui.SceneBuilder();''');
emitCommands(rootLayer['commands'], ' ');
print('''
return builder.build();
}''');
return 0;
}

View File

@ -573,4 +573,7 @@ class CanvasKitRenderer implements Renderer {
baseline: baseline,
lineNumber: lineNumber,
);
@override
void dumpDebugInfo() {}
}

View File

@ -9,11 +9,12 @@ import 'package:ui/ui.dart' as ui;
import 'package:ui/ui_web/src/ui_web.dart' as ui_web;
import 'package:web_locale_keymap/web_locale_keymap.dart' as locale_keymap;
import '../engine.dart' show registerHotRestartListener;
import 'dom.dart';
import 'initialization.dart';
import 'key_map.g.dart';
import 'platform_dispatcher.dart';
import 'raw_keyboard.dart';
import 'renderer.dart';
import 'semantics.dart';
typedef _VoidCallback = void Function();
@ -600,6 +601,14 @@ class KeyboardConverter {
return;
}
if (kDebugMode &&
event.key == 'F10' &&
event.altKey &&
event.type == 'keydown' &&
!(event.repeat ?? false)) {
renderer.dumpDebugInfo();
}
assert(_dispatchKeyData == null);
bool sentAnyEvents = false;
_dispatchKeyData = (ui.KeyData data) {

View File

@ -41,6 +41,11 @@ class NoopOperation implements LayerOperation {
@override
String toString() => 'NoopOperation()';
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{'type': 'noop'};
}
}
class BackdropFilterLayer with PictureEngineLayer implements ui.BackdropFilterEngineLayer {
@ -80,6 +85,15 @@ class BackdropFilterOperation implements LayerOperation {
@override
String toString() => 'BackdropFilterOperation(filter: $filter, mode: $mode)';
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{
'type': 'backdropFilter',
'filter': filter.toString(),
'mode': mode.toString(),
};
}
}
class ClipPathLayer with PictureEngineLayer implements ui.ClipPathEngineLayer {
@ -128,6 +142,21 @@ class ClipPathOperation implements LayerOperation {
@override
String toString() => 'ClipPathOperation(path: $path, clip: $clip)';
@override
Map<String, Object> get debugJsonDescription {
final ui.Rect bounds = path.getBounds();
return <String, Object>{
'type': 'clipPath',
'pathBounds': {
'left': bounds.left,
'top': bounds.top,
'right': bounds.right,
'bottom': bounds.bottom,
},
'clip': clip.name,
};
}
}
class ClipRectLayer with PictureEngineLayer implements ui.ClipRectEngineLayer {
@ -176,6 +205,15 @@ class ClipRectOperation implements LayerOperation {
@override
String toString() => 'ClipRectOperation(rect: $rect, clip: $clip)';
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{
'type': 'clipRect',
'rect': {'left': rect.left, 'top': rect.top, 'right': rect.right, 'bottom': rect.bottom},
'clip': clip.name,
};
}
}
class ClipRRectLayer with PictureEngineLayer implements ui.ClipRRectEngineLayer {
@ -224,6 +262,28 @@ class ClipRRectOperation implements LayerOperation {
@override
String toString() => 'ClipRRectOperation(rrect: $rrect, clip: $clip)';
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{
'type': 'clipRRect',
'rrect': {
'left': rrect.left,
'top': rrect.top,
'right': rrect.right,
'bottom': rrect.bottom,
'tlRadiusX': rrect.tlRadiusX,
'tlRadiusY': rrect.tlRadiusY,
'trRadiusX': rrect.trRadiusX,
'trRadiusY': rrect.trRadiusY,
'brRadiusX': rrect.brRadiusX,
'brRadiusY': rrect.brRadiusY,
'blRadiusX': rrect.blRadiusX,
'blRadiusY': rrect.blRadiusY,
},
'clip': clip.name,
};
}
}
class ClipRSuperellipseLayer with PictureEngineLayer implements ui.ClipRSuperellipseEngineLayer {
@ -274,6 +334,28 @@ class ClipRSuperellipseOperation implements LayerOperation {
@override
String toString() => 'ClipRSuperellipseOperation(rse: $rse, clip: $clip)';
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{
'type': 'clipRSuperEllipse',
'rse': {
'left': rse.left,
'top': rse.top,
'right': rse.right,
'bottom': rse.bottom,
'tlRadiusX': rse.tlRadiusX,
'tlRadiusY': rse.tlRadiusY,
'trRadiusX': rse.trRadiusX,
'trRadiusY': rse.trRadiusY,
'brRadiusX': rse.brRadiusX,
'brRadiusY': rse.brRadiusY,
'blRadiusX': rse.blRadiusX,
'blRadiusY': rse.blRadiusY,
},
'clip': clip.name,
};
}
}
class ColorFilterLayer with PictureEngineLayer implements ui.ColorFilterEngineLayer {
@ -312,6 +394,11 @@ class ColorFilterOperation implements LayerOperation {
@override
String toString() => 'ColorFilterOperation(filter: $filter)';
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{'type': 'colorFilter', 'filter': filter.toString()};
}
}
class ImageFilterLayer with PictureEngineLayer implements ui.ImageFilterEngineLayer {
@ -371,6 +458,15 @@ class ImageFilterOperation implements LayerOperation {
@override
String toString() => 'ImageFilterOperation(filter: $filter)';
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{
'type': 'imageFilter',
'filter': filter.toString(),
'offset': {'x': offset.dx, 'y': offset.dy},
};
}
}
class OffsetLayer with PictureEngineLayer implements ui.OffsetEngineLayer {
@ -412,6 +508,14 @@ class OffsetOperation implements LayerOperation {
@override
String toString() => 'OffsetOperation(dx: $dx, dy: $dy)';
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{
'type': 'offset',
'offset': {'x': dx, 'y': dy},
};
}
}
class OpacityLayer with PictureEngineLayer implements ui.OpacityEngineLayer {
@ -464,6 +568,15 @@ class OpacityOperation implements LayerOperation {
@override
String toString() => 'OpacityOperation(offset: $offset, alpha: $alpha)';
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{
'type': 'opacity',
'alpha': alpha,
'offset': {'x': offset.dx, 'y': offset.dy},
};
}
}
class TransformLayer with PictureEngineLayer implements ui.TransformEngineLayer {
@ -508,6 +621,11 @@ class TransformOperation implements LayerOperation {
@override
String toString() => 'TransformOperation(matrix: $matrix)';
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{'type': 'transform', 'matrix': transform.toList()};
}
}
class ShaderMaskLayer with PictureEngineLayer implements ui.ShaderMaskEngineLayer {
@ -558,6 +676,20 @@ class ShaderMaskOperation implements LayerOperation {
@override
String toString() =>
'ShaderMaskOperation(shader: $shader, maskRect: $maskRect, blendMode: $blendMode)';
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{
'type': 'shaderMask',
'shader': shader.toString(),
'maskRect': {
'left': maskRect.left,
'top': maskRect.top,
'right': maskRect.right,
'bottom': maskRect.bottom,
},
};
}
}
class PlatformView {
@ -614,6 +746,13 @@ mixin PictureEngineLayer implements ui.EngineLayer {
return 'PictureEngineLayer($operation)';
}
Map<String, Object> get debugJsonDescription {
return <String, Object>{
'operation': operation.debugJsonDescription,
'commands': drawCommands.map((c) => c.debugJsonDescription).toList(),
};
}
bool get isSimple {
if (slices.length > 1) {
return false;
@ -643,9 +782,13 @@ abstract class LayerOperation {
/// invoked even if it contains no pictures. (Most operations don't need to
/// actually be performed at all if they don't contain any pictures.)
bool get affectsBackdrop;
Map<String, Object> get debugJsonDescription;
}
sealed class LayerDrawCommand {}
sealed class LayerDrawCommand {
Map<String, Object> get debugJsonDescription;
}
class PictureDrawCommand extends LayerDrawCommand {
PictureDrawCommand(this.offset, this.picture, this.sliceIndex);
@ -653,6 +796,22 @@ class PictureDrawCommand extends LayerDrawCommand {
final int sliceIndex;
final ui.Offset offset;
final ScenePicture picture;
@override
Map<String, Object> get debugJsonDescription {
final bounds = picture.cullRect;
return <String, Object>{
'type': 'picture',
'sliceIndex': sliceIndex,
'offset': <String, Object>{'x': offset.dx, 'y': offset.dy},
'localBounds': <String, Object>{
'left': bounds.left,
'top': bounds.top,
'right': bounds.right,
'bottom': bounds.bottom,
},
};
}
}
class PlatformViewDrawCommand extends LayerDrawCommand {
@ -661,12 +820,32 @@ class PlatformViewDrawCommand extends LayerDrawCommand {
final int sliceIndex;
final int viewId;
final ui.Rect bounds;
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{
'type': 'platformView',
'sliceIndex': sliceIndex,
'viewId': viewId,
'localBounds': <String, Object>{
'left': bounds.left,
'top': bounds.top,
'right': bounds.right,
'bottom': bounds.bottom,
},
};
}
}
class RetainedLayerDrawCommand extends LayerDrawCommand {
RetainedLayerDrawCommand(this.layer);
final PictureEngineLayer layer;
@override
Map<String, Object> get debugJsonDescription {
return <String, Object>{'type': 'layer', 'layer': layer.debugJsonDescription};
}
}
// Represents how a platform view should be positioned in the scene.
@ -883,6 +1062,11 @@ class PlatformViewNoClip implements PlatformViewClip {
@override
ui.Rect get outerRect => ui.Rect.largest;
@override
String toString() {
return 'PlatformViewClip(none)';
}
}
class PlatformViewRectClip implements PlatformViewClip {
@ -922,10 +1106,15 @@ class PlatformViewRectClip implements PlatformViewClip {
@override
ui.Rect get outerRect => rect;
@override
String toString() {
return 'PlatformViewRectClip($rect)';
}
}
class PlatformViewRRectClip implements PlatformViewClip {
PlatformViewRRectClip(this.rrect);
const PlatformViewRRectClip(this.rrect);
final ui.RRect rrect;
@ -961,6 +1150,11 @@ class PlatformViewRRectClip implements PlatformViewClip {
@override
ui.Rect get outerRect => rrect.outerRect;
@override
String toString() {
return 'PlatformViewRRectClip($rrect)';
}
}
class PlatformViewPathClip implements PlatformViewClip {
@ -1001,6 +1195,11 @@ class PlatformViewPathClip implements PlatformViewClip {
@override
ui.Rect get outerRect => path.getBounds();
@override
String toString() {
return 'PlatformViewPathClip($path)';
}
}
class LayerSliceBuilder {

View File

@ -227,4 +227,6 @@ abstract class Renderer {
ui.ParagraphBuilder createParagraphBuilder(ui.ParagraphStyle style);
Future<void> renderScene(ui.Scene scene, EngineFlutterView view);
void dumpDebugInfo();
}

View File

@ -70,6 +70,10 @@ class EngineScene implements ui.Scene {
}
return recorder.endRecording().toImageSync(width, height);
}
Map<String, Object> get debugJsonDescription {
return {'rootLayer': rootLayer.debugJsonDescription};
}
}
sealed class OcclusionMapNode {

View File

@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
@ -53,6 +54,9 @@ class EngineSceneView {
_SceneRender? _currentRender;
_SceneRender? _nextRender;
// Only populated in debug mode.
_SceneRender? _previousRender;
Future<void> renderScene(EngineScene scene, FrameTimingRecorder? recorder) {
if (_currentRender != null) {
// If a scene is already queued up, drop it and queue this one up instead
@ -71,7 +75,7 @@ class EngineSceneView {
Future<void> _kickRenderLoop() async {
final _SceneRender current = _currentRender!;
await _renderScene(current.scene, current.recorder);
current.done();
_renderComplete(current);
_currentRender = _nextRender;
_nextRender = null;
if (_currentRender == null) {
@ -194,6 +198,37 @@ class EngineSceneView {
}
}
}
void _renderComplete(_SceneRender render) {
render.done();
if (kDebugMode) {
_previousRender = render;
}
}
String _generateDebugFilename() {
final now = DateTime.now();
final String y = now.year.toString().padLeft(4, '0');
final String mo = now.month.toString().padLeft(2, '0');
final String d = now.day.toString().padLeft(2, '0');
final String h = now.hour.toString().padLeft(2, '0');
final String mi = now.minute.toString().padLeft(2, '0');
final String s = now.second.toString().padLeft(2, '0');
return 'flutter-scene-$y-$mo-$d-$h-$mi-$s.json';
}
void dumpDebugInfo() {
if (kDebugMode && _previousRender != null) {
final Map<String, Object?> debugJson = _previousRender!.scene.debugJsonDescription;
final String jsonString = jsonEncode(debugJson);
final blob = createDomBlob([jsonString], {'type': 'application/json'});
final url = domWindow.URL.createObjectURL(blob);
final element = domDocument.createElement('a');
element.setAttribute('href', url);
element.setAttribute('download', _generateDebugFilename());
element.click();
}
}
}
sealed class SliceContainer {

View File

@ -463,6 +463,13 @@ class SkwasmRenderer implements Renderer {
imageCreateFromTextureSource(textureSource as JSObject, width, height, surface.handle),
);
}
@override
void dumpDebugInfo() {
for (final view in _sceneViews.values) {
view.dumpDebugInfo();
}
}
}
class SkwasmPictureRenderer implements PictureRenderer {

View File

@ -324,6 +324,11 @@ class SkwasmRenderer implements Renderer {
required int height,
required bool transferOwnership,
}) {
throw Exception('Skwasm not implemented on this platform.');
throw UnimplementedError('Skwasm not implemented on this platform.');
}
@override
void dumpDebugInfo() {
throw UnimplementedError('Skwasm not implemented on this platform.');
}
}