0

Build app tìm kiếm trong video với VModal Flutter SDK — mình học được gì

Build app tìm kiếm trong video bằng tiếng người với VModal Flutter SDK — mình học được gì

Mình dành một ngày build app Flutter trên vmodal_sdk_flutter: upload video, tạo index, rồi gõ mô tả bằng lời để tìm đúng frame. Bài này là build log — code chạy được, ba chỗ SDK làm rất tốt, và một chỗ mình phải tự xử lý.

Mình upload một video nấu ăn, gõ fire, và nhận về đúng những frame lúc chảo bùng lửa.

Nghe thì đơn giản. Nhưng để tự làm được chuyện đó bạn phải trích frame, chạy một model embedding, dựng vector store, rồi lo phần serving. Ở đây nó là bốn lời gọi hàm. Bài này là những gì mình ghi lại sau một ngày ngồi với vmodal_sdk_flutter.

Nói trước cho rõ: đây là việc mình làm cho client (VModal) qua Upwork. Mình được trả tiền để build app demo. Nhưng những gì viết dưới đây là thứ mình thật sự gặp khi code, kể cả chỗ vướng — nếu chỉ muốn đọc quảng cáo thì bạn đọc landing page của họ nhanh hơn.

Kết quả search — lưới frame khớp với query

Mình build cái gì

Một app hai tab tên Moments, chạy trên iOS và Android:

  • Library — nhập API key, gọi auth.me(), chọn video từ Photos, upload có progress bar và nút Cancel chạy thật, tạo image index, poll đến khi xong.
  • Search — gõ mô tả bằng tiếng Anh tự nhiên, nhận về lưới các frame khớp.

Library tab — index đã xong, sẵn sàng search

Code đầy đủ ở đây, clone về chạy được: https://github.com/v-modal/vmodal_moments_demo

This video: https://youtube.com/shorts/81xrwPi9UYo?feature=share

Phần setup — chỗ này làm tốt hơn mình tưởng

bash install.sh install
bash build.sh analyze
bash build.sh test

install.sh đọc version đã pin trong .flutter-version, tải đúng bản Flutter đó về ~/.cache/vmodal/flutter, và verify checksum của archive. Nó không đụng vào bản Flutter global của bạn.

Ai từng phải giữ ba bản Flutter cho ba dự án sẽ hiểu vì sao mình nhắc chỗ này. Phần lớn SDK mặc định bạn đang dùng bản Flutter nào cũng được. Cái này pin 3.44.6, verify, và tự để riêng ra một chỗ. Toàn bộ gate offline chạy sạch trên SDK và cả hai example mà không cần API key.

Ba thứ SDK làm tốt

1. Scope là immutable, nên upload / index / search không thể lệch nhau

Bạn configure project một lần, rồi derive ra các scope bất biến:

final project = VModal.configure(projectId: 'moments', apiKeyProvider: keys);
final scope   = project.scope(collectionName: 'demo', streamName: 'uploads');

VModalScope mang theo collection và stream vào mọi lời gọi. Các class option của scope (ScopedSearchOptions và họ hàng) cố tình không có field override organization.

Nghe thì nhỏ, nhưng nó chặn hẳn một lớp bug rất khó chịu: index vào một chỗ, rồi search ở chỗ khác, và ngồi debug hai tiếng vì "search không ra gì". Ở đây bạn không viết được cái bug đó.

2. API key chỉ nằm trong RAM

ApiKeyProvider.current() được gọi ngay trước mỗi request. MutableApiKeyProvider giữ key chỉ trong memory, có rotate() / clear() / close(), và tự redact trong toString(). SDK không ép bạn dùng UI login nào, và không persist bất cứ thứ gì.

Logout là tường minh: cancel việc đang chạy, clear provider, close project, dựng cái mới. Trong lib/src/session.dart của mình nó gọn đúng bốn dòng:

Future<void> close() async {
  if (_closed) return;
  _closed = true;
  keys.clear();
  await project.close();
  keys.close();
}

Với một SDK mobile thì đây là lựa chọn đúng. Cái gì không lưu thì không rò rỉ được.

3. UploadTask.cancel() cancel thật

UploadTask cho bạn một broadcast stream progress, một future kết quả, và một cancel() hủy thật — task complete với OperationCanceled chứ không âm thầm chạy nốt request rồi vứt kết quả đi. Có sẵn AdaptiveUploadPolicy chọn part size và concurrency một cách deterministic từ kích thước file × loại mạng × RAM máy.

Vài rào an toàn khác đáng ghi nhận: response đi qua đúng một bounded reader (8 MiB JSON, 64 MiB binary); chỉ GET và HEAD mới retry — request mutating gửi đúng một lần; và SdkException.toString() render thẳng lên UI được vì nó chỉ mang classification và status, không bao giờ mang body, URL hay credential.

Còn một điểm nữa mình thích: transcoder là một interface (VideoTranscoder), mặc định passthrough — package không kéo theo native dependency nào để transcode. Chọn file, state management, background scheduling: SDK trả lại hết cho bạn. Với SDK mobile thì đó là quyết định đúng.

Chỗ mình phải tự xử lý: ảnh frame không render bằng NetworkImage

Đây là phần đáng giá nhất của bài này, nên mình để nguyên chi tiết.

Doc nói hãy đưa thẳng url_pre_signed cho NetworkImage. Với backend mình gặp thì không được, vì hai lý do độc lập nhau:

  • giá trị trả về là một relative path (/api/external/v1/image/get_image?...), không có host cho NetworkImage resolve; và
  • route đó chỉ nhận POST và có gateway auth — GET trần trả 401 khi không có Bearer, và 405 khi có.

Cách xử lý: kéo bytes qua chính lời gọi có auth của SDK, rồi bọc lại thành ImageProvider để imageCache của Flutter vẫn làm việc của nó (lưới scroll ngược lên không fetch lại):


class MomentsFrameImage extends ImageProvider<MomentsFrameImage> {
  const MomentsFrameImage(this.urlPreSigned, {required this.loader});

  final String urlPreSigned;
  final Future<Uint8List> Function(String) loader;

  
  Future<MomentsFrameImage> obtainKey(ImageConfiguration configuration) =>
      SynchronousFuture<MomentsFrameImage>(this);

  
  ImageStreamCompleter loadImage(MomentsFrameImage key, ImageDecoderCallback decode) =>
      MultiFrameImageStreamCompleter(
        codec: _decode(key, decode),
        scale: 1,
        // URL là một capability: giữ nó ra khỏi label và mọi error string.
        debugLabel: 'MomentsFrameImage',
      );

  static Future<ui.Codec> _decode(MomentsFrameImage key, ImageDecoderCallback decode) async {
    final bytes = await key.loader(key.urlPreSigned);
    if (bytes.isEmpty) throw StateError('Frame download returned no bytes.');
    return decode(await ui.ImmutableBuffer.fromUint8List(bytes));
  }
}

loader ở đây chính là client.images.getImageFromUrl — nó bắn đúng cái POST có auth mà backend cần.

File đầy đủ: lib/src/frame_image.dart.

Hai ghi chú nhỏ, để bạn không mất buổi chiều như mình

Search source phải gate theo collection. Xin một source mà collection chưa từng index không trả về kết quả rỗng — nó trả 404, kèm Missing LanceDB table: .... Mình đọc GroupItem.modalityTypes rồi chỉ hiện những chip mà collection thật sự có. Tài khoản mình thấy chỉ quảng cáo img_embvid_raw — không OCR, không ASR — nên hai chip đó thường xám.

Hit trả về không mang filename. Search của mình trả 11 match và không hit nào có field filename nào trong danh sách doc liệt kê. Chỗ duy nhất một hit tự khai nó thuộc video nào là đường dẫn frame đã lưu (image_url / thumbnail_url), nên mình recover stream, filename và timestamp từ đó. Logic ở lib/src/search_mapping.dart, có comment đầy đủ ở đầu file.

Và: luôn truyền versionLancedb tường minh khi search. Bỏ trống thì nó âm thầm rơi về v0 mặc định của backend, v0 rỗng, và bạn sẽ tưởng index hỏng.

Kết

Phần mạnh nhất của SDK này là kiến trúc: scope immutable, credential model từ chối persist bất cứ thứ gì, cancel chạy thật, và thái độ dứt khoát không kéo native dependency vào package. Đó là những quyết định của người đã từng ship phần mềm mobile thật.

Chỗ vướng của mình nằm hết ở mét cuối — search chạy, index chạy, rồi render ảnh mới là chỗ tốn thời gian. Một ImageProvider khoảng 40 dòng là xong, và mình đã để sẵn nó trong repo demo để bạn khỏi phải viết lại.

Nếu bạn đang có một đống video và một ô tìm kiếm mà người dùng không biết gõ gì vào — cái này đáng thử một buổi chiều.

Có gì cứ hỏi ở comment, mình trả lời hết.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí