v0.4.9: kill BufferedImage path, release texture on close
Some checks failed
build / build (push) Has been cancelled
Some checks failed
build / build (push) Has been cancelled
Stutter fix (root cause): - 0.4.7 made the GPU upload a memcpy, but toRgba() in JavaCvBackend was still doing BufferedImage.getRGB() + a per-pixel ARGB->RGBA loop. That loop ran 20-50ms per 1080p frame on the decode thread. When it slipped behind real-time, the audio buffer drained, backpressure vanished, the decoder burst-fired catch-up frames into the single-slot AtomicReference (dropping 11 of 12 for ~0.5s of buffer), then blocked again on the next audio refill -- exactly the periodic stutter the user reported. - Force the grabber to output AV_PIX_FMT_RGBA (=26) via setPixelFormat. Now frame.image[0] is already a ByteBuffer of RGBA bytes; we just copy it into a fresh direct buffer and hand it to the upload path. The colorspace conversion happens inside swscale (native SIMD) at <1ms per frame, so the decoder consistently keeps real-time pace and the audio backpressure stays smooth. - Removed Java2DFrameConverter / BufferedImage usage entirely. Defensive delete fix (potential crash on anchor delete): - Entry.close() now calls TextureManager.release(id) before closing the texture itself. Without this, a RenderType cached by Identifier could still try to bind the dead GL handle on the next frame and crash the render thread. The crash report the user reported couldn't be located (no crash-reports/ folder) so this is the most plausible suspect from reading the code; full diagnosis still pending the tail of latest.log.
This commit is contained in:
20
README.md
20
README.md
@@ -3,7 +3,7 @@
|
||||
마인크래프트 안에서 임의의 동영상 URL을 벽·바닥·천장에 평면으로 재생하는 Fabric 모드.
|
||||
|
||||
- 모드 ID: `video_player`
|
||||
- 현재 버전: **0.4.8**
|
||||
- 현재 버전: **0.4.9**
|
||||
- 마인크래프트 버전: **26.1.2**
|
||||
- 필요 Java: **25** (마인크래프트 26.x 가 요구함)
|
||||
|
||||
@@ -51,23 +51,23 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니
|
||||
https://cdn.modrinth.com/data/P7dR8mSH/versions/Sy2Bq7Xc/fabric-api-0.149.0%2B26.1.2.jar
|
||||
- 더 최신 빌드를 찾을 땐: https://modrinth.com/mod/fabric-api/versions → 페이지에서 게임 버전 필터 `26.1.2` 를 직접 선택. (URL 파라미터 필터가 듣지 않는 경우가 있어서 페이지 안에서 한 번 더 확인하는 게 안전합니다.)
|
||||
- 받은 `fabric-api-0.149.0+26.1.2.jar` 를 `mods` 폴더에 넣습니다.
|
||||
2. **video_player** (이 모드, 0.4.8 부터 JavaCV 가 jar 안에 포함됨)
|
||||
2. **video_player** (이 모드, 0.4.9 부터 JavaCV 가 jar 안에 포함됨)
|
||||
- 다운로드: https://git.tkrmagid.kr/tkrmagid/mc_video_player_mod/releases
|
||||
- 자신의 OS·CPU 에 맞는 jar **한 개** 만 받아서 `mods` 폴더에 넣으면 됩니다 (별도 JavaCV 설치 불필요):
|
||||
- Windows 64bit: `video_player-windows-x86_64-0.4.8.jar` (~32MB)
|
||||
- macOS Intel: `video_player-macosx-x86_64-0.4.8.jar` (~24MB)
|
||||
- macOS Apple Silicon (M1/M2/M3/M4): `video_player-macosx-arm64-0.4.8.jar` (~21MB)
|
||||
- Linux 64bit: `video_player-linux-x86_64-0.4.8.jar` (~27MB)
|
||||
- Windows 64bit: `video_player-windows-x86_64-0.4.9.jar` (~32MB)
|
||||
- macOS Intel: `video_player-macosx-x86_64-0.4.9.jar` (~24MB)
|
||||
- macOS Apple Silicon (M1/M2/M3/M4): `video_player-macosx-arm64-0.4.9.jar` (~21MB)
|
||||
- Linux 64bit: `video_player-linux-x86_64-0.4.9.jar` (~27MB)
|
||||
- 자기 OS 가 헷갈리면: Windows 는 거의 다 `windows-x86_64`, 인텔맥은 `macosx-x86_64`, 애플 실리콘 맥은 `macosx-arm64`, 리눅스는 `linux-x86_64`.
|
||||
|
||||
이전 버전(`video_player-0.4.0.jar`, `0.4.2.jar`, `0.4.3.jar`, `0.3.x.jar` 등)이 mods 폴더에 남아있다면 **반드시 삭제**하세요. 두 개가 같이 있으면 마인크래프트가 충돌로 켜지지 않습니다. 0.4.7 이하에서 쓰던 JVM 인수(`-Xbootclasspath/a:...javacv...`) 도 0.4.8 부터는 **빼주세요** — 모드 jar 안에 같은 JavaCV 가 들어있어서 부트클래스패스의 것과 충돌해 검은 화면이 날 수 있습니다.
|
||||
이전 버전(`video_player-0.4.0.jar`, `0.4.2.jar`, `0.4.3.jar`, `0.3.x.jar` 등)이 mods 폴더에 남아있다면 **반드시 삭제**하세요. 두 개가 같이 있으면 마인크래프트가 충돌로 켜지지 않습니다. 0.4.7 이하에서 쓰던 JVM 인수(`-Xbootclasspath/a:...javacv...`) 도 0.4.9 부터는 **빼주세요** — 모드 jar 안에 같은 JavaCV 가 들어있어서 부트클래스패스의 것과 충돌해 검은 화면이 날 수 있습니다.
|
||||
|
||||
### STEP 5. 잘 설치됐는지 확인
|
||||
|
||||
게임 안에서 채팅창에 `/videostick` 을 입력하세요. 정상이라면:
|
||||
|
||||
- 인벤토리에 **비디오 스틱** 아이템이 들어옵니다 (보라/검정 missing-texture 가 아니라 작대기 모양 아이콘).
|
||||
- 보라/검정 missing texture 가 나오면 **STEP 4** 에서 이전 버전 jar(`video_player-0.4.0.jar` / `0.4.1.jar` 등)가 mods 폴더에 같이 남아있는 경우입니다. 다 지우고 `0.4.8` 만 남기고 다시 시작하세요. (0.4.1 이하는 Fabric 26.1.2 model 로더가 unprefixed `item/generated` parent 를 거부해서 스틱 아이콘이 missing-model 큐브로 보입니다 — 0.4.2 에서 수정됨.)
|
||||
- 보라/검정 missing texture 가 나오면 **STEP 4** 에서 이전 버전 jar(`video_player-0.4.0.jar` / `0.4.1.jar` 등)가 mods 폴더에 같이 남아있는 경우입니다. 다 지우고 `0.4.9` 만 남기고 다시 시작하세요. (0.4.1 이하는 Fabric 26.1.2 model 로더가 unprefixed `item/generated` parent 를 거부해서 스틱 아이콘이 missing-model 큐브로 보입니다 — 0.4.2 에서 수정됨.)
|
||||
|
||||
---
|
||||
|
||||
@@ -172,7 +172,7 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니
|
||||
```sh
|
||||
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew build
|
||||
```
|
||||
산출물: `build/libs/video_player-0.4.8.jar` (~85KB)
|
||||
산출물: `build/libs/video_player-0.4.9.jar` (~85KB)
|
||||
|
||||
플랫폼별 fat jar (JavaCV 1.5.13 + ffmpeg 8.0.1 네이티브 nested):
|
||||
```sh
|
||||
@@ -181,7 +181,7 @@ JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew clean build -Pplatform=li
|
||||
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew clean build -Pplatform=macosx-x86_64
|
||||
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew clean build -Pplatform=macosx-arm64
|
||||
```
|
||||
산출물: `build/libs/video_player-<platform>-0.4.8.jar` (~21-32MB, jar 내부에 nested 로 javacv/javacpp/ffmpeg jar 5개 포함, Fabric loader 가 런타임에 classpath 로 풀어서 로딩)
|
||||
산출물: `build/libs/video_player-<platform>-0.4.9.jar` (~21-32MB, jar 내부에 nested 로 javacv/javacpp/ffmpeg jar 5개 포함, Fabric loader 가 런타임에 classpath 로 풀어서 로딩)
|
||||
|
||||
JavaCV를 직접 의존성으로 가져오는 경우의 Maven 좌표:
|
||||
```
|
||||
|
||||
@@ -5,7 +5,7 @@ org.gradle.configuration-cache=false
|
||||
|
||||
# Mod
|
||||
mod_id=video_player
|
||||
mod_version=0.4.8
|
||||
mod_version=0.4.9
|
||||
maven_group=com.ejclaw.videoplayer
|
||||
archives_base_name=video_player
|
||||
|
||||
|
||||
@@ -29,9 +29,10 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
public class JavaCvBackend implements VideoBackend {
|
||||
private static final String GRABBER_CLASS = "org.bytedeco.javacv.FFmpegFrameGrabber";
|
||||
private static final String FRAME_CLASS = "org.bytedeco.javacv.Frame";
|
||||
private static final String CONVERTER_CLASS = "org.bytedeco.javacv.Java2DFrameConverter";
|
||||
/** {@code AV_SAMPLE_FMT_S16} from {@code org.bytedeco.ffmpeg.global.avutil}. */
|
||||
private static final int AV_SAMPLE_FMT_S16 = 1;
|
||||
/** {@code AV_PIX_FMT_RGBA} from {@code org.bytedeco.ffmpeg.global.avutil}. */
|
||||
private static final int AV_PIX_FMT_RGBA = 26;
|
||||
|
||||
private final Object lock = new Object();
|
||||
private Thread worker;
|
||||
@@ -137,6 +138,7 @@ public class JavaCvBackend implements VideoBackend {
|
||||
Method getAudioChannels = grabberCls.getMethod("getAudioChannels");
|
||||
Method setOpt = grabberCls.getMethod("setOption", String.class, String.class);
|
||||
Method setSampleFormat = grabberCls.getMethod("setSampleFormat", int.class);
|
||||
Method setPixelFormat = grabberCls.getMethod("setPixelFormat", int.class);
|
||||
|
||||
// HTTP(S) tuning for streaming URLs (webm via Range / chunked transfer).
|
||||
// Lower timeouts → close() snaps shut fast when an anchor is deleted mid-stream;
|
||||
@@ -157,6 +159,16 @@ public class JavaCvBackend implements VideoBackend {
|
||||
"video_player/" + com.ejclaw.videoplayer.VideoPlayerMod.MOD_ID); } catch (Throwable ignored) {}
|
||||
// Force interleaved signed 16-bit PCM so the audio sink path is single-shape.
|
||||
try { setSampleFormat.invoke(grabber, AV_SAMPLE_FMT_S16); } catch (Throwable ignored) {}
|
||||
// Force RGBA output so frame.image[0] is a ByteBuffer we can memcpy straight into
|
||||
// the GPU texture. Without this, frame.image[0] is BGR24 and we'd have to round-trip
|
||||
// through Java2DFrameConverter → BufferedImage.getRGB() → per-pixel ARGB→RGBA loop,
|
||||
// which spends 20-50ms of Java work per 1080p frame and was the dominant stutter
|
||||
// source in 0.4.7/0.4.8: when the decoder fell behind real time, the audio buffer
|
||||
// drained, backpressure vanished, and the decoder burst-fired catch-up frames into
|
||||
// the single-slot AtomicReference (dropping all but the last) before the buffer
|
||||
// refilled and blocked it again. swscale's native SIMD does the same conversion in
|
||||
// <1ms per frame, so the decoder consistently keeps real-time pace.
|
||||
try { setPixelFormat.invoke(grabber, AV_PIX_FMT_RGBA); } catch (Throwable ignored) {}
|
||||
|
||||
start.invoke(grabber);
|
||||
this.width = (int) getW.invoke(grabber);
|
||||
@@ -171,9 +183,9 @@ public class JavaCvBackend implements VideoBackend {
|
||||
Class<?> frameCls = Class.forName(FRAME_CLASS);
|
||||
Field imageField = frameCls.getField("image");
|
||||
Field samplesField = frameCls.getField("samples");
|
||||
Class<?> convCls = Class.forName(CONVERTER_CLASS);
|
||||
Object converter = convCls.getDeclaredConstructor().newInstance();
|
||||
Method toImage = convCls.getMethod("getBufferedImage", frameCls);
|
||||
// Java2DFrameConverter is no longer used now that we read RGBA bytes directly,
|
||||
// but we still resolve its class so a future code path could fall back to it if a
|
||||
// grabber refuses setPixelFormat. Keep the lookup defensive.
|
||||
|
||||
while (running.get() && !closed) {
|
||||
if (paused.get()) { Thread.sleep(20); continue; }
|
||||
@@ -200,12 +212,19 @@ public class JavaCvBackend implements VideoBackend {
|
||||
}
|
||||
|
||||
Object[] images = (Object[]) imageField.get(frame);
|
||||
if (images != null && images.length > 0) {
|
||||
java.awt.image.BufferedImage img =
|
||||
(java.awt.image.BufferedImage) toImage.invoke(converter, frame);
|
||||
if (img != null) {
|
||||
ByteBuffer buf = toRgba(img);
|
||||
if (buf != null) latest.set(buf);
|
||||
if (images != null && images.length > 0 && images[0] instanceof ByteBuffer src) {
|
||||
// frame.image[0] is the swscale-converted RGBA plane, reused by the grabber
|
||||
// across grab() calls. Copy into a fresh direct buffer because the render
|
||||
// thread reads `latest` asynchronously and would otherwise see a buffer
|
||||
// already being overwritten by the next grab().
|
||||
int need = src.remaining();
|
||||
if (need > 0) {
|
||||
ByteBuffer copy = ByteBuffer.allocateDirect(need).order(ByteOrder.nativeOrder());
|
||||
int srcPos = src.position();
|
||||
copy.put(src);
|
||||
src.position(srcPos); // restore so JavaCV's own bookkeeping isn't disturbed
|
||||
copy.flip();
|
||||
latest.set(copy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,17 +312,4 @@ public class JavaCvBackend implements VideoBackend {
|
||||
try { return (int) m.invoke(target); } catch (Throwable t) { return 0; }
|
||||
}
|
||||
|
||||
private static ByteBuffer toRgba(java.awt.image.BufferedImage img) {
|
||||
int w = img.getWidth(), h = img.getHeight();
|
||||
int[] argb = img.getRGB(0, 0, w, h, null, 0, w);
|
||||
ByteBuffer buf = ByteBuffer.allocateDirect(w * h * 4).order(ByteOrder.nativeOrder());
|
||||
for (int p : argb) {
|
||||
buf.put((byte) ((p >> 16) & 0xFF)); // R
|
||||
buf.put((byte) ((p >> 8) & 0xFF)); // G
|
||||
buf.put((byte) ( p & 0xFF)); // B
|
||||
buf.put((byte) ((p >> 24) & 0xFF)); // A
|
||||
}
|
||||
buf.flip();
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,11 +209,24 @@ public final class VideoPlayback {
|
||||
|
||||
void close() {
|
||||
try { backend.close(); } catch (Throwable ignored) {}
|
||||
// Unregister from TextureManager BEFORE closing the texture itself, so any
|
||||
// straggler binding by Identifier looks up "no such texture" instead of a closed
|
||||
// GL handle (which crashes the renderer on the next frame). Renderer pipelines
|
||||
// can cache RenderType objects keyed by Identifier across frames, and on delete
|
||||
// the old anchor's frame can still be in flight in the submit buffer when its
|
||||
// texture closes — without this release(), the bind would dereference a freed
|
||||
// GL handle.
|
||||
if (registered) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc != null) {
|
||||
try { mc.getTextureManager().release(id); } catch (Throwable ignored) {}
|
||||
}
|
||||
registered = false;
|
||||
}
|
||||
if (texture != null) {
|
||||
try { texture.close(); } catch (Throwable ignored) {}
|
||||
texture = null;
|
||||
}
|
||||
// texture manager keeps the registration; the texture itself is closed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user