Skip to content

Commit 76a6e6b

Browse files
authored
feat: on the fly thumbnail generation (#3523)
## 🎯 Goal This PR adds native video thumbnail generation for local assets and wires it into the attachment flows. The rationale' behind the PR is the fact that starting RN 0.84 and onwards, `ImageBackground` can no longer reliably render `ph:///` video assets on iOS (and probably other edge cases too). Thumbnails are now generated natively on iOS and Android for local video assets coming from the attachment picker / image picker / camera flows, cached in the app cache directory, and reused if they were already generated before. The native API is batched, so we can generate multiple thumbnails in one go instead of doing them one by one. The module only handles local assets. If thumbnail generation fails for a single video, we soft fail that item only and fall back to the existing placeholder / no thumbnail UI instead of failing the whole batch. So in other words: - added native video thumbnail generation on iOS and Android under shared-native - exposed it through a new TurboModule `StreamVideoThumbnail` API - batched thumbnail generation for `getPhotos`, `pickImage`, and `takePhoto` - added deterministic cache reuse for generated thumbnails - limited concurrent native generation to avoid decode storms - made thumbnail generation best-effort per item: - native returns `{ uri, error }` per requested asset - failed items no longer fail the whole batch - restricted `thumbnail` generation for local assets only - Android: raw path, `file://`, `content://` - iOS: raw path, `file://`, `ph://` - switched attachment picker items and image upload previews away from ImageBackground to plain Image Notes - this is new architecture only - thumbnails are written to the app cache dir, so they can survive relaunches but are still disposable cache - existing cached thumbnails from the old cache folder name will not be reused ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent 13944da commit 76a6e6b

42 files changed

Lines changed: 1132 additions & 135 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/changelog-preview.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ jobs:
1717
uses: actions/setup-node@v4
1818
with:
1919
node-version: ${{ matrix.node-version }}
20+
- name: Install Linux build tools
21+
run: sudo apt-get update && sudo apt-get install -y build-essential
2022
- uses: actions/checkout@v2
2123
with:
2224
ref: develop

.github/workflows/check-pr.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ jobs:
2323
uses: actions/setup-node@v4
2424
with:
2525
node-version: ${{ matrix.node-version }}
26+
- name: Install Linux build tools
27+
run: sudo apt-get update && sudo apt-get install -y build-essential
2628
- name: Install && Build - SDK and Sample App
2729
uses: ./.github/actions/install-and-build-sdk
2830
- name: Lint

.github/workflows/release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ jobs:
3131
with:
3232
node-version: ${{ matrix.node-version }}
3333
registry-url: 'https://registry.npmjs.org'
34+
- name: Install Linux build tools
35+
run: sudo apt-get update && sudo apt-get install -y build-essential
3436

3537
- name: Prepare git
3638
run: |

.github/workflows/sample-distribution.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ jobs:
6969
with:
7070
node-version: ${{ matrix.node-version }}
7171
- uses: actions/checkout@v2
72+
- name: Install Linux build tools
73+
run: sudo apt-get update && sudo apt-get install -y build-essential
7274
- uses: actions/setup-java@v3
7375
with:
7476
distribution: 'zulu'

examples/SampleApp/android/app/build.gradle

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ apply plugin: 'com.google.gms.google-services'
44
apply plugin: 'com.google.firebase.crashlytics'
55
apply plugin: "com.facebook.react"
66

7+
// Some libraries look up the React Native location on the application project
8+
// instead of the root project. Mirror the root setting here so they never need
9+
// to shell out to a bare `node` binary just to resolve react-native.
10+
ext.REACT_NATIVE_NODE_MODULES_DIR =
11+
rootProject.ext.has("REACT_NATIVE_NODE_MODULES_DIR")
12+
? rootProject.ext.get("REACT_NATIVE_NODE_MODULES_DIR")
13+
: file("$rootDir/../node_modules/react-native").absolutePath
14+
ext.REACT_NATIVE_WORKLETS_NODE_MODULES_DIR =
15+
rootProject.ext.has("REACT_NATIVE_WORKLETS_NODE_MODULES_DIR")
16+
? rootProject.ext.get("REACT_NATIVE_WORKLETS_NODE_MODULES_DIR")
17+
: file("$rootDir/../node_modules/react-native-worklets").absolutePath
18+
719
/**
820
* This is the configuration block to customize your React Native Android app.
921
* By default you don't need to apply any configuration, just uncomment the lines you need.

examples/SampleApp/android/build.gradle

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ buildscript {
2626
}
2727
}
2828

29+
ext.REACT_NATIVE_NODE_MODULES_DIR = file("$rootDir/../node_modules/react-native").absolutePath
30+
ext.REACT_NATIVE_WORKLETS_NODE_MODULES_DIR =
31+
file("$rootDir/../node_modules/react-native-worklets").absolutePath
32+
33+
subprojects { subproject ->
34+
if (subproject.path != ":app") {
35+
evaluationDependsOn(":app")
36+
project(":app").tasks.matching { task ->
37+
task.name.startsWith("configureCMake")
38+
}.configureEach {
39+
dependsOn(subproject.tasks.matching { it.name == "preBuild" })
40+
}
41+
}
42+
}
43+
2944
allprojects {
3045
repositories {
3146
maven {
@@ -39,15 +54,8 @@ allprojects {
3954
}
4055

4156
project(':app') {
42-
afterEvaluate {
43-
if (tasks.findByName("preBuild")) {
44-
tasks.preBuild.doFirst {
45-
exec {
46-
workingDir rootDir
47-
commandLine './gradlew', 'generateCodegenArtifactsFromSchema'
48-
}
49-
}
50-
}
57+
tasks.matching { it.name == "preBuild" || it.name.startsWith("configureCMake") }.configureEach {
58+
dependsOn("generateCodegenArtifactsFromSchema")
5159
}
5260
}
5361

examples/SampleApp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@
3737
"@react-native-async-storage/async-storage": "^2.2.0",
3838
"@react-native-camera-roll/camera-roll": "^7.10.0",
3939
"@react-native-clipboard/clipboard": "^1.16.3",
40+
"@react-native-community/blur": "^4.4.1",
4041
"@react-native-community/geolocation": "^3.4.0",
4142
"@react-native-community/netinfo": "^11.4.1",
42-
"@react-native-community/blur": "^4.4.1",
4343
"@react-native-documents/picker": "^10.1.3",
4444
"@react-native-firebase/app": "22.2.1",
4545
"@react-native-firebase/messaging": "22.2.1",

examples/SampleApp/yarn.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2190,9 +2190,9 @@
21902190
merge-options "^3.0.4"
21912191

21922192
"@react-native-camera-roll/camera-roll@^7.10.0":
2193-
version "7.10.0"
2194-
resolved "https://registry.yarnpkg.com/@react-native-camera-roll/camera-roll/-/camera-roll-7.10.0.tgz#5e9518d78a9cd87ddc8e68d03e31a608df5033ab"
2195-
integrity sha512-Zm1yHxxTQS2APsnnxUFoLnK+DMMTPqmIQ2z2pGtNyHRXAG40Nt4MLVB3tDJTWnuJLAG87BpTCEvpz49+u0YkUw==
2193+
version "7.10.2"
2194+
resolved "https://registry.yarnpkg.com/@react-native-camera-roll/camera-roll/-/camera-roll-7.10.2.tgz#af2234f60f0b55aff9afb60888ce7f0669d52593"
2195+
integrity sha512-XgJQJDFUycmqSX+MH7vTcRigQwEIQNLIu1GvOngCZRwlSV2mF61UzeruSmmHwkBcGnHZFXkKg9fil0FQVfyglw==
21962196

21972197
"@react-native-clipboard/clipboard@^1.16.3":
21982198
version "1.16.3"

package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,34 @@
1414
import java.util.Map;
1515

1616
public class StreamChatExpoPackage extends TurboReactPackage {
17+
private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail";
18+
1719
@Nullable
1820
@Override
1921
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
22+
if (name.equals(STREAM_VIDEO_THUMBNAIL_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
23+
return createNewArchModule("com.streamchatexpo.StreamVideoThumbnailModule", reactContext);
24+
}
25+
2026
return null;
2127
}
2228

2329
@Override
2430
public ReactModuleInfoProvider getReactModuleInfoProvider() {
2531
return () -> {
2632
final Map<String, ReactModuleInfo> moduleInfos = new HashMap<>();
33+
boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
34+
moduleInfos.put(
35+
STREAM_VIDEO_THUMBNAIL_MODULE,
36+
new ReactModuleInfo(
37+
STREAM_VIDEO_THUMBNAIL_MODULE,
38+
STREAM_VIDEO_THUMBNAIL_MODULE,
39+
false, // canOverrideExistingModule
40+
false, // needsEagerInit
41+
false, // hasConstants
42+
false, // isCxxModule
43+
isTurboModule // isTurboModule
44+
));
2745
return moduleInfos;
2846
};
2947
}
@@ -32,4 +50,19 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() {
3250
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
3351
return Collections.<ViewManager>singletonList(new StreamShimmerViewManager());
3452
}
53+
54+
@Nullable
55+
private NativeModule createNewArchModule(
56+
String className,
57+
ReactApplicationContext reactContext
58+
) {
59+
try {
60+
Class<?> moduleClass = Class.forName(className);
61+
return (NativeModule) moduleClass
62+
.getConstructor(ReactApplicationContext.class)
63+
.newInstance(reactContext);
64+
} catch (Throwable ignored) {
65+
return null;
66+
}
67+
}
3568
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.streamchatexpo
2+
3+
import com.facebook.react.bridge.Arguments
4+
import com.facebook.react.bridge.Promise
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.facebook.react.bridge.ReadableArray
7+
import com.streamchatreactnative.shared.StreamVideoThumbnailGenerator
8+
import java.util.concurrent.Executors
9+
10+
class StreamVideoThumbnailModule(
11+
reactContext: ReactApplicationContext,
12+
) : NativeStreamVideoThumbnailSpec(reactContext) {
13+
override fun getName(): String = NAME
14+
15+
override fun createVideoThumbnails(urls: ReadableArray, promise: Promise) {
16+
val urlList = mutableListOf<String>()
17+
for (index in 0 until urls.size()) {
18+
urlList.add(urls.getString(index) ?: "")
19+
}
20+
21+
executor.execute {
22+
try {
23+
val thumbnails = StreamVideoThumbnailGenerator.generateThumbnails(reactApplicationContext, urlList)
24+
val result = Arguments.createArray()
25+
thumbnails.forEach { thumbnail ->
26+
val thumbnailMap = Arguments.createMap()
27+
if (thumbnail.uri != null) {
28+
thumbnailMap.putString("uri", thumbnail.uri)
29+
} else {
30+
thumbnailMap.putNull("uri")
31+
}
32+
if (thumbnail.error != null) {
33+
thumbnailMap.putString("error", thumbnail.error)
34+
} else {
35+
thumbnailMap.putNull("error")
36+
}
37+
result.pushMap(thumbnailMap)
38+
}
39+
promise.resolve(result)
40+
} catch (error: Throwable) {
41+
promise.reject("stream_video_thumbnail_error", error.message, error)
42+
}
43+
}
44+
}
45+
46+
companion object {
47+
const val NAME = "StreamVideoThumbnail"
48+
private val executor = Executors.newCachedThreadPool()
49+
}
50+
}

0 commit comments

Comments
 (0)