FunyMeeAI/lib/features/generate/generate_screen.dart
2026-04-10 15:36:08 +08:00

868 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<GenerateScreen> createState() => _GenerateScreenState();
}
class _GenerateScreenState extends State<GenerateScreen> {
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<void> _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<ImageSource?> _showPickImageSourceSheet(BuildContext context) {
return showModalBottomSheet<ImageSource>(
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<void> _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<void>(
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] 作 720p480p 按半档估算。
/// 与 [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<void> _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<void> _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,
),
),
],
),
),
],
),
),
),
);
}
/// 设计稿 Lcol114 宽,双槽 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));
}