Skip to content

Commit 2e3bb39

Browse files
blaineamclaude
andcommitted
Multi-text blocks, batch screenshot export, animation resolution picker, project persistence
- Multiple text objects with per-block settings (font, position, size, shadow, arc, etc.) - Max width / word wrapping support for text blocks - Text block list with add/remove/duplicate in sidebar - Animation resolution selector (1080p / 4K) for video export - Screenshots persist in .monkr project files as data URLs - Background images persist in project save/load - Background type falls back gracefully on refresh (image → gradient) - Multi-screenshot drop: drop multiple images on a device for batch export - "Export All Variations" button renders each screenshot variant separately - App Store presets: added Mac, Apple TV, Apple Watch sizes - Bumped scene preset device scales for better visual fill - Scene presets now include text blocks (App Store scenes have placeholder titles) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 80d9c5b commit 2e3bb39

9 files changed

Lines changed: 709 additions & 148 deletions

File tree

src/lib/animation.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -203,18 +203,23 @@ export function createDefaultAnimationConfig(): AnimationConfig {
203203

204204
// ─── Frame Capture ────────────────────────────────────────────
205205

206-
/** Max export dimensions — cap at 1080p for animation (keeps it fast) */
207-
const MAX_ANIM_WIDTH = 1920;
208-
const MAX_ANIM_HEIGHT = 1080;
209206
const MAX_EXPORT_FPS = 60;
210207

208+
export type AnimResolution = '1080p' | '4k';
209+
210+
const RESOLUTION_CAPS: Record<AnimResolution, { w: number; h: number }> = {
211+
'1080p': { w: 1920, h: 1080 },
212+
'4k': { w: 3840, h: 2160 }
213+
};
214+
211215
/** Compute a pixel ratio that keeps the output within bounds */
212-
function computeExportPixelRatio(element: HTMLElement): number {
216+
function computeExportPixelRatio(element: HTMLElement, resolution: AnimResolution = '1080p'): number {
213217
const w = element.offsetWidth;
214218
const h = element.offsetHeight;
215219
if (w <= 0 || h <= 0) return 1;
216-
const scaleW = MAX_ANIM_WIDTH / w;
217-
const scaleH = MAX_ANIM_HEIGHT / h;
220+
const cap = RESOLUTION_CAPS[resolution];
221+
const scaleW = cap.w / w;
222+
const scaleH = cap.h / h;
218223
return Math.min(1, scaleW, scaleH);
219224
}
220225

@@ -237,12 +242,13 @@ export async function captureFrames(
237242
fps: number,
238243
onFrame: (time: number) => void,
239244
onProgress?: (pct: number) => void,
240-
_tracks?: AnimationTrack[] // reserved for future use
245+
_tracks?: AnimationTrack[], // reserved for future use
246+
resolution: AnimResolution = '1080p'
241247
): Promise<Blob[]> {
242248
const clampedFps = Math.min(fps, MAX_EXPORT_FPS);
243249
const totalFrames = Math.ceil((duration / 1000) * clampedFps);
244250
const frameInterval = duration / totalFrames;
245-
const pixelRatio = computeExportPixelRatio(element);
251+
const pixelRatio = computeExportPixelRatio(element, resolution);
246252
const frames: Blob[] = [];
247253

248254
for (let i = 0; i < totalFrames; i++) {

src/lib/components/Canvas.svelte

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,48 @@
209209
/>
210210
{/if}
211211

212-
<!-- Text overlay above -->
213-
{#if store.textOverlay.enabled && store.textOverlay.text && store.textOverlay.position === 'above'}
212+
<!-- Text blocks (above position) -->
213+
{#each store.textBlocks.filter(t => t.text && t.position === 'above') as tb (tb.id)}
214+
{@const isSelText = store.selectedTextId === tb.id}
215+
<div
216+
class="absolute left-0 right-0 text-center"
217+
style="top: {store.padding * 0.4}px;
218+
font-size: {tb.fontSize}px;
219+
font-weight: {tb.fontWeight};
220+
font-family: '{tb.fontFamily}', sans-serif;
221+
color: {tb.color};
222+
text-align: {tb.textAlign};
223+
letter-spacing: {tb.letterSpacing}px;
224+
line-height: {tb.lineHeight};
225+
padding: 0 {store.padding}px;
226+
overflow: visible;
227+
{tb.maxWidth > 0 ? `max-width: ${tb.maxWidth}%; margin: 0 auto; word-wrap: break-word; overflow-wrap: break-word; white-space: normal;` : ''}
228+
transform: perspective(1200px) rotateX({tb.tiltX}deg) rotateY({tb.tiltY}deg) rotate({tb.rotation}deg);
229+
{tb.shadow.enabled ? `text-shadow: ${tb.shadow.offsetX}px ${tb.shadow.offsetY}px ${tb.shadow.blur}px ${tb.shadow.color};` : ''}
230+
{isSelText ? 'outline: 2px dashed rgba(236,72,153,0.4); outline-offset: 4px;' : ''}"
231+
onclick={() => store.selectTextBlock(tb.id)}
232+
>
233+
{#if tb.arcDegrees !== 0}
234+
{@const estW = Math.max(500, tb.fontSize * tb.text.length * 0.7)}
235+
{@const estH = Math.max(300, tb.fontSize * 4)}
236+
{@const pathD = makeTextPath(tb.pathType, tb.arcDegrees, estW * 0.85)}
237+
<svg viewBox="-{estW / 2} -{estH / 2} {estW} {estH}" style="width: 100%; height: {estH}px; overflow: visible; display: block;">
238+
<defs>
239+
<path id="text-arc-{tb.id}" d={pathD} />
240+
</defs>
241+
<text fill={tb.color} font-size={tb.fontSize} font-weight={tb.fontWeight} font-family="'{tb.fontFamily}', sans-serif" letter-spacing={tb.letterSpacing} text-anchor="middle"
242+
style={tb.shadow.enabled ? `filter: drop-shadow(${tb.shadow.offsetX}px ${tb.shadow.offsetY}px ${tb.shadow.blur}px ${tb.shadow.color})` : ''}>
243+
<textPath href="#text-arc-{tb.id}" startOffset="50%">{tb.text}</textPath>
244+
</text>
245+
</svg>
246+
{:else}
247+
{tb.text}
248+
{/if}
249+
</div>
250+
{/each}
251+
252+
<!-- Legacy single text overlay above (backward compat) -->
253+
{#if store.textOverlay.enabled && store.textOverlay.text && store.textOverlay.position === 'above' && store.textBlocks.length === 0}
214254
{@const to = store.textOverlay}
215255
<div
216256
class="absolute left-0 right-0 text-center"
@@ -284,8 +324,70 @@
284324
{/if}
285325
{/each}
286326

287-
<!-- Text overlay below -->
288-
{#if store.textOverlay.enabled && store.textOverlay.text && store.textOverlay.position === 'below'}
327+
<!-- Text blocks (below position) -->
328+
{#each store.textBlocks.filter(t => t.text && t.position === 'below') as tb (tb.id)}
329+
{@const isSelText = store.selectedTextId === tb.id}
330+
<div
331+
class="absolute bottom-0 left-0 right-0 text-center"
332+
style="bottom: {store.padding * 0.4}px;
333+
font-size: {tb.fontSize}px;
334+
font-weight: {tb.fontWeight};
335+
font-family: '{tb.fontFamily}', sans-serif;
336+
color: {tb.color};
337+
text-align: {tb.textAlign};
338+
letter-spacing: {tb.letterSpacing}px;
339+
line-height: {tb.lineHeight};
340+
padding: 0 {store.padding}px;
341+
{tb.maxWidth > 0 ? `max-width: ${tb.maxWidth}%; margin: 0 auto; word-wrap: break-word; overflow-wrap: break-word; white-space: normal;` : ''}
342+
transform: perspective(1200px) rotateX({tb.tiltX}deg) rotateY({tb.tiltY}deg) rotate({tb.rotation}deg);
343+
{tb.shadow.enabled ? `text-shadow: ${tb.shadow.offsetX}px ${tb.shadow.offsetY}px ${tb.shadow.blur}px ${tb.shadow.color};` : ''}
344+
{isSelText ? 'outline: 2px dashed rgba(236,72,153,0.4); outline-offset: 4px;' : ''}"
345+
onclick={() => store.selectTextBlock(tb.id)}
346+
>
347+
{tb.text}
348+
</div>
349+
{/each}
350+
351+
<!-- Text blocks (custom position) -->
352+
{#each store.textBlocks.filter(t => t.text && t.position === 'custom') as tb (tb.id)}
353+
{@const isSelText = store.selectedTextId === tb.id}
354+
<div
355+
class="absolute"
356+
style="left: {tb.x}%; top: {tb.y}%;
357+
transform: translate(-50%, -50%) perspective(1200px) rotateX({tb.tiltX}deg) rotateY({tb.tiltY}deg) rotate({tb.rotation}deg);
358+
font-size: {tb.fontSize}px;
359+
font-weight: {tb.fontWeight};
360+
font-family: '{tb.fontFamily}', sans-serif;
361+
color: {tb.color};
362+
text-align: {tb.textAlign};
363+
letter-spacing: {tb.letterSpacing}px;
364+
line-height: {tb.lineHeight};
365+
{tb.maxWidth > 0 ? `max-width: ${tb.maxWidth}%; word-wrap: break-word; overflow-wrap: break-word; white-space: normal;` : 'white-space: nowrap;'}
366+
{tb.shadow.enabled ? `text-shadow: ${tb.shadow.offsetX}px ${tb.shadow.offsetY}px ${tb.shadow.blur}px ${tb.shadow.color};` : ''}
367+
{isSelText ? 'outline: 2px dashed rgba(236,72,153,0.4); outline-offset: 4px;' : ''}"
368+
onclick={() => store.selectTextBlock(tb.id)}
369+
>
370+
{#if tb.arcDegrees !== 0}
371+
{@const svgW = Math.max(400, tb.fontSize * tb.text.length * 0.7)}
372+
{@const svgH = Math.max(250, tb.fontSize * 4)}
373+
{@const pathD = makeTextPath(tb.pathType, tb.arcDegrees, svgW * 0.85)}
374+
<svg style="overflow: visible; width: {svgW}px; height: {svgH}px; display: block;" viewBox="-{svgW / 2} -{svgH / 2} {svgW} {svgH}">
375+
<defs>
376+
<path id="text-path-{tb.id}" d={pathD} />
377+
</defs>
378+
<text fill={tb.color} font-size={tb.fontSize} font-weight={tb.fontWeight} font-family="'{tb.fontFamily}', sans-serif" letter-spacing={tb.letterSpacing} text-anchor="middle"
379+
style={tb.shadow.enabled ? `filter: drop-shadow(${tb.shadow.offsetX}px ${tb.shadow.offsetY}px ${tb.shadow.blur}px ${tb.shadow.color})` : ''}>
380+
<textPath href="#text-path-{tb.id}" startOffset="50%">{tb.text}</textPath>
381+
</text>
382+
</svg>
383+
{:else}
384+
{tb.text}
385+
{/if}
386+
</div>
387+
{/each}
388+
389+
<!-- Legacy single text overlay below (backward compat) -->
390+
{#if store.textOverlay.enabled && store.textOverlay.text && store.textOverlay.position === 'below' && store.textBlocks.length === 0}
289391
{@const to = store.textOverlay}
290392
<div
291393
class="absolute bottom-0 left-0 right-0 text-center"
@@ -305,8 +407,8 @@
305407
</div>
306408
{/if}
307409

308-
<!-- Text overlay custom position -->
309-
{#if store.textOverlay.enabled && store.textOverlay.text && store.textOverlay.position === 'custom'}
410+
<!-- Legacy single text overlay custom (backward compat) -->
411+
{#if store.textOverlay.enabled && store.textOverlay.text && store.textOverlay.position === 'custom' && store.textBlocks.length === 0}
310412
{@const to = store.textOverlay}
311413
<div
312414
class="absolute whitespace-nowrap"

src/lib/components/DropZone.svelte

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
screenshotUrl = null,
66
index = 0,
77
onupload,
8-
onremove
8+
onremove,
9+
onuploadmultiple
910
}: {
1011
screenshotUrl: string | null;
1112
index?: number;
1213
onupload: (file: File) => void;
1314
onremove: () => void;
15+
onuploadmultiple?: (files: File[]) => void;
1416
} = $props();
1517
1618
let dragging = $state(false);
@@ -19,9 +21,11 @@
1921
function handleDrop(e: DragEvent) {
2022
e.preventDefault();
2123
dragging = false;
22-
const file = e.dataTransfer?.files[0];
23-
if (file && file.type.startsWith('image/')) {
24-
onupload(file);
24+
const files = Array.from(e.dataTransfer?.files ?? []).filter((f) => f.type.startsWith('image/'));
25+
if (files.length > 1 && onuploadmultiple) {
26+
onuploadmultiple(files);
27+
} else if (files.length === 1) {
28+
onupload(files[0]);
2529
}
2630
}
2731
@@ -36,9 +40,11 @@
3640
3741
function handleFileSelect(e: Event) {
3842
const input = e.target as HTMLInputElement;
39-
const file = input.files?.[0];
40-
if (file && file.type.startsWith('image/')) {
41-
onupload(file);
43+
const files = Array.from(input.files ?? []).filter((f) => f.type.startsWith('image/'));
44+
if (files.length > 1 && onuploadmultiple) {
45+
onuploadmultiple(files);
46+
} else if (files.length === 1) {
47+
onupload(files[0]);
4248
}
4349
input.value = '';
4450
}
@@ -74,7 +80,7 @@
7480
>
7581
{#if dragging}
7682
<Image size={20} class="text-pink-400" />
77-
<span class="text-xs text-pink-400">Drop image</span>
83+
<span class="text-xs text-pink-400">Drop image(s)</span>
7884
{:else}
7985
<Upload size={20} class="text-zinc-500" />
8086
<span class="text-xs text-zinc-500">Drop or click</span>
@@ -85,6 +91,7 @@
8591
bind:this={fileInput}
8692
type="file"
8793
accept="image/*"
94+
multiple
8895
class="hidden"
8996
onchange={handleFileSelect}
9097
/>

src/lib/components/ExportButton.svelte

Lines changed: 102 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { Download, Copy, Check } from 'lucide-svelte';
2+
import { Download, Copy, Check, Layers } from 'lucide-svelte';
33
import { store } from '../stores/state.svelte';
44
import { exportCanvas, exportCanvasSections, copyToClipboard } from '../export';
55
@@ -11,6 +11,13 @@
1111
1212
let exporting = $state(false);
1313
let copied = $state(false);
14+
let batchExporting = $state(false);
15+
let batchProgress = $state('');
16+
17+
/** Check if any device has extra screenshots for batch export */
18+
let hasBatchScreenshots = $derived(
19+
store.sceneObjects.some((o) => o.extraScreenshots.length > 0)
20+
);
1421
1522
async function handleExport() {
1623
if (!canvasRef || exporting) return;
@@ -45,26 +52,100 @@
4552
console.error('Copy failed:', err);
4653
}
4754
}
55+
56+
/**
57+
* Export all variations: for each device with extra screenshots,
58+
* cycle through all screenshots (primary + extras) and export each render.
59+
*/
60+
async function handleBatchExport() {
61+
if (!canvasRef || batchExporting) return;
62+
batchExporting = true;
63+
64+
try {
65+
// Collect all objects that have extra screenshots
66+
const batchObjects = store.sceneObjects.filter((o) => o.extraScreenshots.length > 0);
67+
if (batchObjects.length === 0) return;
68+
69+
// For simplicity, batch over the first object with extras
70+
// (multi-device batch is more complex, handle single device first)
71+
const obj = batchObjects[0];
72+
const allScreenshots = [
73+
{ url: obj.screenshotUrl, file: obj.screenshotFile },
74+
...obj.extraScreenshots
75+
].filter((s) => s.url);
76+
77+
for (let i = 0; i < allScreenshots.length; i++) {
78+
batchProgress = `Exporting ${i + 1} of ${allScreenshots.length}...`;
79+
80+
// Swap screenshot on the device
81+
store.updateObject(obj.id, {
82+
screenshotUrl: allScreenshots[i].url,
83+
screenshotFile: allScreenshots[i].file
84+
});
85+
86+
// Wait for DOM update
87+
await new Promise<void>((r) => setTimeout(r, 100));
88+
89+
// Export
90+
if (store.appStoreEnabled) {
91+
await exportCanvasSections(
92+
canvasRef,
93+
store.appStore.numSections,
94+
store.appStore.sectionWidth,
95+
store.appStore.sectionHeight,
96+
store.exportConfig.format,
97+
store.exportConfig.scale,
98+
`variation-${i + 1}`
99+
);
100+
} else {
101+
await exportCanvas(canvasRef, store.exportConfig.format, store.exportConfig.scale, `variation-${i + 1}`);
102+
}
103+
}
104+
105+
// Restore original primary screenshot
106+
store.updateObject(obj.id, {
107+
screenshotUrl: allScreenshots[0].url,
108+
screenshotFile: allScreenshots[0].file
109+
});
110+
} catch (err) {
111+
console.error('Batch export failed:', err);
112+
} finally {
113+
batchExporting = false;
114+
batchProgress = '';
115+
}
116+
}
48117
</script>
49118

50-
<div class="flex gap-2">
51-
<button
52-
onclick={handleExport}
53-
disabled={exporting}
54-
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-pink-600 px-3 py-2.5 text-xs font-semibold text-white transition-colors hover:bg-pink-500 disabled:opacity-50"
55-
>
56-
<Download size={14} />
57-
{exporting ? 'Saving...' : store.appStoreEnabled ? `Download ${store.appStore.numSections} Slides` : 'Download'}
58-
</button>
59-
<button
60-
onclick={handleCopy}
61-
class="flex items-center justify-center rounded-lg bg-zinc-800 px-3 py-2.5 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-white"
62-
title="Copy to clipboard"
63-
>
64-
{#if copied}
65-
<Check size={14} class="text-green-400" />
66-
{:else}
67-
<Copy size={14} />
68-
{/if}
69-
</button>
119+
<div class="space-y-2">
120+
<div class="flex gap-2">
121+
<button
122+
onclick={handleExport}
123+
disabled={exporting || batchExporting}
124+
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-pink-600 px-3 py-2.5 text-xs font-semibold text-white transition-colors hover:bg-pink-500 disabled:opacity-50"
125+
>
126+
<Download size={14} />
127+
{exporting ? 'Saving...' : store.appStoreEnabled ? `Download ${store.appStore.numSections} Slides` : 'Download'}
128+
</button>
129+
<button
130+
onclick={handleCopy}
131+
class="flex items-center justify-center rounded-lg bg-zinc-800 px-3 py-2.5 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-white"
132+
title="Copy to clipboard"
133+
>
134+
{#if copied}
135+
<Check size={14} class="text-green-400" />
136+
{:else}
137+
<Copy size={14} />
138+
{/if}
139+
</button>
140+
</div>
141+
{#if hasBatchScreenshots}
142+
<button
143+
onclick={handleBatchExport}
144+
disabled={batchExporting || exporting}
145+
class="flex w-full items-center justify-center gap-2 rounded-lg bg-violet-600/80 px-3 py-2 text-[11px] font-medium text-white transition-colors hover:bg-violet-500 disabled:opacity-50"
146+
>
147+
<Layers size={13} />
148+
{batchExporting ? batchProgress : 'Export All Variations'}
149+
</button>
150+
{/if}
70151
</div>

0 commit comments

Comments
 (0)