petsHero-AI/lib/features/generate_video/generate_video_screen.dart
2026-03-29 20:41:45 +08:00

671 lines
20 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_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:http/http.dart' as http;
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/home_playback_resume.dart';
import '../../features/home/models/task_item.dart';
import '../../shared/widgets/top_nav_bar.dart';
import '../../core/api/services/image_api.dart';
import 'widgets/album_picker_sheet.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}');
});
}
@override
void dispose() {
// 从本页 pop含关闭相册后再返回首页底层首页需重新触发可见性仅 [RouteAware.didPopNext] 有时序不足
homePlaybackResumeNonce.value++;
super.dispose();
}
/// 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 path = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => SizedBox(
height: MediaQuery.sizeOf(ctx).height * 0.92,
child: const AlbumPickerSheet(),
),
);
if (path == null || path.isEmpty || !mounted) return;
final file = File(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 templateName = widget.task?.templateName ?? '';
final createRes = await ImageApi.createTask(
asset: userId,
guild: filePath,
allowance: false,
cipher: widget.task?.taskType ?? '',
congregation: templateName == 'BananaTask' ? null : 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) {
final topInset = MediaQuery.paddingOf(context).top;
final creditsDisplay =
UserCreditsData.of(context)?.creditsDisplay ?? '--';
return Scaffold(
extendBodyBehindAppBar: true,
backgroundColor: Colors.black,
appBar: PreferredSize(
preferredSize: Size.fromHeight(topInset + 56),
child: Container(
padding: EdgeInsets.only(top: topInset),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.72),
Colors.black.withValues(alpha: 0.35),
Colors.black.withValues(alpha: 0.0),
],
stops: const [0.0, 0.55, 1.0],
),
),
child: SizedBox(
height: 56,
child: TopNavBar(
title: 'Generate',
credits: creditsDisplay,
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
showBackButton: true,
onBack: () => Navigator.of(context).pop(),
backgroundColor: Colors.transparent,
foregroundColor: AppColors.surface,
),
),
),
),
body: Stack(
fit: StackFit.expand,
children: [
_GenerateFullScreenBackground(
videoUrl: widget.task?.previewVideoUrl,
imageUrl: widget.task?.previewImageUrl,
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.12),
Colors.black.withValues(alpha: 0.55),
],
stops: const [0.45, 1.0],
),
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.screenPaddingLarge,
AppSpacing.lg,
AppSpacing.screenPaddingLarge,
AppSpacing.screenPaddingLarge,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(),
if (_hasVideo)
Center(
child: _ResolutionToggle(
selected: _selectedResolution,
onChanged: (r) =>
setState(() => _selectedResolution = r),
),
),
if (_hasVideo) const SizedBox(height: AppSpacing.xxl),
_GenerateButton(
onGenerate: _onGenerateButtonTap,
isLoading: _isGenerating,
credits: _currentCredits.toString(),
),
],
),
),
),
],
),
);
}
}
/// 列表传入的预览图/视频:全屏背景层,超出视口 [BoxFit.cover] 裁切。
class _GenerateFullScreenBackground extends StatefulWidget {
const _GenerateFullScreenBackground({
this.videoUrl,
this.imageUrl,
});
final String? videoUrl;
final String? imageUrl;
@override
State<_GenerateFullScreenBackground> createState() =>
_GenerateFullScreenBackgroundState();
}
class _GenerateFullScreenBackgroundState
extends State<_GenerateFullScreenBackground> {
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 _GenerateFullScreenBackground 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,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
await controller.initialize();
if (!mounted) return;
await controller.setVolume(1.0);
await controller.setLooping(true);
await controller.play();
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;
return Stack(
fit: StackFit.expand,
children: [
if (!hasVideo && hasImage)
Positioned.fill(
child: CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) => const _BgLoadingPlaceholder(),
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
),
)
else if (hasVideo && isReady)
Positioned.fill(
child: ClipRect(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _controller!.value.size.width,
height: _controller!.value.size.height,
child: VideoPlayer(_controller!),
),
),
),
)
else if (hasVideo && hasImage)
Positioned.fill(
child: CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) => const _BgLoadingPlaceholder(),
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
),
)
else if (hasVideo)
const Positioned.fill(child: _BgLoadingPlaceholder())
else
const Positioned.fill(child: _BgErrorPlaceholder()),
],
);
}
}
class _BgLoadingPlaceholder extends StatelessWidget {
const _BgLoadingPlaceholder();
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.textPrimary,
alignment: Alignment.center,
child: const SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.surface,
),
),
);
}
}
class _BgErrorPlaceholder extends StatelessWidget {
const _BgErrorPlaceholder();
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.textPrimary,
alignment: Alignment.center,
child: Icon(
LucideIcons.image_off,
size: 56,
color: AppColors.surface.withValues(alpha: 0.45),
),
);
}
}
/// 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.surface,
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.primaryGlass
: AppColors.surface.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(18),
border: isSelected
? Border.all(
color: AppColors.primary.withValues(alpha: 0.42),
width: 1)
: Border.all(
color: AppColors.surface.withValues(alpha: 0.45),
width: 1,
),
boxShadow: isSelected
? [
BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.22),
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.surface.withValues(alpha: 0.85),
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.primaryGlassEmphasis,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.45),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.28),
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,
),
),
],
),
),
],
],
),
),
),
);
}
}