apps/server/src/device/mjpegStillFrame.ts
/**
* Browsers paint a multipart/x-mixed-replace part only once the next part begins, and the hub
* writes a part only when the screen changes. Left alone, an `<img>` viewer never shows a still
* screen (clients such as the iOS app then give up waiting for the first frame) and always hides
* the last frame of a change. Re-sending the last part after a short quiet period makes it paint.
*/
import * as Arr from "effect/Array";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
const STILL_FRAME_DELAY = Duration.millis(100);
const HEADER_END = Buffer.from("\r\n\r\n");
const MAX_HEADER_BYTES = 16 * 1024;
// Simulator frames are far smaller; a larger claim is unknown framing, not something to retain.
const MAX_PART_BYTES = 16 * 1024 * 1024;
type Part = Arr.NonEmptyReadonlyArray<Uint8Array>;
const partLength = (headers: string) => {
if (!headers.startsWith("--")) return undefined;
const length = Number(/^content-length:\s*(\d+)\s*$/im.exec(headers)?.[1]);
return length <= MAX_PART_BYTES ? length : undefined;
};
const endsWithCrlf = (part: Part) => {
const last = Arr.lastNonEmpty(part);
const cr = last.length > 1 ? last.at(-2) : part.at(-2)?.at(-1);
return cr === 0x0d && last.at(-1) === 0x0a;
};
/**
* Follows the hub's part framing (`--frame`, `Content-Length`, CRLF CRLF, body, CRLF; each part
* is one write) and keeps the last complete part as views into the forwarded chunks, so frame
* bytes are never copied. Any other framing turns tracking off and the rest passes through.
*/
const partTracker = () => {
let pieces: Array<Uint8Array> = [];
let size = 0;
let partEnd: number | null = null;
let tracking = true;
return {
/** The last complete part, while the stream sits at a part boundary. */
still: null as Part | null,
feed(chunk: Uint8Array) {
if (!tracking) return;
let rest = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
let part: Part | undefined;
while (rest.length > 0) {
if (partEnd === null) {
// A header split across chunks is re-scanned per chunk. Only a header-sized
// prefix of the new chunk joins the scan, so frame bytes are never copied.
const head =
size === 0
? rest
: Buffer.concat([...pieces, rest.subarray(0, MAX_HEADER_BYTES + HEADER_END.length)]);
const headerEnd = head.indexOf(HEADER_END);
if (headerEnd < 0 && head.length <= MAX_HEADER_BYTES) {
pieces.push(rest);
size += rest.length;
break;
}
const length =
headerEnd < 0 || headerEnd > MAX_HEADER_BYTES
? undefined
: partLength(head.subarray(0, headerEnd).toString("latin1"));
if (length === undefined) {
tracking = false;
break;
}
partEnd = headerEnd + HEADER_END.length + length + 2;
}
const taken = rest.subarray(0, partEnd - size);
pieces.push(taken);
size += taken.length;
rest = rest.subarray(taken.length);
if (size < partEnd) break;
const complete = pieces;
if (!Arr.isArrayNonEmpty(complete) || !endsWithCrlf(complete)) {
tracking = false;
break;
}
part = complete;
pieces = [];
size = 0;
partEnd = null;
}
if (!tracking) pieces = [];
this.still = tracking && size === 0 ? (part ?? this.still) : null;
},
};
};
/** Re-sends the latest frame once when the stream goes quiet between frames. */
export const repeatStillFrame = <E, R>(body: Stream.Stream<Uint8Array, E, R>) =>
Stream.transformPull(body, (pull, scope) =>
Effect.sync(() => {
const tracker = partTracker();
let pending: Fiber.Fiber<Effect.Success<typeof pull>, Effect.Error<typeof pull>> | null =
null;
const forward = (chunks: Effect.Success<typeof pull>) => {
pending = null;
for (const chunk of chunks) tracker.feed(chunk);
return chunks;
};
return Effect.gen(function* () {
const still = tracker.still;
if (still === null && pending === null) return forward(yield* pull);
pending ??= yield* Effect.forkIn(pull, scope);
if (still === null) return forward(yield* Fiber.join(pending));
// Timing out the join leaves the pull running, so a frame arriving mid-wait is kept.
const next = yield* Fiber.join(pending).pipe(Effect.timeoutOption(STILL_FRAME_DELAY));
if (Option.isSome(next)) return forward(next.value);
tracker.still = null;
return still;
});
}),
);