buildScenes function
Registers scene assets so an app loads them by source path with loadScene
without hand-editing the asset manifest. Discovers three source kinds under
discoveryRoot: .glb (converted to .fsceneb), authored .fscene
(compiled to .fsceneb, with referenced images embedded and prefab instances
intact for runtime compose), and already-built .fsceneb (an editor's
imported/ assets, registered as-is).
Generated outputs go under outputDirectory (relative to
BuildInput.packageRoot); a passthrough .fsceneb is registered in place.
Call this from a consuming app's hook/build.dart:
import 'package:hooks/hooks.dart';
import 'package:flutter_scene/build_hooks.dart';
void main(List<String> args) {
build(args, (config, output) async {
buildScenes(
buildInput: config,
buildOutput: output,
assetMode: SceneAssetMode.dataAssetsIfAvailable,
);
});
}
When inputFilePaths is omitted, every .glb/.fscene/.fsceneb under
discoveryRoot (default assets/, relative to the package root) is
discovered; in a DataAssets mode each is registered as a DataAsset (key
packages/<package>/flutter_scene/scene/<name>.fsceneb), and each source is
declared as a build dependency so changing it retriggers the build (and hot
reload). Conversion runs in-process (no subprocess, no native binary).
Implementation
void buildScenes({
required BuildInput buildInput,
required BuildOutputBuilder buildOutput,
List<String>? inputFilePaths,
String outputDirectory = 'build/scenes/',
String discoveryRoot = 'assets/',
SceneAssetMode assetMode = SceneAssetMode.legacyOnly,
bool compressTextures = false,
}) {
final dataAssetsAvailable = buildInput.config.buildDataAssets;
if (assetMode == SceneAssetMode.dataAssetsRequired && !dataAssetsAvailable) {
throw UnsupportedError(_dataAssetsUnavailableMessage);
}
final emitDataAssets =
assetMode != SceneAssetMode.legacyOnly && dataAssetsAvailable;
final packageRoot = buildInput.packageRoot;
final inputs =
inputFilePaths ??
discoverSceneSources(packageRoot, discoveryRoot: discoveryRoot);
if (inputs.isEmpty) {
return;
}
final scenesRoot = packageRoot.resolve(outputDirectory);
for (final inputFilePath in inputs) {
final extension = _sceneSourceExtensions.firstWhere(
inputFilePath.endsWith,
orElse: () => throw Exception(
'Scene source must be a .glb, .fscene, or .fsceneb file. Given: '
'$inputFilePath',
),
);
if (inputFilePath.startsWith('../') || inputFilePath.contains('/../')) {
throw Exception(
'Scene source must be inside the package: $inputFilePath. Place it '
'under the package (for example in assets/), using a symlink if needed.',
);
}
final sourceUri = packageRoot.resolve(inputFilePath);
// An already-built `.fsceneb` (an editor's imported asset) is registered
// as-is, from its source location, with no conversion or output copy.
if (extension == '.fsceneb') {
buildOutput.dependencies.add(sourceUri);
if (emitDataAssets) {
buildOutput.assets.data.add(
DataAsset(
package: buildInput.packageName,
name: sceneDataAssetName(inputFilePath),
file: sourceUri,
),
);
}
continue;
}
// `.glb` and `.fscene` produce a generated `.fsceneb` under the output dir.
final relativeScenePath =
'${inputFilePath.substring(0, inputFilePath.length - extension.length)}'
'.fsceneb';
final outputSceneUri = scenesRoot.resolve(relativeScenePath);
Directory.fromUri(outputSceneUri.resolve('.')).createSync(recursive: true);
// An authored `.fscene` references its imported images by path; read it up
// front so those files can be embedded into the self-contained `.fsceneb`
// and tracked as build dependencies (editing a referenced image then
// retriggers conversion and hot reload). The document is reused for the
// conversion below when the cache is stale.
SceneDocument? fsceneDocument;
List<ExternalImageAsset> imageAssets = const [];
if (extension == '.fscene') {
fsceneDocument = readFscene(
File(sourceUri.toFilePath()).readAsStringSync(),
);
imageAssets = resolveExternalImageAssets(fsceneDocument, sourceUri);
}
// Skip the work when the source and settings are unchanged since the
// output was produced, so a hook rerun for an unrelated edit does not
// reconvert every scene. Set FLUTTER_SCENE_DISABLE_BUILD_CACHE to always
// run.
final sourceHash = contentHash(
File(sourceUri.toFilePath()).readAsBytesSync(),
);
// Fold each referenced image's content hash into the stamp, so editing an
// embedded image (not just the scene text) invalidates the cache and
// rebuilds the dependent `.fsceneb`.
final assetStamp =
(imageAssets
.map((a) => '${a.key}=${contentHash(a.file.readAsBytesSync())}')
.toList()
..sort())
.join(',');
final stamp =
'rev=$buildCacheRevision scene compress=$compressTextures '
'kind=$extension src=$sourceHash assets=[$assetStamp]';
final stampFile = File('${outputSceneUri.toFilePath()}.inputs');
if (!isBuildCacheFresh(stampFile, stamp, [
File(outputSceneUri.toFilePath()),
])) {
if (extension == '.glb') {
importGltfToFsceneb(
inputFilePath,
outputSceneUri.toFilePath(),
workingDirectory: packageRoot.toFilePath(),
compressTextures: compressTextures,
);
} else {
// `.fscene` (authored text) -> `.fsceneb` (binary), embedding referenced
// images so the container is self-contained and keeping prefab instances
// intact for the runtime to compose.
inlineExternalImageAssets(fsceneDocument!, imageAssets);
File(
outputSceneUri.toFilePath(),
).writeAsBytesSync(writeFsceneb(fsceneDocument));
}
stampFile.writeAsStringSync(stamp);
}
buildOutput.dependencies.add(sourceUri);
// Declare each embedded image as a dependency every run (not only when the
// cache is stale), so editing it retriggers the hook and the dependent
// scene's hot reload.
for (final asset in imageAssets) {
buildOutput.dependencies.add(asset.file.uri);
}
if (emitDataAssets) {
buildOutput.assets.data.add(
DataAsset(
package: buildInput.packageName,
name: sceneDataAssetName(relativeScenePath),
file: outputSceneUri,
),
);
}
}
}