Shaders: Anime4K presets in-session and in Settings
Vendored Anime4K v4.0.1 GLSL (MIT), extracted to app support once per version. Presets A/B/C/A+A/B+B/C+A in HQ and Fast per Anime4K's mpv templates, plus custom files. Shader button in the video controls (works in fullscreen), Ctrl+1..6 / Ctrl+0, live apply with read-back, failures fall back to Off with a toast. Render mode detected from mpv's GL log; warns when on the software path. Docs include the Jellyfin client section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4814bfb561
commit
fb2927c094
28 files changed
+6516
-53
No files matched your search
@@ -0,0 +1,74 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'shader_presets.dart';
|
||||
|
||||
const anime4kAssetDir = 'assets/shaders/anime4k';
|
||||
|
||||
/// mpv needs real files: copies the shipped Anime4K shaders out of the
|
||||
/// asset bundle into `<supportDir>/shaders/anime4k-<version>`, once per
|
||||
/// version (a `.complete` marker is written last, so a cut-short copy is
|
||||
/// redone). Returns that directory.
|
||||
Future<Directory> extractAnime4k(
|
||||
Directory supportDir, {
|
||||
AssetBundle? bundle,
|
||||
}) async {
|
||||
final dir = Directory(
|
||||
'${supportDir.path}${Platform.pathSeparator}shaders'
|
||||
'${Platform.pathSeparator}anime4k-$anime4kVersion');
|
||||
final marker = File('${dir.path}${Platform.pathSeparator}.complete');
|
||||
final files = anime4kAllFiles;
|
||||
if (await marker.exists() &&
|
||||
files.every((f) => File(_join(dir, f)).existsSync())) {
|
||||
return dir;
|
||||
}
|
||||
await dir.create(recursive: true);
|
||||
final b = bundle ?? rootBundle;
|
||||
for (final name in [...files, 'LICENSE']) {
|
||||
final data = await b.load('$anime4kAssetDir/$name');
|
||||
await File(_join(dir, name)).writeAsBytes(
|
||||
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
|
||||
flush: true);
|
||||
}
|
||||
await marker.writeAsString(anime4kVersion, flush: true);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// Absolute paths of [preset]'s Anime4K files inside [dir].
|
||||
List<String> anime4kPaths(
|
||||
Directory dir, ShaderPreset preset, ShaderQuality quality) =>
|
||||
[for (final f in anime4kFiles(preset, quality)) _join(dir, f)];
|
||||
|
||||
String _join(Directory dir, String name) =>
|
||||
'${dir.path}${Platform.pathSeparator}$name';
|
||||
|
||||
/// Where the shipped shaders get extracted; swapped in tests.
|
||||
Future<Directory> Function() shaderSupportDir = getApplicationSupportDirectory;
|
||||
|
||||
/// Absolute `glsl-shaders` entries for a choice, extracting the shipped
|
||||
/// files first when needed. Empty for [ShaderPreset.off].
|
||||
Future<List<String>> shaderPathsFor(
|
||||
ShaderPreset preset,
|
||||
ShaderQuality quality,
|
||||
List<String> custom,
|
||||
) async {
|
||||
if (preset == ShaderPreset.off) return const [];
|
||||
if (preset == ShaderPreset.custom) return [...custom];
|
||||
final dir = await extractAnime4k(await shaderSupportDir());
|
||||
return anime4kPaths(dir, preset, quality);
|
||||
}
|
||||
|
||||
/// Why [paths] can't go to mpv (a missing file, or a path holding the
|
||||
/// list separator), or null when they can.
|
||||
String? shaderPathsProblem(List<String> paths, {bool? windows}) {
|
||||
final sep = shaderListSeparator(windows: windows);
|
||||
for (final p in paths) {
|
||||
final name = p.split(RegExp(r'[/\\]')).last;
|
||||
// Windows drive letters ("C:\") are fine: there the separator is ';'.
|
||||
if (p.contains(sep)) return '"$name" has "$sep" in its path';
|
||||
if (!File(p).existsSync()) return '"$name" not found';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../playback/playback_controller.dart';
|
||||
import '../state/settings.dart';
|
||||
import 'shader_presets.dart';
|
||||
|
||||
/// A shader pick from the menu; a null quality keeps the saved one.
|
||||
typedef ShaderPick = (ShaderPreset, ShaderQuality?);
|
||||
|
||||
/// Compact shader button for the video controls bar (normal and
|
||||
/// fullscreen). Opens a small menu: Off, the six Anime4K modes with
|
||||
/// HQ / Fast, Custom. The menu is a route, so it outlives the controls
|
||||
/// bar hiding under it; [onPick] runs on the caller's side.
|
||||
class ShaderControlButton extends StatelessWidget {
|
||||
final FocusNode focusNode;
|
||||
final ReadonlySignal<RenderMode> renderMode;
|
||||
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const ShaderControlButton({
|
||||
super.key,
|
||||
required this.focusNode,
|
||||
required this.renderMode,
|
||||
required this.onPick,
|
||||
this.onMenuOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final preset = s.shaderPreset.value;
|
||||
final quality = s.shaderQuality.value;
|
||||
final short = switch (preset) {
|
||||
ShaderPreset.off => 'Shaders',
|
||||
ShaderPreset.custom => 'Custom',
|
||||
_ => '${preset.label.replaceFirst('Mode ', '')} ${quality.label}',
|
||||
};
|
||||
return Tooltip(
|
||||
message: 'Video shaders (Ctrl+1..6, Ctrl+0 off)',
|
||||
child: TextButton.icon(
|
||||
focusNode: focusNode,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
preset == ShaderPreset.off ? Colors.white : Colors.amberAccent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () => _open(context),
|
||||
icon: const Icon(Icons.auto_awesome, size: 16),
|
||||
label: Text(short, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _open(BuildContext context) async {
|
||||
final s = AppSettings.instance;
|
||||
final preset = s.shaderPreset.peek();
|
||||
final quality = s.shaderQuality.peek();
|
||||
final hasCustom = s.customShaders.peek().isNotEmpty;
|
||||
final button = context.findRenderObject() as RenderBox;
|
||||
final overlay =
|
||||
Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
|
||||
final position = RelativeRect.fromRect(
|
||||
Rect.fromPoints(
|
||||
button.localToGlobal(Offset.zero, ancestor: overlay),
|
||||
button.localToGlobal(button.size.bottomRight(Offset.zero),
|
||||
ancestor: overlay),
|
||||
),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
final software = renderMode.peek() == RenderMode.software;
|
||||
onMenuOpen?.call(true);
|
||||
final pick = await showMenu<ShaderPick>(
|
||||
context: context,
|
||||
position: position,
|
||||
popUpAnimationStyle: AnimationStyle.noAnimation,
|
||||
// Wider than Material's 280 px cap: the mode rows hold two chips.
|
||||
constraints: const BoxConstraints(minWidth: 340, maxWidth: 340),
|
||||
items: [
|
||||
if (software)
|
||||
const PopupMenuItem(
|
||||
enabled: false,
|
||||
height: 32,
|
||||
child: Text('Software rendering: shaders have no effect',
|
||||
style: TextStyle(fontSize: 12, color: Colors.orange)),
|
||||
),
|
||||
CheckedPopupMenuItem(
|
||||
value: (ShaderPreset.off, null),
|
||||
checked: preset == ShaderPreset.off,
|
||||
height: 36,
|
||||
child: const _Row(label: 'Off', keys: 'Ctrl+0'),
|
||||
),
|
||||
for (final (i, mode) in ShaderPreset.modes.indexed)
|
||||
_ModeEntry(
|
||||
mode: mode,
|
||||
keys: 'Ctrl+${i + 1}',
|
||||
current: preset == mode ? quality : null,
|
||||
),
|
||||
CheckedPopupMenuItem(
|
||||
value: (ShaderPreset.custom, null),
|
||||
checked: preset == ShaderPreset.custom,
|
||||
enabled: hasCustom,
|
||||
height: 36,
|
||||
child: Text(hasCustom
|
||||
? 'Custom shaders'
|
||||
: 'Custom shaders (pick files in Settings)'),
|
||||
),
|
||||
],
|
||||
);
|
||||
onMenuOpen?.call(false);
|
||||
if (pick != null) onPick(pick.$1, pick.$2);
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
final String label;
|
||||
final String keys;
|
||||
const _Row({required this.label, required this.keys});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: Text(label)),
|
||||
Text(keys, style: const TextStyle(fontSize: 11, color: Colors.grey)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One Anime4K mode: the row picks it with the saved quality, the two
|
||||
/// chips pick a quality explicitly.
|
||||
class _ModeEntry extends PopupMenuEntry<ShaderPick> {
|
||||
final ShaderPreset mode;
|
||||
final String keys;
|
||||
/// The active quality when this mode is the current one.
|
||||
final ShaderQuality? current;
|
||||
const _ModeEntry({
|
||||
required this.mode,
|
||||
required this.keys,
|
||||
required this.current,
|
||||
});
|
||||
|
||||
@override
|
||||
double get height => 36;
|
||||
|
||||
@override
|
||||
bool represents(ShaderPick? value) => value?.$1 == mode;
|
||||
|
||||
@override
|
||||
State<_ModeEntry> createState() => _ModeEntryState();
|
||||
}
|
||||
|
||||
class _ModeEntryState extends State<_ModeEntry> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final w = widget;
|
||||
final theme = Theme.of(context);
|
||||
void pick(ShaderQuality? q) => Navigator.of(context).pop((w.mode, q));
|
||||
return InkWell(
|
||||
onTap: () => pick(null),
|
||||
child: Container(
|
||||
height: w.height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: w.current != null
|
||||
? Icon(Icons.done, size: 20, color: theme.colorScheme.primary)
|
||||
: null,
|
||||
),
|
||||
SizedBox(width: 96, child: Text(w.mode.label)),
|
||||
for (final q in ShaderQuality.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: ChoiceChip(
|
||||
label: Text(q.label, style: const TextStyle(fontSize: 11)),
|
||||
selected: w.current == q,
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
labelPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
onSelected: (_) => pick(q),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(w.keys,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:io';
|
||||
|
||||
// Anime4K v4.0.1 presets, file for file as in Anime4K's mpv templates
|
||||
// (md/Template/GLSL_Mac_Linux_{High,Low}-end/input.conf): HQ = the
|
||||
// high-end GPU set, Fast = the low-end set.
|
||||
|
||||
/// Anime4K release the shipped files come from (assets/shaders/anime4k).
|
||||
const anime4kVersion = '4.0.1';
|
||||
|
||||
enum ShaderPreset {
|
||||
off('Off'),
|
||||
a('Mode A'),
|
||||
b('Mode B'),
|
||||
c('Mode C'),
|
||||
aa('Mode A+A'),
|
||||
bb('Mode B+B'),
|
||||
ca('Mode C+A'),
|
||||
custom('Custom shaders');
|
||||
|
||||
final String label;
|
||||
const ShaderPreset(this.label);
|
||||
|
||||
/// The six Anime4K modes, in Ctrl+1..Ctrl+6 order.
|
||||
static const modes = [a, b, c, aa, bb, ca];
|
||||
|
||||
bool get isAnime4k => modes.contains(this);
|
||||
}
|
||||
|
||||
enum ShaderQuality {
|
||||
hq('HQ'),
|
||||
fast('Fast');
|
||||
|
||||
final String label;
|
||||
const ShaderQuality(this.label);
|
||||
}
|
||||
|
||||
/// "Mode A (HQ)", "Off", "Custom shaders".
|
||||
String shaderLabel(ShaderPreset preset, ShaderQuality quality) =>
|
||||
preset.isAnime4k ? '${preset.label} (${quality.label})' : preset.label;
|
||||
|
||||
const _clamp = 'Anime4K_Clamp_Highlights.glsl';
|
||||
const _down = [
|
||||
'Anime4K_AutoDownscalePre_x2.glsl',
|
||||
'Anime4K_AutoDownscalePre_x4.glsl',
|
||||
];
|
||||
|
||||
/// Anime4K file names of [preset] in mpv order; empty for off/custom.
|
||||
List<String> anime4kFiles(ShaderPreset preset, ShaderQuality quality) {
|
||||
final hq = quality == ShaderQuality.hq;
|
||||
// Big/small network sizes: HQ uses VL then M, Fast uses M then S.
|
||||
final big = hq ? 'VL' : 'M';
|
||||
final small = hq ? 'M' : 'S';
|
||||
String restore(String size) => 'Anime4K_Restore_CNN_$size.glsl';
|
||||
String soft(String size) => 'Anime4K_Restore_CNN_Soft_$size.glsl';
|
||||
String up(String size) => 'Anime4K_Upscale_CNN_x2_$size.glsl';
|
||||
String denoise(String size) => 'Anime4K_Upscale_Denoise_CNN_x2_$size.glsl';
|
||||
return switch (preset) {
|
||||
ShaderPreset.off || ShaderPreset.custom => const [],
|
||||
ShaderPreset.a => [_clamp, restore(big), up(big), ..._down, up(small)],
|
||||
ShaderPreset.b => [_clamp, soft(big), up(big), ..._down, up(small)],
|
||||
ShaderPreset.c => [_clamp, denoise(big), ..._down, up(small)],
|
||||
ShaderPreset.aa => [
|
||||
_clamp, restore(big), up(big), restore(small), ..._down, up(small)
|
||||
],
|
||||
ShaderPreset.bb => [
|
||||
_clamp, soft(big), up(big), ..._down, soft(small), up(small)
|
||||
],
|
||||
ShaderPreset.ca => [
|
||||
_clamp, denoise(big), ..._down, restore(small), up(small)
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/// Every file some preset needs: exactly what assets/shaders/anime4k holds.
|
||||
List<String> get anime4kAllFiles => {
|
||||
for (final p in ShaderPreset.modes)
|
||||
for (final q in ShaderQuality.values) ...anime4kFiles(p, q),
|
||||
}.toList()
|
||||
..sort();
|
||||
|
||||
/// mpv's path-list separator: ';' on Windows, ':' elsewhere.
|
||||
String shaderListSeparator({bool? windows}) =>
|
||||
(windows ?? Platform.isWindows) ? ';' : ':';
|
||||
|
||||
/// The `glsl-shaders` value for [paths].
|
||||
String joinShaderPaths(List<String> paths, {bool? windows}) =>
|
||||
paths.join(shaderListSeparator(windows: windows));
|
||||
Reference in new issue
Block a user