import 'dart:async'; import 'dart:io'; import 'dart:math' show max; import 'package:cached_network_image/cached_network_image.dart'; import 'package:client_proxy_framework/client_proxy_framework.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:image_picker/image_picker.dart'; import 'package:video_player/video_player.dart'; import '../../core/app_env.dart'; import '../../core/user/user_state.dart'; import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; import 'generate_progress_screen.dart'; /// `EYsUi` 生成图片页 — 左 112×108 槽位 +「=」+ 右侧效果图;分辨率在「开始生成」上方横排。 /// [ExtConfigItem.imgNeed] == 2 时需两张图并上传;其余(含视频类模板)只传一张,请在配置里将视频项 `img_need` 设为 1。 /// [template] 来自首页选中的 [ExtConfigItem]。 class GenerateScreen extends StatefulWidget { const GenerateScreen({super.key, this.template}); final ExtConfigItem? template; @override State createState() => _GenerateScreenState(); } class _GenerateScreenState extends State { final _picker = ImagePicker(); File? _picked; File? _picked2; /// 对应 create-task 逻辑字段 `size`(换皮 `seminar`),如 480p / 720p。 String _outputSize = '720p'; bool _busy = false; VideoPlayerController? _previewVideo; bool _previewVideoFailed = false; static const double _slotW = 112; static const double _slotH = 108; static const double _previewH = 359; static const double _previewOuterR = 20; static const double _previewBorderW = 1.5; File? get _primaryFile => _picked ?? _picked2; /// 双图:仅当扩展配置 `img_need == 2`(与 [ExtConfigItem.imgNeed] 一致)。 bool get _needTwoImages => widget.template?.imgNeed == 2; /// 与 [app_client] `ImageApi.createTask` 的 `congregation` 一致:值为 [ExtConfigItem.templateName], /// 缺省(纯本地 extConfig)时用 [ExtConfigItem.title];`BananaTask` 不传。 String? get _templateNameForCreateTask { final tpl = widget.template; if (tpl == null) return null; final tn = tpl.templateName?.trim(); final raw = (tn != null && tn.isNotEmpty ? tn : tpl.title.trim()); if (raw.isEmpty || raw == 'BananaTask') return null; return raw; } /// 任务类型:FunyMee 文档/换皮为逻辑 `taskType`(V2 `liaison`);优先 [ExtConfigItem.taskType],再 [params]/[detail]。 String? get _taskTypeForCreateTask { final tt = widget.template?.taskType?.trim(); if (tt != null && tt.isNotEmpty) return tt; final p = widget.template?.params?.trim(); if (p != null && p.isNotEmpty) return p; final d = widget.template?.detail?.trim(); return (d != null && d.isNotEmpty) ? d : null; } /// 对应 `ext`(profile):仅当 params 与 detail 同时存在时传 detail,避免与 taskType 重复。 String? get _extForCreateTask { final p = widget.template?.params?.trim(); final d = widget.template?.detail?.trim(); if (p != null && p.isNotEmpty && d != null && d.isNotEmpty) return d; return null; } Future _pickSlot(int slot) async { if (!mounted) return; final source = await _showPickImageSourceSheet(context); if (source == null || !mounted) return; final x = await _picker.pickImage( source: source, imageQuality: 92, ); if (x == null || !mounted) return; setState(() { if (slot == 0) { _picked = File(x.path); } else { _picked2 = File(x.path); } }); } Future _showPickImageSourceSheet(BuildContext context) { return showModalBottomSheet( context: context, backgroundColor: Colors.transparent, builder: (ctx) { return Container( decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(16)), boxShadow: [ BoxShadow( color: Color(0x20000000), blurRadius: 16, offset: Offset(0, -4), ), ], ), child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 12), Container( width: 40, height: 4, decoration: BoxDecoration( color: PencilTheme.genNavBackStroke, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 8), ListTile( leading: Icon( Icons.camera_alt_outlined, color: PencilTheme.profileAvatarIcon, ), title: Text( 'Camera', style: GoogleFonts.inter( fontSize: 16, fontWeight: FontWeight.w600, color: PencilTheme.stone900, ), ), onTap: () => Navigator.pop(ctx, ImageSource.camera), ), ListTile( leading: Icon( Icons.photo_library_outlined, color: PencilTheme.profileAvatarIcon, ), title: Text( 'Photo library', style: GoogleFonts.inter( fontSize: 16, fontWeight: FontWeight.w600, color: PencilTheme.stone900, ), ), onTap: () => Navigator.pop(ctx, ImageSource.gallery), ), const Divider(height: 1), ListTile( title: Text( 'Cancel', textAlign: TextAlign.center, style: GoogleFonts.inter( fontWeight: FontWeight.w600, color: PencilTheme.stone600, ), ), onTap: () => Navigator.pop(ctx), ), ], ), ), ); }, ); } Future _start() async { final uid = UserState.userId.value; if (uid == null || uid.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Please sign in first.')), ); return; } if (_needTwoImages) { if (_picked == null || _picked2 == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Please select two images first.')), ); return; } } else { final file = _primaryFile; if (file == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Please select an image first.')), ); return; } } setState(() => _busy = true); try { final ImagePresignedUploadCreateTaskResult result; if (_needTwoImages) { result = await ImagePresignedUploadCreateTaskFlow.runTwoSourceFiles( sourceFile1: _picked!, sourceFile2: _picked2!, userId: uid, size: _outputSize, taskType: _taskTypeForCreateTask, templateName: _templateNameForCreateTask, ext: _extForCreateTask, compressFirst: true, compressOptions: const CompressImageForUploadOptions( maxSide: 1024, jpegQuality: 75, ), saveLocalUploadCover: true, ); } else { result = await ImagePresignedUploadCreateTaskFlow.run( sourceFile: _primaryFile!, userId: uid, size: _outputSize, taskType: _taskTypeForCreateTask, templateName: _templateNameForCreateTask, ext: _extForCreateTask, compressFirst: true, compressOptions: const CompressImageForUploadOptions( maxSide: 1024, jpegQuality: 75, ), saveLocalUploadCover: true, ); } final taskId = result.createResponse.taskId; if (taskId == null || taskId.isEmpty) { throw StateError('No task id'); } await UserAccountRefresh.fetchAndNotify( app: currentBackendAppType(), userId: uid, onAccount: (a) { if (a.credits != null) UserState.setCredits(a.credits!); }, ); if (!mounted) return; Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) => GenerateProgressScreen( taskId: taskId, localPreviewPath: result.fileUsedForUpload.path, ), ), ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('$e')), ); } } finally { if (mounted) setState(() => _busy = false); } } /// 与当前 [_outputSize] 一致;优先 [ExtConfigItem.cost480p] / [ExtConfigItem.cost720p], /// 否则用 [ExtConfigItem.cost] 作 720p,480p 按半档估算。 /// 与 [HomeScreen] `_HomeItemVideoBackground`:优先 [ExtConfigItem.videoUrl],否则用 `image`。 String? _previewPlayUrl(ExtConfigItem? t) { if (t == null) return null; final v = t.videoUrl?.trim(); if (v != null && v.isNotEmpty) return v; final u = t.image.trim(); return u.isEmpty ? null : u; } bool get _previewIsVideo { final u = _previewPlayUrl(widget.template); return u != null && _urlLooksLikeVideo(u); } Future _pauseThenDispose(VideoPlayerController c) async { try { await c.pause(); } catch (_) {} try { await c.dispose(); } catch (_) {} } void _disposePreviewVideo() { final c = _previewVideo; _previewVideo = null; _previewVideoFailed = false; if (c != null) { unawaited(_pauseThenDispose(c)); } } Future _tryInitPreviewVideo() async { final playUrl = _previewPlayUrl(widget.template); if (playUrl == null || !_urlLooksLikeVideo(playUrl)) return; final uri = Uri.tryParse(playUrl); if (uri == null) { if (mounted) setState(() => _previewVideoFailed = true); return; } final c = VideoPlayerController.networkUrl(uri) ..setLooping(true) ..setVolume(1); try { await c.initialize(); await c.play(); if (!mounted) { await c.dispose(); return; } setState(() => _previewVideo = c); } catch (_) { await c.dispose(); if (mounted) setState(() => _previewVideoFailed = true); } } @override void initState() { super.initState(); unawaited(_tryInitPreviewVideo()); } @override void didUpdateWidget(covariant GenerateScreen oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.template?.videoUrl != widget.template?.videoUrl || oldWidget.template?.image != widget.template?.image) { _disposePreviewVideo(); unawaited(_tryInitPreviewVideo()); } } @override void dispose() { _disposePreviewVideo(); super.dispose(); } int get _estimatedCost { const fallback = 20; final t = widget.template; if (t == null) return fallback; int effective720() { if (t.cost720p != null && t.cost720p! > 0) return t.cost720p!; if (t.cost > 0) return t.cost; return fallback; } if (_outputSize == '480p') { if (t.cost480p != null && t.cost480p! > 0) return t.cost480p!; final h = effective720(); return max(1, (h / 2).round()); } final c = effective720(); return c > 0 ? c : fallback; } @override Widget build(BuildContext context) { final credits = UserState.credits.value; return Container( decoration: const BoxDecoration( gradient: PencilTheme.yellowWhitePageGradient, ), child: Scaffold( backgroundColor: Colors.transparent, body: SafeArea( bottom: false, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(2, 0, 14, 10), child: SizedBox( height: 56, child: Row( children: [ PencilRoundBackButton( onPressed: () => Navigator.of(context).pop(), ), Expanded( child: Center( child: Text( 'Generate', style: GoogleFonts.inter( fontSize: 19, fontWeight: FontWeight.w700, fontStyle: FontStyle.italic, color: PencilTheme.stone900, ), ), ), ), const SizedBox(width: 44), ], ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: _hintBox(), ), const SizedBox(height: 8), Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(16, 6, 16, 4), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ _needTwoImages ? _leftTwoSlotsColumn() : _leftSingleSlotColumn(), const SizedBox(width: 14), _equalsColumn(), const SizedBox(width: 14), Expanded(child: _effectPreviewPanel()), ], ), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 28), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _resoChip( '480p', _outputSize == '480p', () => setState(() => _outputSize = '480p'), ), const SizedBox(width: 12), _resoChip( '720p', _outputSize == '720p', () => setState(() => _outputSize = '720p'), ), ], ), const SizedBox(height: 12), FilledButton( style: FilledButton.styleFrom( backgroundColor: PencilTheme.underlineGold, foregroundColor: Colors.white, minimumSize: const Size.fromHeight(54), padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), ), onPressed: _busy ? null : _start, child: _busy ? const SizedBox( height: 22, width: 22, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : Text( 'Start', style: GoogleFonts.inter( fontSize: 16, fontWeight: FontWeight.w700, ), ), ), const SizedBox(height: 12), Text( 'Est. cost · $_estimatedCost credits', textAlign: TextAlign.center, style: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w600, color: PencilTheme.stone600, ), ), const SizedBox(height: 8), Text( 'Balance · ${credits.toStringAsFixed(2)}', style: GoogleFonts.inter( fontSize: 12, color: PencilTheme.inkSoft, ), ), ], ), ), ], ), ), ), ); } /// 设计稿 Lcol:114 宽,双槽 112×108,中间 32 高加号。 Widget _leftTwoSlotsColumn() { return SizedBox( width: 114, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ _imageSlot(slotIndex: 0, label: 'Image 1'), const SizedBox(height: 10), SizedBox( height: 32, child: Center( child: Icon( Icons.add, size: 24, color: PencilTheme.profileCredits, ), ), ), const SizedBox(height: 10), _imageSlot(slotIndex: 1, label: 'Image 2'), ], ), ); } /// 单图:仅 `img_need != 2` 或视频菜单开启时;槽位尺寸与设计一致。 Widget _leftSingleSlotColumn() { return SizedBox( width: 114, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ _imageSlot(slotIndex: 0, label: 'Image 1'), ], ), ); } Widget _equalsColumn() { return SizedBox( width: 32, child: Center( child: Text( '=', style: GoogleFonts.inter( fontSize: 26, fontWeight: FontWeight.w800, color: PencilTheme.profileCredits, ), ), ), ); } /// 设计稿 prevBx:高 359,圆角 20,描边 #FBBF24。 /// 描边叠在图片之上(否则子组件会先盖住 decoration 内缘的线)。 Widget _effectPreviewPanel() { final t = widget.template; final url = t?.image.trim() ?? ''; final fix = t?.imageFix?.trim(); final innerR = _previewOuterR - _previewBorderW; return SizedBox( height: _previewH, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(_previewOuterR), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.08), blurRadius: 24, offset: const Offset(0, 8), ), ], ), clipBehavior: Clip.none, child: Stack( fit: StackFit.expand, children: [ ClipRRect( borderRadius: BorderRadius.circular(innerR), child: _buildPreviewMediaLayer(url, fix), ), Positioned.fill( child: IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(_previewOuterR), border: Border.all( color: PencilTheme.profileAvatarRing, width: _previewBorderW, ), ), ), ), ), ], ), ), ); } /// 视频预览:与 [GenerateResultScreen] / 首页背景一致,循环播放;失败则回退静态图。 Widget _buildPreviewMediaLayer(String url, String? fix) { if (_previewIsVideo) { if (_previewVideoFailed) { return _buildPreviewStaticImageLayer(url, fix); } final c = _previewVideo; if (c == null || !c.value.isInitialized) { return _previewPlaceholder(loading: true); } return LayoutBuilder( builder: (context, constraints) { final cw = constraints.maxWidth; final ch = constraints.maxHeight; final w = c.value.size.width; final h = c.value.size.height; if (w <= 0 || h <= 0 || !cw.isFinite || !ch.isFinite || cw <= 0 || ch <= 0) { return _previewPlaceholder(loading: true); } return Stack( fit: StackFit.expand, children: [ Positioned.fill( child: ClipRect( child: SizedBox( width: cw, height: ch, child: FittedBox( fit: BoxFit.cover, alignment: Alignment.center, clipBehavior: Clip.none, child: SizedBox( width: w, height: h, child: VideoPlayer(c), ), ), ), ), ), ], ); }, ); } return _buildPreviewStaticImageLayer(url, fix); } Widget _buildPreviewStaticImageLayer(String url, String? fix) { if (url.isNotEmpty) { return CachedNetworkImage( imageUrl: url, fit: BoxFit.cover, width: double.infinity, height: _previewH, placeholder: (_, _) => _previewPlaceholder(loading: true), errorWidget: (_, _, _) { if (fix != null && fix.isNotEmpty) { return CachedNetworkImage( imageUrl: fix, fit: BoxFit.cover, width: double.infinity, height: _previewH, errorWidget: (_, _, _) => _previewPlaceholder(), ); } return _previewPlaceholder(); }, ); } if (fix != null && fix.isNotEmpty) { return CachedNetworkImage( imageUrl: fix, fit: BoxFit.cover, width: double.infinity, height: _previewH, errorWidget: (_, _, _) => _previewPlaceholder(), ); } return _previewPlaceholder(); } Widget _previewPlaceholder({bool loading = false}) { return Container( color: PencilTheme.cardThumbBg, alignment: Alignment.center, child: loading ? SizedBox( width: 28, height: 28, child: CircularProgressIndicator( strokeWidth: 2, color: PencilTheme.profileAvatarIcon.withValues(alpha: 0.8), ), ) : Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.auto_awesome_outlined, size: 40, color: PencilTheme.stone600.withValues(alpha: 0.5), ), const SizedBox(height: 8), Text( 'Preview', style: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w600, color: PencilTheme.stone600.withValues(alpha: 0.75), ), ), ], ), ); } Widget _imageSlot({required int slotIndex, required String label}) { final file = slotIndex == 0 ? _picked : _picked2; return SizedBox( width: _slotW, height: _slotH, child: Material( color: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: const BorderSide( color: PencilTheme.genSlotBorder, width: 1.5, ), ), child: InkWell( onTap: () => _pickSlot(slotIndex), borderRadius: BorderRadius.circular(16), child: file == null ? Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.add_photo_alternate_outlined, color: PencilTheme.profileAvatarIcon, size: 26, ), const SizedBox(height: 6), Text( label, style: GoogleFonts.inter( fontSize: 11, fontWeight: FontWeight.w700, color: PencilTheme.stone600, ), ), ], ) : ClipRRect( borderRadius: BorderRadius.circular(14), child: Image.file(file, fit: BoxFit.cover), ), ), ), ); } Widget _hintBox() { return Container( padding: const EdgeInsets.fromLTRB(18, 14, 18, 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: PencilTheme.genHintBorder), boxShadow: [ BoxShadow( color: const Color(0x30CA8A04), blurRadius: 20, offset: const Offset(0, 6), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.auto_awesome, size: 18, color: PencilTheme.profileAvatarIcon), const SizedBox(width: 8), Text( 'Upload tips', style: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w700, color: PencilTheme.genHintTitle, ), ), ], ), const SizedBox(height: 8), Text( 'Upload JPG or PNG (≤ 5 MB each, up to 2). You can use the camera or photo library. Use clear, front-facing photos when possible.', style: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w500, height: 1.45, color: PencilTheme.stone600, ), ), ], ), ); } Widget _resoChip(String t, bool on, VoidCallback fn) { return Material( color: on ? PencilTheme.underlineGold.withValues(alpha: 0.2) : Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide( color: on ? PencilTheme.underlineGold : PencilTheme.genNavBackStroke, ), ), child: InkWell( onTap: fn, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: Text( t, style: GoogleFonts.inter( fontWeight: FontWeight.w600, color: PencilTheme.stone900, ), ), ), ), ); } } bool _urlLooksLikeVideo(String url) { if (url.isEmpty) return false; final lower = url.toLowerCase(); const hints = ['.mp4', '.m3u8', '.webm', '.mov', '.mkv', '.avi']; return hints.any((h) => lower.contains(h)); }