@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 +2 -2
- package/package.json +1 -1
- package/template/Dockerfile +38 -0
- package/template/_DOT_dockerignore +6 -0
- package/template/app/pool.server.ts +6 -1
- package/template/app/routes/pff.$effieId.tsx +142 -35
- package/template/package.json +13 -10
- package/template/vite.config.satori.ts +34 -0
- package/template/vite.config.ts +1 -6
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
|
@@ -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"]
|
|
@@ -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: "
|
|
62
|
-
| { step: "ready"; startedAt: number; videoUrl: string; scale: number }
|
|
60
|
+
| { step: "idle"; error: string | null }
|
|
61
|
+
| { step: "starting"; startedAt: number }
|
|
63
62
|
| {
|
|
64
|
-
step: "
|
|
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: "
|
|
86
|
+
| { type: "stream"; videoUrl: string; scale: number }
|
|
74
87
|
| { type: "play" }
|
|
75
|
-
| { type: "
|
|
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: "
|
|
81
|
-
case "
|
|
82
|
-
if (state.step !== "
|
|
96
|
+
return { step: "starting", startedAt: Date.now() };
|
|
97
|
+
case "stream":
|
|
98
|
+
if (state.step !== "starting") return state;
|
|
83
99
|
return {
|
|
84
|
-
step: "
|
|
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
|
|
91
|
-
|
|
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,
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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: "
|
|
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
|
-
|
|
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={
|
|
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 === "
|
|
599
|
-
<div
|
|
600
|
-
|
|
601
|
-
|
|
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
|
|
package/template/package.json
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"scripts": {
|
|
6
|
-
"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.
|
|
15
|
-
"@effing/annie-player": "^0.
|
|
16
|
-
"@effing/effie": "^0.
|
|
17
|
-
"@effing/effie-preview": "^0.
|
|
18
|
-
"@effing/ffs": "^0.
|
|
19
|
-
"@effing/satori": "^0.
|
|
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.
|
|
22
|
-
"
|
|
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": "^
|
|
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
|
+
});
|
package/template/vite.config.ts
CHANGED
|
@@ -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()
|
|
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
|
},
|