@effing/create 0.14.1 → 0.15.1

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/dist/index.js CHANGED
@@ -61,8 +61,8 @@ Creating a new Effing project in ${root}...
61
61
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
62
62
  console.log("Done! To get started:\n");
63
63
  console.log(` cd ${projectName}`);
64
- console.log(" npm install");
65
- console.log(" npm run dev\n");
64
+ console.log(" npm install # or: pnpm install");
65
+ console.log(" npm run dev # or: pnpm run dev\n");
66
66
  console.log("Then open http://localhost:3839 to see your project.\n");
67
67
  }
68
68
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effing/create",
3
- "version": "0.14.1",
3
+ "version": "0.15.1",
4
4
  "description": "Create a new Effing project",
5
5
  "type": "module",
6
6
  "bin": "./dist/index.js",
@@ -0,0 +1,38 @@
1
+ FROM node:22-alpine AS base
2
+ RUN corepack enable
3
+ WORKDIR /app
4
+
5
+ # Stage: install all deps and build
6
+ FROM base AS build
7
+ COPY package.json pnpm-lock.yaml* package-lock.json* ./
8
+ RUN if [ -f pnpm-lock.yaml ]; then \
9
+ pnpm install --frozen-lockfile; \
10
+ elif [ -f package-lock.json ]; then \
11
+ npm ci; \
12
+ else \
13
+ npm install; \
14
+ fi
15
+ COPY . .
16
+ RUN npm run build
17
+
18
+ # Stage: production deps only
19
+ FROM base AS prod-deps
20
+ COPY package.json pnpm-lock.yaml* package-lock.json* ./
21
+ RUN if [ -f pnpm-lock.yaml ]; then \
22
+ pnpm install --frozen-lockfile --prod; \
23
+ elif [ -f package-lock.json ]; then \
24
+ npm ci --omit=dev; \
25
+ else \
26
+ npm install --omit=dev; \
27
+ fi
28
+
29
+ # Stage: final runtime image
30
+ FROM node:22-alpine
31
+ WORKDIR /app
32
+ ENV NODE_ENV=production
33
+ ENV PORT=8080
34
+ COPY --from=prod-deps /app/node_modules ./node_modules
35
+ COPY --from=build /app/package.json ./package.json
36
+ COPY --from=build /app/build ./build
37
+ EXPOSE $PORT
38
+ CMD ["./node_modules/.bin/react-router-serve", "./build/server/index.js"]
@@ -0,0 +1,6 @@
1
+ node_modules
2
+ build
3
+ .react-router
4
+ .git
5
+ .env
6
+ .env.*
@@ -1,3 +1,8 @@
1
+ import path from "node:path";
1
2
  import { createSatoriPool } from "@effing/satori/pool";
2
3
 
3
- export const satoriPool = createSatoriPool();
4
+ export const satoriPool = createSatoriPool(
5
+ process.env.NODE_ENV === "production"
6
+ ? { workerFile: path.resolve("build/satori.mjs") }
7
+ : undefined,
8
+ );
@@ -5,7 +5,7 @@ import {
5
5
  useNavigation,
6
6
  type ShouldRevalidateFunctionArgs,
7
7
  } from "react-router";
8
- import { useEffect, useReducer, useState } from "react";
8
+ import { useEffect, useReducer, useRef, useState } from "react";
9
9
  import invariant from "tiny-invariant";
10
10
  import { serialize } from "@effing/serde";
11
11
  import {
@@ -57,40 +57,72 @@ type ActionResult =
57
57
  | { intent: "reload"; success: false; error: string };
58
58
 
59
59
  type RenderState =
60
- | { step: "idle" }
61
- | { step: "started"; startedAt: number }
62
- | { step: "ready"; startedAt: number; videoUrl: string; scale: number }
60
+ | { step: "idle"; error: string | null }
61
+ | { step: "starting"; startedAt: number }
63
62
  | {
64
- step: "done";
63
+ step: "streaming";
64
+ startedAt: number;
65
+ videoUrl: string;
66
+ scale: number;
67
+ }
68
+ | {
69
+ step: "playing";
65
70
  startedAt: number;
66
71
  videoUrl: string;
67
72
  scale: number;
68
73
  playbackAt: number;
74
+ }
75
+ | {
76
+ step: "done";
77
+ startedAt: number;
78
+ videoUrl: string;
79
+ scale: number;
80
+ playbackAt: number | null;
81
+ downloadUrl: string;
69
82
  };
70
83
 
71
84
  type RenderAction =
72
85
  | { type: "start" }
73
- | { type: "ready"; videoUrl: string; scale: number }
86
+ | { type: "stream"; videoUrl: string; scale: number }
74
87
  | { type: "play" }
75
- | { type: "error" };
88
+ | { type: "finish"; downloadUrl: string }
89
+ | { type: "error"; error?: string };
90
+
91
+ const INITIAL_RENDER_STATE: RenderState = { step: "idle", error: null };
76
92
 
77
93
  function renderReducer(state: RenderState, action: RenderAction): RenderState {
78
94
  switch (action.type) {
79
95
  case "start":
80
- return { step: "started", startedAt: Date.now() };
81
- case "ready":
82
- if (state.step !== "started") return state;
96
+ return { step: "starting", startedAt: Date.now() };
97
+ case "stream":
98
+ if (state.step !== "starting") return state;
83
99
  return {
84
- step: "ready",
100
+ step: "streaming",
85
101
  startedAt: state.startedAt,
86
102
  videoUrl: action.videoUrl,
87
103
  scale: action.scale,
88
104
  };
89
105
  case "play":
90
- if (state.step !== "ready") return state;
91
- return { ...state, step: "done", playbackAt: Date.now() };
106
+ if (state.step === "streaming")
107
+ return { ...state, step: "playing", playbackAt: Date.now() };
108
+ if (state.step === "done" && state.playbackAt === null)
109
+ return { ...state, playbackAt: Date.now() };
110
+ return state;
111
+ case "finish":
112
+ if (state.step === "streaming")
113
+ return {
114
+ ...state,
115
+ step: "done",
116
+ playbackAt: null,
117
+ downloadUrl: action.downloadUrl,
118
+ };
119
+ if (state.step === "playing")
120
+ return { ...state, step: "done", downloadUrl: action.downloadUrl };
121
+ if (state.step === "done")
122
+ return { ...state, downloadUrl: action.downloadUrl };
123
+ return state;
92
124
  case "error":
93
- return { step: "idle" };
125
+ return { step: "idle", error: action.error ?? "Render failed" };
94
126
  }
95
127
  }
96
128
 
@@ -284,24 +316,32 @@ export default function EffiePreviewPage() {
284
316
  const actionData = useActionData<typeof action>();
285
317
  const navigation = useNavigation();
286
318
 
287
- const [render, dispatch] = useReducer(renderReducer, { step: "idle" });
319
+ const [render, dispatch] = useReducer(renderReducer, INITIAL_RENDER_STATE);
288
320
  const [elapsedToPlay, setElapsedToPlay] = useState<number | null>(null);
321
+ const prevDownloadUrlRef = useRef<string | null>(null);
289
322
 
290
323
  // Update elapsed time while rendering is in progress
291
324
  useEffect(() => {
292
- if (render.step === "idle") {
293
- setElapsedToPlay(null);
294
- return;
295
- }
296
- if (render.step === "done") {
297
- setElapsedToPlay((render.playbackAt - render.startedAt) / 1000);
298
- return;
325
+ switch (render.step) {
326
+ case "idle":
327
+ setElapsedToPlay(null);
328
+ return;
329
+ case "playing":
330
+ setElapsedToPlay((render.playbackAt - render.startedAt) / 1000);
331
+ return;
332
+ case "done":
333
+ if (render.playbackAt !== null)
334
+ setElapsedToPlay((render.playbackAt - render.startedAt) / 1000);
335
+ return;
336
+ case "starting":
337
+ case "streaming": {
338
+ const update = () =>
339
+ setElapsedToPlay((Date.now() - render.startedAt) / 1000);
340
+ update();
341
+ const interval = setInterval(update, 100);
342
+ return () => clearInterval(interval);
343
+ }
299
344
  }
300
- const update = () =>
301
- setElapsedToPlay((Date.now() - render.startedAt) / 1000);
302
- update();
303
- const interval = setInterval(update, 100);
304
- return () => clearInterval(interval);
305
345
  }, [render]);
306
346
 
307
347
  // Connect to SSE progress when render action completes
@@ -315,15 +355,23 @@ export default function EffiePreviewPage() {
315
355
  eventSource.addEventListener("ready", (e) => {
316
356
  try {
317
357
  const { videoUrl } = JSON.parse(e.data);
318
- dispatch({ type: "ready", videoUrl, scale: renderScale });
358
+ dispatch({ type: "stream", videoUrl, scale: renderScale });
319
359
  } catch {
320
360
  // Ignore parse errors
321
361
  }
322
362
  eventSource.close();
323
363
  });
324
364
 
325
- eventSource.addEventListener("error", () => {
326
- dispatch({ type: "error" });
365
+ eventSource.addEventListener("error", (e) => {
366
+ let error = "Render failed";
367
+ if (e instanceof MessageEvent) {
368
+ try {
369
+ error = JSON.parse(e.data).message;
370
+ } catch {
371
+ // use default
372
+ }
373
+ }
374
+ dispatch({ type: "error", error });
327
375
  eventSource.close();
328
376
  });
329
377
 
@@ -381,6 +429,19 @@ export default function EffiePreviewPage() {
381
429
 
382
430
  const handleRenderSubmit = () => {
383
431
  dispatch({ type: "start" });
432
+ if (prevDownloadUrlRef.current) {
433
+ URL.revokeObjectURL(prevDownloadUrlRef.current);
434
+ prevDownloadUrlRef.current = null;
435
+ }
436
+ };
437
+
438
+ const handleFullyBuffered = (blob: Blob) => {
439
+ if (prevDownloadUrlRef.current) {
440
+ URL.revokeObjectURL(prevDownloadUrlRef.current);
441
+ }
442
+ const downloadUrl = URL.createObjectURL(blob);
443
+ prevDownloadUrlRef.current = downloadUrl;
444
+ dispatch({ type: "finish", downloadUrl });
384
445
  };
385
446
 
386
447
  const formatSourceUrl = (url: string, maxLen = 70) => {
@@ -459,8 +520,15 @@ export default function EffiePreviewPage() {
459
520
  <EffieCoverPreview
460
521
  cover={effie.cover}
461
522
  resolution={coverResolution}
462
- video={"videoUrl" in render ? render.videoUrl : null}
523
+ video={
524
+ render.step === "streaming" ||
525
+ render.step === "playing" ||
526
+ render.step === "done"
527
+ ? render.videoUrl
528
+ : null
529
+ }
463
530
  onPlay={handleVideoPlay}
531
+ onFullyBuffered={handleFullyBuffered}
464
532
  style={{
465
533
  border: "1px solid black",
466
534
  backgroundColor: "#eee",
@@ -593,12 +661,51 @@ export default function EffiePreviewPage() {
593
661
  issues={actionData.issues}
594
662
  />
595
663
  )}
664
+ {render.step === "idle" && render.error && (
665
+ <div style={{ color: "#E44444" }}>{render.error}</div>
666
+ )}
667
+
668
+ {/* Render Progress */}
669
+ {(render.step === "starting" || render.step === "streaming") &&
670
+ elapsedToPlay !== null && (
671
+ <div style={{ color: "#4CAE4C" }}>
672
+ Rendering... {elapsedToPlay.toFixed(1)}s
673
+ </div>
674
+ )}
596
675
 
597
676
  {/* Render Success */}
598
- {render.step === "done" && elapsedToPlay !== null && (
599
- <div style={{ color: "#4CAE4C" }}>
600
- Started playing after {elapsedToPlay.toFixed(1)}s (at{" "}
601
- {Math.round(render.scale * 100)}%)
677
+ {(render.step === "playing" || render.step === "done") && (
678
+ <div
679
+ style={{
680
+ display: "flex",
681
+ flexDirection: "column",
682
+ alignItems: "flex-start",
683
+ gap: "1rem",
684
+ }}
685
+ >
686
+ {render.playbackAt !== null && elapsedToPlay !== null && (
687
+ <span style={{ color: "#4CAE4C" }}>
688
+ Started playing after {elapsedToPlay.toFixed(1)}s (at{" "}
689
+ {Math.round(render.scale * 100)}%)
690
+ </span>
691
+ )}
692
+ {render.step === "done" && (
693
+ <a
694
+ href={render.downloadUrl}
695
+ download={`${effieId}-${width}x${height}.mp4`}
696
+ style={{
697
+ padding: "0.4rem 0.75rem",
698
+ backgroundColor: "#fff",
699
+ color: "#4CAE4C",
700
+ border: "1px solid #4CAE4C",
701
+ borderRadius: 4,
702
+ fontSize: "14px",
703
+ textDecoration: "none",
704
+ }}
705
+ >
706
+ Download video
707
+ </a>
708
+ )}
602
709
  </div>
603
710
  )}
604
711
 
@@ -3,7 +3,9 @@
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
5
  "scripts": {
6
- "build": "react-router build",
6
+ "build": "run-s build:app build:satori",
7
+ "build:app": "react-router build",
8
+ "build:satori": "vite build -c vite.config.satori.ts",
7
9
  "dev": "run-p dev:*",
8
10
  "dev:app": "react-router dev",
9
11
  "dev:ffs": "ffs",
@@ -11,15 +13,16 @@
11
13
  "typecheck": "react-router typegen && tsc"
12
14
  },
13
15
  "dependencies": {
14
- "@effing/annie": "^0.14.1",
15
- "@effing/annie-player": "^0.14.1",
16
- "@effing/effie": "^0.14.1",
17
- "@effing/effie-preview": "^0.14.1",
18
- "@effing/ffs": "^0.14.1",
19
- "@effing/satori": "^0.14.1",
16
+ "@effing/annie": "^0.15.1",
17
+ "@effing/annie-player": "^0.15.1",
18
+ "@effing/effie": "^0.15.1",
19
+ "@effing/effie-preview": "^0.15.1",
20
+ "@effing/ffs": "^0.15.1",
21
+ "@effing/satori": "^0.15.1",
20
22
  "@resvg/resvg-js": "^2.6.2",
21
- "@effing/serde": "^0.14.1",
22
- "@effing/tween": "^0.14.1",
23
+ "@effing/serde": "^0.15.1",
24
+ "satori": "^0.19.2",
25
+ "@effing/tween": "^0.15.1",
23
26
  "@react-router/node": "^7.0.0",
24
27
  "@react-router/serve": "^7.0.0",
25
28
  "cross-env": "^7.0.3",
@@ -38,7 +41,7 @@
38
41
  "@types/react-dom": "^19.0.0",
39
42
  "npm-run-all": "^4.1.5",
40
43
  "typescript": "^5.9.3",
41
- "vite": "^6.0.0",
44
+ "vite": "^7.0.0",
42
45
  "vite-tsconfig-paths": "^4.3.2"
43
46
  },
44
47
  "engines": {
@@ -0,0 +1,34 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { defineConfig } from "vite";
3
+
4
+ const workerEntry = fileURLToPath(import.meta.resolve("@effing/satori/worker"));
5
+
6
+ // Second Vite build that bundles the satori worker into a single `build/satori.mjs`.
7
+ // Referenced by `app/pool.server.ts` as the worker file in production.
8
+ //
9
+ // Why bundle: The satori pool spawns worker_threads via bare `import()`. Each
10
+ // worker thread independently resolves and parses the full dependency tree on
11
+ // startup (no shared module cache with the main thread). Bundling all pure-JS
12
+ // deps into one file avoids that overhead.
13
+ //
14
+ // Why `@resvg/resvg-js` stays external: It uses a CJS runtime shim to load
15
+ // platform-specific `.node` native binaries — can't be statically bundled.
16
+ //
17
+ // Why `emptyOutDir: false`: The main app build already placed files in `build/`.
18
+ export default defineConfig({
19
+ build: {
20
+ ssr: true,
21
+ outDir: "build",
22
+ rollupOptions: {
23
+ input: workerEntry,
24
+ external: [/\.node$/, "@resvg/resvg-js"],
25
+ output: {
26
+ entryFileNames: "satori.mjs",
27
+ },
28
+ },
29
+ emptyOutDir: false,
30
+ },
31
+ ssr: {
32
+ noExternal: true,
33
+ },
34
+ });
@@ -1,15 +1,10 @@
1
1
  import { reactRouter } from "@react-router/dev/vite";
2
- import { satoriPoolPlugin } from "@effing/satori/vite";
3
2
  import { defineConfig } from "vite";
4
3
  import tsconfigPaths from "vite-tsconfig-paths";
5
4
 
6
5
  export default defineConfig({
7
6
  server: { port: 3839 }, // 3839 = 0xEFF, how effing cool is that? ʘ‿ʘ
8
- plugins: [reactRouter(), tsconfigPaths(), satoriPoolPlugin()],
9
- ssr: {
10
- noExternal: ["yoga-wasm-web", "yoga-wasm-web/asm", "satori", "emoji-regex"],
11
- external: ["@resvg/resvg-js"],
12
- },
7
+ plugins: [reactRouter(), tsconfigPaths()],
13
8
  optimizeDeps: {
14
9
  exclude: ["@resvg/resvg-js"],
15
10
  },