creek

The AI Image Editor of 2030

lib/services/analysis_queue_manager.dart (7424B)


  1import 'dart:convert';
  2import 'dart:io';
  3import 'package:flutter/foundation.dart';
  4import 'package:path_provider/path_provider.dart';
  5import 'package:image/image.dart' as img;
  6import 'package:creekui/data/models/image_model.dart';
  7import 'package:creekui/data/models/note_model.dart';
  8import 'package:creekui/data/repos/image_repo.dart';
  9import 'package:creekui/data/repos/note_repo.dart';
 10import 'analyze/image_analyzer.dart';
 11import 'package:path/path.dart' as p;
 12
 13class AnalysisQueueManager {
 14  static final AnalysisQueueManager _instance =
 15      AnalysisQueueManager._internal();
 16  factory AnalysisQueueManager() => _instance;
 17  AnalysisQueueManager._internal();
 18
 19  bool _isProcessing = false;
 20  final _imageRepo = ImageRepo();
 21  final _noteRepo = NoteRepo();
 22
 23  Future<void> processQueue() async {
 24    if (_isProcessing) return;
 25    _isProcessing = true;
 26
 27    try {
 28      // Loop until no items are left
 29      while (true) {
 30        bool processedAny = false;
 31
 32        // 1. Fetch pending images from DB
 33        List<ImageModel> pendingImages = await _imageRepo.getPendingImages();
 34        if (pendingImages.isNotEmpty) {
 35          processedAny = true;
 36          debugPrint("[Queue]: Found ${pendingImages.length} pending images");
 37          for (final image in pendingImages) {
 38            await _processSingleImage(image);
 39          }
 40        }
 41
 42        // 2. Fetch pending notes from DB
 43        List<NoteModel> pendingNotes = await _noteRepo.getPendingNotes();
 44        if (pendingNotes.isNotEmpty) {
 45          processedAny = true;
 46          debugPrint("[Queue]: Found ${pendingNotes.length} pending notes");
 47
 48          // Group notes to avoid decoding parent image multiple times
 49          final Map<String, List<NoteModel>> notesByImage = {};
 50          for (var note in pendingNotes) {
 51            if (!notesByImage.containsKey(note.imageId)) {
 52              notesByImage[note.imageId] = [];
 53            }
 54            notesByImage[note.imageId]!.add(note);
 55          }
 56
 57          for (final entry in notesByImage.entries) {
 58            await _processNoteGroup(entry.key, entry.value);
 59          }
 60        }
 61
 62        // If no items were processed in this iteration, the queue is drained
 63        if (!processedAny) break;
 64      }
 65    } catch (e) {
 66      debugPrint("[Queue]: Critical Error: $e");
 67    } finally {
 68      _isProcessing = false;
 69    }
 70  }
 71
 72  Future<void> _processSingleImage(ImageModel image) async {
 73    await _imageRepo.updateStatus(image.id, 'analyzing');
 74    try {
 75      Map<String, dynamic> result;
 76
 77      // Run Analysis
 78      if (image.tags.isEmpty) {
 79        debugPrint("[Queue]: Analyzing Full Suite: ${image.name}");
 80        result = await ImageAnalyzerService.analyzeFullSuite(image.filePath);
 81      } else {
 82        debugPrint(
 83          "[Queue]: Analyzing Selected: ${image.name} with tags: ${image.tags}",
 84        );
 85        result = await ImageAnalyzerService.analyzeSelected(
 86          image.filePath,
 87          image.tags,
 88        );
 89      }
 90
 91      // Handle Result
 92      if (result['success'] == true && result['data'] != null) {
 93        final resultsMap = result['data']['results'] ?? {};
 94        final jsonString = jsonEncode(resultsMap);
 95        await _imageRepo.updateAnalysis(image.id, jsonString);
 96        await _imageRepo.updateStatus(image.id, 'completed');
 97        debugPrint("[Queue]: Completed: ${image.name}");
 98      } else {
 99        await _imageRepo.updateStatus(image.id, 'failed');
100        debugPrint("[Queue]: Failed: ${result['error']}");
101      }
102    } catch (e) {
103      debugPrint("[Queue]: Analysis Exception: $e");
104      await _imageRepo.updateStatus(image.id, 'failed');
105    }
106  }
107
108  Future<void> _processNoteGroup(String imageId, List<NoteModel> notes) async {
109    img.Image? parentImageCache;
110    try {
111      // 1. Fetch Parent Info
112      final parentImageModel = await _imageRepo.getById(imageId);
113      if (parentImageModel == null) {
114        throw Exception("Parent image not found: $imageId");
115      }
116      final parentFile = File(parentImageModel.filePath);
117      if (!parentFile.existsSync()) throw Exception("Parent file missing");
118
119      // 2. Decode once for all notes in this group
120      final bytes = await parentFile.readAsBytes();
121      parentImageCache = img.decodeImage(bytes);
122      if (parentImageCache == null) {
123        throw Exception("Failed to decode parent image");
124      }
125
126      final appDir = await getApplicationDocumentsDirectory();
127
128      final cropDirPath = p.join(appDir.path, 'crops');
129      final cropDir = Directory(cropDirPath);
130
131      if (!await cropDir.exists()) {
132        await cropDir.create(recursive: true);
133      }
134
135      for (final note in notes) {
136        await _processSingleNoteWithCache(note, parentImageCache, cropDir);
137      }
138    } catch (e) {
139      debugPrint("[Queue]: Error processing group for image $imageId: $e");
140      for (var note in notes) {
141        await _noteRepo.updateNote(note.id!, status: 'failed');
142      }
143    }
144  }
145
146  Future<void> _processSingleNoteWithCache(
147    NoteModel note,
148    img.Image parentImage,
149    Directory cropDir,
150  ) async {
151    try {
152      // 1. Define Permanent Path
153      final String cropPath = p.join(cropDir.path, 'note_crop_${note.id}.jpg');
154      final File cropFile = File(cropPath);
155
156      // 2. Generate Crop (if it doesn't already exist)
157      if (!await cropFile.exists()) {
158        int x = (note.normX * parentImage.width).round();
159        int y = (note.normY * parentImage.height).round();
160        int w = (note.normWidth * parentImage.width).round();
161        int h = (note.normHeight * parentImage.height).round();
162
163        // Center -> Top-Left conversion
164        int left = x - (w ~/ 2);
165        int top = y - (h ~/ 2);
166
167        // Clamping logic (Safety check)
168        if (left < 0) left = 0;
169        if (top < 0) top = 0;
170        if (left + w > parentImage.width) w = parentImage.width - left;
171        if (top + h > parentImage.height) h = parentImage.height - top;
172
173        if (w <= 0 || h <= 0) {
174          debugPrint("[Queue]: Note ${note.id} has invalid dimensions");
175          await _noteRepo.updateNote(note.id!, status: 'failed');
176          return;
177        }
178
179        final croppedImg = img.copyCrop(
180          parentImage,
181          x: left,
182          y: top,
183          width: w,
184          height: h,
185        );
186        await cropFile.writeAsBytes(img.encodeJpg(croppedImg));
187      }
188
189      await _noteRepo.updateNote(
190        note.id!,
191        status: 'analyzing',
192        cropFilePath: cropPath,
193      );
194
195      // 3. Analysis
196      debugPrint("[Queue]: Analyzing Note ${note.id} tag: ${note.category}");
197
198      // Now when this calls generateAsset internally, the DB lookup will succeed
199      final result = await ImageAnalyzerService.analyzeSelected(cropPath, [
200        note.category,
201      ]);
202
203      // 4. Update DB with Results
204      if (result['success'] == true && result['data'] != null) {
205        final resultsMap = result['data']['results'] ?? {};
206        final jsonString = jsonEncode(resultsMap);
207
208        await _noteRepo.updateNote(
209          note.id!,
210          analysisData: jsonString,
211          status: 'completed',
212          // cropFilePath is already saved, no need to save again
213        );
214      } else {
215        await _noteRepo.updateNote(note.id!, status: 'failed');
216      }
217    } catch (e) {
218      debugPrint("[Queue]: Note processing error: $e");
219      await _noteRepo.updateNote(note.id!, status: 'failed');
220    }
221  }
222}