@effing/create 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,598 @@
1
+ import {
2
+ Form,
3
+ useActionData,
4
+ useLoaderData,
5
+ useNavigation,
6
+ type ShouldRevalidateFunctionArgs,
7
+ } from "react-router";
8
+ import { useEffect, useState } from "react";
9
+ import invariant from "tiny-invariant";
10
+ import { serialize } from "@effing/serde";
11
+ import {
12
+ createEffieSourceResolver,
13
+ parseEffieValidationIssues,
14
+ type EffieValidationIssue,
15
+ } from "@effing/effie-preview";
16
+ import {
17
+ EffieCoverPreview,
18
+ EffieBackgroundPreview,
19
+ EffieSegmentPreview,
20
+ EffieValidationErrors,
21
+ useEffieWarmup,
22
+ } from "@effing/effie-preview/react";
23
+ import { getEffie } from "~/effies";
24
+ import type { Route } from "./+types/pff.$effieId";
25
+
26
+ // ============ Constants ============
27
+
28
+ const RESOLUTIONS = [
29
+ { w: 1080, h: 1080 },
30
+ { w: 1080, h: 1350 },
31
+ { w: 1080, h: 1920 },
32
+ ] as const;
33
+
34
+ const RENDER_SCALES = [
35
+ { value: 1 / 3, label: "33%" },
36
+ { value: 2 / 3, label: "67%" },
37
+ { value: 1, label: "100%" },
38
+ { value: 2, label: "200%" },
39
+ ] as const;
40
+
41
+ // ============ Types ============
42
+
43
+ type RenderSuccess = {
44
+ renderSuccess: true;
45
+ videoUrl: string;
46
+ renderScale: number;
47
+ };
48
+ type RenderError = {
49
+ renderSuccess: false;
50
+ error: string;
51
+ issues?: EffieValidationIssue[];
52
+ };
53
+ type RenderResult = RenderSuccess | RenderError;
54
+
55
+ type ReloadSuccess = { reloadSuccess: true; purged: number; total: number };
56
+ type ReloadError = { reloadSuccess: false; error: string };
57
+ type ReloadResult = ReloadSuccess | ReloadError;
58
+
59
+ type ActionResult = RenderResult | ReloadResult;
60
+
61
+ function isRenderSuccess(data: ActionResult): data is RenderSuccess {
62
+ return "renderSuccess" in data && data.renderSuccess;
63
+ }
64
+
65
+ function isRenderError(data: ActionResult): data is RenderError {
66
+ return "renderSuccess" in data && !data.renderSuccess;
67
+ }
68
+
69
+ type RenderState = {
70
+ videoUrl: string | null;
71
+ startTime: number | null;
72
+ playbackTime: number | null;
73
+ scale: number | null;
74
+ };
75
+
76
+ // ============ Loader ============
77
+
78
+ export async function loader({ request, params }: Route.LoaderArgs) {
79
+ const requestUrl = new URL(request.url);
80
+ const width = parseInt(requestUrl.searchParams.get("w") || "1080", 10);
81
+ const height = parseInt(requestUrl.searchParams.get("h") || "1080", 10);
82
+
83
+ const { previewProps, renderer, propsSchema } = await getEffie(
84
+ params.effieId,
85
+ );
86
+
87
+ if (propsSchema) {
88
+ invariant(
89
+ propsSchema.safeParse(previewProps).success,
90
+ "previewProps does not adhere to the propsSchema",
91
+ );
92
+ }
93
+
94
+ const urlSegment = await serialize(
95
+ { effieId: params.effieId, ...previewProps },
96
+ process.env.SECRET_KEY!,
97
+ );
98
+
99
+ const effie = await renderer({ props: previewProps, width, height });
100
+
101
+ // Create warmup job if FFS is configured
102
+ let warmupStreamUrl: string | null = null;
103
+ if (process.env.FFS_BASE_URL && process.env.FFS_API_KEY) {
104
+ try {
105
+ const warmupResponse = await fetch(`${process.env.FFS_BASE_URL}/warmup`, {
106
+ method: "POST",
107
+ headers: {
108
+ Authorization: `Bearer ${process.env.FFS_API_KEY}`,
109
+ "Content-Type": "application/json",
110
+ },
111
+ body: JSON.stringify({ effie }),
112
+ });
113
+
114
+ if (warmupResponse.ok) {
115
+ const warmupData = await warmupResponse.json();
116
+ warmupStreamUrl = warmupData.url;
117
+ }
118
+ } catch {
119
+ // Warmup is best-effort, don't fail the page load
120
+ }
121
+ }
122
+
123
+ return {
124
+ effieId: params.effieId,
125
+ width,
126
+ height,
127
+ effie,
128
+ jsonUrl: `/ff/${urlSegment}?w=${width}&h=${height}`,
129
+ warmupStreamUrl,
130
+ };
131
+ }
132
+
133
+ export function shouldRevalidate({
134
+ defaultShouldRevalidate,
135
+ formData,
136
+ }: ShouldRevalidateFunctionArgs) {
137
+ // Only skip revalidation for render submissions - we want to preserve the
138
+ // video result. For reload, allow revalidation so the loader runs again and
139
+ // starts a fresh warmup.
140
+ if (formData?.get("intent") === "render") {
141
+ return false;
142
+ }
143
+ return defaultShouldRevalidate;
144
+ }
145
+
146
+ // ============ Action ============
147
+
148
+ async function handleReload(effieJson: string): Promise<ReloadResult> {
149
+ if (!process.env.FFS_BASE_URL || !process.env.FFS_API_KEY) {
150
+ return { reloadSuccess: false, error: "FFS not configured" };
151
+ }
152
+
153
+ try {
154
+ const purgeResponse = await fetch(`${process.env.FFS_BASE_URL}/purge`, {
155
+ method: "POST",
156
+ headers: {
157
+ Authorization: `Bearer ${process.env.FFS_API_KEY}`,
158
+ "Content-Type": "application/json",
159
+ },
160
+ body: JSON.stringify({ effie: JSON.parse(effieJson) }),
161
+ });
162
+
163
+ if (!purgeResponse.ok) {
164
+ return { reloadSuccess: false, error: "Failed to purge cache" };
165
+ }
166
+
167
+ const purgeData = await purgeResponse.json();
168
+ return {
169
+ reloadSuccess: true,
170
+ purged: purgeData.purged,
171
+ total: purgeData.total,
172
+ };
173
+ } catch (err) {
174
+ return {
175
+ reloadSuccess: false,
176
+ error: err instanceof Error ? err.message : "Unknown error",
177
+ };
178
+ }
179
+ }
180
+
181
+ async function handleRender(
182
+ effieJson: string,
183
+ scale: number,
184
+ ): Promise<RenderResult> {
185
+ if (!process.env.FFS_BASE_URL || !process.env.FFS_API_KEY) {
186
+ return { renderSuccess: false, error: "FFS not configured" };
187
+ }
188
+
189
+ try {
190
+ const createResponse = await fetch(`${process.env.FFS_BASE_URL}/render`, {
191
+ method: "POST",
192
+ headers: {
193
+ Authorization: `Bearer ${process.env.FFS_API_KEY}`,
194
+ "Content-Type": "application/json",
195
+ },
196
+ body: JSON.stringify({
197
+ effie: JSON.parse(effieJson),
198
+ scale,
199
+ }),
200
+ });
201
+
202
+ if (!createResponse.ok) {
203
+ try {
204
+ const errorBody = await createResponse.json();
205
+ return {
206
+ renderSuccess: false,
207
+ error: errorBody.error || createResponse.statusText,
208
+ issues: parseEffieValidationIssues(errorBody.issues),
209
+ };
210
+ } catch {
211
+ return { renderSuccess: false, error: createResponse.statusText };
212
+ }
213
+ }
214
+
215
+ const { url } = await createResponse.json();
216
+ return { renderSuccess: true, renderScale: scale, videoUrl: url };
217
+ } catch (err) {
218
+ return {
219
+ renderSuccess: false,
220
+ error: err instanceof Error ? err.message : "Unknown error",
221
+ };
222
+ }
223
+ }
224
+
225
+ export async function action({
226
+ request,
227
+ }: Route.ActionArgs): Promise<ActionResult> {
228
+ const formData = await request.formData();
229
+ const intent = formData.get("intent")?.toString();
230
+ const effieJson = formData.get("effie")?.toString();
231
+
232
+ if (!effieJson) {
233
+ return { renderSuccess: false, error: "Missing effie data" };
234
+ }
235
+
236
+ if (intent === "reload") {
237
+ return handleReload(effieJson);
238
+ }
239
+
240
+ const scale = parseFloat(formData.get("scale")?.toString() || "1");
241
+ return handleRender(effieJson, scale);
242
+ }
243
+
244
+ // ============ Component ============
245
+
246
+ export default function EffiePreviewPage() {
247
+ const { effie, jsonUrl, effieId, width, height, warmupStreamUrl } =
248
+ useLoaderData<typeof loader>();
249
+ const actionData = useActionData<typeof action>();
250
+ const navigation = useNavigation();
251
+
252
+ const [render, setRender] = useState<RenderState>({
253
+ videoUrl: null,
254
+ startTime: null,
255
+ playbackTime: null,
256
+ scale: null,
257
+ });
258
+ const [elapsedToPlay, setElapsedToPlay] = useState<number | null>(null);
259
+
260
+ // Update elapsed time while rendering is in progress
261
+ useEffect(() => {
262
+ if (render.startTime === null) {
263
+ setElapsedToPlay(null);
264
+ return;
265
+ }
266
+ if (render.playbackTime !== null) {
267
+ setElapsedToPlay((render.playbackTime - render.startTime) / 1000);
268
+ return;
269
+ }
270
+ const update = () =>
271
+ setElapsedToPlay((Date.now() - render.startTime!) / 1000);
272
+ update();
273
+ const interval = setInterval(update, 100);
274
+ return () => clearInterval(interval);
275
+ }, [render.startTime, render.playbackTime]);
276
+
277
+ // Update render state when action completes successfully
278
+ useEffect(() => {
279
+ if (actionData && isRenderSuccess(actionData)) {
280
+ setRender((prev) => ({
281
+ ...prev,
282
+ videoUrl: actionData.videoUrl,
283
+ scale: actionData.renderScale,
284
+ }));
285
+ }
286
+ }, [actionData]);
287
+
288
+ const warmup = useEffieWarmup(warmupStreamUrl);
289
+ const resolveSource = createEffieSourceResolver(effie.sources);
290
+
291
+ // Compute scaled resolution for preview (540px height for cover)
292
+ const coverResolution = { width: Math.round((540 * width) / height), height: 540 };
293
+ // Scaled resolution for background/segment previews (270px width)
294
+ const previewResolution = { width: 270, height: Math.round((270 * height) / width) };
295
+
296
+ const isLoading = navigation.state === "loading";
297
+ const isRendering =
298
+ navigation.state === "submitting" &&
299
+ navigation.formData?.get("intent") === "render";
300
+ const isReloading =
301
+ navigation.state === "submitting" &&
302
+ navigation.formData?.get("intent") === "reload";
303
+ const isReloadPending = isLoading || isReloading || warmup.isWarming;
304
+
305
+ const warmupElapsed =
306
+ warmup.state.startTime && warmup.state.status !== "idle"
307
+ ? Math.round(
308
+ ((warmup.state.endTime ?? Date.now()) - warmup.state.startTime) /
309
+ 1000,
310
+ )
311
+ : 0;
312
+ const warmupProgress =
313
+ warmup.state.total > 0
314
+ ? Math.round(
315
+ ((warmup.state.cached + warmup.state.failed) / warmup.state.total) *
316
+ 100,
317
+ )
318
+ : 0;
319
+ const warmupDownloadingItems = [...warmup.state.downloading.values()];
320
+
321
+ // Generic progress bar state (currently driven by warmup)
322
+ const progress = warmupProgress;
323
+ const showProgressBar = warmup.state.status === "warming";
324
+
325
+ const handleVideoPlay = () => {
326
+ if (render.startTime !== null && render.playbackTime === null) {
327
+ setRender((prev) => ({ ...prev, playbackTime: Date.now() }));
328
+ }
329
+ };
330
+
331
+ const handleRenderSubmit = () => {
332
+ setRender({
333
+ videoUrl: null,
334
+ startTime: Date.now(),
335
+ playbackTime: null,
336
+ scale: null,
337
+ });
338
+ };
339
+
340
+ const formatWarmupUrl = (url: string, maxLen = 70) => {
341
+ if (url.length <= maxLen) return url;
342
+ const keepStart = Math.max(20, Math.floor(maxLen * 0.6));
343
+ const keepEnd = Math.max(10, maxLen - keepStart - 5);
344
+ return `${url.slice(0, keepStart)}[...]${url.slice(-keepEnd)}`;
345
+ };
346
+
347
+ return (
348
+ <div>
349
+ {/* Progress Bar */}
350
+ <div
351
+ style={{
352
+ width: "100%",
353
+ height: 6,
354
+ backgroundColor: "#E5E7EB",
355
+ opacity: showProgressBar ? 1 : 0,
356
+ }}
357
+ >
358
+ <div
359
+ style={{
360
+ height: "100%",
361
+ backgroundColor: "#4CAE4C",
362
+ transition: "width 0.3s ease",
363
+ width: showProgressBar ? `${progress}%` : "0%",
364
+ }}
365
+ />
366
+ </div>
367
+
368
+ <div
369
+ style={{
370
+ padding: "2rem",
371
+ display: "flex",
372
+ flexDirection: "column",
373
+ gap: "2rem",
374
+ }}
375
+ >
376
+ {/* Header */}
377
+ <div>
378
+ <h1 style={{ margin: 0 }}>Effie Preview: {effieId}</h1>
379
+ <p style={{ color: "#666" }}>
380
+ Resolution: {width}x{height} |{" "}
381
+ {RESOLUTIONS.filter((r) => r.w !== width || r.h !== height).map((r, i) => (
382
+ <span key={`${r.w}x${r.h}`}>
383
+ {i > 0 && " | "}
384
+ <a href={`/pff/${effieId}?w=${r.w}&h=${r.h}`}>{r.w}x{r.h}</a>
385
+ </span>
386
+ ))}
387
+ </p>
388
+ </div>
389
+
390
+ {/* Cover and Controls */}
391
+ <div
392
+ style={{
393
+ display: "flex",
394
+ flexDirection: "row",
395
+ gap: "2rem",
396
+ flexWrap: "wrap",
397
+ }}
398
+ >
399
+ <EffieCoverPreview
400
+ cover={effie.cover}
401
+ resolution={coverResolution}
402
+ video={render.videoUrl}
403
+ onPlay={handleVideoPlay}
404
+ style={{
405
+ border: "1px solid black",
406
+ backgroundColor: "#eee",
407
+ }}
408
+ />
409
+
410
+ <div
411
+ style={{
412
+ display: "flex",
413
+ flexDirection: "column",
414
+ gap: "2rem",
415
+ flex: "1 1 320px",
416
+ minWidth: 260,
417
+ }}
418
+ >
419
+ <a href={jsonUrl} target="_blank" rel="noreferrer">
420
+ JSON →
421
+ </a>
422
+
423
+ {/* Info */}
424
+ <div
425
+ style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
426
+ >
427
+ <div>
428
+ {effie.width}x{effie.height} @ {effie.fps} fps
429
+ </div>
430
+ <div>{effie.segments.length} segments</div>
431
+ <div>
432
+ <span>
433
+ {warmup.state.cached}/{warmup.state.total} sources cached
434
+ </span>
435
+ {warmup.state.failed > 0 && (
436
+ <span style={{ color: "#E44444" }}>
437
+ {" "}
438
+ - {warmup.state.failed} failed
439
+ </span>
440
+ )}
441
+ {warmupElapsed > 0 && <span> (in {warmupElapsed}s)</span>}
442
+ </div>
443
+ </div>
444
+
445
+ {/* Actions */}
446
+ <div
447
+ style={{
448
+ display: "flex",
449
+ flexDirection: "column",
450
+ gap: "1rem",
451
+ alignItems: "flex-start",
452
+ }}
453
+ >
454
+ <Form method="post">
455
+ <input
456
+ type="hidden"
457
+ name="effie"
458
+ value={JSON.stringify(effie)}
459
+ />
460
+ <button
461
+ type="submit"
462
+ name="intent"
463
+ value="reload"
464
+ disabled={isReloadPending}
465
+ style={{
466
+ padding: "0.4rem 0.75rem",
467
+ backgroundColor: "#fff",
468
+ color: "#222",
469
+ border: "1px solid black",
470
+ borderRadius: 4,
471
+ fontSize: "14px",
472
+ cursor: isReloadPending ? "wait" : "pointer",
473
+ opacity: isReloadPending ? 0.6 : 1,
474
+ }}
475
+ >
476
+ {isReloadPending ? "Reloading sources..." : "Reload sources"}
477
+ </button>
478
+ </Form>
479
+
480
+ <Form
481
+ method="post"
482
+ style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}
483
+ onSubmit={handleRenderSubmit}
484
+ >
485
+ <input type="hidden" name="intent" value="render" />
486
+ <input
487
+ type="hidden"
488
+ name="effie"
489
+ value={JSON.stringify(effie)}
490
+ />
491
+ <button
492
+ disabled={isLoading || isRendering}
493
+ style={{
494
+ padding: "0.5rem 1rem",
495
+ backgroundColor: "#222",
496
+ color: "white",
497
+ border: "none",
498
+ borderRadius: "4px",
499
+ fontSize: "14px",
500
+ fontWeight: 500,
501
+ cursor: isRendering ? "wait" : "pointer",
502
+ opacity: isLoading || isRendering ? 0.6 : 1,
503
+ }}
504
+ >
505
+ {isRendering ? "Rendering..." : "Render it FFS"}
506
+ </button>
507
+ <span>at</span>
508
+ <select
509
+ name="scale"
510
+ defaultValue={RENDER_SCALES[0].value}
511
+ style={{
512
+ padding: "0.4rem",
513
+ border: "1px solid #ccc",
514
+ borderRadius: "4px",
515
+ fontSize: "14px",
516
+ backgroundColor: "white",
517
+ cursor: "pointer",
518
+ }}
519
+ >
520
+ {RENDER_SCALES.map(({ value, label }) => (
521
+ <option key={value} value={value}>
522
+ {label}
523
+ </option>
524
+ ))}
525
+ </select>
526
+ </Form>
527
+ </div>
528
+
529
+ {/* Render Error */}
530
+ {actionData && isRenderError(actionData) && (
531
+ <EffieValidationErrors
532
+ error={actionData.error}
533
+ issues={actionData.issues}
534
+ />
535
+ )}
536
+
537
+ {/* Render Success */}
538
+ {render.scale !== null && elapsedToPlay !== null && (
539
+ <div style={{ color: "#4CAE4C" }}>
540
+ Started playing after {elapsedToPlay.toFixed(1)}s (at{" "}
541
+ {Math.round(render.scale * 100)}%)
542
+ </div>
543
+ )}
544
+
545
+ {/* Downloading Status */}
546
+ {warmupDownloadingItems.length > 0 && (
547
+ <div
548
+ style={{
549
+ color: "#666",
550
+ maxWidth: 520,
551
+ overflowWrap: "anywhere",
552
+ wordBreak: "break-word",
553
+ }}
554
+ >
555
+ Downloading:{" "}
556
+ {warmupDownloadingItems.map((d, i) => (
557
+ <span key={d.url}>
558
+ {i > 0 ? ", " : ""}
559
+ <span title={d.url}>{formatWarmupUrl(d.url)}</span>
560
+ </span>
561
+ ))}
562
+ </div>
563
+ )}
564
+ </div>
565
+ </div>
566
+
567
+ {/* Background */}
568
+ <div>
569
+ <h2>Background</h2>
570
+ <EffieBackgroundPreview
571
+ background={effie.background}
572
+ resolveSource={resolveSource}
573
+ resolution={previewResolution}
574
+ />
575
+ </div>
576
+
577
+ {/* Segments */}
578
+ <div>
579
+ <h2>Segments</h2>
580
+ <div
581
+ style={{ display: "flex", flexDirection: "column", gap: "2rem" }}
582
+ >
583
+ {effie.segments.map((segment, i) => (
584
+ <EffieSegmentPreview
585
+ key={i}
586
+ segment={segment}
587
+ index={i}
588
+ resolveSource={resolveSource}
589
+ resolution={previewResolution}
590
+ stacking="horizontal"
591
+ />
592
+ ))}
593
+ </div>
594
+ </div>
595
+ </div>
596
+ </div>
597
+ );
598
+ }
@@ -0,0 +1,3 @@
1
+ import { flatRoutes } from "@react-router/fs-routes";
2
+
3
+ export default flatRoutes();
@@ -0,0 +1,40 @@
1
+ import { effieWebUrl } from "@effing/effie";
2
+ import { pngFromSatori } from "@effing/satori";
3
+ import type { PngFromSatoriOptions } from "@effing/satori";
4
+ import { serialize } from "@effing/serde";
5
+
6
+ export type { FontData, PngFromSatoriOptions } from "@effing/satori";
7
+
8
+ /**
9
+ * Generate a data URL for a PNG image from a React/JSX template using Satori
10
+ */
11
+ export async function pngUrlFromSatori(
12
+ template: Parameters<typeof pngFromSatori>[0],
13
+ options: PngFromSatoriOptions,
14
+ ) {
15
+ const buffer = await pngFromSatori(template, options);
16
+ return effieWebUrl(`data:image/png;base64,${buffer.toString("base64")}`);
17
+ }
18
+
19
+ /**
20
+ * Generate a signed URL for an annie animation
21
+ */
22
+ export async function annieUrl<P extends Record<string, unknown>>({
23
+ annieId,
24
+ props,
25
+ width,
26
+ height,
27
+ }: {
28
+ annieId: string;
29
+ props: P;
30
+ width: number;
31
+ height: number;
32
+ }) {
33
+ const urlSegment = await serialize(
34
+ { annieId, ...props },
35
+ process.env.SECRET_KEY!,
36
+ );
37
+ return effieWebUrl(
38
+ `${process.env.BASE_URL!}/an/${urlSegment}?w=${width}&h=${height}`,
39
+ );
40
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@effing/starter",
3
+ "type": "module",
4
+ "sideEffects": false,
5
+ "scripts": {
6
+ "build": "react-router build",
7
+ "dev": "run-p dev:*",
8
+ "dev:app": "react-router dev",
9
+ "dev:ffs": "ffs",
10
+ "start": "cross-env NODE_ENV=production node ./build/server/index.js",
11
+ "typecheck": "react-router typegen && tsc"
12
+ },
13
+ "dependencies": {
14
+ "@effing/annie": "^0.1.0",
15
+ "@effing/annie-player": "^0.1.0",
16
+ "@effing/effie": "^0.1.0",
17
+ "@effing/effie-preview": "^0.1.0",
18
+ "@effing/ffs": "^0.1.0",
19
+ "@effing/satori": "^0.1.0",
20
+ "@effing/serde": "^0.1.0",
21
+ "@effing/tween": "^0.1.0",
22
+ "@react-router/node": "^7.0.0",
23
+ "@react-router/serve": "^7.0.0",
24
+ "cross-env": "^7.0.3",
25
+ "isbot": "^5.1.0",
26
+ "react": "^19.0.0",
27
+ "react-dom": "^19.0.0",
28
+ "react-router": "^7.0.0",
29
+ "sharp": "^0.33.0",
30
+ "tiny-invariant": "^1.3.1",
31
+ "zod": "catalog:"
32
+ },
33
+ "devDependencies": {
34
+ "@react-router/dev": "^7.0.0",
35
+ "@react-router/fs-routes": "^7.0.0",
36
+ "@types/react": "^19.0.0",
37
+ "@types/react-dom": "^19.0.0",
38
+ "npm-run-all": "^4.1.5",
39
+ "typescript": "^5.7.0",
40
+ "vite": "^6.0.0",
41
+ "vite-tsconfig-paths": "^4.3.2"
42
+ },
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "license": "O'Saasy"
47
+ }
Binary file
@@ -0,0 +1,4 @@
1
+ User-agent: *
2
+ Disallow: /
3
+
4
+
@@ -0,0 +1,9 @@
1
+ import type { Config } from "@react-router/dev/config";
2
+
3
+ export default {
4
+ ssr: true,
5
+ future: {
6
+ unstable_optimizeDeps: true,
7
+ },
8
+ routeDiscovery: { mode: "initial" },
9
+ } satisfies Config;