Skip to content

Commit 1e98856

Browse files
committed
Polish onboarding and export guidance
1 parent c5298b6 commit 1e98856

4 files changed

Lines changed: 104 additions & 14 deletions

File tree

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ PixelMelt is a desktop-first, local-only web app that turns a single image into
66

77
The entire product runs client-side. There is no backend, no auth, no database, and no paid API dependency.
88

9+
Best results come from faces, masks, flowers, logos, and other bold silhouettes with clear contrast and some negative space around the subject.
10+
911
## Highlights
1012

1113
- Upload one image and convert it into a 168x168 material map.
@@ -47,6 +49,15 @@ npm run preview
4749
npm run check
4850
```
4951

52+
## First Run In 20 Seconds
53+
54+
1. Launch the app. `Molten Echo` loads automatically.
55+
2. Click `Flood` or `Burn` to see how the same source rebuilds into a different scene.
56+
3. Drag on the stage with `Push`, then switch to `Spark` and click into the hot areas.
57+
4. Hit `Export 8s WebM` after you like the motion.
58+
59+
If the export controls are disabled, wait for the stage to finish loading and show the first live frame.
60+
5061
## Demo Flow
5162

5263
1. Launch the app. `Molten Echo` loads automatically.
@@ -57,6 +68,13 @@ npm run check
5768
6. Upload your own image with the drop zone or file picker.
5869
7. Click `Record 8s clip` to export a WebM of the live simulation.
5970

71+
## Source Image Tips
72+
73+
- High-contrast subjects read best at `168x168`.
74+
- Clear silhouettes usually produce the most dramatic melt and burn passes.
75+
- Transparent or simple backgrounds convert more cleanly than busy photos.
76+
- Portraits, icons, flowers, masks, and graphic shapes are the sweet spot.
77+
6078
## How It Works
6179

6280
### 1. Image Conversion
@@ -97,7 +115,7 @@ The canvas renderer in [`src/components/CanvasStage.tsx`](./src/components/Canva
97115

98116
### 4. Clip Export
99117

100-
PixelMelt records directly from the display canvas using `canvas.captureStream()` and `MediaRecorder`. Export is intentionally fixed to 8 seconds so the output is lightweight and easy to share.
118+
PixelMelt records directly from the display canvas using `canvas.captureStream()` and `MediaRecorder`. Export is intentionally fixed to 8 seconds so the output is lightweight and easy to share, and the downloaded file name includes the active source and preset.
101119

102120
## Project Structure
103121

docs/pixelmelt-demo.png

4.5 KB
Loading

src/components/CanvasStage.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@ function formatCompactCount(value: number): string {
3939
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
4040
}
4141

42+
function formatUiLabel(value: string): string {
43+
return value.charAt(0).toUpperCase() + value.slice(1)
44+
}
45+
46+
function slugifyFilenamePart(value: string): string {
47+
const normalized = value
48+
.toLowerCase()
49+
.trim()
50+
.replace(/[^a-z0-9]+/g, '-')
51+
.replace(/^-+|-+$/g, '')
52+
53+
return normalized || 'untitled'
54+
}
55+
4256
export const CanvasStage = forwardRef<CanvasStageHandle, CanvasStageProps>(function CanvasStage(
4357
{ controller, activeTool, activePreset, brushSize, brushIntensity, sourceLabel, paused, sceneStatus },
4458
ref,
@@ -194,7 +208,7 @@ export const CanvasStage = forwardRef<CanvasStageHandle, CanvasStageProps>(funct
194208

195209
async function recordClip(): Promise<void> {
196210
const canvas = displayCanvasRef.current
197-
if (!canvas) {
211+
if (!canvas || sceneStatus !== 'ready' || !latestPayloadRef.current) {
198212
return
199213
}
200214

@@ -229,7 +243,8 @@ export const CanvasStage = forwardRef<CanvasStageHandle, CanvasStageProps>(funct
229243
})
230244

231245
const timestamp = new Date().toISOString().replaceAll(':', '-')
232-
downloadBlob(blob, `pixelmelt-${activePreset}-${timestamp}.webm`)
246+
const sourceSlug = slugifyFilenamePart(sourceLabel)
247+
downloadBlob(blob, `pixelmelt-${sourceSlug}-${activePreset}-${timestamp}.webm`)
233248

234249
usePixelMeltStore.getState().setRecording({
235250
status: 'done',
@@ -259,6 +274,7 @@ export const CanvasStage = forwardRef<CanvasStageHandle, CanvasStageProps>(funct
259274

260275
const metrics = hudPayload?.metrics
261276
const hasFrame = Boolean(hudPayload)
277+
const exportDisabled = sceneStatus !== 'ready' || !hasFrame || recording.status === 'recording' || recording.status === 'saving'
262278

263279
return (
264280
<section className="pm-panel relative flex min-h-[calc(100vh-3rem)] flex-1 flex-col overflow-hidden rounded-[32px]">
@@ -268,19 +284,39 @@ export const CanvasStage = forwardRef<CanvasStageHandle, CanvasStageProps>(funct
268284
<div className="font-mono text-[0.72rem] uppercase tracking-[0.24em] text-white/45">Stage</div>
269285
<div className="mt-1 text-lg font-semibold text-white">{sourceLabel}</div>
270286
<div className="text-sm text-[var(--pm-text-muted)]">
271-
{activePreset} preset{activeTool} tool{paused ? 'paused' : 'live worker sim'}
287+
{formatUiLabel(activePreset)} scene{formatUiLabel(activeTool)} brush{paused ? 'paused' : 'live worker sim'}
272288
</div>
273289
</div>
274290
<button
275291
type="button"
276292
onClick={() => void recordClip()}
277-
className="rounded-full border border-[var(--pm-warm)] bg-[rgba(255,148,71,0.12)] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[rgba(255,148,71,0.2)]"
293+
disabled={exportDisabled}
294+
className={cn(
295+
'rounded-full border px-4 py-2 text-sm font-semibold transition',
296+
exportDisabled
297+
? 'cursor-not-allowed border-white/10 bg-white/[0.03] text-white/45'
298+
: 'border-[var(--pm-warm)] bg-[rgba(255,148,71,0.12)] text-white hover:bg-[rgba(255,148,71,0.2)]',
299+
)}
278300
>
279301
Export 8s WebM
280302
</button>
281303
</div>
282304

283305
<div className="relative flex flex-1 items-center justify-center px-6 py-6">
306+
{sceneStatus === 'ready' && (
307+
<div className="pointer-events-none absolute left-6 top-6 hidden max-w-sm xl:block">
308+
<div className="rounded-[24px] border border-white/8 bg-[rgba(7,11,19,0.72)] px-4 py-3 shadow-[0_18px_40px_rgba(0,0,0,0.22)] backdrop-blur">
309+
<div className="font-mono text-[0.68rem] uppercase tracking-[0.24em] text-white/40">Quick pass</div>
310+
<p className="mt-2 text-sm leading-6 text-white/85">
311+
Pick a bold source, rebuild with a preset, then drag with Push or click with Spark before exporting the exact frame state you like.
312+
</p>
313+
<p className="mt-2 text-xs leading-5 text-[var(--pm-text-muted)]">
314+
Best with faces, masks, flowers, logos, and silhouettes that have clear contrast and some empty space around them.
315+
</p>
316+
</div>
317+
</div>
318+
)}
319+
284320
<div className="relative flex aspect-square w-full max-w-[880px] items-center justify-center rounded-[30px] border border-white/10 bg-[rgba(2,5,11,0.72)] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
285321
<canvas
286322
ref={displayCanvasRef}

src/components/ControlPanel.tsx

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export function ControlPanel({
5151
}: ControlPanelProps) {
5252
const inputRef = useRef<HTMLInputElement>(null)
5353
const [isDragging, setIsDragging] = useState(false)
54+
const activeDemo = demos.find((demo) => demo.id === selectedDemoId) ?? null
55+
const usingCustomSource = activeDemo === null
5456

5557
const recordLabel =
5658
recording.status === 'recording'
@@ -60,6 +62,8 @@ export function ControlPanel({
6062
: 'Record 8s clip'
6163

6264
const interactionDisabled = sceneStatus === 'booting' || sceneStatus === 'loading'
65+
const runControlsDisabled = sceneStatus !== 'ready'
66+
const recordDisabled = runControlsDisabled || recording.status === 'recording' || recording.status === 'saving'
6367

6468
return (
6569
<aside className="pm-panel pm-scrollbar flex h-full min-h-[calc(100vh-3rem)] flex-col overflow-y-auto rounded-[28px] p-6 text-sm text-white/90">
@@ -80,13 +84,22 @@ export function ControlPanel({
8084
<div className="flex items-center justify-between">
8185
<div>
8286
<h2 className="text-base font-semibold text-white">Input</h2>
83-
<p className="text-xs text-[var(--pm-text-muted)]">Upload one image or start from a seeded demo scene.</p>
87+
<p className="text-xs text-[var(--pm-text-muted)]">Upload once, rebuild with presets, then sculpt and export from the same source.</p>
8488
</div>
8589
<span className="rounded-full border border-white/10 px-2.5 py-1 font-mono text-[0.7rem] uppercase tracking-[0.22em] text-white/55">
8690
{sceneStatus}
8791
</span>
8892
</div>
8993

94+
<div className="grid grid-cols-3 gap-2 text-[0.68rem] font-medium uppercase tracking-[0.18em] text-white/60">
95+
{['pick source', 'rebuild preset', 'export clip'].map((step, index) => (
96+
<div key={step} className="rounded-[18px] border border-white/8 bg-black/10 px-3 py-2">
97+
<div className="font-mono text-[0.62rem] text-white/30">0{index + 1}</div>
98+
<div className="mt-1 leading-4">{step}</div>
99+
</div>
100+
))}
101+
</div>
102+
90103
<button
91104
type="button"
92105
onClick={() => inputRef.current?.click()}
@@ -144,10 +157,23 @@ export function ControlPanel({
144157
}}
145158
/>
146159

160+
<div className="rounded-[22px] border border-white/8 bg-black/10 p-3">
161+
<div className="flex items-center justify-between text-[0.72rem] uppercase tracking-[0.2em] text-white/45">
162+
<span>Current source</span>
163+
<span className="font-mono">{usingCustomSource ? 'upload' : 'demo'}</span>
164+
</div>
165+
<div className="mt-2 text-sm font-semibold text-white">{sourceLabel}</div>
166+
<p className="mt-1 text-xs leading-5 text-[var(--pm-text-muted)]">
167+
{usingCustomSource
168+
? 'Using your upload. Presets will keep rebuilding from this image until you swap the source.'
169+
: activeDemo.description}
170+
</p>
171+
</div>
172+
147173
<div className="space-y-2">
148174
<div className="flex items-center justify-between">
149175
<span className="text-xs uppercase tracking-[0.2em] text-white/45">Seeded demos</span>
150-
<span className="font-mono text-[0.72rem] text-white/45">{sourceLabel}</span>
176+
<span className="font-mono text-[0.72rem] text-white/45">instant start</span>
151177
</div>
152178
<div className="grid grid-cols-3 gap-2">
153179
{demos.map((demo) => (
@@ -170,7 +196,7 @@ export function ControlPanel({
170196
))}
171197
</div>
172198
<p className="text-xs leading-5 text-[var(--pm-text-muted)]">
173-
{demos.find((demo) => demo.id === selectedDemoId)?.description}
199+
Faces, masks, logos, flowers, and bold silhouettes with clean negative space usually produce the strongest melts.
174200
</p>
175201
</div>
176202

@@ -273,31 +299,41 @@ export function ControlPanel({
273299
<section className="mb-6 rounded-3xl border border-white/8 bg-white/[0.03] p-4">
274300
<div className="mb-3">
275301
<h2 className="text-base font-semibold text-white">Run</h2>
276-
<p className="text-xs text-[var(--pm-text-muted)]">Pause to inspect, reset to re-seed, export straight from the canvas.</p>
302+
<p className="text-xs text-[var(--pm-text-muted)]">Pause to inspect, reset to re-seed, then export the exact stage you see as an 8-second WebM.</p>
277303
</div>
278304
<div className="grid grid-cols-2 gap-2">
279305
<button
280306
type="button"
281307
onClick={onPauseToggle}
282-
className="rounded-[18px] border border-white/12 bg-white/[0.05] px-4 py-3 font-semibold text-white transition hover:border-white/22 hover:bg-white/[0.08]"
308+
disabled={runControlsDisabled}
309+
className={cn(
310+
'rounded-[18px] border border-white/12 bg-white/[0.05] px-4 py-3 font-semibold text-white transition',
311+
runControlsDisabled ? 'cursor-not-allowed opacity-50' : 'hover:border-white/22 hover:bg-white/[0.08]',
312+
)}
283313
>
284314
{paused ? 'Resume sim' : 'Pause sim'}
285315
</button>
286316
<button
287317
type="button"
288318
onClick={onReset}
289-
className="rounded-[18px] border border-white/12 bg-white/[0.05] px-4 py-3 font-semibold text-white transition hover:border-white/22 hover:bg-white/[0.08]"
319+
disabled={runControlsDisabled}
320+
className={cn(
321+
'rounded-[18px] border border-white/12 bg-white/[0.05] px-4 py-3 font-semibold text-white transition',
322+
runControlsDisabled ? 'cursor-not-allowed opacity-50' : 'hover:border-white/22 hover:bg-white/[0.08]',
323+
)}
290324
>
291325
Reset scene
292326
</button>
293327
<button
294328
type="button"
295329
onClick={onRecord}
296-
disabled={recording.status === 'recording' || recording.status === 'saving'}
330+
disabled={recordDisabled}
297331
className={cn(
298332
'col-span-2 rounded-[18px] border px-4 py-3 font-semibold transition',
299-
recording.status === 'recording' || recording.status === 'saving'
300-
? 'cursor-progress border-amber-300/20 bg-amber-300/10 text-amber-100'
333+
recordDisabled
334+
? recording.status === 'recording' || recording.status === 'saving'
335+
? 'cursor-progress border-amber-300/20 bg-amber-300/10 text-amber-100'
336+
: 'cursor-not-allowed border-white/10 bg-white/[0.03] text-white/45'
301337
: 'border-[var(--pm-warm)] bg-[rgba(255,148,71,0.12)] text-white hover:bg-[rgba(255,148,71,0.2)]',
302338
)}
303339
>

0 commit comments

Comments
 (0)