Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ The private Yarn workspace root contains the public library and its consumers. U

**Node rendering** (`src/env/node/NodeRenderer.ts`): The Node entry exports `NodeRenderer.create({ width, height, ...options })` for an owned native WebGL 2 context through optional `gl@9.0.0-rc.10` (experimental WebGL 2). The factory disables antialiasing and rejects an explicit `true`; `render.pixelRatio` increases image resolution. `new Renderer(options, surface)` and `new NodeRenderer(options, surface)` accept a caller-owned `RendererSurface` outside the deep-merged options: canvas, context, positive integer logical dimensions, and an optional native resize callback receiving physical dimensions. Alpha-enabled contexts injected into `NodeRenderer` require `premultipliedAlpha: true`. Node renderers disable stats, controls, and automatic window resizing, and reject `start()`/`toImage()`; use `renderOnce()` or `renderToBuffer()`. See [platform setup](./packages/minerender/reference/platforms.md).

**Scene documents** (`src/scene/`): Versioned `SceneDocument` JSON is validated before any asset requests. `SceneDocumentLoader` loads each object without instancing inside its own nested `DocumentObjectScene` under a transform `Group`. A document attaches to its parent only after every object loads successfully. Item definitions and GUI item layers accept an optional `context` (`SceneItemContext`: components, properties, count, item references by item ID, and `displayContext` on definitions) that the loader passes to `Models.getMerged`. Async `encodeSceneDocument` / `decodeSceneDocument` validate and convert portable links using `v1.` plus zlib-wrapped deflate in unpadded base64url; compressed data and UTF-8 JSON are each limited to 1 MiB.
**Scene documents** (`src/scene/`): Versioned `SceneDocument` JSON is validated before any asset requests. `SceneDocumentLoader` loads each object without instancing inside its own nested `DocumentObjectScene` under a transform `Group`. A document attaches to its parent only after every object loads successfully. Entity animations accept either one `name` or a nonempty `clips` list of names and optional nonnegative weights, with shared loop, speed, time, and pause settings. Item definitions and GUI item layers accept an optional `context` (`SceneItemContext`: components, properties, count, item references by item ID, and `displayContext` on definitions) that the loader passes to `Models.getMerged`. Async `encodeSceneDocument` / `decodeSceneDocument` validate and convert portable links using `v1.` plus zlib-wrapped deflate in unpadded base64url; compressed data and UTF-8 JSON are each limited to 1 MiB.

**Embeds** (`apps/embed/`): One `/embed/` page accepts exactly one shorthand (`skin`, `block`, `item`, `entity`, `model`), encoded `#scene`, or HTTPS `src`. URL camera/version values override document values; missing cameras fit the scene. Documents use the shared scene schema and a 1 MiB payload limit. Drawing buffers are limited to 4,096 pixels per side and 4,194,304 pixels in total independently of iframe size. Fragment changes replace the scene and discard stale loads. Off-screen embeds release their renderer and WebGL context, then recreate them with the saved camera and entity clip playback time when visible; hidden tabs pause rendering. Context loss shows a recovery message and **Retry** button; native context restoration redraws the scene. The editor's **Copy embed** encodes the document through the shared codec, preserving camera, background, and aspect ratio; `EMBED_URL` overrides its build-time destination. Host-page messaging and legacy query aliases are unsupported. V1 embed routes remain unchanged.

Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { parseSceneDocument } from "minerender/node";
import type { SceneDocument } from "minerender/node";
import { ApiError } from "./problem.js";

const MAX_ANIMATION_CLIPS = 32;
const MAX_ANIMATION_WEIGHT = 1024;

export interface RenderRequest {
scene: SceneDocument & { minecraftVersion: string };
output: {
Expand Down Expand Up @@ -83,6 +86,10 @@ export function parseRenderRequest(value: unknown): RenderRequest {
}
}
if (definition.type === "entity" && definition.animation) {
if ((definition.animation.clips?.length ?? 0) > MAX_ANIMATION_CLIPS) {
throw new ApiError(422, `${path}.animation.clips: at most ${MAX_ANIMATION_CLIPS} clips are supported`);
}
bounded(definition.animation.clips?.map(clip => clip.weight ?? 1), MAX_ANIMATION_WEIGHT, `${path}.animation.clips.weight`);
bounded([definition.animation.time ?? 0, definition.animation.speed ?? 1], 1_000_000, `${path}.animation`);
}
if (definition.type !== "gui") continue;
Expand Down
2 changes: 2 additions & 0 deletions apps/api/test/server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ test("request errors reject before rendering and methods advertise their allowed
{ scene: { ...scene().scene, objects: Array.from({ length: 33 }, (_, i) => ({ id: String(i), type: "skin" })) } },
{ ...scene(), output: { width: 0 } },
{ ...scene(), output: { height: 2049 } },
{ scene: { ...scene().scene, objects: [{ id: "entity", type: "entity", asset: "pig", animation: { clips: Array.from({ length: 33 }, (_, index) => ({ name: `clip${index}` })) } }] } },
{ scene: { ...scene().scene, objects: [{ id: "entity", type: "entity", asset: "pig", animation: { clips: [{ name: "walk", weight: 1025 }] } }] } },
{ ...scene(), output: { width: 1.5 } },
{ ...scene(), output: { format: "jpeg" } },
{ ...scene(), output: { background: 0x1000000 } },
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ use another host or a local embed server; the default is
`https://beta.minerender.org/embed/`. See the [embed workspace README](../../../embed/README.md)
for local development and deployment commands.

In an entity's **Animation** controls, use **Clip to add** and **Add clip** to combine
clips, then adjust each **Weight** without restarting playback. Weights add without
normalization. Until you change **Loop**, each clip uses its own loop setting.

Use **GUI layers** to add, edit, reorder, or remove texture, item, and text layers,
or to insert the layers of a chest background, boss bar, book page, or tooltip. The
controls expose positions, sizes, and each layer's content, including an item layer's
Expand Down
136 changes: 110 additions & 26 deletions apps/web/src/editor/inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface InspectorContext {
onError: (error: unknown) => void;
getAnimationTime?: () => number;
onSkinPoseChange?: (pose: SceneSkinDefinition["pose"]) => void;
onAnimationChange?: (animation: SceneEntityDefinition["animation"]) => void | Promise<void>;
onAnimationChange?: (animation: SceneEntityDefinition["animation"], seek?: boolean) => void | Promise<void>;
}

let nextListId = 0;
Expand Down Expand Up @@ -325,47 +325,128 @@ export function renderInspector(container: HTMLElement, definition: SceneObjectD
animationStatus.textContent = entries.length ? "" : "This entity has no available animation clips.";
if (!entries.length) return;
const drawn = entity.layers ?? ["main", ...passes.filter(pass => pass.when === undefined || entity.when?.includes(pass.when)).map(pass => pass.layer)];
const emitAnimation = () => {
if (context.onAnimationChange) submit(() => context.onAnimationChange!(structuredClone(entity.animation)));
const emitAnimation = (seek = false) => {
if (context.onAnimationChange) submit(() => context.onAnimationChange!(structuredClone(entity.animation), seek));
else emit();
};
const picker = select(animation, "Clip", [["", "None"], ...entries.map(([name, clip]) => [name, `${name} (${clip.layer ?? "main"})`] as [string, string])], entity.animation?.name ?? "", name => {
entity.animation = name ? { name, loop: clips![name].loop, speed: 1, time: 0 } : undefined;
const selectedClips = (): { name: string; weight?: number }[] => entity.animation?.clips
?? (entity.animation?.name ? [{ name: entity.animation.name }] : []);
const keepTime = () => {
if (entity.animation) entity.animation.time = context.getAnimationTime?.() ?? entity.animation.time ?? 0;
};
const setClips = (selected: { name: string; weight?: number }[]) => {
keepTime();
const { loop, speed, time, paused } = entity.animation ?? { speed: 1, time: 0 };
entity.animation = selected.length ? { clips: selected, loop, speed, time, paused } : undefined;
emitAnimation();
};
const choices = entries.map(([name, clip]) => [name, `${name} (${clip.layer ?? "main"})`] as [string, string]);
const enableLayers = (picker: HTMLSelectElement) => {
for (const entry of Array.from(picker.options)) {
const clip = clips?.[entry.value];
if (clip && !drawn.includes(clip.layer ?? "main")) {
entry.disabled = true;
entry.title = `Enable the ${clip.layer ?? "main"} layer to play this clip.`;
}
}
};
const selected = document.createElement("div");
animation.append(selected);
const add = document.createElement("div");
add.className = "editor-row";
animation.append(add);
const picker = select(add, "Clip to add", choices, "", () => {});
enableLayers(picker);
const addClip = button(add, "Add clip", () => {
setClips([...selectedClips(), { name: picker.value, weight: 1 }]);
renderClips();
renderPlayback(false);
selected.querySelectorAll("select").item(selectedClips().length - 1)?.focus();
});
for (const entry of Array.from(picker.options)) {
const clip = clips?.[entry.value];
if (clip && !drawn.includes(clip.layer ?? "main")) {
entry.disabled = true;
entry.title = `Enable the ${clip.layer ?? "main"} layer to play this clip.`;
}
}
if (!entries.some(([, clip]) => drawn.includes(clip.layer ?? "main"))) note(animation, "Enable a clip's entity layer to add an animation.");
const playback = document.createElement("div");
animation.append(playback);
function renderClips() {
selected.replaceChildren();
for (const option of Array.from(picker.options)) {
option.disabled = !drawn.includes(clips![option.value].layer ?? "main")
|| selectedClips().some(clip => clip.name === option.value);
}
const available = Array.from(picker.options).find(option => !option.disabled);
picker.value = available?.value ?? "";
picker.disabled = addClip.disabled = !available;
if (!selectedClips().length) note(selected, "Add a clip to start playback.");
selectedClips().forEach((selection, index) => {
const group = document.createElement("fieldset");
group.className = "editor-section";
selected.append(group);
const clip = select(group, `Clip ${index + 1}`, choices, selection.name, name => {
setClips(selectedClips().map((value, i) => i === index ? { ...value, name } : value));
renderClips();
renderPlayback(false);
});
enableLayers(clip);
for (const option of Array.from(clip.options)) {
if (selectedClips().some((value, i) => i !== index && value.name === option.value)) option.disabled = true;
}
const row = document.createElement("div");
row.className = "editor-row";
group.append(row);
const range = document.createElement("input");
range.type = "range";
range.min = "0";
range.max = String(Math.max(1, Math.ceil(selection.weight ?? 1)));
range.step = "0.01";
range.value = String(selection.weight ?? 1);
range.setAttribute("aria-label", `Clip ${index + 1} weight slider`);
field(row, "Weight", range);
const changeWeight = (weight: number) => {
keepTime();
if (entity.animation?.clips) entity.animation.clips[index].weight = weight;
else if (entity.animation) {
const { name, ...settings } = entity.animation;
entity.animation = { ...settings, clips: [{ name: name!, weight }] };
}
range.max = String(Math.max(1, Math.ceil(weight)));
range.value = value.value = String(weight);
emitAnimation();
};
const value = number(row, "Weight value", selection.weight ?? 1, 0, undefined, "any", changeWeight);
value.setAttribute("aria-label", `Clip ${index + 1} weight value`);
range.addEventListener("input", () => changeWeight(range.valueAsNumber));
button(group, `Remove clip ${index + 1}`, () => {
setClips(selectedClips().filter((_, i) => i !== index));
renderClips();
renderPlayback(false);
picker.focus();
});
});
}
function renderPlayback(liveTime = true) {
playback.replaceChildren();
if (!entity.animation) return;
const clip = clips?.[entity.animation.name];
const keepTime = () => { entity.animation!.time = context.getAnimationTime?.() ?? entity.animation!.time ?? 0; };
const displayTime = (time: number) => clip?.length ? (entity.animation!.loop ?? clip.loop) ? time % clip.length : Math.min(time, clip.length) : time;
check(playback, "Loop", entity.animation.loop ?? clip?.loop ?? false, loop => { keepTime(); entity.animation!.loop = loop; emitAnimation(); });
const activeClips = selectedClips().map(selection => clips?.[selection.name]).filter(clip => clip !== undefined);
const duration = Math.max(0, ...activeClips.map(clip => clip.length));
const loop = check(playback, "Loop", entity.animation.loop ?? activeClips.every(clip => clip.loop), loop => { keepTime(); entity.animation!.loop = loop; emitAnimation(); });
loop.indeterminate = entity.animation.loop === undefined && activeClips.some(clip => clip.loop) && !activeClips.every(clip => clip.loop);
number(playback, "Speed", entity.animation.speed ?? 1, 0, undefined, 0.1, speed => { keepTime(); entity.animation!.speed = speed; emitAnimation(); });
const displayTime = (time: number) => activeClips.length === 1 && (entity.animation?.loop ?? activeClips[0].loop) && duration > 0
? time % duration : time;
const time = displayTime((liveTime ? context.getAnimationTime?.() : undefined) ?? entity.animation.time ?? 0);
const seek = (time: number) => {
entity.animation!.time = time;
entity.animation!.paused = true;
syncPlayback(time);
emitAnimation();
emitAnimation(true);
};
const timeInput = number(playback, "Time (seconds)", time, 0, clip?.length, "any", seek);
const timeInput = number(playback, "Time (seconds)", time, 0, undefined, "any", seek);
const scrub = document.createElement("input");
scrub.type = "range";
scrub.min = "0";
scrub.max = String(clip?.length ?? 0);
scrub.max = String(duration);
scrub.step = "0.01";
scrub.value = String(time);
scrub.disabled = !clip?.length;
scrub.value = String(Math.min(duration, time));
scrub.disabled = !duration;
scrub.addEventListener("input", () => seek(scrub.valueAsNumber));
field(playback, "Scrub animation", scrub);
const row = document.createElement("div");
Expand All @@ -374,18 +455,20 @@ export function renderInspector(container: HTMLElement, definition: SceneObjectD
const play = button(row, entity.animation.paused ? "Play" : "Pause", () => {
keepTime();
entity.animation!.paused = !entity.animation!.paused;
syncPlayback(displayTime(entity.animation!.time!));
syncPlayback(entity.animation!.time!);
emitAnimation();
});
function syncPlayback(time: number) {
time = displayTime(time);
timeInput.value = String(Number(time.toFixed(3)));
scrub.value = String(time);
scrub.value = String(Math.min(duration, time));
play.textContent = entity.animation!.paused ? "Play" : "Pause";
}
button(row, "Restart", () => { entity.animation!.time = 0; entity.animation!.paused = false; syncPlayback(0); emitAnimation(); });
button(row, "Stop", () => { delete entity.animation; picker.value = ""; emitAnimation(); renderPlayback(); });
if (clip) note(playback, `Clip duration: ${clip.length.toFixed(2)} seconds. Set time to pause at a frame.`);
button(row, "Restart", () => { entity.animation!.time = 0; entity.animation!.paused = false; syncPlayback(0); emitAnimation(true); });
button(row, "Stop", () => { delete entity.animation; emitAnimation(); renderClips(); renderPlayback(); });
note(playback, `${activeClips.length === 1 ? "Clip duration" : "Longest clip"}: ${duration.toFixed(2)} seconds. Set time to pause at a frame.`);
}
renderClips();
renderPlayback();
}).catch(error => {
if (active) { animationStatus.textContent = "Could not load animation clips."; fail(error, animation); }
Expand Down Expand Up @@ -614,6 +697,7 @@ function check(parent: HTMLElement, title: string, value: boolean, change: (valu
input.addEventListener("change", () => change(input.checked));
label.append(input, document.createTextNode(title));
parent.append(label);
return input;
}

function number(parent: HTMLElement, title: string, value: number, min: number | undefined, max: number | undefined, step: number | "any", change: (value: number) => void) {
Expand Down
Loading
Loading