petsHero-AI/lib/features/generate_video/generate_video_screen.dart
2026-03-15 12:02:36 +08:00

813 lines
24 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:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:video_player/video_player.dart';
import '../../core/auth/auth_service.dart';
import '../../core/log/app_logger.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_typography.dart';
import '../../core/user/account_refresh.dart';
import '../../core/user/user_state.dart';
import '../../features/home/models/task_item.dart';
import '../../shared/widgets/top_nav_bar.dart';
import '../../core/api/services/image_api.dart';
/// Generate Video screen - matches Pencil mmLB5
class GenerateVideoScreen extends StatefulWidget {
const GenerateVideoScreen({super.key, this.task});
static final _log = AppLogger('GenerateVideo');
final TaskItem? task;
@override
State<GenerateVideoScreen> createState() => _GenerateVideoScreenState();
}
enum _Resolution { p480, p720 }
class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
_Resolution _selectedResolution = _Resolution.p480;
bool _isGenerating = false;
int get _currentCredits {
final task = widget.task;
if (task == null) return 50;
switch (_selectedResolution) {
case _Resolution.p480:
return task.credits480p ?? 50;
case _Resolution.p720:
return task.credits720p ?? 50;
}
}
String get _heatmap =>
_selectedResolution == _Resolution.p480 ? '480p' : '720p';
bool get _hasVideo {
final url = widget.task?.previewVideoUrl;
return url != null && url.isNotEmpty;
}
@override
void initState() {
super.initState();
refreshAccount();
WidgetsBinding.instance.addPostFrameCallback((_) {
GenerateVideoScreen._log.d('opened with task: ${widget.task}');
});
}
/// Click flow per docs/generate_video.md: tap Generate Video -> image picker
/// (camera or gallery) -> after image selected -> proceed to API.
Future<void> _onGenerateButtonTap() async {
if (_isGenerating) return;
final userCredits = UserState.credits.value ?? 0;
if (userCredits < _currentCredits) {
if (mounted) {
Navigator.of(context).pushNamed('/recharge');
}
return;
}
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.image),
title: const Text('Choose from gallery'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
ListTile(
leading: const Icon(LucideIcons.camera),
title: const Text('Take photo'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
],
),
),
);
if (source == null || !mounted) return;
final picker = ImagePicker();
final picked = await picker.pickImage(
source: source,
imageQuality: 85,
);
if (picked == null || !mounted) return;
final file = File(picked.path);
await _runGenerationApi(file);
}
Future<void> _runGenerationApi(File file) async {
setState(() => _isGenerating = true);
try {
await AuthService.loginComplete;
final size = await file.length();
final ext = file.path.split('.').last.toLowerCase();
final contentType = ext == 'png'
? 'image/png'
: ext == 'gif'
? 'image/gif'
: 'image/jpeg';
final fileName = 'img_${DateTime.now().millisecondsSinceEpoch}.$ext';
final presignedRes = await ImageApi.getUploadPresignedUrl(
fileName1: fileName,
contentType: contentType,
expectedSize: size,
);
if (!presignedRes.isSuccess || presignedRes.data == null) {
throw Exception(presignedRes.msg.isNotEmpty
? presignedRes.msg
: 'Failed to get upload URL');
}
final data = presignedRes.data as Map<String, dynamic>;
final uploadUrl =
data['bolster'] as String? ?? data['expound'] as String?;
final filePath = data['recruit'] as String? ?? data['train'] as String?;
final requiredHeaders = data['remap'] as Map<String, dynamic>?;
if (uploadUrl == null ||
uploadUrl.isEmpty ||
filePath == null ||
filePath.isEmpty) {
throw Exception('Invalid presigned URL response');
}
final headers = <String, String>{};
if (requiredHeaders != null) {
for (final e in requiredHeaders.entries) {
headers[e.key] = e.value?.toString() ?? '';
}
}
if (!headers.containsKey('Content-Type')) {
headers['Content-Type'] = contentType;
}
final bytes = await file.readAsBytes();
final uploadResponse = await http.put(
Uri.parse(uploadUrl),
headers: headers,
body: bytes,
);
if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) {
throw Exception('Upload failed: ${uploadResponse.statusCode}');
}
final userId = UserState.userId.value;
if (userId == null || userId.isEmpty) {
throw Exception('User not logged in');
}
final createRes = await ImageApi.createTask(
asset: userId,
guild: filePath,
allowance: false,
cipher: widget.task?.taskType ?? '',
congregation: widget.task?.templateName ?? '',
heatmap: _heatmap,
ext: widget.task?.ext,
);
if (!createRes.isSuccess) {
throw Exception(
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create task');
}
final taskData = createRes.data as Map<String, dynamic>?;
final taskId = taskData?['tree'];
// 创建任务成功后刷新用户账户信息(积分等)
await refreshAccount();
if (!mounted) return;
Navigator.of(context).pushReplacementNamed(
'/progress',
arguments: <String, dynamic>{'taskId': taskId, 'imagePath': file.path},
);
} catch (e, st) {
GenerateVideoScreen._log.e('Generate failed', e, st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Generation failed: ${e.toString().replaceAll('Exception: ', '')}'),
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) {
setState(() => _isGenerating = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: TopNavBar(
title: 'Generate',
showBackButton: true,
onBack: () => Navigator.of(context).pop(),
),
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.screenPaddingLarge,
AppSpacing.screenPaddingLarge,
AppSpacing.screenPaddingLarge,
AppSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_CreditsCard(
credits:
UserCreditsData.of(context)?.creditsDisplay ?? '--',
),
const SizedBox(height: AppSpacing.xxl),
_VideoPreviewArea(
videoUrl: widget.task?.previewVideoUrl,
imageUrl: widget.task?.previewImageUrl,
),
],
),
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.screenPaddingLarge,
AppSpacing.lg,
AppSpacing.screenPaddingLarge,
AppSpacing.screenPaddingLarge,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_hasVideo)
_ResolutionToggle(
selected: _selectedResolution,
onChanged: (r) => setState(() => _selectedResolution = r),
),
if (_hasVideo) const SizedBox(height: AppSpacing.xxl),
_GenerateButton(
onGenerate: _onGenerateButtonTap,
isLoading: _isGenerating,
credits: _currentCredits.toString(),
),
],
),
),
),
],
),
);
}
}
class _CreditsCard extends StatelessWidget {
const _CreditsCard({required this.credits});
final String credits;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xxl,
vertical: AppSpacing.xl,
),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.5),
),
boxShadow: [
BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.25),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
const Icon(LucideIcons.sparkles, size: 28, color: AppColors.surface),
const SizedBox(width: AppSpacing.md),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Available Credits',
style: AppTypography.bodyRegular.copyWith(
color: AppColors.surface.withValues(alpha: 0.8),
),
),
Text(
credits,
style: AppTypography.bodyLarge.copyWith(
fontSize: 32,
fontWeight: FontWeight.w700,
color: AppColors.surface,
),
),
],
),
],
),
);
}
}
/// Video preview area - video URL from card click. Auto-load and play on init.
/// Video fit: contain (no crop). Loading animation until ready.
class _VideoPreviewArea extends StatefulWidget {
const _VideoPreviewArea({
this.videoUrl,
this.imageUrl,
});
final String? videoUrl;
final String? imageUrl;
@override
State<_VideoPreviewArea> createState() => _VideoPreviewAreaState();
}
class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
VideoPlayerController? _controller;
@override
void initState() {
super.initState();
if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) {
_loadAndPlay();
}
}
@override
void dispose() {
_controller?.dispose();
_controller = null;
super.dispose();
}
@override
void didUpdateWidget(covariant _VideoPreviewArea oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.videoUrl != widget.videoUrl) {
_controller?.dispose();
_controller = null;
if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) {
_loadAndPlay();
}
}
}
Future<void> _loadAndPlay() async {
final url = widget.videoUrl;
if (url == null || url.isEmpty) return;
setState(() {});
try {
final file = await DefaultCacheManager().getSingleFile(url);
if (!mounted) return;
final controller = VideoPlayerController.file(file);
await controller.initialize();
if (!mounted) return;
await controller.play();
controller.setLooping(true);
if (mounted) {
setState(() {
_controller = controller;
});
}
} catch (e) {
GenerateVideoScreen._log.e('Video load failed', e);
if (mounted) setState(() {});
}
}
@override
Widget build(BuildContext context) {
final isReady = _controller != null && _controller!.value.isInitialized;
final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty;
final hasImage = widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
// 图片模式:宽度=组件宽度,高度按图片宽高比自适应
if (!hasVideo && hasImage) {
return LayoutBuilder(
builder: (context, constraints) {
return Container(
width: constraints.maxWidth,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.border,
width: 1,
),
),
clipBehavior: Clip.antiAlias,
child: _AspectRatioImage(
imageUrl: widget.imageUrl!,
maxWidth: constraints.maxWidth,
),
);
},
);
}
// 视频模式aspect ratio 来自视频或 16:9 占位
final aspectRatio = isReady &&
_controller!.value.size.width > 0 &&
_controller!.value.size.height > 0
? _controller!.value.size.width / _controller!.value.size.height
: 16 / 9;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = width / aspectRatio;
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.border,
width: 1,
),
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
if (isReady)
SizedBox.expand(
child: FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width: _controller!.value.size.width,
height: _controller!.value.size.height,
child: VideoPlayer(_controller!),
),
),
)
else if (hasImage)
CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) =>
const _LoadingOverlay(isLoading: true),
errorWidget: (_, __, ___) =>
const _LoadingOverlay(isLoading: false),
)
else
const _LoadingOverlay(isLoading: false),
if (hasVideo && !isReady)
const Positioned.fill(
child: _LoadingOverlay(isLoading: true),
),
],
),
);
},
);
}
}
/// 图片展示:宽度=组件宽度,高度按图片宽高比自适应
class _AspectRatioImage extends StatefulWidget {
const _AspectRatioImage({
required this.imageUrl,
required this.maxWidth,
});
final String imageUrl;
final double maxWidth;
@override
State<_AspectRatioImage> createState() => _AspectRatioImageState();
}
class _AspectRatioImageState extends State<_AspectRatioImage> {
double? _aspectRatio;
ImageStream? _stream;
late ImageStreamListener _listener;
@override
void initState() {
super.initState();
_listener = ImageStreamListener(_onImageLoaded, onError: _onImageError);
_resolveImage();
}
@override
void didUpdateWidget(covariant _AspectRatioImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.imageUrl != widget.imageUrl) {
_stream?.removeListener(_listener);
_aspectRatio = null;
_resolveImage();
}
}
void _resolveImage() {
final provider = CachedNetworkImageProvider(widget.imageUrl);
_stream = provider.resolve(const ImageConfiguration());
_stream!.addListener(_listener);
}
void _onImageLoaded(ImageInfo info, bool sync) {
if (!mounted) return;
final w = info.image.width.toDouble();
final h = info.image.height.toDouble();
if (w > 0 && h > 0) {
setState(() => _aspectRatio = w / h);
}
}
void _onImageError(dynamic exception, StackTrace? stackTrace) {
if (mounted) setState(() => _aspectRatio = 1);
}
@override
void dispose() {
_stream?.removeListener(_listener);
super.dispose();
}
@override
Widget build(BuildContext context) {
final ratio = _aspectRatio ?? 1;
final height = widget.maxWidth / ratio;
return SizedBox(
width: widget.maxWidth,
height: height,
child: CachedNetworkImage(
imageUrl: widget.imageUrl,
fit: BoxFit.contain,
width: widget.maxWidth,
height: height,
placeholder: (_, __) => SizedBox(
width: widget.maxWidth,
height: widget.maxWidth,
child: const _LoadingOverlay(isLoading: true),
),
errorWidget: (_, __, ___) => SizedBox(
width: widget.maxWidth,
height: widget.maxWidth,
child: const _LoadingOverlay(isLoading: false),
),
),
);
}
}
class _LoadingOverlay extends StatelessWidget {
const _LoadingOverlay({this.isLoading = true});
final bool isLoading;
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.surfaceAlt,
alignment: Alignment.center,
child: isLoading
? const SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
)
: const Icon(
LucideIcons.video,
size: 48,
color: AppColors.textMuted,
),
);
}
}
/// Resolution row - 1:1 match Pencil oYwsm: label + 480P/720P on same row.
/// Button: height 36, cornerRadius 18, padding 8x16, gap 12 label-to-btns, gap 8 between btns.
class _ResolutionToggle extends StatelessWidget {
const _ResolutionToggle({
required this.selected,
required this.onChanged,
});
final _Resolution selected;
final ValueChanged<_Resolution> onChanged;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Resolution',
style: AppTypography.bodyMedium.copyWith(
fontSize: 14,
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 12),
_ResolutionOption(
label: '480P',
isSelected: selected == _Resolution.p480,
onTap: () => onChanged(_Resolution.p480),
),
const SizedBox(width: 8),
_ResolutionOption(
label: '720P',
isSelected: selected == _Resolution.p720,
onTap: () => onChanged(_Resolution.p720),
),
],
),
);
}
}
class _ResolutionOption extends StatelessWidget {
const _ResolutionOption({
required this.label,
required this.isSelected,
required this.onTap,
});
final String label;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(18),
border:
isSelected ? null : Border.all(color: AppColors.border, width: 1),
boxShadow: isSelected
? [
BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.19),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: null,
),
alignment: Alignment.center,
child: Text(
label,
style: AppTypography.bodyMedium.copyWith(
fontSize: 13,
color: isSelected ? AppColors.surface : AppColors.textSecondary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
),
);
}
}
class _GenerateButton extends StatelessWidget {
const _GenerateButton({
required this.onGenerate,
this.isLoading = false,
this.credits = '50',
});
final VoidCallback onGenerate;
final bool isLoading;
final String credits;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: isLoading ? null : onGenerate,
child: Opacity(
opacity: isLoading ? 0.6 : 1,
child: Container(
height: 56,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.25),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isLoading)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.surface,
),
)
else
Text(
'Generate',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.surface,
),
),
if (!isLoading) ...[
const SizedBox(width: AppSpacing.md),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: AppColors.surface.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
LucideIcons.sparkles,
size: 16,
color: AppColors.surface,
),
const SizedBox(width: AppSpacing.xs),
Text(
credits,
style: AppTypography.bodyRegular.copyWith(
color: AppColors.surface,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
],
),
),
),
);
}
}