import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:tuple/tuple.dart'; import 'package:polyvox_filament/animations/bone_animation_data.dart'; import 'package:polyvox_filament/animations/bone_driver.dart'; import 'package:polyvox_filament/animations/morph_animation_data.dart'; import 'package:vector_math/vector_math.dart'; /// /// A class for loading animation data from a single CSV and allocating between morph/bone animation with help. /// class DynamicAnimation { final MorphAnimationData? morphAnimation; final List boneAnimation; factory DynamicAnimation.load(String? meshName, String csvPath, {List? boneDrivers, List? boneMeshes, String? boneDriverConfigPath, double? framerate}) { // create a MorphAnimationData instance from the given CSV var llf = _loadLiveLinkFaceCSV(csvPath); var frameLengthInMs = 1000 / (framerate ?? 60.0); var morphNames = llf .item1; //.where((name) => !boneDrivers.any((element) => element.blendshape == name)); var morphAnimationData = MorphAnimationData( meshName ?? "NULL", llf.item2, morphNames, frameLengthInMs); final boneAnimations = []; // if applicable, load the bone driver config if (boneDriverConfigPath != null) { if (boneDrivers != null) { throw Exception( "Specify either boneDrivers, or the config path, not both"); } boneDrivers = [ json .decode(File(boneDriverConfigPath).readAsStringSync()) .map(BoneDriver.fromJsonObject) .toList() ]; } // iterate over every bone driver if (boneDrivers != null) { for (var driver in boneDrivers) { // collect the frame data for the blendshapes that this driver uses var morphData = driver.transformations .map((String blendshape, Transformation transformation) { return MapEntry( blendshape, morphAnimationData.getData(blendshape).toList()); }); // apply the driver to the frame data var transformedQ = driver.transform(morphData).toList(); // transform the quaternion to a Float32List var transformedF = _quaternionToFloatList(transformedQ); // add to the list of boneAnimations boneAnimations.add(BoneAnimationData( driver.bone, boneMeshes!, transformedF, frameLengthInMs)); } } return DynamicAnimation(morphAnimationData, boneAnimations); } static Float32List _quaternionToFloatList(List quats) { var data = Float32List(quats.length * 7); int i = 0; for (var quat in quats) { data.setRange(i, i + 7, [0, 0, 0, quat.w, quat.x, quat.y, quat.z]); i += 7; } return data; } DynamicAnimation(this.morphAnimation, this.boneAnimation); /// /// Load visemes fom a CSV file formatted according to the following header: /// "Timecode,BlendShapeCount,EyeBlinkLeft,EyeLookDownLeft,EyeLookInLeft,EyeLookOutLeft,EyeLookUpLeft,EyeSquintLeft,EyeWideLeft,EyeBlinkRight,EyeLookDownRight,EyeLookInRight,EyeLookOutRight,EyeLookUpRight,EyeSquintRight,EyeWideRight,JawForward,JawRight,JawLeft,JawOpen,MouthClose,MouthFunnel,MouthPucker,MouthRight,MouthLeft,MouthSmileLeft,MouthSmileRight,MouthFrownLeft,MouthFrownRight,MouthDimpleLeft,MouthDimpleRight,MouthStretchLeft,MouthStretchRight,MouthRollLower,MouthRollUpper,MouthShrugLower,MouthShrugUpper,MouthPressLeft,MouthPressRight,MouthLowerDownLeft,MouthLowerDownRight,MouthUpperUpLeft,MouthUpperUpRight,BrowDownLeft,BrowDownRight,BrowInnerUp,BrowOuterUpLeft,BrowOuterUpRight,CheekPuff,CheekSquintLeft,CheekSquintRight,NoseSneerLeft,NoseSneerRight,TongueOut,HeadYaw,HeadPitch,HeadRoll,LeftEyeYaw,LeftEyePitch,LeftEyeRoll,RightEyeYaw,RightEyePitch,RightEyeRoll" /// Returns only those specified by [targetNames]. /// static Tuple2, Float32List> _loadLiveLinkFaceCSV(String path) { final data = File(path) .readAsLinesSync() .where((l) => l.length > 1) .map((l) => l.split(",")); final header = data.first; final numBlendShapes = header.length - 2; final _data = []; for (var frame in data.skip(1)) { int numFrameWeights = frame.length - 2; // CSVs may contain rows where the "BlendShapeCount" column is set to "0" and/or the weight columns are simply missing. // This can happen when something went wrong while recording via an app (e.g. LiveLinkFace) // Whenever we encounter this type of row, we consider that all weights should be set to zero for that frame. if (numFrameWeights != int.parse(frame[1])) { _data.addAll(List.filled(numBlendShapes, 0.0)); continue; } // // Now, we check that the actual number of weight columns matches the header // we ignore the "BlendShapeCount" column (and just count the number of columns) // This is due to some legacy issues where we generated CSVs that had 61 weight columns, but accidentally left the "BlendShapeCount" column at 55 // This is probably fine as we always have either zero weights (handled above), or all weights (handled below). // In other words, if this throws, we have a serious problem. if (numFrameWeights != numBlendShapes) { throw Exception( "Malformed CSV, header specifies ${numBlendShapes} columns but frame specified only $numFrameWeights weights"); } _data.addAll(frame .skip(2) .map((weight) => double.parse(weight)) .cast() .toList()); } return Tuple2(header.skip(2).toList(), Float32List.fromList(_data)); } }