If wipeOnShutdown runs between download's pre-move epoch check and the
atomic Files.move, the wipe's directory scan misses the just-promoted
final file. The first epoch-mismatch branch in publishIfNotCancelled was
returning without deleting, leaking the file across sessions. Delete on
the first branch too.
Add ClientLifecycleEvents.CLIENT_STOPPING handler that deletes every
file under <gameDir>/video_player_cache/. Quieter sibling of clearAll()
- no chat notify and no concurrent-download race handling needed since
at shutdown no other code is touching the directory.
Cache repopulation on next launch is already handled: the server's
ServerPlayConnectionEvents.JOIN handler re-broadcasts a PreloadPayload
for every preload_urls and cache_entries entry on every join, so a
freshly wiped cache auto-refills from the server's named entries
without any user action.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
stopWorker() ran a 2 s bounded join on the client tick thread. When a
user runs /videoPlace and immediately /videoDelete, the decoder worker
is still inside native grabber.start() doing the initial HTTP probe
(probesize=8 MB, analyzeduration=2 s); that call doesn't honor the
running flag and Thread.interrupt() doesn't unblock native I/O, so the
join blocked the tick thread for as long as start() took — exactly the
brief in-game freeze the user reported.
Signal stop (running=false, audio line stop+flush, worker.interrupt())
and return. The worker is a daemon, the audio tail is already silenced,
the Entry has been removed from the map, and the worker's finally still
closes the grabber whenever start()/grab() eventually returns — nothing
observable depends on the grabber being closed synchronously before
close() returns.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reviewer flagged v0.4.25 broke existing command blocks by inserting
'volume' as a required arg between height and url. Split into two
Brigadier branches: legacy 5-arg defaults volume=50, muted=false; new
6-arg keeps the -1=mute shortcut. Legacy branch uses a single-token url
arg so '50 https://...' parses as the new (int) branch, not as a legacy
url starting with a digit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New signature:
/videoPlace <pos> <facing> <width> <height> <volume> <url>
volume is int -1..100. 0..100 sets percent and clears mute; -1 is a CLI
shortcut that sets muted=true (underlying volume kept at 0.5 so a later
/videoMute false restores audible level without re-typing). Matches the
percent scale shown in the config GUI.
Bug: /videoPlace (and any path that places an anchor) reported success
on the server but the panel was invisible on the client — no quad, no
video. Walking around did not help.
Root cause: VideoAnchorBlockEntity did not override getUpdateTag() or
getUpdatePacket(). The base implementations return an empty CompoundTag
and null respectively, so url/facing/width/height were never carried in
vanilla BE sync. There is also a packet-ordering race between
level.setBlock() (queues a deferred chunk broadcast) and
ServerPlayNetworking.send(SyncAnchorPayload) (writes immediately): if
the payload arrives first, the client drops it because the BE does not
exist yet, then the chunk packet creates the BE with defaults
(url="") and the renderer silently no-ops.
Fix: override getUpdateTag(HolderLookup.Provider) → toNbt(), and
getUpdatePacket() → ClientboundBlockEntityDataPacket.create(this). NBT
key names already line up between toNbt() and loadAdditional(), so
vanilla wraps the CompoundTag in TagValueInput and existing load logic
reads it. Also fixes the 'walk far away, come back' case — that path
has no SyncAnchorPayload, just vanilla chunk re-sync.
Reported: with render_distance_blocks=256, the panel started shimmering
and the wall texture bled through at ~30 blocks away. Both issues are
inherent distance-rendering bugs that were previously hidden by the
default ~64-block view distance.
Two fixes in VideoAnchorRenderer:
1. SURFACE_EPSILON 0.001 → 0.02. With 24-bit depth and near=0.05, the
smallest resolvable depth step at 30 blocks is ~1mm, so the old 1mm
offset was right at the z-fight boundary. 2cm gives ~20× margin at
30 blocks and remains visually unnoticeable up close.
2. RenderType entityCutout → entitySolid. swscale outputs RGBA with
alpha=255, so there is no real cutout. Cutout's alpha-discard step
makes distant sampling unstable on a dynamic non-mipmapped texture;
solid removes that and is the semantically correct type.
Previously the per-anchor audio gain was just volume × distance attenuation,
which ignored Minecraft's sound options sliders entirely — Master and Players
both had no effect on video audio. Now updateDistanceGains multiplies in
options.getSoundSourceVolume(MASTER) × getSoundSourceVolume(PLAYERS) to
match vanilla SoundEngine.calculateVolume, so the Players slider attenuates
video audio like other player sounds and Master gates everything.
Recomputed at the same 20Hz tick as distance gain — slider drags take
effect within ~50ms with no extra plumbing.
Reviewer-flagged: v0.4.20 fixed the reindex branch's post-put race, but
the main download completion path still did READY.put without a follow-up
epoch check. /videoCache clear landing between READY.put and the chat
notification could leave a resurrected READY entry pointing at a deleted
file, plus a stale "완료" message.
Extracted publishIfNotCancelled(url, path, startEpoch) helper that does
the full pre-check → put → post-check → rollback dance, and routed both
publish sites (reindex branch + download-complete branch) through it.
Centralizing this is the structural fix: future publish sites can't forget
the post-check.
Reviewer-flagged: the existing-cache reindex branch in VideoCache.download
only ran one epoch check before READY.put, so a /videoCache clear landing
between the check and the put could leave a stale entry pointing at a
now-deleted file. Same pattern as the post-move fix in v0.4.19, applied
to the reindex path: pre-check, put, post-check + rollback on mismatch.
Also: preload() previously gated on READY.containsKey(url), which silently
blocks a re-preload if READY holds a stale key whose backing file is gone
(e.g. user deleted the file manually, or the cleanup half of a clear race).
Switched to lookup(url) — same intent, but lookup verifies the file
actually exists on disk, so stale keys self-heal on the next preload.
Reviewer-flagged: v0.4.18 only checked epoch BEFORE Files.move. The window
between the move completing and READY.put / "완료" chat was still racy —
if /videoCache clear landed in that window, clearAll would epoch++ +
clear READY + delete files on disk, then the download thread would do
READY.put(url, finalPath) anyway, resurrecting a cleared entry and emitting
a stale "완료" message.
Add a second epoch check immediately AFTER Files.move(): if the epoch
changed, delete finalPath and return without publishing. The pre-move
check is kept too — it lets the common cancel-during-read case skip the
wasted move/delete round-trip.
clearAll() previously only wiped finalized files + the IN_FLIGHT set; any
download that was already running on the single-thread executor kept
writing past the clear, then atomically moved its .part to final and
re-published into READY — resurrecting one cache entry post-clear.
Fix: add an AtomicLong CACHE_EPOCH. preload() captures the epoch at submit
time; clearAll() bumps the epoch BEFORE wiping disk; download(url, epoch)
checks at three points (pre-start, in read loop, pre-publish) and bails if
the epoch moved, deleting its own .part only AFTER closing the output
stream (Windows can't delete an open file).
Phase ordering in clearAll() also matters: bump epoch first → drop
indexes → delete files. That way the in-flight download sees the
cancellation flag before the index wipe / file delete races can interact
with it.
Implements docs/mc_video_player_mod_integration.md.
Server (MusicQuizPresence):
- every server tick: #server mq_video_mod = 1
- on MqHelloPayload receive: <player> mq_video_mod = 1
- both silently skip if objective absent (datapack not yet loaded)
Client (MusicQuizClient):
- send MqHelloPayload(1) on JOIN
- resend every 100 client ticks (~5 s) — mandatory because the datapack's
mq:players/login resets per-player score to 0 at spawn-dialog passage
Payload: video_player:mq_hello, single VAR_INT body (version=1).
1. VideoPlayerConfig.save() now persists max_cache_mb. Previously the
auto-augment path in load() called save(), but save() didn't emit the
key — so the rewrite kept losing it. Now updated comment + property.
2. /videoCache clear sends a new ClearCachePayload (no body) that wipes
the entire client video_player_cache/ directory. Previously it only
broadcast a DeleteCachePayload per *registered* URL, so leftover files
from legacy preload_urls or prior sessions were never cleaned; now
we always send clearAll regardless of whether the named index was
non-empty.
3. Downloads are now serialized through a single-thread executor instead
of `new Thread(...).start()`. With parallel downloads, every worker
was snapshotting cacheDirSize() before any .part was renamed to its
final name, so 50 simultaneous joins could collectively bust the
max_cache_mb cap. With one in-flight at a time, each download's
directory scan reflects every preceding completion.
Network: new S2C ClearCachePayload(unit codec), registered in
VideoPlayerNetwork + ClientNetworking. VideoCache.clearAll() iterates
the dir, deletes regular files, resets READY/IN_FLIGHT, reports
deleted/failed counts in chat + log. /videoCache clear command always
broadcasts to all clients now.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- max_preload_mb is now strictly the per-video download cap (default raised
to 2048 MB so a single 4K short clip fits without hitting the wall).
- New max_cache_mb knob caps total client-side cache directory size
(default 750 MB, sized for ~50 short FHD clips). Enforced cooperatively
at start of each /videoPreload download and during the read loop so a
late-arriving large clip can't blow past the cap.
- VideoPlayerConfig.load() now detects missing keys (max_preload_mb,
max_cache_mb, render_distance_blocks) and rewrites the file once with
defaults filled in, so existing servers pick up new options without
having to delete config/video_player.json.
- CachePolicyPayload now carries (maxPerVideoBytes, maxCacheBytes,
renderDistanceBlocks); StreamCodec order: VAR_LONG, VAR_LONG, VAR_INT.
- Client receiver wires both caps into VideoCache; chat errors distinguish
"단일 영상 NMB 초과" vs "전체 캐시 NMB 초과".
- Add dist/ to .gitignore (release artifacts uploaded to Gitea, never
committed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- New /videoCache clear subcommand: drops every named entry from server config
and broadcasts DeleteCachePayload per URL so each client purges its disk cache
in one shot.
- Default max_preload_mb lowered from 1024 → 750. Sized to fit ~50 short FHD
clips (FHD H.264 ~5 Mbps × 20 s ≈ 12.5 MB → 50 × 15 MB ≈ 750 MB with headroom).
Config file's max_preload_mb still wins when set; the new default only
affects fresh installs and the client-side bootstrap value before the JOIN
policy packet arrives.
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.
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>
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>
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>
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.
- 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.
- 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.
- 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.
- audio: distance attenuation now uses the panel center (width/2, height/2
offset from the anchor along the renderer's right/up axes) instead of the
anchor block corner, so a 4x4 panel sounds like it's coming from the
middle of the screen and not the bottom-left.
- preload: each client now posts a chat line on start / completion / failure
/ cache-hit, so a command-block sequence like /videopreload -> /videoplace
can be timed against the visible "[videopreload] 완료" message.
- safety: VideoPlayback.tick() verifies the anchor BE still exists at each
active position and forcibly stops playback if it doesn't — covers any
edge case where BLOCK_ENTITY_UNLOAD doesn't fire.
- /videopreload feedback now explicitly states "완료 알림 후 재생하세요".
- fix: stop playback when anchor block entity unloads (BLOCK_ENTITY_UNLOAD)
so deleting a video while audio is playing actually silences it.
- fix: force-stop SourceDataLine and grabber from outside the worker thread
so a blocked line.write() / grab() unblocks immediately on close.
- perf: tune FFmpeg streaming options (buffer_size, probesize, analyzeduration,
max_delay, fflags=+genpts, reconnect_delay_max) and pre-size audio line buffer
to ~0.5s to smooth out mid-stream stutter.
- feat: /videopreload <url> broadcasts a S2C PreloadPayload to all clients;
each client downloads the URL to <gameDir>/video_player_cache/<sha256> and
subsequent playback reads from the local file instead of streaming.
Gated by COMMANDS_GAMEMASTER (op level 2), so command blocks can invoke it.
JavaCPP Loader (javacpp.jar) ships pure-Java code that needs its own
JNI bridge DLL (jnijavacpp) to extract & link other native libraries.
That bridge lives in the platform-specific javacpp-<os>-<arch>.jar
which the old install guide silently omitted — users following it
ended up with a black panel and an UnsatisfiedLinkError for jnijavacpp
because FFmpeg natives could never be loaded.
This bumps the required JavaCV jar list from 4 to 5, updates the
-Xbootclasspath/a: examples on all three OSes, and adds a diagnostic
note for the matching log line.
Docs-only change; mod code is unchanged from v0.4.2.
The held video_stick item rendered as the default missing-model cube even
with v0.4.1 jar loaded (lang strings resolved, so the mod itself was active).
Root cause confirmed against Fabric 26.1.2 docs: the new model loader no
longer auto-resolves unprefixed parent paths. `item/generated` needs to be
written as `minecraft:item/generated`.
models/item/video_stick.json — parent → minecraft:item/generated.
gradle.properties — 0.4.1 → 0.4.2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three fixes for v0.4.1:
1. Video stick item rendered as missing-texture because 26.1.2 requires the
new client_item descriptor at assets/<mod>/items/<name>.json. Add it; the
existing models/item/video_stick.json is kept as the underlying model.
2. Quad placement now anchors the local (0,0) corner at the bottom-left of
the wall face the player clicked, so the clicked block is the BL and the
video grows up & right. Previously it was centered on the anchor.
3. EAST/WEST face rotations were swapped, which placed the quad on the far
side of the air block (~1 block away from the wall) instead of flush.
Derived the correct rotations from first principles:
EAST = Axis.YP +90° (local +Z → world +X, +X → -Z = north)
WEST = Axis.YP -90° (local +Z → world -X, +X → +Z = south)
NORTH/SOUTH/UP/DOWN math re-verified — those were already correct.
The anchor block becomes invisible and non-collidable; it exists only as a
BlockEntity host in the air block adjacent to the clicked wall. The renderer
now translates and rotates the textured quad so it sits flush against the
surface of the wall the user actually clicked, on any of the six faces.
Stick interaction:
right-click face → place anchor at hit.relative(face), facing=face, open GUI
right-click face with anchor already there → reopen the GUI
sneak + left-click face with stick → delete the anchor on that face
The anchor's selection outline / collision / occlusion are all empty, so the
player can target the wall block behind it without interference.
JavaCV / streaming polish:
- Bump missing-JavaCV log to WARN so users notice when the runtime jar is
not installed (previously buried at INFO).
- Add HTTP resilience options: `timeout`, `reconnect`, `reconnect_streamed`,
`reconnect_at_eof`, and a `user_agent` so picky servers don't 403 us.
setVolume/Mute previously stored gain without affecting audible output: the
backend only called grabImage() and never opened an audio sink. Switch to
grab() (interleaved video+audio frames), force AV_SAMPLE_FMT_S16 on the
grabber so samples are always interleaved signed 16-bit PCM, open a matching
JavaSound SourceDataLine and write scaled samples per-frame. gain is read
on every block so /videoMute, GUI Mute and the per-tick distance attenuation
now take effect immediately. SourceDataLine.write blocking provides natural
A/V pacing, so the legacy 15ms sleep is dropped when an audio line is open;
sleep is retained as a 60fps cap when there is no audio device.
bump version to 0.3.1.
- net.fabricmc.fabric-loom 1.16-SNAPSHOT (no remap; MC 26.1+ ships unobfuscated)
- gradle.properties: minecraft_version=26.1.2, loader=0.19.2, fabric-api=0.149.0+26.1.2
- Java 25 toolchain
- fabric.mod.json: fabricloader>=0.19.0, java>=25
- Drop multi-version build script + matrix CI (single-target now)
- Backup of 1.21.6/7/8 working tree preserved on mc-1.21.x branch
Source migration to Mojmap names is in progress on follow-up commits;
this commit alone will not build until source files are ported.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>