From 173872364a49bc7656482f005b7011327bc54525 Mon Sep 17 00:00:00 2001 From: ivan Date: Tue, 31 Mar 2026 09:42:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=93=E5=8C=85=EF=BC=9A1.1.15=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/api/api_config.dart | 2 +- lib/core/auth/auth_service.dart | 60 +++++++-- lib/features/gallery/gallery_screen.dart | 116 +++++++----------- .../gallery/gallery_upload_cover_store.dart | 81 ++++++++++++ .../gallery/models/gallery_task_item.dart | 22 +++- .../generate_progress_screen.dart | 25 +++- .../generate_video/generate_video_screen.dart | 10 ++ lib/features/home/home_screen.dart | 4 +- lib/features/home/widgets/video_card.dart | 22 ++-- pubspec.yaml | 2 +- 10 files changed, 244 insertions(+), 100 deletions(-) create mode 100644 lib/features/gallery/gallery_upload_cover_store.dart diff --git a/lib/core/api/api_config.dart b/lib/core/api/api_config.dart index 9a37a3b..6c35dbd 100644 --- a/lib/core/api/api_config.dart +++ b/lib/core/api/api_config.dart @@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart'; /// petsHeroAI API 配置 abstract final class ApiConfig { /// 调试日志:true 时 release 包也输出 debug/info(正式包调试用,上线前改 false) - static const bool debugLogs = true; + static const bool debugLogs = false; /// AES 密钥 static const String aesKey = 'liyP4LkMfP68XvCt'; diff --git a/lib/core/auth/auth_service.dart b/lib/core/auth/auth_service.dart index 10d7786..e1e702e 100644 --- a/lib/core/auth/auth_service.dart +++ b/lib/core/auth/auth_service.dart @@ -178,10 +178,16 @@ class AuthService { items: items, ); _applyScreenSecure(safeArea); + } else { + _logMsg('common_info: surge 解析为 null(JSON 顶层非对象?)'); } } catch (e) { _logMsg('surge JSON 解析失败: $e'); } + } else { + _logMsg( + 'common_info: 无 surge 或 surge 为空串,extConfig/第三方支付开关可能未更新(其它字段仍已写入)', + ); } } @@ -307,9 +313,28 @@ class AuthService { } static bool _applyCommonInfoAndDidHomeStructureChange(ApiResponse commonRes) { - if (!commonRes.isSuccess || commonRes.data == null) return false; + if (!commonRes.isSuccess) { + _logMsg( + 'common_info 失败: code=${commonRes.code} msg=${commonRes.msg} ' + 'dataPreview=${_shortDataPreview(commonRes.data)}', + ); + return false; + } + if (commonRes.data == null) { + _logMsg('common_info 失败: code=0 但 data 为 null'); + return false; + } final commonData = commonRes.data as Map?; - if (commonData == null) return false; + if (commonData == null) { + _logMsg( + 'common_info 失败: code=0 但 data 非 Map,类型=${commonRes.data.runtimeType} ' + 'preview=${_shortDataPreview(commonRes.data)}', + ); + return false; + } + _logMsg( + 'common_info 收到 data,字段 keys=${commonData.keys.toList()}', + ); final before = _HomeExtSnapshot.capture(); _saveCommonInfoToState(commonData); final after = _HomeExtSnapshot.capture(); @@ -356,23 +381,44 @@ class AuthService { _logMsg('referrer(gg) 失败: code=${rGg.code} msg=${rGg.msg}'); } + _logMsg( + 'common_info 请求 GET /v1/user/common_info ' + 'sentinel=${ApiConfig.appId} asset=$uid', + ); final commonRes = await UserApi.getCommonInfo( sentinel: ApiConfig.appId, asset: uid, ); if (_applyCommonInfoAndDidHomeStructureChange(commonRes)) { UserState.requestHomeFullReload(); - } else if (!commonRes.isSuccess) { - _logMsg( - 'common_info 失败: code=${commonRes.code} msg=${commonRes.msg}'); } } + /// 日志里预览 [data],避免整段 surge 撑爆日志 + static String _shortDataPreview(dynamic data, {int maxLen = 240}) { + if (data == null) return 'null'; + if (data is Map) { + return 'Map(keys=${data.keys.toList()}, size=${data.length})'; + } + if (data is List) { + return 'List(len=${data.length})'; + } + String s; + try { + s = data is String ? data : jsonEncode(data); + } catch (_) { + s = data.toString(); + } + if (s.length <= maxLen) return s; + return '${s.substring(0, maxLen)}…(len=${s.length})'; + } + static Future _runPostLoginReferrerWork(String uid, String deviceId) async { try { await _reportBothReferrersAndRefreshCommonInfo(uid, deviceId); - } catch (e, _) { - _logMsg('referrer/common_info 后台任务失败: $e'); + } catch (e, st) { + _logMsg('referrer/common_info 后台任务异常: $e'); + _logMsg('referrer/common_info 堆栈: $st'); } } } diff --git a/lib/features/gallery/gallery_screen.dart b/lib/features/gallery/gallery_screen.dart index a22bd92..c8b2f3d 100644 --- a/lib/features/gallery/gallery_screen.dart +++ b/lib/features/gallery/gallery_screen.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:typed_data'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:visibility_detector/visibility_detector.dart'; @@ -12,8 +12,8 @@ import '../../core/theme/app_spacing.dart'; import '../../shared/widgets/top_nav_bar.dart'; import '../home/widgets/video_card.dart'; +import 'gallery_upload_cover_store.dart'; import 'models/gallery_task_item.dart'; -import 'video_thumbnail_cache.dart'; /// Gallery screen - matches Pencil hpwBg class GalleryScreen extends StatefulWidget { @@ -38,12 +38,14 @@ class _GalleryScreenState extends State { /// 略低于首页:网格边缘格子可见但比例偏低时也应计入「在屏」 static const double _videoVisibilityThreshold = 0.08; - /// 一屏约 2×(3~4) 个格子;不再强行只播 2 路(会总挑中间行)。滑出后 [VideoCard] 会释放解码器,此处仅防极端滚动时实例过多 - static const int _maxConcurrentGalleryVideos = 16; + /// 可见格子按曝光比例排序,同时最多自动播放 [_maxConcurrentGalleryVideos] 路;滑出后 [VideoCard] 释放解码器 + static const int _maxConcurrentGalleryVideos = 4; Set _visibleVideoIndices = {}; final Set _userPausedVideoIndices = {}; final Map _cardVisibleFraction = {}; Timer? _visibilityDebounce; + /// taskId(tree) -> 本地上传封面路径(接口无封面时使用) + Map _localCoverPaths = {}; /// [IndexedStack] 非当前 Tab 时子树可见性常为 0;首次切到本页时若仅打一次 [notifyNow], /// 可能在 Grid 尚未挂载(仍在 loading)或首帧未稳定时执行,导致预览不自动播放。 @@ -147,6 +149,41 @@ class _GalleryScreenState extends State { return true; } + static const String _defaultGalleryCoverAsset = 'assets/images/logo.png'; + + /// 网络封面优先;无则本地上传图(按 tree);再无则默认占位图 + ({String imageUrl, Widget? cover}) _galleryCardCover(GalleryMediaItem media) { + final net = media.imageUrl?.trim() ?? ''; + if (net.isNotEmpty) { + return (imageUrl: net, cover: null); + } + final tid = media.taskId; + if (tid != null && tid > 0) { + final path = _localCoverPaths[tid]; + if (path != null && path.isNotEmpty && File(path).existsSync()) { + return ( + imageUrl: '', + cover: Image.file(File(path), fit: BoxFit.cover), + ); + } + } + return ( + imageUrl: '', + cover: Image.asset( + _defaultGalleryCoverAsset, + fit: BoxFit.cover, + ), + ); + } + + Future _refreshLocalCoverPaths() async { + if (!mounted) return; + final ids = _tasks.map((t) => t.taskId).where((id) => id > 0).toSet(); + final paths = await GalleryUploadCoverStore.existingPathsForTaskIds(ids); + if (!mounted) return; + setState(() => _localCoverPaths = paths); + } + Future _loadTasks({bool refresh = true}) async { if (refresh) { setState(() { @@ -184,6 +221,7 @@ class _GalleryScreenState extends State { _visibleVideoIndices = {}; _userPausedVideoIndices.clear(); _cardVisibleFraction.clear(); + _localCoverPaths = {}; } else { _tasks = [..._tasks, ...list]; } @@ -193,6 +231,7 @@ class _GalleryScreenState extends State { _loadingMore = false; _hasLoadedOnce = true; }); + await _refreshLocalCoverPaths(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _scheduleGalleryVisibilityRefresh(); }); @@ -314,6 +353,7 @@ class _GalleryScreenState extends State { ); } final media = _gridItems[index]; + final coverSpecs = _galleryCardCover(media); final videoUrl = media.videoUrl; final hasVideo = videoUrl != null && videoUrl.isNotEmpty; @@ -336,14 +376,8 @@ class _GalleryScreenState extends State { child: RepaintBoundary( child: VideoCard( key: ValueKey(detectorKey), - imageUrl: media.imageUrl ?? - videoUrl ?? - '', - cover: hasVideo - ? _VideoThumbnailCover( - videoUrl: media.videoUrl!, - ) - : null, + imageUrl: coverSpecs.imageUrl, + cover: coverSpecs.cover, videoUrl: hasVideo ? videoUrl : null, credits: '50', showCreditsBadge: false, @@ -374,61 +408,3 @@ class _GalleryScreenState extends State { ); } } - -class _VideoThumbnailCover extends StatefulWidget { - const _VideoThumbnailCover({required this.videoUrl}); - - final String videoUrl; - - @override - State<_VideoThumbnailCover> createState() => _VideoThumbnailCoverState(); -} - -class _VideoThumbnailCoverState extends State<_VideoThumbnailCover> { - /// 必须在 State 内固定同一 Future;否则父级每次 build 新建 Future,[FutureBuilder] 会重置并闪一下占位图 - late Future _thumbFuture; - - @override - void initState() { - super.initState(); - _thumbFuture = - VideoThumbnailCache.instance.getThumbnail(widget.videoUrl); - } - - @override - void didUpdateWidget(covariant _VideoThumbnailCover oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.videoUrl != widget.videoUrl) { - _thumbFuture = - VideoThumbnailCache.instance.getThumbnail(widget.videoUrl); - } - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _thumbFuture, - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data != null) { - return Image.memory( - snapshot.data!, - fit: BoxFit.cover, - gaplessPlayback: true, - ); - } - return Container( - color: AppColors.surfaceAlt, - child: snapshot.connectionState == ConnectionState.waiting - ? const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : const SizedBox.shrink(), - ); - }, - ); - } -} diff --git a/lib/features/gallery/gallery_upload_cover_store.dart b/lib/features/gallery/gallery_upload_cover_store.dart new file mode 100644 index 0000000..0a2dcf4 --- /dev/null +++ b/lib/features/gallery/gallery_upload_cover_store.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +/// 生图流程创建任务后,按接口返回的 `tree`(任务 id)将用户上传的压缩图存一份到本地; +/// Gallery 在接口未返回封面 URL 时可用其作为卡片底图。 +abstract final class GalleryUploadCoverStore { + static const String _subdir = 'gallery_upload_covers'; + static const String _fileExt = '.jpg'; + + /// 本地封面最多保留时长,超时文件会在下次读/写前删除。 + static const Duration maxRetention = Duration(hours: 25); + + static Future _directory() async { + final base = await getApplicationSupportDirectory(); + final dir = Directory('${base.path}/$_subdir'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + static Future _directoryAfterPurge() async { + final dir = await _directory(); + await _purgeExpired(dir); + return dir; + } + + static Future _purgeExpired(Directory dir) async { + if (!await dir.exists()) return; + final now = DateTime.now(); + try { + await for (final entity in dir.list(followLinks: false)) { + if (entity is! File) continue; + final name = entity.uri.pathSegments.last; + if (!name.endsWith(_fileExt)) continue; + final stat = await entity.stat(); + if (now.difference(stat.modified) >= maxRetention) { + try { + await entity.delete(); + } catch (_) {} + } + } + } catch (_) {} + } + + static File _fileForTask(Directory dir, int taskId) => + File('${dir.path}/$taskId$_fileExt'); + + /// [source] 一般为 [compressImageForUpload] 输出的待上传文件。 + static Future saveForTask(int taskId, File source) async { + if (taskId <= 0) return; + if (!await source.exists()) return; + final dir = await _directoryAfterPurge(); + final dest = _fileForTask(dir, taskId); + await source.copy(dest.path); + } + + static Future pathIfExists(int taskId) async { + if (taskId <= 0) return null; + final dir = await _directoryAfterPurge(); + final f = _fileForTask(dir, taskId); + return await f.exists() ? f.path : null; + } + + /// 仅查询 [ids] 中已有文件的 path,用于列表刷新后一次性填充状态。 + static Future> existingPathsForTaskIds( + Iterable ids, + ) async { + final dir = await _directoryAfterPurge(); + final out = {}; + for (final id in ids) { + if (id <= 0) continue; + final f = _fileForTask(dir, id); + if (await f.exists()) { + out[id] = f.path; + } + } + return out; + } +} diff --git a/lib/features/gallery/models/gallery_task_item.dart b/lib/features/gallery/models/gallery_task_item.dart index 6cfcc77..163ee4b 100644 --- a/lib/features/gallery/models/gallery_task_item.dart +++ b/lib/features/gallery/models/gallery_task_item.dart @@ -3,10 +3,13 @@ class GalleryMediaItem { const GalleryMediaItem({ this.imageUrl, this.videoUrl, + this.taskId, }) : assert(imageUrl != null || videoUrl != null); final String? imageUrl; - final String? videoUrl; // 视频地址,用于生成封面 + final String? videoUrl; + /// 与列表项 `tree` 一致,用于匹配本地上传封面缓存 + final int? taskId; /// reconnect==0 为视频,1 或其他为图片 bool get isVideo => @@ -30,13 +33,16 @@ class GalleryTaskItem { final List mediaItems; factory GalleryTaskItem.fromJson(Map json) { + final treeRaw = json['tree'] as num?; + final treeId = treeRaw?.toInt() ?? 0; + final itemTaskId = treeId > 0 ? treeId : null; final downsample = json['downsample'] as List? ?? []; final items = []; // 只取downsample的array[0] if (downsample.isNotEmpty) { final first = downsample[0]; if (first is String) { - items.add(GalleryMediaItem(imageUrl: first)); + items.add(GalleryMediaItem(imageUrl: first, taskId: itemTaskId)); } else if (first is Map) { final reconfigure = first['reconfigure'] as String?; if (reconfigure != null && reconfigure.isNotEmpty) { @@ -47,9 +53,15 @@ class GalleryTaskItem { ? reconnect.toInt() : 1; if (imgType == 2) { - items.add(GalleryMediaItem(videoUrl: reconfigure)); + items.add(GalleryMediaItem( + videoUrl: reconfigure, + taskId: itemTaskId, + )); } else { - items.add(GalleryMediaItem(imageUrl: reconfigure)); + items.add(GalleryMediaItem( + imageUrl: reconfigure, + taskId: itemTaskId, + )); } } } @@ -76,7 +88,7 @@ class GalleryTaskItem { // } // } return GalleryTaskItem( - taskId: (json['tree'] as num?)?.toInt() ?? 0, + taskId: treeId, state: json['listing']?.toString() ?? '', taskType: (json['cipher'] as num?)?.toInt() ?? 0, createTime: (json['discover'] as num?)?.toInt() ?? 0, diff --git a/lib/features/generate_video/generate_progress_screen.dart b/lib/features/generate_video/generate_progress_screen.dart index e1a29bf..95ad432 100644 --- a/lib/features/generate_video/generate_progress_screen.dart +++ b/lib/features/generate_video/generate_progress_screen.dart @@ -37,9 +37,23 @@ double _progressForState(int? state) { return 1.0; // 3, 4, 5, 6 } +int? _parseProgressTaskId(dynamic raw) { + if (raw == null) return null; + if (raw is int) return raw > 0 ? raw : null; + if (raw is num) { + final v = raw.toInt(); + return v > 0 ? v : null; + } + final v = int.tryParse(raw.toString()); + return (v != null && v > 0) ? v : null; +} + /// Build GalleryMediaItem from /v1/image/progress response (data = sidekick). /// curate[].reconfigure = imgUrl, reconnect(imgType): 2=视频,1或其他=图片 -GalleryMediaItem? _mediaItemFromProgressData(Map data) { +GalleryMediaItem? _mediaItemFromProgressData( + Map data, { + int? taskId, +}) { final curate = data['curate'] as List?; if (curate == null || curate.isEmpty) return null; final first = curate.first; @@ -53,9 +67,9 @@ GalleryMediaItem? _mediaItemFromProgressData(Map data) { ? reconnect.toInt() : 1; if (imgType == 2) { - return GalleryMediaItem(videoUrl: reconfigure); + return GalleryMediaItem(videoUrl: reconfigure, taskId: taskId); } - return GalleryMediaItem(imageUrl: reconfigure); + return GalleryMediaItem(imageUrl: reconfigure, taskId: taskId); } /// Generate Video Progress screen - matches Pencil rSN3T (video), Eghqc (label) @@ -153,7 +167,10 @@ class _GenerateProgressScreenState extends State { } catch (_) {} } if (!mounted) return; - final mediaItem = _mediaItemFromProgressData(data); + final mediaItem = _mediaItemFromProgressData( + data, + taskId: _parseProgressTaskId(widget.taskId), + ); Navigator.of(context).pushReplacementNamed( '/result', arguments: mediaItem, diff --git a/lib/features/generate_video/generate_video_screen.dart b/lib/features/generate_video/generate_video_screen.dart index 79de71c..5f7a1bd 100644 --- a/lib/features/generate_video/generate_video_screen.dart +++ b/lib/features/generate_video/generate_video_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async' show unawaited; import 'dart:io'; import 'dart:typed_data'; @@ -17,6 +18,7 @@ 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/gallery_upload_cover_store.dart'; import '../../features/gallery/video_thumbnail_cache.dart'; import '../../features/home/home_playback_resume.dart'; import '../../features/home/models/task_item.dart'; @@ -259,6 +261,14 @@ class _GenerateVideoScreenState extends State { final taskData = createRes.data as Map?; final taskId = taskData?['tree']; + final taskIdInt = taskId is int + ? taskId + : taskId is num + ? taskId.toInt() + : int.tryParse(taskId?.toString() ?? ''); + if (taskIdInt != null && taskIdInt > 0) { + unawaited(GalleryUploadCoverStore.saveForTask(taskIdInt, toUpload)); + } // 创建任务成功后刷新用户账户信息(积分等) await refreshAccount(); diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 776d497..8941161 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -150,8 +150,8 @@ class _HomeScreenState extends State with WidgetsBindingObserver { /// 与 Gallery 一致:边缘格子可见比例偏低时也应参与自动播 static const double _videoVisibilityThreshold = 0.08; - /// 凡达到阈值的格子均可播;滑出后 [VideoCard] 会释放解码器,此处仅防极端情况 - static const int _maxConcurrentHomeVideos = 16; + /// 可见格子按曝光比例排序,同时最多 [_maxConcurrentHomeVideos] 路;滑出后 [VideoCard] 释放解码器 + static const int _maxConcurrentHomeVideos = 4; void _onGridCardVisibilityChanged(int index, VisibilityInfo info) { if (!mounted || !widget.isActive) return; diff --git a/lib/features/home/widgets/video_card.dart b/lib/features/home/widgets/video_card.dart index d233aff..9f2f465 100644 --- a/lib/features/home/widgets/video_card.dart +++ b/lib/features/home/widgets/video_card.dart @@ -328,16 +328,18 @@ class _VideoCardState extends State { onTap: widget.onGenerateSimilar, behavior: HitTestBehavior.opaque, child: widget.cover ?? - CachedNetworkImage( - imageUrl: widget.imageUrl, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - color: AppColors.surfaceAlt, - ), - errorWidget: (_, __, ___) => Container( - color: AppColors.surfaceAlt, - ), - ), + (widget.imageUrl.isEmpty + ? Container(color: AppColors.surfaceAlt) + : CachedNetworkImage( + imageUrl: widget.imageUrl, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: AppColors.surfaceAlt, + ), + errorWidget: (_, __, ___) => Container( + color: AppColors.surfaceAlt, + ), + )), ), ), if (showVideoLayer) diff --git a/pubspec.yaml b/pubspec.yaml index bffcd52..f91e4bb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pets_hero_ai description: PetsHero AI Application. publish_to: 'none' -version: 1.1.14+25 +version: 1.1.15+26 environment: sdk: '>=3.0.0 <4.0.0'