@docyrus/animaguru 0.20.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +25 -1
- package/README.md +32 -3
- package/dist/animaguru.js +430 -253
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +1 -1
- package/skills/animaguru-archive-light-table/SKILL.md +34 -0
- package/skills/animaguru-archive-light-table/references/recipe.md +26 -0
- package/skills/animaguru-briefing-board/SKILL.md +34 -0
- package/skills/animaguru-briefing-board/references/recipe.md +27 -0
- package/skills/animaguru-data-physicalization/SKILL.md +34 -0
- package/skills/animaguru-data-physicalization/references/recipe.md +27 -0
- package/skills/animaguru-exploded-teardown/SKILL.md +34 -0
- package/skills/animaguru-exploded-teardown/references/recipe.md +27 -0
- package/skills/animaguru-lidar-survey/SKILL.md +34 -0
- package/skills/animaguru-lidar-survey/references/recipe.md +26 -0
- package/skills/animaguru-machine-anatomy/LICENSE +21 -0
- package/skills/animaguru-machine-anatomy/SKILL.md +42 -0
- package/skills/animaguru-machine-anatomy/references/craft.md +97 -0
- package/skills/animaguru-machine-anatomy/references/recipe.md +52 -0
- package/skills/animaguru-microscope-story/SKILL.md +34 -0
- package/skills/animaguru-microscope-story/references/recipe.md +26 -0
- package/skills/animaguru-satellite-briefing/SKILL.md +34 -0
- package/skills/animaguru-satellite-briefing/references/recipe.md +27 -0
- package/skills/animaguru-white-model-projection/SKILL.md +34 -0
- package/skills/animaguru-white-model-projection/references/recipe.md +27 -0
- package/templates/video/README.md +1 -1
- package/templates/video/VIDEO-STYLE-NOTICES.txt +25 -1
- package/templates/video/shared/locales/en/style-studies.json +73 -0
- package/templates/video/src/kit/anatomy/LICENSE +21 -0
- package/templates/video/src/kit/anatomy/iso-kit.ts +842 -0
- package/templates/video/src/kit/anatomy/palette.ts +41 -0
- package/templates/video/src/kit/anatomy/pixi.ts +43 -0
- package/templates/video/src/player.ts +20 -0
- package/templates/video/src/studies/anatomy-plotter.ts +179 -0
- package/templates/video/src/studies/scenes.ts +171 -0
- package/templates/video/src/style.css +19 -3
- package/templates/video/style-source-provenance.json +7 -2
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { LineTone } from "./iso-kit";
|
|
2
|
+
|
|
3
|
+
/** The vendored kit's ISO_CSS tokens as plain colours, for painting its paths outside a styled SVG (PixiJS, study nodes, raster previews). */
|
|
4
|
+
export type AnatomyTheme = "light" | "dark";
|
|
5
|
+
export type AnatomyPalette = {
|
|
6
|
+
page: string;
|
|
7
|
+
card: string;
|
|
8
|
+
text: string;
|
|
9
|
+
strong: string;
|
|
10
|
+
muted: string;
|
|
11
|
+
paper: string;
|
|
12
|
+
face: string;
|
|
13
|
+
top: string;
|
|
14
|
+
shades: readonly [string, string, string, string];
|
|
15
|
+
litTop: string;
|
|
16
|
+
litShade: string;
|
|
17
|
+
halo: string;
|
|
18
|
+
haloOpacity: number;
|
|
19
|
+
dot: string;
|
|
20
|
+
line: Record<LineTone, string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const ANATOMY_PALETTES: Record<AnatomyTheme, AnatomyPalette> = {
|
|
24
|
+
light: {
|
|
25
|
+
page: "#f2f2ef", card: "#fbfbfa", text: "#57575a", strong: "#18181a", muted: "#8c8c90",
|
|
26
|
+
paper: "#fbfbfa", face: "#ffffff", top: "#ffffff",
|
|
27
|
+
shades: ["#dcdcd9", "#e5e5e2", "#ededea", "#f4f4f1"],
|
|
28
|
+
litTop: "#ffffff", litShade: "#eeeeeb", halo: "#8a8a80", haloOpacity: 0.32, dot: "#3a3a3e",
|
|
29
|
+
line: { hi: "#26262a", mid: "#8a8a8f", lo: "#b4b4b8", faint: "#d4d4d6", lit: "#000000", red: "#e5484d", green: "#30a46c", blue: "#3e63dd" },
|
|
30
|
+
},
|
|
31
|
+
dark: {
|
|
32
|
+
page: "#0b0b0b", card: "#131313", text: "#a3a3a3", strong: "#e6e6e6", muted: "#6e6e6e",
|
|
33
|
+
paper: "#131313", face: "#1b1b1b", top: "#1c1c1c",
|
|
34
|
+
shades: ["#0f0f0f", "#121212", "#151515", "#181818"],
|
|
35
|
+
litTop: "#262626", litShade: "#1d1d1d", halo: "#050505", haloOpacity: 0.9, dot: "#e6e6e6",
|
|
36
|
+
line: { hi: "#d4d4d4", mid: "#6a6a6a", lo: "#3e3e3e", faint: "#2a2a2a", lit: "#ffffff", red: "#ff6b6b", green: "#6be0a0", blue: "#6b9bff" },
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Dot opacity per tone, as ISO_CSS draws screws and rivets. */
|
|
41
|
+
export const DOT_OPACITY: Record<LineTone, number> = { hi: 0.85, mid: 0.45, lo: 0.22, faint: 0.12, lit: 0.85, red: 0.85, green: 0.85, blue: 0.85 };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Graphics } from "pixi.js";
|
|
2
|
+
import type { Flat, LineTone, SolidPaths, Tone } from "./iso-kit";
|
|
3
|
+
import { ANATOMY_PALETTES, DOT_OPACITY, type AnatomyTheme } from "./palette";
|
|
4
|
+
|
|
5
|
+
/** Draws the vendored kit's paths with PixiJS Graphics, in the order and tones its solidSvg, lineSvg and dotsSvg use. */
|
|
6
|
+
export type SolidStyle = { theme?: AnatomyTheme; tone?: Tone; crease?: Tone | "none"; lit?: boolean; flat?: boolean; width?: number };
|
|
7
|
+
|
|
8
|
+
/** Trace a kit path (M, L and Z commands, as pathOf writes them) onto the active Graphics path. */
|
|
9
|
+
export function tracePath(g: Graphics, d: string) {
|
|
10
|
+
const tokens = d.match(/[MLZ]|-?\d+(?:\.\d+)?/g) ?? [];
|
|
11
|
+
for (let i = 0; i < tokens.length;) {
|
|
12
|
+
const command = tokens[i++];
|
|
13
|
+
if (command === "Z") g.closePath();
|
|
14
|
+
else if (command === "M") g.moveTo(Number(tokens[i++]), Number(tokens[i++]));
|
|
15
|
+
else if (command === "L") g.lineTo(Number(tokens[i++]), Number(tokens[i++]));
|
|
16
|
+
else throw new Error(`Unsupported kit path command: ${command}`);
|
|
17
|
+
}
|
|
18
|
+
return g;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Paper fill, four side shades, top, crease and bevel, then the outline. A lit solid gets the white face and the lit edge. */
|
|
22
|
+
export function drawSolid(g: Graphics, solid: SolidPaths, { theme = "light", tone = "hi", crease = "faint", lit = false, flat = false, width = 1 }: SolidStyle = {}) {
|
|
23
|
+
const c = ANATOMY_PALETTES[theme];
|
|
24
|
+
tracePath(g, solid.fill).fill(lit ? c.face : c.paper);
|
|
25
|
+
if (!flat) {
|
|
26
|
+
solid.shades.forEach((d, shade) => { if (d) tracePath(g, d).fill(lit ? c.litShade : c.shades[shade]); });
|
|
27
|
+
tracePath(g, solid.top).fill(lit ? c.litTop : c.top);
|
|
28
|
+
}
|
|
29
|
+
if (crease !== "none") for (const d of [solid.crease, solid.bevel ?? ""]) if (d) drawLine(g, d, { theme, tone: crease, width });
|
|
30
|
+
drawLine(g, solid.outline, { theme, tone: lit ? "lit" : tone, width });
|
|
31
|
+
return g;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function drawLine(g: Graphics, d: string, { theme = "light", tone = "lo", width = 1, alpha = 1 }: { theme?: AnatomyTheme; tone?: LineTone; width?: number; alpha?: number } = {}) {
|
|
35
|
+
if (d) tracePath(g, d).stroke({ color: ANATOMY_PALETTES[theme].line[tone], width, alpha, cap: "round", join: "round" });
|
|
36
|
+
return g;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Screws and rivets: small round dots in the dot colour, faded by tone. */
|
|
40
|
+
export function drawDots(g: Graphics, points: readonly Flat[], { theme = "light", tone = "mid", size = 0.5 }: { theme?: AnatomyTheme; tone?: LineTone; size?: number } = {}) {
|
|
41
|
+
for (const [x, y] of points) g.circle(x, y, size).fill({ color: ANATOMY_PALETTES[theme].dot, alpha: DOT_OPACITY[tone] });
|
|
42
|
+
return g;
|
|
43
|
+
}
|
|
@@ -73,6 +73,26 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
73
73
|
type WebkitDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => void };
|
|
74
74
|
type WebkitElement = HTMLElement & { webkitRequestFullscreen?: () => void };
|
|
75
75
|
const fullscreenElement = () => document.fullscreenElement ?? (document as WebkitDocument).webkitFullscreenElement ?? null;
|
|
76
|
+
// The page's player fits the window (style.css). This measures the page above it and the controls under its picture,
|
|
77
|
+
// which change as the window resizes and the transport wraps. Full screen keeps the last measures for its way back; an
|
|
78
|
+
// embed or a capture fills its frame instead.
|
|
79
|
+
if (!embed && !capture) {
|
|
80
|
+
const main = theater.parentElement!, picture = element("canvas-host");
|
|
81
|
+
let frame = 0;
|
|
82
|
+
const measure = () => {
|
|
83
|
+
frame = 0;
|
|
84
|
+
if (fullscreenElement() || theater.classList.contains("expanded")) return;
|
|
85
|
+
const box = theater.getBoundingClientRect();
|
|
86
|
+
main.style.setProperty("--player-above", `${Math.round(box.top + scrollY)}px`);
|
|
87
|
+
main.style.setProperty("--player-controls", `${Math.ceil(box.height - picture.getBoundingClientRect().height)}px`);
|
|
88
|
+
};
|
|
89
|
+
main.style.setProperty("--film-aspect", `${config.width} / ${config.height}`);
|
|
90
|
+
main.classList.add("fit-window");
|
|
91
|
+
measure();
|
|
92
|
+
// A measure waits for the next frame, so the resize it causes is not reported as a loop.
|
|
93
|
+
const observer = new ResizeObserver(() => { frame ||= requestAnimationFrame(measure); });
|
|
94
|
+
for (const target of [theater, document.querySelector(".masthead")!]) observer.observe(target);
|
|
95
|
+
}
|
|
76
96
|
|
|
77
97
|
async function start() {
|
|
78
98
|
await document.fonts.ready;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import * as K from "../kit/anatomy/iso-kit";
|
|
2
|
+
import { ANATOMY_PALETTES, DOT_OPACITY, type AnatomyTheme } from "../kit/anatomy/palette";
|
|
3
|
+
import { clamp, lerp, spring } from "./timing";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Machine Anatomy study figure: one dispensing head on a gantry visits a 4 × 4 tray of wells in a serpentine pass.
|
|
7
|
+
* Original geometry built with the vendored anatomy kit. The readout, the reservoir level and every moving part read
|
|
8
|
+
* the same schedule, so the numbers on screen are the machine's own. Shared by the starter study and the gallery preview.
|
|
9
|
+
*/
|
|
10
|
+
export type PlotterMark =
|
|
11
|
+
| { kind: "path"; d: string; fill?: string; stroke?: string; width?: number; opacity?: number; dy: number; halo?: boolean }
|
|
12
|
+
| { kind: "dot"; x: number; y: number; r: number; fill: string; opacity: number; dy: number };
|
|
13
|
+
export type PlotterOptions = { width: number; height: number; theme?: AnatomyTheme; line?: number; start?: number; step?: number; intro?: boolean };
|
|
14
|
+
export type PlotterFrame = { marks: PlotterMark[]; well: number; filled: number; dispensed: number; phase: "moving" | "dispensing" | "settled" };
|
|
15
|
+
|
|
16
|
+
export const WELL_COUNT = 16;
|
|
17
|
+
export const WELL_ML = 2;
|
|
18
|
+
const PREFILLED = 5;
|
|
19
|
+
const PLATE = { x: -150, y: -110, w: 300, d: 220, r: 10 };
|
|
20
|
+
const TRAY = { x: -84, y: -84, w: 168, d: 168, r: 8 };
|
|
21
|
+
const LABEL = { x: -114, y: 91, w: 58, d: 12, r: 1.5 };
|
|
22
|
+
const RULER = { x: 137, y: -92, w: 7, d: 184, r: 1 };
|
|
23
|
+
const PITCH = [-57, -19, 19, 57];
|
|
24
|
+
const RAIL_X = 128, TRAY_TOP = 10, BEAM_Z = 87, BEAM_TOP = 97, RAISED = 58, PLUNGED = 15, NOZZLE_Y = 14;
|
|
25
|
+
|
|
26
|
+
/** Serpentine order: along x across a row, then the gantry steps to the next row. */
|
|
27
|
+
export function wellAt(index: number): [number, number] {
|
|
28
|
+
const row = Math.floor(index / 4), column = row % 2 ? 3 - (index % 4) : index % 4;
|
|
29
|
+
return [PITCH[column], PITCH[row]];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const inOut = (k: number) => { const x = clamp(k); return x < 0.5 ? 4 * x ** 3 : 1 - (-2 * x + 2) ** 3 / 2; };
|
|
33
|
+
const projections = new Map<string, K.Projection>();
|
|
34
|
+
function projectionOf(width: number, height: number) {
|
|
35
|
+
const key = `${width}x${height}`;
|
|
36
|
+
let P = projections.get(key);
|
|
37
|
+
if (!P) {
|
|
38
|
+
// Fit what is really there: the plate on its feet and the gantry's full travel, not a box as tall as the head.
|
|
39
|
+
const reach = [...K.boxCorners(PLATE, -18, 0), ...K.boxCorners({ x: -136, y: -77, w: 272, d: 141, r: 0 }, 0, 104)];
|
|
40
|
+
projections.set(key, P = K.fitProjection(reach, width, height, { pad: 6, azimuth: 52, elevation: 33 }));
|
|
41
|
+
}
|
|
42
|
+
return P;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where the head is and how full each well is at time t, from one schedule. */
|
|
46
|
+
function schedule(t: number, start: number, step: number) {
|
|
47
|
+
const fill = Array.from({ length: WELL_COUNT }, (_, i): number => (i < PREFILLED ? 1 : 0));
|
|
48
|
+
const visits = Math.min(WELL_COUNT - PREFILLED, Math.max(0, Math.floor((7.6 - start) / step)));
|
|
49
|
+
let [x, y] = wellAt(PREFILLED - 1), tip = RAISED, well = PREFILLED - 1, phase: PlotterFrame["phase"] = "settled";
|
|
50
|
+
for (let v = 0; v < visits; v++) {
|
|
51
|
+
const index = PREFILLED + v, at = start + v * step, k = (t - at) / step;
|
|
52
|
+
if (k < 0) break;
|
|
53
|
+
const from = wellAt(index - 1), to = wellAt(index), travel = inOut(k / 0.48);
|
|
54
|
+
[x, y] = [lerp(from[0], to[0], travel), lerp(from[1], to[1], travel)];
|
|
55
|
+
well = index;
|
|
56
|
+
const down = clamp((k - 0.48) / 0.14), up = clamp((k - 0.84) / 0.14);
|
|
57
|
+
tip = lerp(RAISED, PLUNGED, down * down * (3 - 2 * down) * (1 - up * up * (3 - 2 * up)));
|
|
58
|
+
fill[index] = clamp((k - 0.6) / 0.22);
|
|
59
|
+
phase = k < 0.48 ? "moving" : k < 0.84 ? "dispensing" : "settled";
|
|
60
|
+
}
|
|
61
|
+
return { x, y, tip, fill, well: well + 1, phase };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function plotterFrame(t: number, { width, height, theme = "light", line = 1, start = 1.6, step = 0.72, intro = true }: PlotterOptions): PlotterFrame {
|
|
65
|
+
const P = projectionOf(width, height), c = ANATOMY_PALETTES[theme], marks: PlotterMark[] = [];
|
|
66
|
+
const s = schedule(t, start, step), gy = s.y - NOZZLE_Y, hx = s.x;
|
|
67
|
+
const dispensed = s.fill.reduce((sum, value) => sum + value, 0) * WELL_ML;
|
|
68
|
+
// Each assembly settles from above in turn; the mechanism only runs once everything has landed.
|
|
69
|
+
const lift = (at: number) => (intro ? -K.screenRise(spring(t - at, 34, 0, 9), P) : 0);
|
|
70
|
+
let dy = 0;
|
|
71
|
+
const path = (d: string, fill?: string, stroke?: string, width = line, opacity = 1) => { if (d) marks.push({ kind: "path", d, fill, stroke, width, opacity, dy }); };
|
|
72
|
+
const stroke = (d: string, tone: K.LineTone, width = line, opacity = 1) => path(d, undefined, c.line[tone], width, opacity);
|
|
73
|
+
const dots = (points: K.Point3[], tone: K.LineTone = "mid", r = 0.55) => {
|
|
74
|
+
for (const [x, y] of K.pointsAt(points, P)) marks.push({ kind: "dot", x, y, r: r * P.scale, fill: c.dot, opacity: DOT_OPACITY[tone], dy });
|
|
75
|
+
};
|
|
76
|
+
const solid = (paths: K.SolidPaths, { tone = "hi" as K.Tone, crease = "faint" as K.Tone | "none", lit = false, inner = [] as [string, K.LineTone][] } = {}) => {
|
|
77
|
+
path(paths.fill, lit ? c.face : c.paper);
|
|
78
|
+
paths.shades.forEach((d, shade) => path(d, lit ? c.litShade : c.shades[shade]));
|
|
79
|
+
path(paths.top, lit ? c.litTop : c.top);
|
|
80
|
+
if (crease !== "none") { stroke(paths.crease, crease); if (paths.bevel) stroke(paths.bevel, crease); }
|
|
81
|
+
for (const [d, tone] of inner) stroke(d, tone);
|
|
82
|
+
stroke(paths.outline, lit ? "lit" : tone, lit ? line * 1.25 : line);
|
|
83
|
+
};
|
|
84
|
+
const screw = (x: number, y: number, z: number) => { stroke(K.ring(x, y, 2.4, z, P, 16), "lo"); dots([[x, y, z]], "mid"); };
|
|
85
|
+
const byDepth = (points: [number, number][]) => [...points].sort((a, b) => K.depthOf([a[0], a[1], 0], P) - K.depthOf([b[0], b[1], 0], P));
|
|
86
|
+
|
|
87
|
+
// Base: halo, feet, plate on feet, groove, corner screws, ruler strip beside the front rail, label plate.
|
|
88
|
+
dy = lift(0);
|
|
89
|
+
marks.push({ kind: "path", d: K.haloOf(PLATE, -18, 12, P), fill: c.halo, opacity: c.haloOpacity, dy, halo: true });
|
|
90
|
+
for (const [x, y] of byDepth(K.corners(PLATE, 24))) {
|
|
91
|
+
solid(K.cylinder(x, y, 9, -18, 2, P, 28), { tone: "lo" });
|
|
92
|
+
solid(K.cylinder(x, y, 7, -16, 4, P, 28), { tone: "mid" });
|
|
93
|
+
}
|
|
94
|
+
solid(K.slabOf(PLATE, -12, 12, P, 8, 1.6), { tone: "mid", inner: [[K.sideSeam(PLATE, -5, P, 8), "faint"]] });
|
|
95
|
+
stroke(K.planOutline(K.insetPlan(PLATE, 5), 0, P), "faint");
|
|
96
|
+
for (const [x, y] of K.corners(PLATE, 10)) screw(x, y, 0);
|
|
97
|
+
solid(K.slabOf(RULER, 0, 0.6, P, 3), { tone: "lo", crease: "none" });
|
|
98
|
+
const ticks = K.topTicks(RULER.y + 4, RULER.y + RULER.d - 4, 4, 5, RULER.x + 0.6, 0.6, [2, 3.6], P, "y");
|
|
99
|
+
stroke(ticks.minor, "lo");
|
|
100
|
+
stroke(ticks.major, "mid");
|
|
101
|
+
solid(K.slabOf(LABEL, 0, 0.8, P, 4), { tone: "lo", crease: "none" });
|
|
102
|
+
stroke(K.planOutline(K.insetPlan(LABEL, 1.6), 0.8, P), "faint");
|
|
103
|
+
[[0.62, 3.4], [0.44, 6], [0.3, 8.6]].forEach(([share, y]) => stroke(K.lineOnTop([LABEL.x + 4.5, LABEL.y + y], [LABEL.x + 4.5 + (LABEL.w - 13) * share, LABEL.y + y], 0.8, P), "lo"));
|
|
104
|
+
dots([[LABEL.x + LABEL.w - 4, LABEL.y + 3.4, 0.8], [LABEL.x + LABEL.w - 4, LABEL.y + 8.6, 0.8]], "mid", 0.45);
|
|
105
|
+
|
|
106
|
+
const rail = (x: number) => {
|
|
107
|
+
dy = lift(0.18);
|
|
108
|
+
solid(K.slabOf({ x: x - 5, y: -104, w: 10, d: 208, r: 1.5 }, 0, 7, P, 3, 0.5), { tone: "mid" });
|
|
109
|
+
dots([-80, -40, 0, 40, 80].map((y) => [x, y, 7] as K.Point3), "mid", 0.45);
|
|
110
|
+
for (const y of [-104, 99]) {
|
|
111
|
+
solid(K.slabOf({ x: x - 7, y, w: 14, d: 5, r: 1.2 }, 0, 12, P, 3, 0.5), { tone: "mid" });
|
|
112
|
+
dots([[x - 3.5, y + 2.5, 12], [x + 3.5, y + 2.5, 12]], "mid", 0.45);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const tower = (x: number) => {
|
|
116
|
+
dy = lift(0.5);
|
|
117
|
+
const carriage = { x: x - 8, y: gy - 12, w: 16, d: 24, r: 2.5 };
|
|
118
|
+
solid(K.slabOf(carriage, 7, 6, P, 4, 0.6), { tone: "mid", inner: [[K.sideSeam(carriage, 10, P, 4), "faint"]] });
|
|
119
|
+
dots(K.corners(carriage, 3).map(([px, py]) => [px, py, 13] as K.Point3), "mid", 0.45);
|
|
120
|
+
// An index bracket hangs from the front carriage to just above the ruler and reads the gantry's travel.
|
|
121
|
+
if (x > 0) solid(K.slabOf({ x: x + 8, y: gy - 1.2, w: 1.8, d: 2.4, r: 0.3 }, 1.2, 11.8, P, 2), { tone: "hi", crease: "none" });
|
|
122
|
+
solid(K.cylinder(x, gy, 7, 13, 4, P, 28, 0.5), { tone: "lo" });
|
|
123
|
+
solid(K.cylinder(x, gy, 5, 17, BEAM_Z - 17, P, 28), { tone: "mid", crease: "none", inner: [[K.sideArc(x, gy, 5, 42, P, 16), "faint"], [K.sideArc(x, gy, 5, 66, P, 16), "faint"]] });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
rail(-RAIL_X);
|
|
127
|
+
tower(-RAIL_X);
|
|
128
|
+
|
|
129
|
+
// The tray is the subject: sixteen shallow wells, each filled by the head in turn.
|
|
130
|
+
dy = lift(0.34);
|
|
131
|
+
solid(K.slabOf(TRAY, 0, TRAY_TOP, P, 8, 1), { tone: "hi", crease: "lo", inner: [[K.sideSeam(TRAY, 4, P, 8), "faint"]] });
|
|
132
|
+
for (const [x, y] of K.corners(TRAY, 7)) screw(x, y, TRAY_TOP);
|
|
133
|
+
for (let i = 0; i < WELL_COUNT; i++) {
|
|
134
|
+
const [x, y] = wellAt(i), k = s.fill[i];
|
|
135
|
+
path(K.onTop(K.circleRing(x, y, 13, 48), TRAY_TOP, P, true), c.shades[0]);
|
|
136
|
+
path(K.onTop(K.circleRing(x, y, 10.6, 40), TRAY_TOP - 2, P, true), c.shades[2]);
|
|
137
|
+
if (k > 0) path(K.onTop(K.circleRing(x, y, 10.4 * Math.sqrt(0.18 + 0.82 * k), 40), TRAY_TOP - 2 + 1.4 * k, P, true), c.line.blue, undefined, line, 0.35 + 0.55 * k);
|
|
138
|
+
stroke(K.ring(x, y, 13, TRAY_TOP, P, 48), "lo");
|
|
139
|
+
stroke(K.ring(x, y, 10.6, TRAY_TOP - 2, P, 40), "faint");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
rail(RAIL_X);
|
|
143
|
+
tower(RAIL_X);
|
|
144
|
+
|
|
145
|
+
// The beam carries a guide rail and a ruler; the head runs on the guide.
|
|
146
|
+
dy = lift(0.66);
|
|
147
|
+
const beam = { x: -136, y: gy - 6, w: 272, d: 12, r: 2 };
|
|
148
|
+
solid(K.slabOf(beam, BEAM_Z, BEAM_TOP - BEAM_Z, P, 4, 0.8), { tone: "mid", inner: [[K.sideSeam(beam, BEAM_Z + 3, P, 4), "faint"]] });
|
|
149
|
+
const strip = { x: -122, y: gy + 0.8, w: 244, d: 4.6, r: 0.6 };
|
|
150
|
+
solid(K.slabOf(strip, BEAM_TOP, 0.4, P, 3), { tone: "lo", crease: "none" });
|
|
151
|
+
const scale = K.topTicks(strip.x + 2, strip.x + strip.w - 2, 6, 5, strip.y + strip.d - 0.4, BEAM_TOP + 0.4, [-1.6, -3], P, "x");
|
|
152
|
+
stroke(scale.minor, "lo");
|
|
153
|
+
stroke(scale.major, "mid");
|
|
154
|
+
for (const x of [-RAIL_X, RAIL_X]) screw(x, gy - 3, BEAM_TOP);
|
|
155
|
+
solid(K.slabOf({ x: -124, y: gy + 6, w: 248, d: 1.5, r: 0.4 }, BEAM_Z + 2, 6, P, 2), { tone: "lo" });
|
|
156
|
+
|
|
157
|
+
dy = lift(0.82);
|
|
158
|
+
const body = { x: hx - 12, y: gy + 7.5, w: 24, d: 13, r: 2 };
|
|
159
|
+
solid(K.cylinder(hx, gy + NOZZLE_Y, 2, s.tip, 68 - s.tip, P, 20), { lit: true });
|
|
160
|
+
solid(K.cylinder(hx, gy + NOZZLE_Y, 4.4, 68, 4, P, 24, 0.4), { tone: "lo" });
|
|
161
|
+
solid(K.slabOf(body, 72, 28, P, 4, 0.8), { tone: "hi", crease: "lo", inner: [[K.sideSeam(body, 77, P, 4), "faint"]] });
|
|
162
|
+
// Reservoir window on the front face: the ink drains as the wells fill.
|
|
163
|
+
const face = body.y + body.d, level = 1 - (dispensed - PREFILLED * WELL_ML) / ((WELL_COUNT - PREFILLED + 2) * WELL_ML);
|
|
164
|
+
const window = (z0: number, z1: number) => K.pathOf(K.pointsAt([[hx - 5, face, z0], [hx + 5, face, z0], [hx + 5, face, z1], [hx - 5, face, z1]], P), true);
|
|
165
|
+
path(window(80, 95), c.shades[0]);
|
|
166
|
+
path(window(80, 80 + 15 * clamp(level)), c.line.blue, undefined, line, 0.75);
|
|
167
|
+
stroke(window(80, 95), "lo");
|
|
168
|
+
dots([[hx - 8.5, face, 97.5], [hx + 8.5, face, 97.5]], "mid", 0.45);
|
|
169
|
+
solid(K.cylinder(hx, gy + NOZZLE_Y, 4.2, 100, 4, P, 28), { tone: "mid" });
|
|
170
|
+
stroke(K.knurl(hx, gy + NOZZLE_Y, 4.2, 100, 104, 28, P), "lo");
|
|
171
|
+
stroke(K.lineOnTop([hx - 2.6, gy + NOZZLE_Y], [hx + 3.4, gy + NOZZLE_Y], 104, P), "hi");
|
|
172
|
+
|
|
173
|
+
const filled = s.fill.filter((value) => value >= 1).length;
|
|
174
|
+
return { marks, well: s.well, filled, dispensed, phase: s.phase };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The live readout for the card's bottom-right corner; the pattern takes {well}, {count} and {ml}. */
|
|
178
|
+
export const plotterReadout = (frame: PlotterFrame, pattern = "well {well} / {count} · {ml} ml") =>
|
|
179
|
+
pattern.replace("{well}", String(frame.well).padStart(2, "0")).replace("{count}", String(WELL_COUNT)).replace("{ml}", frame.dispensed.toFixed(1));
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import copy from "../../shared/locales/en/style-studies.json";
|
|
2
2
|
import { clamp, ease, hash, lerp, progress, sampled, spring, strokePoints, type Point } from "./timing";
|
|
3
3
|
import { type Paint, type StudyNode, type StudyFrame, STUDY_DURATION } from "./model";
|
|
4
|
+
import { plotterFrame, plotterReadout } from "./anatomy-plotter";
|
|
5
|
+
import { ANATOMY_PALETTES } from "../kit/anatomy/palette";
|
|
4
6
|
export const STUDY_IDS = Object.keys(copy.styles) as (keyof typeof copy.styles)[];
|
|
5
7
|
export type StudyId = keyof typeof copy.styles;
|
|
6
8
|
/** Original geometry and choreography; source inspirations are recorded in skill references. */
|
|
@@ -175,6 +177,25 @@ const scenes: Record<StudyId, (d: Drawing, t: number) => void> = {
|
|
|
175
177
|
d.rect(740, 359, 148, 62, "#123b5a");
|
|
176
178
|
d.text("FIG. 01", 755, 379, 18, "#dbedf0", "left", "mono");
|
|
177
179
|
},
|
|
180
|
+
"machine-anatomy": (d, t) => {
|
|
181
|
+
const c = ANATOMY_PALETTES.light;
|
|
182
|
+
d.background(c.page);
|
|
183
|
+
d.rect(36, 28, 888, 470, c.card).resource = "material";
|
|
184
|
+
d.path("M36,28 L924,28 L924,498 L36,498 Z", "none", c.line.mid, 1).resource = "material";
|
|
185
|
+
const figure = plotterFrame(t, { width: 828, height: 368, start: .8, step: .82, intro: false });
|
|
186
|
+
for (const mark of figure.marks) {
|
|
187
|
+
if (mark.kind === "path" && mark.halo)
|
|
188
|
+
continue;
|
|
189
|
+
const node = mark.kind === "dot" ? d.ellipse(mark.x, mark.y, mark.r, mark.r, mark.fill, mark.opacity) : d.path(mark.d, mark.fill ?? "none", mark.stroke, mark.width, mark.opacity);
|
|
190
|
+
node.transform = { x: 66, y: 86 + mark.dy };
|
|
191
|
+
}
|
|
192
|
+
const label = (text: string, x: number, y: number, color: string, align: StudyNode["align"]) => { d.text(text, x, y, 12, color, align, "mono").weight = "400"; };
|
|
193
|
+
label("Fig 1", 60, 44, c.text, "left");
|
|
194
|
+
label(d.lines[0].replace(/\.$/, "").toUpperCase(), 900, 44, c.muted, "right");
|
|
195
|
+
label(d.lines[2].toUpperCase(), 60, 470, c.muted, "left");
|
|
196
|
+
label(plotterReadout(figure, copy.styles["machine-anatomy"].readout), 900, 470, c.text, "right");
|
|
197
|
+
label(d.lines[1], 36, 512, c.muted, "left");
|
|
198
|
+
},
|
|
178
199
|
"holographic-scan": (d, t) => {
|
|
179
200
|
d.background(gradient("#07262c", "#030d18"));
|
|
180
201
|
d.title("#c7ece6");
|
|
@@ -396,6 +417,156 @@ const scenes: Record<StudyId, (d: Drawing, t: number) => void> = {
|
|
|
396
417
|
}
|
|
397
418
|
d.text(d.lines[2], 480, 437, 20, "#c3d9c6", "center");
|
|
398
419
|
},
|
|
420
|
+
"satellite-briefing": (d, t) => {
|
|
421
|
+
d.background(gradient("#3a3f42", "#121619"));
|
|
422
|
+
d.grain("#d6dbde", .07, 320);
|
|
423
|
+
// Each dated pass leaves its shoreline; the current one is white, earlier ones stay as dimmer history.
|
|
424
|
+
const shore = (r: number): Point[] => Array.from({ length: 49 }, (_, i) => { const a = i / 48 * Math.PI * 2, k = r * (1 + .17 * Math.sin(a * 3 + 1) + .07 * Math.sin(a * 7 + 2)); return [470 + Math.cos(a) * k * 1.45, 278 + Math.sin(a) * k]; });
|
|
425
|
+
const sizes = [118, 96, 76, 58], passes = [.5, 1.8, 3, 4.1];
|
|
426
|
+
const current = passes.filter((at) => t >= at).length - 1, scan = progress(t, passes[Math.max(0, current)], .7);
|
|
427
|
+
d.poly(shore(sizes[Math.max(0, current)]), "#0c1418");
|
|
428
|
+
for (let i = 0; i <= current; i++)
|
|
429
|
+
d.line(shore(sizes[i]), i === current ? "#f2f5f6" : "#9aa4a9", i === current ? 2 : 1.2, i === current ? scan : 1);
|
|
430
|
+
if (scan < 1) d.rect(0, 100 + scan * 350, 960, 22, "#ffffff", 0, .07);
|
|
431
|
+
const grow = progress(t, 5.1, 1.2), from = 470 + sizes[0] * 1.45 * 1.05, to = 470 + sizes[3] * 1.45 * .95;
|
|
432
|
+
if (grow > 0) d.line([[from, 278], [lerp(from, to, grow), 278]], "#ffbe55", 3);
|
|
433
|
+
d.text(`${Math.round(64 * grow)} km`, 690, 168, 40, "#ffbe55").opacity = progress(t, 5.1, .3);
|
|
434
|
+
d.title("#e9eef0");
|
|
435
|
+
d.footer("#9aa6ab");
|
|
436
|
+
d.text(d.lines[2], 480, 448, 17, "#c9d1d5", "center", "mono");
|
|
437
|
+
},
|
|
438
|
+
"briefing-board": (d, t) => {
|
|
439
|
+
d.background(gradient("#f4f4f0", "#e3e4e0"));
|
|
440
|
+
d.rect(18, 18, 924, 504, "#d4d6d8", 4, .25);
|
|
441
|
+
d.title("#2b2d31");
|
|
442
|
+
d.footer("#5d6066");
|
|
443
|
+
// Graphite for rules, blue for measurement, red only for the decision.
|
|
444
|
+
const limit = 640;
|
|
445
|
+
if (t > .4) d.line([[limit, 120], [limit, 430]], "#1f5fbf", 2, progress(t, .4, .6));
|
|
446
|
+
const ranges = [330, 560, 760];
|
|
447
|
+
ranges.forEach((end, i) => {
|
|
448
|
+
const y = 170 + i * 105, at = .9 + i * 1.1;
|
|
449
|
+
d.rect(60, y - 28, 110, 56, "#fbfbf8", 3).opacity = progress(t, at, .3);
|
|
450
|
+
d.ellipse(115, y - 30, 7, 7, "#2a2c30", progress(t, at + .1, .1));
|
|
451
|
+
if (t > at + .2) d.line([[200, y], [200 + (end - 200) * progress(t, at + .2, .6), y]], "#1f5fbf", 4);
|
|
452
|
+
if (t <= at + .8) return;
|
|
453
|
+
if (end < limit) d.line([[70, y - 20], [160, y + 20]], "#d8322b", 4, progress(t, at + .8, .25));
|
|
454
|
+
else d.line(Array.from({ length: 33 }, (_, k): Point => [end + Math.cos(k / 32 * Math.PI * 2) * 34, y + Math.sin(k / 32 * Math.PI * 2) * 22]), "#d8322b", 3, progress(t, at + .8, .4));
|
|
455
|
+
});
|
|
456
|
+
const stamp = spring(t - 5, 1.6, 1, 12), n = d.rect(-120, -34, 240, 68, "#d8322b", 6, progress(t, 5, .1) * .9);
|
|
457
|
+
n.transform = { x: 790, y: 120, rotation: -.14, scaleX: stamp, scaleY: stamp };
|
|
458
|
+
const label = d.text(d.lines[2], 0, -12, 17, "#fff6f2", "center");
|
|
459
|
+
label.transform = n.transform;
|
|
460
|
+
label.opacity = n.opacity;
|
|
461
|
+
},
|
|
462
|
+
"lidar-survey": (d, t) => {
|
|
463
|
+
d.background("#05080a");
|
|
464
|
+
d.title("#e8efe9");
|
|
465
|
+
d.footer("#7f948a");
|
|
466
|
+
// Returns colour by height; a cutting plane lifts the canopy away, then the ground condenses behind a front.
|
|
467
|
+
const plane = 60 - 58 * progress(t, 1.6, 2.2), front = 120 + 760 * progress(t, 4, 1.6), colors = ["#1f3f35", "#3f7a62", "#7fb894", "#e2f3dc"];
|
|
468
|
+
for (let i = 0; i < 900; i++) {
|
|
469
|
+
const x = 90 + hash(i) * 780, base = 360 + Math.sin(x * .012) * 26, h = hash(i + 700) < .55 ? hash(i + 1400) * 70 : 0;
|
|
470
|
+
if (h > plane) continue;
|
|
471
|
+
if (x < front && h === 0) continue;
|
|
472
|
+
d.ellipse(x, base - h, 1.4, 1.4, h ? colors[Math.min(3, Math.floor(h / 18))] : "#c8b38c", Math.abs(h - plane) < 3 ? 1 : .8);
|
|
473
|
+
}
|
|
474
|
+
const ground: Point[] = Array.from({ length: 41 }, (_, i) => { const x = 90 + i * 19.5; return [x, 360 + Math.sin(x * .012) * 26]; });
|
|
475
|
+
if (t > 4) d.poly([...ground.filter(([x]) => x <= front), [Math.min(front, 870), 430], [90, 430]], gradient("#d6cfc0", "#7b7466", 0, 330, 0, 430));
|
|
476
|
+
if (t > 5.6) d.line([[220, 352], [420, 368], [610, 345]], "#ffb347", 3, progress(t, 5.6, 1));
|
|
477
|
+
d.line([[40, 140], [40, 400]], "#9fb3aa", 1);
|
|
478
|
+
d.line([[34, 400 - 4 * plane], [70, 400 - 4 * plane]], "#d4fff0", 2, t < 4 ? 1 : 0);
|
|
479
|
+
d.text(d.lines[2], 700, 168, 18, "#ffb347").opacity = progress(t, 5.6, .4);
|
|
480
|
+
},
|
|
481
|
+
"white-model-projection": (d, t) => {
|
|
482
|
+
d.background(gradient("#14171b", "#050608"));
|
|
483
|
+
d.title("#f2f2ee");
|
|
484
|
+
d.footer("#9a9fa6");
|
|
485
|
+
// House lights cut; each block switches on in its decade, then a level of light climbs every facade.
|
|
486
|
+
const house = t < 1.3 ? 1 : .18, ramp = ["#2f5bff", "#2aa7ff", "#6fe7f5", "#f2fdff"];
|
|
487
|
+
for (let i = 0; i < 12; i++) {
|
|
488
|
+
const col = i % 4, row = Math.floor(i / 4), x = 250 + col * 112 - row * 46, y = 340 + row * 46, h = 30 + hash(i) * 90, decade = Math.floor(hash(i + 20) * 4);
|
|
489
|
+
const lit = progress(t, 1.6 + decade * .55, .15) * (1 - progress(t, 4.2, .3)), level = Math.min(h, 150 * progress(t, 4.6, 1.6));
|
|
490
|
+
const white = Math.round(40 + 200 * house).toString(16).padStart(2, "0");
|
|
491
|
+
block(d, x, y - h, 70, h, 34, `#${white}${white}${white}`, "#e8e8e4", "#9a9a96");
|
|
492
|
+
d.rect(x, y - h, 70, h, ramp[decade], 0, lit * .85);
|
|
493
|
+
d.rect(x, y - level, 70, level, gradient("#ffb38a", "#6a3cff", 0, y - 150, 0, y), 0, progress(t, 4.6, .3) * (1 - progress(t, 6.6, .4)) * .9);
|
|
494
|
+
}
|
|
495
|
+
d.text(d.lines[2], 680, 168, 18, "#e5e8ec").opacity = progress(t, 1.6, .4);
|
|
496
|
+
},
|
|
497
|
+
"data-physicalization": (d, t) => {
|
|
498
|
+
d.background(gradient("#e6e0d6", "#c4beb4"));
|
|
499
|
+
d.title("#1c1d20");
|
|
500
|
+
d.footer("#6c6a66");
|
|
501
|
+
d.poly([[110, 420], [850, 420], [890, 460], [70, 460]], "#5b3a26");
|
|
502
|
+
// Plates land one by one; every tenth is brass, so each stack is its own ruler.
|
|
503
|
+
const series = [[22, 20, 18, 14, 13, 12], [5, 8, 11, 15, 19, 26]];
|
|
504
|
+
series.forEach((values, s) => values.forEach((count, year) => {
|
|
505
|
+
const x = 150 + year * 120 + s * 46;
|
|
506
|
+
for (let i = 0; i < count; i++) {
|
|
507
|
+
const at = .4 + year * .55 + s * .2 + i * .02, drop = (1 - progress(t, at, .2)) * 30;
|
|
508
|
+
if (t < at - .2) continue;
|
|
509
|
+
d.rect(x, 410 - i * 9 - drop, 40, 7, (i + 1) % 10 === 0 ? "#d2b07a" : s ? "#8fcfd2" : "#c8ccd1", 1);
|
|
510
|
+
}
|
|
511
|
+
}));
|
|
512
|
+
d.text(d.lines[2], 120, 150, 20, "#d4521c").opacity = progress(t, 4.8, .4);
|
|
513
|
+
},
|
|
514
|
+
"exploded-teardown": (d, t) => {
|
|
515
|
+
d.background(gradient("#34363a", "#0d0e10"));
|
|
516
|
+
d.title("#f1efe9");
|
|
517
|
+
d.footer("#8b8984");
|
|
518
|
+
// Layers lift on one axis, top first; labels land in a fixed column, then the product closes again.
|
|
519
|
+
const layers = [["#e9e6df", 5], ["#3d4046", 4], ["#d9dfe2", 3], ["#1b5238", 2], ["#c3c6cc", 1], ["#3d4046", 0]] as const;
|
|
520
|
+
layers.forEach(([color, index], k) => {
|
|
521
|
+
const open = progress(t, 1 + (5 - index) * .15, 1) * (1 - progress(t, 5.6 + index * .1, .4)), y = 360 - index * 12 - index * 34 * open;
|
|
522
|
+
d.poly([[260, y], [520, y - 40], [640, y], [380, y + 40]], color, "#00000055", 1);
|
|
523
|
+
if (index === 2) for (let i = 0; i < 6; i++) d.ellipse(340 + i * 38, y + 6 - i * 6, 10, 5, "#ffb340", progress(t, 2.8, .5) * open * .8);
|
|
524
|
+
const label = progress(t, 2.6 + k * .2, .3) * (1 - progress(t, 5.2, .3));
|
|
525
|
+
if (label > 0) d.line([[640, y], [700, 130 + k * 52]], "#f1efe9", 1, label);
|
|
526
|
+
d.text(`0${k + 1}`, 712, 120 + k * 52, 14, "#ff6b3d", "left", "mono").opacity = label;
|
|
527
|
+
});
|
|
528
|
+
d.text(d.lines[2], 740, 120, 18, "#f1efe9").opacity = progress(t, 2.6, .3) * (1 - progress(t, 5.2, .3));
|
|
529
|
+
},
|
|
530
|
+
"microscope-story": (d, t) => {
|
|
531
|
+
d.background(gradient("#2a3036", "#0b0d0f"));
|
|
532
|
+
d.title("#f4f1ec");
|
|
533
|
+
d.footer("#9ea6ad");
|
|
534
|
+
// One continuous dive about a fixed point: cells grow from specks to bodies with nuclei, then a circle is counted.
|
|
535
|
+
const zoom = Math.exp(Math.log(5) * progress(t, .8, 3.2)), field = { x: 120, y: 96, width: 720, height: 356 };
|
|
536
|
+
d.rect(120, 96, 720, 356, "#f4f1ec").clip = field;
|
|
537
|
+
for (let row = -16; row <= 16; row++)
|
|
538
|
+
for (let col = -30; col <= 30; col++) {
|
|
539
|
+
const i = (row + 40) * 100 + col + 50, x = 480 + (col * 24 + (hash(i) - .5) * 10) * zoom, y = 274 + (row * 24 + (hash(i + 7) - .5) * 10) * zoom, r = 10 * zoom;
|
|
540
|
+
if (x < 120 - r || x > 840 + r || y < 96 - r || y > 452 + r) continue;
|
|
541
|
+
const septum = Math.abs(Math.sin(col * .35 + row * .2)) < .22;
|
|
542
|
+
d.ellipse(x, y, r, r * .9, septum ? "#4a6cc0" : "#c44e5c", .9).clip = field;
|
|
543
|
+
if (zoom > 2 && !septum && hash(i + 3) < .75) d.ellipse(x, y, r * .36, r * .34, "#34244f").clip = field;
|
|
544
|
+
}
|
|
545
|
+
const ring = progress(t, 4.4, .6);
|
|
546
|
+
if (ring > 0) d.line(Array.from({ length: 49 }, (_, i): Point => [480 + Math.cos(i / 48 * Math.PI * 2) * 120, 274 + Math.sin(i / 48 * Math.PI * 2) * 120]), "#ffffff", 2, ring);
|
|
547
|
+
d.rect(140, 410, 120, 3, "#ffffff");
|
|
548
|
+
d.text(`${Math.round(2 + 3 * progress(t, .8, 3.2))}x`, 268, 400, 14, "#ffffff", "left", "mono");
|
|
549
|
+
d.text(d.lines[2], 480, 462, 17, "#78f0cd", "center").opacity = progress(t, 5.2, .4);
|
|
550
|
+
},
|
|
551
|
+
"archive-light-table": (d, t) => {
|
|
552
|
+
d.background(gradient("#f7f9fb", "#e3e8ec"));
|
|
553
|
+
d.title("#15171a");
|
|
554
|
+
d.footer("#6a7076");
|
|
555
|
+
// Objects lie on the light; the loupe lands on one and the negative turns positive inside its rim.
|
|
556
|
+
const power = t < .3 ? .4 : 1;
|
|
557
|
+
landscape(d, 90, 150, 240, 180, 0);
|
|
558
|
+
d.rect(90, 150, 240, 180, "#ffffff", 0, 1 - power);
|
|
559
|
+
d.rect(380, 130, 260, 200, "#1d1f22");
|
|
560
|
+
landscape(d, 395, 145, 230, 170, 2);
|
|
561
|
+
d.rect(395, 145, 230, 170, "#f0ece4", 0, .8 * (1 - progress(t, 3, 1.4)));
|
|
562
|
+
d.rect(680, 230, 190, 140, "#d9d4c8", 2);
|
|
563
|
+
for (let i = 0; i < 5; i++) d.rect(700, 255 + i * 20, 150 - (i % 2) * 40, 5, "#3a3d44", 1, i === 2 ? 1 : .55);
|
|
564
|
+
const land = spring(t - 2, 1.35, 1, 14), loupe = d.ellipse(510, 232, 70 * land, 70 * land, "#ffffff", progress(t, 1.95, .1) * .12);
|
|
565
|
+
loupe.stroke = "#111214";
|
|
566
|
+
loupe.strokeWidth = 9;
|
|
567
|
+
if (t > 4.6) d.line([[700, 306], [850, 306]], "#ff5a1f", 2, progress(t, 4.6, .5));
|
|
568
|
+
d.text(d.lines[2], 680, 410, 17, "#15171a").opacity = progress(t, 3, .5);
|
|
569
|
+
},
|
|
399
570
|
};
|
|
400
571
|
function ninety(t: number) { return 87 + 9 * (1 - progress(t, .2)); }
|
|
401
572
|
export function studyFrame(id: StudyId, seconds: number): StudyFrame {
|
|
@@ -15,7 +15,18 @@ button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visibl
|
|
|
15
15
|
main { max-width: 1380px; margin: auto; padding: 45px 48px 15px; }
|
|
16
16
|
.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: 1.9px; margin: 0 0 12px; color: #667660; }
|
|
17
17
|
.specs { font: 10px monospace; color: #6a7668; white-space: nowrap; }
|
|
18
|
-
#theater { border-radius: 17px; overflow: hidden; border: 1px solid #d9ddcf; background: #f4f1e9; box-shadow: 0 16px 60px #1726240d; }
|
|
18
|
+
#theater { container: theater / inline-size; border-radius: 17px; overflow: hidden; border: 1px solid #d9ddcf; background: #f4f1e9; box-shadow: 0 16px 60px #1726240d; }
|
|
19
|
+
/* The page's player fits the window: its width follows the height left for the picture below the page header and above
|
|
20
|
+
the controls, so the picture and the controls show without scrolling and the timeline follows below. The player
|
|
21
|
+
measures that header and those controls (--player-above, --player-controls). The small viewport height keeps the
|
|
22
|
+
size steady while a phone's toolbars come and go. The picture keeps 240px of height where the window has it: a header
|
|
23
|
+
that leaves less scrolls away instead, as on a phone on its side. The player keeps a width its controls fit, and a
|
|
24
|
+
tall film narrower than that shows with bars at its sides, as on a phone. */
|
|
25
|
+
.fit-window { --player-room: calc(100vh - var(--player-controls) - 16px); }
|
|
26
|
+
@supports (height: 100svh) { .fit-window { --player-room: calc(100svh - var(--player-controls) - 16px); } }
|
|
27
|
+
.fit-window { --player-fit: max(var(--player-room) - var(--player-above), min(var(--player-room), 240px)); }
|
|
28
|
+
.fit-window #theater:not(.expanded):not(:fullscreen), .fit-window .player-note { min-width: min(100%, 360px); max-width: calc(var(--player-fit) * var(--film-aspect) + 2px); margin-inline: auto; }
|
|
29
|
+
.fit-window #theater:not(.expanded):not(:fullscreen) #canvas-host { max-height: var(--player-fit); }
|
|
19
30
|
#canvas-host { width: 100%; position: relative; aspect-ratio: 16 / 9; }
|
|
20
31
|
#canvas-host canvas { display: block; width: 100%; height: 100%; object-fit: contain; }
|
|
21
32
|
#loading { position: absolute; inset: 45% 0 auto; text-align: center; color: #788073; }
|
|
@@ -33,7 +44,7 @@ main { max-width: 1380px; margin: auto; padding: 45px 48px 15px; }
|
|
|
33
44
|
#download-menu button { display: flex; align-items: center; justify-content: space-between; gap: 14px; width: 100%; padding: 12px 10px; text-align: left; font-size: 12px; }
|
|
34
45
|
#download-menu small { color: #77836f; font-size: 10px; }
|
|
35
46
|
#captions[aria-pressed="true"] { background: #e7ecdc; font-weight: 700; }
|
|
36
|
-
.player-note { display: flex; justify-content: space-between; gap: 15px; color: #7b8174; font-size: 10px; margin: 14px 2px 0; }
|
|
47
|
+
.player-note { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 15px; color: #7b8174; font-size: 10px; margin: 14px 2px 0; }
|
|
37
48
|
.preview-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; }
|
|
38
49
|
#status { color: #9a391c; font-size: 13px; margin: 10px 0; }
|
|
39
50
|
.resource-timeline { min-width: 0; margin: 32px 0; border: 1px solid #daddcf; border-radius: 12px; overflow: hidden; background: #fbf9f3; }
|
|
@@ -108,11 +119,16 @@ body:not(.capture) #canvas-host canvas { cursor: pointer; }
|
|
|
108
119
|
@media (max-width: 750px) {
|
|
109
120
|
.masthead { padding: 20px; }.brand span { display: none; }.brand strong { font-size: 20px; }.template { font-size: 9px; padding: 8px 10px; }
|
|
110
121
|
main { padding: 30px 20px 10px; }
|
|
111
|
-
.transport { flex-wrap: wrap; gap: 12px; padding: 12px; }.tools { justify-content: space-between; width: 100%; }.tools button { min-height: 36px; }
|
|
112
122
|
.player-note { flex-direction: column; gap: 6px; font-size: 9px; }
|
|
113
123
|
.timeline-toolbar { flex-wrap: wrap; padding: 13px; }.timeline-zoom { margin-left: auto; }.timeline-clock { margin-right: 4px; }
|
|
114
124
|
footer { padding: 25px 20px; }footer span:last-child { display: none; }
|
|
115
125
|
}
|
|
126
|
+
/* A short window, such as a phone on its side, keeps more of its height for the picture. */
|
|
127
|
+
@media (max-height: 560px) { .masthead { padding-block: 12px; } main { padding-top: 16px; } }
|
|
128
|
+
/* The transport wraps by the player's width, which a tall film keeps narrow on a wide screen too. */
|
|
129
|
+
@container theater (max-width: 600px) {
|
|
130
|
+
.transport { flex-wrap: wrap; gap: 12px; padding: 12px; }.tools { justify-content: space-between; width: 100%; }.tools button { min-height: 36px; }
|
|
131
|
+
}
|
|
116
132
|
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto; } }
|
|
117
133
|
|
|
118
134
|
#audio-status { margin: 0; padding: 9px 16px; font-size: 12px; color: #8b2f25; background: #fff1eb; }
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"lemo-opuscar": {
|
|
3
3
|
"url": "https://github.com/lemomo-ai/lemo-opuscar",
|
|
4
|
-
"commit": "b65efe6ea393fdc71212898ea4c57c754cfefa46"
|
|
4
|
+
"commit": "b65efe6ea393fdc71212898ea4c57c754cfefa46",
|
|
5
|
+
"evidenceFilmsCommit": "b5e8ef93930b6d828f352055a689320f138237b1"
|
|
5
6
|
},
|
|
6
7
|
"awesome-opus5-5-videos": {
|
|
7
8
|
"url": "https://github.com/yihui-dev/awesome-opus5-5-videos",
|
|
8
9
|
"commit": "3d54892e2ae5b0e8d337171e6508bba4cec01ab8"
|
|
9
10
|
},
|
|
10
|
-
"
|
|
11
|
+
"anatomy": {
|
|
12
|
+
"url": "https://github.com/wheresryan22/anatomy",
|
|
13
|
+
"commit": "1482e8469b0a9e0d8cc18ea2740a01423d29ae80"
|
|
14
|
+
},
|
|
15
|
+
"scope": "Material and motion guidance; original scene study implementations. No source demo assets, data or creator footage bundled. The Machine Anatomy kit file src/kit/anatomy/iso-kit.ts is vendored unchanged from anatomy."
|
|
11
16
|
}
|