Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a29279f0f | ||
|
|
61cc2383fc | ||
|
|
08befffdeb | ||
|
|
87dc305496 | ||
|
|
9c4e09e420 |
No files matched your search
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
out/
|
||||
@@ -1,461 +0,0 @@
|
||||
# Animating Clawd
|
||||
|
||||
Read this whole file before you draw anything. It covers how to make a short, hand-painted cartoon, starring Clawd or any character you design: the rules and animation principles that make it look good, the workflow that catches mistakes, and the full reference for the character and the engine.
|
||||
|
||||
The person prompting you decides **what** the video is about. This guide decides **how** it's made. If they ask for something the rules below forbid (a caption, a 3D spin), do what they ask.
|
||||
|
||||
**No design here is final.** Clawd, the emotions, the props and the helpers are a starting point, not a limit. Change any of them, Clawd's own design included, and add whatever new characters, props or emotions the idea needs. Paint new things with the same tools and rules, so they belong with the rest.
|
||||
|
||||
Look at the model sheets first:
|
||||
- [docs/emotions.jpg](docs/emotions.jpg): all 31 emotions.
|
||||
- [docs/views.jpg](docs/views.jpg): the five key views, the motion helpers and the hats.
|
||||
|
||||
---
|
||||
|
||||
## The three goals
|
||||
|
||||
Every rule below serves one of three goals.
|
||||
|
||||
- **Handmade.** The video should look like someone painted it by hand, frame by frame. That means brush strokes, ink lines that boil, flat 2D and no lettering.
|
||||
- **Alive.** Something is always moving and something is always happening. Faces act instead of snapping, and characters are big enough to feel.
|
||||
- **One piece.** It's one short film, not a pile of clips. Plan it before you draw it, and link every scene to the next.
|
||||
|
||||
Underneath all three, the viewer has to be able to follow it. Timing (rule 4) is the rule models get wrong most often.
|
||||
|
||||
## The rules
|
||||
|
||||
### 1. The medium is solid: brush strokes, flat 2D, boil
|
||||
|
||||
- **Paint everything with p5.brush through `paint()` and `inkLine()`.** Characters get flat `wash` colour plus an ink outline. Backgrounds get soft watercolour `fill` shapes, usually with no outline or a thin one. Never use plain p5 shapes (`rect`, `ellipse`, `fill()`): they look like 2000s Flash.
|
||||
- **The linework boils.** `jit()` and `random()` are reseeded 12 times a second (`BOIL`), so every drawing wobbles slightly, like hand-drawn animation. That's the look; don't fight it. For anything that must stay put from frame to frame (star positions, tuft heights), use `hash(i)`. Give each separate element its own seed with `boilSeed(key)` (see Engine), or one moving thing makes everything drawn after it jitter.
|
||||
- **Everything is flat 2D. Never project 3D.** Don't rotate a box in perspective, don't use `rotateY` or WEBGL 3D and don't fake depth with math. Clawd turns through **drawn key views** (front → 3/4 → side → back 3/4 → back), exactly like a cartoon model sheet: see `turn()` and `spinView()`. Depth comes from overlap, scale and colour (farther = smaller, bluer, paler), never from a projection.
|
||||
- **Light is the one exception.** p5.brush mixes colour like pigment, so a yellow glow painted over blue turns green, and a thin wash over it turns grey. Use `glow()` for anything that shines: it adds real light, under the paper grain.
|
||||
- **Soft palette, no pure black or white.** Use `PAL.ink` for black and `PAL.cream` or `PAL.paper` for white. Keep colours soft and harmonious, and keep Clawd clearly readable against the background.
|
||||
|
||||
### 2. No text
|
||||
|
||||
- **Show it, don't write it.** Models overuse text. No captions, no titles, no labels on objects, no signs, no speech bubbles with words, no words on screens, no "ZZZ" typed in a font.
|
||||
- **Clawd's reactions are painted marks, never letters**: `!`, `?`, zzz, sweat, hearts, a bulb, a rain cloud. Use the emotes (see the reference).
|
||||
- **A sign that repeats the story is the classic failure.** If Clawd holds a sign saying "I'm lost", the shot has failed. Show Clawd being lost: looking left, then right, the map upside down, a sweat drop.
|
||||
- If the prompt truly needs a word (a name, a shop sign that is the joke), use `letter()`. Paint it into the scene, keep it to one or two words and use it once.
|
||||
|
||||
### 3. Something happens in every scene
|
||||
|
||||
- **Every shot needs an event:** something changes between its first frame and its last. Clawd wants something, finds something, tries, fails, reacts or gets it. "Clawd stands in a meadow being cute" is not a shot.
|
||||
- **One focal action at a time.** Stage it with a clear silhouette and nothing competing for attention, so it reads at a glance.
|
||||
- **Cause, then reaction.** When something happens, Clawd reacts to it: a take, an emotion change, a turn toward it. The reaction is often the funniest part, so give it time.
|
||||
- **Pay it off.** Whatever you set up in a shot (a door, a sandwich, a strange noise) gets resolved on screen, in that shot or a later one.
|
||||
|
||||
### 4. Timing: model the viewer
|
||||
|
||||
Timing turns a set of drawings into a story. It's also where generated animation fails most often: everything moves at one brisk speed, events pile on top of each other, and moments are over before anyone understands them.
|
||||
|
||||
You know what happens because you wrote the code. The viewer doesn't: they see it once, at full speed, for the first time. **For every moment, ask what the viewer needs to understand and how long that will take them, and time it for that.**
|
||||
|
||||
- **Write the reads.** For each shot, list in order what the viewer has to understand. Each item is a *read*. Every read needs time for the eye to find it, time to understand it, and a moment to register before the next thing starts. Small, distant, fast or subtle things take longer to find and understand than big, central, obvious ones.
|
||||
- **One read at a time.** Don't start a new read while the viewer is still taking in the last one. When two things happen at once, the viewer sees only one of them. Put a cause and its reaction in sequence, not on top of each other.
|
||||
- **Fast actions, slow meanings.** A motion can be very quick if it's anticipated, but what it means needs held time. Anticipation tells the viewer where to look before the action, and the hold after it lets them understand it. Move quickly through what doesn't matter to the story, and spend time on what does. That contrast between quick and held is what gives a film rhythm; one constant speed, fast or slow, makes it flat and hard to follow.
|
||||
- **Lead the eye.** The viewer looks at whatever moves, is bright, is big or is being looked at. Before an important read, get their eye to the right place (a character looks at it, the camera moves to it, it moves or lights up first), and give the eye time to get there.
|
||||
- **Let the reads set the length.** A shot is as long as its reads need. A shot with many reads can't be short, and a shot whose reads have all landed shouldn't be padded. That includes the last shot: its final read needs time to land before the video ends.
|
||||
|
||||
For a worked example, see how the demo times its ending, at the end of this guide.
|
||||
|
||||
### 5. Alive
|
||||
|
||||
- **Nothing is ever still.** Every emotion has its own idle motion (`feel()`), cameras drift or push, grass sways, stars twinkle and the linework boils. A frozen frame reads as a bug.
|
||||
- **Faces act, they never snap.** Change moods with `emotions()`. It does anticipation, a squint, a take and overshoot around every change. Never swap `eyes`/`mouth` by hand between two frames.
|
||||
- **Move like a cartoon, not a machine.** Every move follows the animation principles in the next section.
|
||||
- **Clawd is big.** In a medium shot, `u` is about 20–28 (Clawd is 10u wide, 8u tall). In a close-up it's 40–70. Tiny Clawds (u < 12) are for wide establishing shots only, and never for the whole video.
|
||||
- **Everything moves on a beat.** `PROJECT.bpm` drives every idle, bounce and dance, so the whole film shares one pulse. Put the hits on beats (`pulse()`, `beatN()`), even with no music.
|
||||
|
||||
### 6. Transitions always
|
||||
|
||||
- **Every seam gets a transition:** into the first shot, between every pair of shots and out of the last one. Never start on a hard frame, and never just stop.
|
||||
- **Pick a transition that belongs to the story**, and don't default to the same one every time. Some options:
|
||||
- a brush wipe (`brushWipe`)
|
||||
- an iris or shaped iris (`iris`, `irisShape`)
|
||||
- a whip pan with a smear
|
||||
- a match cut (the same shape or motion across the cut)
|
||||
- a cut on action (cut mid-move, and finish the move in the next shot)
|
||||
- a camera move that carries through into the next shot
|
||||
- a fade or push from paper or black
|
||||
- A plain cut is fine only when it's on action or a deliberate smash cut.
|
||||
- **Changes inside a shot are transitions too:** emotions go through `emotions()` and turns go through `turn()`. Props arrive and leave on arcs, never popping in.
|
||||
|
||||
### 7. One piece: a vision before any code
|
||||
|
||||
- **Storyboard first**, in writing, before you write any scene code (the workflow below has the format). If you're working with a person, show them the storyboard and let them react before you build.
|
||||
- **One world.** Pick a palette and a setting that carries through, with a colour arc across the video (e.g. cold night → warm dawn as Clawd's mood lifts).
|
||||
- **One thread.** The story has a beginning, a middle and an end, and Clawd's emotional arc follows it. Plan the emotion keys across the whole video, not per shot.
|
||||
- **Rhyme the ending with the opening:** the same place, pose or motif, changed. It makes the film feel whole.
|
||||
- **Link scenes:** motion continues across cuts, and screen direction stays consistent (if Clawd travels right, keep travelling right). Props and characters carry over.
|
||||
|
||||
---
|
||||
|
||||
## Animation principles
|
||||
|
||||
These are the classic principles of character animation, as they apply here. Most of them fix one problem: motion written as code comes out mechanical, because code moves every part at once, on the same curve, by the same amount.
|
||||
|
||||
- **Anticipation.** Before a big move, make a small move the opposite way: a crouch before a jump, a wind-up before a throw, a squint before a take. It tells the viewer something is coming and where to look. `jump()` and `emotions()` build it in.
|
||||
- **Squash and stretch.** Bodies squash on impact and stretch when they move fast, keeping their volume (`sq`).
|
||||
- **Slow in, slow out.** Almost nothing moves at a constant speed. Things ease out of one pose and into the next. A plain `lerp` over time looks mechanical, so run its progress through an easing (`ease`, `easeIn`, `easeOut`, `backOut`).
|
||||
- **Weight.** How something starts and stops says what it weighs. Heavy things take longer to get going and to stop, and land with little bounce. Light things snap into motion, bounce and flutter to rest.
|
||||
- **Arcs.** Living things move on arcs, not straight lines: thrown props, hops, arm swings, head turns (`arcPt`).
|
||||
- **Overlapping action and follow-through.** Don't move every part at once. The eyes lead, the body follows, and arms, hats, props and tails drag behind, overshoot and settle last. Offset each part's timing a little from the one it hangs off (`spring`, `ring` and `backOut` for the settle).
|
||||
- **Avoid twinning.** Code copies values, so both arms end up at the same angle, both eyes blink together and a crowd bounces in unison. Give one arm the action and the other something smaller, and offset timings, phases and `seed`s between characters.
|
||||
- **Exaggeration.** Push poses, takes, squash and leans further than feels natural. In a short cartoon, subtle reads as nothing. If it looks like too much on the sheet, pull it back.
|
||||
- **Strong key poses.** Each shot's storytelling poses should read as stills, with a clear silhouette and the body leaning into what it's doing, before any motion goes between them. If the key poses don't read, the motion won't fix it.
|
||||
- **Show the thought.** A character notices, thinks, then acts, and the eyes move first. The viewer understands a choice when they see it being made.
|
||||
- **Secondary action.** Small actions that support the main one (a hat bobbing, an emote popping, grass stirring) add life, but they never compete with it.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Storyboard
|
||||
|
||||
Write `STORYBOARD.md` before any scene code:
|
||||
|
||||
```
|
||||
Logline: one sentence. Clawd wants ___, but ___, so ___.
|
||||
World: setting, a small palette, light, how the colour changes across the video.
|
||||
Motif: the thing that recurs and pays off.
|
||||
Clawd's arc: the emotion keys across the whole video.
|
||||
Shots:
|
||||
A start–end [transition in: ___] what's seen · the EVENT · Clawd's reaction · camera
|
||||
reads: start–end the first thing the viewer must understand
|
||||
start–end the next one (where is the viewer's eye when it starts?)
|
||||
...
|
||||
B start–end [transition: ___] ...
|
||||
...
|
||||
[transition out: ___]
|
||||
```
|
||||
|
||||
The reads are the timing sheet. Give each one a start and an end, make sure each has time to be found and understood, and make sure no two important reads overlap. If a shot's reads don't fit its length, lengthen the shot or cut a read; don't squeeze them.
|
||||
|
||||
Check the storyboard against the rules:
|
||||
- Is there an event in every shot?
|
||||
- Does every read have time to land before the next one starts?
|
||||
- Is there a transition at every seam?
|
||||
- Is there any text anywhere?
|
||||
- Does the ending rhyme with the opening?
|
||||
|
||||
### 2. Build
|
||||
|
||||
- Set `duration` (and `bpm`) in [src/config.js](src/config.js).
|
||||
- Put your scene in a new file (e.g. `src/scenes/my_video.js`), wrapped in an IIFE, and end it with `shots([...])`. In [studio.html](studio.html), **replace** the `demo.js` script tag with yours.
|
||||
- Build and check one shot at a time, in order.
|
||||
- Within a shot, block the key poses first and check them as stills (`--sheet` at the key times). Add the motion between them once they read.
|
||||
|
||||
```js
|
||||
// src/scenes/my_video.js
|
||||
(() => {
|
||||
function park(t, lt, dur) { // t = video time, lt = time in this shot, dur = shot length
|
||||
camBegin(960 + 20 * Math.sin(lt * .6), 540, 1 + .02 * lt); // slow drift and push: the camera is never dead
|
||||
paint(rectPts(-200, -200, W + 400, H + 400), { wash: PAL.sky, ink: null }); // background
|
||||
paint(ellPts(960, 1150, 1400, 380, 40, 2), { wash: PAL.sap, ink: PAL.ink, sw: 1 }); // ground
|
||||
const mood = emotions(lt, [[0, 'bored'], [1.2, 'surprised'], [1.7, 'excited']]); // acted changes
|
||||
const hop = jump(lt, 2.2, 2.7, 3); // add poses that share fields
|
||||
clawd(960, 860, 26, { ...mood, dy: mood.dy + hop.dy, sq: mood.sq + hop.sq });
|
||||
const at = toScreen(960, 860 - 4 * 26); // Clawd's screen position, for the iris
|
||||
camEnd();
|
||||
if (lt < .45) iris(...at, lerp(0, 1500, easeIn(lt / .45))); // transition in
|
||||
if (lt > dur - .3) brushWipe((lt - (dur - .3)) / .6); // transition out (next shot finishes it)
|
||||
}
|
||||
shots([[0, park] /*, [3.5, nextShot], ... */]);
|
||||
})();
|
||||
```
|
||||
|
||||
### 3. Look at it: the review loop
|
||||
|
||||
You can't see motion by reading code. Render and look at every shot, several times, at three zoom levels:
|
||||
|
||||
```bash
|
||||
# contact sheet: the shape of the whole piece (every shot's first, middle and last frames)
|
||||
node render.mjs --sheet=0.1,0.8,1.6,2.4,3.1,3.9 --cols=6 --w=320 --out=out/check/sheet.jpg
|
||||
# strip: EVERY frame of a moment (turns, takes, jumps, throws, transitions)
|
||||
node render.mjs --strip=2.1:2.6 --cols=6 --w=320 --out=out/check/strip.jpg
|
||||
# crop: full-resolution detail (faces, hands, contacts, glows); crop=x,y,w,h in frame pixels
|
||||
node render.mjs --sheet=2.3,2.4 --crop=760,420,500,400 --w=500 --out=out/check/face.jpg
|
||||
# crop-at: the same, following a WORLD point through each frame's camera (a foot or a prop on a moving shot);
|
||||
# x,y in world pixels (or an expression evaluated in the page), w,h in frame pixels
|
||||
node render.mjs --strip=2.1:2.6 --crop-at=960,700,500,400 --out=out/check/feet.jpg
|
||||
```
|
||||
|
||||
Open each image and actually look at it. Check:
|
||||
|
||||
- **Read:** is the event of each shot clear from its sheet alone? Is Clawd big enough, and does Clawd separate from the background?
|
||||
- **Timing.** You can't judge timing from single frames, so read it like a viewer:
|
||||
- Render the shot as a sheet at a fixed step (every 0.1–0.15 s) and read it in order.
|
||||
- At each frame ask: where is the viewer looking right now, and do they understand it yet?
|
||||
- Count the frames each read gets (24 frames = 1 s). A read that flashes by in a few frames, or shares its frames with another read, will be missed.
|
||||
- After each important moment, is there time to take it in before the next thing starts?
|
||||
- **Motion:** in strips, does every move have anticipation and follow-through? Are there any pops, jumps or snaps between frames? Do the parts move at different times, or all at once? Is anything moving at a constant speed, or mirrored left and right? Are the poses pushed far enough to read?
|
||||
- **Boil:** in a strip, each pair of frames that share a boil drawing should match except where something moves. Anything still that changes every frame needs its own `boilSeed()`.
|
||||
- **Contacts:** do feet touch the ground? Do held things touch the arm tips? Do thrown things leave from the hand?
|
||||
- **Transitions:** check the first and last 0.5 s of every shot and every seam. Does it open and close with a transition?
|
||||
- **Rules:** is there any text? Is there any 3D? Is there any dead stretch where nothing is happening?
|
||||
- **Colour:** any muddy glows (use `glow()`), pure black or pure white?
|
||||
|
||||
Fix what you find, then look again. **Budget:** at least one sheet per shot, a strip for every key motion and transition, and a crop for every face that carries the story. Contact sheets run about 0.1–1 s per frame, so this is cheap: don't skip it.
|
||||
|
||||
### 4. Render
|
||||
|
||||
```bash
|
||||
node render.mjs --clip --out=out/video.mp4 # the whole video, straight to MP4
|
||||
node render.mjs --frames --workers=4 # or: parallel + resumable JPEG frames into out/frames …
|
||||
node render.mjs --encode --out=out/video.mp4 # … then encode them
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Engine
|
||||
|
||||
### Files
|
||||
|
||||
| file | what's in it |
|
||||
|---|---|
|
||||
| `src/config.js` | `PROJECT = { duration, bpm, offset, audio? }` |
|
||||
| `src/core.js` | canvas, palette, timing and motion helpers, `paint()`, camera, full-frame effects, `glow()`, lettering, paper, render hooks |
|
||||
| `src/clawd.js` | Clawd: views, emotions, eyes, mouths, hats, emotes, moves |
|
||||
| `src/timeline.js` | `shots()`, `LOOPS`, `brushWipe()` |
|
||||
| `src/sheets.js` | the model sheets as loops (`?loop=emotions`, `?loop=views`) |
|
||||
| `src/scenes/demo.js` | an 11-second example. **Don't copy it** (see the end of this guide) |
|
||||
| `studio.html` | open it in Chrome to scrub the video (`?t=2.5` jumps to a time, `?loop=emotions` shows a loop) |
|
||||
| `render.mjs` | headless renderer: sheets, strips, crops, stills, PNG loops, MP4 |
|
||||
|
||||
### Frames are pure functions of time
|
||||
|
||||
- **Frames render in parallel and out of order.** A shot is `fn(t, lt, dur)` and must draw the same frame for the same `t`, every time. No state carried between frames, no counters, no `Math.random()`, no physics that integrates frame by frame. Compute everything from `t`, in closed form (the helpers below do this for you).
|
||||
- Randomness: `hash(i)` for stable per-object values, and `jit(a)`/`random()` for boil (they change 12 times a second).
|
||||
- **Seed each element with `boilSeed(key)`.** Each boil drawing holds for two frames, so anything that isn't moving must draw the same in both. But a moving thing uses a different amount of randomness each frame, which shifts the stream for everything drawn after it, and all of that re-boils every frame and looks jittery.
|
||||
- `boilSeed(key)` restarts the stream from the boil frame and a key that stays the same every frame (any string or number, unique within the frame).
|
||||
- Call it before each separate element: each background layer, prop and effect.
|
||||
- `clawd()` seeds itself and each of its parts, then reseeds when it's done, so nothing drawn after it depends on its pose. Its key is its call order; set `boilKey` if characters come and go mid-shot.
|
||||
- **Each shot paints the whole frame,** background included. The paper texture is under everything and the grain is multiplied over the top, so leaving paper showing is a valid look.
|
||||
- **Canvas:** 1920×1080, origin top-left, y down.
|
||||
- `LOOPS.name = t => {...}; LOOPS.name.len = 4;` makes a standalone loop (tests, GIFs, sheets), rendered with `--loop=name`.
|
||||
|
||||
### Painting
|
||||
|
||||
`paint(pts, o)` paints one shape from a point list `[[x, y], ...]`:
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
| `wash, washOp` | flat colour (opacity 0–255, default 255). For characters, props, anything solid. |
|
||||
| `fill, fillOp, bleed, tex, border` | watercolour fill with bleeding edges and pigment texture. For skies, hills, shading and shadows. `bleed` ~.05–.3, `tex` ~.3–.9. |
|
||||
| `hatch: { d, a, o, b, c, w }` | hatching (distance, angle, `{rand, gradient}`, brush e.g. `'charcoal'`/`'HB'`, colour, weight). Texture, sparingly. |
|
||||
| `ink, sw, br` | outline colour (default `PAL.ink`), weight (~.4–2), brush. **`ink: null` means no outline.** |
|
||||
| `curv` | 0–1: smooth the outline through the points. |
|
||||
|
||||
- **Lines:** `inkLine(pts, sw, colour, brush = 'ink', curvature)`. The kit's brushes are `'ink'`, `'inkfine'` and `'dry'` (bristly). p5.brush's built-ins also work: `'2B'`, `'HB'`, `'charcoal'`, `'marker'`, `'pen'`, `'cpencil'`, `'rotring'`, `'spray'`.
|
||||
- **Shapes:**
|
||||
- `rectPts(x, y, w, h, jitter)`
|
||||
- `ellPts(cx, cy, rx, ry, n, jitter, rot)`
|
||||
- `rrPts(x, y, w, h, r, jitter)`: a rounded rectangle
|
||||
- `starPts(cx, cy, r, inner, n, rot)`
|
||||
- `heartPts(cx, cy, r)`
|
||||
- `through(P)`: a smooth curve through the points
|
||||
- `ribbon(P, w0, w1)`: a tapered ribbon along a path, as one outline. Use it for tails, trails, vines and noodly arms.
|
||||
- **One shape, one outline.** Build a creature or prop from as few outlines as you can, so it doesn't look like glued-on stickers: a tail is one `ribbon`, not five circles.
|
||||
- **Light:** `glow(x, y, r, colour, a)`. It's additive, so it stays warm on dark grounds and barely shows on light ones (as real light would). Draw it before the things that sit in front of the light.
|
||||
- **Palette** `PAL`: `paper, ink, clay, clayDk, clayLt, night, indigo, rose, ochre, sap, teal, violet, cream, sky`. `mixCol(a, b, k)` mixes two hex colours in RGB. Any hex colour works: pick a small palette per video.
|
||||
- **p5.brush quirks:**
|
||||
- Colours mix like pigment: yellow over blue makes green. Layer light colours over dark ones with a full-opacity `wash`, or use `glow()`.
|
||||
- `wash` at 255 is exact colour; lower opacities mix.
|
||||
- Strokes drawn far from the origin under a zoomed camera collapse: from zoom ~2, an outline or a line at large
|
||||
world coordinates leaves only a dot at its first vertex. `paint()` and `inkLine()` draw each shape around its own
|
||||
centre, which avoids it. A shape much bigger than the canvas can still lose its outline: draw long edges as
|
||||
`inkLine`s no bigger than the canvas.
|
||||
- Outline weight is in world units, so it grows with the camera's zoom: a fine outline on a small shape becomes a
|
||||
dark blob in a close-up. Scale `sw` down with the zoom for small shapes.
|
||||
- A NaN in a point list throws `Failed to construct 'OffscreenCanvas': Value is not of type 'unsigned long'`, with
|
||||
a stack pointing at your scene rather than the NaN. Guard geometry that can degenerate (`Math.acos` of a ratio > 1).
|
||||
- p5 `push()/pop()/translate()/rotate()/scale()` work with all brush calls.
|
||||
- Cost is the number of `fill` shapes and strokes: hundreds are fine, thousands are not. Aim for ≤ 1.5 s per frame. The render log prints ms/frame.
|
||||
- Some scenes make p5.brush log five `WebGL: INVALID_OPERATION ... not from the associated program` warnings once per page. They're harmless (frames come out identical). Any other page error is real.
|
||||
|
||||
### Time and motion (all pure functions of t)
|
||||
|
||||
- **Progress and keys:**
|
||||
- `seg(t, a, b)`: 0..1 progress through [a, b]
|
||||
- `kf(t, [[t0, v0], [t1, v1], ...], ease)`: keyframes; values may be arrays
|
||||
- `lerp`, `clamp`, `frac`, `wob(t, freq, phase)`, `TAU`
|
||||
- **Easing:** `ease`, `easeIn`, `easeOut`, `backOut` (overshoot) and `elasticOut`.
|
||||
- **Rhythm:** `BEAT` (seconds per beat), `bpOf(t)` (beat position), `beatN(t)` (beat number), and `pulse(t, k)` / `pulse2(t, k)`, which are 1 on each beat (or eighth) and then decay.
|
||||
- **Acting:**
|
||||
- `jump(t, t0, t1, h)`: crouch, stretch, arc and squash-land. Returns `{dy, sq}`.
|
||||
- `take(t, t0, amt)`: a surprise take, returns `{sq, dy}`.
|
||||
- `stroll(t, t0, t1, x0, x1, u)`: an eased walk. Returns `{x, walk, view, flip, dy}`.
|
||||
- `spring(t, t0, k, w)`: a damped wobble after an event, for settles and follow-through.
|
||||
- `ring(t, [t0, t1, ...])`: one `spring` kick per event time.
|
||||
- `arcPt(p0, p1, h, k)`: a point on a thrown arc.
|
||||
- `onTwos(t)`: holds each drawing for two frames. Wrap a shot's `t` in it for a snappier, hand-drawn feel.
|
||||
- **Camera:**
|
||||
- `camBegin(cx, cy, zoom, rot)` … `camEnd()`: world point (cx, cy) lands at screen centre. One level only; always pair them.
|
||||
- `toScreen(x, y)`: world → screen while a camera is active. Use it to aim an iris at a character.
|
||||
- `shakeXY(t, amount)`: [dx, dy] to add to the camera on impacts.
|
||||
- **Full-frame effects** (screen space, after `camEnd()`):
|
||||
- `brushWipe(p, [c1, c2])`: fat strokes cover the frame (p 0 → .5) and then drag off (.5 → 1). Cut under full cover. Its comment in timeline.js shows the two calls.
|
||||
- `iris(cx, cy, r, colour)` and `irisShape(pts, colour)`: shaped reveals.
|
||||
- `flash(k, colour)`: a full-frame flash.
|
||||
- **Lettering** (only if you must, see "No text"):
|
||||
- `letter(txt, x, y, size, colour, {pop, rot, alpha, screen})`
|
||||
- `sfx(txt, x, y, size, colour, age)`
|
||||
- Both are composited at `flushLetters()`, after the shot. If a wipe or iris must cover them, call it yourself first.
|
||||
|
||||
---
|
||||
|
||||
## Clawd
|
||||
|
||||
Clawd is the Claude Code mascot: a terracotta block (`PAL.clay`), 10 units wide and 6 tall, with four stubby legs, two little arm nubs and two tall slit eyes. There's no mouth at rest. It's drawn in flat wash with an ink outline and boils like everything else. That's the default design, not a rule: change it if the idea needs it. Within one video, though, keep every character on model, with the same shape and features in every shot.
|
||||
|
||||
```js
|
||||
clawd(x, y, u, options) // (x, y) = ground point between the feet; u = size unit
|
||||
```
|
||||
|
||||
**Sizes:** Clawd is 10u × 8u (legs included).
|
||||
|
||||
| shot | u | on screen |
|
||||
|---|---|---|
|
||||
| wide | 10–16 | small in the landscape |
|
||||
| medium | 20–28 | the usual acting size |
|
||||
| close-up | 40–70 | face acting |
|
||||
| extreme close-up | 90+ | camera pushed in on the eyes |
|
||||
|
||||
### Options
|
||||
|
||||
| group | options |
|
||||
|---|---|
|
||||
| pose | `dx`, `dy` (in u; −dy = up), `sq` (squash; negative stretches), `rot` (pivots at the feet), `flip`, `sx`, `sy`, `aL`, `aR` (arm angle: 0 = straight out, + = up, − = down; ±1.5 is vertical), `walk` (leg phase), `noLegs`, `noShadow` |
|
||||
| view | `view`: front, q, side, qback, back. `smear` 0..1 + `smearDir` ±1 (0 = both sides) for fast moves |
|
||||
| face | `eyes`, `mouth`, `lookX`/`lookY` (−1..1), `squint` 0..1, `blush` 0..1, `gloom` 0..1, `lid` 0..1, `seed` (blink timing) |
|
||||
| colour | `tint` (pale, flush, blue, rosy, green, gold, or any hex) + `tintK`, or `col`/`dk`/`lt` directly |
|
||||
| extras | `hat`, `emote` + `emoteK` (0..1 pop) + `emoteAge`, `draw(u, sw)`, `armL(u, sw)`, `armR(u, sw)` |
|
||||
| boil | `boilKey`: a stable id for its boil seeds (default: call order) |
|
||||
|
||||
Options compose by spreading: `clawd(x, y, u, { ...feel('happy', t), ...turn(t, 1, 1.15, 0, .25), hat: 'party' })`. Later spreads win, so put the emotion first and the pose after it. If both an emotion and a pose move the same field (`dy`, `sq`), add them together rather than letting one silently replace the other.
|
||||
|
||||
### Views and turns
|
||||
|
||||
These are drawn key views. Every view faces screen-right; add `flip: true` to face left.
|
||||
|
||||
| view | what you see |
|
||||
|---|---|
|
||||
| `front` | the face, both arms, four legs |
|
||||
| `q` | 3/4: the face shifted toward the heading, the side face as a darker strip behind it |
|
||||
| `side` | profile: one eye near the leading edge, one arm |
|
||||
| `qback` | 3/4 from behind: no face |
|
||||
| `back` | no face |
|
||||
|
||||
- **Turn with `turn(t, t0, t1, a0, a1)`.** Headings are in turns: 0 = front, .25 = facing right, .5 = back, −.25 = facing left. It steps through the key views over 0.12–0.25 s with smears, like a drawn turn. For a single heading, use `spinView(a)`.
|
||||
- **Walks across the screen** read best in `side` view with `walk` driving the legs. `stroll()` gives `q`; override it with `view: 'side'` for a trot.
|
||||
- **Facing right, `aL` is the near arm** (drawn in front of the body), and the far arm is behind and darker.
|
||||
|
||||
### Emotions
|
||||
|
||||
31 emotions. Each is a face, a colour and a way of moving, all locked to the beat. `feel(name, t, over)` returns all of it at time t:
|
||||
|
||||
```js
|
||||
clawd(x, y, u, feel('happy', t)); // one emotion, alive
|
||||
clawd(x, y, u, feel('sad', t, { view: 'q', lookX: -1 })); // with overrides
|
||||
clawd(x, y, u, emotions(t, [[0, 'sleepy'], [1.9, 'surprised', { lookX: .8 }], [2.5, 'idea']])); // acted changes
|
||||
```
|
||||
|
||||
| family | emotions |
|
||||
|---|---|
|
||||
| joy | happy, excited, laugh, love, proud, starstruck, playful, hopeful, relieved |
|
||||
| sly | smug, cool, mischief, suspicious |
|
||||
| low | sad, cry, bored, sleepy, ko |
|
||||
| hot | angry, furious (the lunchbox lid opens), determined, disgusted |
|
||||
| alarm | scared, nervous, surprised, confused, dizzy |
|
||||
| mind | neutral, thinking, idea, shy |
|
||||
|
||||
- **`emotions(t, keys)`** is how moods change in a shot. Around each key it squints and squashes just before the change (anticipation), swaps the face under the squint, and fires a take sized to the new emotion. The body then settles into the new motion with overshoot, colour cross-fades and the new emote pops in.
|
||||
- `emotions(t, keys, { take: .5 })` scales every take.
|
||||
- The third element of a key overrides fields for that stretch (e.g. `{ lookX: .8, emote: 'music' }`).
|
||||
- To make a new emotion, add an entry to `EMO` in clawd.js. It needs eyes, mouth, optional tint/blush/gloom/lid/emote, `take` (reaction size) and `body(t)` (its idle motion, locked to the beat through `_b(t)`).
|
||||
|
||||
### Parts
|
||||
|
||||
- **Eyes:** normal, look, wide, happy, closed, sleepy, wink, narrow, angry, determined, sad, teary, cry, squeeze, shine, scared, blank, spark, red, heart, x, swirl, dot, shades.
|
||||
- A pair gives mismatched eyes, e.g. `['narrow', 'wide']`.
|
||||
- `lookX`/`lookY` aim the pupils, and `squint` closes the eyes from any shape.
|
||||
- **Mouths:** o, O, smile, grin, flat, wobble, cat, frown, smirk, laugh, open, wail, teeth, tongue, pout, yawn. Use `null` for none (Clawd's resting face).
|
||||
- **Lunchbox lid:** `lid` 0..1 hinges the top of the body open, with teeth pointing into the mouth. It's for fury, chomping and shouting, in the front view only.
|
||||
- **Hats:** party, hard, crown, halo, wizard, hood, top, fedora, band, sweatband, beanie, bow, flower, headphones, cat (ears and whiskers). Face pieces: masq, mask, bowtie.
|
||||
- **Emotes** are painted marks that pop in by the head: `!` `?` `!!` `!?` zzz, sweat, spark, heart, hearts, anger, steam, bulb, dots, scribble, music, swirl, stars, cloud.
|
||||
- `emoteK` is the 0..1 pop and `emoteAge` drives the looping ones; `emotions()` sets both.
|
||||
- `emote(kind, x, y, s, k, age)` draws one anywhere, for example over a prop.
|
||||
- **Body colour:**
|
||||
- `tint` shifts it with the mood: pale (fear), flush (anger), blue (sadness), rosy (love), green (disgust), gold (pride).
|
||||
- `gloom` adds a dark forehead with hanging lines, and `blush` adds cheeks (hatched when above .6).
|
||||
|
||||
### Hooks: props and accessories
|
||||
|
||||
- **`draw(u, sw)`** paints in body-local space, on top of everything else on the body (face, hat, near arm). It squashes, flips and rotates with Clawd. Front-view coordinates:
|
||||
- the body spans x −5u..5u and y −8u..−2u
|
||||
- the eyes are at (±2.5u, −6u)
|
||||
- the mouth is near (0, −4.3u)
|
||||
- **`armL(u, sw)` / `armR(u, sw)`** are called at the arm tip, in arm space (+x runs outward along the arm), so a held prop just draws around (0, 0) and follows the arm automatically:
|
||||
|
||||
```js
|
||||
clawd(x, y, 24, { ...feel('proud', t), aR: 1.2, armR: (u, sw) => paint(starPts(u * .8, 0, u * 1.4, .5, 5), { wash: '#FFE27A', sw }) });
|
||||
```
|
||||
|
||||
- Anything the arms can't carry, like a big object overhead, is drawn separately at a point computed from the same pose. The demo's star does this. Make sure it touches: check it with a crop.
|
||||
|
||||
### Dances
|
||||
|
||||
`move(style, t, seed)` returns beat-locked pose offsets. The styles are bounce, hop, roof (arms up), sway, spin (a drawn spin through the key views once a bar), wave, walk, run, idle, stomp and shimmy. `mix` changes style every two bars. `dancer(x, y, u, style, t, extra)` is `clawd` + `move`. You can combine a dance with a face: `{ ...move('bounce', t), eyes: 'happy', mouth: 'grin' }`.
|
||||
|
||||
---
|
||||
|
||||
## Music (optional)
|
||||
|
||||
The kit doesn't need music, but it's built for it:
|
||||
|
||||
1. Set `bpm` to the song's tempo in [src/config.js](src/config.js), and set `offset` to the time of its first downbeat in seconds. Every idle, dance and `pulse()` then locks to the song.
|
||||
2. Put the audio in `assets/` and set `PROJECT.audio` (or pass `--audio=`). `--clip` and `--encode` mux it in.
|
||||
3. Land hits, cuts and takes on beats (`beatN`, `pulse`). Cut on bar lines for big changes, and give each musical phrase its own visual.
|
||||
4. **Lyrics are not text.** Don't put words on screen. Act the meaning of a line instead.
|
||||
|
||||
## Common failures
|
||||
|
||||
These are the things that make a Clawd video look generated. Check your storyboard and sheets against them:
|
||||
|
||||
- signs, captions, labels or speech bubbles with words
|
||||
- Clawd standing still and smiling while nothing happens
|
||||
- everything moving at one brisk speed, with events stacked on top of each other and no holds
|
||||
- moments that are over before the viewer understands them
|
||||
- a tiny Clawd in a big empty landscape for the whole video
|
||||
- faces that snap from one expression to another
|
||||
- mechanical motion: linear moves, every part moving at once, both arms or several characters in sync
|
||||
- timid poses and takes that barely read
|
||||
- jittery linework: still things re-boiling every frame because something moving before them shifted the random stream (`boilSeed`)
|
||||
- hard cuts everywhere, or a video that just starts and stops
|
||||
- 3D rotation, perspective boxes or projected turns
|
||||
- plain p5 shapes, gradients or digital glows mixed into the paint (use `paint`/`inkLine`/`glow`)
|
||||
- muddy green-grey glows from painting yellow over blue
|
||||
- props floating near a hand instead of touching it
|
||||
- every shot a different world with nothing linking them
|
||||
|
||||
## About the demo
|
||||
|
||||
[src/scenes/demo.js](src/scenes/demo.js) ("The fallen star") exists to show the kit working: an acted emotion timeline, a drawn turn, a trot, a pickup, a throw on an arc, `glow`, a brush wipe and an iris in and out. **It's one idea, not a template.** Don't reuse its story, night sky, hills, star or shot structure. Start from the prompt and your own storyboard, and replace its script tag in `studio.html` with yours.
|
||||
|
||||
### Worked example: how the demo times its ending
|
||||
|
||||
This shows rule 4 applied to one shot of one video. The reads, the numbers and the way it ends are specific to this story; yours will be different. Read it for the reasoning, not the numbers. It's the demo's last shot, from the throw on, in video time:
|
||||
|
||||
| time | read | why it's timed this way |
|
||||
|---|---|---|
|
||||
| 6.95–7.1 | wind-up and throw | fast motion; it reads because the wind-up anticipates it |
|
||||
| 7.1–7.9 | the star flies home | long enough for the eye to follow it from Clawd to the top of the frame; the camera eases back to give it sky |
|
||||
| 7.9–8.35 | it arrives: a flare, a sparkle, the sky twinkles | the payoff. Clawd only watches, so nothing competes with it |
|
||||
| 8.35–8.95 | Clawd falls in love | the reaction starts only after the viewer has seen the cause |
|
||||
| 8.95–9.4 | Clawd waves goodbye | a new action, on its own |
|
||||
| 9.4–9.8 | the star twinkles back | the answer to the wave |
|
||||
| 9.8–11.0 | iris to Clawd, hold, shut | the last read gets time to land before the video ends |
|
||||
|
||||
The first version packed all of this into about 1.3 s, and nobody could tell what had happened.
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 John Heibel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,41 +0,0 @@
|
||||
# Claude Animation Base
|
||||
|
||||
This is a small starter kit with code, instructions and assets for animating a character in [p5.js](https://p5js.org) and [p5.brush](https://github.com/acamposuribe/p5.brush) with Claude Opus 5.5. It's based on the code from the music video [I'm Upping My P(doom)](https://github.com/JohnHeibel/PDoomVideo) and an analysis of what the model did and didn't do well. I highly recommend playing around with your prompting: make it give you the storyboard before coding, give it very broad instructions, try being very specific, ask for subagents, and try a bunch of other fun ways of testing the model's capabilities. In my testing, it can do a lot with very little, but it's also quite accurate when you give it more requirements. Also try asking the model to swap out the character or make new emotions or costumes, give it your own reference images, and try many other fun things like that. I've found that the reasoning level corresponds to how "extravagant" and detail-oriented the model makes the scene. All test videos were generated with Opus 5.5 on xhigh reasoning in Claude Code.
|
||||
|
||||

|
||||
|
||||
## Make a video
|
||||
|
||||
Clone it, open it in Claude Code (or any coding agent) and ask for what you want:
|
||||
|
||||
> Read ANIMATION_GUIDE.md, then make a 15-second video of Clawd trying to catch a butterfly.
|
||||
|
||||
The model storyboards first, builds shot by shot, renders contact sheets to check its own work, and writes `out/video.mp4`. [ANIMATION_GUIDE.md](ANIMATION_GUIDE.md) holds the rules it follows: handmade, alive, one piece, no text, transitions always, something happens in every scene, and a solid medium of brush strokes, flat 2D and boiling linework. It also covers timing for the viewer and the core principles of character animation.
|
||||
|
||||
## Run it yourself
|
||||
|
||||
You need Node.js, Google Chrome and ffmpeg.
|
||||
|
||||
Without a dedicated GPU, p5.brush's watercolour fills make render times fairly slow, measured in seconds per frame. If you're running on integrated graphics, I recommend asking the model to avoid those fills and replace them with something else appropriate. (I love the look of the watercolours, though.)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
node render.mjs --clip --out=out/video.mp4
|
||||
```
|
||||
|
||||
That renders the 11-second demo in [src/scenes/demo.js](src/scenes/demo.js). Open [studio.html](studio.html) in Chrome to scrub through it. Add `?loop=emotions` or `?loop=views` to see the model sheets. If Chrome isn't in a standard location, pass `--chrome=<path>` or set `CHROME_PATH`.
|
||||
|
||||
On Linux, `render.mjs` starts Chrome with `--no-sandbox` (Ubuntu 23.10+ blocks Chrome's sandbox in headless use) and also finds a Chromium installed by Playwright. With no GPU at all, add `--soft-gl` to render WebGL in software: slow on watercolour fills, but it works. On a headless Linux machine with an NVIDIA GPU (a cloud or cluster node), add `--gpu-angle=gl-egl` (or `vulkan`); `node gpu_probe.mjs <chrome path>` shows which renderer each set of flags gets.
|
||||
|
||||
## What's here
|
||||
|
||||
| path | what it is |
|
||||
|---|---|
|
||||
| [ANIMATION_GUIDE.md](ANIMATION_GUIDE.md) | The rules, the workflow and the full API. Read it first. |
|
||||
| [src/clawd.js](src/clawd.js) | Clawd: views, emotions, eyes, mouths, hats, emotes, dances |
|
||||
| [src/core.js](src/core.js) | Painting, timing, motion helpers, camera, light, paper |
|
||||
| [src/timeline.js](src/timeline.js) | Shots, loops and the brush-wipe transition |
|
||||
| [src/config.js](src/config.js) | Length and tempo |
|
||||
| [src/scenes/](src/scenes/) | Your video goes here (the demo is an example) |
|
||||
| [render.mjs](render.mjs) | Headless renderer: contact sheets, frame strips, crops, stills, MP4 |
|
||||
| [docs/](docs/) | Model sheets: [emotions](docs/emotions.jpg) (also [animated](docs/emotions.webp)) and [views, motion and hats](docs/views.jpg) |
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 545 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 480 KiB |
@@ -1,30 +0,0 @@
|
||||
// Which GPU does headless Chrome's WebGL land on? Prints the unmasked renderer for each flag set.
|
||||
// node gpu_probe.mjs <chrome path>
|
||||
import puppeteer from 'puppeteer-core';
|
||||
|
||||
const chrome = process.argv[2];
|
||||
const base = ['--no-sandbox', '--ignore-gpu-blocklist', '--enable-gpu-rasterization'];
|
||||
const candidates = {
|
||||
'angle-vulkan': ['--use-angle=vulkan', '--enable-features=Vulkan'],
|
||||
'gl-egl': ['--use-gl=egl'],
|
||||
'angle-gl-egl': ['--use-angle=gl-egl'],
|
||||
'kit-default': ['--use-gl=angle'],
|
||||
};
|
||||
for (const [name, flags] of Object.entries(candidates)) {
|
||||
let browser;
|
||||
try {
|
||||
browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: [...base, ...flags] });
|
||||
const page = await browser.newPage();
|
||||
const info = await page.evaluate(() => {
|
||||
const gl = document.createElement('canvas').getContext('webgl2') || document.createElement('canvas').getContext('webgl');
|
||||
if (!gl) return 'no WebGL context';
|
||||
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
|
||||
});
|
||||
console.log(`${name.padEnd(13)} ${info}`);
|
||||
} catch (e) {
|
||||
console.log(`${name.padEnd(13)} FAILED: ${e.message.split('\n')[0]}`);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Copy to .env style settings on your host. Never commit real values.
|
||||
ADMIN_EMAIL=rana.krunal7558@gmail.com
|
||||
ADMIN_PASSWORD=pick-a-long-password-here
|
||||
# Where new tickets are emailed (this is already the default)
|
||||
TICKET_TO=rana.krunal7558@gmail.com
|
||||
# Gmail sending: use an "App Password", not your normal password
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=rana.krunal7558@gmail.com
|
||||
SMTP_PASS=your-16-letter-app-password
|
||||
# Set to 1 when hosted behind a proxy (most hosts)
|
||||
TRUST_PROXY=1
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
server/data
|
||||
.env
|
||||
@@ -0,0 +1,25 @@
|
||||
# Deploy checklist (Hostinger)
|
||||
|
||||
## A. Before you start
|
||||
- [ ] Plan is Business Web Hosting or a Cloud plan (Node.js Web App supported)
|
||||
- [ ] Gmail app password created (myaccount.google.com > Security > 2-Step Verification > App passwords)
|
||||
|
||||
## B. In hPanel: Websites > Add website > Node.js Web App > Import Git repository
|
||||
- [ ] Repo: keithrana/cycle-atlas
|
||||
- [ ] Branch: claude/sleepy-goodall-rykli1 (NOT main)
|
||||
- [ ] Root/app directory: it-support (if no such box, ask Claude to split into its own repo)
|
||||
- [ ] Node version: 22.x or 24.x
|
||||
- [ ] Build command: npm run build
|
||||
- [ ] Start command: npm start (entry file, if asked: server/index.js)
|
||||
|
||||
## C. Environment variables (type them only into Hostinger, never into chat or Git)
|
||||
ADMIN_EMAIL, ADMIN_PASSWORD (10+ chars), SMTP_HOST=smtp.gmail.com, SMTP_PORT=465,
|
||||
SMTP_USER, SMTP_PASS (Gmail app password), TRUST_PROXY=1, DATA_DIR=/home/<your-username>/helpdesk-data
|
||||
|
||||
## D. Deploy, then test in order
|
||||
1. [ ] Page loads over https:// and the video plays
|
||||
2. [ ] Search "wifi" shows answers; send a test ticket
|
||||
3. [ ] Email arrives at rana.krunal7558@gmail.com (check Spam)
|
||||
4. [ ] /#/staff login works and the ticket is listed
|
||||
5. [ ] Add a test FAQ, redeploy, confirm it is still there (proves data is kept)
|
||||
6. [ ] Log out; /#/staff asks for login again
|
||||
@@ -0,0 +1,18 @@
|
||||
# IT Support Site: PRD (plan)
|
||||
|
||||
Restaurant analogy: the FAQ database is the **kitchen's recipe book**. The website is the **dining room**.
|
||||
|
||||
## Core features
|
||||
1. Cinematic hero: looping video, glass buttons (exactly as specified)
|
||||
2. Search bar in the hero: type a problem, get matching answers
|
||||
3. Category chips: browse the 11 topics (Access, Network, Hardware...)
|
||||
4. FAQ answer cards: question, answer, optional "official guide" link
|
||||
5. Footer social icons
|
||||
|
||||
## Phased roadmap
|
||||
- **Phase 1 – Food stand (TODAY):** hero + search + categories + 87 FAQs from the database, baked in as a JSON file. No server.
|
||||
- **Phase 2 – Small restaurant (DONE, browser-only storage):** "Was this helpful?" votes, "Contact IT" ticket form, shareable links per question.
|
||||
- **Phase 3 – Full restaurant (DONE):** small back-of-house server + real database, staff login, admin page to edit FAQs, ticket tracking.
|
||||
|
||||
## Test ladder
|
||||
1. Page loads, no console errors 2. Buttons/search work 3. Real data/links work 4. Security (no raw HTML injection, safe links)
|
||||
@@ -0,0 +1,11 @@
|
||||
# HelpDesk IT Support
|
||||
|
||||
Run locally (needs Node 22+):
|
||||
npm install
|
||||
npm run build
|
||||
ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='a-long-password' npm start
|
||||
Open http://localhost:3001 (staff login: http://localhost:3001/#/staff)
|
||||
|
||||
For development with live reload: run `npm run server` and `npm run dev` in two terminals.
|
||||
|
||||
Settings are listed in `.env.example`. Data lives in `server/data/helpdesk.db`: back it up, and keep it on a persistent disk when hosting.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HelpDesk | IT Support</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3528
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "it-support",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"server": "node server/index.js",
|
||||
"start": "NODE_ENV=production node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.22.3",
|
||||
"lucide-react": "^0.460.0",
|
||||
"nodemailer": "^10.0.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||
@@ -0,0 +1,48 @@
|
||||
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto'
|
||||
import { db } from './db.js'
|
||||
|
||||
export const SESSION_DAYS = 7
|
||||
export const hashPassword = (pw) => {
|
||||
const salt = randomBytes(16)
|
||||
return `${salt.toString('hex')}:${scryptSync(pw, salt, 64).toString('hex')}`
|
||||
}
|
||||
export const verifyPassword = (pw, stored) => {
|
||||
const [salt, hash] = stored.split(':')
|
||||
const a = Buffer.from(hash, 'hex')
|
||||
const b = scryptSync(pw, Buffer.from(salt, 'hex'), 64)
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
}
|
||||
const sha = (t) => createHash('sha256').update(t).digest('hex')
|
||||
|
||||
export function createSession(staffId) {
|
||||
const token = randomBytes(32).toString('hex')
|
||||
db.prepare('INSERT INTO sessions VALUES (?,?,?)').run(sha(token), staffId, Date.now() + SESSION_DAYS * 864e5)
|
||||
return token
|
||||
}
|
||||
export const destroySession = (token) => db.prepare('DELETE FROM sessions WHERE token_hash=?').run(sha(token))
|
||||
|
||||
function cookieToken(req) {
|
||||
const m = /(?:^|;\s*)session=([a-f0-9]{64})/.exec(req.headers.cookie || '')
|
||||
return m ? m[1] : null
|
||||
}
|
||||
export { cookieToken }
|
||||
|
||||
export function requireStaff(req, res, next) {
|
||||
const token = cookieToken(req)
|
||||
const row = token && db.prepare('SELECT s.id, s.email, s.name, x.expires FROM sessions x JOIN staff s ON s.id=x.staff_id WHERE x.token_hash=?').get(sha(token))
|
||||
if (!row || row.expires < Date.now()) return res.status(401).json({ error: 'Please log in.' })
|
||||
req.staff = row
|
||||
next()
|
||||
}
|
||||
|
||||
// Tiny in-memory rate limiter: `max` hits per `windowMs` per IP+bucket.
|
||||
const hits = new Map()
|
||||
export const rateLimit = (bucket, max, windowMs) => (req, res, next) => {
|
||||
const key = `${bucket}:${req.ip}`
|
||||
const now = Date.now()
|
||||
const recent = (hits.get(key) || []).filter((t) => now - t < windowMs)
|
||||
if (recent.length >= max) return res.status(429).json({ error: 'Too many tries. Please wait a bit.' })
|
||||
recent.push(now)
|
||||
hits.set(key, recent)
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { readFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const dataDir = process.env.DATA_DIR || join(here, 'data')
|
||||
mkdirSync(dataDir, { recursive: true })
|
||||
|
||||
export const db = new DatabaseSync(join(dataDir, 'helpdesk.db'))
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS faqs (id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL, question TEXT NOT NULL, answer TEXT NOT NULL, link TEXT NOT NULL DEFAULT '', link_label TEXT NOT NULL DEFAULT '');
|
||||
CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, topic TEXT NOT NULL, message TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', emailed INTEGER NOT NULL DEFAULT 0, created TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS votes (id INTEGER PRIMARY KEY AUTOINCREMENT, faq_id INTEGER NOT NULL, vote TEXT NOT NULL, created TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS staff (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password_hash TEXT NOT NULL, created TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, staff_id INTEGER NOT NULL, expires INTEGER NOT NULL);
|
||||
`)
|
||||
|
||||
// First run: copy the 87 FAQs from the bundled file into the real database.
|
||||
if (db.prepare('SELECT COUNT(*) AS n FROM faqs').get().n === 0) {
|
||||
const seed = JSON.parse(readFileSync(join(here, '..', 'src', 'data', 'faqs.json'), 'utf8'))
|
||||
const ins = db.prepare('INSERT INTO faqs (id, category, question, answer, link, link_label) VALUES (?,?,?,?,?,?)')
|
||||
for (const f of seed) ins.run(f.id, f.category, f.question, f.answer, f.link, f.linkLabel)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import express from 'express'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { db } from './db.js'
|
||||
import { hashPassword, verifyPassword, createSession, destroySession, cookieToken, requireStaff, rateLimit, SESSION_DAYS } from './auth.js'
|
||||
import { sendTicketEmail, emailConfigured } from './mail.js'
|
||||
|
||||
const app = express()
|
||||
const prod = process.env.NODE_ENV === 'production'
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const clean = (s, max) => String(s ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, max)
|
||||
const cleanBlock = (s, max) => String(s ?? '').trim().slice(0, max)
|
||||
const isHttpUrl = (u) => { try { return ['http:', 'https:'].includes(new URL(u).protocol) } catch { return false } }
|
||||
|
||||
app.set('trust proxy', process.env.TRUST_PROXY ? 1 : false)
|
||||
app.disable('x-powered-by')
|
||||
app.use((req, res, next) => {
|
||||
res.set({
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Referrer-Policy': 'same-origin',
|
||||
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; media-src https://d8j0ntlcm91z4.cloudfront.net; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'",
|
||||
})
|
||||
next()
|
||||
})
|
||||
app.use(express.json({ limit: '20kb' }))
|
||||
// Block cross-site writes: browsers always send Origin on POST/PUT/PATCH/DELETE from other sites.
|
||||
app.use((req, res, next) => {
|
||||
if (['GET', 'HEAD'].includes(req.method)) return next()
|
||||
const origin = req.headers.origin
|
||||
if (origin && new URL(origin).host !== req.headers.host) return res.status(403).json({ error: 'Bad origin.' })
|
||||
next()
|
||||
})
|
||||
|
||||
// ---------- Public ----------
|
||||
const faqOut = (r) => ({ id: r.id, category: r.category, question: r.question, answer: r.answer, link: r.link, linkLabel: r.link_label })
|
||||
app.get('/api/faqs', (_req, res) => res.json(db.prepare('SELECT * FROM faqs ORDER BY id').all().map(faqOut)))
|
||||
|
||||
app.post('/api/tickets', rateLimit('ticket', 5, 3600e3), async (req, res) => {
|
||||
const t = {
|
||||
id: `IT-${Date.now().toString(36).toUpperCase()}${randomBytes(2).toString('hex').toUpperCase()}`,
|
||||
name: clean(req.body.name, 100), email: clean(req.body.email, 200),
|
||||
topic: clean(req.body.topic, 100), message: cleanBlock(req.body.message, 2000),
|
||||
}
|
||||
if (!t.name) return res.status(400).json({ error: 'Please enter your name.' })
|
||||
if (!EMAIL_RE.test(t.email)) return res.status(400).json({ error: 'Please enter a valid email.' })
|
||||
if (t.message.length < 10) return res.status(400).json({ error: 'Please describe the problem (at least 10 characters).' })
|
||||
db.prepare('INSERT INTO tickets (id,name,email,topic,message,created) VALUES (?,?,?,?,?,?)').run(t.id, t.name, t.email, t.topic, t.message, new Date().toISOString())
|
||||
const emailed = await sendTicketEmail(t)
|
||||
if (emailed) db.prepare('UPDATE tickets SET emailed=1 WHERE id=?').run(t.id)
|
||||
res.status(201).json({ id: t.id })
|
||||
})
|
||||
|
||||
app.post('/api/votes', rateLimit('vote', 30, 3600e3), (req, res) => {
|
||||
const faqId = Number(req.body.faqId)
|
||||
const vote = req.body.vote
|
||||
if (!['up', 'down'].includes(vote) || !db.prepare('SELECT 1 FROM faqs WHERE id=?').get(faqId)) return res.status(400).json({ error: 'Bad vote.' })
|
||||
db.prepare('INSERT INTO votes (faq_id,vote,created) VALUES (?,?,?)').run(faqId, vote, new Date().toISOString())
|
||||
res.status(201).json({ ok: true })
|
||||
})
|
||||
|
||||
// ---------- Staff login ----------
|
||||
app.post('/api/login', rateLimit('login', 10, 15 * 60e3), (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM staff WHERE email=?').get(clean(req.body.email, 200).toLowerCase())
|
||||
// Same message either way so attackers can't learn which emails exist.
|
||||
if (!row || !verifyPassword(String(req.body.password ?? ''), row.password_hash)) return res.status(401).json({ error: 'Wrong email or password.' })
|
||||
const token = createSession(row.id)
|
||||
res.cookie('session', token, { httpOnly: true, sameSite: 'strict', secure: prod, maxAge: SESSION_DAYS * 864e5, path: '/' })
|
||||
res.json({ name: row.name, email: row.email })
|
||||
})
|
||||
app.post('/api/logout', (req, res) => {
|
||||
const t = cookieToken(req)
|
||||
if (t) destroySession(t)
|
||||
res.clearCookie('session', { path: '/' })
|
||||
res.json({ ok: true })
|
||||
})
|
||||
app.get('/api/me', requireStaff, (req, res) => res.json({ name: req.staff.name, email: req.staff.email }))
|
||||
|
||||
// ---------- Staff only ----------
|
||||
app.get('/api/staff/status', requireStaff, (_req, res) => res.json({ emailConfigured }))
|
||||
app.get('/api/staff/tickets', requireStaff, (_req, res) => res.json(db.prepare('SELECT * FROM tickets ORDER BY created DESC').all()))
|
||||
app.patch('/api/staff/tickets/:id', requireStaff, (req, res) => {
|
||||
if (!['open', 'in_progress', 'closed'].includes(req.body.status)) return res.status(400).json({ error: 'Bad status.' })
|
||||
db.prepare('UPDATE tickets SET status=? WHERE id=?').run(req.body.status, req.params.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/staff/faqs', requireStaff, (_req, res) => {
|
||||
const votes = db.prepare("SELECT faq_id, SUM(vote='up') AS up, SUM(vote='down') AS down FROM votes GROUP BY faq_id").all()
|
||||
const byId = new Map(votes.map((v) => [v.faq_id, v]))
|
||||
res.json(db.prepare('SELECT * FROM faqs ORDER BY id').all().map((r) => ({ ...faqOut(r), up: byId.get(r.id)?.up ?? 0, down: byId.get(r.id)?.down ?? 0 })))
|
||||
})
|
||||
function faqFields(body) {
|
||||
const f = { category: clean(body.category, 100), question: clean(body.question, 300), answer: cleanBlock(body.answer, 3000), link: clean(body.link, 500), link_label: clean(body.linkLabel, 200) }
|
||||
if (!f.category || !f.question || !f.answer) return { error: 'Category, question and answer are required.' }
|
||||
if (f.link && !isHttpUrl(f.link)) return { error: 'Link must start with http:// or https://' }
|
||||
return { f }
|
||||
}
|
||||
app.post('/api/staff/faqs', requireStaff, (req, res) => {
|
||||
const { f, error } = faqFields(req.body)
|
||||
if (error) return res.status(400).json({ error })
|
||||
const r = db.prepare('INSERT INTO faqs (category,question,answer,link,link_label) VALUES (?,?,?,?,?)').run(f.category, f.question, f.answer, f.link, f.link_label)
|
||||
res.status(201).json({ id: Number(r.lastInsertRowid) })
|
||||
})
|
||||
app.put('/api/staff/faqs/:id', requireStaff, (req, res) => {
|
||||
const { f, error } = faqFields(req.body)
|
||||
if (error) return res.status(400).json({ error })
|
||||
db.prepare('UPDATE faqs SET category=?,question=?,answer=?,link=?,link_label=? WHERE id=?').run(f.category, f.question, f.answer, f.link, f.link_label, req.params.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
app.delete('/api/staff/faqs/:id', requireStaff, (req, res) => {
|
||||
db.prepare('DELETE FROM faqs WHERE id=?').run(req.params.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/staff/team', requireStaff, (_req, res) => res.json(db.prepare('SELECT id,email,name,created FROM staff ORDER BY id').all()))
|
||||
app.post('/api/staff/team', requireStaff, (req, res) => {
|
||||
const email = clean(req.body.email, 200).toLowerCase(), name = clean(req.body.name, 100), pw = String(req.body.password ?? '')
|
||||
if (!EMAIL_RE.test(email) || !name) return res.status(400).json({ error: 'Name and a valid email are required.' })
|
||||
if (pw.length < 10) return res.status(400).json({ error: 'Password must be at least 10 characters.' })
|
||||
if (db.prepare('SELECT 1 FROM staff WHERE email=?').get(email)) return res.status(409).json({ error: 'That email is already on the team.' })
|
||||
db.prepare('INSERT INTO staff (email,name,password_hash,created) VALUES (?,?,?,?)').run(email, name, hashPassword(pw), new Date().toISOString())
|
||||
res.status(201).json({ ok: true })
|
||||
})
|
||||
app.delete('/api/staff/team/:id', requireStaff, (req, res) => {
|
||||
if (Number(req.params.id) === req.staff.id) return res.status(400).json({ error: "You can't remove yourself." })
|
||||
db.prepare('DELETE FROM sessions WHERE staff_id=?').run(req.params.id)
|
||||
db.prepare('DELETE FROM staff WHERE id=?').run(req.params.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.use('/api', (_req, res) => res.status(404).json({ error: 'Not found.' }))
|
||||
|
||||
// ---------- First staff member ----------
|
||||
if (db.prepare('SELECT COUNT(*) AS n FROM staff').get().n === 0) {
|
||||
const { ADMIN_EMAIL, ADMIN_PASSWORD } = process.env
|
||||
if (ADMIN_EMAIL && ADMIN_PASSWORD && ADMIN_PASSWORD.length >= 10) {
|
||||
db.prepare('INSERT INTO staff (email,name,password_hash,created) VALUES (?,?,?,?)').run(ADMIN_EMAIL.toLowerCase(), 'Admin', hashPassword(ADMIN_PASSWORD), new Date().toISOString())
|
||||
console.log(`Created first staff login: ${ADMIN_EMAIL}`)
|
||||
} else console.warn('No staff yet. Set ADMIN_EMAIL and ADMIN_PASSWORD (10+ characters) and restart.')
|
||||
}
|
||||
if (!emailConfigured) console.warn('Email not configured (SMTP_HOST/SMTP_USER/SMTP_PASS). Tickets are saved but not emailed.')
|
||||
|
||||
// ---------- Serve the website (production) ----------
|
||||
const dist = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist')
|
||||
if (existsSync(dist)) app.use(express.static(dist))
|
||||
|
||||
const port = Number(process.env.PORT || 3001)
|
||||
app.listen(port, () => console.log(`HelpDesk server on http://localhost:${port}`))
|
||||
@@ -0,0 +1,25 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, TICKET_TO = 'rana.krunal7558@gmail.com' } = process.env
|
||||
const transporter = SMTP_HOST && SMTP_USER && SMTP_PASS
|
||||
? nodemailer.createTransport({ host: SMTP_HOST, port: Number(SMTP_PORT || 465), secure: Number(SMTP_PORT || 465) === 465, auth: { user: SMTP_USER, pass: SMTP_PASS } })
|
||||
: null
|
||||
|
||||
export const emailConfigured = !!transporter
|
||||
|
||||
export async function sendTicketEmail(t) {
|
||||
if (!transporter) return false
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: SMTP_USER,
|
||||
to: TICKET_TO,
|
||||
replyTo: t.email,
|
||||
subject: `[${t.id}] ${t.topic}: new IT request from ${t.name}`,
|
||||
text: `Ticket: ${t.id}\nFrom: ${t.name} <${t.email}>\nTopic: ${t.topic}\n\n${t.message}\n`,
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Email failed:', e.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowRight, Globe, Instagram, Twitter } from 'lucide-react'
|
||||
import VideoBackground from './VideoBackground'
|
||||
import FaqList from './Faq'
|
||||
import ContactForm from './ContactForm'
|
||||
import type { Faq } from './types'
|
||||
import Staff from './Staff'
|
||||
import faqData from './data/faqs.json'
|
||||
|
||||
const bundledFaqs = faqData as Faq[]
|
||||
|
||||
export default function App() {
|
||||
const [faqs, setFaqs] = useState<Faq[]>(bundledFaqs)
|
||||
const [isStaff, setIsStaff] = useState(location.hash.startsWith('#/staff'))
|
||||
const categories = useMemo(() => Array.from(new Set(faqs.map((f) => f.category))), [faqs])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/faqs').then((r) => (r.ok ? r.json() : Promise.reject())).then(setFaqs).catch(() => {})
|
||||
const onHash = () => setIsStaff(location.hash.startsWith('#/staff'))
|
||||
window.addEventListener('hashchange', onHash)
|
||||
return () => window.removeEventListener('hashchange', onHash)
|
||||
}, [])
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState<string | null>(null)
|
||||
const answersRef = useRef<HTMLElement>(null)
|
||||
|
||||
const results = useMemo(() => {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
return faqs.filter((f) => {
|
||||
if (category && f.category !== category) return false
|
||||
const text = `${f.question} ${f.answer} ${f.category}`.toLowerCase()
|
||||
return words.every((w) => text.includes(w))
|
||||
})
|
||||
}, [query, category, faqs])
|
||||
|
||||
if (isStaff) return <Staff />
|
||||
|
||||
const scrollToAnswers = () => answersRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
|
||||
return (
|
||||
<div className="bg-black">
|
||||
<div className="min-h-screen bg-black overflow-hidden relative flex flex-col">
|
||||
<VideoBackground />
|
||||
|
||||
<nav className="relative z-20 pl-6 pr-6 py-6">
|
||||
<div className="liquid-glass rounded-full px-6 py-3 flex items-center justify-between max-w-5xl mx-auto">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex items-center gap-2 text-white font-semibold text-lg">
|
||||
<Globe size={24} />
|
||||
<span>HelpDesk</span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
{[
|
||||
['Topics', '#answers'],
|
||||
['Search', '#top'],
|
||||
['Contact', '#contact'],
|
||||
].map(([label, href]) => (
|
||||
<a key={label} href={href} className="text-white/80 hover:text-white transition-colors text-sm font-medium">
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="#contact" className="text-white text-sm font-medium">Contact IT</a>
|
||||
<a href="#/staff" className="liquid-glass rounded-full px-6 py-2 text-white text-sm font-medium">Staff login</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
id="top"
|
||||
className="relative z-10 flex-1 flex flex-col items-center justify-center px-6 py-12 text-center -translate-y-[20%]"
|
||||
>
|
||||
<h1
|
||||
className="text-5xl md:text-6xl lg:text-7xl text-white mb-8 tracking-tight whitespace-nowrap"
|
||||
style={{ fontFamily: "'Instrument Serif', serif" }}
|
||||
>
|
||||
IT help, <em>made simple</em>
|
||||
</h1>
|
||||
<div className="max-w-xl w-full space-y-4">
|
||||
<form
|
||||
className="liquid-glass rounded-full pl-6 pr-2 py-2 flex items-center gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
scrollToAnswers()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
maxLength={200}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Describe your problem"
|
||||
aria-label="Search IT help"
|
||||
className="bg-transparent outline-none flex-1 text-white placeholder:text-white/40 text-base"
|
||||
/>
|
||||
<button type="submit" aria-label="Search" className="bg-white rounded-full p-3 text-black">
|
||||
<ArrowRight size={20} />
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-white text-sm leading-relaxed px-4">
|
||||
Locked out? Wi-Fi down? Printer acting up? Search {faqs.length} plain-English answers to the most common IT problems.
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={scrollToAnswers}
|
||||
className="liquid-glass rounded-full px-8 py-3 text-white text-sm font-medium hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Browse all topics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex justify-center gap-4 pb-12">
|
||||
{[
|
||||
{ Icon: Instagram, label: 'Instagram' },
|
||||
{ Icon: Twitter, label: 'Twitter' },
|
||||
{ Icon: Globe, label: 'Website' },
|
||||
].map(({ Icon, label }) => (
|
||||
<button
|
||||
key={label}
|
||||
aria-label={label}
|
||||
className="liquid-glass rounded-full p-4 text-white/80 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
<Icon size={20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="answers" ref={answersRef} className="max-w-3xl mx-auto px-6 py-20">
|
||||
<h2 className="text-4xl text-white mb-2" style={{ fontFamily: "'Instrument Serif', serif" }}>
|
||||
Answers
|
||||
</h2>
|
||||
<p className="text-white/60 text-sm mb-6" aria-live="polite">
|
||||
Showing {results.length} of {faqs.length}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{[null, ...categories].map((c) => (
|
||||
<button
|
||||
key={c ?? 'all'}
|
||||
onClick={() => setCategory(c)}
|
||||
aria-pressed={category === c}
|
||||
className={`liquid-glass rounded-full px-4 py-2 text-sm transition-colors ${
|
||||
category === c ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{c ?? 'All'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FaqList items={results} />
|
||||
</section>
|
||||
|
||||
<ContactForm topics={categories} />
|
||||
|
||||
<footer id="about" className="max-w-3xl mx-auto px-6 pb-16 text-center text-white/50 text-sm">
|
||||
General guidance only. For account-specific problems, contact your organisation's IT service desk.
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react'
|
||||
import { Send } from 'lucide-react'
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const inputCls = 'w-full bg-transparent outline-none text-white placeholder:text-white/40 text-base'
|
||||
|
||||
export default function ContactForm({ topics }: { topics: string[] }) {
|
||||
const [form, setForm] = useState({ name: '', email: '', topic: topics[0] ?? '', message: '' })
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [ticketId, setTicketId] = useState<string | null>(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [serverError, setServerError] = useState('')
|
||||
|
||||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
|
||||
setForm({ ...form, [k]: e.target.value })
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const errs: Record<string, string> = {}
|
||||
if (!form.name.trim()) errs.name = 'Please enter your name.'
|
||||
if (!EMAIL_RE.test(form.email.trim())) errs.email = 'Please enter a valid email.'
|
||||
if (form.message.trim().length < 10) errs.message = 'Please describe the problem (at least 10 characters).'
|
||||
setErrors(errs)
|
||||
setServerError('')
|
||||
if (Object.keys(errs).length) return
|
||||
setSending(true)
|
||||
try {
|
||||
const res = await fetch('/api/tickets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(data.error || 'Something went wrong. Please try again.')
|
||||
setTicketId(data.id)
|
||||
setForm({ name: '', email: '', topic: topics[0] ?? '', message: '' })
|
||||
} catch (err) {
|
||||
setServerError(err instanceof Error ? err.message : 'Could not reach the server.')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const field = 'liquid-glass rounded-3xl px-6 py-4'
|
||||
const err = (k: string) => errors[k] && <p role="alert" className="text-red-300 text-sm mt-1 px-2">{errors[k]}</p>
|
||||
|
||||
return (
|
||||
<section id="contact" className="max-w-3xl mx-auto px-6 pb-20">
|
||||
<h2 className="text-4xl text-white mb-2" style={{ fontFamily: "'Instrument Serif', serif" }}>Contact IT</h2>
|
||||
<p className="text-white/60 text-sm mb-6">Can't find your answer? Tell us what's wrong.</p>
|
||||
{ticketId ? (
|
||||
<div className="liquid-glass rounded-3xl p-8 text-white" role="status">
|
||||
<p className="text-xl mb-2">Request sent. Your ticket number is <strong>{ticketId}</strong>.</p>
|
||||
<p className="text-white/70 text-sm mb-4">Our IT team has been notified. Keep this number if you follow up.</p>
|
||||
<button onClick={() => setTicketId(null)} className="liquid-glass rounded-full px-6 py-2 text-sm hover:bg-white/5">Send another</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="space-y-3">
|
||||
<div>
|
||||
<div className={field}><input className={inputCls} placeholder="Your name" aria-label="Your name" maxLength={100} value={form.name} onChange={set('name')} /></div>
|
||||
{err('name')}
|
||||
</div>
|
||||
<div>
|
||||
<div className={field}><input className={inputCls} type="email" placeholder="Your email" aria-label="Your email" maxLength={200} value={form.email} onChange={set('email')} /></div>
|
||||
{err('email')}
|
||||
</div>
|
||||
<div className={field}>
|
||||
<select className={`${inputCls} [&>option]:text-black`} aria-label="Topic" value={form.topic} onChange={set('topic')}>
|
||||
{topics.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div className={field}><textarea className={`${inputCls} resize-none`} rows={5} placeholder="What's the problem?" aria-label="Problem description" maxLength={2000} value={form.message} onChange={set('message')} /></div>
|
||||
{err('message')}
|
||||
</div>
|
||||
{serverError && <p role="alert" className="text-red-300 text-sm px-2">{serverError}</p>}
|
||||
<button type="submit" disabled={sending} className="bg-white text-black rounded-full px-8 py-3 text-sm font-medium inline-flex items-center gap-2 disabled:opacity-60">
|
||||
<Send size={16} /> {sending ? 'Sending...' : 'Send request'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ExternalLink, Link2, SearchX, ThumbsDown, ThumbsUp } from 'lucide-react'
|
||||
import type { Faq } from './types'
|
||||
import { load, save } from './storage'
|
||||
|
||||
type Votes = Record<number, 'up' | 'down'>
|
||||
|
||||
function safeUrl(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function FaqCard({ f, forceOpen, vote, onVote }: { f: Faq; forceOpen: boolean; vote?: 'up' | 'down'; onVote: (v: 'up' | 'down') => void }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const href = f.link ? safeUrl(f.link) : null
|
||||
|
||||
const copyLink = async () => {
|
||||
const url = `${location.origin}${location.pathname}#faq-${f.id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
location.hash = `faq-${f.id}`
|
||||
}
|
||||
}
|
||||
|
||||
const voteBtn = (v: 'up' | 'down', label: string, Icon: typeof ThumbsUp) => (
|
||||
<button
|
||||
onClick={() => onVote(v)}
|
||||
aria-pressed={vote === v}
|
||||
className={`liquid-glass rounded-full px-4 py-2 inline-flex items-center gap-2 text-sm transition-colors ${
|
||||
vote === v ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} /> {label}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<li id={`faq-${f.id}`} className="scroll-mt-6">
|
||||
<details className="liquid-glass rounded-3xl group" open={forceOpen || undefined}>
|
||||
<summary className="cursor-pointer list-none px-6 py-5 flex items-start justify-between gap-4 text-white">
|
||||
<span>
|
||||
<span className="block text-xs uppercase tracking-wider text-white/50 mb-1">{f.category}</span>
|
||||
<span className="text-lg">{f.question}</span>
|
||||
</span>
|
||||
<span className="text-white/60 transition-transform group-open:rotate-45 text-2xl leading-none">+</span>
|
||||
</summary>
|
||||
<div className="px-6 pb-6 text-white/80 text-sm leading-relaxed">
|
||||
<p>{f.answer}</p>
|
||||
{href && (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 mt-4 text-white hover:underline">
|
||||
{f.linkLabel || 'Official guide'} <ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||
<span className="text-white/50 mr-1">Was this helpful?</span>
|
||||
{voteBtn('up', 'Yes', ThumbsUp)}
|
||||
{voteBtn('down', 'No', ThumbsDown)}
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="liquid-glass rounded-full px-4 py-2 inline-flex items-center gap-2 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-colors ml-auto"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Link2 size={14} />} {copied ? 'Link copied' : 'Copy link'}
|
||||
</button>
|
||||
</div>
|
||||
{vote === 'down' && (
|
||||
<p className="mt-3 text-white/60">
|
||||
Sorry about that. <a href="#contact" className="text-white underline">Contact IT</a> and we'll help directly.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FaqList({ items }: { items: Faq[] }) {
|
||||
const [votes, setVotes] = useState<Votes>(() => load<Votes>('faq-votes', {}))
|
||||
const [hashId, setHashId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const read = () => {
|
||||
const m = /^#faq-(\d+)$/.exec(location.hash)
|
||||
setHashId(m ? Number(m[1]) : null)
|
||||
if (m) window.setTimeout(() => document.getElementById(`faq-${m[1]}`)?.scrollIntoView({ behavior: 'smooth' }), 50)
|
||||
}
|
||||
read()
|
||||
window.addEventListener('hashchange', read)
|
||||
return () => window.removeEventListener('hashchange', read)
|
||||
}, [])
|
||||
|
||||
const onVote = (id: number, v: 'up' | 'down') => {
|
||||
const next = { ...votes, [id]: v }
|
||||
setVotes(next)
|
||||
save('faq-votes', next)
|
||||
fetch('/api/votes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ faqId: id, vote: v }) }).catch(() => {})
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="liquid-glass rounded-3xl p-10 text-center text-white/70">
|
||||
<SearchX className="mx-auto mb-3" size={28} />
|
||||
<p>No answers found. Try different words, or <a href="#contact" className="text-white underline">contact IT</a>.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{items.map((f) => (
|
||||
<FaqCard key={f.id} f={f} forceOpen={hashId === f.id} vote={votes[f.id]} onVote={(v) => onVote(f.id, v)} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { LogOut } from 'lucide-react'
|
||||
|
||||
interface Ticket { id: string; name: string; email: string; topic: string; message: string; status: string; emailed: number; created: string }
|
||||
interface StaffFaq { id: number; category: string; question: string; answer: string; link: string; linkLabel: string; up: number; down: number }
|
||||
interface Member { id: number; email: string; name: string }
|
||||
type Tab = 'tickets' | 'faqs' | 'team'
|
||||
|
||||
async function api<T = unknown>(path: string, method = 'GET', body?: unknown): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status })
|
||||
return data as T
|
||||
}
|
||||
|
||||
const box = 'liquid-glass rounded-3xl p-6 text-white'
|
||||
const input = 'w-full bg-white/5 rounded-2xl px-4 py-3 text-white placeholder:text-white/40 outline-none'
|
||||
const btn = 'liquid-glass rounded-full px-5 py-2 text-sm text-white hover:bg-white/5 transition-colors'
|
||||
|
||||
function Login({ onDone }: { onDone: () => void }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try { await api('/login', 'POST', { email, password }); onDone() } catch (err) { setError((err as Error).message) }
|
||||
}
|
||||
return (
|
||||
<form onSubmit={submit} className={`${box} max-w-sm mx-auto space-y-3`}>
|
||||
<h1 className="text-3xl mb-2" style={{ fontFamily: "'Instrument Serif', serif" }}>Staff login</h1>
|
||||
<input className={input} type="email" placeholder="Email" aria-label="Email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="username" />
|
||||
<input className={input} type="password" placeholder="Password" aria-label="Password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
|
||||
{error && <p role="alert" className="text-red-300 text-sm">{error}</p>}
|
||||
<button type="submit" className="bg-white text-black rounded-full px-6 py-2 text-sm font-medium">Log in</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function Tickets() {
|
||||
const [rows, setRows] = useState<Ticket[]>([])
|
||||
const load = useCallback(() => api<Ticket[]>('/staff/tickets').then(setRows), [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const setStatus = async (id: string, status: string) => { await api(`/staff/tickets/${id}`, 'PATCH', { status }); load() }
|
||||
if (!rows.length) return <p className="text-white/60">No tickets yet.</p>
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{rows.map((t) => (
|
||||
<li key={t.id} className={box}>
|
||||
<div className="flex flex-wrap justify-between gap-2 text-sm text-white/60">
|
||||
<span>{t.id} · {t.topic} · {new Date(t.created).toLocaleString()}</span>
|
||||
<span>{t.emailed ? 'emailed' : 'not emailed'}</span>
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-wrap">{t.message}</p>
|
||||
<p className="mt-2 text-sm text-white/70">{t.name} · <a className="underline" href={`mailto:${encodeURIComponent(t.email)}`}>{t.email}</a></p>
|
||||
<select aria-label="Status" value={t.status} onChange={(e) => setStatus(t.id, e.target.value)} className="mt-3 bg-white/10 rounded-full px-4 py-2 text-sm [&>option]:text-black">
|
||||
<option value="open">Open</option><option value="in_progress">In progress</option><option value="closed">Closed</option>
|
||||
</select>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyFaq = { category: '', question: '', answer: '', link: '', linkLabel: '' }
|
||||
function Faqs() {
|
||||
const [rows, setRows] = useState<StaffFaq[]>([])
|
||||
const [editing, setEditing] = useState<(typeof emptyFaq & { id?: number }) | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const load = useCallback(() => api<StaffFaq[]>('/staff/faqs').then(setRows), [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!editing) return
|
||||
try {
|
||||
if (editing.id) await api(`/staff/faqs/${editing.id}`, 'PUT', editing)
|
||||
else await api('/staff/faqs', 'POST', editing)
|
||||
setEditing(null); setError(''); load()
|
||||
} catch (err) { setError((err as Error).message) }
|
||||
}
|
||||
const remove = async (f: StaffFaq) => { if (confirm(`Delete "${f.question}"?`)) { await api(`/staff/faqs/${f.id}`, 'DELETE'); load() } }
|
||||
const set = (k: keyof typeof emptyFaq) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => editing && setEditing({ ...editing, [k]: e.target.value })
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{editing ? (
|
||||
<form onSubmit={save} className={`${box} space-y-3`}>
|
||||
<input className={input} placeholder="Category" aria-label="Category" value={editing.category} onChange={set('category')} />
|
||||
<input className={input} placeholder="Question" aria-label="Question" value={editing.question} onChange={set('question')} />
|
||||
<textarea className={input} rows={5} placeholder="Answer" aria-label="Answer" value={editing.answer} onChange={set('answer')} />
|
||||
<input className={input} placeholder="Link (optional, https://...)" aria-label="Link" value={editing.link} onChange={set('link')} />
|
||||
<input className={input} placeholder="Link label (optional)" aria-label="Link label" value={editing.linkLabel} onChange={set('linkLabel')} />
|
||||
{error && <p role="alert" className="text-red-300 text-sm">{error}</p>}
|
||||
<div className="flex gap-2"><button type="submit" className="bg-white text-black rounded-full px-6 py-2 text-sm font-medium">Save</button><button type="button" className={btn} onClick={() => { setEditing(null); setError('') }}>Cancel</button></div>
|
||||
</form>
|
||||
) : <button className={btn} onClick={() => setEditing({ ...emptyFaq })}>+ Add FAQ</button>}
|
||||
<ul className="space-y-2">
|
||||
{rows.map((f) => (
|
||||
<li key={f.id} className={`${box} flex flex-wrap items-center justify-between gap-3 !py-4`}>
|
||||
<span><span className="block text-xs text-white/50">{f.category}</span>{f.question}</span>
|
||||
<span className="flex items-center gap-3 text-sm text-white/70">
|
||||
👍 {f.up} 👎 {f.down}
|
||||
<button className={btn} onClick={() => setEditing(f)}>Edit</button>
|
||||
<button className={btn} onClick={() => remove(f)}>Delete</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Team({ me }: { me: string }) {
|
||||
const [rows, setRows] = useState<Member[]>([])
|
||||
const [f, setF] = useState({ name: '', email: '', password: '' })
|
||||
const [error, setError] = useState('')
|
||||
const load = useCallback(() => api<Member[]>('/staff/team').then(setRows), [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const add = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try { await api('/staff/team', 'POST', f); setF({ name: '', email: '', password: '' }); setError(''); load() } catch (err) { setError((err as Error).message) }
|
||||
}
|
||||
const remove = async (m: Member) => { if (confirm(`Remove ${m.name}?`)) { try { await api(`/staff/team/${m.id}`, 'DELETE'); load() } catch (err) { setError((err as Error).message) } } }
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ul className="space-y-2">
|
||||
{rows.map((m) => (
|
||||
<li key={m.id} className={`${box} flex items-center justify-between !py-4`}>
|
||||
<span>{m.name} <span className="text-white/60 text-sm">· {m.email}</span></span>
|
||||
{m.email !== me && <button className={btn} onClick={() => remove(m)}>Remove</button>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form onSubmit={add} className={`${box} space-y-3`}>
|
||||
<h2 className="text-xl">Add a team member</h2>
|
||||
<input className={input} placeholder="Name" aria-label="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} />
|
||||
<input className={input} type="email" placeholder="Email" aria-label="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
|
||||
<input className={input} type="password" placeholder="Temporary password (10+ characters)" aria-label="Password" value={f.password} onChange={(e) => setF({ ...f, password: e.target.value })} autoComplete="new-password" />
|
||||
{error && <p role="alert" className="text-red-300 text-sm">{error}</p>}
|
||||
<button type="submit" className="bg-white text-black rounded-full px-6 py-2 text-sm font-medium">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Staff() {
|
||||
const [me, setMe] = useState<{ name: string; email: string } | null | undefined>(undefined)
|
||||
const [emailOk, setEmailOk] = useState(true)
|
||||
const [tab, setTab] = useState<Tab>('tickets')
|
||||
const check = useCallback(() => {
|
||||
api<{ name: string; email: string }>('/me').then((m) => { setMe(m); api<{ emailConfigured: boolean }>('/staff/status').then((s) => setEmailOk(s.emailConfigured)) }).catch(() => setMe(null))
|
||||
}, [])
|
||||
useEffect(() => { check() }, [check])
|
||||
const logout = async () => { await api('/logout', 'POST'); setMe(null) }
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white px-6 py-10">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<a href="#" className="text-white/70 hover:text-white text-sm">← Back to site</a>
|
||||
{me && <button onClick={logout} className={`${btn} inline-flex items-center gap-2`}><LogOut size={14} /> Log out ({me.name})</button>}
|
||||
</div>
|
||||
{me === undefined ? <p className="text-white/60">Loading...</p> : me === null ? <Login onDone={check} /> : (
|
||||
<>
|
||||
{!emailOk && <p className="mb-4 text-amber-300 text-sm">Email is not set up on the server yet, so new tickets are saved here but not emailed.</p>}
|
||||
<div className="flex gap-2 mb-6">
|
||||
{(['tickets', 'faqs', 'team'] as Tab[]).map((t) => (
|
||||
<button key={t} onClick={() => setTab(t)} aria-pressed={tab === t} className={`${btn} capitalize ${tab === t ? 'bg-white/20' : ''}`}>{t === 'faqs' ? 'FAQs' : t}</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'tickets' && <Tickets />}{tab === 'faqs' && <Faqs />}{tab === 'team' && <Team me={me.email} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
const VIDEO_URL =
|
||||
'https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260328_115001_bcdaa3b4-03de-47e7-ad63-ae3e392c32d4.mp4'
|
||||
const FADE_MS = 500
|
||||
const FADE_OUT_REMAINING = 0.55
|
||||
|
||||
export default function VideoBackground() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const fadingOutRef = useRef(false)
|
||||
|
||||
const fadeTo = useCallback((target: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
||||
const startOpacity = parseFloat(video.style.opacity || '0')
|
||||
const startTime = performance.now()
|
||||
const step = (now: number) => {
|
||||
const t = Math.min((now - startTime) / FADE_MS, 1)
|
||||
video.style.opacity = String(startOpacity + (target - startOpacity) * t)
|
||||
rafRef.current = t < 1 ? requestAnimationFrame(step) : null
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
||||
}, [])
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
const video = videoRef.current
|
||||
if (!video || !video.duration || fadingOutRef.current) return
|
||||
if (video.duration - video.currentTime <= FADE_OUT_REMAINING) {
|
||||
fadingOutRef.current = true
|
||||
fadeTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
video.style.opacity = '0'
|
||||
window.setTimeout(() => {
|
||||
video.currentTime = 0
|
||||
void video.play().catch(() => {})
|
||||
fadingOutRef.current = false
|
||||
fadeTo(1)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={VIDEO_URL}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
style={{ opacity: 0 }}
|
||||
className="absolute inset-0 w-full h-full object-cover translate-y-[17%]"
|
||||
onLoadedData={() => fadeTo(1)}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onEnded={handleEnded}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"category": "Access & logins",
|
||||
"question": "I forgot my password",
|
||||
"answer": "Most organisations have a self-service reset link on the sign-in page, usually 'Forgot password' or 'Can't access your account'. You'll be asked to verify your identity, normally with a code sent to a backup email or phone number you registered earlier. If there's no self-service option, or verification fails, your IT service desk can reset it after confirming your identity another way. Keep any backup codes you were given when MFA was set up somewhere safe, they're often the fastest way back in.",
|
||||
"link": "https://support.microsoft.com/en-us/account-billing/how-to-reset-your-microsoft-account-password-eff4f067-5042-c1a3-fe72-b04d60556c37",
|
||||
"linkLabel": "Microsoft: reset your password"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"category": "Access & logins",
|
||||
"question": "I'm locked out after too many failed attempts",
|
||||
"answer": "This is a security feature, not a fault, it stops someone guessing your password repeatedly. Most systems clear the lockout automatically after a set time, often 15 to 30 minutes, so waiting is sometimes the fastest fix. If it's urgent, or the lockout doesn't clear itself, your IT service desk can unlock the account manually once they've verified it's really you.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"category": "Access & logins",
|
||||
"question": "My multi-factor login (authenticator app) isn't working",
|
||||
"answer": "If you've lost your phone or the authenticator app stopped generating valid codes, try a saved backup code first, most systems issue a set of one-time codes when MFA is first set up. If you don't have one, contact IT to re-register your MFA device, this normally requires an identity check (staff or student ID, or a manager confirming it's you) since it's effectively resetting your account security.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"category": "Access & logins",
|
||||
"question": "My account is disabled or expired after returning from leave",
|
||||
"answer": "Accounts are sometimes automatically disabled after a period of inactivity, or a leave period that wasn't flagged as 'returning' in the system. This is an account status issue in the directory (Active Directory or similar), not a password problem, ask IT to check whether the account itself is disabled or expired rather than resetting the password again.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"category": "Access & logins",
|
||||
"question": "Single sign-on (SSO) keeps redirecting in a loop",
|
||||
"answer": "An SSO loop, bouncing between your organisation's login page and the app without ever landing you in, is usually a cookie or session problem rather than your password. Try a private or incognito browser window first, if that works cleanly, clear cookies for your organisation's login domain. If it still loops, the connection between your identity provider and that specific app may need IT to check its configuration.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"category": "Access & logins",
|
||||
"question": "A guest or contractor account isn't working",
|
||||
"answer": "Guest and contractor accounts usually go through a separate approval process, often needing an internal sponsor, and typically expire automatically after a set period for security. If a guest account stops working, check with the person who sponsored the access first, then IT if they confirm it should still be active.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"category": "Access & logins",
|
||||
"question": "My security questions aren't being accepted during recovery",
|
||||
"answer": "Recovery answers need to match exactly what was originally entered, including capitalisation in some systems, so a common cause is simply misremembering the exact wording. If you've genuinely forgotten the answers, look for a 'try another way' option instead of repeatedly guessing, most recovery tools offer an alternative path such as an email or SMS code.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"category": "Access & logins",
|
||||
"question": "A new password keeps being rejected as too weak or previously used",
|
||||
"answer": "Organisations enforce password rules (minimum length, a mix of character types, and often a block on reusing recent passwords) to reduce the risk of a compromised account. A longer passphrase, three or four random unrelated words plus a number and symbol, is both stronger and easier to remember than a short complex one, and tends to pass these checks more easily. Avoid names, birthdays or the organisation's own name.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"category": "Network & wifi",
|
||||
"question": "My wifi keeps dropping",
|
||||
"answer": "Try moving closer to the access point, restarting your device's wifi, and checking if other devices nearby have the same problem, that tells you whether it's your device or the network. On a corporate or school network with certificate-based login (802.1x), an expired certificate is a common cause, IT can reissue it. A network adapter driver that's out of date after a Windows update is another very common cause worth ruling out.",
|
||||
"link": "https://support.microsoft.com/en-us/windows/fix-wi-fi-connection-issues-in-windows-9424a1f7-6a3b-65a6-4d78-7f07eee84d2c",
|
||||
"linkLabel": "Microsoft: fix Wi-Fi connection issues"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"category": "Network & wifi",
|
||||
"question": "The VPN won't connect",
|
||||
"answer": "Check your normal internet connection works first. Then fully close and reopen the VPN app rather than just reconnecting, a half-open session is a common cause. Public wifi (cafes, hotels) sometimes blocks VPN traffic entirely until you've accepted its own login page in a browser first.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"category": "Network & wifi",
|
||||
"question": "I can't reach a shared drive or folder",
|
||||
"answer": "Confirm you're connected to the VPN or office network, shared drives usually don't work from the open internet. If it still fails, the folder permissions may have changed, ask IT to check your access rather than repeatedly trying to reconnect.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"category": "Network & wifi",
|
||||
"question": "I'm getting an IP conflict or 'limited connectivity' message",
|
||||
"answer": "This usually means your device didn't get a valid address from the network's DHCP server, often after being moved between networks or waking from sleep. Disconnecting and reconnecting to wifi, or running `ipconfig /release` then `ipconfig /renew` on Windows, fixes most cases. If it keeps happening on the same device, report it, it can indicate a DHCP address pool running low.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"category": "Network & wifi",
|
||||
"question": "A captive portal (hotel or cafe wifi login page) won't load",
|
||||
"answer": "If the login page doesn't appear automatically, try opening a plain, non-secure website, some networks only trigger the portal on unencrypted traffic. A corporate VPN client often blocks the portal from appearing at all, disconnect the VPN first, log in to the wifi, then reconnect the VPN.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"category": "Network & wifi",
|
||||
"question": "Wifi shows connected but the internet is very slow",
|
||||
"answer": "Being 'connected' only confirms a local network link, not that everything beyond it is fast. Run a speed test, then check whether it's isolated to one device (a device or driver issue) or affects everyone nearby, which usually points to the access point, the internet link itself, or a bandwidth-heavy device worth reporting to IT.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"category": "Network & wifi",
|
||||
"question": "A mapped network drive keeps failing after a password change",
|
||||
"answer": "A mapped drive often stores the old credentials separately, so it can keep failing silently even though you can log in to everything else fine. Disconnect the mapped drive and reconnect it fresh, entering the new password when prompted, rather than relying on the saved ones.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"category": "Network & wifi",
|
||||
"question": "A Bluetooth device won't pair",
|
||||
"answer": "Turn Bluetooth off and on again on both devices, and make sure the device is actually in pairing mode. If it was previously paired with a different computer, 'forget' or remove the old pairing on both ends first, most Bluetooth accessories only hold a limited number of paired connections at once.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"category": "Hardware",
|
||||
"question": "The printer won't print",
|
||||
"answer": "Check it's powered on, has paper and toner, and that you picked the right printer name in the print dialog. Network printers can show as 'offline' if they've lost wifi, turning them off and on again often fixes this.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"category": "Hardware",
|
||||
"question": "An external monitor isn't detected",
|
||||
"answer": "Check the cable is firmly in at both ends and try a different port or cable if you have one. Then open your display settings and click 'Detect'. A driver or graphics update can also help on older laptops.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"category": "Hardware",
|
||||
"question": "A USB device isn't recognised",
|
||||
"answer": "Try a different USB port, and a different cable if it's detachable. If it still doesn't show up, check Device Manager (Windows) or System Report (Mac) for a warning icon next to the device.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"category": "Hardware",
|
||||
"question": "A laptop won't turn on at all",
|
||||
"answer": "First rule out a flat battery, plug in the charger, check for a charging light, then leave it 15 to 30 minutes before trying again, some laptops won't start with a completely dead battery even while charging. If there's a charging light but still nothing on screen, try an external monitor, that tells you whether it's a screen problem or the laptop genuinely isn't starting.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"category": "Hardware",
|
||||
"question": "The battery is draining fast or won't charge",
|
||||
"answer": "Test the charger and cable on a different device or outlet first, to rule out a faulty charger rather than the laptop itself. Battery health does genuinely fade over 2 to 4 years of daily use, if the laptop is older and the drop was gradual, a battery replacement through IT asset management is often the real fix rather than a software setting.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"category": "Hardware",
|
||||
"question": "The keyboard or trackpad isn't responding",
|
||||
"answer": "Restart the device first, a stuck driver is a common and simple cause. If only specific keys are affected, check for anything physically stuck underneath, common after spills or crumbs, and try an external USB keyboard or mouse to confirm whether it's the built-in hardware or a wider fault.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"category": "Hardware",
|
||||
"question": "A docking station isn't detecting monitors or peripherals",
|
||||
"answer": "Confirm the dock itself has power, many need their own power cable, not just the laptop cable, and try unplugging the laptop from the dock and reconnecting it firmly. Docks that connect via USB-C or Thunderbolt can be picky about cable quality, swap the cable before assuming the dock has failed.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"category": "Hardware",
|
||||
"question": "The webcam isn't detected by any app",
|
||||
"answer": "Some laptops have a physical privacy shutter or a function-key toggle that disables the camera entirely, check for both before assuming it's broken. Checking Device Manager (Windows) or System Information (Mac) shows whether the operating system sees the camera at all, if it's not listed there, it's a driver or hardware fault rather than an app setting.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"category": "Software & apps",
|
||||
"question": "My computer is running very slow",
|
||||
"answer": "Restart it first, this clears a surprising number of slowdowns. After that, check how much storage is free and close programs you're not using that start automatically. If it's still slow, let IT know, it could be a background update or a bigger issue.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"category": "Software & apps",
|
||||
"question": "An app keeps crashing or won't open",
|
||||
"answer": "Close and reopen it, then restart your computer if that doesn't help. Check whether an update is available. If it's an approved work app that IT installed for you, they may need to reinstall it rather than you doing it yourself.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"category": "Software & apps",
|
||||
"question": "My email isn't syncing or I'm missing messages",
|
||||
"answer": "Check your internet connection and your junk or spam folder. Also check your mailbox isn't full, a full mailbox silently stops receiving mail in some systems. Outlook and Gmail both have a 'repair account' option worth trying.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"category": "Software & apps",
|
||||
"question": "My camera isn't working in Microsoft Teams",
|
||||
"answer": "Close other apps that might already be using the camera (like Zoom or FaceTime), unplug and reconnect it if it's external, and restart the device. Make a test call from Teams' device settings to check camera, mic and speaker together in one go.",
|
||||
"link": "https://support.microsoft.com/en-US/teams/meetings/my-camera-isn-t-working-in-microsoft-teams",
|
||||
"linkLabel": "Microsoft: Teams camera troubleshooting"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"category": "Software & apps",
|
||||
"question": "Zoom audio or video isn't working",
|
||||
"answer": "Check the correct microphone, speaker and camera are selected inside Zoom's own settings, not just that your device isn't muted at the operating system level. If it's a browser-based meeting, the browser also needs camera and microphone permission granted specifically for that site.",
|
||||
"link": "https://support.zoom.us/",
|
||||
"linkLabel": "Zoom support"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"category": "Software & apps",
|
||||
"question": "Windows Update is stuck or won't install",
|
||||
"answer": "Restart the device once and let it sit for at least 30 minutes, some updates genuinely take a long time on the 'working on updates' screen, and interrupting them can cause more problems than waiting. If it's still stuck well beyond that, or fails repeatedly, note the exact error code for IT, the built-in update troubleshooter resolves a lot of common stuck updates on its own.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"category": "Software & apps",
|
||||
"question": "Office or Microsoft 365 shows 'unlicensed product' or won't activate",
|
||||
"answer": "This usually means the app can't confirm your licence with Microsoft's servers, often because you're signed in with the wrong account, or you've been offline too long since the last successful check-in. Sign out and back in with your work or school account specifically, not a personal Microsoft account, and make sure you're online or on VPN when it tries to reactivate.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"category": "Software & apps",
|
||||
"question": "'Access denied' or 'permission denied' opening a file",
|
||||
"answer": "This is almost always a permissions setting on the file or folder itself, not a fault with your computer. Confirm you're opening it from the right location, a synced local copy can behave differently to the shared original, and if you believe you should have access, ask the file's owner or IT to check the permission list rather than trying to force it open.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"category": "Security",
|
||||
"question": "I received a suspicious email, is it phishing?",
|
||||
"answer": "Don't click links or open attachments. Check the sender's actual email address, not just the display name, and look for urgency or requests for passwords or gift cards, common phishing signs. Report it to your IT security team rather than deleting it, they may need to warn others across the organisation.",
|
||||
"link": "https://www.cyber.gov.au/report-and-recover",
|
||||
"linkLabel": "ACSC: report a cybercrime (ReportCyber)"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"category": "Security",
|
||||
"question": "My computer crashed or showed a blue screen",
|
||||
"answer": "Note down any error code or message shown before it restarts, a photo on your phone is fine. Restart the computer. If it happens more than once, that error code is exactly what IT will need to diagnose it.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"category": "Security",
|
||||
"question": "I think I've been hit by ransomware",
|
||||
"answer": "If files suddenly become inaccessible with strange new extensions, or a ransom note appears, disconnect the device from the network immediately (unplug the cable or turn off wifi) without shutting it down, this can stop it spreading before you power off. Don't pay anything and don't try to remove it yourself, contact IT security immediately, this is treated as a priority incident.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"category": "Security",
|
||||
"question": "I suspect my account has been compromised",
|
||||
"answer": "Change your password immediately from a device you trust, and check for any MFA devices, mail forwarding rules, or app connections you don't recognise, attackers often quietly add a forwarding rule or a second MFA device to keep access even after the password changes. Report it to IT security even after you've changed the password, they may need to check for wider impact.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"category": "Security",
|
||||
"question": "What actually makes a strong password or passphrase",
|
||||
"answer": "Length matters more than complexity, three or four random unrelated words make a passphrase that's both stronger and easier to remember than a short complex one. Never reuse a password across personal and work accounts, and use a password manager rather than trying to remember unique passwords for everything, most organisations provide or approve one.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"category": "Security",
|
||||
"question": "How to report a data breach or accidental data exposure",
|
||||
"answer": "If personal or sensitive information was sent to the wrong person, uploaded somewhere public by mistake, or a device holding such data is lost or stolen, report it to IT and your privacy contact as soon as possible. Timing genuinely matters for breach response, and in government and other regulated sectors there can be mandatory notification obligations attached.",
|
||||
"link": "https://www.cyber.gov.au/report-and-recover",
|
||||
"linkLabel": "ACSC: report and recover"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"category": "Security",
|
||||
"question": "Windows is asking for a BitLocker recovery key",
|
||||
"answer": "This usually appears after a hardware or firmware change, like a BIOS update, a new battery, or a changed boot order, that the encryption chip treats as suspicious, it's the protection working as intended, not a sign of a hack. The 48-digit recovery key is normally stored in your organisation's directory (Azure AD/Intune) or was saved separately when the device was set up, IT can retrieve it if you don't have it yourself. Microsoft itself cannot recover a lost key, so if neither you nor IT can locate it, the drive has to be reset.",
|
||||
"link": "https://support.microsoft.com/en-us/help/4026181",
|
||||
"linkLabel": "Microsoft: find your BitLocker recovery key"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"category": "Security",
|
||||
"question": "A Mac is asking for a FileVault recovery key",
|
||||
"answer": "Similar to Windows BitLocker, this appears after certain hardware or firmware changes and is the encryption protecting your data working as designed. The recovery key is usually escrowed with your organisation's MDM (Jamf or similar), IT can retrieve it if it wasn't saved when the device was set up.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"category": "Security",
|
||||
"question": "Antivirus or endpoint protection just quarantined a file or blocked something",
|
||||
"answer": "Don't try to restore or override it yourself, even if you're confident the file is safe, false positives do happen but confirming that is IT security's job, not yours. Report exactly what you were doing and what the alert said, that detail is what determines whether it's a false positive or something needing wider action.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"category": "Security",
|
||||
"question": "A USB drive or device is blocked by policy",
|
||||
"answer": "Many organisations block USB storage devices entirely as a data-loss-prevention measure, this is deliberate policy rather than a fault, and it usually can't be worked around with a different port or drive. If you have a genuine business need to transfer files this way, request an exception through IT rather than trying another device.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"category": "Sector notes",
|
||||
"question": "Why are some settings locked on my device?",
|
||||
"answer": "Government, school and many corporate devices are deliberately locked down for security, this is policy, not a fault. Australian government agencies generally follow the ACSC's Essential Eight security controls, which is why things like installing your own software or changing certain settings are restricted to IT staff.",
|
||||
"link": "https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/essential-eight",
|
||||
"linkLabel": "ACSC: Essential Eight explained"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"category": "Sector notes",
|
||||
"question": "School: why is a legitimate website or resource blocked?",
|
||||
"answer": "Australian schools typically apply internet content filtering as required by state and territory education department policy, and manage student devices through a mobile device management (MDM) system. If a legitimate educational site is blocked, that's a filtering rule, not a fault, report it to the school's IT support so they can review or whitelist it rather than trying to work around the filter.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"category": "Sector notes",
|
||||
"question": "Government: why can't I install software myself?",
|
||||
"answer": "Restricting who can install software (application control) is one of the Essential Eight strategies Australian government agencies are expected to implement, it stops unapproved or malicious software running on government devices. If you need software for a work purpose, the usual path is a request to IT for review and installation through an approved, managed process.",
|
||||
"link": "https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/essential-eight",
|
||||
"linkLabel": "ACSC: Essential Eight explained"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"category": "Sector notes",
|
||||
"question": "Corporate: why can't I do everything on my personal (BYOD) device?",
|
||||
"answer": "A personal device enrolled for work access (BYOD) is usually only given a limited, contained work profile rather than full management of the whole device, protecting both your personal data and the organisation's. If a work app or file won't open, or copy-paste out of a work app is blocked, that's often a deliberate BYOD policy restriction rather than a technical fault.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"category": "How IT support works",
|
||||
"question": "What do Level 1, 2, 3 (and 4) support actually mean?",
|
||||
"answer": "Level 1 is the frontline help desk, handling common self-service issues like this page's Access, Wifi and Hardware topics, usually resolved in one call or ticket. Level 2 is more technical staff who handle things needing deeper diagnosis, like the Network Hardware topics here. Level 3 is specialist or senior engineers, often involved in design and complex incidents, and Level 4 is the vendor's own support (Cisco TAC, Fortinet support), brought in when the issue is inside the product itself, not the configuration.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"category": "How IT support works",
|
||||
"question": "When should something be escalated to the next level?",
|
||||
"answer": "As a general rule, escalate when the fix needs a permission, tool, or specialist knowledge the current level doesn't have, not simply because an issue is taking a long time. A good escalation includes what's already been tried, exact error messages, and which users or systems are affected, that context is what actually speeds up the next level's diagnosis.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"category": "How IT support works",
|
||||
"question": "What's the difference between an incident, a request, and a problem?",
|
||||
"answer": "An incident is something broken right now (the printer isn't printing, the VPN is down), the goal is to restore service quickly. A request is asking for something new (new software, more storage, account access), it follows an approval process rather than a fix. A problem is the underlying root cause behind repeated incidents, if the same printer fails every Monday, that pattern gets investigated as a problem so it stops recurring, rather than just fixed each time it happens.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"category": "How IT support works",
|
||||
"question": "Where does a change request fit in?",
|
||||
"answer": "A change request is a planned, deliberate modification to a system, like a firmware upgrade or a new firewall rule, done through an approval process because unplanned changes are one of the most common causes of new incidents. This is why network hardware changes especially are usually scheduled for a maintenance window rather than made on the spot.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"category": "Mobile & remote work",
|
||||
"question": "A phone or tablet won't enrol in the company's mobile device management (MDM)",
|
||||
"answer": "Confirm you're using the exact enrolment method IT specified (a QR code, an enrolment URL, or a specific app store link), a generic 'add work account' option in phone settings sometimes skips steps the proper enrolment method includes. Also check the device's operating system version meets what IT requires, MDM enrolment is commonly blocked on outdated OS versions for security reasons.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"category": "Mobile & remote work",
|
||||
"question": "Work email or apps stopped working on a personal phone after an update",
|
||||
"answer": "A phone OS update can reset app permissions or trigger a compliance re-check by the organisation's MDM, temporarily blocking access until the device is confirmed to still meet security requirements like having a passcode set. Check for a compliance or 'action needed' notification from the company portal app before assuming it's broken.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"category": "Mobile & remote work",
|
||||
"question": "A mobile VPN drops every time the screen locks",
|
||||
"answer": "Many phones aggressively kill background network connections to save battery, including VPN apps, unless the app is specifically excluded from battery optimisation. Check the phone's battery settings for an 'unrestricted' or 'no optimisation' option for that VPN app specifically.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"category": "Mobile & remote work",
|
||||
"question": "A remote desktop (RDP) session freezes or won't connect",
|
||||
"answer": "Confirm you're connected to the VPN first if the remote machine is inside a private network, RDP almost never works over the open internet directly, for good security reasons. If the VPN is connected but it still fails, check whether the remote machine is actually powered on and not asleep, a surprisingly common cause.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"category": "Mobile & remote work",
|
||||
"question": "A VDI session (Citrix, Horizon) is slow or won't launch",
|
||||
"answer": "Test your own internet connection's latency and stability first, VDI performance is very sensitive to a poor home connection in a way a locally installed app isn't. If your connection is fine, try a different VDI client version or a browser-based login as a workaround while reporting the failure to IT with the exact error shown.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"category": "Mobile & remote work",
|
||||
"question": "Working from home, the VPN connects but internal apps still time out",
|
||||
"answer": "Some VPN setups use 'split tunnelling', where only traffic for specific internal systems goes through the VPN and everything else uses your home internet directly, if a newer internal app isn't yet included in that list, it will time out even with the VPN connected. That's a configuration detail for IT to add, not something you can usually fix from your end.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"category": "Cloud storage & sync",
|
||||
"question": "OneDrive is stuck on 'processing changes' or won't sync",
|
||||
"answer": "Pause and resume syncing from the OneDrive icon in the system tray, this resets a stuck sync engine more often than you'd expect. A very long file path, a file open in another program, or special characters in a file name are the next most common causes of a stuck sync. Moving the affected file out of the synced folder and back in again can also clear a single stuck item without resetting the whole sync.",
|
||||
"link": "https://support.microsoft.com/en-us/onedrive/fix-onedrive-sync-problems",
|
||||
"linkLabel": "Microsoft: fix OneDrive sync problems"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"category": "Cloud storage & sync",
|
||||
"question": "Google Drive shows a red exclamation mark on a file",
|
||||
"answer": "This means that specific file failed to sync, usually because it's open in another application, too large for your remaining storage quota, or has a file type or name Drive can't handle. Hover over the icon for the exact reason, then close the file elsewhere or check your storage quota before retrying.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"category": "Cloud storage & sync",
|
||||
"question": "Cloud storage quota is full",
|
||||
"answer": "Check what's actually taking up space first, large video files and old email attachments are common causes, rather than immediately requesting more storage. If you genuinely need more for legitimate work purposes, that's a request rather than an incident (see 'How IT support works'), so it goes through IT's normal approval process.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"category": "Cloud storage & sync",
|
||||
"question": "A shared file link says 'access denied' for someone outside the organisation",
|
||||
"answer": "Many organisations restrict external sharing by default for security, so a link that works fine internally can fail for an outside recipient. Check the specific sharing settings on that file or folder, external sharing is often something that needs to be deliberately enabled per-file or per-site, rather than being on by default.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"category": "Cloud storage & sync",
|
||||
"question": "Files show as 'online-only' and won't open without internet",
|
||||
"answer": "Cloud sync clients often keep infrequently used files as a placeholder to save local disk space, downloading the real content only when opened, which fails without an internet connection. Right-click the specific file or folder you need offline and choose 'always keep on this device' before you lose connectivity, before a flight or a site visit, for example.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"category": "macOS specific",
|
||||
"question": "A Mac update is stuck or won't install",
|
||||
"answer": "Confirm there's enough free disk space, macOS updates need significant free space to stage the install and fail silently without it. Restart once and try again from System Settings rather than clicking install repeatedly, which can leave the updater in a confused state.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"category": "macOS specific",
|
||||
"question": "Time Machine backup keeps failing",
|
||||
"answer": "Check the backup drive isn't full and is actually connected and mounted, an intermittent external drive connection is the most common cause. If backing up to a network location, confirm you can still reach that server or NAS at all, a backup failure is often really a network or permissions issue in disguise.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"category": "macOS specific",
|
||||
"question": "A Mac keeps asking for the keychain password unexpectedly",
|
||||
"answer": "This usually happens after a password change that the login keychain doesn't yet know about, since it's a separate password store from your account login. Go to Keychain Access and update or reset the login keychain's password to match your new account password, rather than repeatedly dismissing the prompt. Resetting the keychain does clear its saved passwords, so only do this if you can't recover the old one.",
|
||||
"link": "https://support.apple.com/guide/keychain-access/kyca2429/mac",
|
||||
"linkLabel": "Apple: update your keychain password"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"category": "macOS specific",
|
||||
"question": "A Mac won't join a corporate wifi network with certificate login",
|
||||
"answer": "Confirm the required certificate is actually installed in the Mac's keychain, it's normally pushed automatically by MDM (Jamf or similar) but can fail to install if the Mac hasn't checked in with MDM recently. Manually forcing an MDM check-in (System Settings > general profile management) before retrying the wifi connection resolves this in many cases.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A Cisco switch port shows no link light",
|
||||
"answer": "Start at the physical layer: reseat the cable, try a different port, and check for a bad SFP. Then confirm the port isn't administratively shut down with `show interfaces status`, if it is, `no shutdown` brings it back up.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A Cisco port went err-disabled",
|
||||
"answer": "This means the switch shut the port down itself, usually after a security violation, a BPDU guard trip, or a duplex mismatch. Check the reason with `show interfaces status err-disabled`, fix the underlying cause, then cycle the port with `shutdown` then `no shutdown`.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "Devices can't reach each other across a Cisco VLAN or trunk",
|
||||
"answer": "Confirm the VLAN exists on both switches with `show vlan brief`, then check the trunk with `show interfaces trunk`. A mismatched native VLAN or a VLAN missing from the allowed list is the most common cause.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 69,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A Cisco router or Layer 3 switch can't reach a remote subnet",
|
||||
"answer": "Check `show ip interface brief` to confirm the interface is up, then `show ip route` to confirm a route exists. `ping` and `traceroute` will show exactly where the path breaks.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 70,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A Cisco device is running hot (high CPU)",
|
||||
"answer": "Check `show processes cpu sorted` to see what's using it. A spanning-tree loop or broadcast storm is a common cause on switches, `show spanning-tree` will show repeated topology changes if that's happening.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 71,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A FortiGate isn't passing traffic that looks like it should be allowed",
|
||||
"answer": "Confirm a firewall policy actually matches the source, destination and service, then use `diagnose sniffer packet any 'host x.x.x.x' 4` to see if traffic is arriving, and `diagnose debug flow` to see how the FortiGate is treating it.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 72,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A FortiGate site-to-site IPsec VPN won't come up",
|
||||
"answer": "Phase 1 and Phase 2 settings (encryption, PSK or certificate, proposals) must match exactly on both ends. `diagnose vpn ike log-filter` with `diagnose debug application ike -1` shows exactly where negotiation is failing.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 73,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A FortiGate is slow or unresponsive",
|
||||
"answer": "Check `get system performance status` for CPU, memory and session count, and `diagnose sys top` to see which process is responsible. A nearly-full session table is a common cause on busy units.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 74,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "Can't reach the FortiGate admin GUI from a particular network",
|
||||
"answer": "FortiGate can restrict admin access to trusted hosts and specific interfaces. Check the admin account's trusted host settings and confirm the interface you're connecting through has HTTPS/SSH admin access enabled.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 75,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "How to safely back up a Cisco device's configuration",
|
||||
"answer": "Before any change, save the running config to the startup config with `copy running-config startup-config`, then copy it off the device via TFTP or SCP so there's an external copy too. Always confirm the backup completed before making changes.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 76,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "How to safely upgrade Cisco IOS firmware",
|
||||
"answer": "Confirm the new image is supported on the exact hardware model and that there's enough flash and RAM. Copy the image to flash, verify its checksum, set the new boot variable, save the config, then reload in a maintenance window in case it needs rolling back.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 77,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "Lost the password to a Cisco device",
|
||||
"answer": "Password recovery needs physical console access and a reboot into ROMMON mode to bypass the startup config temporarily. This is a standard, well-documented process, but the exact key combination differs by model, check the vendor documentation for your specific device first.",
|
||||
"link": "https://www.cisco.com/c/en/us/support/index.html",
|
||||
"linkLabel": "Cisco Support"
|
||||
},
|
||||
{
|
||||
"id": 78,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A wireless access point won't join the Cisco WLC",
|
||||
"answer": "Check the AP can discover the controller (DHCP option 43, DNS, or same-subnet broadcast), and that the AP's firmware is compatible with the controller's software version, a mismatch is a common cause of a failed join.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 79,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "Traffic is blocked on a Cisco ASA despite a matching ACL",
|
||||
"answer": "Check NAT rules aren't exempting or redirecting the traffic unexpectedly, and confirm the ACL is applied in the right direction on the right interface. `packet-tracer` simulates a specific flow and shows exactly which rule drops it.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 80,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "One FortiGate in an HA cluster won't sync",
|
||||
"answer": "Check both units are running the identical firmware version first, a mismatch is the most common HA sync blocker. `diagnose sys ha status` shows the current sync state and any errors.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 81,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A FortiSwitch isn't showing up under FortiGate management",
|
||||
"answer": "FortiGate manages FortiSwitches over FortiLink, confirm the FortiLink interface is correctly configured and that the switch port used for FortiLink hasn't been repurposed for something else.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 82,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A FortiAP won't join its FortiGate",
|
||||
"answer": "Confirm the AP can discover the FortiGate (same subnet, or DHCP option 138/CAPWAP discovery across routed networks) and that the AP and FortiGate firmware are a supported combination.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 83,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "SD-WAN traffic is taking the wrong path on a FortiGate",
|
||||
"answer": "Check the SD-WAN rule order, first match wins, and whether the SLA health checks for that link are actually passing, a failed health check silently removes a path from consideration. `diagnose sys sdwan` shows live link status.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 84,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A FortiGate says its licence or support contract has expired",
|
||||
"answer": "An expired contract stops signature and firmware updates but doesn't usually stop the firewall passing traffic. Check the exact status with `get system status`, then renew through your reseller or Fortinet, running without updates for long is a real security risk.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 85,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "Where to find full Cisco documentation and command syntax",
|
||||
"answer": "Always confirm exact command syntax against Cisco's own docs, it changes between switch/router models and IOS versions.",
|
||||
"link": "https://www.cisco.com/c/en/us/support/index.html",
|
||||
"linkLabel": "Cisco Support"
|
||||
},
|
||||
{
|
||||
"id": 86,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "Where to find full Fortinet documentation and command syntax",
|
||||
"answer": "FortiOS CLI commands and defaults vary by firmware version, the official Fortinet Document Library is the source of truth.",
|
||||
"link": "https://docs.fortinet.com/",
|
||||
"linkLabel": "Fortinet Document Library"
|
||||
},
|
||||
{
|
||||
"id": 87,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "An IPsec tunnel looks correctly configured but still won't come up",
|
||||
"answer": "When the config looks right on paper, checking the live IKE negotiation logs tells you more than re-checking the configuration a third time. Fortinet's own support community has a detailed walkthrough of the exact `diagnose` commands and log filters their engineers use for exactly this scenario.",
|
||||
"link": "https://community.fortinet.com/t5/FortiGate/Troubleshooting-Tip-IPsec-VPN-tunnels/ta-p/195955",
|
||||
"linkLabel": "Fortinet Community: IPsec VPN troubleshooting"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.liquid-glass {
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
background-blend-mode: luminosity;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
border: none;
|
||||
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.liquid-glass::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1.4px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.45) 0%,
|
||||
rgba(255, 255, 255, 0.15) 20%,
|
||||
rgba(255, 255, 255, 0) 40%,
|
||||
rgba(255, 255, 255, 0) 60%,
|
||||
rgba(255, 255, 255, 0.15) 80%,
|
||||
rgba(255, 255, 255, 0.45) 100%
|
||||
);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
// Safe wrappers: localStorage can be blocked (private mode), so never let it crash the page.
|
||||
export function load<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? (JSON.parse(raw) as T) : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function save(key: string, value: unknown): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface Faq {
|
||||
id: number
|
||||
category: string
|
||||
question: string
|
||||
answer: string
|
||||
link: string
|
||||
linkLabel: string
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/ContactForm.tsx","./src/Faq.tsx","./src/Staff.tsx","./src/VideoBackground.tsx","./src/main.tsx","./src/storage.ts","./src/types.ts","./src/vite-env.d.ts","./vite.config.ts"],"version":"5.6.3"}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
server: { proxy: { '/api': 'http://localhost:3001' } },
|
||||
})
|
||||
Generated
-550
@@ -1,550 +0,0 @@
|
||||
{
|
||||
"name": "claude-animation-base",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-animation-base",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p5": "^2.3.3",
|
||||
"p5.brush": "^2.2.3",
|
||||
"puppeteer-core": "^25.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@davepagurek/bezier-path": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@davepagurek/bezier-path/-/bezier-path-0.0.7.tgz",
|
||||
"integrity": "sha512-CVlnCOrV1iy4Z12T756i9l4G6kF7r8uhlnb+xqDemAMmWQB+8Q0b+8VEqIiUfywgZDSiDr18Rm7pZlnA69rE8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@japont/unicode-range": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@japont/unicode-range/-/unicode-range-1.0.0.tgz",
|
||||
"integrity": "sha512-BckHvA2XdjRBVAWe2uceNuRf78lBeI28kyWEbfr/Q2pE17POkwuZ6WWY/UMv8FL9iBxhW4xfDoNLM9UVZaTeUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz",
|
||||
"integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"modern-tar": "^0.8.4",
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"browsers": "lib/main-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"proxy-agent": ">=8.0.1",
|
||||
"yauzl": "^2.10.0 || ^3.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"proxy-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"yauzl": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
|
||||
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"devtools-protocol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/colorjs.io": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.6.1.tgz",
|
||||
"integrity": "sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/color"
|
||||
}
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1680125",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1680125.tgz",
|
||||
"integrity": "sha512-rVARKAvOFJCFkvgX01VLvYqKIvt+cRCqvUxIhB2oAqVGPalxk3w3sb5TUGDagpow/EFblBpm/Zl/29QA2dd+jA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escodegen": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
|
||||
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"esprima": "^4.0.1",
|
||||
"estraverse": "^5.2.0",
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"escodegen": "bin/escodegen.js",
|
||||
"esgenerate": "bin/esgenerate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"source-map": "~0.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/estraverse": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
|
||||
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gifenc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
|
||||
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "19.9.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-19.9.2.tgz",
|
||||
"integrity": "sha512-0i6cuo6ER6usEOtKajUUDj92zlG+KArFia0857xxiEHAQcUwh/RtOQocui1LPJwunSYT574Pk64aNva1kwtxZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-4.3.1.tgz",
|
||||
"integrity": "sha512-KIToAzf8zwWvacgnRwJp63ase26o24AuNUlfNVJ5YZAFmdGhsJpmFClxXPuk9rv1FMI4lnc8zLSqgZPEZMrW4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/libtess": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/libtess/-/libtess-1.2.2.tgz",
|
||||
"integrity": "sha512-Nps8HPeVVcsmJxUvFLKVJcCgcz+1ajPTXDVAVPs6+giOQP4AHV31uZFFkh+CKow/bkB7GbZWKmwmit7myaqDSw==",
|
||||
"license": "SGI-B-2.0"
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/modern-tar": {
|
||||
"version": "0.8.5",
|
||||
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
|
||||
"integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/omggif": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
|
||||
"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p5": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/p5/-/p5-2.3.3.tgz",
|
||||
"integrity": "sha512-+zVz5JFp3sCLpLscTMOLoA0UEHV0dsHxngKVfMmeW1J2/IXvfFqCjNYZeIOtG4Lhfnpm7cu6446P6Ho64ihXfA==",
|
||||
"license": "LGPL-2.1",
|
||||
"dependencies": {
|
||||
"@davepagurek/bezier-path": "^0.0.7",
|
||||
"@japont/unicode-range": "^1.0.0",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-walk": "^8.3.4",
|
||||
"colorjs.io": "^0.6.0",
|
||||
"escodegen": "^2.1.0",
|
||||
"gifenc": "^1.0.3",
|
||||
"i18next": "^19.0.2",
|
||||
"i18next-browser-languagedetector": "^4.0.1",
|
||||
"libtess": "^1.2.2",
|
||||
"omggif": "^1.0.10",
|
||||
"pako": "^2.1.0",
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/p5.brush": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/p5.brush/-/p5.brush-2.2.3.tgz",
|
||||
"integrity": "sha512-Jxl7Rl01StakZN7OnaY+HYLXvemeIiCnDzVz6qnvsABF8GunCBcQON7xUKZUjV1E/Ab7VIFSMOspmVzLO/ljTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"simplex-noise": "^4.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"p5": "^2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "25.11.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.11.0.tgz",
|
||||
"integrity": "sha512-Ujym7JbxoAqanaxGcHgmTomfhC01HfDCSuGZ8Hs9boHrDjzbNVMnFRJo2KtbDT9LCRgeJoFnkn9JQZ41eEUrLw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.2.2",
|
||||
"chromium-bidi": "17.0.2",
|
||||
"devtools-protocol": "0.0.1680125",
|
||||
"typed-query-selector": "^2.12.2",
|
||||
"webdriver-bidi-protocol": "0.4.3",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simplex-noise": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz",
|
||||
"integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-query-selector": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
|
||||
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz",
|
||||
"integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
|
||||
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^8.2.1",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-11
@@ -1,16 +1,11 @@
|
||||
{
|
||||
"name": "claude-animation-base",
|
||||
"version": "1.0.0",
|
||||
"name": "cycle-atlas-root",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"description": "Clawd, painted in p5.brush: a character kit and guide for making hand-painted Clawd animations",
|
||||
"version": "1.0.0",
|
||||
"description": "Root wrapper so hosts can build and start the IT Support site in it-support/",
|
||||
"engines": { "node": ">=22.13" },
|
||||
"scripts": {
|
||||
"sheet": "node render.mjs --sheet=0,1,2,3,4,5 --out=out/sheet.jpg",
|
||||
"video": "node render.mjs --clip --out=out/video.mp4"
|
||||
},
|
||||
"dependencies": {
|
||||
"p5": "^2.3.3",
|
||||
"p5.brush": "^2.2.3",
|
||||
"puppeteer-core": "^25.11.0"
|
||||
"build": "npm --prefix it-support install --include=dev && npm --prefix it-support run build",
|
||||
"start": "NODE_ENV=production node it-support/server/index.js"
|
||||
}
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
// render.mjs: renders studio.html in headless Chrome. Length and fps come from the page (PROJECT in src/config.js).
|
||||
//
|
||||
// Look at it (open the images with your image viewer / Read tool):
|
||||
// node render.mjs --sheet=0.5,1,1.5,2 [--cols=4] [--w=480] --out=out/check/a.jpg contact sheet of chosen times
|
||||
// node render.mjs --strip=2.0:2.5 [--cols=6] [--w=320] --out=out/check/strip.jpg EVERY frame in a stretch (motion)
|
||||
// node render.mjs --sheet=2.1,2.2 --crop=760,300,400,400 --w=600 --out=out/check/face.jpg full-res crops (details)
|
||||
// node render.mjs --strip=2.0:2.5 --crop-at=960,780,500,400 --out=out/check/feet.jpg crops that follow a WORLD point
|
||||
// (x,y in world px, may be page expressions like PLK.MX(1.38); w,h in screen px) through each frame's camera
|
||||
// node render.mjs --stills=1.2,3.4 --out=out/stills full-res PNGs
|
||||
// Make the video:
|
||||
// node render.mjs --clip [--range=0:4] --out=out/video.mp4 straight to MP4 (one worker)
|
||||
// node render.mjs --frames [--range=0:8] --workers=4 JPEG frames → out/frames (parallel, resumable)
|
||||
// node render.mjs --encode --out=out/video.mp4 out/frames → MP4
|
||||
// Standalone loops (LOOPS in the page): add --loop=<name> to any of the above (times are then loop times), or
|
||||
// node render.mjs --loop=emotions --png --out=out/loop_emotions one cycle as PNGs (for GIFs)
|
||||
// Music: --audio=assets/song.mp3 (or PROJECT.audio) is muxed into --clip and --encode. Other flags: --fps=24,
|
||||
// --chrome=<path to Chrome/Chromium>.
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync, existsSync, statSync, renameSync, readdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; }));
|
||||
const CHROMES = [args.chrome, process.env.CHROME_PATH, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
||||
...playwrightChromes()];
|
||||
// Chromium builds Playwright downloaded (~/.cache/ms-playwright/chromium-NNNN), newest first
|
||||
function playwrightChromes() {
|
||||
const dir = `${homedir()}/.cache/ms-playwright`;
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).filter(n => /^chromium-\d+$/.test(n)).sort((a, b) => b.split('-')[1] - a.split('-')[1])
|
||||
.map(n => `${dir}/${n}/chrome-linux64/chrome`);
|
||||
}
|
||||
const CHROME = CHROMES.find(p => p && existsSync(p));
|
||||
if (!CHROME) { console.error('Chrome not found: pass --chrome=<path> or set CHROME_PATH'); process.exit(1); }
|
||||
const fps = +(args.fps || 24), FRAMES_DIR = 'out/frames';
|
||||
const run = (cmd, a) => new Promise((ok, bad) => { const p = spawn(cmd, a, { stdio: 'inherit' }); p.on('close', c => c ? bad(new Error(cmd + ' exited ' + c)) : ok()); });
|
||||
const times = s => String(s).split(',').map(Number);
|
||||
const span = s => String(s).split(':').map(Number);
|
||||
// comma-separated fields, keeping commas inside parentheses ('PLK.MX(1.38),PLK.WL,500,300'); numbers stay numbers
|
||||
const fields = s => { const out = []; let d = 0, cur = ''; for (const ch of String(s)) { if (ch === ',' && !d) { out.push(cur); cur = ''; continue; } d += ch === '(' ? 1 : ch === ')' ? -1 : 0; cur += ch; } out.push(cur); return out.map(v => isNaN(+v) ? v : +v); };
|
||||
|
||||
if (args.encode) {
|
||||
const out = args.out || 'out/video.mp4', n = readdirSync(FRAMES_DIR).filter(f => f.endsWith('.jpg')).length, audio = args.audio;
|
||||
console.log(`encoding ${n} frames → ${out}${audio ? ' with ' + audio : ''}`);
|
||||
await run('ffmpeg', ['-y', '-loglevel', 'error', '-stats', '-framerate', String(fps), '-i', `${FRAMES_DIR}/f%05d.jpg`,
|
||||
...(audio ? ['-i', audio, '-map', '0:v', '-map', '1:a', '-c:a', 'aac', '-b:a', '192k', '-shortest'] : []),
|
||||
'-c:v', 'libx264', '-preset', 'slow', '-crf', '17', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', out]);
|
||||
console.log('wrote ' + out);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --soft-gl: no GPU on this machine; render WebGL in software (SwiftShader), which Chrome only allows when asked.
|
||||
// --gpu-angle=vulkan|gl-egl: headless Linux on an NVIDIA GPU (e.g. a cloud or cluster node); plain --use-gl=angle gets
|
||||
// no WebGL context there. Check which GPU Chrome actually lands on with gpu_probe.mjs.
|
||||
const ANGLE = { vulkan: ['--use-angle=vulkan', '--enable-features=Vulkan'], 'gl-egl': ['--use-angle=gl-egl'] };
|
||||
if (args['gpu-angle'] && !ANGLE[args['gpu-angle']]) { console.error(`--gpu-angle must be one of ${Object.keys(ANGLE)}`); process.exit(1); }
|
||||
const gpu = args['soft-gl'] ? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']
|
||||
: args['gpu-angle'] ? ANGLE[args['gpu-angle']]
|
||||
: process.platform === 'win32' ? ['--use-angle=d3d11'] : process.platform === 'darwin' ? ['--use-angle=metal'] : ['--use-gl=angle'];
|
||||
// Ubuntu 23.10+ blocks Chrome's user-namespace sandbox; headless rendering of local files doesn't need it.
|
||||
const sandbox = process.platform === 'linux' ? ['--no-sandbox'] : [];
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME, headless: true, protocolTimeout: 0,
|
||||
args: [...sandbox, '--allow-file-access-from-files', '--ignore-gpu-blocklist', ...gpu, '--enable-gpu-rasterization', '--window-size=1920,1080', '--disable-renderer-backgrounding', '--disable-background-timer-throttling']
|
||||
});
|
||||
async function openPage(tag = '') {
|
||||
const page = await browser.newPage();
|
||||
page.on('console', m => { if (['error', 'warn'].includes(m.type())) console.log(`[page${tag}]`, m.text()); });
|
||||
page.on('pageerror', e => console.log(`[page error${tag}]`, e.message));
|
||||
await page.goto(pathToFileURL(resolve('studio.html')).href + '?render', { waitUntil: 'networkidle0', timeout: 300000 });
|
||||
await page.waitForFunction('window.ready === true', { timeout: 600000 });
|
||||
if (args.loop) {
|
||||
const ok = await page.evaluate(name => { if (!LOOPS[name]) return false; window.LOOP = LOOPS[name]; return true; }, args.loop);
|
||||
if (!ok) { console.error(`no loop named "${args.loop}"`); process.exit(1); }
|
||||
}
|
||||
return page;
|
||||
}
|
||||
const frameOf = async (page, t, type, q) => {
|
||||
const url = await page.evaluate((t, type, q) => window.renderAt(t, type, q), t, type, q);
|
||||
return Buffer.from(url.slice(url.indexOf(',') + 1), 'base64');
|
||||
};
|
||||
// the length of whatever is being rendered: a loop's .len, or the video's duration
|
||||
const lengthOf = page => page.evaluate(() => window.LOOP ? window.LOOP.len : DUR);
|
||||
|
||||
if (args.sheet || args.strip) {
|
||||
const page = await openPage(), out = args.out || 'out/sheet.jpg'; mkdirSync(dirname(out), { recursive: true });
|
||||
let ts;
|
||||
if (args.strip) { const [a, b] = span(args.strip); ts = []; for (let i = Math.round(a * fps); i <= Math.round(b * fps); i++) ts.push(i / fps); }
|
||||
else ts = times(args.sheet);
|
||||
const crop = args.crop ? times(args.crop) : null, at = args['crop-at'] ? fields(args['crop-at']) : null;
|
||||
const { url, ms } = await page.evaluate((ts, c, w, crop, at) => window.renderSheet(ts, c, w, crop, at), ts, +(args.cols || (args.strip ? 6 : 3)), +(args.w || (args.strip ? 320 : 640)), crop, at);
|
||||
writeFileSync(out, Buffer.from(url.slice(url.indexOf(',') + 1), 'base64'));
|
||||
console.log(`${out} (${ts.length} frames) ms/frame: ${ms.join(' ')}`);
|
||||
} else if (args.stills) {
|
||||
const page = await openPage(), out = args.out || 'out/stills'; mkdirSync(out, { recursive: true });
|
||||
console.log('GPU:', await page.evaluate(() => window.gpuInfo()));
|
||||
for (const s of times(args.stills)) {
|
||||
const t0 = Date.now(), buf = await frameOf(page, s, 'image/png');
|
||||
const f = `${out}/t${s.toFixed(2).replace('.', '_')}.png`; writeFileSync(f, buf);
|
||||
console.log(`${f} ${Date.now() - t0} ms`);
|
||||
}
|
||||
} else if (args.png) {
|
||||
// PNG sequence (for GIFs): a loop's full cycle (frame n equals frame 0, so it isn't rendered), or --range=a:b.
|
||||
const probe = await openPage(), len = await lengthOf(probe); await probe.close();
|
||||
const [a, b] = args.range ? span(args.range) : [0, len], n = Math.round((b - a) * fps);
|
||||
const out = args.out || `out/${args.loop ? 'loop_' + args.loop : 'png'}`, workers = +(args.workers || 3); mkdirSync(out, { recursive: true });
|
||||
let next = 0; const start = Date.now();
|
||||
await Promise.all(Array.from({ length: workers }, async (_, w) => {
|
||||
const page = await openPage('#' + w);
|
||||
while (next < n) { const i = next++; writeFileSync(`${out}/f${String(i).padStart(4, '0')}.png`, await frameOf(page, a + i / fps, 'image/png')); }
|
||||
}));
|
||||
console.log(`${n} frames → ${out} (${((Date.now() - start) / n).toFixed(0)} ms/frame)`);
|
||||
} else if (args.frames) {
|
||||
// Parallel and resumable: each worker pulls the next missing frame; files are written atomically.
|
||||
const probe = await openPage(), len = await lengthOf(probe); await probe.close();
|
||||
const [a, b] = args.range ? span(args.range) : [0, len], workers = +(args.workers || 4);
|
||||
mkdirSync(FRAMES_DIR, { recursive: true });
|
||||
const first = Math.round(a * fps), last = Math.min(Math.ceil(len * fps) - 1, Math.round(b * fps) - 1);
|
||||
const todo = []; for (let i = first; i <= last; i++) { const f = `${FRAMES_DIR}/f${String(i).padStart(5, '0')}.jpg`; if (!existsSync(f) || statSync(f).size < 1000) todo.push(i); }
|
||||
console.log(`${todo.length} frames to render (${last - first + 1 - todo.length} already done), ${workers} workers`);
|
||||
let next = 0, done = 0; const start = Date.now();
|
||||
await Promise.all(Array.from({ length: workers }, async (_, w) => {
|
||||
const page = await openPage('#' + w);
|
||||
while (next < todo.length) {
|
||||
const i = todo[next++], f = `${FRAMES_DIR}/f${String(i).padStart(5, '0')}.jpg`;
|
||||
const buf = await frameOf(page, i / fps, 'image/jpeg', .94);
|
||||
writeFileSync(f + '.tmp', buf); renameSync(f + '.tmp', f);
|
||||
if (++done % 24 === 0 || done === todo.length) {
|
||||
const el = (Date.now() - start) / 1000;
|
||||
console.log(`frame ${done}/${todo.length} ${(el / done * 1000).toFixed(0)} ms/frame effective eta ${((todo.length - done) * el / done / 60).toFixed(1)} min`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
} else if (args.clip) {
|
||||
const page = await openPage(), len = await lengthOf(page);
|
||||
const [a, b] = args.range ? span(args.range) : typeof args.clip === 'string' ? span(args.clip) : [0, len];
|
||||
const audio = args.audio || await page.evaluate(() => PROJECT.audio || '');
|
||||
const out = args.out || 'out/clip.mp4'; mkdirSync(dirname(out), { recursive: true });
|
||||
const ff = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', String(fps), '-c:v', 'mjpeg', '-i', '-',
|
||||
...(audio ? ['-ss', String(a), '-t', String(b - a), '-i', audio, '-map', '0:v', '-map', '1:a', '-c:a', 'aac', '-b:a', '192k', '-shortest'] : []),
|
||||
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', out],
|
||||
{ stdio: ['pipe', 'inherit', 'inherit'] });
|
||||
const n = Math.round((b - a) * fps), start = Date.now();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const buf = await frameOf(page, a + i / fps, 'image/jpeg', .93);
|
||||
if (!ff.stdin.write(buf)) await new Promise(r => ff.stdin.once('drain', r));
|
||||
if (i % 24 === 0 || i === n - 1) console.log(`frame ${i + 1}/${n} ${((Date.now() - start) / (i + 1)).toFixed(0)} ms/frame`);
|
||||
}
|
||||
ff.stdin.end(); await new Promise(r => ff.on('close', r));
|
||||
console.log(`wrote ${out}`);
|
||||
} else {
|
||||
console.log('nothing to do: see the usage notes at the top of render.mjs');
|
||||
}
|
||||
await browser.close();
|
||||
-644
@@ -1,644 +0,0 @@
|
||||
// clawd.js: Clawd, painted in watercolour and ink. The silhouette is blocky: a 10×6 body, four stubby legs, two little
|
||||
// arm nubs and two slit eyes. `u` is the body unit (the body is 10u wide and 8u tall with legs). (x, y) is the point on
|
||||
// the ground between the feet.
|
||||
//
|
||||
// Body-local coordinates in the front view, used by the o.draw / o.armL / o.armR hooks:
|
||||
// body x -5u..5u, y -8u..-2u; eye centres (±2.5u, -6u), each 1u wide and 2u tall; mouth near (0, -4.3u); legs reach y 0.
|
||||
// Arms pivot at (±4.9u, -4.5u) and are 2.2u long. o.armL / o.armR are called at the arm TIP in arm space (x runs
|
||||
// outward along the arm), so a held prop just draws around (0, 0).
|
||||
//
|
||||
// Everything here draws one frame. Motion comes from what you pass in: feel() / emotions() for moods, move() for
|
||||
// dances, turn() for turns, and jump() / take() / stroll() / spring() from core.js for acting.
|
||||
|
||||
// ---------- views ----------
|
||||
// Turns are drawn KEY VIEWS, like a cartoon model sheet, never a rotated 3D box. Every view faces screen-right;
|
||||
// flip: true mirrors it to face left. Numbers are in u.
|
||||
// L, R: silhouette edges. strip: the darker side face. seam: the edge between faces.
|
||||
// face: where the features go. A front-view x maps to cx + x·fw; sides = which eyes show; mx = mouth x; bx = cheek x.
|
||||
// legs: [x, far] (far legs are darker). arms: [pivotX, dir, which, layer] ('L' = o.aL / o.armL; layer 0 = behind the
|
||||
// body, 1 = in front of it, 2 = behind and in shadow). hat / hw: hat offset and width.
|
||||
const VIEWS = {
|
||||
front: { L: -5, R: 5, face: { cx: 0, fw: 1, sides: [-1, 1], mx: 0, bx: 3.6 }, hat: 0, hw: 1,
|
||||
legs: [[-4, 0], [-2, 0], [1, 0], [3, 0]], arms: [[-4.9, -1, 'L', 0], [4.9, 1, 'R', 0]] },
|
||||
q: { L: -5, R: 5.1, strip: [-5, -2.3], seam: -2.3, face: { cx: 1.5, fw: .74, sides: [-1, 1], mx: .3, bx: 3.6 }, hat: .6, hw: 1,
|
||||
legs: [[-4.3, 1], [-1.8, 0], [.8, 0], [3.3, 0]], arms: [[5, 1, 'R', 2], [-4.7, -1, 'L', 1]] },
|
||||
side: { L: -3.1, R: 3.1, face: { cx: 1.35, fw: .55, sides: [1], mx: 1.7, bx: 1 }, hat: .3, hw: .66,
|
||||
legs: [[-2.1, 1], [1.3, 1], [-2.6, 0], [.9, 0]], arms: [[1.6, 0, 'L', 1]] },
|
||||
qback: { L: -5.1, R: 5, strip: [2.3, 5], seam: 2.3, face: null, hat: -.4, hw: 1,
|
||||
legs: [[3.3, 1], [.8, 0], [-1.8, 0], [-4.3, 0]], arms: [[-5, -1, 'R', 2], [4.7, 1, 'L', 1]] },
|
||||
back: { L: -5, R: 5, face: null, hat: 0, hw: 1, back: true,
|
||||
legs: [[-4, 0], [-2, 0], [1, 0], [3, 0]], arms: [[-4.9, -1, 'R', 0], [4.9, 1, 'L', 0]] },
|
||||
};
|
||||
// Key view for a heading in turns: 0 = front, .25 = facing right, .5 = back, .75 (or -.25) = facing left.
|
||||
// Snaps to the nearest eighth, which is how a drawn turn works: front → q → side, one drawing per step.
|
||||
function spinView(a) {
|
||||
const K = [['front', 0], ['q', 0], ['side', 0], ['qback', 0], ['back', 0], ['qback', 1], ['side', 1], ['q', 1]];
|
||||
const [view, f] = K[((Math.round(a * 8) % 8) + 8) % 8]; return { view, flip: !!f };
|
||||
}
|
||||
// A turn from heading a0 to a1 (in turns) between t0 and t1. It steps through the key views and smears the
|
||||
// in-between drawings. Spread it into clawd(): clawd(x, y, u, { ...feel('happy', t), ...turn(t, 2, 2.2, 0, .25) }).
|
||||
function turn(t, t0, t1, a0, a1) {
|
||||
const k = ease(seg(t, t0, t1)), v = spinView(lerp(a0, a1, k));
|
||||
return { ...v, smear: t > t0 && t < t1 ? .55 * Math.sin(k * Math.PI) : 0, smearDir: 0 };
|
||||
}
|
||||
|
||||
// ---------- colour ----------
|
||||
// Tints shift the body colour with the mood. tint: a name here or any hex colour; tintK: 0..1 strength.
|
||||
const TINT = { pale: '#F6E6D2', flush: '#E23E36', blue: '#6D86BE', rosy: '#EF8EA8', green: '#98B25E', gold: '#F0BE46' };
|
||||
function tintCols(o) {
|
||||
const c = { col: o.col || PAL.clay, dk: o.dk || PAL.clayDk, lt: o.lt || '#F5B394' };
|
||||
const tc = o.tint && (TINT[o.tint] || o.tint), k = clamp(o.tintK ?? 1);
|
||||
if (!tc || k <= 0) return c;
|
||||
return { col: mixCol(c.col, tc, .55 * k), dk: mixCol(c.dk, mixCol(tc, PAL.ink, .35), .5 * k), lt: mixCol(c.lt, mixCol(tc, '#FFFFFF', .4), .45 * k) };
|
||||
}
|
||||
|
||||
// ---------- Clawd ----------
|
||||
// Options (all optional):
|
||||
// pose: dx, dy (in u; negative dy = up), sq (squash; negative stretches), rot (pivots at the feet), flip, sx, sy,
|
||||
// aL, aR (arm raise angles: 0 = straight out, + = up, - = down), walk (phase; the legs step), noLegs, noShadow
|
||||
// view: front | q | side | qback | back (see VIEWS). smear 0..1 + smearDir ±1 (0 = both sides) for fast moves
|
||||
// face: eyes (a name, or [left, right] for mismatched eyes), mouth, lookX / lookY (-1..1), squint 0..1, blush 0..1,
|
||||
// gloom 0..1 (dark forehead with gloom lines), lid 0..1 (lunchbox mouth, front view only), seed (blink timing)
|
||||
// colour: col / dk / lt, or tint + tintK
|
||||
// extras: hat, emote + emoteK (0..1 pop) + emoteAge (s since it appeared), draw(u, sw), armL(u, sw), armR(u, sw)
|
||||
// boil: boilKey (a stable id for its boil seeds; defaults to call order, so set it if characters come and go mid-shot)
|
||||
function clawd(x, y, u, o = {}) {
|
||||
// each part boils from its own seed (see boilSeed), so a moving arm never re-boils the body, or the next character
|
||||
const id = o.boilKey ?? ++CLAWD_N, rs = part => boilSeed(`clawd ${id} ${part}`);
|
||||
x += (o.dx || 0) * u;
|
||||
const V = VIEWS[o.view] || VIEWS.front;
|
||||
const dy = (o.dy || 0) * u, sq = (o.sq || 0) + (o.take || 0), sm = clamp(o.smear || 0);
|
||||
const sw = clamp(u / 15, .45, 2.4) * (o.swMul || 1), J = u * .07;
|
||||
const { col, dk, lt } = tintCols(o), far = mixCol(dk, PAL.ink, .22);
|
||||
|
||||
rs('shadow');
|
||||
if (!o.noShadow) {
|
||||
const f = 1 - Math.min(.5, Math.abs(o.dy || 0) * .06), w = (V.R - V.L) / 10;
|
||||
paint(ellPts(x, y + u * .15, u * 5.6 * f * w, u * f, 22), { fill: PAL.ink, fillOp: 90, bleed: .25, tex: .3, border: .1, ink: null });
|
||||
}
|
||||
if (sm > .05) smearTrail(x, y + dy, u, V, sm, o.smearDir ?? (o.flip ? -1 : 1), col);
|
||||
|
||||
push();
|
||||
translate(x, y + dy);
|
||||
if (o.rot) rotate(o.rot);
|
||||
scale((o.flip ? -1 : 1) * (o.sx ?? 1) * (1 + sq * .6) * (1 + sm * .35), (o.sy ?? 1) * (1 - sq));
|
||||
|
||||
const arm = ([px, dir, which, layer]) => {
|
||||
rs('arm' + which);
|
||||
const a = which === 'L' ? (o.aL ?? .2) : (o.aR ?? .2), hook = which === 'L' ? o.armL : o.armR;
|
||||
// a raised (or dropped) arm slides its root out to the body's edge, so it stands beside the body instead of behind it
|
||||
push(); translate((px + dir * .55 * clamp((Math.abs(a) - .7) / .9)) * u, -4.5 * u);
|
||||
if (dir === 0) { // side view: the near arm reaches forward past the front edge; a raises it, a < 0 lowers it
|
||||
translate(0, .3 * u); rotate(.7 - a);
|
||||
paint(rectPts(-.2 * u, -.45 * u, 2.3 * u, .9 * u, J * .6), { wash: col, washOp: 255, fill: dk, fillOp: 60, tex: .5, ink: PAL.ink, sw: sw * .8 });
|
||||
if (hook) { translate(2.1 * u, 0); hook(u, sw); }
|
||||
} else {
|
||||
rotate(dir < 0 ? a : -a);
|
||||
paint(rectPts(dir < 0 ? -2.2 * u : 0, -.5 * u, 2.2 * u, u, J * .6), { wash: layer === 2 ? mixCol(col, dk, .4) : col, washOp: 255, fill: dk, fillOp: 60, tex: .5, ink: PAL.ink, sw: sw * .8 });
|
||||
if (hook) { translate(dir * 2.2 * u, 0); if (dir < 0) scale(-1, 1); hook(u, sw); }
|
||||
}
|
||||
pop();
|
||||
};
|
||||
|
||||
// arms behind, then legs (under the body), then the body, then arms in front
|
||||
V.arms.filter(a => a[3] !== 1).forEach(arm);
|
||||
if (!o.noLegs) V.legs.forEach(([lx, isFar], i) => {
|
||||
rs('leg' + i);
|
||||
let h = 2.2, sx = 0;
|
||||
if (o.walk != null) {
|
||||
if (o.view === 'side') { // profile: diagonal pairs swing together, lifting as they swing forward
|
||||
const ph = (o.walk + [0, .5, .5, 0][i]) * TAU;
|
||||
sx = Math.sin(ph) * .55; h = 2.2 - Math.max(0, Math.cos(ph)) * .8;
|
||||
} else { const ph = Math.sin((o.walk + (i % 2 ? .5 : 0)) * TAU); if (ph > 0) h = 2.2 - ph * .9; }
|
||||
}
|
||||
paint(rectPts((lx + sx) * u, -2.4 * u, u, h * u, J * .6), { wash: isFar ? far : dk, washOp: 255, ink: PAL.ink, sw: sw * .8 });
|
||||
});
|
||||
|
||||
rs('body');
|
||||
const lid = V === VIEWS.front ? o.lid || 0 : 0;
|
||||
if (lid > .01) lunchbox(u, o, sw, J, lid, col, dk, lt);
|
||||
else {
|
||||
const L = V.L * u, R = V.R * u, wk = (V.R - V.L) / 10, body = rectPts(L, -8 * u, R - L, 6 * u, J);
|
||||
paint(body, { wash: col, washOp: 255, ink: null });
|
||||
const px = V.strip ? (V.strip[0] < 0 ? (V.seam + V.R) / 2 - 1.2 : (V.L + V.seam) / 2 - .6) : -1.6 * wk;
|
||||
paint(ellPts(px * u, -6.4 * u, 3.4 * u * (V.strip ? .75 : wk), 1.5 * u, 18, J * 2, -.08), { fill: lt, fillOp: V.back ? 80 : 120, bleed: .2, tex: .85, border: .8, ink: null });
|
||||
paint(rectPts(L + .2 * u, -3.8 * u, R - L - .4 * u, 1.6 * u, J), { fill: dk, fillOp: 120, bleed: .03, tex: .7, border: .5, ink: null });
|
||||
if (V.strip) {
|
||||
paint(rectPts(V.strip[0] * u, -8 * u, (V.strip[1] - V.strip[0]) * u, 6 * u, J * .5), { fill: dk, fillOp: 150, bleed: .04, tex: .6, border: .4, ink: null });
|
||||
inkLine([[V.seam * u + jit(J), -7.9 * u], [V.seam * u + jit(J), -5 * u], [V.seam * u + jit(J), -2.1 * u]], sw * .55, PAL.ink, 'inkfine', 0);
|
||||
}
|
||||
paint(body, { ink: PAL.ink, sw });
|
||||
if (o.gloom > .02) gloom(u, sw, V, o.gloom);
|
||||
if (V.face) {
|
||||
const F = V.face;
|
||||
push(); translate(F.cx * u, 0); scale(F.fw, 1);
|
||||
if (o.blush) blush(u, sw, F, o.blush === true ? 1 : o.blush);
|
||||
if (o.hat === 'mask' && F.sides.length > 1) paint([[-5.5 * u, -7.7 * u], [5.5 * u, -7.7 * u], [4.4 * u, -4.7 * u], [.6 * u, -5.4 * u], [-.6 * u, -5.4 * u], [-4.4 * u, -4.7 * u]], { wash: PAL.violet, ink: PAL.ink, sw: sw * .7 });
|
||||
rs('eyes'); eyes(u, o, sw, F.sides, sm); rs('mouth');
|
||||
push(); translate(F.mx * u, 0); mouth(u, o.mouth, sw); pop();
|
||||
if (['cat', 'masq', 'bowtie'].includes(o.hat)) faceHat(u, o.hat, sw, F.sides);
|
||||
pop();
|
||||
}
|
||||
rs('hat'); push(); translate(V.hat * u, 0); scale(V.hw, 1); hat(u, o.hat, sw); pop();
|
||||
}
|
||||
V.arms.filter(a => a[3] === 1).forEach(arm);
|
||||
rs('draw'); if (o.draw) o.draw(u, sw);
|
||||
pop();
|
||||
|
||||
rs('emote');
|
||||
if (o.emote) {
|
||||
const top = EMOTE_TOP.includes(o.emote), dir = o.flip ? -1 : 1;
|
||||
const ex = top ? x + dir * V.hat * u : x + dir * (V.R + .4) * u, ey = y + dy + (top ? -10.4 : -8.6) * u * (1 - sq);
|
||||
emote(o.emote, ex, ey, u * .9, o.emoteK ?? 1, o.emoteAge ?? T);
|
||||
}
|
||||
rs('after'); // so whatever is drawn next doesn't depend on this pose
|
||||
}
|
||||
|
||||
// The lunchbox mouth: the top 2.9u of the body hinges open at the back-left corner (front view only). The mouth is the
|
||||
// dark wedge between jaw and lid; both rows of teeth point INTO it, staggered, and each tooth is sized to the gap at
|
||||
// its spot, so they never cross, even when the lid is only open a crack.
|
||||
function lunchbox(u, o, sw, J, lid, col, dk, lt) {
|
||||
const hy = -5.1 * u, A = lid * 1.25, gap = x => (x + 5 * u) * Math.sin(A);
|
||||
const tooth = (tx, dir) => { // base on the jaw line from tx to tx + 1.3u, tip pointing up (dir -1) or down (dir 1)
|
||||
const h = Math.min(.8 * u, .42 * gap(tx + .65 * u)); if (h < .12 * u) return;
|
||||
paint([[tx, hy], [tx + 1.3 * u, hy], [tx + .65 * u, hy + dir * h]], { wash: PAL.cream, ink: PAL.ink, sw: sw * .45 });
|
||||
};
|
||||
// lower jaw
|
||||
paint(rectPts(-5 * u, hy, 10 * u, 3.1 * u, J), { wash: col, washOp: 255, ink: null });
|
||||
paint(rectPts(-4.8 * u, -3.8 * u, 9.6 * u, 1.6 * u, J), { fill: dk, fillOp: 120, bleed: .03, tex: .7, border: .5, ink: null });
|
||||
// the open mouth: a dark wedge from the hinge to the lid's far corner, with the tongue lying in it
|
||||
paint([[-5 * u, hy], [5 * u, hy], [-5 * u + 10 * u * Math.cos(A), hy - 10 * u * Math.sin(A)]], { wash: '#4A1F2A', ink: null });
|
||||
const th = Math.min(.5 * u, .3 * gap(1.8 * u));
|
||||
if (th > .08 * u) paint(ellPts(1.8 * u, hy - th * .7, 2.2 * u, th, 16), { wash: PAL.rose, fill: '#E2476E', fillOp: 60, ink: null });
|
||||
for (let i = 0; i < 6; i++) tooth(-4.4 * u + i * 1.6 * u, -1);
|
||||
paint(rectPts(-5 * u, hy, 10 * u, 3.1 * u, J), { ink: PAL.ink, sw });
|
||||
// lid
|
||||
push(); translate(-5 * u, hy); rotate(-A); translate(5 * u, -hy);
|
||||
paint(rectPts(-5 * u, -8 * u, 10 * u, 2.9 * u, J), { wash: col, washOp: 255, ink: null });
|
||||
paint(ellPts(-1.6 * u, -6.9 * u, 3.2 * u, 1 * u, 16, J), { fill: lt, fillOp: 110, bleed: .15, tex: .8, border: .8, ink: null });
|
||||
for (let i = 0; i < 5; i++) tooth(-3.6 * u + i * 1.6 * u, 1);
|
||||
paint(rectPts(-5 * u, -8 * u, 10 * u, 2.9 * u, J), { ink: PAL.ink, sw });
|
||||
if (o.gloom > .02) gloom(u, sw, VIEWS.front, o.gloom);
|
||||
eyes(u, o, sw, [-1, 1], 0);
|
||||
hat(u, o.hat, sw);
|
||||
pop();
|
||||
}
|
||||
|
||||
// A smear drawing: dry-brush streaks and speed lines trailing a fast move (dir ±1), or on both sides for a spin (0).
|
||||
function smearTrail(x, y, u, V, k, dir, col) {
|
||||
const sides = dir === 0 ? [-1, 1] : [-dir];
|
||||
for (const s of sides) for (let i = 0; i < 4; i++) {
|
||||
const yy = y - (7.2 - i * 1.5) * u, x0 = x + s * (V.R - .5) * u, len = (3 + 3 * hash(i + 7 * s)) * u * k;
|
||||
inkLine([[x0, yy], [x0 + s * len * .5, yy + jit(u * .1)], [x0 + s * len, yy]], 2.2 * k, col, 'dry', .3);
|
||||
if (i % 2) inkLine([[x0 + s * len * .3, yy + .5 * u], [x0 + s * len * 1.1, yy + .5 * u]], .6, PAL.ink, 'inkfine', 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Gloom: a dark wash over the forehead with hanging gloom lines (despair, dread, a guilty conscience).
|
||||
function gloom(u, sw, V, g) {
|
||||
const L = V.L * u, R = V.R * u;
|
||||
paint(rectPts(L + .15 * u, -7.9 * u, R - L - .3 * u, 2.6 * u, u * .05), { fill: PAL.indigo, fillOp: 150 * g, bleed: .08, tex: .5, border: .6, ink: null });
|
||||
const n = Math.round((V.R - V.L) * .7);
|
||||
for (let i = 0; i < n; i++) { const gx = lerp(L + .7 * u, R - .7 * u, i / Math.max(1, n - 1)); inkLine([[gx, -7.8 * u], [gx + jit(u * .05), (-7.8 + 2.4 * g * (.7 + .3 * hash(i))) * u]], sw * .45, PAL.ink, 'inkfine', 0); }
|
||||
}
|
||||
|
||||
function blush(u, sw, F, b) {
|
||||
for (const s of F.sides) {
|
||||
const bx = s * F.bx * u;
|
||||
paint(ellPts(bx, -4.6 * u, u * .85, u * .42, 14), { fill: PAL.rose, fillOp: 170 * clamp(b), bleed: .2, ink: null });
|
||||
if (b > .6) for (let k = 0; k < 3; k++) inkLine([[bx - .5 * u + k * .4 * u, -4.35 * u], [bx - .3 * u + k * .4 * u, -4.85 * u]], sw * .4, mixCol(PAL.rose, PAL.ink, .3), 'inkfine', 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- eyes ----------
|
||||
// normal, look, wide, happy, closed, sleepy, wink, narrow, angry, determined, sad, teary, cry, squeeze, shine,
|
||||
// scared, blank, spark, red, heart, x, swirl, dot, shades. o.eyes may be a pair: ['narrow', 'wide'].
|
||||
function eyes(u, o, sw, sides, smear = 0) {
|
||||
const kinds = Array.isArray(o.eyes) ? o.eyes : o.eyes === 'wink' ? ['happy', 'normal'] : [o.eyes || 'normal', o.eyes || 'normal'];
|
||||
const sqz = clamp(o.squint || 0);
|
||||
if (smear > .5) { for (const s of sides) inkLine([[s * 2.5 * u - .9 * u, -6 * u], [s * 2.5 * u + .9 * u, -6 * u]], sw * 1.4, PAL.ink, 'ink', 0); return; }
|
||||
if (sqz > .8) { for (const s of sides) inkLine([[s * 2.5 * u - .8 * u, -5.9 * u], [s * 2.5 * u + .8 * u, -5.9 * u]], sw, PAL.ink, 'ink', 0); return; }
|
||||
if (kinds[0] === 'shades') { // sunglasses: two dark lenses, a bridge, arms running back along the head
|
||||
const P = pts => pts.map(([a, b]) => [a * u, b * u]);
|
||||
const lens = cx => P([[cx - 1.4, -7.1], [cx + 1.4, -7.1], [cx + 1.3, -6.2], [cx + .8, -5.35], [cx, -5.2], [cx - .8, -5.35], [cx - 1.3, -6.2]]);
|
||||
if (sides.length > 1) {
|
||||
for (const s of [-1, 1]) inkLine(P([[s * 3.9, -6.85], [s * 5, -6.95]]), sw * .8, PAL.ink, 'ink', 0);
|
||||
inkLine(P([[-1.15, -6.75], [0, -7.05], [1.15, -6.75]]), sw * .8, PAL.ink, 'ink', .5);
|
||||
} else inkLine(P([[1.1, -6.85], [-5, -6.95]]), sw * .8, PAL.ink, 'ink', 0); // profile: the arm runs back to the ear
|
||||
for (const s of sides) {
|
||||
const cx = s * 2.5;
|
||||
paint(lens(cx), { wash: '#2A2740', ink: PAL.ink, sw: sw * .9, curv: .3 });
|
||||
inkLine(P([[cx - .85, -6.35], [cx - .25, -6.85]]), sw * .45, PAL.cream, 'inkfine', 0); // glint
|
||||
inkLine(P([[cx - .45, -5.85], [cx - .05, -6.2]]), sw * .3, PAL.cream, 'inkfine', 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (sqz > 0) { push(); translate(0, -6 * u); scale(1 + sqz * .15, 1 - sqz); translate(0, 6 * u); }
|
||||
for (const s of sides) { push(); translate(s * 2.5 * u, -6 * u); eye(kinds[s < 0 ? 0 : 1], s, u, o, sw); pop(); }
|
||||
if (sqz > 0) pop();
|
||||
}
|
||||
|
||||
// One eye, drawn around its centre. s = -1 for the left eye, 1 for the right.
|
||||
function eye(e, s, u, o, sw) {
|
||||
const lx = (o.lookX || 0) * u * .5, ly = (o.lookY || 0) * u * .4;
|
||||
const slit = (w, h) => {
|
||||
paint(rectPts(-w / 2 * u + lx, -h / 2 * u + ly, w * u, h * u, u * .04), { wash: PAL.ink, ink: null });
|
||||
if (u > 9) paint(ellPts(lx - w * .18 * u, ly - h * .29 * u, u * .17 * w, u * .24 * w, 10), { wash: PAL.cream, washOp: 230, ink: null });
|
||||
};
|
||||
const blinkOn = ['normal', 'look', 'wide'].includes(e) && ((T * .9 + (o.seed || 0) * 1.7) % 3.3) < .12;
|
||||
if (blinkOn) { inkLine([[-.7 * u, .5 * u], [.7 * u, .5 * u]], sw, PAL.ink, 'ink', 0); return; }
|
||||
const lineEye = (pts, w = 1.3, c = .2) => inkLine(pts.map(([a, b]) => [a * u, b * u]), sw * w, PAL.ink, 'ink', c);
|
||||
switch (e) {
|
||||
case 'normal': case 'look': slit(1, 2); break;
|
||||
case 'wide': slit(1.25, 2.7); break;
|
||||
case 'happy': lineEye([[-.9, .7], [0, -.5], [.9, .7]]); break;
|
||||
case 'closed': lineEye([[-.9, .2], [0, .6], [.9, .2]], 1.2, .4); break;
|
||||
case 'sleepy':
|
||||
paint(rectPts(-.5 * u + lx, .05 * u, u, .95 * u, u * .03), { wash: PAL.ink, ink: null });
|
||||
lineEye([[-.8, .05], [0, -.1], [.8, .1]], 1.1, .4); break;
|
||||
case 'narrow': paint(rectPts(-.6 * u + lx, -.1 * u + ly, 1.2 * u, .7 * u, u * .03), { wash: PAL.ink, ink: null }); break;
|
||||
case 'angry': case 'determined': { // top edge slants DOWN toward the middle
|
||||
const hi = e === 'angry' ? .75 : .5, top = e === 'angry' ? -.7 : -.55;
|
||||
const P = [[-.65, s < 0 ? top : top + hi], [.65, s < 0 ? top + hi : top], [.65, 1], [-.65, 1]];
|
||||
paint(P.map(([a, b]) => [a * u + lx, b * u + ly]), { wash: PAL.ink, ink: null });
|
||||
if (e === 'determined' && u > 9) paint(ellPts(lx - .2 * u, ly + .25 * u, u * .16, u * .2, 8), { wash: PAL.cream, ink: null });
|
||||
break;
|
||||
}
|
||||
case 'sad': case 'teary': { // top edge slants UP toward the middle (worried)
|
||||
const P = [[-.6, s < 0 ? -.2 : -.85], [.6, s < 0 ? -.85 : -.2], [.6, .9], [-.6, .9]];
|
||||
paint(P.map(([a, b]) => [a * u + lx, b * u + ly]), { wash: PAL.ink, ink: null });
|
||||
if (u > 9) paint(ellPts(lx - .18 * u, ly + .05 * u, u * .16, u * .22, 8), { wash: PAL.cream, ink: null });
|
||||
if (e === 'teary') {
|
||||
const w = Math.sin(T * 9 + s) * .06 * u;
|
||||
paint(ellPts(0, .95 * u + w, .95 * u, .38 * u, 14), { wash: PAL.sky, washOp: 210, fill: '#FFFFFF', fillOp: 60, ink: PAL.ink, sw: sw * .4 });
|
||||
paint(ellPts(-.35 * u, .85 * u + w, .18 * u, .1 * u, 8), { wash: '#FFFFFF', ink: null });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'cry': { // squeezed shut, with tear streams running down the body and drops flicking off
|
||||
lineEye([[-.9, .45], [-.3, -.05], [.3, .15], [.9, -.2]].map(([a, b]) => [a * -s, b]), 1.3, .3);
|
||||
const R = []; for (let k = 0; k <= 5; k++) R.push([(s * .55 + Math.sin(T * 8 + k * 1.3 + s) * .12 * k / 5) * u, (.4 + k * .72) * u]);
|
||||
paint(ribbon(R, .45 * u, .8 * u), { wash: PAL.sky, washOp: 230, fill: '#FFFFFF', fillOp: 70, ink: PAL.ink, sw: sw * .45 });
|
||||
for (let k = 0; k < 2; k++) {
|
||||
const ph = frac(T * 1.8 + k * .5 + (s > 0 ? .25 : 0)), p = arcPt([s * .9 * u, .2 * u], [s * 3.6 * u, 1.8 * u], 1.6 * u, ph);
|
||||
paint(ellPts(p[0], p[1], .22 * u * (1 - ph * .5), .3 * u * (1 - ph * .5), 8), { wash: PAL.sky, ink: PAL.ink, sw: sw * .35 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'squeeze': lineEye([[-.55 * -s, -.7], [.55 * -s, 0], [-.55 * -s, .7]], 1.3, 0); break; // > <
|
||||
case 'shine':
|
||||
paint(ellPts(lx * .6, ly * .6, u * .78, u * 1.12, 16), { wash: PAL.ink, ink: null });
|
||||
paint(ellPts(lx * .6 - .25 * u, ly * .6 - .45 * u, u * .3, u * .38, 10), { wash: PAL.cream, ink: null });
|
||||
paint(ellPts(lx * .6 + .25 * u, ly * .6 + .45 * u, u * .14, u * .14, 8), { wash: PAL.cream, ink: null });
|
||||
break;
|
||||
case 'scared':
|
||||
paint(ellPts(0, 0, u * .95, u * 1.15, 16), { wash: PAL.cream, ink: PAL.ink, sw: sw * .6 });
|
||||
paint(ellPts((o.lookX || 0) * u * .3 + Math.sin(T * 40) * .04 * u, .1 * u, u * .3, u * .4, 10), { wash: PAL.ink, ink: null });
|
||||
break;
|
||||
case 'blank': paint(ellPts(0, 0, u * .8, u * 1.05, 14), { wash: PAL.cream, ink: PAL.ink, sw: sw * .7 }); break;
|
||||
case 'spark':
|
||||
paint(ellPts(0, 0, u * 1.5, u * 1.5, 18), { fill: PAL.ochre, fillOp: 90, bleed: .3, ink: null });
|
||||
paint(starPts(0, 0, u * 1.35 * (1 + .12 * Math.sin(T * 14))), { wash: PAL.cream, fill: PAL.ochre, fillOp: 80, ink: PAL.ink, sw: sw * .55 });
|
||||
break;
|
||||
case 'red':
|
||||
paint(ellPts(0, 0, u * 1.7, u * 1.7, 18), { fill: '#E0283F', fillOp: 110, bleed: .35, ink: null });
|
||||
paint(rectPts(-.6 * u, -u, 1.2 * u, 2 * u, u * .05), { wash: '#FF2F4A', ink: PAL.ink, sw: sw * .5 });
|
||||
break;
|
||||
case 'heart': paint(heartPts(0, .1 * u, u * .9 * (1 + .1 * pulse(T))), { wash: '#E2476E', ink: PAL.ink, sw: sw * .5 }); break;
|
||||
case 'x': lineEye([[-.8, -.8], [.8, .8]], 1, 0); lineEye([[.8, -.8], [-.8, .8]], 1, 0); break;
|
||||
case 'swirl': {
|
||||
const sp = []; for (let k = 0; k < 16; k++) { const a = k * .7 + T * 6 * s, r = k * .06 * u; sp.push([Math.cos(a) * r, Math.sin(a) * r]); }
|
||||
inkLine(sp, sw * .6, PAL.ink, 'inkfine', .6); break;
|
||||
}
|
||||
case 'dot': paint(ellPts(lx * .5, ly * .5, u * .45, u * .55, 12), { wash: PAL.ink, ink: null }); break;
|
||||
default: slit(1, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- mouths ----------
|
||||
// o, O, smile, grin, flat, wobble, cat, frown, smirk, laugh, open, wail, teeth, tongue, pout, yawn
|
||||
function mouth(u, m, sw) {
|
||||
if (!m) return;
|
||||
const P = pts => pts.map(([a, b]) => [a * u, b * u]), dark = '#4A1F2A';
|
||||
const line = (pts, w = .8, c = .6) => inkLine(P(pts), sw * w, PAL.ink, 'ink', c);
|
||||
switch (m) {
|
||||
case 'o': paint(ellPts(0, -4.3 * u, u * .45, u * .5, 12), { wash: PAL.ink, ink: null }); break;
|
||||
case 'O': paint(ellPts(0, -4.1 * u, u * .8, u * .95, 14), { wash: dark, ink: PAL.ink, sw: sw * .6 }); break;
|
||||
case 'smile': line([[-.8, -4.6], [0, -4.1], [.8, -4.6]]); break;
|
||||
case 'frown': line([[-.8, -4.1], [0, -4.6], [.8, -4.1]]); break;
|
||||
case 'grin': paint(P([[-1.3, -4.8], [1.3, -4.8], [.9, -3.9], [-.9, -3.9]]), { wash: dark, ink: PAL.ink, sw: sw * .6, curv: .3 }); break;
|
||||
case 'flat': line([[-.7, -4.4], [.7, -4.4]], .8, 0); break;
|
||||
case 'wobble': line([[-1, -4.4], [-.5, -4.7], [0, -4.4], [.5, -4.7], [1, -4.4]], .7, .3); break;
|
||||
case 'cat': line([[-.9, -4.5], [-.45, -4.1], [0, -4.5], [.45, -4.1], [.9, -4.5]], .7, .5); break;
|
||||
case 'smirk': line([[-.8, -4.35], [.2, -4.3], [.9, -4.75]], .8, .5); break;
|
||||
case 'laugh':
|
||||
paint(P([[-1.4, -4.9], [1.4, -4.9], [1, -4], [0, -3.4], [-1, -4]]), { wash: dark, ink: PAL.ink, sw: sw * .6, curv: .4 });
|
||||
paint(ellPts(0, -3.85 * u, .7 * u, .32 * u, 12), { wash: PAL.rose, ink: null }); break;
|
||||
case 'open':
|
||||
paint(ellPts(0, -4.25 * u, u * .75, u * .6, 14), { wash: dark, ink: PAL.ink, sw: sw * .6 });
|
||||
paint(ellPts(0, -3.95 * u, u * .45, u * .2, 10), { wash: PAL.rose, ink: null }); break;
|
||||
case 'wail': {
|
||||
const w = Math.sin(T * 30) * .06;
|
||||
paint(P([[-1.5, -4.5 + w], [-.5, -4.9], [.5, -4.9 - w], [1.5, -4.5], [1.2, -3.3], [-1.2, -3.3]]), { wash: dark, ink: PAL.ink, sw: sw * .6, curv: .3 });
|
||||
paint(ellPts(0, -3.6 * u, .8 * u, .25 * u, 12), { wash: PAL.rose, ink: null }); break;
|
||||
}
|
||||
case 'teeth':
|
||||
paint(rectPts(-1.3 * u, -4.85 * u, 2.6 * u, .95 * u, u * .04), { wash: PAL.cream, ink: PAL.ink, sw: sw * .6 });
|
||||
line([[-1.25, -4.38], [1.25, -4.38]], .4, 0);
|
||||
for (const tx of [-.65, 0, .65]) line([[tx, -4.8], [tx, -3.95]], .35, 0);
|
||||
break;
|
||||
case 'tongue':
|
||||
paint(P([[.05, -4.3], [.75, -4.3], [.72, -3.75], [.4, -3.55], [.08, -3.75]]), { wash: PAL.rose, ink: PAL.ink, sw: sw * .5, curv: .5 });
|
||||
line([[-.8, -4.6], [0, -4.2], [.8, -4.5]]); break;
|
||||
case 'pout': line([[-.45, -4.2], [0, -4.5], [.45, -4.2]], 1, .6); line([[-.25, -4.0], [0, -3.9], [.25, -4.0]], .6, .6); break;
|
||||
case 'yawn':
|
||||
paint(ellPts(0, -4.1 * u, u * .6, u * 1.05, 14), { wash: dark, ink: PAL.ink, sw: sw * .6 });
|
||||
paint(ellPts(0, -3.45 * u, u * .38, u * .25, 10), { wash: PAL.rose, ink: null }); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- hats ----------
|
||||
// party, hard, crown, halo, wizard, hood, top, fedora, band, sweatband, beanie, bow, flower, headphones, cat (ears; the
|
||||
// whiskers show in the front and 3/4 views), plus face pieces for the front and 3/4 views: masq, mask, bowtie
|
||||
function hat(u, h, sw) {
|
||||
if (!h || h === 'mask' || h === 'masq' || h === 'bowtie') return;
|
||||
const P = pts => pts.map(([a, b]) => [a * u, b * u]);
|
||||
if (h === 'party') {
|
||||
paint(P([[-1.8, -7.9], [0, -12.8], [1.8, -7.9]]), { wash: PAL.rose, fill: PAL.violet, fillOp: 50, ink: PAL.ink, sw: sw * .8 });
|
||||
paint(ellPts(0, -12.8 * u, u * .75, u * .75, 12), { wash: PAL.ochre, ink: PAL.ink, sw: sw * .6 });
|
||||
} else if (h === 'hard') {
|
||||
const d = []; for (let i = 0; i <= 12; i++) { const a = Math.PI + i / 12 * Math.PI; d.push([Math.cos(a) * 3.5 * u, -8 * u + Math.sin(a) * 3.3 * u]); }
|
||||
paint(d, { wash: '#F2C53D', ink: PAL.ink, sw: sw * .8 });
|
||||
paint(rectPts(-4.9 * u, -8.6 * u, 9.8 * u, .9 * u), { wash: '#F2C53D', ink: PAL.ink, sw: sw * .8 });
|
||||
} else if (h === 'crown') {
|
||||
paint(P([[-3, -7.9], [-3, -10.8], [-1.5, -9.3], [0, -11.2], [1.5, -9.3], [3, -10.8], [3, -7.9]]), { wash: '#F2C53D', fill: PAL.ochre, fillOp: 90, ink: PAL.ink, sw: sw * .8 });
|
||||
for (const gx of [-1.5, 0, 1.5]) paint(ellPts(gx * u, -8.7 * u, u * .3, u * .3, 8), { wash: gx ? PAL.teal : PAL.rose, ink: null });
|
||||
} else if (h === 'halo') {
|
||||
brush.noFill(); brush.noWash(); brush.noHatch(); brush.set('ink', PAL.ochre, sw * 1.4);
|
||||
brush.beginShape(0); for (const p of ellPts(0, -10.4 * u, 3.2 * u, .8 * u, 20)) brush.vertex(p[0], p[1]); brush.endShape(true);
|
||||
} else if (h === 'wizard' || h === 'hood') {
|
||||
paint(P([[-3.9, -7.9], [.9, -15], [3.9, -7.9]]), { wash: h === 'hood' ? PAL.violet : PAL.indigo, fill: PAL.violet, fillOp: 80, ink: PAL.ink, sw: sw * .8 });
|
||||
paint(starPts(.2 * u, -10.6 * u, u * .9, .4, 5), { wash: PAL.ochre, ink: null });
|
||||
} else if (h === 'top') {
|
||||
paint(rectPts(-2.6 * u, -12.5 * u, 5.2 * u, 4.3 * u), { wash: PAL.ink, ink: null });
|
||||
paint(rectPts(-2.6 * u, -9.4 * u, 5.2 * u, .8 * u), { wash: '#C8324A', ink: null });
|
||||
paint(rectPts(-3.9 * u, -8.6 * u, 7.8 * u, .8 * u), { wash: PAL.ink, ink: null });
|
||||
} else if (h === 'fedora') {
|
||||
paint(P([[-2.8, -8.3], [-2.3, -10.8], [0, -10.2], [2.3, -10.8], [2.8, -8.3]]), { wash: '#3B3550', ink: PAL.ink, sw: sw * .8, curv: .3 });
|
||||
paint(rectPts(-2.8 * u, -9.2 * u, 5.6 * u, .8 * u), { wash: '#C8324A', ink: null });
|
||||
paint(ellPts(0, -8.2 * u, 4.6 * u, .6 * u, 20), { wash: '#3B3550', ink: PAL.ink, sw: sw * .8 });
|
||||
} else if (h === 'band' || h === 'sweatband') {
|
||||
paint(rectPts(-5.1 * u, -8.2 * u, 10.2 * u, .95 * u), { wash: h === 'band' ? '#D8394E' : PAL.cream, ink: PAL.ink, sw: sw * .5 });
|
||||
if (h === 'sweatband') for (let i = 0; i < 4; i++) inkLine([[-4 * u + i * 2.6 * u, -8.1 * u], [-3.6 * u + i * 2.6 * u, -7.4 * u]], sw * .4, '#D8394E', 'inkfine', 0);
|
||||
} else if (h === 'beanie') {
|
||||
const d = []; for (let i = 0; i <= 12; i++) { const a = Math.PI + i / 12 * Math.PI; d.push([Math.cos(a) * 3.9 * u, -8.4 * u + Math.sin(a) * 2.6 * u]); }
|
||||
paint(d, { wash: PAL.teal, fill: PAL.sky, fillOp: 60, ink: PAL.ink, sw: sw * .8 });
|
||||
paint(rectPts(-4.2 * u, -8.9 * u, 8.4 * u, 1.1 * u), { wash: mixCol(PAL.teal, PAL.ink, .2), ink: PAL.ink, sw: sw * .7 });
|
||||
paint(ellPts(0, -11.2 * u, .8 * u, .8 * u, 12), { wash: PAL.cream, ink: PAL.ink, sw: sw * .6 });
|
||||
} else if (h === 'bow') {
|
||||
for (const s of [-1, 1]) paint(P([[2.6, -8.1], [2.6 + s * 1.7, -9.3], [2.6 + s * 1.7, -7.1]]), { wash: PAL.rose, fill: '#E2476E', fillOp: 70, ink: PAL.ink, sw: sw * .6, curv: .3 });
|
||||
paint(ellPts(2.6 * u, -8.2 * u, .45 * u, .45 * u, 10), { wash: '#E2476E', ink: PAL.ink, sw: sw * .5 });
|
||||
} else if (h === 'flower') {
|
||||
const fx = -2.6 * u, fy = -8.6 * u;
|
||||
inkLine([[fx, fy], [fx + .3 * u, -7.9 * u]], sw * .6, PAL.sap, 'ink', 0);
|
||||
for (let i = 0; i < 5; i++) { const a = i / 5 * TAU + .3; paint(ellPts(fx + Math.cos(a) * .7 * u, fy + Math.sin(a) * .7 * u, .55 * u, .55 * u, 10), { wash: PAL.cream, ink: PAL.ink, sw: sw * .4 }); }
|
||||
paint(ellPts(fx, fy, .45 * u, .45 * u, 10), { wash: PAL.ochre, ink: PAL.ink, sw: sw * .4 });
|
||||
} else if (h === 'headphones') {
|
||||
inkLine(P([[-5.1, -5.8], [-4.6, -9.4], [0, -10.6], [4.6, -9.4], [5.1, -5.8]]), sw * 2.4, PAL.ink, 'ink', .6);
|
||||
for (const s of [-1, 1]) paint(rrPts((s < 0 ? -6.1 : 4.6) * u, -7.4 * u, 1.5 * u, 2.8 * u, .6 * u), { wash: PAL.violet, fill: PAL.rose, fillOp: 50, ink: PAL.ink, sw: sw * .7 });
|
||||
} else if (h === 'cat') {
|
||||
for (const s of [-1, 1]) {
|
||||
paint([[s * 4.9 * u, -7.9 * u], [s * 4.3 * u, -11 * u], [s * 1.9 * u, -7.9 * u]], { wash: PAL.clay, ink: PAL.ink, sw: sw * .8 });
|
||||
paint([[s * 4.2 * u, -8.1 * u], [s * 4 * u, -10 * u], [s * 2.8 * u, -8.1 * u]], { wash: PAL.rose, ink: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
function faceHat(u, h, sw, sides) {
|
||||
if (h === 'cat') {
|
||||
for (const s of sides) for (const k of [-.3, .3]) inkLine([[s * 4 * u, -4.7 * u + k * u], [s * 6.4 * u, -4.9 * u + k * 2 * u]], sw * .4, PAL.ink, 'inkfine', 0);
|
||||
} else if (h === 'masq') {
|
||||
paint([[-5.2, -7.3], [-2.4, -8], [0, -7.1], [2.4, -8], [5.2, -7.3], [4.2, -5.2], [1.4, -5.3], [0, -6], [-1.4, -5.3], [-4.2, -5.2]].map(([a, b]) => [a * u, b * u]), { wash: PAL.violet, fill: PAL.rose, fillOp: 60, ink: PAL.ink, sw: sw * .7, curv: .3 });
|
||||
for (const ex of [-2.5, 2.5]) paint(ellPts(ex * u, -6.4 * u, 1.1 * u, .6 * u, 12), { wash: PAL.ink, ink: null });
|
||||
} else if (h === 'bowtie') {
|
||||
for (const s of [-1, 1]) paint([[0, -2.9 * u], [s * 1.5 * u, -3.6 * u], [s * 1.5 * u, -2.1 * u]], { wash: PAL.rose, ink: PAL.ink, sw: sw * .5 });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- emotes ----------
|
||||
// Painted reaction marks, never lettering: ! ? !! !? zzz sweat spark heart hearts anger steam bulb dots scribble
|
||||
// music swirl stars cloud. k = 0..1 pop-in progress (with overshoot); age = seconds since it appeared (drives the
|
||||
// looping ones). Emotes in EMOTE_TOP sit over the head; the rest sit by its top corner.
|
||||
const EMOTE_TOP = ['steam', 'stars', 'cloud', 'bulb', 'scribble'];
|
||||
function emote(kind, x, y, s, k = 1, age = T) {
|
||||
const p = backOut(k); if (p < .02) return;
|
||||
const sw = clamp(s / 15, .4, 2), P = pts => pts.map(([a, b]) => [a * s, b * s]);
|
||||
const dot = (dx, col) => paint(ellPts(dx, 1.25 * s, .42 * s, .42 * s, 12), { wash: col, ink: PAL.ink, sw: sw * .7 });
|
||||
const bang = (dx, col) => {
|
||||
push(); translate(dx, 0); rotate(.1 + .08 * Math.sin(age * 16) * Math.exp(-age * 3));
|
||||
paint(P([[-.62, -2.3], [.62, -2.3], [.22, .45], [-.22, .45]]), { wash: col, fill: PAL.cream, fillOp: 70, ink: PAL.ink, sw: sw * .8, curv: .25 }); dot(0, col);
|
||||
pop();
|
||||
};
|
||||
const quest = (dx, col) => {
|
||||
push(); translate(dx, 0); rotate(-.08 + .08 * Math.sin(age * 12) * Math.exp(-age * 3));
|
||||
paint(ribbon(P([[-1, -1.3], [-.55, -2.15], [.35, -2.3], [1, -1.6], [.7, -.75], [.05, -.3], [0, .4]]), .75 * s, .5 * s), { wash: col, fill: PAL.cream, fillOp: 60, ink: PAL.ink, sw: sw * .8 });
|
||||
dot(0, col); pop();
|
||||
};
|
||||
push(); translate(x, y); scale(p);
|
||||
switch (kind) {
|
||||
case '!': bang(0, PAL.ochre); break;
|
||||
case '?': quest(0, PAL.sky); break;
|
||||
case '!!': bang(-.8 * s, PAL.ochre); bang(.9 * s, PAL.ochre); break;
|
||||
case '!?': bang(-.9 * s, PAL.ochre); quest(1 * s, PAL.sky); break;
|
||||
case 'zzz': for (let i = 0; i < 3; i++) { // painted Z shapes drifting up and away
|
||||
const ph = frac(age * .4 + i / 3), a = Math.sin(ph * Math.PI), zs = (.8 + ph * .7) * s * Math.min(1, a * 1.6);
|
||||
if (a < .12) continue;
|
||||
const zx = ph * 2.6 * s, zy = -ph * 4.2 * s, Z = [[-.6, -.6], [.6, -.6], [.6, -.28], [-.12, .3], [.6, .3], [.6, .6], [-.6, .6], [-.6, .28], [.12, -.3], [-.6, -.3]];
|
||||
push(); translate(zx, zy); rotate(-.15 + .1 * Math.sin(age * 2 + i));
|
||||
paint(Z.map(([a, b]) => [a * zs, b * zs]), { wash: PAL.cream, fill: PAL.sky, fillOp: 70, ink: PAL.ink, sw: sw * .7 });
|
||||
pop();
|
||||
} break;
|
||||
case 'sweat': for (const [dx, dy, r] of [[0, 0, 1], [1.6, 1.4, .7]])
|
||||
paint(P([[dx, dy - 1.6 * r], [dx + .9 * r, dy + .2], [dx, dy + .9 * r], [dx - .9 * r, dy + .2]]), { wash: PAL.sky, fill: '#FFFFFF', fillOp: 60, ink: PAL.ink, sw: sw * .6, curv: .7 });
|
||||
break;
|
||||
case 'spark': {
|
||||
const tw = 1 + .15 * Math.sin(age * 12);
|
||||
paint(starPts(0, 0, 1.6 * s * tw), { wash: PAL.cream, fill: PAL.ochre, fillOp: 80, ink: PAL.ink, sw: sw * .5 });
|
||||
paint(starPts(1.9 * s, 1.2 * s, .8 * s / tw), { wash: PAL.ochre, ink: PAL.ink, sw: sw * .4 });
|
||||
break;
|
||||
}
|
||||
case 'heart': paint(heartPts(0, 0, s * 1.8 * (1 + .12 * pulse(age + OFF))), { wash: '#E2476E', fill: PAL.rose, fillOp: 90, ink: PAL.ink, sw: sw * .6 }); break;
|
||||
case 'hearts': for (let i = 0; i < 3; i++) {
|
||||
const ph = frac(age * .55 + i / 3), a = Math.sin(ph * Math.PI); if (a < .1) continue;
|
||||
paint(heartPts((Math.sin(ph * 6 + i * 2) * .7 + i * .7 - .7) * s, -ph * 3.8 * s, s * (.5 + .6 * a)), { wash: '#E2476E', fill: PAL.rose, fillOp: 90, ink: PAL.ink, sw: sw * .5 });
|
||||
} break;
|
||||
case 'anger': { // the comic "vein pop": four curved strokes, each bowed in toward the centre
|
||||
const b = 1 + .15 * pulse(age + OFF, 8);
|
||||
for (let i = 0; i < 4; i++) { push(); rotate(i * Math.PI / 2 + Math.PI / 4); scale(b); inkLine(P([140, 160, 180, 200, 220].map(d => [2.1 + 1.3 * Math.cos(d * Math.PI / 180), 1.3 * Math.sin(d * Math.PI / 180)])), sw * 1.1, '#D8394E', 'ink', .6); pop(); }
|
||||
break;
|
||||
}
|
||||
case 'steam': for (const sd of [-1, 1]) for (let j = 0; j < 2; j++) {
|
||||
const ph = frac(age / .7 + j * .5 + (sd > 0 ? .25 : 0)), r = (.55 + ph * .9) * s, cx = sd * (3.2 + ph * 1.8) * s, cy = (1 - ph * 3) * s;
|
||||
const puff = []; for (let i = 0; i < 18; i++) { const a = i / 18 * TAU, bump = 1 + .22 * Math.abs(Math.sin(a * 2.5)); puff.push([cx + Math.cos(a) * r * bump, cy + Math.sin(a) * r * .8 * bump]); }
|
||||
paint(puff, { wash: PAL.cream, washOp: 255 * (1 - ph * .8), ink: ph < .6 ? PAL.ink : null, sw: sw * .5 });
|
||||
} break;
|
||||
case 'bulb': {
|
||||
const gl = .5 + .5 * Math.sin(age * 10);
|
||||
glow(0, -1.4 * s, 4.5 * s, '#FFD27A', .75 + .25 * gl);
|
||||
for (let i = 0; i < 7; i++) { const a = -Math.PI / 2 + (i - 3) * .45, r0 = 1.6 * s, r1 = (2.2 + .4 * gl) * s; inkLine([[Math.cos(a) * r0, -1.4 * s + Math.sin(a) * r0], [Math.cos(a) * r1, -1.4 * s + Math.sin(a) * r1]], sw * .8, PAL.ochre, 'ink', 0); }
|
||||
paint(ellPts(0, -1.4 * s, 1.15 * s, 1.2 * s, 16), { wash: '#FFE68A', fill: PAL.cream, fillOp: 90, ink: PAL.ink, sw: sw * .7 });
|
||||
paint(rectPts(-.5 * s, -.3 * s, s, .8 * s), { wash: '#9A93A8', ink: PAL.ink, sw: sw * .6 });
|
||||
break;
|
||||
}
|
||||
case 'dots': for (let i = 0; i < 3; i++) {
|
||||
const ph = frac(age / 1.8), q = backOut(clamp((ph - i * .22) * 6)); if (q < .02) continue;
|
||||
paint(ellPts((i - 1) * 1.3 * s, 0, .42 * s * q, .42 * s * q, 10), { wash: PAL.ink, ink: null });
|
||||
} break;
|
||||
case 'scribble': {
|
||||
const pts = []; for (let i = 0; i < 34; i++) { const a = i * .95, r = (1.1 + .5 * Math.sin(i * 1.7)) * s; pts.push([Math.cos(a) * r * 1.5 + jit(.2 * s), Math.sin(a) * r * .8 + jit(.2 * s)]); }
|
||||
inkLine(pts, sw * .8, PAL.ink, 'ink', .7); break;
|
||||
}
|
||||
case 'music': {
|
||||
const b = Math.sin(age * 5) * .3 * s;
|
||||
push(); translate(0, b); rotate(.1 * Math.sin(age * 5));
|
||||
paint(ellPts(0, 1.2 * s, .7 * s, .5 * s, 12, 0, -.3), { wash: PAL.ink, ink: null });
|
||||
inkLine(P([[.6, 1.1], [.6, -1.6], [1.6, -1]]), sw * .8, PAL.ink, 'ink', 0);
|
||||
pop();
|
||||
const b2 = Math.sin(age * 5 + 2) * .3 * s;
|
||||
paint(ellPts(2.2 * s, 2.2 * s + b2, .45 * s, .33 * s, 10, 0, -.3), { wash: PAL.ink, ink: null });
|
||||
inkLine([[2.6 * s, 2.15 * s + b2], [2.6 * s, .6 * s + b2]], sw * .6, PAL.ink, 'ink', 0);
|
||||
break;
|
||||
}
|
||||
case 'swirl': {
|
||||
const sp = []; for (let i = 0; i < 18; i++) { const a = i * .6 + age * 5, r = i * .09 * s; sp.push([Math.cos(a) * r, Math.sin(a) * r]); }
|
||||
inkLine(sp, sw * .7, PAL.violet, 'inkfine', .6); break;
|
||||
}
|
||||
case 'stars': for (let i = 0; i < 3; i++) { // little stars circling the head
|
||||
const a = age * 5 + i * TAU / 3, d = .75 + .25 * Math.sin(a);
|
||||
paint(starPts(Math.cos(a) * 4.2 * s, Math.sin(a) * s, .75 * s * d, .45, 5, age * 3), { wash: PAL.ochre, fill: PAL.cream, fillOp: 60, ink: PAL.ink, sw: sw * .5 });
|
||||
} break;
|
||||
case 'cloud': { // a little rain cloud of its own
|
||||
const bob = Math.sin(age * 2) * .15 * s, c = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const a = i / 40 * TAU, up = Math.sin(a) < 0, r = up ? 1 + .38 * Math.pow(Math.abs(Math.sin(a * 3)), .6) : 1;
|
||||
c.push([Math.cos(a) * 2.8 * s * (up ? r * .92 : 1), bob - .2 * s + Math.sin(a) * (up ? 1.35 : .7) * s * r]);
|
||||
}
|
||||
for (let i = 0; i < 6; i++) { const ph = frac(age * 2.4 + hash(i)), rx = (-2 + i * .8) * s, ry = (.7 + ph * 2) * s; inkLine([[rx, ry], [rx - .12 * s, ry + .7 * s]], sw * 1.1, mixCol(PAL.sky, PAL.indigo, .3), 'ink', 0); }
|
||||
paint(c, { wash: '#A3A8C4', fill: PAL.indigo, fillOp: 70, bleed: .1, ink: PAL.ink, sw: sw * .8, curv: .4 });
|
||||
break;
|
||||
}
|
||||
}
|
||||
pop();
|
||||
}
|
||||
// Heart outline points, centred at (cx, cy), about 2r wide.
|
||||
function heartPts(cx, cy, r, n = 22) {
|
||||
const p = []; for (let i = 0; i < n; i++) { const a = i / n * TAU; p.push([cx + 16 * Math.pow(Math.sin(a), 3) * r / 16, cy - (13 * Math.cos(a) - 5 * Math.cos(2 * a) - 2 * Math.cos(3 * a) - Math.cos(4 * a)) * r / 16]); } return p;
|
||||
}
|
||||
|
||||
// ---------- emotions ----------
|
||||
// Each emotion is a face AND a way of moving. body(t) returns pose offsets for clawd(), and every one is alive: it
|
||||
// moves on its own, at its own energy, locked to the beat (PROJECT.bpm). take = how big the reaction is when Clawd
|
||||
// switches INTO this emotion. fade = the emote is a one-off that fades out instead of staying.
|
||||
const _b = t => { const bp = bpOf(t), s1 = Math.sin(bp * Math.PI); return { bp, s1, ab: Math.abs(s1), hit: pulse(t), s2: Math.sin(bp * TAU), f: frac(bp) }; };
|
||||
const EMO = {
|
||||
neutral: { eyes: 'normal', take: .3, body: t => { const b = _b(t); return { dy: -.25 * b.ab, sq: .03 * b.hit, aL: .15 + .05 * b.s1, aR: .15 - .05 * b.s1 }; } },
|
||||
happy: { eyes: 'happy', mouth: 'smile', blush: .3, take: .6, body: t => { const b = _b(t); return { dy: -1.2 * b.ab, sq: .1 * b.hit, aL: .4 + .4 * b.s1, aR: .4 - .4 * b.s1 }; } },
|
||||
excited: { eyes: 'wide', mouth: 'open', emote: 'spark', take: 1, body: t => { const b = _b(t), h = Math.abs(b.s2); return { dy: -2.4 * h, sq: .16 * pulse2(t) - .06 * h, aL: 1 + .5 * Math.sin(b.bp * TAU * 2), aR: 1 - .5 * Math.sin(b.bp * TAU * 2) }; } },
|
||||
laugh: { eyes: 'squeeze', mouth: 'laugh', blush: .45, take: .8, body: t => { const c = Math.abs(Math.sin(t * TAU * 5)); return { dy: -.45 * c, sq: .08 * c - .04, rot: -.07 + .03 * Math.sin(t * TAU * 5), aL: -.35 + .12 * c, aR: -.35 + .12 * c }; } },
|
||||
love: { eyes: 'heart', mouth: 'cat', blush: .8, tint: 'rosy', tintK: .5, emote: 'hearts', take: .7, body: t => { const b = _b(t), s = Math.sin(b.bp * Math.PI / 2); return { rot: .08 * s, dx: .5 * s, dy: -.6 * b.ab, sq: .05 * b.hit, aL: -.1 + .15 * b.s1, aR: -.1 - .15 * b.s1 }; } },
|
||||
shy: { eyes: 'look', mouth: 'wobble', blush: 1, take: .3, body: t => { const b = _b(t), s = Math.sin(b.bp * Math.PI / 2); return { lookX: -.7, lookY: .8, sq: .06, rot: -.05 + .02 * s, dx: .15 * s, aL: -.55 + .15 * Math.sin(t * TAU * 1.5), aR: -.6 - .12 * Math.sin(t * TAU * 1.5) }; } },
|
||||
proud: { eyes: 'closed', mouth: 'smile', tint: 'gold', tintK: .3, emote: 'spark', take: .5, body: t => { const b = _b(t); return { sq: -.1 - .03 * b.ab, dy: -.3 * b.hit, aL: -.9, aR: -.9, rot: .02 * b.s1 }; } },
|
||||
smug: { eyes: 'narrow', mouth: 'smirk', take: .4, body: t => { const b = _b(t), s = Math.sin(b.bp * Math.PI / 2); return { lookX: .4, rot: -.06 + .03 * s, dy: -.2 * b.ab, aL: -.5, aR: .5 + .15 * s }; } },
|
||||
relieved: { eyes: 'closed', mouth: 'smile', emote: 'sweat', take: .4, body: t => { const b = _b(t), br = Math.sin(t * TAU * .35); return { sq: .05 + .05 * br, dy: -.15 * b.ab, aL: -.7 + .1 * br, aR: -.7 + .1 * br }; } },
|
||||
sad: { eyes: 'sad', mouth: 'frown', tint: 'blue', tintK: .35, gloom: .35, emote: 'cloud', take: .3, body: t => { const b = _b(t), s = Math.sin(b.bp * Math.PI / 4); return { sq: .08 + .02 * s, rot: .03 * s, aL: -.75, aR: -.75, lookY: .5 }; } },
|
||||
cry: { eyes: 'cry', mouth: 'wail', tint: 'blue', tintK: .2, take: .8, body: t => { const b = _b(t), sob = Math.sin(b.f * Math.PI) * Math.exp(-b.f * 2); return { sq: .14 * sob - .02, dy: -.7 * sob, aL: .9 + .15 * Math.sin(t * TAU * 6), aR: .9 - .15 * Math.sin(t * TAU * 6), rot: .03 * b.s1 }; } },
|
||||
angry: { eyes: 'angry', mouth: 'teeth', tint: 'flush', tintK: .45, emote: 'anger', take: .8, body: t => { const b = _b(t); return { dx: .1 * Math.sin(t * TAU * 18), sq: .12 * b.hit, aL: -.3 + .1 * b.hit, aR: -.3 + .1 * b.hit }; } },
|
||||
furious: { eyes: 'red', lid: .28, tint: 'flush', tintK: .85, emote: 'steam', take: 1.2, body: t => { const b = _b(t); return { dx: .22 * Math.sin(t * TAU * 20), rot: .025 * Math.sin(t * TAU * 13), sq: .2 * b.hit, dy: -.8 * Math.sin(b.f * Math.PI) * (1 - b.f), aL: 1 + .2 * Math.sin(t * TAU * 9), aR: 1 - .2 * Math.sin(t * TAU * 9) }; } },
|
||||
scared: { eyes: 'scared', mouth: 'wobble', tint: 'pale', tintK: .6, emote: 'sweat', take: 1.1, body: t => ({ dx: .1 * Math.sin(t * TAU * 22), sq: -.06, aL: 1 + .08 * Math.sin(t * TAU * 17), aR: 1 + .08 * Math.sin(t * TAU * 19), lookX: .6 * Math.sign(Math.sin(t * 2.3)) }) },
|
||||
surprised: { eyes: 'wide', mouth: 'O', emote: '!', fade: true, take: 1.3, body: t => { const b = _b(t); return { sq: -.12, dy: -.3 - .15 * b.ab, aL: 1.1 + .05 * b.s2, aR: 1.1 - .05 * b.s2 }; } },
|
||||
confused: { eyes: ['narrow', 'wide'], mouth: 'wobble', emote: '?', take: .6, body: t => { const b = _b(t); return { rot: .1 * Math.sin(b.bp * Math.PI / 4), aL: .1, aR: 1.5 + .15 * Math.sin(t * TAU * 3), dy: -.15 * b.ab }; } },
|
||||
thinking: { eyes: 'look', mouth: 'flat', emote: 'dots', take: .4, body: t => { const b = _b(t); return { lookX: .5, lookY: -.8, rot: .05, aR: .9, aL: -.3 + .25 * Math.sin(b.f * Math.PI), dy: -.1 * b.ab }; } },
|
||||
idea: { eyes: 'shine', mouth: 'grin', emote: 'bulb', take: 1.1, body: t => { const b = _b(t); return { dy: -1 * b.ab, sq: -.05 + .1 * b.hit, aR: 1.55 + .1 * b.s2, aL: .2 + .2 * b.s1 }; } },
|
||||
determined: { eyes: 'determined', mouth: 'flat', take: .7, body: t => { const b = _b(t), p1 = Math.sin(b.f * Math.PI), p2 = Math.abs(Math.cos(b.f * Math.PI)); return { rot: .06, sq: -.04 + .06 * b.hit, dy: -.3 * b.ab, aL: .1 + .7 * p1, aR: .1 + .7 * p2 }; } },
|
||||
sleepy: { eyes: 'sleepy', mouth: 'o', emote: 'zzz', take: .2, body: t => { const br = Math.sin(t * TAU * .3); return { sq: .05 + .05 * br, rot: .06 * Math.sin(t * .8), aL: -.6, aR: -.6 }; } },
|
||||
bored: { eyes: 'narrow', mouth: 'flat', take: .2, body: t => { const f = frac(bpOf(t) / 4), sigh = f < .3 ? ease(f / .3) : 1 - ease((f - .3) / .7); return { lookX: -.3, lookY: .4, sq: .08 - .14 * sigh, rot: -.04, aL: -.85 + .05 * Math.sin(t * 2), aR: -.85 - .05 * Math.sin(t * 2) }; } },
|
||||
nervous: { eyes: 'look', mouth: 'wobble', tint: 'pale', tintK: .2, emote: 'sweat', take: .5, body: t => { const b = _b(t); return { lookX: beatN(t) % 2 ? .8 : -.8, dx: .3 * b.s1, sq: .04, aL: -.1 + .2 * Math.sin(t * TAU * 5), aR: -.1 + .2 * Math.sin(t * TAU * 5 + 1) }; } },
|
||||
suspicious: { eyes: 'narrow', mouth: 'flat', take: .4, body: t => { const s = Math.sin(t * TAU * .25); return { lookX: s, rot: -.08 * s, dx: .4 * s, sq: .04, aL: -.4, aR: -.4 }; } },
|
||||
disgusted: { eyes: 'squeeze', mouth: 'wobble', tint: 'green', tintK: .5, take: .8, body: t => { const a = frac(bpOf(t) / 2) * BEAT * 2, sh = Math.exp(-a * 6) * Math.sin(a * 45); return { rot: -.1, dx: -.3 + .15 * sh, sq: .05, aL: .7 + .2 * sh, aR: .7 - .2 * sh }; } },
|
||||
dizzy: { eyes: 'swirl', mouth: 'wobble', emote: 'stars', take: .7, body: t => { const a = t * TAU * .8; return { rot: .12 * Math.sin(a), dx: .7 * Math.sin(a), dy: -.3 * Math.abs(Math.cos(a)), aL: .3 + .6 * Math.sin(a * 1.3), aR: .3 - .6 * Math.sin(a * 1.3) }; } },
|
||||
cool: { eyes: 'shades', mouth: 'smirk', emote: 'music', take: .4, body: t => { const b = _b(t); return { rot: .04 * b.hit, dy: -.35 * b.hit, sq: .06 * b.hit, aL: -.4, aR: .7 + .1 * b.s1 }; } },
|
||||
starstruck: { eyes: 'spark', mouth: 'open', tint: 'gold', tintK: .3, take: 1, body: t => { const b = _b(t), h = Math.abs(b.s2); return { dy: -1.6 * h, sq: .1 * pulse2(t), aL: 1.1 + .35 * Math.sin(b.bp * TAU * 2), aR: 1.1 + .35 * Math.sin(b.bp * TAU * 2) }; } },
|
||||
ko: { eyes: 'x', mouth: 'wobble', tint: 'pale', tintK: .3, emote: 'stars', take: 1, body: t => ({ sq: .28 + .02 * Math.sin(t * 3), rot: .12, aL: -1, aR: -.9 }) },
|
||||
playful: { eyes: 'wink', mouth: 'tongue', take: .6, body: t => { const b = _b(t), side = beatN(t) % 2 ? 1 : -1, k = lerp(-side, side, easeOut(clamp(b.f * 3))); return { dx: .8 * k, dy: -1.4 * b.ab, rot: .1 * k, sq: .1 * b.hit, aL: .65 + .55 * k, aR: .65 - .55 * k }; } },
|
||||
mischief: { eyes: 'narrow', mouth: 'grin', gloom: .3, take: .5, body: t => { const r = Math.sin(t * TAU * 4); return { lookX: .3, sq: .07, rot: .04, aL: -.05 + .15 * r, aR: -.05 - .15 * r, dy: -.1 * Math.abs(r) }; } },
|
||||
hopeful: { eyes: 'shine', mouth: 'cat', blush: .35, take: .5, body: t => { const b = _b(t); return { sq: -.06 - .02 * b.ab, dy: -.25 * b.ab, lookY: -.4, aL: .5 + .05 * b.s1, aR: .5 - .05 * b.s1, rot: .03 * Math.sin(b.bp * Math.PI / 2) }; } },
|
||||
};
|
||||
|
||||
// One emotion, alive at time t: face, colour and body motion together. Spread it into clawd():
|
||||
// clawd(x, y, u, feel('happy', t)) clawd(x, y, u, { ...feel('sad', t), view: 'q', hat: 'beanie' })
|
||||
function feel(name, t, over = {}) {
|
||||
const E = EMO[name] || EMO.neutral;
|
||||
return { eyes: E.eyes, mouth: E.mouth, tint: E.tint, tintK: E.tintK ?? 1, blush: E.blush || 0, gloom: E.gloom || 0,
|
||||
lid: E.lid || 0, emote: E.emote, ...(E.body ? E.body(t) : {}), ...over };
|
||||
}
|
||||
|
||||
// An emotion timeline with ACTED changes: keys = [[t0, 'neutral'], [t1, 'surprised'], [t2, 'happy', { emote: 'music' }]].
|
||||
// Around each change the eyes squeeze shut and the body squashes just before (anticipation), and the face swaps
|
||||
// under the squint. Then a take fires (a squash-stretch the size of the new emotion's `take`), the body settles into
|
||||
// its new motion with overshoot, colour, blush and gloom cross-fade, and the new emote pops in. o.take scales every take.
|
||||
// Spread the result into clawd() and add any other pose: clawd(x, y, u, { ...emotions(t, keys), view: 'q' }).
|
||||
function emotions(t, keys, o = {}) {
|
||||
let i = 0; while (i + 1 < keys.length && t >= keys[i + 1][0]) i++;
|
||||
const [tc, name, over] = keys[i], age = t - tc, cur = feel(name, t, over);
|
||||
const tn = i + 1 < keys.length ? keys[i + 1][0] : Infinity, tkS = o.take ?? 1;
|
||||
const E = EMO[name] || EMO.neutral;
|
||||
let squint = cur.squint || 0;
|
||||
if (tn - t < .1) squint = Math.max(squint, 1 - (tn - t) / .1);
|
||||
if (i > 0 && age < .14) squint = Math.max(squint, 1 - age / .14);
|
||||
const prev = i > 0 ? feel(keys[i - 1][1], t, keys[i - 1][2]) : null;
|
||||
if (prev && age < .5) {
|
||||
const base = { dy: 0, sq: 0, aL: .2, aR: .2, rot: 0, dx: 0, lookX: 0, lookY: 0 };
|
||||
const k = backOut(seg(age, 0, .4)), kc = ease(seg(age, 0, .3));
|
||||
for (const f in base) cur[f] = lerp(prev[f] ?? base[f], cur[f] ?? base[f], k);
|
||||
const a = tintCols(prev), b = tintCols(cur);
|
||||
cur.col = mixCol(a.col, b.col, kc); cur.dk = mixCol(a.dk, b.dk, kc); cur.lt = mixCol(a.lt, b.lt, kc); cur.tint = null;
|
||||
for (const f of ['blush', 'gloom', 'lid']) cur[f] = lerp(prev[f] || 0, cur[f] || 0, kc);
|
||||
}
|
||||
// takes: the one this key fires, plus the anticipation squash of the next one
|
||||
const t1 = prev ? take(t, tc, (E.take ?? .6) * tkS) : { sq: 0, dy: 0 };
|
||||
const En = i + 1 < keys.length ? EMO[keys[i + 1][1]] || EMO.neutral : null, t2 = En ? take(t, tn, (En.take ?? .6) * tkS) : { sq: 0, dy: 0 };
|
||||
cur.sq = (cur.sq || 0) + t1.sq + t2.sq; cur.dy = (cur.dy || 0) + t1.dy + t2.dy;
|
||||
cur.squint = squint;
|
||||
// the emote pops in after the swap (it carries on if the emote didn't change); one-off emotes fade out
|
||||
const same = prev && prev.emote === cur.emote;
|
||||
cur.emoteK = same ? 1 : seg(age, .06, .32) * (E.fade && !(over && over.emote) ? 1 - seg(age, 1.4, 1.8) : 1);
|
||||
cur.emoteAge = age;
|
||||
return cur;
|
||||
}
|
||||
|
||||
// ---------- moves ----------
|
||||
// Beat-locked dances: bounce, hop, roof (arms up), sway, spin (a drawn spin through the key views, once a bar), wave,
|
||||
// walk, run, idle, stomp, shimmy, mix (changes every two bars). Returns pose offsets; spread into clawd().
|
||||
function move(style, t, seed = 0) {
|
||||
const bp = bpOf(t), bi = Math.floor(bp), bf = bp - bi, hit = Math.max(0, 1 - bf * 3.5), s1 = Math.sin(bp * Math.PI), ab = Math.abs(s1);
|
||||
const o = { dy: 0, sq: 0, aL: .2, aR: .2, rot: 0, walk: null, dx: 0 };
|
||||
if (style === 'mix') style = ['bounce', 'roof', 'sway', 'spin', 'hop', 'wave'][(Math.floor(bp / 8) + seed) % 6];
|
||||
switch (style) {
|
||||
case 'bounce': o.dy = -ab * 1.6; o.sq = hit * .12; o.aL = .4 + s1; o.aR = .4 - s1; break;
|
||||
case 'hop': o.dy = -ab * 4; o.sq = hit * .18; o.aL = o.aR = .3 + ab * 1.1; break;
|
||||
case 'roof': o.dy = -ab * 1.2; o.sq = hit * .1; o.aL = o.aR = 1.25 + .3 * Math.sin(bp * TAU); break;
|
||||
case 'sway': o.dx = s1 * 3; o.rot = s1 * .12; o.aL = .5 + .6 * s1; o.aR = .5 - .6 * s1; o.sq = hit * .08; break;
|
||||
case 'spin': {
|
||||
const ph = (((bi % 4) + 4) % 4 === 3) ? bf : 0;
|
||||
Object.assign(o, spinView(ph)); o.smear = ph > .1 && ph < .9 ? .5 : 0; o.smearDir = 0;
|
||||
o.dy = -Math.sin(ph * Math.PI) * 3 - ab; o.aL = o.aR = .6 + ph; o.sq = hit * .1; break;
|
||||
}
|
||||
case 'wave': o.dy = -ab; o.aL = 1.1 + .5 * Math.sin(bp * TAU * 2); o.aR = -.2; o.sq = hit * .08; break;
|
||||
case 'walk': o.walk = bp / 2; o.dy = -ab * .6; o.aL = .3 * s1; o.aR = -o.aL; break;
|
||||
case 'run': o.walk = bp * 1.5; o.dy = -Math.abs(Math.sin(bp * TAU)); o.aL = .8 * Math.sin(bp * TAU * 1.5); o.aR = -o.aL; o.rot = -.08; break;
|
||||
case 'idle': o.dy = -ab * .5; o.sq = hit * .05; break;
|
||||
case 'stomp': o.dy = -Math.max(0, Math.sin(bp * TAU)) * 1.4; o.sq = hit * .2; o.rot = (bi % 2 ? 1 : -1) * .06 * hit; o.aL = o.aR = -.3 + hit * .9; break;
|
||||
case 'shimmy': o.dx = Math.sin(bp * TAU * 2) * .6; o.rot = Math.sin(bp * TAU * 2) * .05; o.aL = .9 + .4 * Math.sin(bp * TAU * 2); o.aR = .9 - .4 * Math.sin(bp * TAU * 2); o.dy = -ab * .5; break;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
function dancer(x, y, u, style, t, extra = {}) { clawd(x, y, u, { ...move(style, t, extra.seed || 0), ...extra }); }
|
||||
@@ -1,5 +0,0 @@
|
||||
// config.js: project settings.
|
||||
// duration: the video's length in seconds.
|
||||
// bpm: the rhythm that bounces, dances and pulse() follow. Clawd always moves to some beat; if the video has music,
|
||||
// set this to the song's tempo, and set offset to the time in seconds of its first downbeat.
|
||||
const PROJECT = { duration: 11, bpm: 120, offset: 0 };
|
||||
-326
@@ -1,326 +0,0 @@
|
||||
// core.js: constants, helpers, paper, paint wrapper, compositing and render hooks.
|
||||
// Length and rhythm come from PROJECT in config.js.
|
||||
const W = 1920, H = 1080;
|
||||
const BPM = PROJECT.bpm, BEAT = 60 / BPM, OFF = PROJECT.offset || 0, BOIL = 12, DUR = PROJECT.duration;
|
||||
const TAU = Math.PI * 2;
|
||||
const PAL = {
|
||||
paper: '#F3EBDC', ink: '#2B2233', clay: '#D97757', clayDk: '#A84D33', clayLt: '#F2A283',
|
||||
night: '#1F2550', indigo: '#2F3C7A', rose: '#E27A92', ochre: '#E8AA38', sap: '#6E9F58',
|
||||
teal: '#3A9C98', violet: '#7B5CA8', cream: '#FFF5E2', sky: '#8EC3E6'
|
||||
};
|
||||
|
||||
const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
|
||||
const lerp = (a, b, x) => a + (b - a) * x;
|
||||
const ease = x => { x = clamp(x); return x * x * (3 - 2 * x); };
|
||||
const easeOut = x => 1 - Math.pow(1 - clamp(x), 3);
|
||||
const backOut = x => { x = clamp(x); const s = 1.9; return 1 + (s + 1) * Math.pow(x - 1, 3) + s * Math.pow(x - 1, 2); };
|
||||
const hash = i => { const x = Math.sin(i * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
|
||||
const bpOf = t => (t - OFF) / BEAT;
|
||||
// Seeded by the boil frame, so linework "boils" at BOIL fps like hand-drawn animation.
|
||||
const jit = a => (random() * 2 - 1) * a;
|
||||
// Each boil drawing holds for several frames, so whatever isn't moving must draw the same until the next one. But a moving
|
||||
// thing uses a different amount of randomness each frame, which shifts the stream for everything drawn after it and makes
|
||||
// that re-boil every frame (jitter). boilSeed(key) restarts the stream from the boil frame and a key (any string or
|
||||
// number) that's the same every frame: call it before each separate element. clawd() does this for itself and its parts.
|
||||
let BOILN = 0, CLAWD_N = 0;
|
||||
const boilSeed = key => { let h = 2166136261; for (const c of key + '|' + BOILN) h = Math.imul(h ^ c.charCodeAt(0), 16777619); randomSeed(h >>> 0); };
|
||||
|
||||
// ---------- timing helpers (everything is a pure function of t; no state survives between frames) ----------
|
||||
const seg = (t, a, b) => clamp((t - a) / (b - a)); // 0..1 progress of t through [a, b]
|
||||
const frac = x => x - Math.floor(x);
|
||||
const beatN = t => Math.floor(bpOf(t)); // integer beat index
|
||||
const pulse = (t, k = 6) => Math.exp(-frac(bpOf(t)) * k); // 1 exactly on each beat, decays after
|
||||
const pulse2 = (t, k = 6) => Math.exp(-frac(bpOf(t) * 2) * k); // same on eighth notes
|
||||
const wob = (t, f = 1, ph = 0) => Math.sin((t * f + ph) * TAU);
|
||||
const easeIn = x => Math.pow(clamp(x), 3);
|
||||
const elasticOut = x => { x = clamp(x); return x === 0 || x === 1 ? x : Math.pow(2, -10 * x) * Math.sin((x * 10 - .75) * (TAU / 3)) + 1; };
|
||||
// keyframes: kf(t, [[t0, v0], [t1, v1], ...], easeFn). Values may be numbers or arrays of numbers.
|
||||
function kf(t, keys, e = ease) {
|
||||
if (t <= keys[0][0]) return keys[0][1];
|
||||
for (let i = 1; i < keys.length; i++) {
|
||||
if (t < keys[i][0]) {
|
||||
const [a, va] = keys[i - 1], [b, vb] = keys[i], k = e((t - a) / (b - a));
|
||||
return Array.isArray(va) ? va.map((v, j) => lerp(v, vb[j], k)) : lerp(va, vb, k);
|
||||
}
|
||||
}
|
||||
return keys[keys.length - 1][1];
|
||||
}
|
||||
// hex color mix
|
||||
function mixCol(a, b, k) {
|
||||
const pa = parseInt(a.slice(1), 16), pb = parseInt(b.slice(1), 16), c = i => Math.round(lerp((pa >> i) & 255, (pb >> i) & 255, clamp(k)));
|
||||
return '#' + ((1 << 24) + (c(16) << 16) + (c(8) << 8) + c(0)).toString(16).slice(1);
|
||||
}
|
||||
// small deterministic camera shake, changes at 24 fps
|
||||
const shakeXY = (t, amt) => { const f = Math.floor(t * 24); return [(hash(f * 1.7) - .5) * 2 * amt, (hash(f * 2.3 + 9) - .5) * 2 * amt]; };
|
||||
|
||||
// ---------- motion principles, as pure functions of t ----------
|
||||
// Damped spring kicked at t0: 0 before, then a wobble that dies away. Use it for secondary motion and settles: a body
|
||||
// after landing, a hat that jiggles, a stack that sways, a tail that drags. k = damping, w = wobble speed (rad/s).
|
||||
const spring = (t, t0, k = 6, w = 18) => t < t0 ? 0 : Math.exp(-k * (t - t0)) * Math.sin(w * (t - t0));
|
||||
const ring = (t, evs, k = 6, w = 18) => evs.reduce((s, e) => s + spring(t, e, k, w), 0); // one kick per event time
|
||||
// Hold each drawing for two frames (12 drawings a second), like hand-drawn animation "on twos". Wrap a shot's t in it.
|
||||
const onTwos = t => Math.floor(t * 12 + 1e-6) / 12;
|
||||
// Point on a thrown or jumping arc from p0 to p1, peaking h px above the straight line; k = 0..1 along the flight.
|
||||
const arcPt = (p0, p1, h, k) => [lerp(p0[0], p1[0], k), lerp(p0[1], p1[1], k) - h * 4 * k * (1 - k)];
|
||||
// A hop that takes off at t0 and lands at t1, h body units high: crouch (anticipation), stretch on takeoff,
|
||||
// round at the top, squash on landing and spring back. Returns { dy, sq } to spread into clawd().
|
||||
function jump(t, t0, t1, h = 3) {
|
||||
if (t < t0 - .12) return { dy: 0, sq: 0 };
|
||||
if (t < t0) return { dy: 0, sq: .18 * ease(seg(t, t0 - .12, t0)) };
|
||||
if (t < t1) { const k = (t - t0) / (t1 - t0); return { dy: -h * 4 * k * (1 - k), sq: -.16 * Math.abs(1 - 2 * k) }; }
|
||||
const a = t - t1; return { dy: 0, sq: .22 * Math.exp(-8 * a) * Math.cos(20 * a) };
|
||||
}
|
||||
// A surprise "take" peaking at t0: a quick squash, then a big stretch up that springs back. amt scales it.
|
||||
function take(t, t0, amt = 1) {
|
||||
if (t < t0 - .1) return { sq: 0, dy: 0 };
|
||||
if (t < t0) return { sq: .12 * amt * ease(seg(t, t0 - .1, t0)), dy: 0 };
|
||||
const a = t - t0; return { sq: -.26 * amt * Math.exp(-6 * a) * Math.cos(16 * a), dy: -1.2 * amt * Math.exp(-7 * a) * Math.max(0, Math.cos(9 * a)) };
|
||||
}
|
||||
// Walk from x0 to x1 (px) between t0 and t1, for a character of unit u: eases in and out, faces the way it's
|
||||
// going in 3/4 view, and faces front when it stops. Returns { x, walk, view, flip, dy } for clawd().
|
||||
function stroll(t, t0, t1, x0, x1, u) {
|
||||
const x = lerp(x0, x1, ease(seg(t, t0, t1))), d = Math.abs(x - x0) / (4 * u), moving = t > t0 && t < t1;
|
||||
return { x, walk: d, view: moving ? 'q' : 'front', flip: x1 < x0, dy: moving ? -Math.abs(Math.sin(d * Math.PI)) * .5 : 0 };
|
||||
}
|
||||
|
||||
// ---------- camera ----------
|
||||
// camBegin(cx, cy, zoom, rot): world point (cx, cy) lands at screen centre. Letters queued while a camera is
|
||||
// active are placed through it automatically (pass {screen:true} to opt out). One level only: always pair with camEnd().
|
||||
// LAST_CAM stays set after camEnd(), until the next frame: renderSheet's crops that follow a world point use it.
|
||||
let CAM = null, LAST_CAM = null;
|
||||
function camBegin(cx = W / 2, cy = H / 2, zoom = 1, rot = 0) { push(); translate(W / 2, H / 2); rotate(rot); scale(zoom); translate(-cx, -cy); CAM = LAST_CAM = { cx, cy, zoom, rot }; }
|
||||
function camEnd() { pop(); CAM = null; }
|
||||
function toScreen(x, y, cam = CAM) {
|
||||
if (!cam) return [x, y];
|
||||
const c = Math.cos(cam.rot), s = Math.sin(cam.rot), dx = (x - cam.cx) * cam.zoom, dy = (y - cam.cy) * cam.zoom;
|
||||
return [W / 2 + dx * c - dy * s, H / 2 + dx * s + dy * c];
|
||||
}
|
||||
|
||||
// ---------- full-frame effects (call outside a camera, in screen space) ----------
|
||||
function flash(k, col = '#FFFDF6') { if (k > .01) paint(rectPts(-60, -60, W + 120, H + 120), { wash: col, washOp: 255 * clamp(k), ink: null }); }
|
||||
// Light: glow(x, y, r, col, a) ADDS a soft halo of light for anything that shines (stars, lamps, fireflies, magic).
|
||||
// p5.brush mixes every colour like pigment, so yellow painted over blue turns green and light can't be painted; this
|
||||
// is the one non-paint mark in the kit. It lands on what's painted so far, under anything painted after it, follows
|
||||
// the camera, and boils a little. Keep a = 1 on dark grounds; on light grounds it barely shows (as light would).
|
||||
function glow(x, y, r, col = '#FFC766', a = 1) {
|
||||
if (a <= 0 || r < 1) return;
|
||||
flushBrush();
|
||||
const c = color(col), rr = r * (1 + jit(.03));
|
||||
push(); blendMode(ADD); tint(red(c), green(c), blue(c), 150 * clamp(a)); image(glowTex, x - rr, y - rr, 2 * rr, 2 * rr); noTint(); blendMode(BLEND); pop();
|
||||
}
|
||||
function makeGlowTex() {
|
||||
const g = createGraphics(256, 256); g.pixelDensity(1); const c = g.drawingContext, gr = c.createRadialGradient(128, 128, 0, 128, 128, 128);
|
||||
[[0, 1], [.18, .8], [.45, .32], [.75, .08], [1, 0]].forEach(([s, a]) => gr.addColorStop(s, `rgba(255,255,255,${a})`));
|
||||
c.fillStyle = gr; c.fillRect(0, 0, 256, 256);
|
||||
return g;
|
||||
}
|
||||
// Paint everything OUTSIDE a star-shaped hole (irises, mouth-shaped reveals, keyholes).
|
||||
function irisShape(pts, col = PAL.ink, far = 4000) {
|
||||
const n = pts.length; let cx = 0, cy = 0; for (const p of pts) { cx += p[0]; cy += p[1]; } cx /= n; cy /= n;
|
||||
const out = p => { const dx = p[0] - cx, dy = p[1] - cy, d = Math.hypot(dx, dy) || 1; return [cx + dx / d * far, cy + dy / d * far]; };
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i], b = pts[(i + 1) % n], ex = (b[0] - a[0]) * .06, ey = (b[1] - a[1]) * .06;
|
||||
const a2 = [a[0] - ex, a[1] - ey], b2 = [b[0] + ex, b[1] + ey];
|
||||
paint([a2, b2, out(b2), out(a2)], { wash: col, washOp: 255, ink: null });
|
||||
}
|
||||
}
|
||||
function iris(cx, cy, r, col = PAL.ink) { if (r < 4) paint(rectPts(-60, -60, W + 120, H + 120), { wash: col, ink: null }); else irisShape(ellPts(cx, cy, r, r, 40), col); }
|
||||
|
||||
let T = 0, paperG = null, grainC = null, letG = null, glowTex = null, outC = null, outX = null;
|
||||
let LETTERS = [];
|
||||
|
||||
// ---------- geometry ----------
|
||||
function rectPts(x, y, w, h, j = 0) {
|
||||
return [[x + jit(j), y + jit(j)], [x + w / 2 + jit(j), y + jit(j) * .5], [x + w + jit(j), y + jit(j)],
|
||||
[x + w + jit(j) * .5, y + h / 2], [x + w + jit(j), y + h + jit(j)], [x + w / 2 + jit(j), y + h + jit(j) * .5],
|
||||
[x + jit(j), y + h + jit(j)], [x + jit(j) * .5, y + h / 2]];
|
||||
}
|
||||
function ellPts(cx, cy, rx, ry, n = 28, j = 0, rot = 0) {
|
||||
const p = []; for (let i = 0; i < n; i++) { const a = rot + i / n * TAU; p.push([cx + Math.cos(a) * rx + jit(j), cy + Math.sin(a) * ry + jit(j)]); } return p;
|
||||
}
|
||||
function rrPts(x, y, w, h, r, j = 0) {
|
||||
const p = [], seg = 5, corner = (cx, cy, a0) => { for (let i = 0; i <= seg; i++) { const a = a0 + i / seg * Math.PI / 2; p.push([cx + Math.cos(a) * r + jit(j), cy + Math.sin(a) * r + jit(j)]); } };
|
||||
corner(x + w - r, y + r, -Math.PI / 2); corner(x + w - r, y + h - r, 0); corner(x + r, y + h - r, Math.PI / 2); corner(x + r, y + r, Math.PI);
|
||||
return p;
|
||||
}
|
||||
function starPts(cx, cy, r, inner = .38, n = 4, rot = -Math.PI / 2) {
|
||||
const p = []; for (let i = 0; i < n * 2; i++) { const a = rot + i * Math.PI / n, q = i % 2 ? r * inner : r; p.push([cx + Math.cos(a) * q, cy + Math.sin(a) * q]); } return p;
|
||||
}
|
||||
// Smooth curve through the points (Catmull-Rom), n samples per span.
|
||||
function through(P, n = 6) {
|
||||
if (P.length < 3) return P.slice();
|
||||
const out = [];
|
||||
for (let i = 0; i < P.length - 1; i++) {
|
||||
const p0 = P[Math.max(0, i - 1)], p1 = P[i], p2 = P[i + 1], p3 = P[Math.min(P.length - 1, i + 2)];
|
||||
for (let k = 0; k < n; k++) {
|
||||
const u = k / n, u2 = u * u, u3 = u2 * u;
|
||||
out.push([0, 1].map(d => .5 * (2 * p1[d] + (p2[d] - p0[d]) * u + (2 * p0[d] - 5 * p1[d] + 4 * p2[d] - p3[d]) * u2 + (3 * p1[d] - p0[d] - 3 * p2[d] + p3[d]) * u3)));
|
||||
}
|
||||
}
|
||||
out.push(P[P.length - 1]);
|
||||
return out;
|
||||
}
|
||||
// Tapered ribbon around a path (w0 wide at the start, w1 at the end), as one closed outline for paint().
|
||||
// Tails, tentacles, noodly arms, painted glyphs: one shape with one outline, so nothing looks glued on.
|
||||
function ribbon(P, w0, w1 = w0) {
|
||||
const C = through(P), n = C.length, L = [], R = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = C[Math.max(0, i - 1)], b = C[Math.min(n - 1, i + 1)], dx = b[0] - a[0], dy = b[1] - a[1], d = Math.hypot(dx, dy) || 1, w = lerp(w0, w1, i / Math.max(1, n - 1)) / 2;
|
||||
L.push([C[i][0] - dy / d * w, C[i][1] + dx / d * w]); R.push([C[i][0] + dy / d * w, C[i][1] - dx / d * w]);
|
||||
}
|
||||
return L.concat(R.reverse());
|
||||
}
|
||||
|
||||
// ---------- paint wrapper ----------
|
||||
// One call = one painted shape: optional flat wash, optional watercolor fill, optional hatch, optional ink outline.
|
||||
// p5.brush 2.2.3 loses strokes drawn far from the origin under a zoomed camera (from zoom ~2, an outline or a line
|
||||
// leaves only a dot at its first vertex), so every shape and line is drawn around its own centre.
|
||||
function centred(pts, draw) {
|
||||
if (!pts.length) return;
|
||||
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
||||
for (const [x, y] of pts) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
|
||||
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
||||
push(); translate(cx, cy); draw(pts.map(([x, y]) => [x - cx, y - cy])); pop();
|
||||
}
|
||||
function paint(pts, o = {}) { centred(pts, (P) => paintAt(P, o)); }
|
||||
function paintAt(pts, o) {
|
||||
if (o.wash || o.fill || o.hatch) {
|
||||
if (o.wash) brush.wash(o.wash, o.washOp ?? 255); else brush.noWash();
|
||||
if (o.fill) { brush.fill(o.fill, o.fillOp ?? 170); brush.fillBleed(o.bleed ?? .1); brush.fillTexture(o.tex ?? .4, o.border ?? .35); } else brush.noFill();
|
||||
if (o.hatch) { brush.hatch(o.hatch.d, o.hatch.a, o.hatch.o || { rand: .15 }); brush.hatchStyle(o.hatch.b || 'HB', o.hatch.c || PAL.ink, o.hatch.w || 1); } else brush.noHatch();
|
||||
brush.noStroke();
|
||||
if (o.curv) { brush.beginShape(o.curv); for (const p of pts) brush.vertex(p[0], p[1]); brush.endShape(true); }
|
||||
else brush.polygon(pts);
|
||||
}
|
||||
// the outline is one continuous tapered stroke, not a stroke per side
|
||||
if (o.ink !== null) {
|
||||
brush.noWash(); brush.noFill(); brush.noHatch(); brush.set(o.br || 'ink', o.ink || PAL.ink, o.sw ?? 1);
|
||||
brush.beginShape(o.curv || 0); for (const p of pts) brush.vertex(p[0], p[1]); brush.endShape(true);
|
||||
}
|
||||
}
|
||||
function inkLine(pts, sw = 1, col = PAL.ink, br = 'ink', curv = .5) {
|
||||
centred(pts, (P) => { brush.noFill(); brush.noWash(); brush.noHatch(); brush.set(br, col, sw); brush.spline(P, curv); });
|
||||
}
|
||||
|
||||
// ---------- lettering (drawn on the 2D compositor, under the paper grain) ----------
|
||||
// Use sparingly: see "No text" in ANIMATION_GUIDE.md. Clawd's emotes are painted and never need these.
|
||||
function letter(txt, x, y, size, color, o = {}) {
|
||||
if (CAM && !o.screen) { [x, y] = toScreen(x, y); size *= CAM.zoom; o = { ...o, rot: (o.rot || 0) + CAM.rot }; if (o.font) o.font = o.font.replace(/(\d+(\.\d+)?)px/, (m, v) => (v * CAM.zoom) + 'px'); }
|
||||
LETTERS.push({ txt, x, y, size, color, ...o });
|
||||
}
|
||||
// Comic sound effect: pops in at age 0, wobbles, fades by `life` seconds.
|
||||
function sfx(txt, x, y, size, color, age, o = {}) {
|
||||
const life = o.life ?? 1.2; if (age < 0 || age > life) return;
|
||||
letter(txt, x, y, size, color, { pop: age * 5, rot: (o.rot ?? -.08) + Math.sin(age * 20) * .03 * (1 - age / life), alpha: 1 - seg(age, life - .25, life), ...o });
|
||||
}
|
||||
function drawLetters(c) {
|
||||
for (const L of LETTERS) {
|
||||
const k = L.pop != null ? backOut(L.pop) : 1; if (k <= .01) continue;
|
||||
c.save(); c.translate(L.x, L.y); c.rotate(L.rot || 0); c.scale(k, k); c.globalAlpha = L.alpha ?? 1;
|
||||
c.font = L.font || `${L.size}px "Permanent Marker", "Comic Sans MS", cursive`;
|
||||
c.textAlign = L.align || 'center'; c.textBaseline = 'middle';
|
||||
if (L.stroke) { c.lineJoin = 'round'; c.lineWidth = L.size * .12; c.strokeStyle = L.stroke; c.strokeText(L.txt, 0, 0); }
|
||||
if (L.ink !== false) { c.fillStyle = PAL.ink; c.fillText(L.txt, L.size * .045, L.size * .055); }
|
||||
c.fillStyle = L.color; c.fillText(L.txt, 0, 0);
|
||||
c.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// p5.brush defers washes and strokes into a mask layer; a (tiny, off-screen) watercolor fill forces it to composite
|
||||
// now, so everything painted before this call really lands under whatever p5 draws next (letters, glow).
|
||||
function flushBrush() {
|
||||
push(); resetMatrix(); translate(-W / 2, -H / 2);
|
||||
brush.noStroke(); brush.noHatch(); brush.noWash(); brush.fill('#000000', 1); brush.fillBleed(0); brush.fillTexture(0, 0);
|
||||
brush.polygon([[-50, -50], [-40, -50], [-40, -40]]); brush.noFill(); pop();
|
||||
}
|
||||
// Paint the queued lettering into the scene itself, so later layers (wipes) cover it. drawWorld calls this after each
|
||||
// frame; call it yourself before a wipe or iris if the shot has lettering, or the letters will sit on top of it.
|
||||
function flushLetters() {
|
||||
if (!LETTERS.length) return;
|
||||
letG.clear(); drawLetters(letG.drawingContext); LETTERS = [];
|
||||
flushBrush();
|
||||
// Letters are already in screen space, so composite them with the base transform even inside camBegin().
|
||||
push(); resetMatrix(); translate(-W / 2, -H / 2); image(letG, 0, 0); pop();
|
||||
}
|
||||
|
||||
// ---------- paper ----------
|
||||
function lcg(seed) { let s = seed; return () => (s = (s * 16807) % 2147483647) / 2147483647; }
|
||||
function makePaper() {
|
||||
const g = createGraphics(W, H); g.pixelDensity(1); const c = g.drawingContext, rnd = lcg(11);
|
||||
c.fillStyle = PAL.paper; c.fillRect(0, 0, W, H);
|
||||
for (let i = 0; i < 70; i++) { const x = rnd() * W, y = rnd() * H, r = 120 + rnd() * 380, gr = c.createRadialGradient(x, y, 0, x, y, r), a = .045 * rnd(); gr.addColorStop(0, `rgba(160,125,80,${a})`); gr.addColorStop(1, 'rgba(160,125,80,0)'); c.fillStyle = gr; c.fillRect(x - r, y - r, 2 * r, 2 * r); }
|
||||
c.lineWidth = 1;
|
||||
for (let i = 0; i < 1400; i++) { const x = rnd() * W, y = rnd() * H, l = 6 + rnd() * 26, a = rnd() * TAU; c.strokeStyle = `rgba(110,88,60,${.035 + rnd() * .06})`; c.beginPath(); c.moveTo(x, y); c.quadraticCurveTo(x + Math.cos(a + .6) * l * .5, y + Math.sin(a + .6) * l * .5, x + Math.cos(a) * l, y + Math.sin(a) * l); c.stroke(); }
|
||||
return g;
|
||||
}
|
||||
// Static grain + vignette, multiplied over the painted frame so pigment sits "in" the paper.
|
||||
function makeGrain() {
|
||||
const cv = document.createElement('canvas'); cv.width = W; cv.height = H; const c = cv.getContext('2d'), rnd = lcg(5);
|
||||
const id = c.createImageData(W, H), d = id.data;
|
||||
for (let i = 0; i < d.length; i += 4) { const v = 255 - (rnd() < .55 ? rnd() * rnd() * 34 : 0); d[i] = v; d[i + 1] = v - 1; d[i + 2] = v - 3; d[i + 3] = 255; }
|
||||
c.putImageData(id, 0, 0);
|
||||
const g = c.createRadialGradient(W / 2, H / 2, H * .45, W / 2, H / 2, H * 1.05); g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(1, 'rgba(120,95,70,.35)');
|
||||
c.fillStyle = g; c.fillRect(0, 0, W, H);
|
||||
return cv;
|
||||
}
|
||||
|
||||
// ---------- custom brushes ----------
|
||||
function defineBrushes() {
|
||||
brush.add('ink', { type: 'default', weight: 5, scatter: .25, sharpness: .8, grain: 40, opacity: 235, spacing: .2, pressure: [1.15, .75], rotate: 'natural', noise: .15 });
|
||||
brush.add('inkfine', { type: 'default', weight: 2.6, scatter: .15, sharpness: .85, grain: 40, opacity: 230, spacing: .2, pressure: [1.1, .8], rotate: 'natural', noise: .1 });
|
||||
brush.add('dry', { type: 'default', weight: 14, scatter: 3, sharpness: .3, grain: 6, opacity: 90, spacing: .6, pressure: [1, .6], rotate: 'natural', noise: .4 });
|
||||
}
|
||||
|
||||
// ---------- frame ----------
|
||||
async function setup() {
|
||||
createCanvas(W, H, WEBGL); pixelDensity(1); noLoop();
|
||||
brush.scaleBrushes(5); defineBrushes();
|
||||
paperG = makePaper(); grainC = makeGrain(); glowTex = makeGlowTex(); letG = createGraphics(W, H); letG.pixelDensity(1);
|
||||
outC = document.getElementById('out'); outX = outC.getContext('2d');
|
||||
await document.fonts.load('100px "Permanent Marker"');
|
||||
window.ready = true;
|
||||
if (!location.search.includes('render')) devUI();
|
||||
}
|
||||
function draw() {
|
||||
if (!window.ready) return;
|
||||
LETTERS = []; CAM = LAST_CAM = null;
|
||||
push(); translate(-W / 2, -H / 2);
|
||||
BOILN = Math.floor(T * BOIL); CLAWD_N = 0; boilSeed('frame'); noiseSeed(77);
|
||||
image(paperG, 0, 0);
|
||||
drawWorld(T);
|
||||
pop();
|
||||
}
|
||||
function composite(t) {
|
||||
const c = outX;
|
||||
c.globalCompositeOperation = 'source-over'; c.globalAlpha = 1;
|
||||
c.drawImage(drawingContext.canvas, 0, 0, W, H);
|
||||
drawLetters(c);
|
||||
c.globalCompositeOperation = 'multiply'; c.drawImage(grainC, 0, 0);
|
||||
c.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
window.renderAt = async (t, type = 'image/png', q = .92) => { T = t; await redraw(); composite(t); return outC.toDataURL(type, q); };
|
||||
// Contact sheet of several times, for visual checks: returns { url, ms[] }. crop = [x, y, w, h] fills each cell with just
|
||||
// that region of the frame, at full resolution (for checking faces, hands and contacts up close). at = [x, y, w, h]
|
||||
// instead crops w × h around the WORLD point (x, y), wherever each frame's camera put it (a foot, a splash, a prop on
|
||||
// a moving shot); x and y may be expressions evaluated in the page.
|
||||
window.renderSheet = async (times, cols = 3, w = 640, crop = null, at = null) => {
|
||||
if (at) at = at.map((v) => typeof v === 'string' ? (0, eval)(v) : v);
|
||||
const [, , cw, ch] = at || crop || [0, 0, W, H], h = Math.round(w * ch / cw), rows = Math.ceil(times.length / cols), sc = document.createElement('canvas');
|
||||
sc.width = cols * w; sc.height = rows * h; const c = sc.getContext('2d'), ms = [];
|
||||
for (let i = 0; i < times.length; i++) {
|
||||
const t0 = performance.now(); T = times[i]; await redraw(); composite(times[i]); ms.push(Math.round(performance.now() - t0));
|
||||
const x = (i % cols) * w, y = Math.floor(i / cols) * h;
|
||||
const [cx, cy] = at ? toScreen(at[0], at[1], LAST_CAM).map((v, j) => v - (j ? ch : cw) / 2) : crop || [0, 0];
|
||||
c.drawImage(outC, cx, cy, cw, ch, x, y, w, h); c.fillStyle = 'rgba(0,0,0,.65)'; c.fillRect(x, y, 84, 24); c.fillStyle = '#fff'; c.font = '15px sans-serif'; c.fillText(times[i].toFixed(2) + 's', x + 6, y + 17);
|
||||
}
|
||||
return { url: sc.toDataURL('image/jpeg', .9), ms };
|
||||
};
|
||||
window.gpuInfo = () => { const gl = drawingContext, e = gl.getExtension('WEBGL_debug_renderer_info'); return e ? gl.getParameter(e.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER); };
|
||||
|
||||
function devUI() {
|
||||
const s = document.getElementById('scrub'), lab = document.getElementById('tt'); s.max = window.LOOP ? window.LOOP.len : DUR;
|
||||
let busy = false, want = null;
|
||||
const go = async () => { if (busy) return; busy = true; while (want != null) { const t = want; want = null; const t0 = performance.now(); await window.renderAt(t); lab.textContent = `${t.toFixed(2)}s · ${Math.round(performance.now() - t0)} ms/frame`; } busy = false; };
|
||||
s.addEventListener('input', () => { want = +s.value; go(); });
|
||||
want = +(new URLSearchParams(location.search).get('t') || 0); s.value = want; go();
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
// demo.js: "The fallen star", an 11-second example that exercises the kit. It is one idea, not a template: don't
|
||||
// copy its story, staging or palette into your own video (see ANIMATION_GUIDE.md).
|
||||
// Shot A (0–4.4 s): Clawd dozes on a hill at night. A star falls behind the next hill. Clawd wakes, has an idea,
|
||||
// turns (drawn key views) and trots off after it. Brush wipe.
|
||||
// Shot B (4.4–11 s): Clawd finds the star dim and sad in the grass, lifts it, and throws it back into the sky, where
|
||||
// it lights up again and twinkles goodbye. Iris out.
|
||||
(() => {
|
||||
const NIGHT = [PAL.indigo, PAL.violet]; // wipe colours, the same on both sides of the cut
|
||||
|
||||
// ---------- set pieces ----------
|
||||
// ripple (optional): { x, y, t0 }, a wave of bright twinkles spreading out from (x, y) at time t0.
|
||||
function sky(t, horizon = 1.2, ripple = null) {
|
||||
boilSeed('sky');
|
||||
paint(rectPts(-600, -400, W + 1200, H + 800), { wash: PAL.night, ink: null }); // oversized: the camera pans
|
||||
paint(ellPts(W / 2, H * .95, W * .75, H * .45 * horizon, 30, 10), { fill: PAL.violet, fillOp: 110, bleed: .3, tex: .6, ink: null });
|
||||
paint(ellPts(W * .3, H * .15, W * .35, H * .25, 24, 10), { fill: PAL.indigo, fillOp: 90, bleed: .3, tex: .5, ink: null });
|
||||
for (let i = 0; i < 42; i++) { // twinkling stars, each on its own clock and its own boil seed
|
||||
boilSeed('star' + i);
|
||||
const x = hash(i) * (W + 200) - 100, y = hash(i + 100) * H * .6 - 40;
|
||||
let tw = .55 + .45 * Math.sin(t * (2 + 2 * hash(i + 300)) + i);
|
||||
if (ripple) { const a = t - ripple.t0 - Math.hypot(x - ripple.x, y - ripple.y) / 1100; if (a > 0) tw += 1.3 * Math.exp(-a * 5) * Math.min(1, a * 25); }
|
||||
paint(starPts(x, y, (3 + 5 * hash(i + 200)) * tw, .35, 4), { wash: PAL.cream, washOp: Math.min(255, 150 + 100 * tw), ink: null });
|
||||
}
|
||||
boilSeed('moon');
|
||||
paint(ellPts(260, 170, 70, 70, 24, 1.5), { wash: PAL.cream, fill: PAL.ochre, fillOp: 40, ink: PAL.ink, sw: .8 });
|
||||
paint(ellPts(245, 150, 16, 12, 10), { fill: mixCol(PAL.cream, PAL.ochre, .5), fillOp: 120, ink: null });
|
||||
}
|
||||
function hill(cx, cy, rx, ry, col, t, tufts = 0) {
|
||||
boilSeed('hill' + cx); // keyed by its position, which never changes
|
||||
paint(ellPts(cx, cy, rx, ry, 40, 2), { wash: col, fill: mixCol(col, PAL.night, .35), fillOp: 90, bleed: .08, tex: .6, ink: PAL.ink, sw: 1 });
|
||||
for (let i = 0; i < tufts; i++) { // grass tufts along the top, swaying
|
||||
const a = -Math.PI / 2 + (hash(i + cx) - .5) * 1.6, x = cx + Math.cos(a) * rx, y = cy + Math.sin(a) * ry + 4, sw = wob(t, .5, hash(i) * 3) * 5;
|
||||
for (const k of [-1, 0, 1]) inkLine([[x + k * 7, y], [x + k * 11 + sw, y - 22 - 8 * hash(i + k)]], .7, mixCol(col, PAL.cream, .35), 'inkfine', .4);
|
||||
}
|
||||
}
|
||||
const hillY = (x, cx, cy, rx, ry) => cy - ry * Math.sqrt(Math.max(0, 1 - ((x - cx) / rx) ** 2));
|
||||
|
||||
// The star: a five-point pal with a face. glow 0..1 (dim → bright), sad true/false.
|
||||
function starling(x, y, r, o = {}) {
|
||||
const g = clamp(o.glow ?? .6), tw = 1 + .06 * Math.sin(T * 9);
|
||||
glow(x, y, r * (1.6 + 1.4 * g) * tw, '#FFD96A', .25 + .75 * g);
|
||||
push(); translate(x, y); rotate(o.rot || 0);
|
||||
paint(starPts(0, 0, r, .52, 5), { wash: mixCol('#B9A98A', '#FFE27A', g), fill: PAL.cream, fillOp: 30 + 80 * g, ink: PAL.ink, sw: clamp(r / 40, .5, 1.2) });
|
||||
const e = r * .16;
|
||||
if (o.sad) {
|
||||
for (const s of [-1, 1]) inkLine([[s * e * 1.9 - e * .7, -e * .3 + s * e * .25], [s * e * 1.9 + e * .7, -e * .3 - s * e * .25]], clamp(r / 50, .4, 1), PAL.ink, 'ink', 0);
|
||||
inkLine([[-e, e * 1.6], [0, e * 1.1], [e, e * 1.6]], clamp(r / 60, .35, .9), PAL.ink, 'ink', .6);
|
||||
} else {
|
||||
for (const s of [-1, 1]) paint(ellPts(s * e * 1.9, -e * .3, e * .45, e * .6, 10), { wash: PAL.ink, ink: null });
|
||||
inkLine([[-e, e * .9], [0, e * 1.6], [e, e * .9]], clamp(r / 60, .35, .9), PAL.ink, 'ink', .6);
|
||||
if (r > 20) for (const s of [-1, 1]) paint(ellPts(s * e * 3.1, e * 1.1, e * .6, e * .3, 10), { fill: PAL.rose, fillOp: 150, ink: null });
|
||||
}
|
||||
pop();
|
||||
}
|
||||
const sparkle = (x, y, r, k) => { if (k > 0 && k < 1) paint(starPts(x, y, r * backOut(k) * (1 - k * .6), .25, 4, k * 2), { wash: PAL.cream, washOp: 255 * (1 - k * k), ink: null }); };
|
||||
|
||||
// ---------- shot A: the fall ----------
|
||||
const FRONT = [700, 1010, 900, 220]; // front hill ellipse (cx, cy, rx, ry)
|
||||
const tFall0 = 1.05, tLand = 1.9;
|
||||
const fallAt = t => { const k = easeIn(seg(t, tFall0, tLand)); return [lerp(1680, 1545, k), lerp(40, 700, k)]; };
|
||||
|
||||
function shotFall(t, lt, dur) {
|
||||
const shake = t > tLand ? shakeXY(t, 7 * Math.exp(-(t - tLand) * 7)) : [0, 0];
|
||||
camBegin(kf(t, [[0, 900], [2.9, 900], [4.4, 1230]]) + shake[0], kf(t, [[0, 560], [2, 540]]) + shake[1], kf(t, [[0, 1], [1.85, 1.07], [2.05, 1.03], [4.4, 1.01]]));
|
||||
sky(t);
|
||||
// the falling star, its trail, and the glow where it lands
|
||||
boilSeed('fall');
|
||||
if (t > tFall0 && t < tLand + .05) {
|
||||
const P = []; for (let k = 6; k >= 0; k--) P.push(fallAt(Math.max(tFall0, t - k * .045)));
|
||||
if (Math.hypot(P[6][0] - P[0][0], P[6][1] - P[0][1]) > 8) paint(ribbon(P, 2, 26), { wash: PAL.cream, fill: PAL.ochre, fillOp: 90, ink: null });
|
||||
const [sx, sy] = fallAt(t); starling(sx, sy, 22, { glow: 1, rot: t * 9 });
|
||||
}
|
||||
if (t > tLand) {
|
||||
const a = t - tLand, k = Math.exp(-a * 1.2);
|
||||
glow(1545, 690, 60 + 240 * (1 - Math.exp(-a * 5)), '#FFD96A', .3 + .9 * k);
|
||||
for (let i = 0; i < 7; i++) { const q = seg(a, i * .03, .5 + i * .03), ang = -Math.PI / 2 + (i - 3) * .35; sparkle(1545 + Math.cos(ang) * 190 * q, 660 + Math.sin(ang) * 170 * q, 18, q); }
|
||||
}
|
||||
hill(1560, 900, 820, 260, mixCol(PAL.teal, PAL.night, .45), t, 10);
|
||||
hill(...FRONT, mixCol(PAL.sap, PAL.night, .35), t, 16);
|
||||
|
||||
// Clawd: dozing → startled → the idea → a turn through the key views → a trot down the hill after it
|
||||
const u = 17, x0 = 760, walk = stroll(t, 3.05, 4.7, x0, 1520, u);
|
||||
const x = t < 3.05 ? x0 : walk.x, gy = hillY(x, ...FRONT) + 4;
|
||||
const mood = emotions(t, [[0, 'sleepy'], [tLand + .05, 'surprised', { lookX: .8 }], [2.55, 'idea'], [3.1, 'excited']]);
|
||||
const facing = t < 2.9 ? {} : t < 3.05 ? turn(t, 2.9, 3.05, 0, .25) : { view: 'side', walk: walk.walk, dy: (mood.dy || 0) * .4 + walk.dy };
|
||||
const slope = (hillY(x + 5, ...FRONT) - hillY(x - 5, ...FRONT)) / 10;
|
||||
clawd(x, gy, u, { ...mood, ...facing, rot: (mood.rot || 0) + Math.atan(slope) * .8 });
|
||||
const eye = toScreen(x, gy - 4 * u); // screen position of Clawd, for the iris
|
||||
camEnd();
|
||||
if (lt < .5) iris(...eye, lerp(0, 1500, easeIn(lt / .5))); // open on Clawd
|
||||
boilSeed('transition');
|
||||
if (lt > dur - .3) brushWipe((lt - (dur - .3)) / .6, NIGHT);
|
||||
}
|
||||
|
||||
// ---------- shot B: the return ----------
|
||||
// The ending is timed as a sequence of reads, one at a time, each held long enough to land:
|
||||
// the throw (fast) → the flight (0.8 s: the eye follows the star up, the camera eases back to give it sky) →
|
||||
// ARRIVAL, the payoff (the star flares and a twinkle ripples across the sky; Clawd only watches, held ~0.45 s) →
|
||||
// Clawd's REACTION (love, 0.45 s after the arrival: cause, then effect; held ~0.6 s) →
|
||||
// goodbye (Clawd waves; 0.45 s later the star twinkles back) → the iris closes to Clawd, holds, and shuts.
|
||||
function shotReturn(t, lt, dur) {
|
||||
const G = 880, u = 24;
|
||||
const tStop = 1.0, tLift = 1.9, tHold = 2.25, tWind = 2.55, tThrow = 2.7, tHome = 3.5, tLove = 3.95, tWave = 4.55, tReply = 5.0, tIris = 5.4;
|
||||
camBegin(960 + 18 * Math.sin(lt * .8), kf(lt, [[0, 530], [tThrow, 515], [tHome + .1, 470]]), kf(lt, [[0, 1], [tThrow, 1.04], [tHome + .1, 1], [dur, 1.06]]));
|
||||
sky(t, 1.4, { x: 1440, y: 150, t0: t - lt + tHome }); // the sky twinkles hello when the star gets home
|
||||
hill(1300, 1180, 1300, 330, mixCol(PAL.teal, PAL.night, .45), t, 0);
|
||||
boilSeed('ground');
|
||||
paint(rectPts(-200, G - 20, W + 400, 400, 3), { wash: mixCol(PAL.sap, PAL.night, .3), fill: mixCol(PAL.sap, PAL.night, .55), fillOp: 100, bleed: .05, tex: .6, ink: null });
|
||||
inkLine([[-200, G - 18], [W / 2, G - 24], [W + 200, G - 16]], 1, PAL.ink, 'ink', .5);
|
||||
|
||||
const walk = stroll(lt, 0, tStop, 300, 950, u), x = walk.x;
|
||||
const mood = emotions(lt, [[0, 'excited'], [1.05, 'surprised', { lookX: .8, lookY: .6 }], [1.45, 'hopeful', { lookX: .6, lookY: .5 }],
|
||||
[tHold, 'starstruck'], [tThrow + .05, 'hopeful', { lookX: .6, lookY: -.9 }], [tLove, 'love']]);
|
||||
let pose;
|
||||
if (lt < tStop) pose = { view: 'side', walk: walk.walk, dy: walk.dy };
|
||||
else if (lt < tLift - .1) pose = turn(lt, tStop, tStop + .12, .25, .125);
|
||||
else if (lt < tLift) pose = { ...turn(lt, tLift - .1, tLift, .125, 0), sq: .18 * ease(seg(lt, tLift - .1, tLift)), aL: -.2, aR: -.2 }; // crouch to pick it up
|
||||
else if (lt < tThrow) {
|
||||
const up = backOut(seg(lt, tLift, tHold)), wind = ease(seg(lt, tWind, tThrow));
|
||||
pose = { aL: lerp(-.2, 1.45, up) - .5 * wind, aR: lerp(-.2, 1.45, up) - .5 * wind, sq: .2 * wind };
|
||||
} else { // follow-through: arms fly up with the throw, then settle while Clawd watches
|
||||
const a = lt - tThrow, arms = 1.5 - 1.0 * ease(seg(a, .3, .8));
|
||||
pose = { aL: arms, aR: arms, sq: -.22 * Math.exp(-a * 7) * Math.cos(a * 18), dy: -1.2 * Math.exp(-a * 6) * Math.max(0, Math.cos(a * 9)) };
|
||||
}
|
||||
const cl = { ...mood, ...pose, sq: (mood.sq || 0) + (pose.sq || 0), dy: (mood.dy || 0) * (lt < tStop ? .3 : 1) + (pose.dy || 0) };
|
||||
if (lt >= tLift) { // arms and height belong to the lift and throw; the mood takes them back once the star is home
|
||||
const back = seg(lt, tHome, tLove);
|
||||
cl.dy = (pose.dy || 0) + (mood.dy || 0) * seg(lt, tThrow + .3, tHome);
|
||||
cl.aL = lerp(pose.aL, mood.aL ?? .2, back); cl.aR = lerp(pose.aR, mood.aR ?? .2, back);
|
||||
}
|
||||
if (lt > tWave) cl.aL = lerp(cl.aL, 1.2 + .5 * Math.sin((lt - tWave) * 13), ease(seg(lt, tWave, tWave + .2))); // waving goodbye, with the left arm: the hearts sit by the right one
|
||||
clawd(x, G, u, cl);
|
||||
|
||||
// the star: sad in the grass → lifted overhead, brightening → thrown home along an arc → home, twinkling back
|
||||
const head = [x, G + (cl.dy || 0) * u - 8 * u * (1 - (cl.sq || 0)) - 46];
|
||||
const ground = [1080, G - 30], home = [1440, 150];
|
||||
if (lt < tLift) starling(...ground, 38, { glow: .15 + .5 * seg(lt, 1.45, 1.9), sad: lt < 1.6, rot: -.15 + .04 * Math.sin(lt * 3) });
|
||||
else if (lt < tThrow) {
|
||||
const k = easeOut(seg(lt, tLift, tHold)), p = arcPt(ground, head, 80, k);
|
||||
starling(p[0], p[1] + 14 * ease(seg(lt, tWind, tThrow)), 38, { glow: .65 + .35 * seg(lt, tLift, tHold + .2), rot: .2 * Math.sin(lt * 7) });
|
||||
} else if (lt < tHome) {
|
||||
const k = easeOut(seg(lt, tThrow, tHome)), p = arcPt(head, home, 260, k);
|
||||
for (let i = 1; i < 9; i++) { const kk = Math.max(0, k - i * .045), q = arcPt(head, home, 260, kk); sparkle(q[0] + jit(4), q[1], 16 - i, .3 + i * .08); }
|
||||
starling(p[0], p[1], lerp(38, 22, k), { glow: 1, rot: lt * 10 * (1 - k) });
|
||||
} else {
|
||||
const a = lt - tHome, flare = Math.exp(-a * 4) + (lt > tReply ? Math.exp(-(lt - tReply) * 5) : 0);
|
||||
glow(...home, 90 + 120 * flare, '#FFE9A8', .6 * clamp(flare));
|
||||
sparkle(...home, 120, seg(a, 0, .55));
|
||||
sparkle(...home, 80, seg(lt, tReply, tReply + .45));
|
||||
starling(...home, 22 * (1 + .3 * clamp(flare)), { glow: .85 + .15 * Math.sin(a * 6), rot: .1 * Math.sin(a * 3) });
|
||||
}
|
||||
const eye = toScreen(x, G - 4 * u);
|
||||
camEnd();
|
||||
boilSeed('transition');
|
||||
if (lt < .3) brushWipe(.5 + lt / .6, NIGHT);
|
||||
if (lt > tIris) { // iris to a small circle on Clawd, hold a moment, then shut
|
||||
const r = lt < tIris + .5 ? lerp(1500, 250, ease(seg(lt, tIris, tIris + .5))) : lt < dur - .3 ? lerp(250, 232, seg(lt, tIris + .5, dur - .3)) : lerp(232, 0, easeIn(seg(lt, dur - .3, dur - .05)));
|
||||
iris(...eye, r);
|
||||
}
|
||||
}
|
||||
|
||||
shots([[0, shotFall], [4.4, shotReturn]]);
|
||||
})();
|
||||
@@ -1,43 +0,0 @@
|
||||
// sheets.js: Clawd's model sheets, as standalone loops. They're reference for you (and the model), not part of a video,
|
||||
// so they're the one place labels are fine. Scrub them at studio.html?loop=views or ?loop=emotions, or render:
|
||||
// node render.mjs --loop=emotions --sheet=1 --cols=1 --w=1920 --out=docs/emotions.jpg
|
||||
(() => {
|
||||
const label = (txt, x, y, size = 20) => letter(txt, x, y, size, PAL.ink, { ink: false, alpha: .8 });
|
||||
const floor = (y, x0 = 0, x1 = W) => inkLine([[x0 + 40, y + 6], [W / 2, y + 4], [x1 - 40, y + 7]], .6, mixCol(PAL.paper, PAL.ink, .35), 'inkfine', .5);
|
||||
|
||||
// Every emotion in EMO, alive. Order follows EMO.
|
||||
LOOPS.emotions = t => {
|
||||
const names = Object.keys(EMO), cols = 8, cw = W / cols, ch = H / 4;
|
||||
names.forEach((name, i) => {
|
||||
const cx = cw * (i % cols) + cw / 2, gy = ch * Math.floor(i / cols) + ch - 58;
|
||||
clawd(cx, gy, 11, feel(name, t, { seed: i }));
|
||||
label(name, cx, gy + 32);
|
||||
});
|
||||
for (let r = 0; r < 4; r++) floor(ch * r + ch - 58);
|
||||
};
|
||||
LOOPS.emotions.len = 4;
|
||||
|
||||
// Views, motion helpers, hats.
|
||||
LOOPS.views = t => {
|
||||
// row 1: the five key views (flip any of them to face left)
|
||||
const views = ['front', 'q', 'side', 'qback', 'back'];
|
||||
views.forEach((v, i) => { const x = 240 + i * 360; clawd(x, 420, 19, { ...feel('neutral', t, { seed: i }), view: v }); label(v, x, 462, 24); });
|
||||
floor(420);
|
||||
// row 2: motion
|
||||
const y2 = 730, u2 = 13, row = [
|
||||
['walk (side)', x => clawd(x, y2, u2, { ...move('walk', t), view: 'side' })],
|
||||
['turn / spinView', x => clawd(x, y2, u2, { ...feel('happy', t), ...spinView(Math.floor(t * 2) / 8) })],
|
||||
['smear', x => clawd(x, y2, u2, { ...feel('excited', t), view: 'side', smear: .8, smearDir: 1, dx: Math.sin(t * TAU / 2) * .3 })],
|
||||
['jump', x => { const k = t % 1.5; clawd(x, y2, u2, { ...feel('happy', t), ...jump(k, .35, .95, 3) }); }],
|
||||
['take', x => clawd(x, y2, u2, { ...feel('surprised', t), ...take(t % 2, .5, 1.2), emoteAge: (t % 2) - .5, emoteK: seg(t % 2, .5, .7) })],
|
||||
['lid', x => clawd(x, y2, u2, { ...feel('laugh', t), mouth: null, lid: .35 + .3 * Math.abs(Math.sin(t * TAU * 1.5)) })],
|
||||
];
|
||||
row.forEach(([name, f], i) => { const x = 180 + i * 312; f(x); label(name, x, y2 + 38); });
|
||||
floor(y2);
|
||||
// row 3: hats
|
||||
const hats = ['party', 'hard', 'crown', 'halo', 'wizard', 'top', 'fedora', 'band', 'beanie', 'bow', 'flower', 'headphones', 'cat', 'masq', 'bowtie'];
|
||||
hats.forEach((h, i) => { const x = 82 + i * 125.5; clawd(x, 985, 8, { ...feel('neutral', t, { seed: i }), hat: h }); label(h, x, 1015, 17); });
|
||||
floor(985);
|
||||
};
|
||||
LOOPS.views.len = 4;
|
||||
})();
|
||||
@@ -1,54 +0,0 @@
|
||||
// timeline.js: the shot list, standalone loops, and a brush-wipe transition.
|
||||
//
|
||||
// shots([[t0, fn], [t1, fn], ...]) registers shots in time order. Each fn(t, lt, dur) is called with t = video time,
|
||||
// lt = time since the shot started, dur = the shot's length. It paints the WHOLE frame, background included, and must be
|
||||
// a pure function of t: frames render in parallel and out of order, so nothing may carry over from one frame to the next.
|
||||
|
||||
const SHOTS = [];
|
||||
function shots(list) { SHOTS.push(...list); SHOTS.sort((a, b) => a[0] - b[0]); }
|
||||
|
||||
// Standalone loops (model sheets, GIFs, tests), outside the main timeline: window.LOOP = LOOPS[name] swaps the whole
|
||||
// frame for that function, called with loop time. Give each a length: LOOPS.x = t => { ... }; LOOPS.x.len = 4;
|
||||
const LOOPS = {};
|
||||
|
||||
function drawWorld(t) {
|
||||
if (window.LOOP) window.LOOP(t);
|
||||
else if (!SHOTS.length) placeholder(t);
|
||||
else {
|
||||
let i = 0; while (i + 1 < SHOTS.length && t >= SHOTS[i + 1][0]) i++;
|
||||
const t0 = SHOTS[i][0], end = i + 1 < SHOTS.length ? SHOTS[i + 1][0] : DUR;
|
||||
SHOTS[i][1](t, t - t0, end - t0);
|
||||
CAM = null;
|
||||
}
|
||||
flushLetters();
|
||||
}
|
||||
|
||||
function placeholder(t) {
|
||||
paint(ellPts(960, 520, 520, 300, 30, 20), { fill: PAL.sky, fillOp: 90, bleed: .3, ink: null });
|
||||
clawd(960, 820, 20, feel('happy', t));
|
||||
}
|
||||
|
||||
// ---------- brush wipe ----------
|
||||
// A transition: fat paint strokes sweep across to cover the frame (p 0 → .5), then drag off (p .5 → 1).
|
||||
// Cut to the next shot at p = .5, under full cover. Call it last in both shots, in screen space (outside a camera):
|
||||
// end of shot A: if (lt > dur - .3) brushWipe((lt - (dur - .3)) / .6);
|
||||
// start of shot B: if (lt < .3) brushWipe(.5 + lt / .6);
|
||||
function brushWipe(p, cols = [PAL.clayDk, PAL.clay]) {
|
||||
if (p <= 0 || p >= 1) return;
|
||||
const [c1, c2] = cols, n = 5, bh = (H + 420) / n + 40;
|
||||
push(); translate(W / 2, H / 2); rotate(-.1); translate(-W / 2, -H / 2);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const y0 = -230 + i * (H + 420) / n, d = [0, .14, .06, .18, .1][i];
|
||||
const q = p < .5 ? easeOut(clamp((p * 2 - d) / (1 - d))) : ease(clamp(((p - .5) * 2 - d) / (1 - d)));
|
||||
const x0 = p < .5 ? -300 : lerp(-300, W + 400, q), x1 = p < .5 ? lerp(-300, W + 400, q) : W + 400;
|
||||
if (x1 - x0 < 30) continue;
|
||||
const pts = [], rag = k => 40 + 50 * hash(i * 31 + k) + jit(12);
|
||||
for (let k = 0; k <= 8; k++) pts.push([lerp(x0, x1, k / 8), y0 + Math.sin(k * .9 + i) * 14 + jit(5)]);
|
||||
for (let k = 1; k < 9; k++) pts.push([x1 + rag(k) - 40, y0 + bh * k / 9]);
|
||||
for (let k = 8; k >= 0; k--) pts.push([lerp(x0, x1, k / 8), y0 + bh + Math.sin(k * .8 + i * 2) * 14 + jit(5)]);
|
||||
if (p >= .5) for (let k = 8; k > 0; k--) pts.push([x0 - rag(k + 20) + 40, y0 + bh * k / 9]);
|
||||
paint(pts, { wash: i % 2 ? c1 : c2, washOp: 255, fill: i % 2 ? c2 : c1, fillOp: 70, bleed: .05, tex: .8, border: .6, ink: null,
|
||||
hatch: { d: 44, a: 0, o: { rand: .6, gradient: .5 }, b: 'charcoal', c: i % 2 ? c2 : PAL.cream, w: .8 } });
|
||||
}
|
||||
pop();
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Clawd studio</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Permanent+Marker&display=block">
|
||||
<style>
|
||||
html, body { margin: 0; background: #1b1820; color: #eee; font: 13px system-ui, sans-serif; }
|
||||
main { padding: 12px; display: grid; gap: 8px; }
|
||||
#out { width: min(100%, 1280px); aspect-ratio: 16 / 9; height: auto; background: #F3EBDC; }
|
||||
.p5Canvas { display: none !important; }
|
||||
.bar { display: flex; gap: 10px; align-items: center; max-width: 1280px; }
|
||||
#scrub { flex: 1; }
|
||||
#tt { font-variant-numeric: tabular-nums; min-width: 190px; }
|
||||
</style>
|
||||
<script src="node_modules/p5/lib/p5.min.js"></script>
|
||||
<script src="node_modules/p5.brush/dist/p5.brush.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<canvas id="out" width="1920" height="1080"></canvas>
|
||||
<div class="bar"><input id="scrub" type="range" min="0" max="11" step="0.0417" value="0"><span id="tt">loading…</span></div>
|
||||
</main>
|
||||
<!-- engine -->
|
||||
<script src="src/config.js"></script>
|
||||
<script src="src/core.js"></script>
|
||||
<script src="src/clawd.js"></script>
|
||||
<script src="src/timeline.js"></script>
|
||||
<!-- reference sheets (standalone loops) -->
|
||||
<script src="src/sheets.js"></script>
|
||||
<!-- your video: add a script per scene file, in order -->
|
||||
<script src="src/scenes/demo.js"></script>
|
||||
<script>
|
||||
// Open studio.html?loop=emotions (or views) to scrub a standalone loop instead of the video.
|
||||
const q = new URLSearchParams(location.search).get('loop'); if (q && LOOPS[q]) window.LOOP = LOOPS[q];
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user