@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.
- package/LICENSE +11 -0
- package/README.md +63 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +71 -0
- package/package.json +33 -0
- package/template/README.md +245 -0
- package/template/_env.example +5 -0
- package/template/app/annies/index.ts +45 -0
- package/template/app/annies/photo-zoom.annie.tsx +56 -0
- package/template/app/annies/text-typewriter.annie.tsx +151 -0
- package/template/app/effies/index.ts +48 -0
- package/template/app/effies/simple-slideshow.effie.tsx +148 -0
- package/template/app/fonts.server.ts +103 -0
- package/template/app/root.tsx +31 -0
- package/template/app/routes/_index.tsx +88 -0
- package/template/app/routes/an.$segment.tsx +32 -0
- package/template/app/routes/ff.$segment.tsx +27 -0
- package/template/app/routes/pan.$annieId.tsx +87 -0
- package/template/app/routes/pff.$effieId.tsx +598 -0
- package/template/app/routes.ts +3 -0
- package/template/app/urls.server.ts +40 -0
- package/template/package.json +47 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/robots.txt +4 -0
- package/template/react-router.config.ts +9 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.config.ts +15 -0
|
@@ -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,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
|