diff --git a/.github/workflows/3ds-runtime.yml b/.github/workflows/3ds-runtime.yml index 488aa684..78e64dd8 100644 --- a/.github/workflows/3ds-runtime.yml +++ b/.github/workflows/3ds-runtime.yml @@ -1,10 +1,10 @@ name: 3DS runtime contracts on: pull_request: - paths: ['hosts/3ds/**', 'contracts/**', 'tools/3ds*.ts', 'tools/media-stream.ts', 'framework/src/media.ts', 'tests/media.test.ts', 'tools/native-source.ts', 'tools/native-host-build.ts', 'tests/native-source.test.ts', 'tests/3ds*.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml'] + paths: ['hosts/3ds/**', 'contracts/**', 'tools/3ds*.ts', 'tools/media-stream.ts', 'tools/media-download.ts', 'tests/media-library.test.ts', 'tests/fixtures/media-library/**', 'framework/src/media.ts', 'tests/media.test.ts', 'tools/native-source.ts', 'tools/native-host-build.ts', 'tests/native-source.test.ts', 'tests/3ds*.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml'] push: branches: [main] - paths: ['hosts/3ds/**', 'contracts/**', 'tools/3ds*.ts', 'tools/media-stream.ts', 'framework/src/media.ts', 'tests/media.test.ts', 'tools/native-source.ts', 'tools/native-host-build.ts', 'tests/native-source.test.ts', 'tests/3ds*.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml'] + paths: ['hosts/3ds/**', 'contracts/**', 'tools/3ds*.ts', 'tools/media-stream.ts', 'tools/media-download.ts', 'tests/media-library.test.ts', 'tests/fixtures/media-library/**', 'framework/src/media.ts', 'tests/media.test.ts', 'tools/native-source.ts', 'tools/native-host-build.ts', 'tests/native-source.test.ts', 'tests/3ds*.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml'] permissions: contents: read jobs: @@ -17,4 +17,4 @@ jobs: with: bun-version: 1.3.14 - run: bun install --frozen-lockfile - - run: bun test tests/3ds-profile.test.ts tests/3ds-runtime-state.test.ts tests/3ds-runtime-wire.test.ts tests/3ds-soc.test.ts tests/native-source.test.ts tests/blackberry-classic.test.ts tests/media.test.ts + - run: bun test tests/3ds-profile.test.ts tests/3ds-runtime-state.test.ts tests/3ds-runtime-wire.test.ts tests/3ds-soc.test.ts tests/native-source.test.ts tests/blackberry-classic.test.ts tests/media.test.ts tests/media-library.test.ts diff --git a/contracts/spec/media.ts b/contracts/spec/media.ts index 233e133c..035743a2 100644 --- a/contracts/spec/media.ts +++ b/contracts/spec/media.ts @@ -6,8 +6,9 @@ export const MEDIA = Object.freeze({ sampleRate: 22050, channels: 2, audioFrames: 1024, tokenChars: 64, packetCredits: 8, + captionWidth: 256, captionHeight: 32, }); -export const MEDIA_PACKET = Object.freeze({ video: 1, audio: 2, end: 3, error: 4 }); +export const MEDIA_PACKET = Object.freeze({ video: 1, audio: 2, end: 3, error: 4, caption: 5 }); export interface MediaSource { /** Numeric IPv4 address of the paired companion's media endpoint. */ host: string; @@ -15,6 +16,18 @@ export interface MediaSource { /** Ephemeral stream ticket issued by that companion. */ token: string; } +export interface LocalMediaSource { file: string; positionMs?: number } +export interface MediaCaption { width: number; height: number; coverage: string; endMs: number } +export interface MediaLibraryEntry { key: string; title: string; language: string; durationMs: number; bytes: number; video: boolean; captions: boolean } +export interface MediaDownloadStatus { phase: "idle" | "connecting" | "downloading" | "verifying" | "complete" | "cancelled" | "error"; receivedBytes: number; totalBytes: number; error: string } +export interface MediaLibraryOps { + download(host: string, port: number, token: string, key: string): boolean; + cancelDownload(): void; + downloadStatus(): string; + refreshLibrary(): boolean; + library(): string | null; + removeDownload(key: string): boolean; +} export type MediaPhase = "idle" | "opening" | "buffering" | "playing" | "paused" | "ended" | "error"; export interface MediaStatus { phase: MediaPhase; @@ -39,7 +52,11 @@ export interface MediaOps { texture(): number; /** Bounded snapshot. No socket, filesystem or decoder calls on the UI. */ status(): string; + openLocal?(key: string, positionMs: number): boolean; + /** Changed timed caption, null when unchanged, or an empty object to clear. */ + caption?(): string | null; } +export function validMediaKey(key: string): boolean { return typeof key === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(key); } export function validMediaSource(source: MediaSource): boolean { return typeof source?.host === "string" && /^\d{1,3}(\.\d{1,3}){3}$/.test(source.host) && source.host.split(".").every(p => Number(p) <= 255) diff --git a/docs/MEDIA.md b/docs/MEDIA.md index 41cd344e..fa313b5f 100644 --- a/docs/MEDIA.md +++ b/docs/MEDIA.md @@ -73,7 +73,7 @@ firmware in Rosalina, then reopen the media source. The dump reads firmware from the console; the application does not distribute firmware. See the [devkitPro audio setup](https://github.com/devkitPro/3ds-examples/blob/master/audio/README.md). The 3DS profile remains -private and its host ABI is 10; ABI 8 launchers require a native replacement. +private and its host ABI is 11; earlier launchers require a native replacement. The CIA declares `mvd:STD` service access and the MVD system-module dependency listed in [3dbrew's title table](https://www.3dbrew.org/wiki/Title_list#00040130_-_System_Modules). @@ -93,3 +93,50 @@ The 3DS decoder requests **`MVD_OUTPUT_BGR565` (0x40002)** for the packed words consumed by `GX_TRANSFER_FMT_RGB565` and `GPU_RGB565`. The MVD and GPU names use different channel-order conventions. Selecting MVD's `RGB565` exchanges red and blue; the companion must retain the source colors. + +## Local media and downloads + +`mediaLibrary()` submits downloads, refreshes and deletions to a storage +worker. `download(source, key)` accepts a ticketed companion endpoint and an +entry key containing 1–64 ASCII letters, digits, hyphens or underscores. +**The key names an entry under `sdmc:/pocketjs/media//`.** It cannot +name a path outside that directory. The application reads `status()` for +connection, transfer, verification, completion, cancellation and errors; +`entries()` returns a new library snapshot when one is published. + +`createMediaDownloadServer` in `tools/media-download.ts` serves immutable +PKDL files. Each transfer consumes one ticket and acknowledges the 256-byte +header, then each **32 KiB block**. The storage worker writes `.part`, checks +the payload CRC32, closes the file, reads it back, checks CRC32 again, exports +the UTF-8 `.vtt` sidecar, and renames the package to `.pkd`. **Only committed +packages appear in the library.** Cancel and transfer failures remove the +temporary package. Starting a download never replaces an existing key. + +The 256-byte PKDL header records media, index and caption byte lengths, +duration, CRC32, a bounded UTF-8 title and language. The payload contains a +PKMV stream, 12-byte keyframe records `(PTS, media offset, active-caption +offset)`, then WebVTT. Caption-only packages have zero media and index bytes. +The current limits are **64 library entries, a package below 2 GiB, a duration +up to 24 hours, and 4 MiB of WebVTT**. The companion owns preparation and +progress reporting before transfer; the native worker owns SD progress. + +`mediaPlayer().open({ file: key, positionMs })` opens a committed package. +The playback worker searches the keyframe index, starts at the preceding IDR, +and discards audio and presentation frames before the requested position. +**Local playback, pause, volume and seek require no companion connection.** +An application must keep its local player mounted when a companion session +disconnects. Deleting the selected entry requires closing that player first. + +## Timed captions + +Packet kind 5 contains a duration in milliseconds, 16-bit width and height, +and **256×32 pixels of 2-bit alpha coverage**. The companion rasterizes text +in the source script. Coverage stays in the saved stream, and WebVTT remains +available as text on SD. Native playback queues eight cues and selects them +using the audio clock; local seek restores the cue active at the keyframe. + +`mediaPlayer().caption()` returns a changed cue, an empty object to clear, +or `null` when unchanged. The guest polls this method even when captions are +hidden, uploads changed coverage through `uploadCoverage`, and releases the +previous texture. Each caption handoff copies at most 2 KiB of coverage; +the UI performs no file or socket reads. diff --git a/framework/compiler/subpaths.ts b/framework/compiler/subpaths.ts index 52e4d4d5..ee4f926f 100644 --- a/framework/compiler/subpaths.ts +++ b/framework/compiler/subpaths.ts @@ -69,6 +69,7 @@ export const SUBPATHS: Record = { audio: { file: "framework/src/audio-api.ts", aliases: TWINS }, media: { file: "framework/src/media.ts", aliases: TWINS }, "media/provider": { file: "tools/media-stream.ts" }, + "media/download-provider": { file: "tools/media-download.ts" }, "media/audio": { file: "contracts/spec/media-adpcm.ts" }, clock: { file: "framework/src/clock.ts", aliases: TWINS }, config: { file: "framework/src/config.ts" }, diff --git a/framework/src/media.ts b/framework/src/media.ts index ce90475f..348f307f 100644 --- a/framework/src/media.ts +++ b/framework/src/media.ts @@ -1,13 +1,18 @@ -import { MEDIA, validMediaSource, type MediaOps, type MediaSource, type MediaStatus } from "../../contracts/spec/media.ts"; +import { MEDIA, validMediaSource, validMediaKey, type MediaOps, type MediaSource, type MediaStatus, type LocalMediaSource, type MediaCaption, type MediaLibraryOps, type MediaLibraryEntry, type MediaDownloadStatus } from "../../contracts/spec/media.ts"; export { MEDIA }; -export type { MediaSource, MediaStatus, MediaPhase } from "../../contracts/spec/media.ts"; +export type { MediaSource, MediaStatus, MediaPhase, LocalMediaSource, MediaCaption, MediaLibraryEntry, MediaDownloadStatus } from "../../contracts/spec/media.ts"; /** One native playback plane per realm. Surface placement is an ordinary * Image binding, independent of the number of displays and input devices. */ export function mediaPlayer(ops = (globalThis as unknown as { media?: MediaOps }).media) { if (!ops) throw new Error("Host does not implement media.playback"); return { - open(source: MediaSource): boolean { + open(source: MediaSource | LocalMediaSource): boolean { + if ("file" in source) { + const position = source.positionMs ?? 0; + if (!validMediaKey(source.file) || !Number.isFinite(position) || position < 0 || position > 86400000) throw new Error("Invalid local media source"); + return ops.openLocal?.(source.file, Math.round(position)) ?? false; + } if (!validMediaSource(source)) throw new Error("Invalid companion media source"); return ops.open(source.host, source.port, source.token); }, @@ -16,6 +21,24 @@ export function mediaPlayer(ops = (globalThis as unknown as { media?: MediaOps } volume: (value: number) => ops.volume(Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0), texture: () => ops.texture(), status: (): MediaStatus => JSON.parse(ops.status()), + caption: (): MediaCaption | Record | null => { const value = ops.caption?.(); return value ? JSON.parse(value) : null; }, + }; +} + +/** SD library operations are bounded handoffs to a storage worker. Keys name + * entries in the host's app-specific media directory, never arbitrary paths. */ +export function mediaLibrary(ops = (globalThis as unknown as { media?: MediaLibraryOps }).media) { + if (!ops) throw new Error("Host does not implement a media library"); + return { + download(source: MediaSource, key: string) { + if (!validMediaSource(source) || !validMediaKey(key)) throw new Error("Invalid media download"); + return ops.download(source.host, source.port, source.token, key); + }, + cancel: () => ops.cancelDownload(), + status: (): MediaDownloadStatus => JSON.parse(ops.downloadStatus()), + refresh: () => ops.refreshLibrary(), + entries: (): MediaLibraryEntry[] | null => { const value = ops.library(); return value ? JSON.parse(value) : null; }, + remove(key: string) { if (!validMediaKey(key)) throw new Error("Invalid media key"); return ops.removeDownload(key); }, }; } diff --git a/framework/src/virtual-list.ts b/framework/src/virtual-list.ts index f87b30b1..62bb4713 100644 --- a/framework/src/virtual-list.ts +++ b/framework/src/virtual-list.ts @@ -78,6 +78,8 @@ export interface VirtualListProps { * false: rows are plain views and the d-pad scrolls the im way. */ focusRows?: boolean; onRowPress?: (index: number) => void; + /** A stationary hold claims the contact and suppresses its release tap. */ + onRowLongPress?: (index: number) => void; /** Fired every frame while the offset is within nearStartPx of the top — * guard with your own loading/hasMore flags (the im convention). */ onNearStart?: () => void; @@ -286,6 +288,12 @@ export function VirtualList(props: VirtualListProps): SolidJSX.Element { props.onRowPress?.(row.index); } }, + onLongPress: props.onRowLongPress ? (c) => { + if (!active()) return; + const row = rowFromContact(c); + setActiveNode(null); + if (row) { setFocusedIndex(row.index); props.onRowLongPress?.(row.index); } + } : undefined, onCancel: () => { setActiveNode(null); if (scroller.state() === "tracking") scroller.endDrag(0); // no fling out of a modal open diff --git a/hosts/3ds/Makefile b/hosts/3ds/Makefile index 03a1f7c4..17811060 100644 --- a/hosts/3ds/Makefile +++ b/hosts/3ds/Makefile @@ -92,6 +92,7 @@ endif ifeq ($(POCKETJS_MEDIA),1) CFLAGS += -DPOCKETJS_MEDIA +CFLAGS += -DPOCKETJS_MEDIA_ROOT='"sdmc:/pocketjs/media/$(POCKETJS_OFFLOAD_SLOT)"' endif ifeq ($(POCKETJS_CAPTURE),1) @@ -106,7 +107,7 @@ LDFLAGS := -specs=3dsx.specs $(ARCH) -Wl,--gc-sections -Wl,-Map,$(BUILD)/pocketj LIBPATHS := -L$(DEVKITPRO)/libctru/lib LIBS := -lcitro3d -lctru -lm -OBJECTS := $(BUILD)/main.o $(BUILD)/media.o $(BUILD)/offload.o $(BUILD)/soc.o $(BUILD)/svcwire.o $(BUILD)/runtime.o $(BUILD)/dev_protocol.o $(BUILD)/devserver.o $(BUILD)/devmenu.o $(BUILD)/gfx.o $(BUILD)/qjs.o $(BUILD)/input.o $(BUILD)/vshader_shbin.o +OBJECTS := $(BUILD)/main.o $(BUILD)/media.o $(BUILD)/media_library.o $(BUILD)/offload.o $(BUILD)/soc.o $(BUILD)/svcwire.o $(BUILD)/runtime.o $(BUILD)/dev_protocol.o $(BUILD)/devserver.o $(BUILD)/devmenu.o $(BUILD)/gfx.o $(BUILD)/qjs.o $(BUILD)/input.o $(BUILD)/vshader_shbin.o ELF := $(BUILD)/pocketjs-3ds.elf SMDH := $(BUILD)/pocketjs-3ds.smdh @@ -152,7 +153,8 @@ $(BUILD)/%.o: $(SOURCE)/%.c $(BUILD)/vshader_shbin.h $(FLAGS_STAMP) | $(BUILD) $(BUILD)/offload.o: $(SOURCE)/offload.h $(SOURCE)/offload_queue.h $(BUILD)/qjs.o: $(SOURCE)/offload.h $(SOURCE)/offload_coverage.h -$(BUILD)/media.o: $(SOURCE)/media.h $(SOURCE)/media_wire.h $(SOURCE)/media_adpcm.h +$(BUILD)/media.o: $(SOURCE)/media.h $(SOURCE)/media_wire.h $(SOURCE)/media_adpcm.h $(SOURCE)/media_archive.h $(SOURCE)/media_library.h +$(BUILD)/media_library.o: $(SOURCE)/media_library.h $(SOURCE)/media_archive.h $(SOURCE)/media_wire.h $(BUILD)/main.o $(BUILD)/qjs.o $(BUILD)/gfx.o: $(SOURCE)/media.h $(ELF): $(OBJECTS) $(POCKETJS_CORE_LIB) $(POCKETJS_QUICKJS_DIR)/libquickjs.a diff --git a/hosts/3ds/src/media.c b/hosts/3ds/src/media.c index 5475e32a..7fa728f2 100644 --- a/hosts/3ds/src/media.c +++ b/hosts/3ds/src/media.c @@ -3,6 +3,8 @@ #include "media.h" #include "media_wire.h" #include "media_adpcm.h" +#include "media_archive.h" +#include "media_library.h" #include "pocket_core.h" #include "soc.h" #include <3ds.h> @@ -18,9 +20,9 @@ #include enum { IDLE, OPENING, BUFFERING, PLAYING, ENDED, FAILED }; -enum { ERR_NONE, ERR_MEMORY, ERR_SOCKET, ERR_HEADER, ERR_PACKET, ERR_MVD_INIT, ERR_MVD_DECODE, ERR_MVD_RENDER, ERR_AUDIO, ERR_REMOTE }; +enum { ERR_NONE, ERR_MEMORY, ERR_SOCKET, ERR_HEADER, ERR_PACKET, ERR_MVD_INIT, ERR_MVD_DECODE, ERR_MVD_RENDER, ERR_AUDIO, ERR_REMOTE, ERR_FILE }; enum { VIDEO_SLOTS=16, AUDIO_SLOTS=24, COMMAND_SLOTS=4, AUDIO_CHANNEL=0 }; -typedef struct { char host[16], token[65]; unsigned port, generation; } Command; +typedef struct { char host[16], token[65]; unsigned port, generation, seek_ms; char key[65]; } Command; typedef struct { uint8_t *pixels; uint32_t pts, generation; } VideoFrame; static Command commands[COMMAND_SLOTS]; static _Atomic unsigned command_read, command_write, requested, status_generation; @@ -42,7 +44,14 @@ static bool startup_ready, draining; static uint32_t origin_position, buffer_origin; static unsigned last_volume=101; static uint8_t *nal_buffer; -static unsigned current_generation; +static unsigned current_generation, seek_target; +static FILE *local_file; +static long local_end; +typedef struct { unsigned pts,end,generation; uint8_t data[MEDIA_CAPTION_BYTES-8]; } Caption; +static Caption captions[8]; +static _Atomic unsigned caption_read,caption_write; +static unsigned caption_generation,caption_end; +static bool caption_visible; _Static_assert(ATOMIC_INT_LOCK_FREE==2, "Media UI handoff must be lock-free"); static bool current(void) { return atomic_load(&running) && atomic_load(&requested)==current_generation; } @@ -77,18 +86,20 @@ static void audio_tick(void) { was_starved=starved; } static bool receive_exact(int fd, void *data, size_t count) { + if(local_file && (uint64_t)ftell(local_file)+count>(uint64_t)local_end) return false; uint8_t *p=data; uint64_t deadline=osGetTime()+10000; while (count && current()) { audio_tick(); if (atomic_load(&paused)) { deadline=osGetTime()+10000; svcSleepThread(1000000); continue; } - int n=recv(fd,p,count,0); + int n=local_file ? (int)fread(p,1,count,local_file) : recv(fd,p,count,0); if (n>0) { p+=n; count-=n; atomic_fetch_add(&received,n); deadline=osGetTime()+10000; continue; } - if (!n || (errno!=EWOULDBLOCK && errno!=EAGAIN) || osGetTime()>deadline) return false; + if (local_file || !n || (errno!=EWOULDBLOCK && errno!=EAGAIN) || osGetTime()>deadline) return false; svcSleepThread(1000000); } return count==0 && current(); } static bool put_audio(const uint8_t *data, unsigned size, uint32_t pts) { + if(ptsatomic_load(&decode_max)) atomic_store(&decode_max,us); if (r!=MVD_STATUS_OK) { fail(ERR_MVD_RENDER,r); return false; } + if(ptspts=pts; slot->generation=current_generation; atomic_fetch_add(&decoded,1); atomic_store_explicit(&frame_write,write+1,memory_order_release); @@ -161,6 +173,20 @@ static bool decode_video(MVDSTD_Config *config,const uint8_t *data,size_t size,u } return current(); } +static bool put_caption(const uint8_t *data,unsigned size,uint32_t pts) { + if(size!=MEDIA_CAPTION_BYTES || media_u16(data+4)!=MEDIA_CAPTION_WIDTH || media_u16(data+6)!=MEDIA_CAPTION_HEIGHT + || !media_u32(data) || media_u32(data)>3600000 || (uint64_t)pts+media_u32(data)>UINT32_MAX) {fail(ERR_PACKET,0);return false;} + if(pts+media_u32(data)<=seek_target) return true; + while(current()) { + unsigned write=atomic_load_explicit(&caption_write,memory_order_relaxed); + if(write-atomic_load_explicit(&caption_read,memory_order_acquire)<8) { + Caption *cue=&captions[write%8];cue->pts=pts;cue->end=pts+media_u32(data);cue->generation=current_generation; + memcpy(cue->data,data+8,sizeof cue->data);atomic_store_explicit(&caption_write,write+1,memory_order_release);return true; + } + audio_tick();svcSleepThread(1000000); + } + return false; +} static void play(const Command *cmd) { current_generation=cmd->generation; atomic_store(&phase,OPENING); atomic_store(&failure,0); atomic_store(&result_code,0); @@ -169,6 +195,19 @@ static void play(const Command *cmd) { atomic_store(&underruns,0); atomic_store(&buffered_until,0); atomic_store_explicit(&status_generation,current_generation,memory_order_release); int fd=-1; bool mvd=false; uint8_t *packet=NULL; + uint8_t header[32],archive[256];uint32_t start_pts=0,offset=32,caption_offset=0; + local_file=NULL;seek_target=0; + if(cmd->key[0]) { + char path[320];snprintf(path,sizeof path,"%s/%s.pkd",POCKETJS_MEDIA_ROOT,cmd->key); + local_file=fopen(path,"rb"); + if(!local_file || fread(archive,1,256,local_file)!=256 || !media_archive_valid(archive) || !media_u32(archive+8)) {fail(ERR_FILE,0);goto done;} + seek_target=cmd->seek_ms; + unsigned duration=media_u32(archive+20);if(duration && seek_target>=duration) seek_target=duration-1; + local_end=256+media_u32(archive+8); + if(!media_archive_seek(local_file,archive,seek_target,&start_pts,&offset,&caption_offset) + || fseek(local_file,256,SEEK_SET)) {fail(ERR_FILE,0);goto done;} + goto source_ready; + } while (current() && !soc_ensure(NULL,0)) svcSleepThread(10000000); if (!current()) return; fd=socket(AF_INET,SOCK_STREAM,0); @@ -186,10 +225,11 @@ static void play(const Command *cmd) { else svcSleepThread(1000000); } if (sent!=64) { fail(ERR_SOCKET,errno); goto done; } - uint8_t header[32]; +source_ready: if (!receive_exact(fd,header,32)) { fail(ERR_SOCKET,errno); goto done; } if (!media_header_valid(header)) { fail(ERR_HEADER,0); goto done; } - origin_position=media_u32(header+24); + origin_position=local_file ? seek_target : media_u32(header+24); + if(!local_file) start_pts=origin_position; buffer_origin=origin_position; atomic_store(&position,origin_position); packet=malloc(MEDIA_PACKET_BYTES); @@ -211,7 +251,14 @@ static void play(const Command *cmd) { MVDSTD_Config config; mvdstdGenerateDefaultConfig(&config,MEDIA_WIDTH,MEDIA_HEIGHT,MEDIA_WIDTH,MEDIA_HEIGHT,NULL,(u32*)frames[0].pixels,(u32*)frames[0].pixels); atomic_store(&phase,BUFFERING); - uint32_t last_video=media_u32(header+24),last_audio=last_video; + if(local_file) { + if(caption_offset) { + if(fseek(local_file,256+caption_offset,SEEK_SET) || !receive_exact(fd,header,16) || header[0]!=5 || !media_packet_valid(header) + || !receive_exact(fd,packet,media_u32(header+4)) || !put_caption(packet,media_u32(header+4),media_u32(header+8))) {fail(ERR_FILE,0);goto done;} + } + if(fseek(local_file,256+offset,SEEK_SET)) {fail(ERR_FILE,0);goto done;} + } + uint32_t last_video=start_pts,last_audio=start_pts; while (current()) { if (!receive_exact(fd,header,16)) { fail(ERR_SOCKET,errno); break; } if (!media_packet_valid(header)) { fail(ERR_PACKET,0); break; } @@ -223,9 +270,10 @@ static void play(const Command *cmd) { if (size && !receive_exact(fd,packet,size)) { fail(ERR_SOCKET,errno); break; } if (header[0]==1 && !decode_video(&config,packet,size,pts)) break; if (header[0]==2 && !put_audio(packet,size,pts)) break; - if (!atomic_load(&decoded) && (header[0]==1 || header[0]==2) && pts-origin_position>500) { fail(ERR_MVD_DECODE,0); break; } + if (header[0]==5 && !put_caption(packet,size,pts)) break; + if (!atomic_load(&decoded) && (header[0]==1 || header[0]==2) && pts>origin_position+1000) { fail(ERR_MVD_DECODE,0); break; } if (header[0]==4) { fail(ERR_REMOTE,0); break; } - if(header[0]==1 || header[0]==2) { + if(!local_file && (header[0]==1 || header[0]==2 || header[0]==5)) { char credit=1; bool acknowledged=false; while(current()) { if(send(fd,&credit,1,0)==1) { acknowledged=true; break; } @@ -251,6 +299,8 @@ static void play(const Command *cmd) { } done: if (fd>=0) close(fd); + if(local_file) fclose(local_file); + local_file=NULL; if (audio_live) { ndspChnWaveBufClear(AUDIO_CHANNEL); ndspExit(); audio_live=false; } if (mvd) mvdstdExit(); free(packet); @@ -282,9 +332,10 @@ bool media_start(void) { texture_live=true; memset(texture.data,0,texture.size); GSPGPU_FlushDataCache(texture.data,texture.size); C3D_TexSetFilter(&texture,GPU_LINEAR,GPU_LINEAR); C3D_TexSetWrap(&texture,GPU_CLAMP_TO_EDGE,GPU_CLAMP_TO_EDGE); atomic_store(&running,true); worker=threadCreate(run,NULL,64*1024,0x3e,-2,false); - return worker!=NULL; + return worker!=NULL && media_library_start(); } void media_stop(void) { + media_library_stop(); atomic_store(&running,false); atomic_fetch_add(&requested,1); if (worker) { threadJoin(worker,U64_MAX); threadFree(worker); worker=NULL; } for (unsigned i=0;i='0' && token[i]<='9') || (token[i]>='a' && token[i]<='f'))) return false; unsigned write=atomic_load_explicit(&command_write,memory_order_relaxed); if(write-atomic_load_explicit(&command_read,memory_order_acquire)>=COMMAND_SLOTS) return false; - Command *cmd=&commands[write%COMMAND_SLOTS]; strcpy(cmd->host,host); strcpy(cmd->token,token); cmd->port=port; + Command *cmd=&commands[write%COMMAND_SLOTS]; memset(cmd,0,sizeof *cmd); strcpy(cmd->host,host); strcpy(cmd->token,token); cmd->port=port; cmd->generation=atomic_fetch_add(&requested,1)+1; atomic_store(&paused,false); atomic_store(&requested_open,true); atomic_store_explicit(&command_write,write+1,memory_order_release); return true; } +bool media_open_local(const char *key,unsigned position_ms) { + if(!atomic_load(&running) || !media_key_valid(key) || position_ms>86400000) return false; + unsigned write=atomic_load_explicit(&command_write,memory_order_relaxed); + if(write-atomic_load_explicit(&command_read,memory_order_acquire)>=COMMAND_SLOTS) return false; + Command *cmd=&commands[write%COMMAND_SLOTS];memset(cmd,0,sizeof *cmd);strcpy(cmd->key,key);cmd->seek_ms=position_ms; + cmd->generation=atomic_fetch_add(&requested,1)+1; + atomic_store(&paused,false);atomic_store(&requested_open,true); + atomic_store_explicit(&command_write,write+1,memory_order_release);return true; +} +/* Consume due cues and encode at most one 2 KiB coverage block per call. + * The guest uploads the returned coverage with its existing bounded texture op. */ +bool media_caption_snapshot(char *out,size_t capacity) { + unsigned generation=atomic_load(&requested),now=atomic_load(&position); + bool changed=false;Caption selected;bool found=false; + if(caption_generation!=generation || !atomic_load(&requested_open)) {caption_generation=generation;caption_visible=false;changed=true;} + unsigned read=atomic_load_explicit(&caption_read,memory_order_relaxed),write=atomic_load_explicit(&caption_write,memory_order_acquire); + while(read!=write) { + Caption *cue=&captions[read%8]; + if(cue->generation==generation && cue->pts>now) break; + if(cue->generation==generation) {selected=*cue;found=true;} + read++; + } + atomic_store_explicit(&caption_read,read,memory_order_release); + if(found && selected.end>now) { + static const char digits[]="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + size_t at=(size_t)snprintf(out,capacity,"{\"width\":%u,\"height\":%u,\"endMs\":%u,\"coverage\":\"",MEDIA_CAPTION_WIDTH,MEDIA_CAPTION_HEIGHT,selected.end); + if(capacity1)v|=(unsigned)selected.data[i+1]<<8; + if(n>2)v|=selected.data[i+2]; + out[at++]=digits[v>>18];out[at++]=digits[(v>>12)&63];out[at++]=n>1?digits[(v>>6)&63]:'=';out[at++]=n>2?digits[v&63]:'='; + } + out[at++]='"';out[at++]='}';out[at]=0;caption_end=selected.end;caption_visible=true;return true; + } + if(caption_visible && now>=caption_end) {caption_visible=false;changed=true;} + if(changed) {snprintf(out,capacity,"{}");return true;}return false; +} void media_close(void) { atomic_fetch_add(&requested,1); atomic_store(&requested_open,false); atomic_store(&paused,false); } void media_paused(bool value) { atomic_store(&paused,value); } void media_volume(float value) { atomic_store(&volume_percent,(unsigned)(!isfinite(value) || value<0 ? 0 : value>1 ? 100 : value*100)); } @@ -314,7 +403,7 @@ int32_t media_texture_handle(void) { } return texture_handle; } -void media_forget_guest(void) { media_close(); texture_handle=-1; } +void media_forget_guest(void) { media_close(); media_library_forget_guest(); texture_handle=-1; } C3D_Tex *media_texture(int32_t handle) { return texture_live && handle>=0 && handle==texture_handle ? &texture : NULL; } void media_present(void) { unsigned read=atomic_load_explicit(&frame_read,memory_order_relaxed),write=atomic_load_explicit(&frame_write,memory_order_acquire); @@ -337,7 +426,7 @@ void media_present(void) { } void media_snapshot(char *out,size_t capacity) { static const char *names[]={"idle","opening","buffering","playing","ended","error"}; - static const char *errors[]={"","Media allocation failed","Media connection lost","Unsupported media stream","Invalid media packet","H.264 hardware decoder unavailable","H.264 decode failed","Video render failed","Audio output unavailable","Companion media failed"}; + static const char *errors[]={"","Media allocation failed","Media connection lost","Unsupported media stream","Invalid media packet","H.264 hardware decoder unavailable","H.264 decode failed","Video render failed","Audio output unavailable","Companion media failed","Local media file unavailable or corrupt"}; unsigned p=atomic_load(&phase),e=atomic_load(&failure),now=atomic_load(&position),until=atomic_load(&buffered_until); if(!atomic_load(&requested_open)) { p=IDLE; e=0; } else if(atomic_load_explicit(&status_generation,memory_order_acquire)!=atomic_load(&requested)) { p=OPENING; e=0; now=until=0; } diff --git a/hosts/3ds/src/media.h b/hosts/3ds/src/media.h index f82c390e..20e34952 100644 --- a/hosts/3ds/src/media.h +++ b/hosts/3ds/src/media.h @@ -7,6 +7,8 @@ bool media_start(void); void media_stop(void); bool media_open(const char *host, unsigned port, const char *token); +bool media_open_local(const char *key, unsigned position_ms); +bool media_caption_snapshot(char *out, size_t capacity); void media_close(void); void media_paused(bool paused); void media_volume(float volume); diff --git a/hosts/3ds/src/media_archive.h b/hosts/3ds/src/media_archive.h new file mode 100644 index 00000000..4b988556 --- /dev/null +++ b/hosts/3ds/src/media_archive.h @@ -0,0 +1,42 @@ +#ifndef POCKET_MEDIA_ARCHIVE_H +#define POCKET_MEDIA_ARCHIVE_H +#include "media_wire.h" +#include +#include +#define MEDIA_ARCHIVE_HEADER 256 +#define MEDIA_ARCHIVE_CHUNK 32768 +#define MEDIA_LIBRARY_LIMIT 64 +static inline bool media_key_valid(const char *key) { + if (!key || !*key || strlen(key)>64) return false; + for (const char *p=key;*p;p++) if (!((*p>='a' && *p<='z') || (*p>='A' && *p<='Z') || (*p>='0' && *p<='9') || *p=='-' || *p=='_')) return false; + return true; +} +static inline uint32_t media_crc(const uint8_t *p,size_t n,uint32_t crc) { + while(n--) { crc^=*p++; for(unsigned i=0;i<8;i++) crc=(crc>>1)^((crc&1)?0xedb88320:0); } + return crc; +} +static inline bool media_archive_valid(const uint8_t *p) { + uint32_t video=media_u32(p+8),index=media_u32(p+12),captions=media_u32(p+16); + return !memcmp(p,"PKDL",4) && media_u16(p+4)==1 && media_u16(p+6)==256 + && (!video || video>=48) && !!video==!!index && !(index%12) && index<=86401*12 + && captions<=4*1024*1024 && media_u32(p+20)<=86400000 + && (uint64_t)256+video+index+captions<=0x7fffffff + && memchr(p+32,0,160) && memchr(p+192,0,32); +} +/* Binary search the keyframe table. Every offset remains inside the media + * section, including the optional active-caption packet for seek preroll. */ +static inline bool media_archive_seek(FILE *file,const uint8_t *header,uint32_t target,uint32_t *pts,uint32_t *offset,uint32_t *caption) { + uint32_t video=media_u32(header+8),count=media_u32(header+12)/12; + if(!count) return false; + uint32_t low=0,high=count; + uint8_t record[12]; + while(low+1=32 && *offset=32 && *caption<*offset)); +} +#endif diff --git a/hosts/3ds/src/media_library.c b/hosts/3ds/src/media_library.c new file mode 100644 index 00000000..70848460 --- /dev/null +++ b/hosts/3ds/src/media_library.c @@ -0,0 +1,208 @@ +/* Storage and sockets belong to this worker. Guest calls copy bounded + * commands and atomics; the library response is an SPSC publication. */ +#include "media_library.h" +#include "media_archive.h" +#include "soc.h" +#include <3ds.h> +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { LIB_SCAN=1, LIB_SAVE, LIB_REMOVE }; +enum { DL_IDLE, DL_CONNECTING, DL_DOWNLOADING, DL_VERIFYING, DL_COMPLETE, DL_CANCELLED, DL_ERROR }; +enum { DL_OK, DL_NETWORK, DL_INVALID, DL_STORAGE, DL_CHECKSUM, DL_EXISTS, DL_FULL }; +typedef struct { unsigned kind,port,generation; char host[16],token[65],key[65]; } LibraryCommand; +static LibraryCommand commands[4]; +static _Atomic unsigned command_read,command_write,cancellation,phase,received,total,error_code; +static _Atomic bool running,save_pending; +static Thread worker; +static unsigned current_generation; +/* One writer publishes only while the reader has released the previous copy. */ +static char library_json[65536],guest_json[65536]; +static _Atomic bool library_ready; +static uint8_t buffer[MEDIA_ARCHIVE_CHUNK]; +_Static_assert(ATOMIC_INT_LOCK_FREE==2,"Library commands must be lock-free"); + +static void path_for(char *out,size_t capacity,const char *key,const char *suffix) { snprintf(out,capacity,"%s/%s%s",POCKETJS_MEDIA_ROOT,key,suffix); } +static bool alive(void) { return atomic_load(&running) && current_generation==atomic_load(&cancellation); } +static bool mkdirs(void) { + char path[256]; snprintf(path,sizeof path,"%s",POCKETJS_MEDIA_ROOT); + for(char *p=path;*p;p++) if(*p=='/' && p>path && p[-1]!=':') { *p=0; if(mkdir(path,0777) && errno!=EEXIST) return false; *p='/'; } + return !mkdir(path,0777) || errno==EEXIST; +} +static size_t quote(char *out,const uint8_t *text,size_t length) { + size_t at=0; out[at++]='"'; + for(size_t i=0;id_name); if(n<5 || n>68 || strcmp(entry->d_name+n-4,".pkd")) continue; + char key[65],path[320];memcpy(key,entry->d_name,n-4);key[n-4]=0; + if(!media_key_valid(key)) continue; + path_for(path,sizeof path,key,".pkd");FILE *file=fopen(path,"rb");uint8_t h[256]; + bool valid=file && fread(h,1,256,file)==256 && media_archive_valid(h); + struct stat info;valid=valid && !stat(path,&info) && (uint64_t)info.st_size==256ull+media_u32(h+8)+media_u32(h+12)+media_u32(h+16); + if(file) fclose(file); + if(!valid) continue; + if(publish && count0) { bytes+=n;count-=n;deadline=osGetTime()+15000;continue; } + if(!n || (errno!=EWOULDBLOCK && errno!=EAGAIN) || osGetTime()>deadline) return false; + svcSleepThread(1000000); + } + return !count && alive(); +} +static bool send_bytes(int fd,const char *bytes,size_t count) { + uint64_t deadline=osGetTime()+15000; + while(count && alive() && osGetTime()0) { bytes+=n;count-=n;continue; } + if(errno!=EWOULDBLOCK && errno!=EAGAIN && errno!=ENOTCONN && errno!=EINPROGRESS) return false; + svcSleepThread(1000000); + } + return !count && alive(); +} +static void download(const LibraryCommand *cmd) { + current_generation=cmd->generation; + unsigned error=DL_NETWORK;int fd=-1;FILE *file=NULL,*vtt=NULL; + char temporary[320],destination[320],subpart[320],subtitle[320]; + path_for(temporary,sizeof temporary,cmd->key,".part");path_for(destination,sizeof destination,cmd->key,".pkd"); + path_for(subpart,sizeof subpart,cmd->key,".vtt.part");path_for(subtitle,sizeof subtitle,cmd->key,".vtt"); + uint8_t header[256];uint32_t crc=0xffffffff; + if(!alive()) goto done; + if(!mkdirs()) {error=DL_STORAGE;goto done;} + struct stat info;if(!stat(destination,&info)) {error=DL_EXISTS;goto done;} + if(scan(false)>=MEDIA_LIBRARY_LIMIT) {error=DL_FULL;goto done;} + uint64_t deadline=osGetTime()+15000; + while(alive() && !soc_ensure(NULL,0) && osGetTime()port)}; + if(inet_pton(AF_INET,cmd->host,&address.sin_addr)!=1) goto done; + if(connect(fd,(struct sockaddr *)&address,sizeof address)<0 && errno!=EINPROGRESS) goto done; + if(!send_bytes(fd,cmd->token,64) || !receive_bytes(fd,header,256)) goto done; + if(!media_archive_valid(header)) {error=DL_INVALID;goto done;} + uint32_t payload=media_u32(header+8)+media_u32(header+12)+media_u32(header+16); + atomic_store(&total,payload+256);atomic_store(&received,256); + file=fopen(temporary,"wb");if(!file || fwrite(header,1,256,file)!=256) {error=DL_STORAGE;goto done;} + if(!send_bytes(fd,"\1",1)) goto done; + atomic_store(&phase,DL_DOWNLOADING); + for(uint32_t remaining=payload;remaining && alive();) { + unsigned count=remaining=0) close(fd); + if(file) fclose(file); + if(vtt) fclose(vtt); + remove(temporary);remove(subpart); + atomic_store(&error_code,error);atomic_store(&phase,error==DL_OK?DL_COMPLETE:!alive()?DL_CANCELLED:DL_ERROR); + atomic_store(&save_pending,false); + scan(true); +} +static void run(void *unused) { + (void)unused;mkdirs();scan(true); + while(atomic_load(&running)) { + unsigned read=atomic_load_explicit(&command_read,memory_order_relaxed),write=atomic_load_explicit(&command_write,memory_order_acquire); + if(read==write) {svcSleepThread(1000000);continue;} + LibraryCommand cmd=commands[read%4];atomic_store_explicit(&command_read,read+1,memory_order_release); + if(cmd.kind==LIB_SAVE) download(&cmd); + else if(cmd.kind==LIB_SCAN) scan(true); + else if(cmd.kind==LIB_REMOVE) {char path[320];path_for(path,sizeof path,cmd.key,".pkd");remove(path);path_for(path,sizeof path,cmd.key,".vtt");remove(path);scan(true);} + } +} +static bool enqueue(LibraryCommand cmd) { + unsigned write=atomic_load_explicit(&command_write,memory_order_relaxed); + if(!atomic_load(&running) || write-atomic_load_explicit(&command_read,memory_order_acquire)>=4) return false; + commands[write%4]=cmd;atomic_store_explicit(&command_write,write+1,memory_order_release);return true; +} +bool media_library_start(void) {atomic_store(&running,true);worker=threadCreate(run,NULL,32*1024,0x3f,-2,false);return worker!=NULL;} +void media_library_stop(void) {atomic_store(&running,false);atomic_fetch_add(&cancellation,1);if(worker){threadJoin(worker,U64_MAX);threadFree(worker);worker=NULL;}} +bool media_download(const char *host,unsigned port,const char *token,const char *key) { + if(!host || strlen(host)>15 || !port || port>65535 || !token || strlen(token)!=64 || !media_key_valid(key) || atomic_load(&save_pending)) return false; + for(unsigned i=0;i<64;i++) if(!((token[i]>='0' && token[i]<='9') || (token[i]>='a' && token[i]<='f'))) return false; + LibraryCommand cmd={.kind=LIB_SAVE,.port=port,.generation=atomic_load(&cancellation)}; + strcpy(cmd.host,host);strcpy(cmd.token,token);strcpy(cmd.key,key); + atomic_store(&received,0);atomic_store(&total,0);atomic_store(&error_code,0);atomic_store(&phase,DL_CONNECTING);atomic_store(&save_pending,true); + if(enqueue(cmd)) return true; + atomic_store(&save_pending,false);atomic_store(&phase,DL_IDLE);return false; +} +void media_download_cancel(void) {atomic_fetch_add(&cancellation,1);} +bool media_library_refresh(void) {return enqueue((LibraryCommand){.kind=LIB_SCAN});} +bool media_library_remove(const char *key) {if(!media_key_valid(key)) return false;LibraryCommand cmd={.kind=LIB_REMOVE};strcpy(cmd.key,key);return enqueue(cmd);} +const char *media_library_snapshot(void) { + if(!atomic_load_explicit(&library_ready,memory_order_acquire)) return NULL; + memcpy(guest_json,library_json,sizeof guest_json);atomic_store_explicit(&library_ready,false,memory_order_release);return guest_json; +} +void media_library_forget_guest(void) {media_download_cancel();media_library_snapshot();media_library_refresh();} +void media_download_status(char *out,size_t capacity) { + static const char *phases[]={"idle","connecting","downloading","verifying","complete","cancelled","error"}; + static const char *errors[]={"","Download connection lost; retry","Invalid download format","SD write failed; check free space","Download checksum failed; retry","Already saved on SD","Library full; delete a saved item"}; + unsigned p=atomic_load(&phase),e=atomic_load(&error_code); + snprintf(out,capacity,"{\"phase\":\"%s\",\"receivedBytes\":%u,\"totalBytes\":%u,\"error\":\"%s\"}",phases[p<7?p:6],atomic_load(&received),atomic_load(&total),errors[e<7?e:1]); +} diff --git a/hosts/3ds/src/media_library.h b/hosts/3ds/src/media_library.h new file mode 100644 index 00000000..d1cf4075 --- /dev/null +++ b/hosts/3ds/src/media_library.h @@ -0,0 +1,17 @@ +#ifndef POCKET_MEDIA_LIBRARY_H +#define POCKET_MEDIA_LIBRARY_H +#include +#include +#ifndef POCKETJS_MEDIA_ROOT +#define POCKETJS_MEDIA_ROOT "sdmc:/pocketjs/media/default" +#endif +bool media_library_start(void); +void media_library_stop(void); +bool media_download(const char *host,unsigned port,const char *token,const char *key); +void media_download_cancel(void); +void media_download_status(char *out,size_t capacity); +bool media_library_refresh(void); +bool media_library_remove(const char *key); +const char *media_library_snapshot(void); +void media_library_forget_guest(void); +#endif diff --git a/hosts/3ds/src/media_wire.h b/hosts/3ds/src/media_wire.h index 509bbd16..d62d6929 100644 --- a/hosts/3ds/src/media_wire.h +++ b/hosts/3ds/src/media_wire.h @@ -8,6 +8,9 @@ #define MEDIA_PACKET_BYTES (128 * 1024) #define MEDIA_SAMPLE_RATE 22050 #define MEDIA_AUDIO_FRAMES 1024 +#define MEDIA_CAPTION_WIDTH 256 +#define MEDIA_CAPTION_HEIGHT 32 +#define MEDIA_CAPTION_BYTES (8+MEDIA_CAPTION_WIDTH*MEDIA_CAPTION_HEIGHT/4) static inline uint32_t media_u32(const uint8_t *p) { return (uint32_t)p[0] | (uint32_t)p[1]<<8 | (uint32_t)p[2]<<16 | (uint32_t)p[3]<<24; } @@ -26,6 +29,7 @@ static inline bool media_packet_valid(const uint8_t *p) { case 2: return n>=8 && n<=MEDIA_AUDIO_FRAMES+7; case 3: return n==0; case 4: return n>0 && n<=160; + case 5: return n==MEDIA_CAPTION_BYTES; default: return false; } } diff --git a/hosts/3ds/src/qjs.c b/hosts/3ds/src/qjs.c index 929c4e19..dc733aca 100644 --- a/hosts/3ds/src/qjs.c +++ b/hosts/3ds/src/qjs.c @@ -21,6 +21,7 @@ #include "qjs.h" #include "offload.h" #include "media.h" +#include "media_library.h" #include "offload_coverage.h" #include @@ -52,6 +53,7 @@ typedef enum { HostMediaOpen, HostMediaClose, HostMediaPaused, HostMediaVolume, HostMediaTexture, HostMediaStatus, + HostMediaOpenLocal, HostMediaCaption, HostMediaDownload, HostMediaCancelDownload, HostMediaDownloadStatus, HostMediaLibraryRefresh, HostMediaLibrary, HostMediaRemoveDownload, HostOffloadSession, HostOffloadSubmit, HostOffloadTake, HostOffloadCoverage, HostCreateNode, HostDestroyNode, @@ -255,6 +257,28 @@ static JSValue host_operation( if(token) JS_FreeCString(ctx,token); return JS_NewBool(ctx,ok); } + case HostMediaDownload: { + if(argc<4) return JS_FALSE; + const char *host=JS_ToCString(ctx,argv[0]),*token=JS_ToCString(ctx,argv[2]),*key=JS_ToCString(ctx,argv[3]); + bool ok=media_download(host,(unsigned)argument_int(ctx,argc,argv,1),token,key); + if(host) JS_FreeCString(ctx,host); + if(token) JS_FreeCString(ctx,token); + if(key) JS_FreeCString(ctx,key); + return JS_NewBool(ctx,ok); + } + case HostMediaOpenLocal: + case HostMediaRemoveDownload: { + if(argc<1) return JS_FALSE; + const char *key=JS_ToCString(ctx,argv[0]); + bool ok=magic==HostMediaOpenLocal ? media_open_local(key,(unsigned)argument_int(ctx,argc,argv,1)) : media_library_remove(key); + if(key) JS_FreeCString(ctx,key); + return JS_NewBool(ctx,ok); + } + case HostMediaCaption: {char caption[3000];return media_caption_snapshot(caption,sizeof caption)?JS_NewString(ctx,caption):JS_NULL;} + case HostMediaCancelDownload: media_download_cancel();return JS_UNDEFINED; + case HostMediaDownloadStatus: {char status[320];media_download_status(status,sizeof status);return JS_NewString(ctx,status);} + case HostMediaLibraryRefresh: return JS_NewBool(ctx,media_library_refresh()); + case HostMediaLibrary: {const char *value=media_library_snapshot();return value?JS_NewString(ctx,value):JS_NULL;} case HostMediaClose: media_close(); return JS_UNDEFINED; case HostMediaPaused: media_paused(argc>0 && JS_ToBool(ctx,argv[0])); return JS_UNDEFINED; case HostMediaVolume: media_volume((float)argument_float(ctx,argc,argv,0)); return JS_UNDEFINED; @@ -601,6 +625,14 @@ static void install_host(void) { add_operation(media,"volume",1,HostMediaVolume); add_operation(media,"texture",0,HostMediaTexture); add_operation(media,"status",0,HostMediaStatus); + add_operation(media,"openLocal",2,HostMediaOpenLocal); + add_operation(media,"caption",0,HostMediaCaption); + add_operation(media,"download",4,HostMediaDownload); + add_operation(media,"cancelDownload",0,HostMediaCancelDownload); + add_operation(media,"downloadStatus",0,HostMediaDownloadStatus); + add_operation(media,"refreshLibrary",0,HostMediaLibraryRefresh); + add_operation(media,"library",0,HostMediaLibrary); + add_operation(media,"removeDownload",1,HostMediaRemoveDownload); JS_SetPropertyStr(context,global,"media",media); #endif #ifdef POCKETJS_OFFLOAD diff --git a/package.json b/package.json index a11c9a8b..adbe6d4d 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "./audio": "./framework/src/audio-api.ts", "./media": "./framework/src/media.ts", "./media/provider": "./tools/media-stream.ts", + "./media/download-provider": "./tools/media-download.ts", "./media/audio": "./contracts/spec/media-adpcm.ts", "./clock": "./framework/src/clock.ts", "./config": "./framework/src/config.ts", diff --git a/tests/3ds-profile.test.ts b/tests/3ds-profile.test.ts index 0066b0ee..4656eb2f 100644 --- a/tests/3ds-profile.test.ts +++ b/tests/3ds-profile.test.ts @@ -120,9 +120,9 @@ describe("private Nintendo 3DS build profile", () => { // hostAbi is one sequence across every profile, private ones included: // 1 psp, 2 vita, 3 macos-widget, 4 symbian-e7-dev, 5 pocketbook, // 6 iphone2g-dev, 7 top-screen-only 3DS, 8 dual-screen 3DS, - // 9 Blackberry Classic, 10 companion media. A collision + // 9 Blackberry Classic, 10 companion media, 11 local media and captions. A collision // would let a bundle mount on the wrong host. - expect(THREE_DS_DEV_HOST_ABI).toBe(10); + expect(THREE_DS_DEV_HOST_ABI).toBe(11); expect( Object.values(POCKET_TARGETS).map((profile) => profile.hostAbi), ).not.toContain(THREE_DS_DEV_HOST_ABI); diff --git a/tests/fixtures/media-library/3ds.h b/tests/fixtures/media-library/3ds.h new file mode 100644 index 00000000..0348b889 --- /dev/null +++ b/tests/fixtures/media-library/3ds.h @@ -0,0 +1,24 @@ +/* Desktop OS shim for executing the actual SD/socket worker in tests. + * It substitutes only libctru thread and time calls, never the transfer code. */ +#ifndef TEST_CTRU_THREAD_H +#define TEST_CTRU_THREAD_H +#include +#include +#include +#include +#include +#include +#define U64_MAX UINT64_MAX +typedef pthread_t *Thread; +typedef struct { void (*run)(void *); void *arg; } Start; +static void *thread_entry(void *arg) { Start start=*(Start*)arg;free(arg);start.run(start.arg);return NULL; } +static Thread threadCreate(void (*run)(void *),void *arg,size_t stack,int priority,int cpu,bool detached) { + (void)stack;(void)priority;(void)cpu;(void)detached; + Thread thread=malloc(sizeof *thread);Start *start=malloc(sizeof *start);*start=(Start){run,arg}; + if(pthread_create(thread,NULL,thread_entry,start)) abort();return thread; +} +static void threadJoin(Thread thread,uint64_t timeout) { (void)timeout;pthread_join(*thread,NULL); } +static void threadFree(Thread thread) {free(thread);} +static uint64_t osGetTime(void) {struct timespec now;clock_gettime(CLOCK_MONOTONIC,&now);return (uint64_t)now.tv_sec*1000+now.tv_nsec/1000000;} +static void svcSleepThread(int64_t ns) {struct timespec delay={ns/1000000000,ns%1000000000};nanosleep(&delay,NULL);} +#endif diff --git a/tests/fixtures/media-library/harness.c b/tests/fixtures/media-library/harness.c new file mode 100644 index 00000000..6d9f6e91 --- /dev/null +++ b/tests/fixtures/media-library/harness.c @@ -0,0 +1,29 @@ +#include "3ds.h" +#include "media_library.h" +#include +#include +#include +#include +bool soc_ensure(char *error,size_t length) {(void)error;(void)length;return true;} +static const char *wait_library(void) { + for(unsigned i=0;i<5000;i++) {const char *value=media_library_snapshot();if(value)return value;svcSleepThread(1000000);} + assert(!"Library publication timed out");return NULL; +} +int main(int argc,char **argv) { + assert(argc==5);signal(SIGPIPE,SIG_IGN); + assert(media_library_start());puts(wait_library()); + assert(!media_download("127.0.0.1",atoi(argv[1]),argv[2],"../escape")); + assert(media_download("127.0.0.1",atoi(argv[1]),argv[2],argv[3])); + assert(!media_download("127.0.0.1",atoi(argv[1]),argv[2],"duplicate")); + if(!strcmp(argv[4],"cancel"))media_download_cancel(); + char status[320];bool done=false; + for(uint64_t deadline=osGetTime()+20000;osGetTime() { + const result = Bun.spawnSync(["cc", "-std=gnu11", "-O2", "-pthread", "-Wall", "-Wextra", "-I", "tests/fixtures/media-library", "-I", "hosts/3ds/src", + `-DPOCKETJS_MEDIA_ROOT="${sd}"`, "tests/fixtures/media-library/harness.c", "hosts/3ds/src/media_library.c", "-o", harness]); + expect(result.exitCode, result.stderr.toString()).toBe(0); +}); +afterAll(() => rmSync(directory, { recursive: true, force: true })); + +test("native storage worker verifies transfer, exports captions, reopens offline and deletes", async () => { + const vtt = Buffer.from(`WEBVTT\n\n${"00:00:01.000 --> 00:00:02.000\nこんにちは 世界 & captions\n\n".repeat(8000)}`); + const header = downloadHeader({ mediaBytes: 0, indexBytes: 0, captionBytes: vtt.length, durationMs: 120000, crc: mediaCRC(vtt) ^ 0xffffffff, title: '日本語 "captions"', language: "ja" }); + const path = join(directory, "captions.pkd"), bytes = Buffer.concat([header, vtt]); writeFileSync(path, bytes); + const server = await createMediaDownloadServer({ advertiseHost: "127.0.0.1" }); + try { + const source = await server.publish(path), process = Bun.spawn([harness, String(source.port), source.token, "captions", "keep"], { stdout: "pipe", stderr: "pipe" }); + const output = await new Response(process.stdout).text(); + expect(await process.exited, await new Response(process.stderr).text()).toBe(0); + const [before, status, entries, reboot] = output.trim().split("\n").map(line => JSON.parse(line)); + expect(before).toEqual([]); expect(status.phase).toBe("complete"); expect(status.receivedBytes).toBe(bytes.length); + expect(entries).toEqual(reboot); expect(entries[0].title).toBe('日本語 "captions"'); expect(entries[0].captions).toBe(true); + expect(readFileSync(join(sd, "captions.pkd"))).toEqual(bytes); expect(readFileSync(join(sd, "captions.vtt"))).toEqual(vtt); + expect(readdirSync(sd).some(name => name.endsWith(".part"))).toBe(false); + const retry = await server.publish(path), remove = Bun.spawn([harness, String(retry.port), retry.token, "captions", "remove"], { stdout: "pipe", stderr: "pipe" }); + const lines = (await new Response(remove.stdout).text()).trim().split("\n").map(line => JSON.parse(line)); + expect(await remove.exited).toBe(0); expect(lines[1].phase).toBe("error"); expect(lines[1].error).toContain("Already saved"); + expect(lines.at(-1)).toEqual([]); expect(readdirSync(sd)).toEqual([]); + } finally { server.close(); } +}, 30000); + +test("corruption, truncation, cancellation and failed SD writes never publish partial files", async () => { + for (const failure of ["crc", "truncated", "cancel", "storage"]) { + const payload = Buffer.from("WEBVTT\n\n".repeat(5000)); + const header = downloadHeader({ mediaBytes: 0, indexBytes: 0, captionBytes: payload.length, durationMs: 1000, + crc: (mediaCRC(payload) ^ 0xffffffff) + (failure === "crc" ? 1 : 0), title: failure, language: "en" }); + const path = join(directory, `${failure}.pkd`); writeFileSync(path, Buffer.concat([header, failure === "truncated" ? payload.subarray(0, 40) : payload])); + if (failure === "storage") mkdirSync(join(sd, `${failure}.part`)); + const server = await createMediaDownloadServer({ advertiseHost: "127.0.0.1" }); + try { + const source = await server.publish(path), process = Bun.spawn([harness, String(source.port), source.token, failure, failure === "cancel" ? "cancel" : "keep"], { stdout: "pipe", stderr: "pipe" }); + const lines = (await new Response(process.stdout).text()).trim().split("\n").map(line => JSON.parse(line)); + expect(await process.exited, await new Response(process.stderr).text()).toBe(0); + expect(lines[1].phase).toBe(failure === "cancel" ? "cancelled" : "error"); + expect(lines[2]).toEqual([]); expect(lines[3]).toEqual([]); + expect(readdirSync(sd)).toEqual([]); + } finally { server.close(); } + } +}, 30000); + +test("local media APIs reject paths and nonfinite seeks before native handoff", () => { + const calls: unknown[] = [], ops = { open: () => true, close() {}, paused() {}, volume() {}, texture: () => 1, status: () => "{}", openLocal: (...args: unknown[]) => { calls.push(args); return true; } }; + expect(mediaPlayer(ops).open({ file: "movie_01", positionMs: 1234 })).toBe(true); + for (const file of ["../movie", "sdmc:/movie", "", "a".repeat(65)]) expect(() => mediaPlayer(ops).open({ file })).toThrow(); + expect(() => mediaPlayer(ops).open({ file: "movie", positionMs: NaN })).toThrow(); expect(calls).toEqual([["movie_01", 1234]]); + expect(() => mediaLibrary({} as any).remove("../movie")).toThrow(); +}); diff --git a/tools/3ds-profile.ts b/tools/3ds-profile.ts index 0dc249a1..10b954d1 100644 --- a/tools/3ds-profile.ts +++ b/tools/3ds-profile.ts @@ -18,7 +18,7 @@ import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.t * input.touch.auxiliary. */ export const THREE_DS_DEV_TARGET_ID = "3ds-dev"; -export const THREE_DS_DEV_HOST_ABI = 10; +export const THREE_DS_DEV_HOST_ABI = 11; export const THREE_DS_VIEWPORT = [400, 240] as const; export const THREE_DS_AUXILIARY_VIEWPORT = [320, 240] as const; diff --git a/tools/media-download.ts b/tools/media-download.ts new file mode 100644 index 00000000..900f2027 --- /dev/null +++ b/tools/media-download.ts @@ -0,0 +1,84 @@ +/** Immutable transfer file: header, PKMV stream, keyframe index, UTF-8 WebVTT. + * The native storage worker commits the file only after its CRC matches. */ +import { createServer, type Socket } from "node:net"; +import { randomBytes } from "node:crypto"; +import { open, stat } from "node:fs/promises"; +import type { MediaSource } from "../contracts/spec/media.ts"; + +export const MEDIA_DOWNLOAD = { headerBytes: 256, chunkBytes: 32768, maxBytes: 0x7fffffff, maxCaptions: 4 * 1024 * 1024, indexRecordBytes: 12 } as const; +export function mediaCRC(bytes: Uint8Array, crc = 0xffffffff): number { + for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); } + return crc >>> 0; +} +export function downloadHeader(input: { mediaBytes: number; indexBytes: number; captionBytes: number; durationMs: number; crc: number; title: string; language: string }) { + const { mediaBytes, indexBytes, captionBytes, durationMs } = input; + if (![mediaBytes, indexBytes, captionBytes, durationMs].every(v => Number.isInteger(v) && v >= 0) + || (mediaBytes !== 0 && mediaBytes < 48) || indexBytes % 12 || indexBytes > 86401 * 12 + || captionBytes > MEDIA_DOWNLOAD.maxCaptions || durationMs > 86400000 + || mediaBytes + indexBytes + captionBytes + 256 > MEDIA_DOWNLOAD.maxBytes + || (!!mediaBytes !== !!indexBytes)) throw new Error("Invalid download sizes"); + const out = Buffer.alloc(256); out.write("PKDL"); out.writeUInt16LE(1, 4); out.writeUInt16LE(256, 6); + [mediaBytes, indexBytes, captionBytes, durationMs, input.crc >>> 0].forEach((v, i) => out.writeUInt32LE(v, 8 + i * 4)); + const utf8 = (text: string, at: number, max: number) => { + let bytes = Buffer.from(text.replace(/[\x00-\x1f]/g, " ")); + if (bytes.length >= max) { let end = max - 1; while ((bytes[end] & 0xc0) === 0x80) end--; bytes = bytes.subarray(0, end); } + out.set(bytes, at); + }; + utf8(input.title, 32, 160); utf8(input.language, 192, 32); + return out; +} + +/** A ticket is single use. Each acknowledged 32 KiB block permits the next + * disk read; neither the producer nor the native socket queue grows with film length. */ +export async function createMediaDownloadServer(options: { advertiseHost: string; port?: number }) { + type Ticket = { path: string; expires: number; release: () => void }; + const tickets = new Map(), connections = new Map void>(); + const server = createServer(socket => { + socket.setNoDelay(true); socket.setTimeout(15000, () => socket.destroy()); socket.on("error", () => {}); + let token = Buffer.alloc(0), started = false, credit = false, wake: (() => void) | undefined; + const release = () => { wake?.(); }; connections.set(socket, release); + socket.once("close", () => { connections.delete(socket); release(); }); + socket.on("data", (chunk: Buffer) => { + if (started) { + if (chunk.length !== 1 || chunk[0] !== 1 || credit) return socket.destroy(); + credit = true; wake?.(); wake = undefined; return; + } + token = Buffer.concat([token, chunk]); + if (token.length > 64) return socket.destroy(); + if (token.length !== 64) return; + started = true; + const ticket = tickets.get(token.toString()); tickets.delete(token.toString()); + if (!ticket || ticket.expires < Date.now()) { ticket?.release(); return socket.destroy(); } + void (async () => { + let file: Awaited> | undefined; + try { + file = await open(ticket.path, "r"); + const size = (await file.stat()).size; + for (let offset = 0; offset < size;) { + const count = offset === 0 ? 256 : Math.min(MEDIA_DOWNLOAD.chunkBytes, size - offset); + const bytes = Buffer.alloc(count); + if ((await file.read(bytes, 0, count, offset)).bytesRead !== count) throw new Error("Truncated download"); + if (socket.destroyed) break; + socket.write(bytes); offset += count; + while (!credit && !socket.destroyed) await new Promise(resolve => { wake = resolve; }); + credit = false; + } + socket.end(); + } catch { socket.destroy(); } + finally { await file?.close(); ticket.release(); } + })(); + }); + }); + const expiry = setInterval(() => { for (const [token, ticket] of tickets) if (ticket.expires < Date.now()) { tickets.delete(token); ticket.release(); } }, 10000); + expiry.unref(); + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(options.port ?? 0, "0.0.0.0", () => { server.off("error", reject); resolve(); }); }); + const address = server.address(); if (!address || typeof address === "string") throw new Error("Missing download listener"); + return { + async publish(path: string, release: () => void = () => {}): Promise { + if (tickets.size + connections.size >= 4 || (await stat(path)).size > MEDIA_DOWNLOAD.maxBytes) throw new Error("Download capacity exceeded"); + const token = randomBytes(32).toString("hex"); tickets.set(token, { path, expires: Date.now() + 120000, release }); + return { host: options.advertiseHost, port: address.port, token }; + }, + close() { clearInterval(expiry); for (const ticket of tickets.values()) ticket.release(); tickets.clear(); for (const socket of connections.keys()) socket.destroy(); server.close(); }, + }; +} diff --git a/tools/media-stream.ts b/tools/media-stream.ts index 3d4b5dda..ed58b766 100644 --- a/tools/media-stream.ts +++ b/tools/media-stream.ts @@ -18,7 +18,8 @@ export function mediaPacket(packet: MediaPacket): Buffer { const n=packet.data.byteLength; const valid=packet.kind===1 ? n>=4 && n<=MEDIA.packetBytes : packet.kind===2 ? n>=8 && n<=MEDIA.audioFrames+7 - : packet.kind===3 ? n===0 : packet.kind===4 && n>0 && n<=160; + : packet.kind===3 ? n===0 : packet.kind===4 ? n>0 && n<=160 + : packet.kind===5 && n===8+MEDIA.captionWidth*MEDIA.captionHeight/4; if (!valid || !Number.isInteger(packet.ptsMs) || packet.ptsMs<0) throw new Error("Invalid media packet"); const out=Buffer.alloc(MEDIA.packetHeaderBytes+n); out[0]=packet.kind; out.writeUInt32LE(n,4); out.writeUInt32LE(packet.ptsMs,8);