From 91fa8efb5f7c76f0dc3bbbddaa0d0f1d0b7b8e7c Mon Sep 17 00:00:00 2001 From: engmung <122682380+engmung@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:23:38 +0900 Subject: [PATCH 1/2] web(patterns): 13 new presets, lab-only split, Pattern Lab stepper; drop Video Baker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add presets 0628-0719 (13) to the live editor registry. - New LivePreset.labOnly flag: patterns built on Pattern Lab features (color ramp / value field) render wrong in the simple /pattern showcase, so /pattern now uses showcasePresets (labOnly excluded) while Pattern Lab keeps the full set. Flagged: 0707, 0710, 0712, 0712-2, 0713, 0719. - Pattern Lab: preset stepper in the Code header browses the labOnly set; the editor opens on the first lab preset (with its @knobs annotation applied) instead of Origin. V demo button removed. - Remove the Video Baker tool entirely (route, pfv/ledPipeline/ videoDecoder/serialUpload libs, mp4box dep) — video mode is gone from the firmware and the tool no longer fits the project. Co-Authored-By: Claude Fable 5 --- web/ARCHITECTURE.md | 3 - web/README.md | 2 +- web/package-lock.json | 10 - web/package.json | 1 - web/src/app/pattern-lab/PatternLab.module.css | 31 +- web/src/app/pattern-lab/PatternLabClient.tsx | 115 +-- web/src/app/robots.ts | 2 +- web/src/app/video-baker/VideoBaker.module.css | 697 ------------- web/src/app/video-baker/VideoBakerClient.tsx | 956 ------------------ web/src/app/video-baker/page.tsx | 15 - web/src/components/sections/PatternPanel.tsx | 31 +- web/src/lib/ledPipeline.ts | 209 ---- web/src/lib/pfv.ts | 273 ----- web/src/lib/presets/_TEMPLATE.ts | 2 + web/src/lib/presets/index.ts | 31 +- web/src/lib/presets/pattern-0628.ts | 111 ++ web/src/lib/presets/pattern-0629-2.ts | 104 ++ web/src/lib/presets/pattern-0629.ts | 101 ++ web/src/lib/presets/pattern-0701.ts | 111 ++ web/src/lib/presets/pattern-0707.ts | 81 ++ web/src/lib/presets/pattern-0710.ts | 89 ++ web/src/lib/presets/pattern-0712-2.ts | 379 +++++++ web/src/lib/presets/pattern-0712.ts | 138 +++ web/src/lib/presets/pattern-0713.ts | 163 +++ web/src/lib/presets/pattern-0715.ts | 99 ++ web/src/lib/presets/pattern-0716.ts | 86 ++ web/src/lib/presets/pattern-0718.ts | 109 ++ web/src/lib/presets/pattern-0719.ts | 175 ++++ web/src/lib/presets/types.ts | 6 + web/src/lib/serialUpload.ts | 219 ---- web/src/lib/videoDecoder.ts | 235 ----- web/src/types/mp4box.d.ts | 54 - 32 files changed, 1868 insertions(+), 2770 deletions(-) delete mode 100644 web/src/app/video-baker/VideoBaker.module.css delete mode 100644 web/src/app/video-baker/VideoBakerClient.tsx delete mode 100644 web/src/app/video-baker/page.tsx delete mode 100644 web/src/lib/ledPipeline.ts delete mode 100644 web/src/lib/pfv.ts create mode 100644 web/src/lib/presets/pattern-0628.ts create mode 100644 web/src/lib/presets/pattern-0629-2.ts create mode 100644 web/src/lib/presets/pattern-0629.ts create mode 100644 web/src/lib/presets/pattern-0701.ts create mode 100644 web/src/lib/presets/pattern-0707.ts create mode 100644 web/src/lib/presets/pattern-0710.ts create mode 100644 web/src/lib/presets/pattern-0712-2.ts create mode 100644 web/src/lib/presets/pattern-0712.ts create mode 100644 web/src/lib/presets/pattern-0713.ts create mode 100644 web/src/lib/presets/pattern-0715.ts create mode 100644 web/src/lib/presets/pattern-0716.ts create mode 100644 web/src/lib/presets/pattern-0718.ts create mode 100644 web/src/lib/presets/pattern-0719.ts delete mode 100644 web/src/lib/serialUpload.ts delete mode 100644 web/src/lib/videoDecoder.ts delete mode 100644 web/src/types/mp4box.d.ts diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index c6e8e29..f1e789b 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -15,7 +15,6 @@ The `web/` app is the Patternflow site at [patternflow.work](https://patternflow | `/roadmap` | Roadmap rendered from GitHub issues via `/api/roadmap` | | `/api/roadmap` | Server route that pulls open issues + sub-issue progress (10 min revalidate; optional `GITHUB_TOKEN` for rate limit) | | `/pattern-lab` | **Internal, noindex.** Pattern authoring/curation workspace: Monaco editor, preset library, BYOK Gemini generation, JS→C++ conversion prompt | -| `/video-baker` | **Internal, noindex.** Bakes video clips into 128×64 PFV loops for the hardware | | `/business` · `/contact` | Static pages | | `feed.xml` · `sitemap.ts` · `robots.ts` | Feeds and SEO plumbing | @@ -47,8 +46,6 @@ This is the core of the site and mirrors the firmware: - **`src/lib/patternHarness.ts`** — runs pattern JS in the browser on a 128×64 virtual matrix with 4 virtual encoders (20 detents/turn), matching device semantics (`knobDeltas`, `btnPressed`/`btnHeld`). - **`src/lib/gemini.ts`** — bring-your-own-key Gemini generation for Pattern Lab. The key lives in `localStorage` and calls go straight from the browser to Google; no server proxy, no bundled key. - **Flasher** — esp-web-tools driven by `public/flash/manifest.json` + prebuilt binaries in `public/flash/bin/`. -- **`src/lib/serialUpload.ts`** — Web Serial upload of PFV data to a running device (`PFV::\n` + raw bytes). -- **`src/lib/pfv.ts`** — PFV1 binary LED video format (128×64, RGB565). **`src/lib/ledPipeline.ts`** — fit/crop/dither correction pipeline. **`src/lib/videoDecoder.ts`** — mp4box + WebCodecs decode for the Video Baker. ## Content pipeline diff --git a/web/README.md b/web/README.md index 4e3179b..fb81b0c 100644 --- a/web/README.md +++ b/web/README.md @@ -32,7 +32,7 @@ None are required — the site runs fully without them. ## Where things live -- `src/app/` — routes (App Router). `/pattern-lab` and `/video-baker` are internal noindex tools. +- `src/app/` — routes (App Router). `/pattern-lab` is an internal noindex tool. - `src/components/` — 3D viewer, landing sections, journal renderer. - `src/lib/presets/` — JS pattern library; **source of truth** for the firmware preset headers. - `content/` — markdown/MDX site copy and journal articles. Editing copy means editing these files. diff --git a/web/package-lock.json b/web/package-lock.json index 156676a..47128ff 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -22,7 +22,6 @@ "feed": "^5.2.1", "gray-matter": "^4.0.3", "leva": "^0.10.1", - "mp4box": "^2.3.0", "next": "16.2.4", "posthog-js": "^1.371.4", "react": "19.2.4", @@ -8323,15 +8322,6 @@ "@types/trusted-types": "^2.0.7" } }, - "node_modules/mp4box": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-2.3.0.tgz", - "integrity": "sha512-nnABYbdh4UguEYyV+uRwQBi1tbb8kXka2Fx9yKzmDKAeh8gkvRKYxoK1XDd8GQIjSfN4rvsXrW1CBo4yRQJZDA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=20.8.1" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/web/package.json b/web/package.json index 7f65de2..f11193d 100644 --- a/web/package.json +++ b/web/package.json @@ -23,7 +23,6 @@ "feed": "^5.2.1", "gray-matter": "^4.0.3", "leva": "^0.10.1", - "mp4box": "^2.3.0", "next": "16.2.4", "posthog-js": "^1.371.4", "react": "19.2.4", diff --git a/web/src/app/pattern-lab/PatternLab.module.css b/web/src/app/pattern-lab/PatternLab.module.css index 991f290..69560d7 100644 --- a/web/src/app/pattern-lab/PatternLab.module.css +++ b/web/src/app/pattern-lab/PatternLab.module.css @@ -313,24 +313,6 @@ accent-color: #171512; } -.rampDemo { - margin-left: auto; - min-height: 28px; - border: 1px solid rgba(23, 21, 18, 0.22); - background: #ffffff; - color: #171512; - padding: 0 8px; - font: inherit; - font-family: var(--font-jetbrains), monospace; - font-size: 11px; - text-transform: uppercase; - cursor: pointer; -} - -.rampDemo:hover { - border-color: #171512; -} - .rampTrack { position: relative; height: 34px; @@ -654,6 +636,19 @@ gap: 10px; } +.presetStep { + display: flex; + align-items: center; + gap: 6px; +} + +.presetStep span { + font-family: var(--font-jetbrains), monospace; + font-size: 12px; + min-width: 44px; + text-align: center; +} + .editorHeader .guideButton { background: #e8dcc2 !important; color: #171512 !important; diff --git a/web/src/app/pattern-lab/PatternLabClient.tsx b/web/src/app/pattern-lab/PatternLabClient.tsx index 3c667f9..b81055c 100644 --- a/web/src/app/pattern-lab/PatternLabClient.tsx +++ b/web/src/app/pattern-lab/PatternLabClient.tsx @@ -52,10 +52,15 @@ import { } from "@/lib/patternPatch"; import { captureEvent } from "@/lib/posthogEvents"; import SharePatternModal from "@/components/share/SharePatternModal"; -import { preset as originPreset } from "@/lib/presets/pattern-origin"; import { livePresets } from "@/lib/presets"; import styles from "./PatternLab.module.css"; +// Pattern Lab's own presets — the labOnly set (built on lab features like the +// color ramp / value field) that the /pattern showcase hides. The header +// stepper browses these; the editor opens on the first one. +const labPresets = livePresets.filter((preset) => preset.labOnly); +const initialLabPreset = labPresets[0] ?? livePresets[0]; + const DEFAULT_KNOB_LABELS = ["Knob 1", "Knob 2", "Knob 3", "Knob 4"]; const initialKnobs = [...LOGICAL_KNOB_DEFAULTS]; @@ -89,6 +94,22 @@ function parseKnobsAnnotation(code: string): KnobAnnotationEntry[] | null { return result.some(Boolean) ? result : null; } const defaultRanges: KnobRange[] = LOGICAL_KNOB_RANGES.map(([min, max]) => [min, max]); + +// The initial preset seeds useState directly (bypassing updateCode), so honor +// its @knobs annotation here; every later load goes through applyKnobsAnnotation. +const initialAnnotationRaw = initialLabPreset.code.match(KNOBS_ANNOTATION_RE)?.[0] ?? null; +const initialAnnotation = initialAnnotationRaw ? parseKnobsAnnotation(initialLabPreset.code) : null; +const presetKnobLabels = DEFAULT_KNOB_LABELS.map( + (label, index) => initialAnnotation?.[index]?.name ?? label, +); +const presetRanges: KnobRange[] = defaultRanges.map((range, index) => { + const entry = initialAnnotation?.[index]; + return entry ? [entry.min, entry.max] : range; +}); +const presetKnobs = initialKnobs.map((value, index) => { + const entry = initialAnnotation?.[index]; + return entry ? Math.max(entry.min, Math.min(entry.max, value)) : value; +}); const sweepValues = [0, 0.25, 0.5, 0.75, 1]; const minRangeSpan = 0.001; const pixelsPerDigitStep = 10; @@ -256,50 +277,6 @@ function loadStoredPatch(): PatchState { } } -// Demo pattern for the value-field workflow: pure 0..1 field via setValue, -// color comes entirely from the Color Ramp panel. -const VFIELD_DEMO_CODE = `// V-field demo — this pattern outputs only a 0..1 value field. -// Color comes from the Color Ramp panel, not from this code. -// @knobs Warp=0..2.2, Speed=0.1..10, Zoom=0.6..3, Bands=0..8 - -export function setup(params) { - params.t = 0; -} - -export function update(dt, input, params) { - const kv = input.knobValues || [1.1, 2.0, 1.5, 0]; - params.warp = kv[0]; - params.speed = kv[1]; - params.zoom = kv[2]; - params.bands = Math.round(kv[3]); - params.t += dt * params.speed * 0.3; -} - -export function draw(display, params, time) { - const w = display.width; - const h = display.height; - const t = params.t; - const zoom = params.zoom; - const cx = 0.5 + 0.22 * Math.sin(t * 0.7); - const cy = 0.5 + 0.22 * Math.cos(t * 0.9); - - for (let y = 0; y < h; y++) { - const ny = (y / h - 0.5); - for (let x = 0; x < w; x++) { - const nx = (x / h - w / h * 0.5); - const dx = x / h - cx * (w / h); - const dy = y / h - cy; - const ring = Math.sin((dx * dx + dy * dy) * 14 * zoom - t * 2.0); - const wave = Math.sin(nx * 6 * zoom + t + params.warp * Math.sin(ny * 5 * zoom - t * 0.8)); - let v = 0.5 + 0.25 * ring + 0.25 * wave; - if (params.bands > 1) { - v = Math.floor(v * params.bands) / (params.bands - 1); - } - display.setValue(x, y, v); - } - } -}`; - type GalleryItem = PatternVariant & { id: string; pinned?: boolean }; // Cap the gallery without ever dropping pinned (kept) items. @@ -483,10 +460,10 @@ function VariantPreview({ } export default function PatternLabClient() { - const [code, setCode] = useState(originPreset.code); - const [knobs, setKnobs] = useState(initialKnobs); - const [ranges, setRanges] = useState(defaultRanges); - const [knobLabels, setKnobLabels] = useState(DEFAULT_KNOB_LABELS); + const [code, setCode] = useState(initialLabPreset.code); + const [knobs, setKnobs] = useState(presetKnobs); + const [ranges, setRanges] = useState(presetRanges); + const [knobLabels, setKnobLabels] = useState(presetKnobLabels); const [running, setRunning] = useState(true); const [runtimeError, setRuntimeError] = useState(null); const [renderStats, setRenderStats] = useState({ fps: 0, ms: 0 }); @@ -522,7 +499,7 @@ export default function PatternLabClient() { const canvasRef = useRef(null); const runtimeRef = useRef(null); const knobsRef = useRef(knobs); - const previousKnobsRef = useRef(initialKnobs); + const previousKnobsRef = useRef(presetKnobs); const runningRef = useRef(running); const simTimeRef = useRef(0); const runtimeErrorRef = useRef(null); @@ -796,7 +773,7 @@ export default function PatternLabClient() { // Apply a pattern's @knobs annotation only when the annotation text itself // changes — manual label/range edits persist until different code arrives. - const lastKnobsAnnotationRef = useRef(null); + const lastKnobsAnnotationRef = useRef(initialAnnotationRaw); const applyKnobsAnnotation = (nextCode: string) => { const raw = nextCode.match(KNOBS_ANNOTATION_RE)?.[0] ?? null; @@ -821,12 +798,24 @@ export default function PatternLabClient() { }; // Single entry point for replacing the editor code so annotations apply on - // every path (typing/pasting, gallery load, demo, patch send). + // every path (typing/pasting, gallery load, preset step, patch send). const updateCode = (nextCode: string) => { applyKnobsAnnotation(nextCode); setCode(nextCode); }; + // ── Lab preset stepper ── browses the labOnly set; index follows the editor + // content, so editing away from a preset shows "–". + const activeLabIndex = labPresets.findIndex((preset) => preset.code === code); + + const stepLabPreset = (dir: number) => { + if (labPresets.length === 0) return; + const base = activeLabIndex >= 0 ? activeLabIndex : dir > 0 ? -1 : 0; + const next = (base + dir + labPresets.length) % labPresets.length; + updateCode(labPresets[next].code); + setEditorView("code"); + }; + useEffect(() => { knobsRef.current = knobs; }, [knobs]); @@ -1625,14 +1614,6 @@ ${activeCode} /> recolor -
) : ( <> +
= 0 ? labPresets[activeLabIndex].name : "Lab presets"} + > + + + {activeLabIndex >= 0 ? activeLabIndex + 1 : "–"}/{labPresets.length} + + +
diff --git a/web/src/app/robots.ts b/web/src/app/robots.ts index 30e530e..a22ad57 100644 --- a/web/src/app/robots.ts +++ b/web/src/app/robots.ts @@ -9,7 +9,7 @@ export default function robots(): MetadataRoute.Robots { // Search engines and AI crawlers are welcome on public pages. userAgent: "*", allow: "/", - disallow: ["/api/", "/pattern-lab", "/video-baker"], + disallow: ["/api/", "/pattern-lab"], }, ], sitemap: `${siteUrl}/sitemap.xml`, diff --git a/web/src/app/video-baker/VideoBaker.module.css b/web/src/app/video-baker/VideoBaker.module.css deleted file mode 100644 index 676a1f6..0000000 --- a/web/src/app/video-baker/VideoBaker.module.css +++ /dev/null @@ -1,697 +0,0 @@ -/* Video Baker — Pattern Lab visual language */ - -.shell { - min-height: 100vh; - background: #f7f3ea; - color: #171512; - padding: 22px; -} - -/* ── Header ── */ - -.header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; - padding-bottom: 18px; - border-bottom: 1px solid rgba(23, 21, 18, 0.18); -} - -.brand { - color: inherit; - font-size: 18px; - font-weight: 700; - letter-spacing: 0; - text-decoration: none; -} - -.headerMeta { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 10px; - color: rgba(23, 21, 18, 0.62); - font-family: var(--font-jetbrains), monospace; - font-size: 12px; - text-transform: uppercase; -} - -/* ── Workspace layout ── */ - -.workspace { - display: grid; - grid-template-columns: minmax(340px, 1.15fr) minmax(300px, 0.85fr); - align-items: start; - gap: 22px; - margin-top: 22px; -} - -.previewColumn, -.controlColumn { - border: 1px solid rgba(23, 21, 18, 0.18); - background: #fffdf8; - padding: 18px; -} - -/* ── Mini labels ── */ - -.miniLabel { - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - letter-spacing: 0.14em; - text-transform: uppercase; - color: rgba(23, 21, 18, 0.4); - margin-bottom: 8px; -} - -/* ── Source video section ── */ - -.videoSection { - margin-bottom: 20px; - padding-bottom: 20px; - border-bottom: 1px solid rgba(23, 21, 18, 0.12); -} - -.videoWrap { - position: relative; - border: 1px solid rgba(23, 21, 18, 0.15); - background: #000; - overflow: hidden; -} - -.sourceVideo { - display: block; - width: 100%; - max-height: 260px; - object-fit: contain; - background: #000; -} - -/* ── Trim controls ── */ - -.trimSection { - margin-top: 12px; -} - -.trimRow { - display: grid; - grid-template-columns: 32px 1fr 60px; - align-items: center; - gap: 10px; - margin-bottom: 6px; -} - -.trimLabel { - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; - color: rgba(23, 21, 18, 0.5); -} - -.trimSlider { - width: 100%; - accent-color: #171512; - height: 4px; -} - -.trimTime { - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - color: #171512; - text-align: right; -} - -.trimInfo { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-top: 8px; - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - color: rgba(23, 21, 18, 0.45); - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.trimInfo strong { - color: #171512; - font-weight: 600; -} - -/* ── Source info ── */ - -.sourceInfo { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-top: 10px; - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - color: rgba(23, 21, 18, 0.45); - text-transform: uppercase; -} - -/* ── LED preview section ── */ - -.ledSection { - margin-bottom: 4px; -} - -.matrixFrame { - position: relative; - overflow: hidden; - border: 1px solid rgba(23, 21, 18, 0.2); - background: - linear-gradient(90deg, rgba(23, 21, 18, 0.06) 1px, transparent 1px), - linear-gradient(rgba(23, 21, 18, 0.06) 1px, transparent 1px), - #030303; - background-size: 16px 16px; - aspect-ratio: 2 / 1; -} - -.matrixFrame canvas { - display: block; - width: 100%; - height: 100%; - image-rendering: pixelated; -} - -.emptyMatrix { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - color: rgba(255, 255, 255, 0.2); - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - text-transform: uppercase; - letter-spacing: 0.14em; -} - -/* ── Timeline / transport ── */ - -.transport { - display: flex; - align-items: center; - gap: 4px; - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid rgba(23, 21, 18, 0.12); -} - -.transport button { - width: 32px; - height: 32px; - border: 1px solid rgba(23, 21, 18, 0.18); - background: #ffffff; - color: rgba(23, 21, 18, 0.55); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - transition: color 0.1s, border-color 0.1s, background 0.1s; -} - -.transport button:hover { - color: #171512; - border-color: rgba(23, 21, 18, 0.4); -} - -.transport button.active { - background: #171512; - color: #f7f3ea; - border-color: #171512; -} - -.transport button svg { - display: block; -} - -/* Play/pause button — slightly larger */ -.playBtn { - width: 38px !important; - height: 38px !important; - margin: 0 2px; -} - -.timeline { - flex: 1; - height: 4px; - appearance: none; - background: rgba(23, 21, 18, 0.1); - outline: none; - cursor: pointer; - margin: 0 6px; -} - -.timeline::-webkit-slider-thumb { - appearance: none; - width: 12px; - height: 12px; - background: #171512; - border-radius: 0; - cursor: grab; -} - -.frameCounter { - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - color: rgba(23, 21, 18, 0.45); - min-width: 72px; - text-align: right; - white-space: nowrap; -} - -/* ── Drop zone ── */ - -.dropZone { - border: 2px dashed rgba(23, 21, 18, 0.18); - padding: 48px 20px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - text-align: center; - cursor: pointer; - transition: border-color 0.15s, background-color 0.15s; - min-height: 140px; -} - -.dropZone:hover, -.dropZoneActive { - border-color: #171512; - background: rgba(23, 21, 18, 0.03); -} - -.dropZone p { - margin: 0; - font-family: var(--font-jetbrains), monospace; - font-size: 12px; - color: rgba(23, 21, 18, 0.45); - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.dropZone span { - font-size: var(--pf-fs-mono); - color: rgba(23, 21, 18, 0.3); - font-family: var(--font-jetbrains), monospace; -} - -/* ── Control panel ── */ - -.sectionTitle { - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - letter-spacing: 0.14em; - text-transform: uppercase; - color: rgba(23, 21, 18, 0.4); - margin: 0 0 14px; - padding-bottom: 8px; - border-bottom: 1px solid rgba(23, 21, 18, 0.12); -} - -.sectionTitle:not(:first-child) { - margin-top: 22px; -} - -.devNote { - margin: 0 0 18px; - padding: 10px 12px; - border: 1px solid rgba(23, 21, 18, 0.16); - background: #f7f3ea; - color: rgba(23, 21, 18, 0.68); - font-size: 12px; - line-height: 1.45; -} - -.devNote strong { - display: block; - margin-bottom: 6px; - color: #171512; - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.devNote ul { - margin: 0; - padding-left: 16px; -} - -.devNote li + li { - margin-top: 4px; -} - -/* ── Setting rows ── */ - -.settingRow { - display: grid; - grid-template-columns: 80px 1fr 52px; - align-items: center; - gap: 10px; - margin-bottom: 8px; -} - -.settingRow label { - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - text-transform: uppercase; - color: rgba(23, 21, 18, 0.55); - letter-spacing: 0.06em; -} - -.settingRow input[type="range"] { - width: 100%; - accent-color: #171512; - height: 3px; -} - -.settingRow .value { - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - color: #171512; - text-align: right; -} - -/* ── Toggle / selector buttons ── */ - -.toggleGroup { - display: flex; - gap: 0; -} - -.toggleGroup button { - flex: 1; - min-height: 30px; - border: 1px solid rgba(23, 21, 18, 0.18); - border-right-width: 0; - background: #ffffff; - color: rgba(23, 21, 18, 0.5); - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - text-transform: uppercase; - cursor: pointer; - transition: background 0.1s, color 0.1s; -} - -.toggleGroup button:last-child { - border-right-width: 1px; -} - -.toggleGroup button.active { - background: #171512; - color: #f7f3ea; - border-color: #171512; -} - -.toggleGroup button:hover:not(.active) { - color: #171512; - border-color: rgba(23, 21, 18, 0.35); -} - -/* ── Action buttons ── */ - -.actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 22px; - padding-top: 16px; - border-top: 1px solid rgba(23, 21, 18, 0.12); -} - -.actions button { - min-height: 38px; - padding: 0 14px; - border: 1px solid rgba(23, 21, 18, 0.2); - background: #ffffff; - color: #171512; - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - text-transform: uppercase; - letter-spacing: 0.06em; - cursor: pointer; - transition: background 0.1s, color 0.1s; -} - -.actions button:hover { - border-color: #171512; -} - -.actions button.primary { - background: #171512; - color: #f7f3ea; - border-color: #171512; - flex: 1; -} - -.actions button.primary:hover { - background: #2a2724; -} - -.actions button:disabled { - opacity: 0.3; - cursor: not-allowed; -} - -/* ── Progress bar ── */ - -.progressWrap { - margin-top: 14px; -} - -.progressBar { - width: 100%; - height: 3px; - background: rgba(23, 21, 18, 0.08); - overflow: hidden; -} - -.progressFill { - height: 100%; - background: #171512; - transition: width 0.1s linear; -} - -.progressLabel { - margin-top: 4px; - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - color: rgba(23, 21, 18, 0.45); - text-transform: uppercase; -} - -/* ── File stats ── */ - -.fileStats { - display: flex; - flex-wrap: wrap; - gap: 14px; - margin-top: 10px; - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - color: rgba(23, 21, 18, 0.45); - text-transform: uppercase; -} - -.fileStats .statValue { - color: #171512; - font-weight: 600; -} - -/* ── PFV meta (Phase 2) ── */ - -.pfvMeta { - margin-top: 14px; - padding: 10px 12px; - border: 1px solid rgba(23, 21, 18, 0.12); - background: rgba(23, 21, 18, 0.02); - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - color: rgba(23, 21, 18, 0.55); - display: grid; - grid-template-columns: 1fr 1fr; - gap: 5px 16px; -} - -.pfvMeta strong { - color: #171512; - font-weight: 500; -} - -/* ── Error ── */ - -.errorBox { - margin-top: 12px; - border: 1px solid #b42318; - background: #fff4f2; - color: #b42318; - padding: 10px 12px; - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - line-height: 1.5; -} - -/* ── Upload to device button ── */ - -.uploadBtn { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - width: 100%; - min-height: 42px; - margin-top: 10px; - border: 1px solid rgba(23, 21, 18, 0.2); - background: #1a3a1a; - color: #b8e6b8; - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - text-transform: uppercase; - letter-spacing: 0.06em; - cursor: pointer; - transition: background 0.15s, color 0.15s; -} - -.uploadBtn:hover { - background: #244d24; - color: #d4f5d4; -} - -.uploadBtn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.uploadBtn.uploading { - background: #2a4a2a; - animation: pulse 1.2s ease-in-out infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 0.7; } - 50% { opacity: 1; } -} - -.uploadDone { - margin-top: 8px; - padding: 8px 12px; - border: 1px solid #2a7a2a; - background: #f0faf0; - color: #1a5a1a; - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); -} - -/* ── Size warning ── */ - -.sizeWarning { - margin-top: 12px; - padding: 10px 12px; - border: 1px solid #c07b00; - background: #fffbf0; - color: #8a5d00; - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - line-height: 1.5; -} - -/* ── Responsive ── */ - -@media (max-width: 800px) { - .workspace { - grid-template-columns: 1fr; - } - - .settingRow { - grid-template-columns: 70px 1fr 44px; - } - - .sourceVideo { - max-height: 200px; - } -} - -/* ── Device management panel ── */ - -.devicePanel { - margin-top: 10px; - border: 1px solid rgba(23, 21, 18, 0.12); - background: rgba(23, 21, 18, 0.02); - font-family: var(--font-jetbrains), monospace; - font-size: 10px; -} - -.deviceFree { - padding: 8px 10px; - color: rgba(23, 21, 18, 0.5); - text-transform: uppercase; - letter-spacing: 0.06em; - border-bottom: 1px solid rgba(23, 21, 18, 0.08); -} - -.deviceFree strong { - color: #171512; - font-weight: 600; -} - -.deviceEmpty { - padding: 14px 10px; - text-align: center; - color: rgba(23, 21, 18, 0.3); - text-transform: uppercase; -} - -.deviceFile { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 10px; - border-bottom: 1px solid rgba(23, 21, 18, 0.06); -} - -.deviceFile:last-child { - border-bottom: none; -} - -.deviceFileName { - flex: 1; - color: #171512; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.deviceFileSize { - color: rgba(23, 21, 18, 0.4); - flex-shrink: 0; -} - -.deviceDeleteBtn { - width: 20px; - height: 20px; - border: 1px solid rgba(180, 35, 24, 0.3); - background: transparent; - color: #b42318; - font-size: 12px; - line-height: 1; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - padding: 0; - transition: background 0.1s; -} - -.deviceDeleteBtn:hover { - background: #fff4f2; -} diff --git a/web/src/app/video-baker/VideoBakerClient.tsx b/web/src/app/video-baker/VideoBakerClient.tsx deleted file mode 100644 index eb30882..0000000 --- a/web/src/app/video-baker/VideoBakerClient.tsx +++ /dev/null @@ -1,956 +0,0 @@ -"use client"; - -import { useState, useRef, useCallback, useEffect } from "react"; -import Link from "next/link"; -import s from "./VideoBaker.module.css"; -import { - encodePFV1, - decodePFV1, - imageDataToRgb565, - rgb565ToImageData, - estimatePfvSize, - formatBytes, - fpsFromMilli, - PFV_WIDTH, - PFV_HEIGHT, - type PFV1Header, -} from "@/lib/pfv"; -import { - processFrame, - DEFAULT_PIPELINE, - type PipelineOptions, - type FitMode, - type DitherMode, - type Rotation, -} from "@/lib/ledPipeline"; -import { - supportsWebCodecs, - extractFramesWebCodecs, - extractFramesLegacy, - getVideoInfo, - type SourceInfo, -} from "@/lib/videoDecoder"; -import { - supportsWebSerial, - uploadPfvToDevice, - listDeviceFiles, - clearDeviceFiles, - deleteDeviceFile, - type UploadProgress, - type DeviceInfo, -} from "@/lib/serialUpload"; - -// ── Types ────────────────────────────────────────────── - -type TargetFps = 8 | 12 | 15 | 30; - -const SIZE_WARNING_BYTES = 4 * 1024 * 1024; // 4 MB - -interface BakerState { - sourceFile: File | null; - sourceUrl: string | null; - sourceInfo: SourceInfo | null; - targetFps: TargetFps; - trimStart: number; - trimEnd: number; - pipeline: PipelineOptions; - bakedFrames: Uint16Array[]; - previewFrames: ImageData[]; - currentFrame: number; - isPlaying: boolean; - isBaking: boolean; - bakeProgress: number; - error: string | null; - pfvHeader: PFV1Header | null; -} - -const INITIAL: BakerState = { - sourceFile: null, - sourceUrl: null, - sourceInfo: null, - targetFps: 12, - trimStart: 0, - trimEnd: 0, - pipeline: { ...DEFAULT_PIPELINE }, - bakedFrames: [], - previewFrames: [], - currentFrame: 0, - isPlaying: false, - isBaking: false, - bakeProgress: 0, - error: null, - pfvHeader: null, -}; - -// ── Helpers ──────────────────────────────────────────── - -function fmtTime(sec: number): string { - const m = Math.floor(sec / 60); - const s = (sec % 60).toFixed(1); - return m > 0 ? `${m}:${s.padStart(4, "0")}` : `${s}s`; -} - -// ── Component ────────────────────────────────────────── - -export default function VideoBakerClient() { - const [st, setSt] = useState(INITIAL); - const canvasRef = useRef(null); - const videoRef = useRef(null); - const fileInputRef = useRef(null); - const pfvInputRef = useRef(null); - const playTimerRef = useRef(null); - const [dragOver, setDragOver] = useState(false); - const previewTimerRef = useRef | null>(null); - const [uploadStatus, setUploadStatus] = useState(null); - const [deviceInfo, setDeviceInfo] = useState(null); - const [deviceLoading, setDeviceLoading] = useState(false); - const [canUseSerial, setCanUseSerial] = useState(false); - - // ── Canvas rendering ───────────────────────────────── - - useEffect(() => { - setCanUseSerial(supportsWebSerial()); - }, []); - - const drawFrame = useCallback((frameIdx: number, frames: ImageData[]) => { - const canvas = canvasRef.current; - if (!canvas || frames.length === 0) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - const idx = Math.max(0, Math.min(frames.length - 1, frameIdx)); - ctx.putImageData(frames[idx], 0, 0); - }, []); - - useEffect(() => { - drawFrame(st.currentFrame, st.previewFrames); - }, [st.currentFrame, st.previewFrames, drawFrame]); - - // ── Live pipeline preview (single frame) ───────────── - - const renderLivePreview = useCallback(() => { - const video = videoRef.current; - const canvas = canvasRef.current; - if (!video || !canvas || video.readyState < 2) return; - if (st.bakedFrames.length > 0) return; // don't overwrite baked preview - const ctx = canvas.getContext("2d"); - if (!ctx) return; - const frame = processFrame( - video, - video.videoWidth, - video.videoHeight, - st.pipeline, - ); - ctx.putImageData(frame, 0, 0); - }, [st.pipeline, st.bakedFrames.length]); - - // Debounced live preview on pipeline changes - useEffect(() => { - if (!st.sourceUrl || st.bakedFrames.length > 0) return; - if (previewTimerRef.current) clearTimeout(previewTimerRef.current); - previewTimerRef.current = setTimeout(renderLivePreview, 60); - return () => { - if (previewTimerRef.current) clearTimeout(previewTimerRef.current); - }; - }, [st.pipeline, st.sourceUrl, st.bakedFrames.length, renderLivePreview]); - - // ── Playback loop ──────────────────────────────────── - - useEffect(() => { - if (st.isPlaying && st.previewFrames.length > 1) { - const interval = 1000 / st.targetFps; - playTimerRef.current = window.setInterval(() => { - setSt((p) => ({ - ...p, - currentFrame: (p.currentFrame + 1) % p.previewFrames.length, - })); - }, interval); - } - return () => { - if (playTimerRef.current) clearInterval(playTimerRef.current); - }; - }, [st.isPlaying, st.previewFrames.length, st.targetFps]); - - // ── Cleanup source URL ─────────────────────────────── - - useEffect(() => { - return () => { - if (st.sourceUrl) URL.revokeObjectURL(st.sourceUrl); - }; - }, [st.sourceUrl]); - - // ── File handling ──────────────────────────────────── - - const handleFile = useCallback(async (file: File) => { - // Revoke old URL - setSt((p) => { - if (p.sourceUrl) URL.revokeObjectURL(p.sourceUrl); - return p; - }); - - const url = URL.createObjectURL(file); - - setSt((p) => ({ - ...p, - sourceFile: file, - sourceUrl: url, - sourceInfo: null, - bakedFrames: [], - previewFrames: [], - currentFrame: 0, - isPlaying: false, - error: null, - pfvHeader: null, - trimStart: 0, - trimEnd: 0, - })); - - try { - const info = await getVideoInfo(file); - setSt((p) => ({ - ...p, - sourceInfo: info, - trimEnd: info.duration, - })); - } catch (e) { - setSt((p) => ({ - ...p, - error: `Failed to read video: ${e instanceof Error ? e.message : String(e)}`, - })); - } - }, []); - - const onDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - setDragOver(false); - const file = e.dataTransfer.files[0]; - if (file) handleFile(file); - }, - [handleFile], - ); - - const onFileChange = useCallback( - (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) handleFile(file); - }, - [handleFile], - ); - - // ── Video preview seek ─────────────────────────────── - - const seekVideo = useCallback((time: number) => { - if (videoRef.current) { - videoRef.current.currentTime = time; - } - }, []); - - // ── Bake ───────────────────────────────────────────── - - const handleBake = useCallback(async () => { - if (!st.sourceFile) return; - - setSt((p) => ({ - ...p, - isBaking: true, - bakeProgress: 0, - error: null, - bakedFrames: [], - previewFrames: [], - currentFrame: 0, - isPlaying: false, - })); - - try { - const opts = { - targetFps: st.targetFps, - trimStart: st.trimStart, - trimEnd: st.trimEnd, - onProgress: (p: number) => { - setSt((prev) => ({ ...prev, bakeProgress: p * 0.6 })); - }, - }; - - let sourceBitmaps: (VideoFrame | ImageBitmap)[] = []; - let srcW = 0; - let srcH = 0; - - if (supportsWebCodecs()) { - try { - console.log("[baker] WebCodecs path"); - const result = await extractFramesWebCodecs(st.sourceFile, opts); - sourceBitmaps = result.frames; - srcW = result.info.width; - srcH = result.info.height; - console.log(`[baker] WebCodecs → ${sourceBitmaps.length} frames`); - } catch (wcErr) { - console.warn("[baker] WebCodecs failed, fallback:", wcErr); - sourceBitmaps = []; - } - } - - if (sourceBitmaps.length === 0) { - console.log("[baker] Legacy
diff --git a/web/src/lib/ledPipeline.ts b/web/src/lib/ledPipeline.ts deleted file mode 100644 index 6e478cc..0000000 --- a/web/src/lib/ledPipeline.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * LED Correction Pipeline - * Transforms source frames into 128×64 LED-friendly images. - * License: MIT - */ - -import { PFV_WIDTH, PFV_HEIGHT } from "./pfv"; - -export type FitMode = "cover" | "contain"; -export type DitherMode = "off" | "floyd-steinberg" | "ordered"; -export type Rotation = 0 | 90 | 180 | 270; - -export interface PipelineOptions { - fitMode: FitMode; - cropX: number; - cropY: number; - rotation: Rotation; - brightness: number; - contrast: number; - saturation: number; - gamma: number; - dither: DitherMode; -} - -export const DEFAULT_PIPELINE: PipelineOptions = { - fitMode: "cover", - cropX: 0.5, - cropY: 0.5, - rotation: 0, - brightness: 100, - contrast: 100, - saturation: 100, - gamma: 1.0, - dither: "off", -}; - -let _resizeCanvas: OffscreenCanvas | null = null; -let _resizeCtx: OffscreenCanvasRenderingContext2D | null = null; - -function getResizeCtx() { - if (!_resizeCanvas) { - _resizeCanvas = new OffscreenCanvas(PFV_WIDTH, PFV_HEIGHT); - _resizeCtx = _resizeCanvas.getContext("2d", { willReadFrequently: true }) as OffscreenCanvasRenderingContext2D; - } - return _resizeCtx!; -} - -export function processFrame( - source: CanvasImageSource, - srcW: number, - srcH: number, - opts: PipelineOptions = DEFAULT_PIPELINE, -): ImageData { - const ctx = getResizeCtx(); - drawResized(ctx, source, srcW, srcH, opts); - const imageData = ctx.getImageData(0, 0, PFV_WIDTH, PFV_HEIGHT); - applyCorrections(imageData, opts); - if (opts.dither !== "off") applyDither(imageData, opts.dither); - return imageData; -} - -// Scratch canvas for rotation pre-pass -let _rotCanvas: OffscreenCanvas | null = null; -let _rotCtx: OffscreenCanvasRenderingContext2D | null = null; - -function getRotatedSource( - source: CanvasImageSource, - srcW: number, srcH: number, - rotation: Rotation, -): { img: CanvasImageSource; w: number; h: number } { - if (rotation === 0) return { img: source, w: srcW, h: srcH }; - - const swap = rotation === 90 || rotation === 270; - const outW = swap ? srcH : srcW; - const outH = swap ? srcW : srcH; - - if (!_rotCanvas || _rotCanvas.width !== outW || _rotCanvas.height !== outH) { - _rotCanvas = new OffscreenCanvas(outW, outH); - _rotCtx = _rotCanvas.getContext("2d") as OffscreenCanvasRenderingContext2D; - } - const rc = _rotCtx!; - rc.clearRect(0, 0, outW, outH); - rc.save(); - rc.translate(outW / 2, outH / 2); - rc.rotate((rotation * Math.PI) / 180); - rc.drawImage(source, -srcW / 2, -srcH / 2); - rc.restore(); - - return { img: _rotCanvas, w: outW, h: outH }; -} - -function drawResized( - ctx: OffscreenCanvasRenderingContext2D, - source: CanvasImageSource, - srcW: number, srcH: number, - opts: PipelineOptions, -) { - // Apply rotation first - const { img, w: rW, h: rH } = getRotatedSource(source, srcW, srcH, opts.rotation); - - const dW = PFV_WIDTH, dH = PFV_HEIGHT; - const srcA = rW / rH, dstA = dW / dH; - ctx.clearRect(0, 0, dW, dH); - - if (opts.fitMode === "contain") { - ctx.fillStyle = "#000"; - ctx.fillRect(0, 0, dW, dH); - let drawW: number, drawH: number; - if (srcA > dstA) { drawW = dW; drawH = dW / srcA; } - else { drawH = dH; drawW = dH * srcA; } - ctx.drawImage(img, (dW - drawW) / 2, (dH - drawH) / 2, drawW, drawH); - } else { - let sx: number, sy: number, sw: number, sh: number; - if (srcA > dstA) { - sh = rH; sw = rH * dstA; - sx = (rW - sw) * opts.cropX; sy = 0; - } else { - sw = rW; sh = rW / dstA; - sx = 0; sy = (rH - sh) * opts.cropY; - } - ctx.drawImage(img, sx, sy, sw, sh, 0, 0, dW, dH); - } -} - -function applyCorrections(imageData: ImageData, opts: PipelineOptions) { - const d = imageData.data; - const bf = opts.brightness / 100; // 0→0, 100→1, 200→2 - const cm = opts.contrast / 100; // 0→0 (gray), 100→1 (neutral), 200→2 - const sf = opts.saturation / 100; // 0→0 (mono), 100→1 (neutral), 200→2 - const gi = opts.gamma !== 0 ? 1 / opts.gamma : 1; - - for (let i = 0; i < d.length; i += 4) { - let r = d[i] / 255, g = d[i+1] / 255, b = d[i+2] / 255; - - // Brightness - r *= bf; g *= bf; b *= bf; - - // Contrast (around 0.5 midpoint) - r = (r - 0.5) * cm + 0.5; - g = (g - 0.5) * cm + 0.5; - b = (b - 0.5) * cm + 0.5; - - // Saturation (luminosity-preserving) - const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; - r = lum + (r - lum) * sf; - g = lum + (g - lum) * sf; - b = lum + (b - lum) * sf; - - // Gamma - r = Math.pow(Math.max(0, r), gi); - g = Math.pow(Math.max(0, g), gi); - b = Math.pow(Math.max(0, b), gi); - - d[i] = Math.max(0, Math.min(255, Math.round(r * 255))); - d[i+1] = Math.max(0, Math.min(255, Math.round(g * 255))); - d[i+2] = Math.max(0, Math.min(255, Math.round(b * 255))); - } -} - -const BAYER4 = [0/16,8/16,2/16,10/16, 12/16,4/16,14/16,6/16, 3/16,11/16,1/16,9/16, 15/16,7/16,13/16,5/16]; - -function applyDither(imageData: ImageData, mode: DitherMode) { - if (mode === "ordered") applyOrderedDither(imageData); - else applyFloydSteinberg(imageData); -} - -function applyFloydSteinberg(img: ImageData) { - const w = img.width, h = img.height, d = img.data; - const rf = new Float32Array(w*h), gf = new Float32Array(w*h), bf = new Float32Array(w*h); - for (let i = 0; i < w*h; i++) { rf[i] = d[i*4]; gf[i] = d[i*4+1]; bf[i] = d[i*4+2]; } - - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - const idx = y*w+x; - const oR = rf[idx], oG = gf[idx], oB = bf[idx]; - const nR = Math.round(oR/255*31)*(255/31); - const nG = Math.round(oG/255*63)*(255/63); - const nB = Math.round(oB/255*31)*(255/31); - rf[idx] = nR; gf[idx] = nG; bf[idx] = nB; - const eR = oR-nR, eG = oG-nG, eB = oB-nB; - const sp = (di: number, w: number) => { rf[di]+=eR*w; gf[di]+=eG*w; bf[di]+=eB*w; }; - if (x+1=0) sp(idx+w-1, 3/16); - sp(idx+w, 5/16); - if (x+1 { - const t = new Uint32Array(256); - for (let i = 0; i < 256; i++) { - let c = i; - for (let k = 0; k < 8; k++) { - c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - } - t[i] = c; - } - return t; -})(); - -export function crc32(data: Uint8Array): number { - let crc = 0xffffffff; - for (let i = 0; i < data.length; i++) { - crc = crcTable[(crc ^ data[i]) & 0xff] ^ (crc >>> 8); - } - return (crc ^ 0xffffffff) >>> 0; -} - -// ── RGB565 conversion ────────────────────────────────── - -/** Pack 8-bit RGB to 16-bit RGB565 little-endian. */ -export function rgb888toRgb565(r: number, g: number, b: number): number { - const r5 = (r >> 3) & 0x1f; - const g6 = (g >> 2) & 0x3f; - const b5 = (b >> 3) & 0x1f; - return (r5 << 11) | (g6 << 5) | b5; -} - -/** Unpack 16-bit RGB565 to 8-bit [r, g, b]. */ -export function rgb565toRgb888(pixel: number): [number, number, number] { - const r5 = (pixel >> 11) & 0x1f; - const g6 = (pixel >> 5) & 0x3f; - const b5 = pixel & 0x1f; - return [ - (r5 << 3) | (r5 >> 2), - (g6 << 2) | (g6 >> 4), - (b5 << 3) | (b5 >> 2), - ]; -} - -/** Convert an RGBA ImageData (128×64) to a RGB565 Uint16Array. */ -export function imageDataToRgb565(imageData: ImageData): Uint16Array { - const pixelCount = imageData.width * imageData.height; - const out = new Uint16Array(pixelCount); - const d = imageData.data; - for (let i = 0; i < pixelCount; i++) { - const off = i * 4; - out[i] = rgb888toRgb565(d[off], d[off + 1], d[off + 2]); - } - return out; -} - -/** Convert a RGB565 Uint16Array to RGBA ImageData (128×64). */ -export function rgb565ToImageData( - frame: Uint16Array, - width = PFV_WIDTH, - height = PFV_HEIGHT, -): ImageData { - const data = new Uint8ClampedArray(width * height * 4); - for (let i = 0; i < frame.length; i++) { - const [r, g, b] = rgb565toRgb888(frame[i]); - const off = i * 4; - data[off] = r; - data[off + 1] = g; - data[off + 2] = b; - data[off + 3] = 255; - } - return new ImageData(data, width, height); -} - -// ── Encoder ──────────────────────────────────────────── - -export function encodePFV1( - frames: Uint16Array[], - fps: number, - options?: { loop?: boolean; loopStart?: number }, -): ArrayBuffer { - const frameCount = frames.length; - const payloadSize = frameCount * PFV_FRAME_BYTES; - const totalSize = PFV1_HEADER_SIZE + payloadSize; - const buffer = new ArrayBuffer(totalSize); - const view = new DataView(buffer); - const bytes = new Uint8Array(buffer); - - // Write frame payload first (to compute CRC before header) - for (let f = 0; f < frameCount; f++) { - const frameBytes = new Uint8Array(frames[f].buffer, frames[f].byteOffset, frames[f].byteLength); - bytes.set(frameBytes, PFV1_HEADER_SIZE + f * PFV_FRAME_BYTES); - } - - // Compute data CRC - const payloadSlice = new Uint8Array(buffer, PFV1_HEADER_SIZE, payloadSize); - const dataCrc = crc32(payloadSlice); - - // Write header - // magic - bytes[0] = 0x50; // P - bytes[1] = 0x46; // F - bytes[2] = 0x56; // V - bytes[3] = 0x31; // 1 - - view.setUint16(4, PFV1_HEADER_SIZE, true); // headerSize - view.setUint16(6, PFV_WIDTH, true); // width - view.setUint16(8, PFV_HEIGHT, true); // height - view.setUint16(10, Math.round(fps * 1000), true); // fpsMilli - view.setUint32(12, frameCount, true); // frameCount - bytes[16] = PFV1_FORMAT_RGB565_LE; // format - bytes[17] = (options?.loop !== false ? 0x01 : 0x00); // flags - view.setUint32(18, options?.loopStart ?? 0, true); // loopStart - // reserved bytes 22–51 are already zero - view.setUint32(52, dataCrc, true); // dataCrc32 - view.setUint32(56, 0, true); // headerCrc32 (MVP: 0) - // bytes 60-63 reserved pad, already zero - - return buffer; -} - -// ── Decoder ──────────────────────────────────────────── - -export function decodePFV1(buffer: ArrayBuffer): PFV1File { - if (buffer.byteLength < PFV1_HEADER_SIZE) { - throw new Error("File too small to contain PFV1 header"); - } - - const view = new DataView(buffer); - const bytes = new Uint8Array(buffer); - - // Check magic - const magic = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]); - if (magic !== PFV1_MAGIC) { - throw new Error(`Invalid magic: expected "${PFV1_MAGIC}", got "${magic}"`); - } - - const headerSize = view.getUint16(4, true); - const width = view.getUint16(6, true); - const height = view.getUint16(8, true); - const fpsMilli = view.getUint16(10, true); - const frameCount = view.getUint32(12, true); - const format = bytes[16]; - const flags = bytes[17]; - const loopStart = view.getUint32(18, true); - const dataCrc32 = view.getUint32(52, true); - const headerCrc32 = view.getUint32(56, true); - - if (format !== PFV1_FORMAT_RGB565_LE) { - throw new Error(`Unsupported format: 0x${format.toString(16)}`); - } - - if (width !== PFV_WIDTH || height !== PFV_HEIGHT) { - throw new Error(`Unsupported dimensions: ${width}×${height}, expected ${PFV_WIDTH}×${PFV_HEIGHT}`); - } - - const frameBytes = width * height * 2; - const expectedPayload = frameCount * frameBytes; - - if (buffer.byteLength < headerSize + expectedPayload) { - throw new Error( - `File truncated: expected ${headerSize + expectedPayload} bytes, got ${buffer.byteLength}`, - ); - } - - // Verify data CRC - const payloadSlice = new Uint8Array(buffer, headerSize, expectedPayload); - const computedCrc = crc32(payloadSlice); - if (computedCrc !== dataCrc32) { - throw new Error( - `Data CRC mismatch: expected 0x${dataCrc32.toString(16)}, computed 0x${computedCrc.toString(16)}`, - ); - } - - // Extract frames - const frames: Uint16Array[] = []; - for (let f = 0; f < frameCount; f++) { - const offset = headerSize + f * frameBytes; - const frameData = new Uint16Array(buffer.slice(offset, offset + frameBytes)); - frames.push(frameData); - } - - return { - header: { - magic, - headerSize, - width, - height, - fpsMilli, - frameCount, - format, - flags, - loopStart, - dataCrc32, - headerCrc32, - }, - frames, - }; -} - -// ── Helpers ──────────────────────────────────────────── - -/** Estimated PFV1 file size in bytes. */ -export function estimatePfvSize(frameCount: number): number { - return PFV1_HEADER_SIZE + frameCount * PFV_FRAME_BYTES; -} - -/** Format bytes to human readable string. */ -export function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; -} - -/** Get fps from fpsMilli field. */ -export function fpsFromMilli(fpsMilli: number): number { - return fpsMilli / 1000; -} diff --git a/web/src/lib/presets/_TEMPLATE.ts b/web/src/lib/presets/_TEMPLATE.ts index 0bd2852..fcc81ba 100644 --- a/web/src/lib/presets/_TEMPLATE.ts +++ b/web/src/lib/presets/_TEMPLATE.ts @@ -20,6 +20,8 @@ export const preset: LivePreset = { license: "CC-BY-SA-4.0", // SPDX id; project default is CC-BY-SA-4.0 source: "", // Instagram/Discord/PR URL, if any lineage: "original", // e.g. "remixed from @someone's Wave Saw" + // labOnly: true, // set if it needs Pattern Lab features (color ramp / + // // value panel) — hides it from the /pattern showcase code: `// Pattern: Template // Author: engmung diff --git a/web/src/lib/presets/index.ts b/web/src/lib/presets/index.ts index 4d4cd4a..c62df33 100644 --- a/web/src/lib/presets/index.ts +++ b/web/src/lib/presets/index.ts @@ -39,6 +39,19 @@ import { preset as pattern_0614_2 } from "./pattern-0614-2"; import { preset as pattern_0619 } from "./pattern-0619"; import { preset as pattern_0622 } from "./pattern-0622"; import { preset as pattern_0624 } from "./pattern-0624"; +import { preset as pattern_0628 } from "./pattern-0628"; +import { preset as pattern_0629 } from "./pattern-0629"; +import { preset as pattern_0629_2 } from "./pattern-0629-2"; +import { preset as pattern_0701 } from "./pattern-0701"; +import { preset as pattern_0707 } from "./pattern-0707"; +import { preset as pattern_0710 } from "./pattern-0710"; +import { preset as pattern_0712 } from "./pattern-0712"; +import { preset as pattern_0712_2 } from "./pattern-0712-2"; +import { preset as pattern_0713 } from "./pattern-0713"; +import { preset as pattern_0715 } from "./pattern-0715"; +import { preset as pattern_0716 } from "./pattern-0716"; +import { preset as pattern_0718 } from "./pattern-0718"; +import { preset as pattern_0719 } from "./pattern-0719"; import { preset as pattern_a_big_hit } from "./pattern-a-big-hit"; const presets: LivePreset[] = [ @@ -76,10 +89,26 @@ const presets: LivePreset[] = [ pattern_0619, pattern_0622, pattern_0624, + pattern_0628, + pattern_0629, + pattern_0629_2, + pattern_0701, + pattern_0707, + pattern_0710, + pattern_0712, + pattern_0712_2, + pattern_0713, + pattern_0715, + pattern_0716, + pattern_0718, + pattern_0719, pattern_a_big_hit ]; export type { LivePreset } from "./types"; -/** All live-editor presets, sorted by pattern number. */ +/** All live-editor presets, sorted by pattern number. Pattern Lab shows this full set. */ export const livePresets: LivePreset[] = [...presets].sort((a, b) => a.num - b.num); + +/** Presets for the simple /pattern showcase — lab-only patterns are excluded. */ +export const showcasePresets: LivePreset[] = livePresets.filter((p) => !p.labOnly); diff --git a/web/src/lib/presets/pattern-0628.ts b/web/src/lib/presets/pattern-0628.ts new file mode 100644 index 0000000..acc6d19 --- /dev/null +++ b/web/src/lib/presets/pattern-0628.ts @@ -0,0 +1,111 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0628", + num: 628, + name: "0628", + desc: "Retro 8-bit digital pixel tapestry", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-06-28", + lineage: "AI generated and curated via Pattern Lab", + code: `// ===== Patternflow pattern ===== +// Title: Retro Digital Tapestry +// Author: Seunghun LEE +// Date: 2026-06-28 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// Pattern: Retro Digital Tapestry +// Author: your name here +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Date: 2026-06-28 +// Made with Patternflow Pattern Lab — https://patternflow.work/pattern-lab + +export function setup(params) { + params.cellScale = 0.3; + params.speed = 1.0; + params.logicMode = 0.2; + params.waveMod = 0.5; + params.timeAcc = 0; +} + +export function update(dt, input, params) { + if (input && input.knobValues) { + params.cellScale = input.knobValues[0]; + params.speed = input.knobValues[1]; + params.logicMode = input.knobValues[2]; + params.waveMod = input.knobValues[3]; + } + params.timeAcc += dt * params.speed * 2.0; +} + +export function draw(display, params, time) { + let w = display.width; + let h = display.height; + let t = params.timeAcc; + + let scale = Math.floor(4 + params.cellScale * 24); + let mode = Math.floor(params.logicMode * 5.0); + let waveF = 0.02 + params.waveMod * 0.08; + + for (let y = 0; y < h; y++) { + let sy = Math.floor(y / scale); + for (let x = 0; x < w; x++) { + let sx = Math.floor(x / scale); + let patternVal = 0; + + switch (mode) { + case 0: patternVal = (sx ^ sy) + Math.floor(t); break; + case 1: patternVal = (sx & sy) * 3 + Math.floor(t * 1.5); break; + case 2: patternVal = (sx * 7 + sy * 3) ^ Math.floor(t); break; + case 3: patternVal = (sx ^ (sy + Math.floor(t))) & 15; break; + default: patternVal = ((sx * sx + sy * sy) >> 2) + Math.floor(t * 0.8); break; + } + + let smoothS = Math.sin(x * waveF + t) * Math.cos(y * waveF - t); + let bitActive = (patternVal & 8) !== 0; + + let r = 0, g = 0, b = 0; + + if (bitActive) { + let hu = (smoothS * 0.3 + 0.5 + (patternVal % 16) / 32.0) % 1.0; + let hueIdx = Math.floor(hu * 6); + let f = hu * 6 - hueIdx; + let maxVal = 220; + let minVal = Math.floor(50 * (Math.sin(t * 2.0) * 0.5 + 0.5)); + let range = maxVal - minVal; + + let p = maxVal; + let q = minVal + Math.floor(range * (1 - f)); + let s = minVal + Math.floor(range * f); + + switch (hueIdx % 6) { + case 0: r = p; g = s; b = minVal; break; + case 1: r = q; g = p; b = minVal; break; + case 2: r = minVal; g = p; b = s; break; + case 3: r = minVal; g = q; b = p; break; + case 4: r = s; g = minVal; b = p; break; + default: r = p; g = minVal; b = q; break; + } + } else { + if ((x % scale === 0) || (y % scale === 0)) { + r = 20; g = 10; b = 40; + } + } + + display.setPixel(x, y, r, g, b); + } + } +} + +// --- +// Generated at https://patternflow.work/pattern-lab — https://patternflow.work +// Licensed CC-BY-SA-4.0. Keep this notice if you share or remix. + +// ── Made with Patternflow · https://patternflow.work ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/pattern-0629-2.ts b/web/src/lib/presets/pattern-0629-2.ts new file mode 100644 index 0000000..3cc8f16 --- /dev/null +++ b/web/src/lib/presets/pattern-0629-2.ts @@ -0,0 +1,104 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0629-2", + num: 629.02, + name: "0629-2", + desc: "Vector field particle flow simulation", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-06-29", + lineage: "AI generated and curated via Pattern Lab", + code: `// ===== Patternflow pattern ===== +// Title: Vector Field Particle Flow +// Author: Seunghun LEE +// Date: 2026-06-29 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// Pattern: Vector Field Particle Flow +// Author: Creative AI Collaborator +// SPDX-License-Identifier: CC-BY-SA-4.0 +// +// Knob 1: Field Turbulence / Curl +// Knob 2: Stream Velocity +// Knob 3: Particle Density Scaling +// Knob 4: Directional Palette Shift + +function hsvToRgb(h, s, v) { + let r, g, b; + let i = Math.floor(h * 6); + let f = h * 6 - i; + let p = v * (1 - s); + let q = v * (1 - f * s); + let t = v * (1 - (1 - f) * s); + switch (((i % 6) + 6) % 6) { + case 0: r = v; g = t; b = p; break; + case 1: r = q; g = v; b = p; break; + case 2: r = p; g = v; b = t; break; + case 3: r = p; g = q; b = v; break; + case 4: r = t; g = p; b = v; break; + case 5: r = v; g = p; b = q; break; + } + return [Math.floor(r * 255), Math.floor(g * 255), Math.floor(b * 255)]; +} + +export function setup(params) { + params.turbulence = 0.5; + params.speed = 2.0; + params.density = 2.5; + params.palette = 0.1; + params.timeAcc = 0.0; +} + +export function update(dt, input, params) { + if (input && input.knobValues) { + params.turbulence = input.knobValues[0]; + params.speed = input.knobValues[1]; + params.density = input.knobValues[2]; + params.palette = input.knobValues[3]; + } + params.timeAcc += dt * params.speed; +} + +export function draw(display, params, time) { + let w = display.width; + let h = display.height; + let t = params.timeAcc; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + let nx = x / w - 0.5; + let ny = y / h - 0.5; + + // 회전 벡터 필드 계산 + let angle = Math.sin(nx * params.density * 4.0 + t) + Math.cos(ny * params.density * 4.0 - t); + let forceX = Math.sin(angle * params.turbulence * 5.0); + let forceY = Math.cos(angle * params.turbulence * 5.0); + + let value = Math.sin((nx * forceX + ny * forceY) * 10.0 + t * 2.0); + let intensity = Math.max(0.0, value * 0.5 + 0.5); + + let r = 0, g = 0, b = 0; + if (intensity > 0.1) { + let hue = params.palette + (angle / (Math.PI * 2)) + (forceX * 0.2); + hue = Math.abs(hue % 1.0); + + let rgb = hsvToRgb(hue, 0.85, intensity); + r = rgb[0]; g = rgb[1]; b = rgb[2]; + + if (intensity > 0.88) { + r = 255; g = 255; b = 255; + } + } + display.setPixel(x, y, r, g, b); + } + } +} + +// ── Made with Patternflow · https://patternflow.work ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/pattern-0629.ts b/web/src/lib/presets/pattern-0629.ts new file mode 100644 index 0000000..eb90d6b --- /dev/null +++ b/web/src/lib/presets/pattern-0629.ts @@ -0,0 +1,101 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0629", + num: 629, + name: "0629", + desc: "Chromatic aberration vortex waves", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-06-29", + lineage: "AI generated and curated via Pattern Lab", + code: `// ===== Patternflow pattern ===== +// Title: Chromatic Aberration Vortex +// Author: Seunghun LEE +// Date: 2026-06-29 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// Pattern: Chromatic Aberration Vortex +// Author: Creative AI Collaborator +// SPDX-License-Identifier: CC-BY-SA-4.0 +// +// Knob 1: Aberration Split Distance +// Knob 2: Rotation Swirl Speed +// Knob 3: Vortex Ring Wave Density +// Knob 4: Base Color Shift Matrix + +export function setup(params) { + params.split = 0.4; + params.speed = 1.5; + params.density = 2.0; + params.colorBias = 0.5; + params.timeAcc = 0.0; +} + +export function update(dt, input, params) { + if (input && input.knobValues) { + params.split = input.knobValues[0]; + params.speed = input.knobValues[1]; + params.density = input.knobValues[2]; + params.colorBias = input.knobValues[3]; + } + params.timeAcc += dt * params.speed; +} + +export function draw(display, params, time) { + let w = display.width; + let h = display.height; + let t = params.timeAcc; + + let cx = w / 2, cy = h / 2; + let maxShift = params.split * 8.0; + + for (let y = 0; y < h; y++) { + let dy = y - cy; + for (let x = 0; x < w; x++) { + let dx = x - cx; + let dist = Math.sqrt(dx * dx + dy * dy); + let angle = Math.atan2(dy, dx); + + // R, G, B 개별 채널에 서로 다른 물리 왜곡 위상(반경/각도) 적용 + let shiftR = Math.sin(dist * 0.05 - t) * maxShift; + let shiftG = Math.sin(dist * 0.05 - t + 1.0) * maxShift * 0.5; + let shiftB = Math.sin(dist * 0.05 - t + 2.0) * maxShift * -0.5; + + // Red Channel Matrix + let rDist = dist + shiftR; + let rWave = Math.sin(rDist * (params.density * 0.1 + 0.05) - t * 2.0 + angle); + let rInt = Math.max(0.0, rWave * 0.5 + 0.5); + + // Green Channel Matrix + let gDist = dist + shiftG; + let gWave = Math.sin(gDist * (params.density * 0.1 + 0.05) - t * 2.0 + angle + 1.0); + let gInt = Math.max(0.0, gWave * 0.5 + 0.5); + + // Blue Channel Matrix + let bDist = dist + shiftB; + let bWave = Math.sin(bDist * (params.density * 0.1 + 0.05) - t * 2.0 + angle + 2.0); + let bInt = Math.max(0.0, bWave * 0.5 + 0.5); + + // 컬러 마스터 바이어스 합성 + let r = Math.floor(rInt * 255 * (params.colorBias * 0.5 + 0.5)); + let g = Math.floor(gInt * 255 * (1.0 - params.colorBias * 0.3)); + let b = Math.floor(bInt * 255 * (0.3 + params.colorBias * 0.7)); + + // 채널들이 완벽히 중첩되는 피크는 완전한 흰색 광원 형성 + if (rInt > 0.85 && gInt > 0.85 && bInt > 0.85) { + r = 255; g = 255; b = 255; + } + + display.setPixel(x, y, r, g, b); + } + } +} + +// ── Made with Patternflow · https://patternflow.work ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/pattern-0701.ts b/web/src/lib/presets/pattern-0701.ts new file mode 100644 index 0000000..6c46e67 --- /dev/null +++ b/web/src/lib/presets/pattern-0701.ts @@ -0,0 +1,111 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0701", + num: 701, + name: "0701", + desc: "Lissajous curve weaving generator", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-07-01", + lineage: "AI generated and curated via Pattern Lab", + code: `// ===== Patternflow pattern ===== +// Title: Lissajous Weave +// Author: Seunghun LEE +// Date: 2026-07-01 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// Variation 16: Lissajous Weave (Structural Remix) +// Creates woven patterns using Lissajous curves with varying frequencies. +// Knob 1: X frequency (1-8) +// Knob 2: Speed (0.1-10.0) +// Knob 3: Y frequency (1-8) +// Knob 4: Phase offset (0.0-1.0) + +export function setup(params) { + params.freqX = 3.0; + params.speed = 2.0; + params.freqY = 4.0; + params.phase = 0.5; + params.timeAcc = 0.0; +} + +export function update(dt, input, params) { + if (input && input.knobValues) { + params.freqX = 1 + Math.floor(input.knobValues[0] * 7); + params.speed = input.knobValues[1]; + params.freqY = 1 + Math.floor(input.knobValues[2] * 7); + params.phase = input.knobValues[3]; + } + params.timeAcc += dt * params.speed; +} + +export function draw(display, params, time) { + let w = display.width; + let h = display.height; + let t = params.timeAcc; + let fx = params.freqX; + let fy = params.freqY; + let phase = params.phase * Math.PI * 2; + + // Clear with background gradient + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + let bg = Math.floor(10 + (x / w) * 20); + display.setPixel(x, y, 0, bg, Math.floor(bg * 0.7)); + } + } + + // Draw multiple Lissajous curves + let numCurves = 12; + for (let curve = 0; curve < numCurves; curve++) { + let curvePhase = (curve / numCurves) * Math.PI * 2; + let points = 300; + let hue = (curve / numCurves + t * 0.01) % 1.0; + + for (let i = 0; i < points; i++) { + let theta = (i / points) * Math.PI * 2 * 4 + t * 0.2; + let cx = w/2 + Math.sin(theta * fx + t * 0.1 + curvePhase) * (w * 0.4); + let cy = h/2 + Math.sin(theta * fy + t * 0.15 + curvePhase + phase) * (h * 0.4); + + let px = Math.floor(cx); + let py = Math.floor(cy); + + if (px >= 0 && px < w && py >= 0 && py < h) { + let brightness = 0.3 + 0.7 * (0.5 + 0.5 * Math.sin(theta * 3)); + let saturation = 0.8; + + let hh = hue * 6; + let i = Math.floor(hh); + let f = hh - i; + let p = brightness * (1 - saturation); + let q = brightness * (1 - saturation * f); + let tt = brightness * (1 - saturation * (1 - f)); + let r, g, b; + switch (i % 6) { + case 0: r = brightness; g = tt; b = p; break; + case 1: r = q; g = brightness; b = p; break; + case 2: r = p; g = brightness; b = tt; break; + case 3: r = p; g = q; b = brightness; break; + case 4: r = tt; g = p; b = brightness; break; + case 5: r = brightness; g = p; b = q; break; + } + + // 직접 픽셀 설정 (additive 대신) + display.setPixel(px, py, + Math.floor(r * 255), + Math.floor(g * 255), + Math.floor(b * 255) + ); + } + } + } +} + +// ── Made with Patternflow · https://patternflow.work ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/pattern-0707.ts b/web/src/lib/presets/pattern-0707.ts new file mode 100644 index 0000000..2c74bb3 --- /dev/null +++ b/web/src/lib/presets/pattern-0707.ts @@ -0,0 +1,81 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0707", + num: 707, + name: "0707", + desc: "Concentric wave with glitch phase displacement", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-07-07", + lineage: "AI generated and curated via Pattern Lab", + labOnly: true, + code: `// ===== Patternflow pattern ===== +// Title: 260707 +// Author: Seunghun LEE +// Date: 2026-07-07 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// @knobs Glitch=0..1, Speed=0.05..5, Freq=10..120, Quantize=2..10 +// +// Knob 1 (Glitch): Phase displacement and row slippage amount +// Knob 2 (Speed): Base time flow rate +// Knob 3 (Freq): Spatial frequency of the wave generation +// Knob 4 (Quantize): Level discretization steps for a stepped material feel + +export function setup(params) { + params.glitch = 0.1; + params.speed = 1.0; + params.freq = 40.0; + params.quantize = 4; + params.timeAcc = 0.0; +} + +export function update(dt, input, params) { + if (input && input.knobValues) { + params.glitch = input.knobValues[0]; + params.speed = input.knobValues[1]; + params.freq = input.knobValues[2]; + params.quantize = Math.floor(input.knobValues[3]); + } + params.timeAcc += dt * params.speed; +} + +export function draw(display, params, time) { + const w = display.width; + const h = display.height; + const t = params.timeAcc; + + for (let y = 0; y < h; y++) { + let rowShift = 0; + if (params.glitch > 0.02) { + let noise = Math.sin(y * 0.5 + t * 10.0) * Math.cos(y * 0.1 - t * 4.0); + if (noise > 1.0 - params.glitch) { + rowShift = Math.sin(t * 30.0) * params.glitch * 30.0; + } + } + + for (let x = 0; x < w; x++) { + let nx = (x + rowShift - w / 2) / (w / 2); + let ny = (y - h / 2) / (h / 2); + let d = Math.sqrt(nx * nx + ny * ny); + + let waveValue = Math.sin(d * params.freq - t * 4.0 + Math.sin(nx * 4.0 + t) * params.glitch * 5.0); + let rawV = (waveValue + 1.0) * 0.5; + + let steps = params.quantize; + let v = Math.floor(rawV * steps) / (steps - 1); + v = Math.max(0.0, Math.min(1.0, v)); + + display.setValue(x, y, v); + } + } +} + +// ── Made with Patternflow Pattern Lab · https://patternflow.work/pattern-lab ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/pattern-0710.ts b/web/src/lib/presets/pattern-0710.ts new file mode 100644 index 0000000..bee400e --- /dev/null +++ b/web/src/lib/presets/pattern-0710.ts @@ -0,0 +1,89 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0710", + num: 710, + name: "0710", + desc: "Phase delayed, time-quantized motion tile waves", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-07-10", + lineage: "AI generated and curated via Pattern Lab", + labOnly: true, + code: `// ===== Patternflow pattern ===== +// Title: 260710 +// Author: Seunghun LEE +// Date: 2026-07-10 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// @knobs Quantize=1..12, Speed=0.1..10, PhaseShift=0..5, Sharpness=1..10 +// +// Knob 1: Quantize - The stepping threshold of the time updates +// Knob 2: Speed - Master flow rate of the pattern clock +// Knob 3: PhaseShift - Geometric lag spreading outwards from the center +// Knob 4: Sharpness - Value profile modulation between smooth ramp and hard step + +export function setup(params) { + params.timeAcc = 0; +} + +export function update(dt, input, params) { + let v = input.knobValues; + params.quantize = v[0]; + params.speed = v[1]; + params.phaseShift = v[2]; + params.sharpness = v[3]; + params.timeAcc += dt * params.speed; +} + +export function draw(display, params, time) { + let w = display.width; + let h = display.height; + + let tileSize = 8; + let cx = w / 2; + let cy = h / 2; + + for (let y = 0; y < h; y++) { + let gridY = Math.floor(y / tileSize); + for (let x = 0; x < w; x++) { + let gridX = Math.floor(x / tileSize); + + // Core coordinate metrics based on tile centers + let tx = gridX * tileSize + tileSize / 2; + let ty = gridY * tileSize + tileSize / 2; + let dx = tx - cx; + let dy = ty - cy; + let dist = Math.sqrt(dx * dx + dy * dy); + + // Phase delayed, time-quantized motion pipeline + let localTime = params.timeAcc - dist * params.phaseShift * 0.08; + if (params.quantize > 1.0) { + let step = 1.0 / params.quantize; + localTime = Math.floor(localTime / step) * step; + } + + // Generate concentric tile waves derived from custom time + let wave = Math.sin(dist * 0.25 - localTime * 3.0); + let val = (wave + 1) * 0.5; + + // Apply high-contrast sharpening curves via power scaling + val = Math.pow(val, params.sharpness); + + // Add tile frame borders to retain the structural matrix grid + if (x % tileSize === 0 || y % tileSize === 0) { + val = 0.0; + } + + display.setValue(x, y, Math.max(0.0, Math.min(1.0, val))); + } + } +} + +// ── Made with Patternflow Pattern Lab · https://patternflow.work/pattern-lab ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/pattern-0712-2.ts b/web/src/lib/presets/pattern-0712-2.ts new file mode 100644 index 0000000..f706ef3 --- /dev/null +++ b/web/src/lib/presets/pattern-0712-2.ts @@ -0,0 +1,379 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0712-2", + num: 712.02, + name: "0712-2", + desc: "Breakout arcade game with paddle and balls", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-07-12", + lineage: "AI generated and curated via Pattern Lab", + labOnly: true, + code: `// ===== Patternflow pattern ===== +// Title: 260713_Breakout Arcade +// Author: Seunghun LEE +// Date: 2026-07-12 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// Pattern: Breakout Arcade v2 (Wide Paddle + Item-Hungry AI) +// 세로(64x128) — 노브1 수동 조작 / 5초 방치 시 AI 복귀, AI가 아이템도 판단해서 캐치 +// 벽돌 8줄 고정, 아이템(멀티볼/와이드/파이어볼) + 글로우 버퍼 +// @knobs Paddle=2..62, Speed=25..90, Luck=0..1, Glow=0..1 +// +// Knob 1 (Paddle): 패들 위치 직결 (돌리면 수동, 5초 방치 시 AI 복귀) +// Knob 2 (Speed): 공 속도 (px/s) +// Knob 3 (Luck): 아이템 드랍 확률 +// Knob 4 (Glow): 잔광 유지 시간 +// Button 1: 벽돌 리셋 / Button 2: 멀티볼 / Button 3: 와이드 / Button 4: 파이어볼 + +function rnd(params) { + params.rng = (params.rng * 48271) % 2147483647; + return params.rng / 2147483647; +} + +const ROWS = 8; +const PAD_W = 10; // 기본 패들 폭 (기존 7의 1.5배) +const PAD_W_WIDE = 16; // 와이드 아이템 시 +const ITEM_FALL = 20; // 아이템 낙하 속도 px/s +const AI_SPD = 115; // AI 패들 속도 px/s + +export function setup(params) { + params.vw = 64; params.vh = 128; + params.speed = 50; + params.luck = 0.4; params.glowK = 0.5; + params.rng = 12345; + + params.dead = new Uint8Array(64); // 8열 x 8줄 + params.balls = new Float32Array(32); // 8개 x (x, y, dx, dy) + params.ballOn = new Uint8Array(8); + params.items = new Float32Array(18); // 6개 x (x, y, type) + params.itemOn = new Uint8Array(6); + params.glow = new Float32Array(64 * 128); + + params.balls[0] = 32; params.balls[1] = 80; + params.balls[2] = 0.55; params.balls[3] = -0.84; + params.ballOn[0] = 1; + + params.px = 32; + params.lastKnob = -999; + params.idleT = 99; // 시작은 AI 모드 + params.wideT = 0; params.fireT = 0; + params.flash = 0; params.combo = 0; + params.aiTarget = 32; +} + +function depositGlow(params, gx, gy, amt, rad) { + const vw = params.vw, vh = params.vh; + const x0 = Math.max(0, Math.floor(gx - rad)); + const x1 = Math.min(vw - 1, Math.ceil(gx + rad)); + const y0 = Math.max(0, Math.floor(gy - rad)); + const y1 = Math.min(vh - 1, Math.ceil(gy + rad)); + for (let gy2 = y0; gy2 <= y1; gy2++) { + for (let gx2 = x0; gx2 <= x1; gx2++) { + const du = gx2 - gx, dv = gy2 - gy; + const f = 1.0 - Math.sqrt(du * du + dv * dv) / (rad + 0.001); + if (f > 0) { + const idx = gx2 + gy2 * vw; + const nv = params.glow[idx] + amt * f; + params.glow[idx] = nv > 1 ? 1 : nv; + } + } + } +} + +function multiball(params) { + for (let i = 0; i < 8; i++) { + if (!params.ballOn[i]) continue; + for (let j = 0; j < 8; j++) { + if (params.ballOn[j]) continue; + params.ballOn[j] = 1; + params.balls[j * 4] = params.balls[i * 4]; + params.balls[j * 4 + 1] = params.balls[i * 4 + 1]; + const a = 0.3 + rnd(params) * 0.4; + params.balls[j * 4 + 2] = -params.balls[i * 4 + 2] * (0.7 + a); + params.balls[j * 4 + 3] = params.balls[i * 4 + 3]; + const dx = params.balls[j * 4 + 2], dy = params.balls[j * 4 + 3]; + const len = Math.sqrt(dx * dx + dy * dy); + params.balls[j * 4 + 2] = dx / len; + params.balls[j * 4 + 3] = dy / len; + break; + } + } +} + +export function update(dt, input, params) { + let knobPad = params.px; + if (input && input.knobValues) { + knobPad = input.knobValues[0]; + params.speed = input.knobValues[1]; + params.luck = input.knobValues[2]; + params.glowK = input.knobValues[3]; + } + if (input && input.btnPressed) { + if (input.btnPressed[0]) params.dead.fill(0); + if (input.btnPressed[1]) { multiball(params); params.flash = 1.0; } + if (input.btnPressed[2]) params.wideT = 6.0; + if (input.btnPressed[3]) { params.fireT = 4.0; params.flash = 0.8; } + } + + // --- 수동/자동 판정 --- + if (params.lastKnob < -100) params.lastKnob = knobPad; + if (Math.abs(knobPad - params.lastKnob) > 0.15) params.idleT = 0; + else params.idleT += dt; + params.lastKnob = knobPad; + const manual = params.idleT < 5.0; + + const vw = params.vw, vh = params.vh; + const brickTop = 10, bw = 8, bh = 5; + + // 글로우 감쇠 + const decay = Math.exp(-dt * (8.0 - 7.0 * params.glowK)); + const glow = params.glow; + for (let i = 0; i < glow.length; i++) glow[i] *= decay; + + params.wideT = Math.max(0, params.wideT - dt); + params.fireT = Math.max(0, params.fireT - dt); + const padW = params.wideT > 0 ? PAD_W_WIDE : PAD_W; + const fire = params.fireT > 0; + const padV = vh - 7; + + // --- 패들 이동 (충돌 판정보다 먼저) --- + if (manual) { + params.px = knobPad; + } else { + const diff = params.aiTarget - params.px; + const step = Math.min(Math.abs(diff), AI_SPD * dt); + params.px += Math.sign(diff) * step; + } + params.px = Math.max(padW * 0.5, Math.min(vw - padW * 0.5, params.px)); + + // --- 공 시뮬레이션 + 위협 공 ETA 계산 --- + let aliveBalls = 0; + let ballTX = -1, ballETA = 1e9; + + for (let i = 0; i < 8; i++) { + if (!params.ballOn[i]) continue; + const o = i * 4; + params.balls[o] += params.balls[o + 2] * params.speed * dt; + params.balls[o + 1] += params.balls[o + 3] * params.speed * dt; + let bx = params.balls[o], by = params.balls[o + 1]; + + if (bx < 2) { params.balls[o] = bx = 2; params.balls[o + 2] = Math.abs(params.balls[o + 2]); } + if (bx > vw - 2) { params.balls[o] = bx = vw - 2; params.balls[o + 2] = -Math.abs(params.balls[o + 2]); } + if (by < 2) { params.balls[o + 1] = by = 2; params.balls[o + 3] = Math.abs(params.balls[o + 3]); } + + // 벽돌 충돌 + if (by >= brickTop && by < brickTop + ROWS * bh) { + const col = Math.min(7, Math.max(0, Math.floor(bx / bw))); + const row = Math.floor((by - brickTop) / bh); + const idx = row * 8 + col; + if (!params.dead[idx]) { + params.dead[idx] = 1; + if (!fire) params.balls[o + 3] = -params.balls[o + 3]; + params.combo++; + params.flash = Math.min(1, 0.25 + params.combo * 0.1); + depositGlow(params, col * bw + bw * 0.5, brickTop + row * bh + bh * 0.5, + 0.9, fire ? 5 : 3); + + if (rnd(params) < params.luck * 0.4) { + for (let s = 0; s < 6; s++) { + if (params.itemOn[s]) continue; + params.itemOn[s] = 1; + params.items[s * 3] = col * bw + bw * 0.5; + params.items[s * 3 + 1] = brickTop + row * bh; + params.items[s * 3 + 2] = Math.floor(rnd(params) * 3); + break; + } + } + } + } + + // 패들 반사 + if (by >= padV - 1 && by <= padV + 2 && params.balls[o + 3] > 0 + && Math.abs(bx - params.px) < padW * 0.5 + 1) { + params.balls[o + 3] = -Math.abs(params.balls[o + 3]); + params.balls[o + 2] += (bx - params.px) / (padW * 0.5) * 0.7; + let dx = params.balls[o + 2], dy = params.balls[o + 3]; + let len = Math.sqrt(dx * dx + dy * dy); + dx /= len; dy /= len; + if (dy > -0.35) dy = -0.35; + len = Math.sqrt(dx * dx + dy * dy); + params.balls[o + 2] = dx / len; + params.balls[o + 3] = dy / len; + params.combo = 0; + } + + if (by > vh + 4) { params.ballOn[i] = 0; continue; } + + aliveBalls++; + + // 하강 중인 공의 패들 도착 시간 (가장 급한 공 기록) + if (params.balls[o + 3] > 0) { + const eta = (padV - by) / (params.balls[o + 3] * params.speed); + if (eta >= 0 && eta < ballETA) { ballETA = eta; ballTX = bx; } + } + + depositGlow(params, bx, by, fire ? 0.5 : 0.3, fire ? 2.5 : 1.5); + } + + // 전멸 → 리스폰 + if (aliveBalls === 0) { + params.ballOn[0] = 1; + params.balls[0] = vw * 0.5; params.balls[1] = vh * 0.55; + params.balls[2] = rnd(params) > 0.5 ? 0.55 : -0.55; + params.balls[3] = -0.84; + params.flash = 1.0; params.combo = 0; + } + + // 벽돌 전멸 → 리필 + let brickAlive = 0; + for (let r = 0; r < ROWS; r++) + for (let c = 0; c < 8; c++) + if (!params.dead[r * 8 + c]) brickAlive++; + if (brickAlive === 0) { + params.dead.fill(0); + params.flash = 1.0; + depositGlow(params, vw * 0.5, vh * 0.3, 1.0, 14); + } + + // --- 아이템 낙하 & 캐치 + 아이템 ETA 계산 --- + let itemTX = -1, itemETA = 1e9; + for (let s = 0; s < 6; s++) { + if (!params.itemOn[s]) continue; + params.items[s * 3 + 1] += ITEM_FALL * dt; + const ix = params.items[s * 3], iy = params.items[s * 3 + 1]; + + if (iy >= padV - 1 && iy <= padV + 3 && Math.abs(ix - params.px) < padW * 0.5 + 1.5) { + const ty = params.items[s * 3 + 2]; + if (ty === 0) multiball(params); + else if (ty === 1) params.wideT = 6.0; + else params.fireT = 4.0; + params.flash = 1.0; + depositGlow(params, ix, padV, 1.0, 6); + params.itemOn[s] = 0; + continue; + } + if (iy > vh + 3) { params.itemOn[s] = 0; continue; } + + // AI용: 가장 먼저 떨어질 아이템 + const eta = (padV - iy) / ITEM_FALL; + if (eta >= 0 && eta < itemETA) { itemETA = eta; itemTX = ix; } + } + + // --- AI 타겟 결정: ETA 우선순위 --- + // 1) 위협 공이 없으면 아이템으로 (없으면 중앙 대기) + // 2) 아이템이 공보다 0.35초 이상 먼저 도착하고, 거기까지 갈 시간이 되면 아이템 먼저 + // 3) 그 외엔 공 수비 + if (ballTX < 0) { + params.aiTarget = itemTX >= 0 ? itemTX : vw * 0.5; + } else if (itemTX >= 0) { + const travelT = Math.abs(itemTX - params.px) / AI_SPD; + if (itemETA + 0.35 < ballETA && travelT < itemETA + 0.2) { + params.aiTarget = itemTX; + } else { + params.aiTarget = ballTX; + } + } else { + params.aiTarget = ballTX; + } + + params.flash = Math.max(0, params.flash - dt * 2.5); + params.padW = padW; + params.fire = fire ? 1 : 0; + params.manual = manual ? 1 : 0; + params.t = (params.t || 0) + dt; +} + +export function draw(display, params, time) { + const W = display.width, H = display.height; + const FLIP = 0; + const portrait = W < H; + const vw = params.vw, vh = params.vh; + const brickTop = 10, bw = 8, bh = 5; + const padV = vh - 7; + const t = params.t || 0; + + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + let u, v; + if (portrait) { u = x; v = y; } + else if (FLIP) { u = H - 1 - y; v = x; } + else { u = y; v = W - 1 - x; } + + let val = 0.03 + 0.02 * (v % 2) + params.flash * 0.15; + + if (u < 1 || u > vw - 2) val = 0.3; + + // 벽돌 8줄 + if (v >= brickTop && v < brickTop + ROWS * bh) { + const col = Math.floor(u / bw); + const row = Math.floor((v - brickTop) / bh); + if (col >= 0 && col < 8 && !params.dead[row * 8 + col]) { + const lu = u - col * bw; + const lv = (v - brickTop) - row * bh; + const border = (lu === 0 || lu === bw - 1 || lv === 0 || lv === bh - 1); + val = border ? 0.30 : 0.80 - row * 0.05; + if (params.fire) val += 0.12 * Math.sin(t * 10 + col * 2 + row); + } + } + + // 패들 + if (v >= padV && v <= padV + 1 && Math.abs(u - params.px) < params.padW * 0.5) { + val = 0.9; + if (params.padW > PAD_W + 1 && Math.abs(u - params.px) > params.padW * 0.5 - 2) { + val = 0.6 + 0.4 * Math.sin(t * 12); + } + if (params.manual && Math.abs(u - params.px) < 1 && v === padV) { + val = 0.7 + 0.3 * Math.sin(t * 8); + } + } + + val += params.glow[u + v * vw] * 0.85; + + display.setValue(x, y, Math.max(0, Math.min(1, val))); + } + } + + const plot = (pu, pv, pval) => { + let px, py; + pu = Math.round(pu); pv = Math.round(pv); + if (portrait) { px = pu; py = pv; } + else if (FLIP) { px = pv; py = H - 1 - pu; } + else { px = W - 1 - pv; py = pu; } + if (px >= 0 && px < W && py >= 0 && py < H) display.setValue(px, py, pval); + }; + + // 아이템 글리프 + for (let s = 0; s < 6; s++) { + if (!params.itemOn[s]) continue; + const ix = params.items[s * 3], iy = params.items[s * 3 + 1]; + const ty = params.items[s * 3 + 2]; + const blink = 0.6 + 0.4 * Math.sin(t * (6 + ty * 4) + s); + plot(ix, iy, 1.0); + plot(ix - 1, iy, blink); plot(ix + 1, iy, blink); + plot(ix, iy - 1, blink); plot(ix, iy + 1, blink); + if (ty === 0) { plot(ix - 2, iy, blink * 0.5); plot(ix + 2, iy, blink * 0.5); } + } + + // 공 + for (let i = 0; i < 8; i++) { + if (!params.ballOn[i]) continue; + const bx = params.balls[i * 4], by = params.balls[i * 4 + 1]; + plot(bx, by, 1.0); + plot(bx - 1, by, 0.8); plot(bx + 1, by, 0.8); + plot(bx, by - 1, 0.8); plot(bx, by + 1, 0.8); + if (params.fire) { + plot(bx - 1, by - 1, 0.6); plot(bx + 1, by - 1, 0.6); + plot(bx - 1, by + 1, 0.6); plot(bx + 1, by + 1, 0.6); + } + } +} + +// ── Made with Patternflow Pattern Lab · https://patternflow.work/pattern-lab ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/pattern-0712.ts b/web/src/lib/presets/pattern-0712.ts new file mode 100644 index 0000000..82f6d35 --- /dev/null +++ b/web/src/lib/presets/pattern-0712.ts @@ -0,0 +1,138 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0712", + num: 712, + name: "0712", + desc: "Emergent waves resembling a midsummer sea", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-07-12", + lineage: "AI generated and curated via Pattern Lab", + labOnly: true, + code: `// ===== Patternflow pattern ===== +// Title: 260712_Midsummer Sea +// Author: Seunghun LEE +// Date: 2026-07-12 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// Pattern: Midsummer Sea (Vertical Pixel Seascape) +// 세로(64x128) 구성 — 하늘/태양/원근 파도/윤슬/해변 포말 레이어 +// @knobs Waves=0..1, Speed=0.1..3, Sun=0..1, Glitter=0..1 +// +// Knob 1 (Waves): 파도 진폭 + 해변에 밀려오는 물의 세기 +// Knob 2 (Speed): 전체 시간 흐름 속도 +// Knob 3 (Sun): 태양 고도 (0=수평선 노을, 1=한낮) +// Knob 4 (Glitter): 윤슬(반짝임) 밀도 + +function hash(n) { + let s = Math.sin(n) * 43758.5453123; + return s - Math.floor(s); +} + +export function setup(params) { + params.waves = 0.5; + params.speed = 1.0; + params.sun = 0.7; + params.glit = 0.5; + params.t = 0; +} + +export function update(dt, input, params) { + if (input && input.knobValues) { + params.waves = input.knobValues[0]; + params.speed = input.knobValues[1]; + params.sun = input.knobValues[2]; + params.glit = input.knobValues[3]; + } + params.t += dt * params.speed; +} + +export function draw(display, params, time) { + const W = display.width, H = display.height; + const FLIP = 0; // 패널 장착 방향 반대면 1 + const portrait = W < H; + const vw = portrait ? W : H; + const vh = portrait ? H : W; + + const t = params.t; + const amp = params.waves; + const horizon = Math.floor(vh * 0.34); + const beachTop = vh - Math.floor(vh * 0.14); + const sunU = vw * 0.5; + const sunV = horizon - 3 - params.sun * (horizon - 8); + + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + let u, v; + if (portrait) { u = x; v = y; } + else if (FLIP) { u = H - 1 - y; v = x; } + else { u = y; v = W - 1 - x; } + + let val; + + if (v < horizon) { + // --- 하늘: 수평선 쪽이 밝은 헤이즈 그라디언트 --- + const g = v / horizon; + val = 0.12 + 0.38 * g; + + // 태양 디스크 + 글로우 + const du = u - sunU, dv = (v - sunV) * 1.2; + const d = Math.sqrt(du * du + dv * dv); + if (d < 4.5) val = 1.0; + else val += 0.7 * Math.exp(-d * 0.18); + + // 얇은 구름 밴드 (느리게 흐름) + const cl = Math.sin(u * 0.11 + v * 0.9 + t * 0.25) + + Math.sin(u * 0.05 - v * 0.5 + 2.0); + if (cl > 1.2 && v < horizon * 0.8) val += 0.12; + + } else if (v < beachTop) { + // --- 바다: 원근 파도 밴드 --- + const depth = (v - horizon) / (beachTop - horizon); // 0=수평선, 1=해변 앞 + const persp = 1.0 / (depth + 0.09); + const wob = Math.sin(u * 0.25 + t * 1.3) * 0.5 * amp; + const band = Math.sin(persp * 2.6 + wob + t * (1.5 + depth * 2.0)); + val = 0.42 - 0.22 * depth + + band * (0.06 + 0.14 * amp) * (0.4 + depth); + + // 태양 반사 기둥 (윤슬 길, 살짝 흔들림) + const pathW = 2.5 + depth * 9.0; + const sway = Math.sin(v * 0.5 + t) * amp * 2.0; + const inPath = Math.abs(u - sunU + sway) < pathW; + if (inPath) val += 0.12 + 0.1 * (1.0 - depth); + + // 윤슬 스파클 (파도 마루에서만 터짐) + const sp = hash(u * 7.3 + v * 13.1 + Math.floor(t * 7.0) * 17.7); + const thr = 1.0 - params.glit * (inPath ? 0.10 : 0.03); + if (sp > thr && band > 0.2) val = 1.0; + + } else { + // --- 해변: 모래 질감 + 밀려오는 포말 라인 --- + const s = (v - beachTop) / (vh - beachTop); + val = 0.5 + 0.15 * s + 0.06 * hash(u * 3.7 + v * 5.1); + + const surge = Math.sin(t * 1.8) * 0.5 + 0.5; // 밀물/썰물 호흡 + const edge = beachTop + + 2 + surge * (vh - beachTop) * 0.55 * (0.4 + amp) + + Math.sin(u * 0.35 + t * 2.2) * 2.5 * amp; + + if (v < edge) { + // 얕은 물 (모래보다 어둡게) + val = 0.34 - 0.1 * (edge - v) / (vh - beachTop); + if (edge - v < 1.5) val = 0.95; // 포말 라인 + } + } + + display.setValue(x, y, Math.max(0, Math.min(1, val))); + } + } +} + +// ── Made with Patternflow Pattern Lab · https://patternflow.work/pattern-lab ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/pattern-0713.ts b/web/src/lib/presets/pattern-0713.ts new file mode 100644 index 0000000..cd2584d --- /dev/null +++ b/web/src/lib/presets/pattern-0713.ts @@ -0,0 +1,163 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0713", + num: 713, + name: "0713", + desc: "Particles wandering like fireflies in a dark space", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-07-13", + lineage: "AI generated and curated via Pattern Lab", + labOnly: true, + code: `// ===== Patternflow pattern ===== +// Title: 260713_Firefly +// Author: Seunghun LEE +// Date: 2026-07-13 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// Pattern: Firefly Hollow (Summer Night Valley) +// 세로(64x128) 구성 — 달/fBM 구름 / 언덕 실루엣 / 흔들리는 풀숲 / 점멸 반딧불이 +// @knobs Flies=2..14, Speed=0.2..3, Glow=1.5..6, Wind=0..1 +// +// Knob 1 (Flies): 반딧불이 개체 수 +// Knob 2 (Speed): 유영/점멸 속도 +// Knob 3 (Glow): 발광 반경 (빛번짐 크기) +// Knob 4 (Wind): 풀숲 흔들림 세기 (+구름 드리프트 가속) + +function hash(n) { + let s = Math.sin(n) * 43758.5453123; + return s - Math.floor(s); +} + +// 2D 밸류 노이즈: 정수 격자 해시 + 코사인 보간 +function noise2(x, y) { + const xi = Math.floor(x), yi = Math.floor(y); + const xf = x - xi, yf = y - yi; + const sx = (1 - Math.cos(xf * Math.PI)) * 0.5; + const sy = (1 - Math.cos(yf * Math.PI)) * 0.5; + const a = hash(xi * 12.9898 + yi * 78.233); + const b = hash((xi + 1) * 12.9898 + yi * 78.233); + const c = hash(xi * 12.9898 + (yi + 1) * 78.233); + const d = hash((xi + 1) * 12.9898 + (yi + 1) * 78.233); + const ab = a + (b - a) * sx; + const cd = c + (d - c) * sx; + return ab + (cd - ab) * sy; +} + +export function setup(params) { + params.flies = 8; + params.speed = 1.0; + params.glow = 3.0; + params.wind = 0.4; + params.t = 0; + // 반딧불이 좌표/밝기 버퍼 (정규화 0..1) — 프레임당 할당 방지 + params.fu = new Float32Array(16); + params.fv = new Float32Array(16); + params.fb = new Float32Array(16); +} + +export function update(dt, input, params) { + if (input && input.knobValues) { + params.flies = Math.round(input.knobValues[0]); + params.speed = input.knobValues[1]; + params.glow = input.knobValues[2]; + params.wind = input.knobValues[3]; + } + params.t += dt * params.speed; + + const t = params.t; + const n = Math.min(16, params.flies); + for (let i = 0; i < n; i++) { + const s1 = 0.13 + 0.11 * hash(i * 7.1); + const s2 = 0.09 + 0.13 * hash(i * 13.7); + // 리사주 유영 (아래쪽 2/3 영역에서) + params.fu[i] = 0.5 + 0.44 * Math.sin(t * s1 * 2.0 + i * 2.39); + params.fv[i] = 0.62 + 0.30 * Math.sin(t * s2 * 2.0 + i * 5.17) + + 0.05 * Math.sin(t * 0.9 + i); + // 숨쉬듯 점멸 (개체마다 위상/주기 다름) + const p = Math.sin(t * (0.8 + 0.5 * hash(i * 3.3)) + i * 1.7); + params.fb[i] = Math.max(0, p) ** 3; + } +} + +export function draw(display, params, time) { + const W = display.width, H = display.height; + const FLIP = 0; + const portrait = W < H; + const vw = portrait ? W : H; + const vh = portrait ? H : W; + + const t = params.t; + const n = Math.min(16, params.flies); + const g2 = params.glow * params.glow; + const cloudDrift = t * (0.4 + params.wind * 0.8); + + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + let u, v; + if (portrait) { u = x; v = y; } + else if (FLIP) { u = H - 1 - y; v = x; } + else { u = y; v = W - 1 - x; } + + // --- 하늘: 위가 짙고 아래로 은은한 지평 헤이즈 --- + const g = v / vh; + let val = 0.05 + 0.14 * g; + + + // --- 구름: 2옥타브 fBM 밸류 노이즈, 덩어리 형태 --- + if (v < vh * 0.5) { + // 옥타브별로 다른 속도로 흘러 → 이동하며 형태가 변형됨 + let cn = noise2(u * 0.045 + cloudDrift * 0.12, + v * 0.09 + cloudDrift * 0.015) * 0.65 + + noise2(u * 0.11 - cloudDrift * 0.07, + v * 0.22 + 40.0) * 0.35; + // 높이 감쇠: 위쪽 하늘에 몰리고 아래로 갈수록 옅어짐 + cn *= 1.0 - (v / (vh * 0.5)) * 0.7; + // soft threshold: 가장자리는 옅은 안개, 중심은 진한 덩어리 + const cd = (cn - 0.38) / 0.18; + if (cd > 0) { + const soft = cd > 1 ? 1 : cd * cd * (3 - 2 * cd); // smoothstep + const lit = 1.0 + 0.5; + val += soft * 0.13 * lit; + } + } + + // --- 먼 언덕 실루엣 --- + const hillTop = vh * 0.58 + + Math.sin(u * 0.09 + 2.0) * 5 + + Math.sin(u * 0.023) * 8; + if (v >= hillTop) val = 0.07; + + // --- 풀숲 (앞쪽, 바람에 출렁이는 실루엣) --- + const sway = Math.sin(u * 0.3 + t * 1.8) * params.wind * 4 + + Math.sin(u * 0.9 - t * 2.6) * params.wind * 2; + const gh = 10 + hash(Math.floor(u / 2) * 5.3) * 16; + const grassTop = vh - gh + sway; + if (v >= grassTop) { + val = 0.015; + if (v - grassTop < 1.2) val = 0.11; // 달빛 받은 풀끝 + } + + // --- 반딧불이 (가산 발광, 풀 위에도 비침) --- + for (let i = 0; i < n; i++) { + const du = u - params.fu[i] * vw; + const dv = v - params.fv[i] * vh; + const dd = du * du + dv * dv; + if (dd < g2 * 9) { + val += params.fb[i] * Math.exp(-dd / g2); + } + } + + display.setValue(x, y, Math.max(0, Math.min(1, val))); + } + } +} + +// ── Made with Patternflow Pattern Lab · https://patternflow.work/pattern-lab ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/pattern-0715.ts b/web/src/lib/presets/pattern-0715.ts new file mode 100644 index 0000000..8179750 --- /dev/null +++ b/web/src/lib/presets/pattern-0715.ts @@ -0,0 +1,99 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0715", + num: 715, + name: "0715", + desc: "Poincaré sphere projection visualization", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-07-15", + lineage: "AI generated and curated via Pattern Lab", + code: `// ===== Patternflow pattern ===== +// Title: Poincaré Sphere +// Author: Seunghun LEE +// Date: 2026-07-15 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// Pattern: Poincaré Sphere +// Author: Collaborator +// SPDX-License-Identifier: CC-BY-SA-4.0 +// +// Knob 1: 위상 변화 컬러톤 (0.0 to 1.0) +// Knob 2: 구면 자전 속도 (0.1 to 10.0) +// Knob 3: 구면 위선/경선 조밀도 (0.0 to 4.9) +// Knob 4: 정사영 왜곡 곡률 (0.0 to 1.0) + +export function setup(params) { + params.hue = 0.4; + params.speed = 2.0; + params.density = 2.5; + params.curve = 0.5; + params.time = 0.0; +} + +export function update(dt, input, params) { + if (input && input.knobValues) { + params.hue = input.knobValues[0]; + params.speed = input.knobValues[1]; + params.density = input.knobValues[2]; + params.curve = input.knobValues[3]; + } + params.time += dt * params.speed; +} + +export function draw(display, params, time) { + let w = display.width; + let h = display.height; + let t = params.time; + + let cx = w / 2; + let cy = h / 2; + + let gridCount = 2.0 + params.density * 3.0; // 격자 조밀도 + let curvature = 0.1 + params.curve * 2.0; + + for (let y = 0; y < h; y++) { + let dy = (y - cy) / cy; // -1.0 to 1.0 + for (let x = 0; x < w; x++) { + let dx = (x - cx) / cy; // -w/h to w/h + + let r2 = dx * dx + dy * dy; + + // 3D 구면 정사영 왜곡 인자 + let projectionScale = 1.0 / (1.0 + r2 * curvature); + let sphereX = dx * projectionScale; + let sphereY = dy * projectionScale; + + // 왜곡 좌표계 기반의 평형 가상 웨이브 + let waveU = Math.sin(sphereX * gridCount * 6.28 + t); + let waveV = Math.sin(sphereY * gridCount * 6.28 - t * 0.7); + + // 격자 선 검출 + let line = Math.abs(waveU) * Math.abs(waveV); + let r = 0, g = 0, b = 0; + + if (line < 0.15) { + let intensity = (1.0 - line / 0.15); + + r = Math.floor(intensity * 128 * params.hue); + g = Math.floor(intensity * 255 * (1.0 - params.hue)); + b = Math.floor(intensity * 255); + + if (line < 0.03) { + r = 255; g = 255; b = 255; + } + } + + display.setPixel(x, y, r, g, b); + } + } +} + +// ── Made with Patternflow Pattern Lab · https://patternflow.work/pattern-lab ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/pattern-0716.ts b/web/src/lib/presets/pattern-0716.ts new file mode 100644 index 0000000..dce327f --- /dev/null +++ b/web/src/lib/presets/pattern-0716.ts @@ -0,0 +1,86 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0716", + num: 716, + name: "0716", + desc: "Triangular marching distance field rendering", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-07-16", + lineage: "AI generated and curated via Pattern Lab", + code: `// ===== Patternflow pattern ===== +// Title: 260716_TriMarch +// Author: Seunghun LEE +// Date: 2026-07-16 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// Knob 1: Triangle size · Knob 2: Speed · Knob 3: March angle · Knob 4: Color palette +function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; } + +export function setup(p) { + p.tsize = 0.5; p.speed = 1.5; p.angle = 0.5; p.palette = 0.3; p.t = 0; +} + +export function update(dt, input, p) { + if (input && input.knobValues) { + let v = input.knobValues; + p.tsize = v[0]; p.speed = v[1]; p.angle = v[2]; p.palette = v[3]; + } + p.t += dt * p.speed; +} + +export function draw(d, p, time) { + let w = d.width, h = d.height, t = p.t; + let triH = 6 + p.tsize * 25; + let triW = triH * 0.866; + let rows = Math.ceil(h / triH); + let cols = Math.ceil(w / triW); + + let moveX = Math.cos(p.angle * Math.PI) * t * 10; + let moveY = Math.sin(p.angle * Math.PI) * t * 10; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + let fx = (x - moveX) / triW; + let fy = (y - moveY) / triH; + let row = Math.floor(fy); + let col = Math.floor(fx); + let lx = fx - col - 0.5; + let ly = fy - row - 0.5; + let flip = (col + row) % 2; + + let inTri = false; + if (flip === 0) { + inTri = (ly < 0.3 && Math.abs(lx) * 1.8 + ly < 0.45); + } else { + inTri = (ly > -0.3 && Math.abs(lx) * 1.8 - ly < 0.45); + } + + if (inTri) { + let seed = col * 11 + row * 17; + let brightness = Math.sin(seed * 0.5 + t * 2) * 0.4 + 0.6; + let hue = (seed * 0.03 * p.palette + brightness * 0.2 + t * 0.05) % 1; + let r2, g, b; + let h6 = (hue * 6) % 6, c = brightness * 255, xc = c * (1 - Math.abs(h6 % 2 - 1)); + if (h6 < 1) { r2 = c; g = xc; b = 0; } + else if (h6 < 2) { r2 = xc; g = c; b = 0; } + else if (h6 < 3) { r2 = 0; g = c; b = xc; } + else if (h6 < 4) { r2 = 0; g = xc; b = c; } + else if (h6 < 5) { r2 = xc; g = 0; b = c; } + else { r2 = c; g = 0; b = xc; } + d.setPixel(x, y, Math.floor(r2), Math.floor(g), Math.floor(b)); + } else { + d.setPixel(x, y, 0, 0, 0); + } + } + } +} + +// ── Made with Patternflow Live Editor · https://patternflow.work/pattern ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/pattern-0718.ts b/web/src/lib/presets/pattern-0718.ts new file mode 100644 index 0000000..1db8f5b --- /dev/null +++ b/web/src/lib/presets/pattern-0718.ts @@ -0,0 +1,109 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0718", + num: 718, + name: "0718", + desc: "Moving-center coordinate warping waves", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-07-18", + lineage: "AI generated and curated via Pattern Lab", + code: `// ===== Patternflow pattern ===== +// Title: 260718_Warped Wave +// Author: Seunghun LEE +// Date: 2026-07-18 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// Variation 4 — Warped Wave (Domain Remix) +// Full-screen concentric wave with coordinate warping — no tile grid. +// Knob 1: Hue · Knob 2: Speed · Knob 3: Warp amplitude · Knob 4: Warp frequency +function hsvToRgb(h, s, v) { + let r, g, b; + let i = Math.floor(h * 6); + let f = h * 6 - i; + let p = v * (1 - s); + let q = v * (1 - f * s); + let t = v * (1 - (1 - f) * s); + switch (((i % 6) + 6) % 6) { + case 0: r = v; g = t; b = p; break; + case 1: r = q; g = v; b = p; break; + case 2: r = p; g = v; b = t; break; + case 3: r = p; g = q; b = v; break; + case 4: r = t; g = p; b = v; break; + case 5: r = v; g = p; b = q; break; + } + return [Math.floor(r * 255), Math.floor(g * 255), Math.floor(b * 255)]; +} + +function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + +export function setup(params) { + params.hue = 0.0; + params.speed = 2.0; + params.warpAmp = 0.8; // will be set by K3 + params.warpFreq = 0.5; // K4 + params.timeAcc = 0.0; +} + +export function update(dt, input, params) { + if (input && input.knobValues) { + let v = input.knobValues; + if (!params.lastKnob) params.lastKnob = [v[0], v[1], v[2], v[3]]; + if (Math.abs(v[0] - params.lastKnob[0]) > 1e-6) params.hue = v[0]; + if (Math.abs(v[1] - params.lastKnob[1]) > 1e-6) params.speed = v[1]; + if (Math.abs(v[2] - params.lastKnob[2]) > 1e-6) params.warpAmp = v[2] * 0.25; // 0..1.225 + if (Math.abs(v[3] - params.lastKnob[3]) > 1e-6) params.warpFreq = v[3]; + params.lastKnob = [v[0], v[1], v[2], v[3]]; + } + if (input && input.btnPressed) { + if (input.btnPressed[0]) params.hue = 0.0; + if (input.btnPressed[1]) params.speed = 2.0; + if (input.btnPressed[2]) params.warpAmp = 0.2; + if (input.btnPressed[3]) params.warpFreq = 0.5; + } + params.timeAcc += dt * params.speed; +} + +export function draw(display, params, time) { + let w = display.width; + let h = display.height; + let t = params.timeAcc; + + let waveFreq = 15.0; + let warpAmp = params.warpAmp; + let warpFreqBase = 3.0 + params.warpFreq * 8.0; + + let hc = hsvToRgb(params.hue, 1.0, 1.0); + + for (let y = 0; y < h; y++) { + let ny = (y / h - 0.5) * 2; // -1..1 + for (let x = 0; x < w; x++) { + let nx = (x / w - 0.5) * 2; + // warp coordinates + let wx = nx + warpAmp * Math.sin(ny * warpFreqBase + t * 0.3); + let wy = ny + warpAmp * Math.cos(nx * warpFreqBase * 1.3 + t * 0.4); + let dist = Math.sqrt(wx * wx + wy * wy); + let wave = Math.sin(dist * waveFreq + t); + + let tt = clamp((wave * 0.8 + 1.0) * 0.5, 0.0, 1.0); + let r = 0, g = 0, b = 0; + if (tt >= 0.154) { r = 10; g = 10; b = 10; } + if (tt >= 0.556) { + r = clamp(Math.floor(hc[0] * 1.5), 0, 255); + g = clamp(Math.floor(hc[1] * 1.5), 0, 255); + b = clamp(Math.floor(hc[2] * 1.5), 0, 255); + } + if (tt >= 0.816) { r = 255; g = 255; b = 255; } + display.setPixel(x, y, r, g, b); + } + } +} + +// ── Made with Patternflow Pattern Lab · https://patternflow.work/pattern-lab ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/pattern-0719.ts b/web/src/lib/presets/pattern-0719.ts new file mode 100644 index 0000000..9c73ad0 --- /dev/null +++ b/web/src/lib/presets/pattern-0719.ts @@ -0,0 +1,175 @@ +import type { LivePreset } from "./types"; + +export const preset: LivePreset = { + id: "pattern-0719", + num: 719, + name: "0719", + desc: "Magnetic particle orbit simulation with trails", + author: "Seunghun LEE", + license: "CC-BY-SA-4.0", + date: "2026-07-19", + lineage: "AI generated and curated via Pattern Lab", + labOnly: true, + code: `// ===== Patternflow pattern ===== +// Title: 260719_MagVortex +// Author: Seunghun LEE +// Date: 2026-07-19 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// =============================== + +// @knobs TrailDecay=0.5..0.98, Velocity=0.1..10, SpinPull=0..4.9, InflowPitch=-20..20 +// Knob 1: Trail decay rate (higher = longer trails, more density) +// Knob 2: Simulation speed / iteration rate +// Knob 3: Rotational angular velocity around core +// Knob 4: Flow direction & strength (-20 = explode outward, 0 = pure orbits, +20 = suck inward) + +export function setup(params) { + params.velocity = 2.5; + params.spin = 3.0; + params.pitch = 0.0; + params.trailDecay = 0.88; + params.timeCount = 0.0; + + params.densityMap = new Float32Array(128 * 64); + + params.charges = []; + for (let i = 0; i < 48; i++) { + // Initialize based on neutral state + let r = 10 + Math.random() * 60; + let angle = Math.random() * Math.PI * 2; + let xPos = 64 + Math.cos(angle) * r; + let yPos = 32 + Math.sin(angle) * r; + + params.charges.push({ + r: r, + angle: angle, + speedProfile: 0.8 + Math.random() * 1.4, + brightness: 0.4 + Math.random() * 0.6, + prevX: xPos, + prevY: yPos + }); + } +} + +export function update(dt, input, params) { + if (input && input.knobValues) { + params.trailDecay = input.knobValues[0]; + params.velocity = input.knobValues[1]; + params.spin = input.knobValues[2]; + params.pitch = input.knobValues[3]; + } + + if (input && input.btnPressed) { + if (input.btnPressed[0]) params.trailDecay = 0.88; + if (input.btnPressed[1]) params.velocity = 2.5; + if (input.btnPressed[2]) params.spin = 3.0; + if (input.btnPressed[3]) params.pitch = 0.0; + } + + let decay = params.trailDecay; + for (let i = 0; i < 128 * 64; i++) { + params.densityMap[i] *= decay; + } + + let tStep = params.velocity * dt * 4.0; + let cx = 64, cy = 32; + + for (let i = 0; i < params.charges.length; i++) { + let c = params.charges[i]; + + // Store previous position + c.prevX = cx + Math.cos(c.angle) * c.r; + c.prevY = cy + Math.sin(c.angle) * c.r; + + // Update orbit + c.angle += (params.spin * 0.4) * c.speedProfile * tStep; + + // Flow direction: positive = inward, negative = outward + c.r -= (params.pitch * 6.0) * tStep; + + // Reset logic depends on flow direction + if (params.pitch > 0.01) { + // Inward flow: particles get sucked to center, respawn at edge + if (c.r < 3.0) { + c.r = 55 + Math.random() * 35; + c.angle = Math.random() * Math.PI * 2; + c.prevX = cx + Math.cos(c.angle) * c.r; + c.prevY = cy + Math.sin(c.angle) * c.r; + } + } else if (params.pitch < -0.01) { + // Outward flow: particles explode from center, respawn at center + if (c.r > 90.0) { + c.r = 2 + Math.random() * 5; + c.angle = Math.random() * Math.PI * 2; + c.prevX = cx + Math.cos(c.angle) * c.r; + c.prevY = cy + Math.sin(c.angle) * c.r; + } + } else { + // Neutral: orbit freely, reset if out of bounds either way + if (c.r < 3.0 || c.r > 90.0) { + c.r = 30 + Math.random() * 40; + c.angle = Math.random() * Math.PI * 2; + c.prevX = cx + Math.cos(c.angle) * c.r; + c.prevY = cy + Math.sin(c.angle) * c.r; + } + } + + // Current position + let curX = cx + Math.cos(c.angle) * c.r; + let curY = cy + Math.sin(c.angle) * c.r; + + // Interpolate trail + let dx = curX - c.prevX; + let dy = curY - c.prevY; + let dist = Math.sqrt(dx * dx + dy * dy); + let steps = Math.max(1, Math.ceil(dist)); + + for (let s = 0; s <= steps; s++) { + let frac = s / steps; + let px = Math.floor(c.prevX + dx * frac); + let py = Math.floor(c.prevY + dy * frac); + + if (px >= 0 && px < 128 && py >= 0 && py < 64) { + params.densityMap[py * 128 + px] = Math.min(1.0, + params.densityMap[py * 128 + px] + c.brightness * 0.7); + } + } + } +} + +export function draw(display, params, time) { + let w = display.width; + let h = display.height; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + let val = params.densityMap[y * 128 + x]; + + let v = 0.0; + + if (val > 0.001) { + if (val < 0.15) { + v = 0.06 + val * 0.6; + } else if (val < 0.45) { + v = 0.2 + (val - 0.15) * 1.3; + } else if (val < 0.75) { + v = 0.55 + (val - 0.45) * 1.5; + } else { + v = 0.85 + (val - 0.75) * 2.0; + } + v = Math.min(1.0, Math.max(0.0, v)); + } else { + v = 0.02; + } + + display.setValue(x, y, v); + } + } +} + +// ── Made with Patternflow Pattern Lab · https://patternflow.work/pattern-lab ── +// Shared under CC-BY-SA-4.0. Attribution is part of this licence — +// please keep this notice and the author credit above when you reuse, +// remix, or redistribute this pattern. Do not delete it. +`, +}; diff --git a/web/src/lib/presets/types.ts b/web/src/lib/presets/types.ts index ed055a7..a2826ee 100644 --- a/web/src/lib/presets/types.ts +++ b/web/src/lib/presets/types.ts @@ -26,4 +26,10 @@ export type LivePreset = { source?: string; /** Remix lineage, e.g. "remixed from @someone's Wave Saw". "original" if none. */ lineage?: string; + /** + * Made with Pattern Lab-only features (color ramp / value panel), so the + * simple /pattern preview can't render it faithfully. Excluded from the + * /pattern showcase; Pattern Lab always shows the full set. + */ + labOnly?: boolean; }; diff --git a/web/src/lib/serialUpload.ts b/web/src/lib/serialUpload.ts deleted file mode 100644 index c8c1f2e..0000000 --- a/web/src/lib/serialUpload.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Web Serial PFV uploader. - * - * Sends PFV data directly to an ESP32 running the Patternflow firmware. - * Protocol: "PFV::\n" followed by raw PFV bytes. - * - * License: MIT - */ - -export function supportsWebSerial(): boolean { - return typeof navigator !== "undefined" && "serial" in navigator; -} - -export interface UploadProgress { - phase: "connecting" | "sending" | "done" | "error"; - percent: number; - message: string; -} - -export interface DeviceFile { - name: string; - size: number; -} - -export interface DeviceInfo { - files: DeviceFile[]; - freeBytes: number; -} - -export async function uploadPfvToDevice( - pfvData: ArrayBuffer, - filename: string, - onProgress: (p: UploadProgress) => void, -): Promise { - if (!supportsWebSerial()) { - throw new Error("Web Serial is not supported. Use Chrome or Edge."); - } - - onProgress({ phase: "connecting", percent: 0, message: "Select the ESP32 COM port." }); - - const port = await requestAndOpenPort(); - let writer: WritableStreamDefaultWriter | null = null; - let reader: ReadableStreamDefaultReader | null = null; - - try { - writer = port.writable!.getWriter(); - reader = port.readable!.getReader(); - - const cmd = `PFV:${filename}:${pfvData.byteLength}\n`; - await writer.write(new TextEncoder().encode(cmd)); - - onProgress({ phase: "connecting", percent: 0, message: "Waiting for ESP32..." }); - - const ready = await waitForLine(reader, "READY:", 5000); - if (!ready) { - throw new Error("ESP32 did not respond. Flash the latest firmware, reset the board, and close other serial tools."); - } - - onProgress({ phase: "sending", percent: 0, message: "Uploading..." }); - - const chunkSize = 2048; - const data = new Uint8Array(pfvData); - let sent = 0; - - while (sent < data.length) { - const end = Math.min(sent + chunkSize, data.length); - await writer.write(data.subarray(sent, end)); - sent = end; - - const pct = Math.round((sent / data.length) * 100); - onProgress({ - phase: "sending", - percent: pct, - message: `${pct}% - ${(sent / 1024).toFixed(0)}/${(data.length / 1024).toFixed(0)} KB`, - }); - - await sleep(3); - } - - const result = await waitForLine(reader, "OK:", 15000); - if (!result) throw new Error("Upload timed out or failed."); - - onProgress({ phase: "done", percent: 100, message: "Upload complete." }); - } finally { - reader?.releaseLock(); - writer?.releaseLock(); - await closePort(port); - } -} - -export async function listDeviceFiles(): Promise { - const lines = await sendSerialCommand("PFV:LIST"); - const files: DeviceFile[] = []; - let freeBytes = 0; - - for (const line of lines) { - if (line.startsWith("FILE:")) { - const parts = line.substring(5).split(":"); - files.push({ name: parts[0], size: parseInt(parts[1]) || 0 }); - } else if (line.startsWith("FREE:")) { - freeBytes = parseInt(line.substring(5)) || 0; - } - } - return { files, freeBytes }; -} - -export async function clearDeviceFiles(): Promise { - const lines = await sendSerialCommand("PFV:CLEAR"); - const freeLine = lines.find((l) => l.startsWith("FREE:")); - return freeLine ? parseInt(freeLine.substring(5)) || 0 : 0; -} - -export async function deleteDeviceFile(filename: string): Promise { - const name = filename.startsWith("/") ? filename.substring(1) : filename; - await sendSerialCommand(`PFV:DELETE:${name}`); -} - -async function sendSerialCommand(cmd: string, timeoutMs = 5000): Promise { - if (!supportsWebSerial()) { - throw new Error("Web Serial is not supported. Use Chrome or Edge."); - } - - const port = await requestAndOpenPort(); - let writer: WritableStreamDefaultWriter | null = null; - let reader: ReadableStreamDefaultReader | null = null; - - try { - writer = port.writable!.getWriter(); - reader = port.readable!.getReader(); - - await writer.write(new TextEncoder().encode(cmd + "\n")); - - const lines = await readLinesUntil(reader, timeoutMs, (line) => - line.startsWith("FREE:") || line.startsWith("OK:"), - ); - - if (lines.length === 0) { - throw new Error("ESP32 did not respond. Flash the latest firmware, reset the board, and close other serial tools."); - } - - return lines; - } finally { - reader?.releaseLock(); - writer?.releaseLock(); - await closePort(port); - } -} - -async function requestAndOpenPort(): Promise { - let port: SerialPort; - try { - port = await navigator.serial.requestPort(); - } catch { - throw new Error("No port selected. Choose the ESP32 COM port in the browser dialog."); - } - - try { - await port.open({ baudRate: 115200 }); - } catch { - throw new Error("Could not open the serial port. Close Arduino Serial Monitor or any other app using it."); - } - - return port; -} - -async function closePort(port: SerialPort): Promise { - try { - await port.close(); - } catch { - // Ignore close errors after failed serial operations. - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function waitForLine( - reader: ReadableStreamDefaultReader, - prefix: string, - timeoutMs: number, -): Promise { - const lines = await readLinesUntil(reader, timeoutMs, (line) => line.startsWith(prefix)); - return lines.find((line) => line.startsWith(prefix)) ?? null; -} - -async function readLinesUntil( - reader: ReadableStreamDefaultReader, - timeoutMs: number, - shouldStop: (line: string) => boolean, -): Promise { - const decoder = new TextDecoder(); - const lines: string[] = []; - let buffer = ""; - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - const { value, done } = await Promise.race([ - reader.read(), - sleep(100).then(() => ({ value: undefined, done: false })), - ]); - - if (done) break; - if (value) buffer += decoder.decode(value, { stream: true }); - - const parts = buffer.split("\n"); - buffer = parts.pop() || ""; - - for (const part of parts) { - const line = part.trim(); - if (!line) continue; - if (line.startsWith("ERR:")) throw new Error(`ESP32: ${line}`); - lines.push(line); - if (shouldStop(line)) return lines; - } - } - - return lines; -} diff --git a/web/src/lib/videoDecoder.ts b/web/src/lib/videoDecoder.ts deleted file mode 100644 index b8e6501..0000000 --- a/web/src/lib/videoDecoder.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Video Frame Decoder - * - * Extracts frames from video files using WebCodecs API (primary) - * or