petsHero-AI/lib/features/generate_video/generate_video_screen.dart

860 lines
26 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 'dart:typed_data';
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:image_picker/image_picker.dart';
import 'package:video_player/video_player.dart';
import '../../core/auth/auth_service.dart';
import '../../core/util/image_compress.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/gallery/video_thumbnail_cache.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 'in_app_camera_page.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;
/// 防止在选图/相机流程未结束时再次进入,避免并发 native picker / ScreenSecure 错乱
bool _isPickingMedia = false;
/// 应用内相机打开时暂停背景 [VideoPlayer],避免与 CameraPreview 争用 Surface
bool _suspendBackgroundVideo = 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 || _isPickingMedia) return;
final userCredits = UserState.credits.value ?? 0;
if (userCredits < _currentCredits) {
if (mounted) {
Navigator.of(context).pushNamed('/recharge');
}
return;
}
_isPickingMedia = true;
if (mounted) setState(() {});
try {
await AuthService.runWithNativeMediaPicker(() async {
final path = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
),
child: const AlbumPickerSheet(),
),
);
if (!mounted) return;
if (path == kAlbumPickerRequestGallery) {
final picker = ImagePicker();
final x = await picker.pickImage(source: ImageSource.gallery);
if (!mounted) return;
if (x != null && x.path.isNotEmpty) {
final f = File(x.path);
if (await f.exists()) {
await _runGenerationApi(f);
}
}
return;
}
if (path == kAlbumPickerRequestCamera) {
// 应用内相机:推全屏页前暂停背景视频,返回后短暂延迟再恢复,降低 GPU 切换毛刺
setState(() => _suspendBackgroundVideo = true);
await Future<void>.delayed(const Duration(milliseconds: 80));
if (!mounted) return;
final capturedPath = await Navigator.of(context, rootNavigator: true)
.push<String>(
MaterialPageRoute<String>(
fullscreenDialog: true,
builder: (context) => const InAppCameraPage(),
),
);
if (mounted) {
await Future<void>.delayed(const Duration(milliseconds: 150));
if (mounted) {
setState(() => _suspendBackgroundVideo = false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
}
}
if (!mounted) return;
if (capturedPath != null && capturedPath.isNotEmpty) {
final f = File(capturedPath);
if (await f.exists()) {
await _runGenerationApi(f);
}
}
return;
}
if (path == null || path.isEmpty) return;
final file = File(path);
await _runGenerationApi(file);
});
} finally {
_isPickingMedia = false;
if (mounted) setState(() {});
}
}
Future<void> _runGenerationApi(File file) async {
setState(() => _isGenerating = true);
try {
await AuthService.loginComplete;
final toUpload = await compressImageForUpload(
file,
maxSide: 1024,
jpegQuality: 75,
);
final size = await toUpload.length();
final ext = toUpload.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 toUpload.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: [
_GenerateBackgroundLayer(
videoUrl: widget.task?.previewVideoUrl,
imageUrl: widget.task?.previewImageUrl,
suspendVideoPlayback: _suspendBackgroundVideo,
),
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 || _isPickingMedia,
credits: _currentCredits.toString(),
),
],
),
),
),
],
),
);
}
}
/// 生成页背景:有预览视频则循环播放;打开应用内相机时暂停并显示静帧,避免与 CameraPreview 冲突。
class _GenerateBackgroundLayer extends StatefulWidget {
const _GenerateBackgroundLayer({
this.videoUrl,
this.imageUrl,
this.suspendVideoPlayback = false,
});
final String? videoUrl;
final String? imageUrl;
final bool suspendVideoPlayback;
@override
State<_GenerateBackgroundLayer> createState() =>
_GenerateBackgroundLayerState();
}
class _GenerateBackgroundLayerState extends State<_GenerateBackgroundLayer> {
VideoPlayerController? _controller;
int _videoLoadGen = 0;
Uint8List? _videoPosterBytes;
bool _videoPosterLoading = false;
void _bumpVideoLoadGen() {
_videoLoadGen++;
}
@override
void initState() {
super.initState();
_loadVideoPosterIfNeeded();
if (!widget.suspendVideoPlayback &&
widget.videoUrl != null &&
widget.videoUrl!.isNotEmpty) {
_loadAndPlay();
}
}
@override
void dispose() {
_bumpVideoLoadGen();
_controller?.dispose();
_controller = null;
super.dispose();
}
@override
void didUpdateWidget(covariant _GenerateBackgroundLayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.videoUrl != widget.videoUrl ||
oldWidget.imageUrl != widget.imageUrl) {
_videoPosterBytes = null;
_videoPosterLoading = false;
_loadVideoPosterIfNeeded();
_bumpVideoLoadGen();
_controller?.dispose();
_controller = null;
if (widget.videoUrl != null &&
widget.videoUrl!.isNotEmpty &&
!widget.suspendVideoPlayback) {
_loadAndPlay();
} else if (mounted) {
setState(() {});
}
return;
}
if (widget.suspendVideoPlayback != oldWidget.suspendVideoPlayback) {
if (widget.suspendVideoPlayback) {
_bumpVideoLoadGen();
_controller?.pause();
_controller?.dispose();
_controller = null;
if (mounted) setState(() {});
} else if (widget.videoUrl != null &&
widget.videoUrl!.isNotEmpty) {
_loadAndPlay();
}
}
}
Future<void> _loadVideoPosterIfNeeded() async {
final v = widget.videoUrl;
final img = widget.imageUrl;
if (v == null || v.isEmpty) return;
if (img != null && img.isNotEmpty) return;
if (mounted) setState(() => _videoPosterLoading = true);
final bytes = await VideoThumbnailCache.instance.getPosterFrame(v);
if (!mounted) return;
setState(() {
_videoPosterBytes = bytes;
_videoPosterLoading = false;
});
}
Future<void> _loadAndPlay() async {
final url = widget.videoUrl;
if (url == null || url.isEmpty) return;
if (widget.suspendVideoPlayback) return;
final myGen = ++_videoLoadGen;
if (mounted) setState(() {});
VideoPlayerController? created;
try {
final file = await DefaultCacheManager().getSingleFile(url);
if (!mounted || myGen != _videoLoadGen) return;
created = VideoPlayerController.file(
file,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
await created.initialize();
if (!mounted || myGen != _videoLoadGen) return;
await created.setVolume(1.0);
await created.setLooping(true);
await created.play();
if (!mounted || myGen != _videoLoadGen) return;
if (widget.suspendVideoPlayback) return;
final toAttach = created;
created = null;
if (!mounted || myGen != _videoLoadGen) {
await toAttach.dispose();
return;
}
final previous = _controller;
setState(() => _controller = toAttach);
previous?.dispose();
} catch (e) {
GenerateVideoScreen._log.e('Video load failed', e);
if (mounted) setState(() {});
} finally {
final orphan = created;
if (orphan != null) {
await orphan.dispose();
}
}
}
@override
Widget build(BuildContext context) {
final suspended = widget.suspendVideoPlayback;
final videoUrl = widget.videoUrl;
final hasVideoUrl = videoUrl != null && videoUrl.isNotEmpty;
final hasImageUrl = widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
final videoReady = !suspended &&
_controller != null &&
_controller!.value.isInitialized;
if (hasImageUrl && !hasVideoUrl) {
return CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) => const _BgLoadingPlaceholder(),
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
);
}
if (suspended && hasVideoUrl) {
if (hasImageUrl) {
return CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) => const _BgLoadingPlaceholder(),
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
);
}
final bytes = _videoPosterBytes;
if (bytes != null && bytes.isNotEmpty) {
return Image.memory(
bytes,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
gaplessPlayback: true,
);
}
if (_videoPosterLoading) {
return const _BgLoadingPlaceholder();
}
return const ColoredBox(color: Colors.black);
}
if (hasVideoUrl && videoReady) {
return ClipRect(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _controller!.value.size.width,
height: _controller!.value.size.height,
child: VideoPlayer(_controller!),
),
),
);
}
if (hasVideoUrl && hasImageUrl) {
return CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) => const _BgLoadingPlaceholder(),
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
);
}
if (hasVideoUrl) {
final bytes = _videoPosterBytes;
if (bytes != null && bytes.isNotEmpty) {
return Image.memory(
bytes,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
gaplessPlayback: true,
);
}
if (_videoPosterLoading) {
return const _BgLoadingPlaceholder();
}
return const _BgLoadingPlaceholder();
}
if (hasImageUrl) {
return CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) => const _BgLoadingPlaceholder(),
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
);
}
return const _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,
),
),
],
),
),
],
],
),
),
),
);
}
}