8 Commits

Author SHA1 Message Date
tkrmagid
c53ebcc30d v0.4.13: fix delete-while-playing race, /videoCache, config additions, name resolution
Some checks failed
build / build (push) Has been cancelled
Crash fix (4K delete EXCEPTION_ACCESS_VIOLATION):
- JavaCvBackend.stopWorker() no longer calls grabber.close() from caller thread.
  Only flips running=false, stops/flushes audio line, then interrupt+join(2s). The
  worker's own finally still closes grabber from the decoder thread, so the av_frame
  native plane is never freed mid-memCopy.
- Validate memCopy length against ByteBuffer.capacity() AND width*height*4 before
  copying, and re-check running/closed inside the frameLock.

Config:
- max_preload_mb (default 1024) — replaces the hard-coded 512 MB cap in VideoCache.
  Pushed to clients at join via CachePolicyPayload.
- render_distance_blocks (default 128) — replaces the hard-coded 128 in
  VideoAnchorRenderer.getViewDistance(). Mirrored client-side via ClientPolicy.

Command rename: /videopreload → /videoCache add|list|remove
- Persistent named index in cache_entries (server config).
- /videoCache list prints clickable URLs (ClickEvent.OpenUrl).
- /videoCache remove broadcasts DeleteCachePayload so each client purges its disk
  cache file.

Name resolution:
- /videoPlace ... <urlOrName> and the GUI save path both accept a /videoCache name
  in place of an http(s) URL; VideoPlayerConfig.resolveUrlOrName() does the lookup
  server-side before persisting to the anchor BE.

Cleanup:
- Drop the lowercase Brigadier aliases (videoplace, videostick, videodelete,
  videomute) — keep camelCase only.
2026-05-16 04:03:43 +09:00
tkrmagid
6896870186 v0.4.12: single-sided anchor quad (drop back face)
Some checks failed
build / build (push) Has been cancelled
The anchor sits flush against a wall by design, so the back face never
gets a viewer. Drawing it cost an extra 4 vertices and doubled the
fragment shader work for the video texture per anchor for no visible
benefit. Removing it also fixes the mirrored-texture artifact a player
would see if they clipped behind the wall (e.g. spectator mode).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 03:29:03 +09:00
tkrmagid
9b99283b70 v0.4.11: video frame ring buffer + decoder stats + 0.1s audio buffer
Some checks failed
build / build (push) Has been cancelled
0.4.10 still played at ~2-5 fps even though the decoder buffer was
preallocated. Root cause: the single-slot staging buffer was paced by
SourceDataLine backpressure at the audio buffer's granularity (~0.5 s),
so the decoder burst-produced ~12 video frames into the slot while audio
drained, the consumer saw only the last frame of each burst, then the
decoder stalled until audio drained again. Net visible rate ~ source_fps
/ frames_per_burst.

Fix:
- Replace single staging slot with a 4-slot ring (preallocated, FIFO).
  Decoder writes to ringTail; if full, overwrites oldest and bumps
  droppedFrames so we can see overflow in the log. Render thread drains
  oldest under the same lock — no allocation, no race.
- Shrink audio driver buffer 0.5 s → 0.1 s so the decoder is paced more
  tightly. Burst size collapses from ~12 frames to 2-3, which fits
  inside the ring.
- Log decoder spec on start (WxH @ fps, audio Hz x ch, ring depth) and
  produced/consumed/dropped counters every ~10 s. Lets the user log
  confirm whether the decoder is keeping real-time pace and whether the
  ring is overflowing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 02:10:47 +09:00
tkrmagid
cee01bd448 v0.4.10: preallocate decoder direct buffer, fix 5fps video
Some checks failed
build / build (push) Has been cancelled
0.4.9 allocated a fresh w*h*4 direct ByteBuffer on every grab() — at
1080p × 24fps that's ~192 MB/s of direct memory churn (page zero-fill +
Cleaner enqueue). The decoder thread spent most of its frame budget on
memory bookkeeping instead of decoding, fell behind real time, and the
single-slot AtomicReference saw bursty refills that the render thread
could only sample at ~5fps. Game thread was fine, only the video looked
like 5fps.

Replace it with one preallocated direct buffer per backend instance,
filled under a short-held lock on the decoder side. Swap the pollFrame()
ByteBuffer-returning API for consumeFrame(dstAddr, maxBytes) so the
render thread memcpys straight from staging buffer → GPU texture
pointer under the same lock — no allocation, no race window between
"got buffer" and "decoder overwrote it".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:55:04 +09:00
tkrmagid
3d4843dd0d v0.4.9: kill BufferedImage path, release texture on close
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.
2026-05-15 22:32:32 +09:00
tkrmagid
dbc76e0083 v0.4.8: bundle JavaCV per-platform via Fabric jarJar (no separate install)
Some checks failed
build / build (push) Has been cancelled
- build.gradle: optional -Pplatform=<id> property switches the build into
  a fat-jar mode where javacv 1.5.13 + javacpp + ffmpeg 8.0.1 (java + the
  picked platform's native jar) are all nested into the mod jar via
  Fabric loom's `include` directive. Fabric loader unpacks them at
  runtime, so users no longer need -Xbootclasspath/a:... or 5 separate
  jars in .minecraft/libraries.
- Without -Pplatform, the build produces the same small ~85KB vanilla
  jar as before, so devs/server-side and bring-your-own-JavaCV setups
  still work.
- Per-platform artifacts: video_player-<platform>-0.4.8.jar where
  <platform> ∈ windows-x86_64 / linux-x86_64 / macosx-x86_64 /
  macosx-arm64. Sizes 21-32MB.
- README: STEP 5 (the long JavaCV manual-install + -Xbootclasspath
  section) is gone. New STEP 4 just says 'pick the jar for your OS'.
  Also added a warning about removing the old -Xbootclasspath JVM arg
  when upgrading, since duplicate JavaCV on the boot classpath can
  silently break decoding.
2026-05-15 22:15:34 +09:00
tkrmagid
7b7fd7f320 v0.4.7: smoother playback via memcpy upload + render-rate pump
Some checks failed
build / build (push) Has been cancelled
- Replace per-pixel RGBA->ABGR loop in Entry.upload() with a single
  MemoryUtil.memCopy() into NativeImage's native buffer. The two layouts
  are identical when viewed as little-endian bytes, so no swap is needed.
  Cuts 1080p upload time from a ~2M-iter Java loop to one native memcpy.
- Move the frame-pump tick from 20Hz client tick (END_CLIENT_TICK) to
  per-render-frame (LevelRenderEvents.START_MAIN). At 60+fps display vs
  24fps source, this removes the worst stutter window where a decoded
  frame waited up to 50ms for the next tick. Distance-gain math stays on
  20Hz where it's plenty.
- Bump version 0.4.6 -> 0.4.7 in gradle.properties and README.
2026-05-15 22:06:15 +09:00
tkrmagid
d34dc97671 v0.4.6: server config for auto-preload on join
Some checks failed
build / build (push) Has been cancelled
- new: config/video_player.json on first server start. Field preload_urls
  is a list of HTTP(S) URLs (≤256 chars each) that the server broadcasts
  via PreloadPayload to every player when they finish joining, so common
  videos are warmed into each client's video_player_cache/ before they
  ever play. Reuses the same PreloadPayload + VideoCache path as
  /videopreload, so chat feedback ("[videopreload] 완료") still applies.
- config is loaded once at mod init; invalid entries are dropped with a
  WARN line. Edit + restart server to apply changes.
2026-05-15 21:58:26 +09:00
23 changed files with 972 additions and 254 deletions

135
README.md
View File

@@ -3,7 +3,7 @@
마인크래프트 안에서 임의의 동영상 URL을 벽·바닥·천장에 평면으로 재생하는 Fabric 모드.
- 모드 ID: `video_player`
- 현재 버전: **0.4.5**
- 현재 버전: **0.4.12**
- 마인크래프트 버전: **26.1.2**
- 필요 Java: **25** (마인크래프트 26.x 가 요구함)
@@ -51,91 +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** (이 모드)
2. **video_player** (이 모드, 0.4.12 부터 JavaCV 가 jar 안에 포함됨)
- 다운로드: https://git.tkrmagid.kr/tkrmagid/mc_video_player_mod/releases
- `video_player-0.4.5.jar` 를 다운로드해서 같은 `mods` 폴더에 넣습니다.
- 자신의 OS·CPU 에 맞는 jar **한 개** 만 받아서 `mods` 폴더에 넣으면 됩니다 (별도 JavaCV 설치 불필요):
- Windows 64bit: `video_player-windows-x86_64-0.4.12.jar` (~32MB)
- macOS Intel: `video_player-macosx-x86_64-0.4.12.jar` (~24MB)
- macOS Apple Silicon (M1/M2/M3/M4): `video_player-macosx-arm64-0.4.12.jar` (~21MB)
- Linux 64bit: `video_player-linux-x86_64-0.4.12.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 폴더에 남아있다면 **반드시 삭제**하세요. 두 개가 같이 있으면 마인크래프트가 충돌로 켜지지 않습니다.
이전 버전(`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.12 부터는 **빼주세요** — 모드 jar 안에 같은 JavaCV 가 들어있어서 부트클래스패스의 것과 충돌해 검은 화면이 날 수 있습니다.
### STEP 5. 영상 재생 라이브러리 (JavaCV) 설치
여기까지만 하면 마인크래프트는 켜지지만, 영상 자리에 검은 판만 보입니다. 진짜 영상을 재생하려면 **JavaCV** 라는 디코더 라이브러리가 필요합니다.
> 솔직한 안내: 마인크래프트 공식 런처는 mods 폴더에 들어있는 일반 라이브러리 jar를 자동으로 읽어 주지 않습니다. 그래서 JavaCV 설치 절차가 조금 번거롭습니다. 가장 쉬운 길과 공식 런처에서 동작시키는 길 두 가지를 안내합니다.
#### 5-A. 가장 쉬운 길: Prism Launcher 로 갈아타기 (선택)
공식 런처 대신 무료 오픈소스 런처인 **Prism Launcher** 를 쓰면 클릭 몇 번으로 JavaCV를 라이브러리로 추가할 수 있습니다. 게임 자체는 같고, 정품 마인크래프트 계정으로 로그인하는 것도 동일합니다.
1. https://prismlauncher.org/download/ 에서 다운로드 → 설치
2. Prism에서 인스턴스 → 마인크래프트 26.1.2 + Fabric Loader 0.19.2 선택해서 생성
3. 인스턴스 우클릭 → **Edit** → 왼쪽의 **Version** 탭 → **Add to Minecraft.jar** 버튼
4. 아래 STEP 5-B 의 1번 항목에서 받은 JavaCV jar 5개를 한꺼번에 선택해서 추가
5. **Launch** 로 실행
이 방법이 가장 안정적이고 빠릅니다.
#### 5-B. 공식 런처에서 동작시키기
공식 런처를 계속 쓰고 싶다면 아래 절차를 따르세요.
1. **JavaCV 1.5.13 다운로드**
- 다운로드 링크: https://github.com/bytedeco/javacv/releases/download/1.5.13/javacv-platform-1.5.13-bin.zip (2026-02-22 릴리스, FFmpeg 8.0.1 동봉)
- 압축을 풀고 `javacv-platform-1.5.13-bin` 폴더 안에서 **다음 jar 5개**를 골라 둡니다 (다른 파일은 안 씁니다):
- `javacv.jar`
- `javacpp.jar`
- 자신의 OS에 맞는 **javacpp 네이티브 jar** (이게 빠지면 검은 화면 — JNI bridge `jnijavacpp` 로딩에 꼭 필요):
- Windows 64bit: `javacpp-1.5.13-windows-x86_64.jar`
- macOS Intel: `javacpp-1.5.13-macosx-x86_64.jar`
- macOS Apple Silicon (M1/M2/M3/M4): `javacpp-1.5.13-macosx-arm64.jar`
- Linux 64bit: `javacpp-1.5.13-linux-x86_64.jar`
- `ffmpeg.jar`
- 자신의 OS에 맞는 **ffmpeg 네이티브 jar**:
- Windows 64bit: `ffmpeg-8.0.1-1.5.13-windows-x86_64.jar`
- macOS Intel: `ffmpeg-8.0.1-1.5.13-macosx-x86_64.jar`
- macOS Apple Silicon (M1/M2/M3/M4): `ffmpeg-8.0.1-1.5.13-macosx-arm64.jar`
- Linux 64bit: `ffmpeg-8.0.1-1.5.13-linux-x86_64.jar`
2. **이 5개 jar 를 게임이 읽도록 등록**
- 가장 안전한 위치: `.minecraft/libraries/javacv/` 폴더를 새로 만들고 5개 jar를 거기에 복사하세요. (긴 파일명이 부담스러우면 `javacpp-1.5.13-windows-x86_64.jar``javacpp-windows-x86_64.jar`, `ffmpeg-8.0.1-1.5.13-windows-x86_64.jar``ffmpeg-windows-x86_64.jar` 식으로 짧게 rename해도 됩니다. 아래 예시는 짧은 이름 기준.)
- 그 다음, 공식 런처에서 fabric 프로필 옆 **편집** 또는 **설치 설정** → 화면 아래쪽 **"JVM 인수"** (Java arguments) 칸을 켜고 기존 인수 **끝**에 한 칸 띄우고 다음 한 줄을 추가합니다.
**반드시 절대경로(`C:\Users\...`)로 적어주세요.** 마인크래프트 공식 런처는 JVM 인수의 `%APPDATA%`·`%USERPROFILE%` 같은 환경변수를 풀어주지 않고 글자 그대로 Java 에 넘깁니다. 그러면 boot classpath 가 빈 상태가 되어 영상이 안 나옵니다.
Windows (사용자명 `홍길동` 예시):
```
-Xbootclasspath/a:C:\Users\홍길동\AppData\Roaming\.minecraft\libraries\javacv\javacv.jar;C:\Users\홍길동\AppData\Roaming\.minecraft\libraries\javacv\javacpp.jar;C:\Users\홍길동\AppData\Roaming\.minecraft\libraries\javacv\javacpp-windows-x86_64.jar;C:\Users\홍길동\AppData\Roaming\.minecraft\libraries\javacv\ffmpeg.jar;C:\Users\홍길동\AppData\Roaming\.minecraft\libraries\javacv\ffmpeg-windows-x86_64.jar
```
(`C:\Users\<본인 윈도우 사용자명>\AppData\Roaming\` 부분을 본인 사용자 폴더로 바꾸세요. 윈도우키+R → `cmd``echo %APPDATA%` 로 정확한 경로 확인 가능.)
macOS (Apple Silicon 예시):
```
-Xbootclasspath/a:/Users/사용자이름/Library/Application Support/minecraft/libraries/javacv/javacv.jar:/Users/사용자이름/Library/Application Support/minecraft/libraries/javacv/javacpp.jar:/Users/사용자이름/Library/Application Support/minecraft/libraries/javacv/javacpp-macosx-arm64.jar:/Users/사용자이름/Library/Application Support/minecraft/libraries/javacv/ffmpeg.jar:/Users/사용자이름/Library/Application Support/minecraft/libraries/javacv/ffmpeg-macosx-arm64.jar
```
Linux:
```
-Xbootclasspath/a:/home/사용자이름/.minecraft/libraries/javacv/javacv.jar:/home/사용자이름/.minecraft/libraries/javacv/javacpp.jar:/home/사용자이름/.minecraft/libraries/javacv/javacpp-linux-x86_64.jar:/home/사용자이름/.minecraft/libraries/javacv/ffmpeg.jar:/home/사용자이름/.minecraft/libraries/javacv/ffmpeg-linux-x86_64.jar
```
주의:
- Windows는 jar 사이를 **세미콜론(`;`)** 으로 구분합니다.
- macOS/Linux는 **콜론(`:`)** 으로 구분합니다.
- 경로에 띄어쓰기가 있으면(특히 macOS의 `Application Support`) 전체 인수를 큰따옴표로 감싸세요.
- `사용자이름` 부분은 실제 자신의 사용자 폴더 이름으로 바꾸세요.
- `javacpp-...` 와 `ffmpeg-...` 부분의 jar 이름은 OS에 맞춰 1번에서 고른 그 파일명 그대로 적습니다 (zip 안에는 `javacpp-1.5.13-windows-x86_64.jar`, `ffmpeg-8.0.1-1.5.13-windows-x86_64.jar` 같은 긴 이름으로 들어 있고, 위 예시는 짧게 rename한 것 기준입니다).
- **5개 모두** 적어야 합니다. `javacpp-<os>.jar` (네이티브) 가 빠지면 JNI bridge 가 로딩 안 돼서 영상이 검게만 보입니다.
3. 저장 후 **플레이** 를 누르면 영상이 재생됩니다.
> 절차가 너무 번거롭다고 느끼면 **5-A 의 Prism Launcher** 를 권장합니다. 동일한 jar 를 클릭으로 끌어다 놓기만 하면 끝납니다.
### STEP 6. 잘 설치됐는지 확인
### STEP 5. 잘 설치됐는지 확인
게임 안에서 채팅창에 `/videostick` 을 입력하세요. 정상이라면:
- 인벤토리에 **비디오 스틱** 아이템이 들어옵니다 (보라/검정 missing-texture 가 아니라 작대기 모양 아이콘).
- 보라/검정 missing texture 가 나오면 **STEP 4** 에서 이전 버전 jar(`video_player-0.4.0.jar` / `0.4.1.jar` 등)가 mods 폴더에 같이 남아있는 경우입니다. 다 지우고 `0.4.5` 만 남기고 다시 시작하세요. (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.12` 만 남기고 다시 시작하세요. (0.4.1 이하는 Fabric 26.1.2 model 로더가 unprefixed `item/generated` parent 를 거부해서 스틱 아이콘이 missing-model 큐브로 보입니다 — 0.4.2 에서 수정됨.)
---
@@ -193,22 +125,63 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니
> 0.4.5 부터 오디오 거리 감쇠가 **판때기 중앙**을 기준으로 계산됩니다. 예전엔 앵커 블록(보통 화면 모서리)을 기준으로 측정해서 큰 화면일수록 소리가 한쪽에서 들리는 느낌이었습니다.
### 서버 config 로 자동 프리로드 (0.4.6+)
서버에 모드를 넣고 한 번 실행하면 `<서버폴더>/config/video_player.json` 가 자동 생성됩니다. 이 파일에 자주 쓰는 영상 URL 을 적어두면, 플레이어가 접속할 때마다 서버가 자동으로 그 URL 들의 프리로드 요청을 보냅니다 (= `/videopreload` 를 사람마다 친 것과 같음).
기본 생성된 파일 예시:
```json
{
"_comment": "preload_urls: HTTP(S) video URLs broadcast to every player on join. ...",
"preload_urls": []
}
```
사용 예시 — 인트로 영상과 BGM 영상을 모든 접속자가 미리 받도록:
```json
{
"preload_urls": [
"https://video.example.com/intro.mp4",
"https://video.example.com/bgm-loop.webm"
]
}
```
규칙:
- 각 URL 은 `http://` 또는 `https://` 시작, 256자 이하 (그 외는 무시되고 서버 로그에 WARN)
- 파일 수정 후 적용하려면 **서버 재시작** 이 필요합니다 (config 는 시작 시 1회만 로딩)
- 접속한 플레이어 화면에는 `/videopreload` 와 동일한 `[videopreload] 다운로드 시작 / 완료` 채팅 메시지가 보입니다
- 이미 캐시된 URL 은 다시 다운로드하지 않습니다 (SHA-256 캐시 키)
- 싱글플레이는 자체적으로 통합 서버를 띄우므로, 통합 서버의 config 폴더(`<게임폴더>/config/video_player.json`)에도 같은 효과로 동작합니다
---
## 알려진 이슈
- JavaCV가 안 잡혀있으면 영상 자리만 잡힐 뿐 검게 보니다. 로그 파일(`.minecraft/logs/latest.log`)에서 `JavaCV not on classpath` WARN 또는 `JavaCV decode error: ...UnsatisfiedLinkError... jnijavacpp` 메시지로 확인 가능합니다. 후자는 `javacpp-<os>.jar` 네이티브 jar 가 빠진 케이스입니다 (STEP 5-B 1번의 5개 jar 모두 등록되어야 함).
- 영상 자리만 잡히고 검게 보이는 경우: 자신의 OS·CPU 와 다른 플랫폼의 jar 를 받았거나, 이전 버전(0.4.7 이하)의 `-Xbootclasspath/a:...javacv...` JVM 인수가 그대로 남아 있는 경우가 가장 흔합니다. 로그 파일(`.minecraft/logs/latest.log`)에서 `JavaCV not on classpath` WARN 또는 `UnsatisfiedLinkError... jnijavacpp` 메시지로 확인 가능합니다.
- 0.3.x 이하 버전에서 만든 영상은 새 버전(0.4.x) 에서 보이지 않으니 다시 배치해야 합니다.
---
## 개발자용 빌드
바닐라(JavaCV 미포함, 별도 설치 가정) 빌드:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew build
```
산출물: `build/libs/video_player-0.4.12.jar` (~85KB)
산출물: `build/libs/video_player-0.4.5.jar`
플랫폼별 fat jar (JavaCV 1.5.13 + ffmpeg 8.0.1 네이티브 nested):
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew clean build -Pplatform=windows-x86_64
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew clean build -Pplatform=linux-x86_64
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.12.jar` (~21-32MB, jar 내부에 nested 로 javacv/javacpp/ffmpeg jar 5개 포함, Fabric loader 가 런타임에 classpath 로 풀어서 로딩)
JavaCV를 직접 의존성으로 가져오는 경우의 Maven 좌표:
```

View File

@@ -7,8 +7,25 @@ plugins {
version = project.mod_version
group = project.maven_group
// Optional platform bundle target: `-Pplatform=windows-x86_64` etc. produces a fat jar
// with that platform's JavaCV + FFmpeg natives nested inside via Fabric's `include`
// directive. Without `-Pplatform`, the build matches pre-0.4.8 behavior (small jar,
// user supplies JavaCV separately).
def javacvPlatform = (project.findProperty('platform') ?: '').toString()
def bundleJavaCv = !javacvPlatform.isEmpty()
def javacvVersion = '1.5.13'
def ffmpegVersion = '8.0.1-1.5.13'
def supportedPlatforms = ['windows-x86_64', 'linux-x86_64', 'macosx-x86_64', 'macosx-arm64']
if (bundleJavaCv && !supportedPlatforms.contains(javacvPlatform)) {
throw new GradleException(
"Unknown -Pplatform=${javacvPlatform}. Allowed: ${supportedPlatforms.join(', ')}")
}
base {
archivesName = project.archives_base_name
archivesName = bundleJavaCv
? "${project.archives_base_name}-${javacvPlatform}"
: project.archives_base_name
}
repositories {
@@ -26,6 +43,19 @@ dependencies {
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
if (bundleJavaCv) {
// Nest the same 5 jars that the README's manual install step used to require, so
// users no longer need -Xbootclasspath/a. `include` adds them as jar-in-jar entries;
// Fabric loader unpacks and classloads them at runtime.
// `transitive = false` keeps us from dragging in opencv/openblas/etc — we only use
// FFmpegFrameGrabber / Frame / Java2DFrameConverter.
include(implementation("org.bytedeco:javacv:${javacvVersion}") { transitive = false })
include(implementation("org.bytedeco:javacpp:${javacvVersion}") { transitive = false })
include(implementation("org.bytedeco:ffmpeg:${ffmpegVersion}") { transitive = false })
include(implementation("org.bytedeco:javacpp:${javacvVersion}:${javacvPlatform}") { transitive = false })
include(implementation("org.bytedeco:ffmpeg:${ffmpegVersion}:${javacvPlatform}") { transitive = false })
}
}
processResources {

View File

@@ -5,7 +5,7 @@ org.gradle.configuration-cache=false
# Mod
mod_id=video_player
mod_version=0.4.5
mod_version=0.4.13
maven_group=com.ejclaw.videoplayer
archives_base_name=video_player

View File

@@ -14,6 +14,7 @@ import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientBlockEntityEvents
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.client.rendering.v1.BlockEntityRendererRegistry;
import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents;
import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
@@ -54,10 +55,15 @@ public class VideoPlayerClient implements ClientModInitializer {
return InteractionResult.PASS;
});
ClientTickEvents.END_CLIENT_TICK.register(client -> {
VideoPlayback.tick();
updateDistanceGains(client);
});
// Pump frame uploads on every render frame (60+Hz) rather than every client tick
// (20Hz). At 24fps source, a 20Hz pump can skip a frame whenever a tick window happens
// to miss the decode boundary, producing visible micro-stutter even when frames are
// ready. Polling at render rate lets the texture latch as soon as a frame is decoded.
LevelRenderEvents.START_MAIN.register(ctx -> VideoPlayback.tick());
// Distance-gain math is cheap and only audible state — 20Hz is plenty and avoids
// recomputing it on every render frame.
ClientTickEvents.END_CLIENT_TICK.register(VideoPlayerClient::updateDistanceGains);
ClientTickEvents.END_LEVEL_TICK.register(world -> {
// no-op for now

View File

@@ -0,0 +1,262 @@
package com.ejclaw.videoplayer;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.fabricmc.loader.api.FabricLoader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Server-side mod config, stored at {@code <gameDir>/config/video_player.json}.
*
* <p>Schema (auto-generated on first start):
* <pre>{@code
* {
* "max_preload_mb": 1024,
* "preload_urls": [
* "https://example.com/intro.mp4"
* ],
* "cache_entries": [
* { "name": "intro", "url": "https://example.com/intro.mp4" }
* ]
* }
* }</pre>
*
* <p>{@code max_preload_mb} is the hard ceiling each client enforces on a single video download
* (default 1024 MB ≈ 1 GB; enough headroom for ~50 short FHD clips). Pushed to every client on
* join via {@link com.ejclaw.videoplayer.net.CachePolicyPayload}.
*
* <p>{@code cache_entries} is the named cache index managed by {@code /videocache add|list|remove}.
* Each entry's URL is pushed to clients on join (and immediately on {@code add}). Names are unique.
*
* <p>{@code preload_urls} is the legacy un-named auto-preload list — same behavior as
* {@code cache_entries} but without a removable handle. Kept for backward-compat.
*/
public final class VideoPlayerConfig {
private VideoPlayerConfig() {}
private static final String FILE_NAME = "video_player.json";
private static final int DEFAULT_MAX_PRELOAD_MB = 1024;
/** Default render-distance cap for video anchors, in blocks. 128 = the legacy hard-coded value. */
private static final int DEFAULT_RENDER_DISTANCE = 128;
private static volatile int maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
private static volatile int renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
private static volatile List<String> preloadUrls = Collections.emptyList();
/** Insertion-ordered name → url. Mutated only under the class monitor. */
private static final Map<String, String> CACHE_ENTRIES = new LinkedHashMap<>();
/** Load (or create) the config file. Called once during mod initialization. */
public static synchronized void load() {
Path path = configPath();
try {
if (!Files.exists(path)) {
writeDefault(path);
VideoPlayerMod.LOG.info("[{}] created default config at {}",
VideoPlayerMod.MOD_ID, path);
maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
preloadUrls = Collections.emptyList();
CACHE_ENTRIES.clear();
return;
}
String raw = Files.readString(path, StandardCharsets.UTF_8);
JsonObject json = JsonParser.parseString(raw).getAsJsonObject();
// max_preload_mb (sanity-clamped to [16, 16384] so a typo can't brick a client)
int cap = DEFAULT_MAX_PRELOAD_MB;
if (json.has("max_preload_mb") && json.get("max_preload_mb").isJsonPrimitive()
&& json.get("max_preload_mb").getAsJsonPrimitive().isNumber()) {
cap = json.get("max_preload_mb").getAsInt();
}
if (cap < 16) cap = 16;
if (cap > 16384) cap = 16384;
maxPreloadMb = cap;
// render_distance_blocks (sanity-clamped to [16, 2048])
int rd = DEFAULT_RENDER_DISTANCE;
if (json.has("render_distance_blocks") && json.get("render_distance_blocks").isJsonPrimitive()
&& json.get("render_distance_blocks").getAsJsonPrimitive().isNumber()) {
rd = json.get("render_distance_blocks").getAsInt();
}
if (rd < 16) rd = 16;
if (rd > 2048) rd = 2048;
renderDistanceBlocks = rd;
// preload_urls (legacy)
List<String> urls = new ArrayList<>();
if (json.has("preload_urls") && json.get("preload_urls").isJsonArray()) {
json.getAsJsonArray("preload_urls").forEach(el -> {
if (el.isJsonPrimitive() && el.getAsJsonPrimitive().isString()) {
String u = sanitizeUrl(el.getAsString());
if (u != null) urls.add(u);
}
});
}
preloadUrls = Collections.unmodifiableList(urls);
// cache_entries (named)
CACHE_ENTRIES.clear();
if (json.has("cache_entries") && json.get("cache_entries").isJsonArray()) {
for (JsonElement el : json.getAsJsonArray("cache_entries")) {
if (!el.isJsonObject()) continue;
JsonObject o = el.getAsJsonObject();
String name = o.has("name") && o.get("name").isJsonPrimitive()
? o.get("name").getAsString().trim() : null;
String url = o.has("url") && o.get("url").isJsonPrimitive()
? sanitizeUrl(o.get("url").getAsString()) : null;
if (name == null || name.isEmpty() || url == null) continue;
if (CACHE_ENTRIES.containsKey(name)) {
VideoPlayerMod.LOG.warn(
"[{}] config: duplicate cache entry name '{}' — keeping first",
VideoPlayerMod.MOD_ID, name);
continue;
}
CACHE_ENTRIES.put(name, url);
}
}
VideoPlayerMod.LOG.info(
"[{}] config loaded: cap={} MB, render={} blocks, preload_urls={}, cache_entries={}",
VideoPlayerMod.MOD_ID, maxPreloadMb, renderDistanceBlocks, urls.size(), CACHE_ENTRIES.size());
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] failed to read config {}: {} — using defaults",
VideoPlayerMod.MOD_ID, path, t.toString());
maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
preloadUrls = Collections.emptyList();
CACHE_ENTRIES.clear();
}
}
// -- accessors ---------------------------------------------------------------------------
/** Hard cap on a single client-side video download, in MB. */
public static int maxPreloadMb() { return maxPreloadMb; }
/** Same value in bytes. */
public static long maxPreloadBytes() { return (long) maxPreloadMb * 1024L * 1024L; }
/** Anchor BE view-distance cap, in blocks. */
public static int renderDistanceBlocks() { return renderDistanceBlocks; }
/** Legacy un-named preload list (still pushed at join). Never null. */
public static List<String> preloadUrls() { return preloadUrls; }
/** Snapshot of name → url, insertion-ordered. Never null. */
public static synchronized Map<String, String> cacheEntries() {
return new LinkedHashMap<>(CACHE_ENTRIES);
}
/** Lookup a single entry's URL by name. */
public static synchronized String cacheUrl(String name) {
return CACHE_ENTRIES.get(name);
}
/**
* Accept either a raw HTTP(S) URL or a previously-registered cache entry name and return
* the canonical URL to store on the anchor. Returns the trimmed input when it's already a
* URL, the looked-up URL when the input matches a cache entry name, or {@code null} if the
* input is neither (caller decides whether to fail or fall through).
*/
public static synchronized String resolveUrlOrName(String input) {
if (input == null) return null;
String t = input.trim();
if (t.isEmpty()) return "";
if (t.startsWith("http://") || t.startsWith("https://")) return t;
return CACHE_ENTRIES.get(t);
}
// -- mutations (driven by /videocache add|remove) -----------------------------------------
/** Returns true if added; false if the name already exists. Persists on success. */
public static synchronized boolean addCacheEntry(String name, String url) {
if (name == null || name.isEmpty() || url == null) return false;
if (CACHE_ENTRIES.containsKey(name)) return false;
CACHE_ENTRIES.put(name, url);
save();
return true;
}
/** Returns the removed URL, or null if no entry by that name. Persists on success. */
public static synchronized String removeCacheEntry(String name) {
if (name == null) return null;
String removed = CACHE_ENTRIES.remove(name);
if (removed != null) save();
return removed;
}
// -- io ----------------------------------------------------------------------------------
private static Path configPath() {
return FabricLoader.getInstance().getConfigDir().resolve(FILE_NAME);
}
private static String sanitizeUrl(String s) {
if (s == null) return null;
String t = s.trim();
if (t.isEmpty() || t.length() > 256) return null;
if (!(t.startsWith("http://") || t.startsWith("https://"))) return null;
return t;
}
private static void writeDefault(Path path) throws IOException {
Files.createDirectories(path.getParent());
JsonObject root = new JsonObject();
root.addProperty("_comment",
"max_preload_mb: per-video download cap (each client). "
+ "render_distance_blocks: max distance at which a video anchor still renders. "
+ "preload_urls: HTTP(S) videos auto-pushed to every player on join (no name). "
+ "cache_entries: named entries managed by /videoCache add|list|remove.");
root.addProperty("max_preload_mb", DEFAULT_MAX_PRELOAD_MB);
root.addProperty("render_distance_blocks", DEFAULT_RENDER_DISTANCE);
root.add("preload_urls", new JsonArray());
root.add("cache_entries", new JsonArray());
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
Files.writeString(path, gson.toJson(root), StandardCharsets.UTF_8);
}
/** Persist current in-memory state. Best-effort; logs on failure. */
private static void save() {
Path path = configPath();
try {
Files.createDirectories(path.getParent());
JsonObject root = new JsonObject();
root.addProperty("_comment",
"max_preload_mb: per-video download cap (each client). "
+ "render_distance_blocks: max distance at which a video anchor still renders. "
+ "preload_urls: legacy un-named auto-preload list. "
+ "cache_entries: managed by /videoCache add|list|remove.");
root.addProperty("max_preload_mb", maxPreloadMb);
root.addProperty("render_distance_blocks", renderDistanceBlocks);
JsonArray legacyArr = new JsonArray();
for (String u : preloadUrls) legacyArr.add(u);
root.add("preload_urls", legacyArr);
JsonArray entriesArr = new JsonArray();
for (Map.Entry<String, String> e : CACHE_ENTRIES.entrySet()) {
JsonObject o = new JsonObject();
o.addProperty("name", e.getKey());
o.addProperty("url", e.getValue());
entriesArr.add(o);
}
root.add("cache_entries", entriesArr);
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
Files.writeString(path, gson.toJson(root), StandardCharsets.UTF_8);
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] failed to save config: {}",
VideoPlayerMod.MOD_ID, t.toString());
}
}
}

View File

@@ -1,16 +1,20 @@
package com.ejclaw.videoplayer;
import com.ejclaw.videoplayer.command.VideoCacheCommand;
import com.ejclaw.videoplayer.command.VideoDeleteCommand;
import com.ejclaw.videoplayer.command.VideoMuteCommand;
import com.ejclaw.videoplayer.command.VideoPlaceCommand;
import com.ejclaw.videoplayer.command.VideoPreloadCommand;
import com.ejclaw.videoplayer.command.VideoStickCommand;
import com.ejclaw.videoplayer.net.CachePolicyPayload;
import com.ejclaw.videoplayer.net.PreloadPayload;
import com.ejclaw.videoplayer.net.VideoPlayerNetwork;
import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities;
import com.ejclaw.videoplayer.registry.VideoPlayerBlocks;
import com.ejclaw.videoplayer.registry.VideoPlayerItems;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -27,12 +31,40 @@ public class VideoPlayerMod implements ModInitializer {
VideoPlayerNetwork.registerPayloadTypes();
VideoPlayerNetwork.registerServerReceivers();
VideoPlayerConfig.load();
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, env) -> {
VideoStickCommand.register(dispatcher);
VideoPlaceCommand.register(dispatcher);
VideoDeleteCommand.register(dispatcher);
VideoMuteCommand.register(dispatcher);
VideoPreloadCommand.register(dispatcher);
VideoCacheCommand.register(dispatcher);
});
// On join: (1) push the per-video download cap so the client knows whether to abort
// an over-cap stream, (2) replay every legacy preload_urls entry, (3) replay every
// named /videocache entry. Policy must go first so caps are honored before downloads
// start. Each PreloadPayload is fire-and-forget; clients post their own "[videopreload]"
// status lines when downloads finish.
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
var player = handler.getPlayer();
ServerPlayNetworking.send(player, new CachePolicyPayload(
VideoPlayerConfig.maxPreloadBytes(),
VideoPlayerConfig.renderDistanceBlocks()));
int sent = 0;
for (String url : VideoPlayerConfig.preloadUrls()) {
ServerPlayNetworking.send(player, new PreloadPayload(url));
sent++;
}
for (var e : VideoPlayerConfig.cacheEntries().entrySet()) {
ServerPlayNetworking.send(player, new PreloadPayload(e.getValue()));
sent++;
}
if (sent > 0) {
LOG.info("[{}] sent policy + {} preload(s) to {}",
MOD_ID, sent, player.getName().getString());
}
});
LOG.info("[{}] initialized", MOD_ID);

View File

@@ -0,0 +1,30 @@
package com.ejclaw.videoplayer.client;
import com.ejclaw.videoplayer.VideoPlayerMod;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
/**
* Client-side mirror of the server's policy bundle (pushed via {@code CachePolicyPayload}
* on join). Currently just the anchor render-distance cap; the per-video download cap lives
* directly on {@link com.ejclaw.videoplayer.client.playback.VideoCache}.
*
* <p>Default 128 matches the legacy hard-coded value, so unpaired clients (e.g. dev tests
* with no JOIN packet, or older servers without the payload) render identically to before.
*/
@Environment(EnvType.CLIENT)
public final class ClientPolicy {
private ClientPolicy() {}
private static volatile int renderDistanceBlocks = 128;
public static int renderDistanceBlocks() { return renderDistanceBlocks; }
public static void setRenderDistanceBlocks(int blocks) {
if (blocks < 16) blocks = 16;
if (blocks > 2048) blocks = 2048;
renderDistanceBlocks = blocks;
VideoPlayerMod.LOG.info("[{}] anchor render distance set to {} blocks",
VideoPlayerMod.MOD_ID, blocks);
}
}

View File

@@ -1,9 +1,12 @@
package com.ejclaw.videoplayer.client.net;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.client.ClientPolicy;
import com.ejclaw.videoplayer.client.gui.VideoConfigScreen;
import com.ejclaw.videoplayer.client.playback.VideoCache;
import com.ejclaw.videoplayer.client.playback.VideoPlayback;
import com.ejclaw.videoplayer.net.CachePolicyPayload;
import com.ejclaw.videoplayer.net.DeleteCachePayload;
import com.ejclaw.videoplayer.net.OpenScreenPayload;
import com.ejclaw.videoplayer.net.PreloadPayload;
import com.ejclaw.videoplayer.net.SyncAnchorPayload;
@@ -40,5 +43,17 @@ public final class ClientNetworking {
ClientPlayNetworking.registerGlobalReceiver(PreloadPayload.TYPE, (payload, context) -> {
VideoCache.preload(payload.url());
});
// Server tells us the per-video download cap (bytes). Must arrive before PreloadPayload
// (the server sends policy first on JOIN), so we don't accidentally use the stale default.
ClientPlayNetworking.registerGlobalReceiver(CachePolicyPayload.TYPE, (payload, context) -> {
VideoCache.setMaxBytes(payload.maxBytes());
ClientPolicy.setRenderDistanceBlocks(payload.renderDistanceBlocks());
});
// /videocache remove — drop the URL from this client's disk cache.
ClientPlayNetworking.registerGlobalReceiver(DeleteCachePayload.TYPE, (payload, context) -> {
VideoCache.purge(payload.url());
});
}
}

View File

@@ -4,6 +4,8 @@ import com.ejclaw.videoplayer.VideoPlayerMod;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import org.lwjgl.system.MemoryUtil;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.SourceDataLine;
@@ -13,7 +15,7 @@ import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ShortBuffer;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicLong;
/**
* SPEC §5.3 — fallback mp4/http(s) backend driven by JavaCV's FFmpegFrameGrabber.
@@ -29,15 +31,45 @@ 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;
private final AtomicBoolean running = new AtomicBoolean(false);
private final AtomicBoolean paused = new AtomicBoolean(false);
private final AtomicReference<ByteBuffer> latest = new AtomicReference<>();
/**
* Ring buffer of preallocated RGBA staging slots. Decoder thread writes to {@code ringTail}
* under {@link #frameLock}; render thread drains the oldest slot via
* {@link #consumeFrame(long, long)} under the same lock.
*
* <p>0.4.10 used a single staging slot and relied on {@link SourceDataLine#write}
* backpressure to pace the decoder. That paced only at audio-buffer granularity (~0.5 s):
* the decoder burst-produced ~12 video frames into the slot while the audio line drained,
* the consumer (60+ Hz polling) saw only the last frame of each burst, then the decoder
* stalled until audio drained again — net effect ~2 fps of visible video despite the
* decoder producing at the source's 24 fps. The ring absorbs the burst; combined with the
* smaller audio buffer (~0.1 s) below the burst collapses to 23 frames which fits in
* {@link #FRAME_RING_SLOTS}.
*
* <p>If the ring still fills, the decoder overwrites the oldest slot and increments
* {@link #droppedFrames}. Memory cost: {@code 4 × w × h × 4} bytes (32 MB at 1080p,
* ~130 MB at 4K).
*/
private static final int FRAME_RING_SLOTS = 4;
private final Object frameLock = new Object();
private final ByteBuffer[] ringBufs = new ByteBuffer[FRAME_RING_SLOTS];
private final int[] ringBytes = new int[FRAME_RING_SLOTS];
private int ringHead = 0; // next slot to consume
private int ringTail = 0; // next slot to produce into
private int ringCount = 0;
/** Decoder telemetry (cumulative). Logged ~every 10 s from the decode thread. */
private final AtomicLong producedFrames = new AtomicLong();
private final AtomicLong consumedFrames = new AtomicLong();
private final AtomicLong droppedFrames = new AtomicLong();
private volatile int width = 0;
private volatile int height = 0;
private volatile float gain = 1.0F;
@@ -87,14 +119,39 @@ public class JavaCvBackend implements VideoBackend {
public int videoHeight() { return height; }
@Override
public ByteBuffer pollFrame() {
return latest.getAndSet(null);
public boolean consumeFrame(long dstAddr, long maxBytes) {
synchronized (frameLock) {
if (ringCount <= 0) return false;
int idx = ringHead;
int n = ringBytes[idx];
ByteBuffer buf = ringBufs[idx];
// Always advance head regardless of memcpy outcome — otherwise a single oversize
// frame (e.g. mid-resize) would jam the ring forever.
ringHead = (idx + 1) % FRAME_RING_SLOTS;
ringCount--;
if (buf == null || n <= 0 || n > maxBytes) {
// Texture not yet sized for this frame, or empty slot — skip. ensureTexture()
// runs in Entry.tryUpload() before consumeFrame, so n > maxBytes only happens
// on the exact tick of a resolution change.
return false;
}
MemoryUtil.memCopy(MemoryUtil.memAddress(buf), dstAddr, n);
consumedFrames.incrementAndGet();
return true;
}
}
@Override
public void close() {
closed = true;
stopWorker();
synchronized (frameLock) {
for (int i = 0; i < FRAME_RING_SLOTS; i++) {
ringBufs[i] = null;
ringBytes[i] = 0;
}
ringHead = ringTail = ringCount = 0;
}
}
private void stopWorker() {
@@ -107,16 +164,44 @@ public class JavaCvBackend implements VideoBackend {
try { line.stop(); } catch (Throwable ignored) {}
try { line.flush(); } catch (Throwable ignored) {}
}
// Yank the grabber too so a blocked grab() inside an HTTP read returns promptly.
// JavaCV's close() is best-effort thread-safe — worst case we trip an AVERROR which
// the catch-all in runLoop swallows.
Object g = grabberHandle;
if (g != null) {
try { g.getClass().getMethod("close").invoke(g); } catch (Throwable ignored) {}
}
// CRITICAL: we do NOT call grabber.close() from this (caller) thread. The decoder's
// per-frame path is:
//
// frame = grab(grabber); // grabber-owned native memory
// src = frame.image[0]; // DirectByteBuffer over that memory
// need = src.remaining(); // (lock-free)
// srcAddr = MemoryUtil.memAddress(src); // (lock-free)
// synchronized (frameLock) {
// MemoryUtil.memCopy(srcAddr, ...); // reads from grabber-owned memory
// }
//
// Even if we held frameLock while closing the grabber, there's a window between
// grab() returning and entering the synchronized block where the decoder is holding a
// stale srcAddr — closing the grabber there frees the av_frame plane and the next
// memcpy crashes inside StubRoutines::jbyte_disjoint_arraycopy (exactly the 4K-delete
// crash dump we saw). So the safe rule is: only the decoder thread touches the
// grabber. External stop signals `running=false`, stops the audio line, interrupts the
// worker, and joins briefly; the worker's own `finally` calls grabber.close(). Inside
// the loop, grab() unblocks via the rw_timeout/timeout options (3 s, set in runLoop)
// even on a stuck HTTP read, so the join below normally returns within a frame.
Thread t = worker;
worker = null;
if (t != null) t.interrupt();
if (t != null) {
t.interrupt();
try {
t.join(2000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
if (t.isAlive()) {
// Worker still blocked in native grab() — let it finish on its own. Its
// finally still closes the grabber when grab() eventually returns / throws.
// No native pointers leak in the meantime because we don't touch them here.
VideoPlayerMod.LOG.warn(
"[{}] decoder did not exit within 2 s of stop; orphaning until next grab() returns",
VideoPlayerMod.MOD_ID);
}
}
ready = false;
}
@@ -137,6 +222,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 +243,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);
@@ -168,12 +264,32 @@ public class JavaCvBackend implements VideoBackend {
localAudioLine = openLine(sampleRate, audioChannels);
this.audioLine = localAudioLine;
// Decoder spec — printed once per playback so the user log shows what the decoder
// actually sees (resolution / frame rate / sample rate). Used to verify our pacing
// assumptions (e.g. ring depth, audio buffer length) match the source.
double srcFrameRate = 0;
try { srcFrameRate = ((Number) grabberCls.getMethod("getFrameRate").invoke(grabber)).doubleValue(); }
catch (Throwable ignored) {}
VideoPlayerMod.LOG.info(
"[{}] decoder started: {}x{} @ {} fps, audio {} Hz x{}, ring={} slots",
VideoPlayerMod.MOD_ID, width, height,
String.format("%.2f", srcFrameRate),
sampleRate, audioChannels, FRAME_RING_SLOTS);
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.
// Stats sampling: every 10 s of wall-clock we log produced/consumed/dropped deltas
// and the implied fps. Lets us tell from the log whether the decoder is keeping
// real-time pace (produced≈source fps) and whether the ring is overflowing
// (dropped>0). All counters are cumulative; we keep the previous sample to compute
// deltas.
long statsLastNs = System.nanoTime();
long lastProd = 0, lastCons = 0, lastDrop = 0;
while (running.get() && !closed) {
if (paused.get()) { Thread.sleep(20); continue; }
@@ -200,13 +316,84 @@ 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 the next ring slot under frameLock so the
// render thread's consumeFrame() sees coherent frames in FIFO order.
//
// Allocation is one-time per slot, lazily on first use (or on a resolution
// upgrade) — never per frame. 0.4.9's per-frame allocateDirect was the
// primary memory-churn problem; 0.4.10 fixed that; 0.4.11 adds the ring on
// top to absorb the burst-then-stall caused by SourceDataLine backpressure
// pacing only at audio-buffer granularity.
int need = src.remaining();
// Reviewer-mandated sanity bounds: memCopy is a raw native copy with no
// fence against overrun. Validate against (a) the source buffer's own
// capacity (so a corrupt plane can't read past it) and (b) the expected
// RGBA frame size (width*height*4) (so an unexpectedly oversized plane
// can't smash the dst slot we'll allocate). If either fails, skip this
// frame and continue — the next grab() will give us a fresh one.
int expected = width * height * 4;
if (need > src.capacity()) {
VideoPlayerMod.LOG.warn("[{}] frame overruns source capacity (need={}, cap={}); skipping",
VideoPlayerMod.MOD_ID, need, src.capacity());
need = 0;
} else if (need > expected) {
VideoPlayerMod.LOG.warn("[{}] frame larger than expected RGBA size (need={}, expected={}); skipping",
VideoPlayerMod.MOD_ID, need, expected);
need = 0;
}
if (need > 0) {
int srcPos = src.position();
long srcAddr = MemoryUtil.memAddress(src) + srcPos;
synchronized (frameLock) {
// Recheck shutdown inside the lock: stopWorker() flipped running=false
// before signaling, so worker is the only writer here and grabber.close()
// only runs from this thread's finally — but the explicit check keeps
// the contract obvious to future readers.
if (!running.get() || closed) break;
int idx = ringTail;
if (ringBufs[idx] == null || ringBufs[idx].capacity() < need) {
ringBufs[idx] = ByteBuffer.allocateDirect(need).order(ByteOrder.nativeOrder());
}
long dstAddr = MemoryUtil.memAddress(ringBufs[idx]);
MemoryUtil.memCopy(srcAddr, dstAddr, need);
ringBytes[idx] = need;
ringTail = (idx + 1) % FRAME_RING_SLOTS;
if (ringCount < FRAME_RING_SLOTS) {
ringCount++;
} else {
// Ring was full — we overwrote the oldest frame. Advance head
// to point at the next-oldest so consume order stays FIFO.
ringHead = (ringHead + 1) % FRAME_RING_SLOTS;
droppedFrames.incrementAndGet();
}
producedFrames.incrementAndGet();
}
src.position(srcPos); // restore — JavaCV reads it on subsequent grabs
}
}
// Periodic stats — once per ~10 s of wall-clock. Includes ring depth so we can
// see whether the consumer is keeping up.
long now = System.nanoTime();
if (now - statsLastNs > 10_000_000_000L) {
long prod = producedFrames.get();
long cons = consumedFrames.get();
long drop = droppedFrames.get();
double elapsedS = (now - statsLastNs) / 1e9;
int depth;
synchronized (frameLock) { depth = ringCount; }
VideoPlayerMod.LOG.info(
"[{}] decoder stats: produced={} ({} fps), consumed={} ({} fps), dropped={} (+{}) over {}s, ring={}/{}",
VideoPlayerMod.MOD_ID,
prod, String.format("%.1f", (prod - lastProd) / elapsedS),
cons, String.format("%.1f", (cons - lastCons) / elapsedS),
drop, (drop - lastDrop),
String.format("%.1f", elapsedS),
depth, FRAME_RING_SLOTS);
statsLastNs = now;
lastProd = prod; lastCons = cons; lastDrop = drop;
}
// If we have an open audio line, SourceDataLine.write() blocks for backpressure
@@ -243,10 +430,17 @@ public class JavaCvBackend implements VideoBackend {
try {
AudioFormat fmt = new AudioFormat(sampleRate, 16, channels, true, false); // signed 16-bit LE
SourceDataLine line = AudioSystem.getSourceDataLine(fmt);
// ~0.5 s of audio buffered in the driver. Smooths over upstream hiccups without
// delaying close() — stopWorker() calls line.stop() / line.flush() to dump it.
// ~0.1 s of audio buffered in the driver. 0.4.10 used 0.5 s, which let the decoder
// burst ~12 video frames between backpressure stalls — way past the video ring's
// capacity and the visible cause of the "2-5 fps" stutter the user saw. With 0.1 s
// the audio line refills more often, so the decoder is paced more tightly and
// bursts collapse to 2-3 frames (well inside FRAME_RING_SLOTS).
//
// Floor at frameSizeBytes * 256 keeps the buffer above the typical OS / driver
// minimum so we don't get UnsupportedOperationException at line.open() on
// exotic sample rates.
int frameSizeBytes = 2 * channels;
int bufferBytes = Math.max(sampleRate * frameSizeBytes / 2, frameSizeBytes * 1024);
int bufferBytes = Math.max(sampleRate * frameSizeBytes / 10, frameSizeBytes * 256);
line.open(fmt, bufferBytes);
line.start();
return line;
@@ -293,17 +487,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;
}
}

View File

@@ -3,8 +3,6 @@ package com.ejclaw.videoplayer.client.playback;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import java.nio.ByteBuffer;
/**
* SPEC §5.3 — minimal playback backend abstraction. Implementations: WatermediaBackend (preferred,
* when v2 supports the target MC version) and JavaCvBackend (fallback).
@@ -21,10 +19,19 @@ public interface VideoBackend {
int videoHeight();
/**
* Poll a new decoded RGBA frame if one is ready.
* @return the frame buffer (capacity = w*h*4) or {@code null} if no new frame is ready.
* If a new RGBA frame is ready, memcpy it directly into the GPU texture buffer at
* {@code dstAddr} (must have room for at least {@code w*h*4} bytes) and clear the dirty
* flag. Returns {@code true} when a frame was written.
*
* <p>Replaces the prior {@code pollFrame()} which returned a {@link java.nio.ByteBuffer}.
* The old contract forced the decoder to either allocate a fresh direct buffer per frame
* (huge memory churn at 1080p — see 0.4.10 changelog) or expose a reused buffer whose
* memory the decoder could clobber while the renderer was still reading. Pushing the copy
* inside the backend lets the decoder hold a single preallocated buffer under its own
* lock and copy out to the GPU pointer in one synchronized block — zero allocation, no
* race window.
*/
ByteBuffer pollFrame();
boolean consumeFrame(long dstAddr, long maxBytes);
void close();
}

View File

@@ -41,8 +41,49 @@ public final class VideoCache {
/** urls whose download is currently in flight. */
private static final Set<String> IN_FLIGHT = ConcurrentHashMap.newKeySet();
/** Hard ceiling on a single preload — 512 MB. Keeps an accidental giant URL from filling disk. */
private static final long MAX_BYTES = 512L * 1024 * 1024;
/**
* Hard ceiling on a single preload, in bytes. Default 1 GB so a fresh client without a
* policy packet (e.g. integrated server, dev test) still has a sensible cap. Overridden by
* {@link com.ejclaw.videoplayer.net.CachePolicyPayload} on join.
*/
private static volatile long MAX_BYTES = 1024L * 1024 * 1024;
/** Server-driven override of the per-video cap. */
public static void setMaxBytes(long bytes) {
if (bytes < 16L * 1024 * 1024) bytes = 16L * 1024 * 1024;
if (bytes > 16L * 1024 * 1024 * 1024) bytes = 16L * 1024 * 1024 * 1024;
MAX_BYTES = bytes;
VideoPlayerMod.LOG.info("[{}] preload cap set to {} MB",
VideoPlayerMod.MOD_ID, bytes / (1024 * 1024));
}
/** Server-driven delete of a cached URL. Removes from READY and from disk. */
public static void purge(String url) {
if (url == null || url.isEmpty()) return;
Path p = READY.remove(url);
if (p == null) {
// Not in this session's index, but the file may still be on disk from a prior run.
// Reconstruct the path by hash + extension and try to delete it.
try {
Path dir = cacheDir();
if (dir != null) {
Path guess = dir.resolve(sha256(url) + extensionFromUrl(url));
if (Files.exists(guess)) p = guess;
}
} catch (Throwable ignored) {}
}
if (p != null) {
try {
boolean gone = Files.deleteIfExists(p);
VideoPlayerMod.LOG.info("[{}] purge: {} -> deleted={} ({})",
VideoPlayerMod.MOD_ID, url, gone, p.getFileName());
if (gone) notifyChat("[videocache] 캐시 삭제: " + url, ChatFormatting.YELLOW);
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] purge failed for {}: {}",
VideoPlayerMod.MOD_ID, url, t.toString());
}
}
}
/** Kick off a background download. No-op if already cached or in flight. */
public static void preload(String url) {
@@ -133,11 +174,12 @@ public final class VideoCache {
while ((n = in.read(buf)) >= 0) {
total += n;
if (total > MAX_BYTES) {
long capMb = MAX_BYTES / (1024 * 1024);
VideoPlayerMod.LOG.warn(
"[{}] preload: {} exceeded {} MB cap; aborting",
VideoPlayerMod.MOD_ID, url, MAX_BYTES / (1024 * 1024));
VideoPlayerMod.MOD_ID, url, capMb);
try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {}
notifyChat("[videopreload] 실패 (512MB 초과): " + url, ChatFormatting.RED);
notifyChat("[videopreload] 실패 (" + capMb + "MB 초과): " + url, ChatFormatting.RED);
return;
}
out.write(buf, 0, n);

View File

@@ -10,7 +10,6 @@ import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
@@ -112,10 +111,8 @@ public final class VideoPlayback {
continue;
}
if (!e.backend.isReady()) continue;
ByteBuffer buf = e.backend.pollFrame();
if (buf == null) continue;
try {
e.upload(buf);
e.tryUpload();
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] texture upload failed: {}", VideoPlayerMod.MOD_ID, t.toString());
e.close();
@@ -187,34 +184,45 @@ public final class VideoPlayback {
}
}
/** Copy an incoming RGBA byte buffer into the texture, resizing if dimensions changed. */
void upload(ByteBuffer rgba) {
/**
* If the backend has a new RGBA frame, copy it straight into the texture's native
* pixel buffer and re-upload to GPU. The backend does the memcpy under its own lock
* so we never read a half-written frame. RGBA bytes already match NativeImage's
* ABGR-int layout in little-endian byte order (byte 0 = R = low byte of the int).
*/
void tryUpload() {
int w = backend.videoWidth();
int h = backend.videoHeight();
if (w <= 0 || h <= 0) return;
ensureTexture(w, h, false);
NativeImage img = texture.getPixels();
if (img == null) return;
int pixels = w * h;
for (int i = 0; i < pixels; i++) {
int r = rgba.get() & 0xFF;
int g = rgba.get() & 0xFF;
int b = rgba.get() & 0xFF;
int a = rgba.get() & 0xFF;
int abgr = (a << 24) | (b << 16) | (g << 8) | r;
img.setPixelABGR(i % w, i / w, abgr);
long maxBytes = (long) w * h * 4L;
if (backend.consumeFrame(img.getPointer(), maxBytes)) {
texture.upload();
}
texture.upload();
}
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.
}
}
}

View File

@@ -4,8 +4,6 @@ import com.ejclaw.videoplayer.VideoPlayerMod;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import java.nio.ByteBuffer;
/**
* SPEC §5.3 / §5.4 — WaterMedia v2 backend. Reflection-only so the mod jar stays clean of
* compile-time WaterMedia dependencies. Until a v2 build supports 1.21.6+ this returns
@@ -38,8 +36,8 @@ public class WatermediaBackend implements VideoBackend {
@Override public int videoHeight() { return height; }
@Override
public ByteBuffer pollFrame() {
return null; // no frames until v2 is wired up
public boolean consumeFrame(long dstAddr, long maxBytes) {
return false; // no frames until v2 is wired up
}
@Override

View File

@@ -1,6 +1,7 @@
package com.ejclaw.videoplayer.client.render;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.client.ClientPolicy;
import com.ejclaw.videoplayer.client.playback.VideoPlayback;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
@@ -76,16 +77,14 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
final Matrix4f mat = new Matrix4f(pose.last().pose());
RenderType rt = RenderTypes.entityCutout(tex);
collector.submitCustomGeometry(pose, rt, (poseUnused, vc) -> {
// Front face (visible from outside, looking back at the wall)
// Single-sided: the back of the anchor is by design pressed against the wall the
// player clicked, so a back face is pure GPU waste. Halves the fragment shader work
// per anchor and removes the mirrored-texture artifact a player would see if they
// somehow clipped behind the wall.
emit(vc, mat, 0F, 0F, 0F, 0F, 1F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
emit(vc, mat, w, h, 0F, 1F, 0F, light);
emit(vc, mat, 0F, h, 0F, 0F, 0F, light);
// Back face (in case the player ends up on the other side, e.g. clipping into the wall)
emit(vc, mat, 0F, h, 0F, 0F, 0F, light);
emit(vc, mat, w, h, 0F, 1F, 0F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
emit(vc, mat, 0F, 0F, 0F, 0F, 1F, light);
});
pose.popPose();
@@ -126,7 +125,9 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
@Override
public int getViewDistance() {
return 128;
// Driven by server config (video_player.json:render_distance_blocks), pushed to clients
// on join. Default 128 = legacy behavior, so an unpaired client behaves identically.
return ClientPolicy.renderDistanceBlocks();
}
/** Per-frame render data extracted from the BE. */

View File

@@ -0,0 +1,147 @@
package com.ejclaw.videoplayer.command;
import com.ejclaw.videoplayer.VideoPlayerConfig;
import com.ejclaw.videoplayer.net.DeleteCachePayload;
import com.ejclaw.videoplayer.net.PreloadPayload;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.ClickEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
import net.minecraft.network.chat.Style;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import java.net.URI;
import java.util.Map;
/**
* {@code /videocache add <name> <url>} — name a URL, store it in server config, and broadcast a
* preload request to every client.
* <br>{@code /videocache list} — print the named index with clickable URLs.
* <br>{@code /videocache remove <name>} — drop the entry from server config and tell every client
* to delete the matching cache file.
*
* <p>Replaces the old {@code /videopreload}. Same permission gate
* ({@link Permissions#COMMANDS_GAMEMASTER}) so command blocks can drive it.
*/
public final class VideoCacheCommand {
private VideoCacheCommand() {}
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(build("videoCache"));
}
private static LiteralArgumentBuilder<CommandSourceStack> build(String root) {
return Commands.literal(root)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.then(Commands.literal("add")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("url", StringArgumentType.greedyString())
.executes(VideoCacheCommand::runAdd))))
.then(Commands.literal("list")
.executes(VideoCacheCommand::runList))
.then(Commands.literal("remove")
.then(Commands.argument("name", StringArgumentType.word())
.executes(VideoCacheCommand::runRemove)));
}
private static int runAdd(CommandContext<CommandSourceStack> ctx) throws CommandSyntaxException {
CommandSourceStack src = ctx.getSource();
String name = StringArgumentType.getString(ctx, "name").trim();
String url = StringArgumentType.getString(ctx, "url").trim();
if (name.isEmpty() || name.length() > 64) {
src.sendFailure(Component.literal("이름은 1~64자여야 합니다"));
return 0;
}
if (url.isEmpty() || !(url.startsWith("http://") || url.startsWith("https://"))) {
src.sendFailure(Component.literal("url 은 http:// 또는 https:// 로 시작해야 합니다"));
return 0;
}
if (url.length() > 256) {
src.sendFailure(Component.literal("url 이 너무 깁니다 (최대 256자)"));
return 0;
}
if (VideoPlayerConfig.cacheUrl(name) != null) {
src.sendFailure(Component.literal("이미 사용 중인 이름입니다: " + name));
return 0;
}
if (!VideoPlayerConfig.addCacheEntry(name, url)) {
src.sendFailure(Component.literal("저장 실패: 이름 중복 또는 IO 오류"));
return 0;
}
MinecraftServer server = src.getServer();
PreloadPayload payload = new PreloadPayload(url);
int sent = 0;
for (ServerPlayer p : PlayerLookup.all(server)) {
ServerPlayNetworking.send(p, payload);
sent++;
}
final int sentFinal = sent;
src.sendSuccess(() -> Component.literal(
"[videocache] 추가됨: " + name + "" + url
+ " (" + sentFinal + " 클라이언트에 preload 전송)"), false);
return 1;
}
private static int runList(CommandContext<CommandSourceStack> ctx) {
CommandSourceStack src = ctx.getSource();
Map<String, String> entries = VideoPlayerConfig.cacheEntries();
if (entries.isEmpty()) {
src.sendSuccess(() -> Component.literal("[videocache] 저장된 항목이 없습니다")
.withStyle(ChatFormatting.GRAY), false);
return 0;
}
src.sendSuccess(() -> Component.literal("[videocache] 저장된 항목 " + entries.size() + "개:")
.withStyle(ChatFormatting.YELLOW), false);
for (Map.Entry<String, String> e : entries.entrySet()) {
String url = e.getValue();
ClickEvent click;
try {
click = new ClickEvent.OpenUrl(URI.create(url));
} catch (Throwable t) {
click = null; // bad URI — show without click action rather than failing the whole list
}
Style urlStyle = Style.EMPTY.withColor(ChatFormatting.AQUA).withUnderlined(true);
if (click != null) urlStyle = urlStyle.withClickEvent(click);
MutableComponent line = Component.literal("" + e.getKey() + " : ")
.withStyle(ChatFormatting.WHITE)
.append(Component.literal(url).withStyle(urlStyle));
src.sendSuccess(() -> line, false);
}
return entries.size();
}
private static int runRemove(CommandContext<CommandSourceStack> ctx) throws CommandSyntaxException {
CommandSourceStack src = ctx.getSource();
String name = StringArgumentType.getString(ctx, "name").trim();
String url = VideoPlayerConfig.removeCacheEntry(name);
if (url == null) {
src.sendFailure(Component.literal("해당 이름의 항목이 없습니다: " + name));
return 0;
}
MinecraftServer server = src.getServer();
DeleteCachePayload payload = new DeleteCachePayload(url);
int sent = 0;
for (ServerPlayer p : PlayerLookup.all(server)) {
ServerPlayNetworking.send(p, payload);
sent++;
}
final int sentFinal = sent;
src.sendSuccess(() -> Component.literal(
"[videocache] 삭제됨: " + name + "" + url
+ " (" + sentFinal + " 클라이언트에 cache_delete 전송)"), false);
return 1;
}
}

View File

@@ -19,7 +19,6 @@ public final class VideoDeleteCommand {
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(build("videoDelete"));
dispatcher.register(build("videodelete"));
}
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>

View File

@@ -22,7 +22,6 @@ public final class VideoMuteCommand {
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(build("videoMute"));
dispatcher.register(build("videomute"));
}
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>

View File

@@ -1,5 +1,6 @@
package com.ejclaw.videoplayer.command;
import com.ejclaw.videoplayer.VideoPlayerConfig;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.net.SyncAnchorPayload;
import com.ejclaw.videoplayer.registry.VideoPlayerBlocks;
@@ -27,7 +28,6 @@ public final class VideoPlaceCommand {
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(build("videoPlace"));
dispatcher.register(build("videoplace"));
}
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
@@ -54,9 +54,14 @@ public final class VideoPlaceCommand {
}
int width = IntegerArgumentType.getInteger(ctx, "width");
int height = IntegerArgumentType.getInteger(ctx, "height");
String url = StringArgumentType.getString(ctx, "url").trim();
if (!url.isEmpty() && !(url.startsWith("http://") || url.startsWith("https://"))) {
src.sendFailure(Component.literal("url must be http:// or https:// (or empty)"));
String raw = StringArgumentType.getString(ctx, "url").trim();
// Accept either an http(s) URL or a /videoCache add <name> entry: resolveUrlOrName()
// returns the canonical URL in both cases, or null when a non-URL string didn't match
// any named entry.
String url = VideoPlayerConfig.resolveUrlOrName(raw);
if (url == null) {
src.sendFailure(Component.literal(
"url 은 http(s):// 로 시작하거나 /videoCache add 로 등록된 이름이어야 합니다: " + raw));
return 0;
}
if (url.length() > 256) url = url.substring(0, 256);

View File

@@ -1,74 +0,0 @@
package com.ejclaw.videoplayer.command;
import com.ejclaw.videoplayer.net.PreloadPayload;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
/**
* {@code /videoPreload <url>} — broadcast a preload request to every connected client so each
* client fully downloads the URL into its local {@code video_player_cache/} folder. Subsequent
* {@code /videoPlace} calls (or right-click placements) that use the same URL will then play
* from the local file, eliminating mid-stream stutter.
*
* <p>Uses the same {@link Permissions#COMMANDS_GAMEMASTER} gate as {@code /videoPlace} et al.,
* so command blocks (which run at op level 2 by default) can invoke it.
*/
public final class VideoPreloadCommand {
private VideoPreloadCommand() {}
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(build("videoPreload"));
dispatcher.register(build("videopreload"));
}
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) {
return Commands.literal(name)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.then(Commands.argument("url", StringArgumentType.greedyString())
.executes(VideoPreloadCommand::run));
}
private static int run(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException {
CommandSourceStack src = ctx.getSource();
String url = StringArgumentType.getString(ctx, "url").trim();
if (url.isEmpty()) {
src.sendFailure(Component.literal("url is required"));
return 0;
}
if (!(url.startsWith("http://") || url.startsWith("https://"))) {
src.sendFailure(Component.literal("url must be http:// or https://"));
return 0;
}
if (url.length() > 256) {
src.sendFailure(Component.literal("url too long (max 256)"));
return 0;
}
MinecraftServer server = src.getServer();
PreloadPayload payload = new PreloadPayload(url);
int sent = 0;
for (ServerPlayer p : PlayerLookup.all(server)) {
ServerPlayNetworking.send(p, payload);
sent++;
}
final int sentFinal = sent;
// Use sendSuccess(..., false) so the chat noise is local-only and command blocks don't
// spam every operator on each tick. Be explicit that this is fire-and-forget: each
// client posts its own "[videopreload] 완료" chat line when the download finishes.
src.sendSuccess(() -> Component.literal(
"preload 요청을 " + sentFinal + " 클라이언트에 전송: " + payload.url()
+ " (완료 알림 후 재생하세요)"), false);
return sent;
}
}

View File

@@ -14,9 +14,6 @@ public final class VideoStickCommand {
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(Commands.literal("videoStick")
.executes(ctx -> run(ctx.getSource())));
// Lowercase alias — Brigadier is case-sensitive.
dispatcher.register(Commands.literal("videostick")
.executes(ctx -> run(ctx.getSource())));
}
private static int run(CommandSourceStack source) {

View File

@@ -0,0 +1,29 @@
package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerMod;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/**
* S2C — broadcasts the server-configured client-side policy bundle on join, before any
* {@link PreloadPayload}. Currently carries: {@code maxBytes} (per-video download cap) and
* {@code renderDistanceBlocks} (anchor BE view-distance cap).
*/
public record CachePolicyPayload(long maxBytes, int renderDistanceBlocks) implements CustomPacketPayload {
public static final CustomPacketPayload.Type<CachePolicyPayload> TYPE =
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "cache_policy"));
public static final StreamCodec<RegistryFriendlyByteBuf, CachePolicyPayload> CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_LONG, CachePolicyPayload::maxBytes,
ByteBufCodecs.VAR_INT, CachePolicyPayload::renderDistanceBlocks,
CachePolicyPayload::new
);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -0,0 +1,28 @@
package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerMod;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/**
* S2C — tell connected clients to drop a previously preloaded URL from their on-disk cache.
* Sent by {@code /videocache remove <name>}. Each client deletes the matching cache file and
* drops it from {@code VideoCache.READY}.
*/
public record DeleteCachePayload(String url) implements CustomPacketPayload {
public static final CustomPacketPayload.Type<DeleteCachePayload> TYPE =
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "cache_delete"));
public static final StreamCodec<RegistryFriendlyByteBuf, DeleteCachePayload> CODEC = StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, DeleteCachePayload::url,
DeleteCachePayload::new
);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -1,5 +1,6 @@
package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerConfig;
import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
@@ -25,6 +26,8 @@ public final class VideoPlayerNetwork {
PayloadTypeRegistry.clientboundPlay().register(OpenScreenPayload.TYPE, OpenScreenPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(SyncAnchorPayload.TYPE, SyncAnchorPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(PreloadPayload.TYPE, PreloadPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(CachePolicyPayload.TYPE, CachePolicyPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(DeleteCachePayload.TYPE, DeleteCachePayload.CODEC);
// C2S
PayloadTypeRegistry.serverboundPlay().register(SaveConfigPayload.TYPE, SaveConfigPayload.CODEC);
PayloadTypeRegistry.serverboundPlay().register(DeleteAnchorPayload.TYPE, DeleteAnchorPayload.CODEC);
@@ -95,13 +98,13 @@ public final class VideoPlayerNetwork {
private static String trimUrl(String s) {
if (s == null) return "";
String t = s.trim();
if (t.length() > 256) t = t.substring(0, 256);
// SPEC §4.4: only https?:// or empty
if (!t.isEmpty() && !(t.startsWith("http://") || t.startsWith("https://"))) {
return "";
}
return t;
// GUI / C2S accepts either an http(s) URL or a /videoCache add <name>. Names resolve
// to their stored URL; URLs pass through verbatim. Anything else collapses to empty
// (SPEC §4.4: anchors with non-URL urls are no-ops).
String resolved = VideoPlayerConfig.resolveUrlOrName(s);
if (resolved == null) return "";
if (resolved.length() > 256) resolved = resolved.substring(0, 256);
return resolved;
}
private static int clamp(int v, int lo, int hi) {