@effing/create 0.13.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effing/create",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Create a new Effing project",
5
5
  "type": "module",
6
6
  "bin": "./dist/index.js",
@@ -1,4 +1,3 @@
1
- import { useEffect } from "react";
2
1
  import type { MetaFunction } from "react-router";
3
2
  import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
4
3
 
@@ -8,12 +7,6 @@ export const meta: MetaFunction = () => [
8
7
  ];
9
8
 
10
9
  export default function App() {
11
- useEffect(() => {
12
- if ("serviceWorker" in navigator) {
13
- navigator.serviceWorker.register("/sw.js");
14
- }
15
- }, []);
16
-
17
10
  return (
18
11
  <html lang="en">
19
12
  <head>
@@ -5,7 +5,7 @@ import {
5
5
  useNavigation,
6
6
  type ShouldRevalidateFunctionArgs,
7
7
  } from "react-router";
8
- import { useEffect, useState } from "react";
8
+ import { useEffect, useReducer, useState } from "react";
9
9
  import invariant from "tiny-invariant";
10
10
  import { serialize } from "@effing/serde";
11
11
  import {
@@ -40,39 +40,60 @@ const RENDER_SCALES = [
40
40
 
41
41
  // ============ Types ============
42
42
 
43
- type RenderSuccess = {
44
- renderSuccess: true;
45
- progressUrl: 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
- }
43
+ type ActionResult =
44
+ | {
45
+ intent: "render";
46
+ success: true;
47
+ progressUrl: string;
48
+ renderScale: number;
49
+ }
50
+ | {
51
+ intent: "render";
52
+ success: false;
53
+ error: string;
54
+ issues?: EffieValidationIssue[];
55
+ }
56
+ | { intent: "reload"; success: true; purged: number; total: number }
57
+ | { intent: "reload"; success: false; error: string };
58
+
59
+ type RenderState =
60
+ | { step: "idle" }
61
+ | { step: "started"; startedAt: number }
62
+ | { step: "ready"; startedAt: number; videoUrl: string; scale: number }
63
+ | {
64
+ step: "done";
65
+ startedAt: number;
66
+ videoUrl: string;
67
+ scale: number;
68
+ playbackAt: number;
69
+ };
64
70
 
65
- function isRenderError(data: ActionResult): data is RenderError {
66
- return "renderSuccess" in data && !data.renderSuccess;
71
+ type RenderAction =
72
+ | { type: "start" }
73
+ | { type: "ready"; videoUrl: string; scale: number }
74
+ | { type: "play" }
75
+ | { type: "error" };
76
+
77
+ function renderReducer(state: RenderState, action: RenderAction): RenderState {
78
+ switch (action.type) {
79
+ case "start":
80
+ return { step: "started", startedAt: Date.now() };
81
+ case "ready":
82
+ if (state.step !== "started") return state;
83
+ return {
84
+ step: "ready",
85
+ startedAt: state.startedAt,
86
+ videoUrl: action.videoUrl,
87
+ scale: action.scale,
88
+ };
89
+ case "play":
90
+ if (state.step !== "ready") return state;
91
+ return { ...state, step: "done", playbackAt: Date.now() };
92
+ case "error":
93
+ return { step: "idle" };
94
+ }
67
95
  }
68
96
 
69
- type RenderState = {
70
- videoUrl: string | null;
71
- startTime: number | null;
72
- playbackTime: number | null;
73
- scale: number | null;
74
- };
75
-
76
97
  // ============ Loader ============
77
98
 
78
99
  export async function loader({ request, params }: Route.LoaderArgs) {
@@ -80,9 +101,11 @@ export async function loader({ request, params }: Route.LoaderArgs) {
80
101
  const width = parseInt(requestUrl.searchParams.get("w") || "1080", 10);
81
102
  const height = parseInt(requestUrl.searchParams.get("h") || "1080", 10);
82
103
 
83
- const { previewProps, renderer, propsSchema } = await getEffie(
84
- params.effieId,
85
- );
104
+ const {
105
+ previewProps,
106
+ renderer: generateEffie,
107
+ propsSchema,
108
+ } = await getEffie(params.effieId);
86
109
 
87
110
  if (propsSchema) {
88
111
  invariant(
@@ -96,10 +119,10 @@ export async function loader({ request, params }: Route.LoaderArgs) {
96
119
  process.env.SECRET_KEY!,
97
120
  );
98
121
 
99
- const effie = await renderer({ props: previewProps, width, height });
122
+ const effie = await generateEffie({ props: previewProps, width, height });
100
123
 
101
124
  // Create warmup job if FFS is configured
102
- let warmupStreamUrl: string | null = null;
125
+ let warmupUrl: string | null = null;
103
126
  if (process.env.FFS_BASE_URL && process.env.FFS_API_KEY) {
104
127
  try {
105
128
  const warmupResponse = await fetch(`${process.env.FFS_BASE_URL}/warmup`, {
@@ -113,7 +136,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
113
136
 
114
137
  if (warmupResponse.ok) {
115
138
  const warmupData = await warmupResponse.json();
116
- warmupStreamUrl = warmupData.progressUrl;
139
+ warmupUrl = warmupData.progressUrl;
117
140
  }
118
141
  } catch {
119
142
  // Warmup is best-effort, don't fail the page load
@@ -126,7 +149,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
126
149
  height,
127
150
  effie,
128
151
  jsonUrl: `/ff/${urlSegment}?w=${width}&h=${height}`,
129
- warmupStreamUrl,
152
+ warmupUrl,
130
153
  };
131
154
  }
132
155
 
@@ -145,9 +168,9 @@ export function shouldRevalidate({
145
168
 
146
169
  // ============ Action ============
147
170
 
148
- async function handleReload(effieJson: string): Promise<ReloadResult> {
171
+ async function handleReload(effieJson: string): Promise<ActionResult> {
149
172
  if (!process.env.FFS_BASE_URL || !process.env.FFS_API_KEY) {
150
- return { reloadSuccess: false, error: "FFS not configured" };
173
+ return { intent: "reload", success: false, error: "FFS not configured" };
151
174
  }
152
175
 
153
176
  try {
@@ -161,18 +184,24 @@ async function handleReload(effieJson: string): Promise<ReloadResult> {
161
184
  });
162
185
 
163
186
  if (!purgeResponse.ok) {
164
- return { reloadSuccess: false, error: "Failed to purge cache" };
187
+ return {
188
+ intent: "reload",
189
+ success: false,
190
+ error: "Failed to purge cache",
191
+ };
165
192
  }
166
193
 
167
194
  const purgeData = await purgeResponse.json();
168
195
  return {
169
- reloadSuccess: true,
196
+ intent: "reload",
197
+ success: true,
170
198
  purged: purgeData.purged,
171
199
  total: purgeData.total,
172
200
  };
173
201
  } catch (err) {
174
202
  return {
175
- reloadSuccess: false,
203
+ intent: "reload",
204
+ success: false,
176
205
  error: err instanceof Error ? err.message : "Unknown error",
177
206
  };
178
207
  }
@@ -181,9 +210,9 @@ async function handleReload(effieJson: string): Promise<ReloadResult> {
181
210
  async function handleRender(
182
211
  effieJson: string,
183
212
  scale: number,
184
- ): Promise<RenderResult> {
213
+ ): Promise<ActionResult> {
185
214
  if (!process.env.FFS_BASE_URL || !process.env.FFS_API_KEY) {
186
- return { renderSuccess: false, error: "FFS not configured" };
215
+ return { intent: "render", success: false, error: "FFS not configured" };
187
216
  }
188
217
 
189
218
  try {
@@ -203,20 +232,26 @@ async function handleRender(
203
232
  try {
204
233
  const errorBody = await createResponse.json();
205
234
  return {
206
- renderSuccess: false,
235
+ intent: "render",
236
+ success: false,
207
237
  error: errorBody.error || createResponse.statusText,
208
238
  issues: parseEffieValidationIssues(errorBody.issues),
209
239
  };
210
240
  } catch {
211
- return { renderSuccess: false, error: createResponse.statusText };
241
+ return {
242
+ intent: "render",
243
+ success: false,
244
+ error: createResponse.statusText,
245
+ };
212
246
  }
213
247
  }
214
248
 
215
249
  const { progressUrl } = await createResponse.json();
216
- return { renderSuccess: true, renderScale: scale, progressUrl };
250
+ return { intent: "render", success: true, renderScale: scale, progressUrl };
217
251
  } catch (err) {
218
252
  return {
219
- renderSuccess: false,
253
+ intent: "render",
254
+ success: false,
220
255
  error: err instanceof Error ? err.message : "Unknown error",
221
256
  };
222
257
  }
@@ -230,7 +265,7 @@ export async function action({
230
265
  const effieJson = formData.get("effie")?.toString();
231
266
 
232
267
  if (!effieJson) {
233
- return { renderSuccess: false, error: "Missing effie data" };
268
+ return { intent: "render", success: false, error: "Missing effie data" };
234
269
  }
235
270
 
236
271
  if (intent === "reload") {
@@ -244,39 +279,35 @@ export async function action({
244
279
  // ============ Component ============
245
280
 
246
281
  export default function EffiePreviewPage() {
247
- const { effie, jsonUrl, effieId, width, height, warmupStreamUrl } =
282
+ const { effie, jsonUrl, effieId, width, height, warmupUrl } =
248
283
  useLoaderData<typeof loader>();
249
284
  const actionData = useActionData<typeof action>();
250
285
  const navigation = useNavigation();
251
286
 
252
- const [render, setRender] = useState<RenderState>({
253
- videoUrl: null,
254
- startTime: null,
255
- playbackTime: null,
256
- scale: null,
257
- });
287
+ const [render, dispatch] = useReducer(renderReducer, { step: "idle" });
258
288
  const [elapsedToPlay, setElapsedToPlay] = useState<number | null>(null);
259
289
 
260
290
  // Update elapsed time while rendering is in progress
261
291
  useEffect(() => {
262
- if (render.startTime === null) {
292
+ if (render.step === "idle") {
263
293
  setElapsedToPlay(null);
264
294
  return;
265
295
  }
266
- if (render.playbackTime !== null) {
267
- setElapsedToPlay((render.playbackTime - render.startTime) / 1000);
296
+ if (render.step === "done") {
297
+ setElapsedToPlay((render.playbackAt - render.startedAt) / 1000);
268
298
  return;
269
299
  }
270
300
  const update = () =>
271
- setElapsedToPlay((Date.now() - render.startTime!) / 1000);
301
+ setElapsedToPlay((Date.now() - render.startedAt) / 1000);
272
302
  update();
273
303
  const interval = setInterval(update, 100);
274
304
  return () => clearInterval(interval);
275
- }, [render.startTime, render.playbackTime]);
305
+ }, [render]);
276
306
 
277
307
  // Connect to SSE progress when render action completes
278
308
  useEffect(() => {
279
- if (!actionData || !isRenderSuccess(actionData)) return;
309
+ if (!actionData || actionData.intent !== "render" || !actionData.success)
310
+ return;
280
311
 
281
312
  const { progressUrl, renderScale } = actionData;
282
313
  const eventSource = new EventSource(progressUrl);
@@ -284,11 +315,7 @@ export default function EffiePreviewPage() {
284
315
  eventSource.addEventListener("ready", (e) => {
285
316
  try {
286
317
  const { videoUrl } = JSON.parse(e.data);
287
- setRender((prev) => ({
288
- ...prev,
289
- videoUrl,
290
- scale: renderScale,
291
- }));
318
+ dispatch({ type: "ready", videoUrl, scale: renderScale });
292
319
  } catch {
293
320
  // Ignore parse errors
294
321
  }
@@ -296,6 +323,7 @@ export default function EffiePreviewPage() {
296
323
  });
297
324
 
298
325
  eventSource.addEventListener("error", () => {
326
+ dispatch({ type: "error" });
299
327
  eventSource.close();
300
328
  });
301
329
 
@@ -304,7 +332,7 @@ export default function EffiePreviewPage() {
304
332
  };
305
333
  }, [actionData]);
306
334
 
307
- const warmup = useEffieWarmup(warmupStreamUrl);
335
+ const warmup = useEffieWarmup(warmupUrl);
308
336
  const resolveSource = createEffieSourceResolver(effie.sources);
309
337
 
310
338
  // Compute scaled resolution for preview (540px height for cover)
@@ -325,7 +353,7 @@ export default function EffiePreviewPage() {
325
353
  const isReloading =
326
354
  navigation.state === "submitting" &&
327
355
  navigation.formData?.get("intent") === "reload";
328
- const isReloadPending = isLoading || isReloading || warmup.isWarming;
356
+ const isReloadProhibited = isLoading || isReloading || warmup.isWarming;
329
357
 
330
358
  const warmupElapsed =
331
359
  warmup.state.startTime && warmup.state.status !== "idle"
@@ -348,21 +376,14 @@ export default function EffiePreviewPage() {
348
376
  const showProgressBar = warmup.state.status === "warming";
349
377
 
350
378
  const handleVideoPlay = () => {
351
- if (render.startTime !== null && render.playbackTime === null) {
352
- setRender((prev) => ({ ...prev, playbackTime: Date.now() }));
353
- }
379
+ dispatch({ type: "play" });
354
380
  };
355
381
 
356
382
  const handleRenderSubmit = () => {
357
- setRender({
358
- videoUrl: null,
359
- startTime: Date.now(),
360
- playbackTime: null,
361
- scale: null,
362
- });
383
+ dispatch({ type: "start" });
363
384
  };
364
385
 
365
- const formatWarmupUrl = (url: string, maxLen = 70) => {
386
+ const formatSourceUrl = (url: string, maxLen = 70) => {
366
387
  if (url.length <= maxLen) return url;
367
388
  const keepStart = Math.max(20, Math.floor(maxLen * 0.6));
368
389
  const keepEnd = Math.max(10, maxLen - keepStart - 5);
@@ -438,7 +459,7 @@ export default function EffiePreviewPage() {
438
459
  <EffieCoverPreview
439
460
  cover={effie.cover}
440
461
  resolution={coverResolution}
441
- video={render.videoUrl}
462
+ video={"videoUrl" in render ? render.videoUrl : null}
442
463
  onPlay={handleVideoPlay}
443
464
  style={{
444
465
  border: "1px solid black",
@@ -490,32 +511,6 @@ export default function EffiePreviewPage() {
490
511
  alignItems: "flex-start",
491
512
  }}
492
513
  >
493
- <Form method="post">
494
- <input
495
- type="hidden"
496
- name="effie"
497
- value={JSON.stringify(effie)}
498
- />
499
- <button
500
- type="submit"
501
- name="intent"
502
- value="reload"
503
- disabled={isReloadPending}
504
- style={{
505
- padding: "0.4rem 0.75rem",
506
- backgroundColor: "#fff",
507
- color: "#222",
508
- border: "1px solid black",
509
- borderRadius: 4,
510
- fontSize: "14px",
511
- cursor: isReloadPending ? "wait" : "pointer",
512
- opacity: isReloadPending ? 0.6 : 1,
513
- }}
514
- >
515
- {isReloadPending ? "Reloading sources..." : "Reload sources"}
516
- </button>
517
- </Form>
518
-
519
514
  <Form
520
515
  method="post"
521
516
  style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}
@@ -563,10 +558,36 @@ export default function EffiePreviewPage() {
563
558
  ))}
564
559
  </select>
565
560
  </Form>
561
+
562
+ <Form method="post">
563
+ <input
564
+ type="hidden"
565
+ name="effie"
566
+ value={JSON.stringify(effie)}
567
+ />
568
+ <button
569
+ type="submit"
570
+ name="intent"
571
+ value="reload"
572
+ disabled={isReloadProhibited}
573
+ style={{
574
+ padding: "0.4rem 0.75rem",
575
+ backgroundColor: "#fff",
576
+ color: "#222",
577
+ border: "1px solid black",
578
+ borderRadius: 4,
579
+ fontSize: "14px",
580
+ cursor: isReloadProhibited ? "wait" : "pointer",
581
+ opacity: isReloadProhibited ? 0.6 : 1,
582
+ }}
583
+ >
584
+ {isReloadProhibited ? "Loading sources..." : "Reload sources"}
585
+ </button>
586
+ </Form>
566
587
  </div>
567
588
 
568
589
  {/* Render Error */}
569
- {actionData && isRenderError(actionData) && (
590
+ {actionData?.intent === "render" && !actionData.success && (
570
591
  <EffieValidationErrors
571
592
  error={actionData.error}
572
593
  issues={actionData.issues}
@@ -574,7 +595,7 @@ export default function EffiePreviewPage() {
574
595
  )}
575
596
 
576
597
  {/* Render Success */}
577
- {render.scale !== null && elapsedToPlay !== null && (
598
+ {render.step === "done" && elapsedToPlay !== null && (
578
599
  <div style={{ color: "#4CAE4C" }}>
579
600
  Started playing after {elapsedToPlay.toFixed(1)}s (at{" "}
580
601
  {Math.round(render.scale * 100)}%)
@@ -595,7 +616,7 @@ export default function EffiePreviewPage() {
595
616
  {warmupDownloadingItems.map((d, i) => (
596
617
  <span key={d.url}>
597
618
  {i > 0 ? ", " : ""}
598
- <span title={d.url}>{formatWarmupUrl(d.url)}</span>
619
+ <span title={d.url}>{formatSourceUrl(d.url)}</span>
599
620
  </span>
600
621
  ))}
601
622
  </div>
@@ -11,15 +11,15 @@
11
11
  "typecheck": "react-router typegen && tsc"
12
12
  },
13
13
  "dependencies": {
14
- "@effing/annie": "^0.13.0",
15
- "@effing/annie-player": "^0.13.0",
16
- "@effing/effie": "^0.13.0",
17
- "@effing/effie-preview": "^0.13.0",
18
- "@effing/ffs": "^0.13.0",
19
- "@effing/satori": "^0.13.0",
14
+ "@effing/annie": "^0.14.0",
15
+ "@effing/annie-player": "^0.14.0",
16
+ "@effing/effie": "^0.14.0",
17
+ "@effing/effie-preview": "^0.14.0",
18
+ "@effing/ffs": "^0.14.0",
19
+ "@effing/satori": "^0.14.0",
20
20
  "@resvg/resvg-js": "^2.6.2",
21
- "@effing/serde": "^0.13.0",
22
- "@effing/tween": "^0.13.0",
21
+ "@effing/serde": "^0.14.0",
22
+ "@effing/tween": "^0.14.0",
23
23
  "@react-router/node": "^7.0.0",
24
24
  "@react-router/serve": "^7.0.0",
25
25
  "cross-env": "^7.0.3",
@@ -1,48 +0,0 @@
1
- /* global clients */
2
-
3
- // FFS render URLs (GET /render/{uuid}) are one-time-consumption — the server
4
- // deletes the job after streaming the response. However, browsers might make
5
- // follow-up requests to the same URL (e.g. to re-read the moov atom for seeking),
6
- // which return 404 and cause the video to break. This service worker caches the
7
- // response so those subsequent requests are served from cache.
8
-
9
- const CACHE_NAME = "ffs-render-cache";
10
- const RENDER_URL_PATTERN = /\/render\/[0-9a-f-]{36}\/video$/;
11
- const MAX_ENTRIES = 5;
12
-
13
- /** @type {string[]} URLs in insertion order — reset on activate alongside the cache. */
14
- const cacheOrder = [];
15
-
16
- self.addEventListener("activate", (event) => {
17
- cacheOrder.length = 0;
18
- event.waitUntil(caches.delete(CACHE_NAME).then(() => clients.claim()));
19
- });
20
-
21
- self.addEventListener("fetch", (event) => {
22
- if (!RENDER_URL_PATTERN.test(new URL(event.request.url).pathname)) return;
23
-
24
- event.respondWith(
25
- caches.open(CACHE_NAME).then(async (cache) => {
26
- const cached = await cache.match(event.request);
27
- if (cached) return cached;
28
-
29
- const response = await fetch(event.request);
30
- if (response.ok) {
31
- await cache.put(event.request, response.clone());
32
- cacheOrder.push(event.request.url);
33
-
34
- // Prune oldest entries in the background — don't block the response.
35
- if (cacheOrder.length > MAX_ENTRIES) {
36
- const toDelete = cacheOrder.splice(
37
- 0,
38
- cacheOrder.length - MAX_ENTRIES,
39
- );
40
- event.waitUntil(
41
- Promise.all(toDelete.map((url) => cache.delete(url))),
42
- );
43
- }
44
- }
45
- return response;
46
- }),
47
- );
48
- });