- Converting
+
+
+
- {done} of {BATCH}
+
+
+ {' '}
+ of {BATCH_SIZE}
- {LANES.map((duration, index) => (
-
-
Worker {index + 1}
-
-
+ {lanes.map((lane, worker) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: the four workers never reorder
+
+
+
+ {lane ? (
+
+ ) : null}
+
+
+
+
+
+ Worker {worker + 1}
+ {lane ? (
+
+ {' · '}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
))}
-
+
@@ -352,6 +545,7 @@ function WorkersDemo() {
type RowState = 'done' | 'changed' | 'cached' | 'running' | 'processed'
+/** Each step's state in each beat: as run, after the edit, then through the rerun. */
const CACHE_STEPS: { type: string; title: string; states: RowState[] }[] = [
{ type: 'rotate', title: 'Rotate', states: ['done', 'done', 'cached', 'cached', 'cached'] },
{ type: 'resize', title: 'Resize', states: ['done', 'done', 'cached', 'cached', 'cached'] },
@@ -405,39 +599,111 @@ function StateBadge({ state }: { state: RowState }) {
)
}
+/**
+ * The pointer lowers Convert's quality, which marks Convert and the step after it as changed, then
+ * presses Run: the two steps before come from the step cache at once, and only the changed ones
+ * run.
+ */
function CacheDemo() {
- const { ref, step } = useLoop(6, 1100)
- const phase = Math.min(step, 4)
+ const [phase, setPhase] = useState(0)
+ const ref = useScene(
+ (timeline, q, root) => {
+ const cursor = q('[data-cursor]')
+ timeline
+ .call(setPhase, [0])
+ .set(cursor, { x: 250, y: 230 })
+ .addLabel('poster')
+ .to({}, { duration: BEAT.read })
+ clickOn(timeline, cursor, q('[data-quality]')[0], root)
+ .call(setPhase, [1])
+ .to(q('[data-ring]'), { autoAlpha: 1, duration: BEAT.quick }, '<')
+ .to({}, { duration: BEAT.read })
+ clickOn(timeline, cursor, q('[data-run]')[0], root)
+ .to(q('[data-ring]'), { autoAlpha: 0, duration: BEAT.base })
+ .call(setPhase, [2], '<')
+ .fromTo(
+ q('[data-cached]'),
+ { autoAlpha: 1 },
+ {
+ autoAlpha: 0,
+ duration: 0.9,
+ ease: 'power1.out',
+ stagger: STAGGER,
+ immediateRender: false,
+ },
+ '<',
+ )
+ .fromTo(
+ q('[data-progress]'),
+ { scaleX: 0, autoAlpha: 1 },
+ { scaleX: 1, duration: 1.6, ease: 'power1.inOut', immediateRender: false },
+ '<',
+ )
+ .to(q('[data-progress]'), { autoAlpha: 0, duration: BEAT.quick })
+ .call(setPhase, [3], '<')
+ .to({}, { duration: 0.9 })
+ .call(setPhase, [4])
+ .to(cursor, { autoAlpha: 0, duration: BEAT.base }, `+=${BEAT.rest}`)
+ },
+ { delay: COLUMN_DELAY, repeat: -1, repeatDelay: BEAT.base },
+ )
return (
-
+
+
+ Pipeline · {BATCH_SIZE} images
+
+
+ Run
+
+
{CACHE_STEPS.map((row) => {
const state = row.states[phase]
- const isEdited = row.type === 'convert' && phase === 1
return (
-
-
-
-
- {row.title}
-
- {row.type === 'convert' ? (
-
- WebP, quality {phase >= 1 ? 60 : 80}
+
+ {row.type === 'rotate' || row.type === 'resize' ? (
+
+ ) : null}
+ {row.type === 'convert' ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+
+
+ {row.title}
- ) : null}
+ {row.type === 'convert' ? (
+
+ WebP, quality{' '}
+
+ = 1 ? '60' : '80'} />
+
+
+ ) : null}
+
+
+
+
-
)
})}
+
)
}
@@ -445,62 +711,114 @@ function CacheDemo() {
// ─── Save as a tool ─────────────────────────────────────────────────────────
const SAVED = ['Photos for email', 'Square thumbnails']
+const NEW_TOOL = 'Shop photos'
+/**
+ * The pointer presses Save, types a name, and the pipeline joins the list of saved tools, where
+ * the pointer opens it.
+ */
function SaveDemo() {
- const { ref, step } = useLoop(5, 1200)
- const pressed = step === 1
- const added = step >= 2
+ const [typed, setTyped] = useState('')
+ const ref = useScene(
+ (timeline, q, root) => {
+ const cursor = q('[data-cursor]')
+ const field = q('[data-field]')
+ const row = q('[data-new-row]')
+ timeline
+ .call(setTyped, [''])
+ .set(cursor, { x: 250, y: 210 })
+ .set(field, { height: 0, autoAlpha: 0 })
+ .set(row, { height: 0, autoAlpha: 0 })
+ .to({}, { duration: BEAT.base })
+ clickOn(timeline, cursor, q('[data-save]')[0], root).to(field, {
+ height: 'auto',
+ autoAlpha: 1,
+ duration: BEAT.base,
+ ease: EASE.enter,
+ })
+ for (let length = 1; length <= NEW_TOOL.length; length++) {
+ timeline.call(setTyped, [NEW_TOOL.slice(0, length)], `+=${length === 1 ? 0.2 : 0.07}`)
+ }
+ timeline
+ .fromTo(
+ q('[data-enter]'),
+ { scale: 1 },
+ { scale: 0.85, duration: 0.1, yoyo: true, repeat: 1 },
+ `+=${BEAT.base}`,
+ )
+ .to(field, { height: 0, autoAlpha: 0, duration: BEAT.base, ease: EASE.move })
+ .to(row, { height: 'auto', autoAlpha: 1, duration: BEAT.base, ease: EASE.enter }, '<0.15')
+ .fromTo(
+ q('[data-new-ring]'),
+ { autoAlpha: 1 },
+ { autoAlpha: 0, duration: 2, immediateRender: false },
+ )
+ .addLabel('poster', '<')
+ clickOn(timeline, cursor, q('[data-new-name]')[0], root, '<0.4')
+ .to({}, { duration: BEAT.rest })
+ .to(cursor, { autoAlpha: 0, duration: BEAT.base })
+ .to(row, { height: 0, autoAlpha: 0, duration: BEAT.base, ease: EASE.move }, '<')
+ },
+ { delay: COLUMN_DELAY * 2, repeat: -1, repeatDelay: BEAT.base },
+ )
return (
-
-
- {['files', 'resize', 'convert', 'output'].map((type, index) => (
-
- {index > 0 ? : null}
-
+
+
+
+ {['files', 'resize', 'convert', 'output'].map((type, index) => (
+
+ {index > 0 ? : null}
+
+
+ ))}
+
+ Save
- ))}
-
- Save
-
+
+
+
+ {typed}
+
+
+
+
+
+
-
-
+
+
Your tools
-
- {added ? (
-
-
- Shop photos
-
-
-
- ) : null}
- {SAVED.map((name) => (
-
- {name}
-
-
- ))}
-
+
+
+
+
+ {NEW_TOOL}
+
+
+
+
+ {SAVED.map((name) => (
+
+ {name}
+
+
+ ))}
+
)
}
diff --git a/src/features/home/tool-demos.tsx b/src/features/home/tool-demos.tsx
index 54b7765..69b3ca5 100644
--- a/src/features/home/tool-demos.tsx
+++ b/src/features/home/tool-demos.tsx
@@ -3,7 +3,7 @@ import { Aperture, CalendarClock, Camera, Check, MapPin, RotateCw } from 'lucide
import { AnimatePresence, motion, useSpring, useTransform } from 'motion/react'
import { useEffect } from 'react'
-import { useLoop } from '#/features/home/motion-kit'
+import { Rolling, useLoop } from '#/features/home/motion-kit'
import type { QuickTool } from '#/features/quick-tools/tools'
/*
@@ -24,26 +24,6 @@ function Photo({ name = 'dusk' }: { name?: 'dusk' | 'dawn' | 'desert' }) {
)
}
-/** A label that slides to its next value. */
-function Rolling({ value }: { value: string }) {
- return (
-
-
-
- {value}
-
-
-
- )
-}
-
function FileTile({
format,
size,
@@ -55,13 +35,13 @@ function FileTile({
}) {
return (
-
+
@@ -123,7 +103,7 @@ function CompressDemo() {
return (
@@ -174,7 +154,7 @@ function ResizeDemo() {
-
+
@@ -231,7 +211,7 @@ function RotateDemo() {
>
-
+
@@ -254,10 +234,10 @@ function StripDemo() {
return (
-
+
@@ -276,7 +256,7 @@ function StripDemo() {
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 24, transition: { duration: 0.3 } }}
- className="flex items-center gap-2 rounded-md bg-muted px-2 py-1.5 text-xs"
+ className="flex items-center gap-2 rounded bg-muted px-2 py-1.5 text-xs"
>
{field.label}
diff --git a/src/features/home/worker-plan.ts b/src/features/home/worker-plan.ts
new file mode 100644
index 0000000..7ea9cad
--- /dev/null
+++ b/src/features/home/worker-plan.ts
@@ -0,0 +1,16 @@
+export type PlannedJob = { index: number; worker: number; start: number; end: number }
+
+/**
+ * Hands out jobs the way the engine's worker pool does: every worker starts one at once, and each
+ * next job goes to the worker that frees up first, the lowest-numbered one on a tie. Times are in
+ * the same unit as `durations`.
+ */
+export function planWorkers(durations: number[], workers: number): PlannedJob[] {
+ const freeAt = Array.from({ length: workers }, () => 0)
+ return durations.map((duration, index) => {
+ const worker = freeAt.indexOf(Math.min(...freeAt))
+ const start = freeAt[worker]
+ freeAt[worker] = start + duration
+ return { index, worker, start, end: start + duration }
+ })
+}