@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
package/LICENSE
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
O'Saasy License
|
|
2
|
+
|
|
3
|
+
Copyright © 2026, Trackuity BV.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @effing/create
|
|
2
|
+
|
|
3
|
+
**Scaffold a new Effing project with the starter template.**
|
|
4
|
+
|
|
5
|
+
> Part of the [**Effing**](../../README.md) family — programmatic video creation with TypeScript.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Using npm
|
|
11
|
+
npm create @effing my-app
|
|
12
|
+
|
|
13
|
+
# Using pnpm
|
|
14
|
+
pnpm create @effing my-app
|
|
15
|
+
|
|
16
|
+
# Using yarn
|
|
17
|
+
yarn create @effing my-app
|
|
18
|
+
|
|
19
|
+
# Using npx directly
|
|
20
|
+
npx @effing/create my-app
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
cd my-app
|
|
27
|
+
npm install
|
|
28
|
+
npm run dev
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Open [http://localhost:3839](http://localhost:3839) to see your project.
|
|
32
|
+
|
|
33
|
+
## What's included
|
|
34
|
+
|
|
35
|
+
The starter template includes:
|
|
36
|
+
|
|
37
|
+
- **React Router** for routing and nifty preview pages
|
|
38
|
+
- **Annies** — Frame-based animations streamed as TAR archives
|
|
39
|
+
- **Effies** — Video compositions combining animations, images, and audio
|
|
40
|
+
- **FFS integration** — A “Render it FFS” button that can POST your Effie JSON to an `@effing/ffs` server (which you can run locally or remotely)
|
|
41
|
+
- Example annies and effies to get you started
|
|
42
|
+
|
|
43
|
+
## Development
|
|
44
|
+
|
|
45
|
+
This package is part of the Effing monorepo.
|
|
46
|
+
|
|
47
|
+
### Testing locally
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# From the monorepo root
|
|
51
|
+
cd packages/create
|
|
52
|
+
pnpm install
|
|
53
|
+
pnpm build
|
|
54
|
+
|
|
55
|
+
# Test the CLI (creates a project outside the monorepo)
|
|
56
|
+
node dist/index.js /tmp/test-effing-app
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Updating the template
|
|
60
|
+
|
|
61
|
+
1. Make changes in `demos/starter`
|
|
62
|
+
2. Run `pnpm build` in this package
|
|
63
|
+
3. The `prebuild` script automatically copies `demos/starter` → `template/`, renaming dotfiles and replacing `workspace:*` versions
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import fs from "fs/promises";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
var __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
async function copyDir(src, dest) {
|
|
9
|
+
await fs.mkdir(dest, { recursive: true });
|
|
10
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
11
|
+
for (const entry of entries) {
|
|
12
|
+
const srcPath = path.join(src, entry.name);
|
|
13
|
+
const destName = entry.name.startsWith("_") ? "." + entry.name.slice(1) : entry.name;
|
|
14
|
+
const destPath = path.join(dest, destName);
|
|
15
|
+
if (entry.isDirectory()) {
|
|
16
|
+
await copyDir(srcPath, destPath);
|
|
17
|
+
} else {
|
|
18
|
+
await fs.copyFile(srcPath, destPath);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function printUsage() {
|
|
23
|
+
console.log(`
|
|
24
|
+
Usage: npm create @effing <project-name>
|
|
25
|
+
|
|
26
|
+
Creates a new Effing project with the starter template.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
npm create @effing my-app
|
|
30
|
+
pnpm create @effing my-app
|
|
31
|
+
yarn create @effing my-app
|
|
32
|
+
`);
|
|
33
|
+
}
|
|
34
|
+
async function main() {
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
37
|
+
printUsage();
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
const projectName = args[0];
|
|
41
|
+
if (!projectName) {
|
|
42
|
+
console.error("Error: Please specify a project name.\n");
|
|
43
|
+
printUsage();
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const root = path.resolve(projectName);
|
|
47
|
+
const templateDir = path.resolve(__dirname, "../template");
|
|
48
|
+
try {
|
|
49
|
+
await fs.access(root);
|
|
50
|
+
console.error(`Error: Directory "${projectName}" already exists.`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
console.log(`
|
|
55
|
+
Creating a new Effing project in ${root}...
|
|
56
|
+
`);
|
|
57
|
+
await copyDir(templateDir, root);
|
|
58
|
+
const pkgPath = path.join(root, "package.json");
|
|
59
|
+
const pkg = JSON.parse(await fs.readFile(pkgPath, "utf-8"));
|
|
60
|
+
pkg.name = path.basename(root);
|
|
61
|
+
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
62
|
+
console.log("Done! To get started:\n");
|
|
63
|
+
console.log(` cd ${projectName}`);
|
|
64
|
+
console.log(" npm install");
|
|
65
|
+
console.log(" npm run dev\n");
|
|
66
|
+
console.log("Then open http://localhost:3839 to see your project.\n");
|
|
67
|
+
}
|
|
68
|
+
main().catch((err) => {
|
|
69
|
+
console.error("Error:", err.message);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@effing/create",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create a new Effing project",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": "./dist/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"template"
|
|
10
|
+
],
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"tsup": "^8.0.0",
|
|
13
|
+
"typescript": "^5.9.3"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"effing",
|
|
20
|
+
"create",
|
|
21
|
+
"scaffold",
|
|
22
|
+
"cli"
|
|
23
|
+
],
|
|
24
|
+
"license": "O'Saasy",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"prebuild": "node scripts/copy-template.js",
|
|
30
|
+
"build": "tsup",
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# Effing Starter Demo
|
|
2
|
+
|
|
3
|
+
A complete example application demonstrating how to build Annies (animations) and Effies (video compositions) using the `@effing/*` packages.
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd demos/starter
|
|
9
|
+
pnpm install
|
|
10
|
+
pnpm dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Open [http://localhost:3839](http://localhost:3839) to see the demo.
|
|
14
|
+
|
|
15
|
+
## Project Structure
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
demos/starter/
|
|
19
|
+
├── app/
|
|
20
|
+
│ ├── annies/ # Annie animation definitions
|
|
21
|
+
│ │ ├── index.ts # Annie registry and types
|
|
22
|
+
│ │ └── *.annie.tsx # Annies
|
|
23
|
+
│ ├── effies/ # Effie composition definitions
|
|
24
|
+
│ │ ├── index.ts # Effie registry and types
|
|
25
|
+
│ │ └── *.effie.tsx # Effies
|
|
26
|
+
│ ├── routes/
|
|
27
|
+
│ │ ├── _index.tsx # Homepage listing all annies/effies
|
|
28
|
+
│ │ ├── an.$segment.tsx # Annie TAR streaming endpoint
|
|
29
|
+
│ │ ├── ff.$segment.tsx # Effie JSON endpoint
|
|
30
|
+
│ │ ├── pan.$annieId.tsx # Annie preview page
|
|
31
|
+
│ │ └── pff.$effieId.tsx # Effie preview page
|
|
32
|
+
│ ├── fonts.server.ts # Font definitions and loading utils
|
|
33
|
+
│ └── urls.server.ts # URL generation helpers
|
|
34
|
+
└── vite.config.ts
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Creating Annies
|
|
38
|
+
|
|
39
|
+
Annies are frame-based animations. Create a file at `app/annies/*.annie.tsx`:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
// app/annies/my-animation.annie.tsx
|
|
43
|
+
import { z } from "zod";
|
|
44
|
+
import { pngFromSatori } from "@effing/satori";
|
|
45
|
+
import { tween, easeOutQuad } from "@effing/tween";
|
|
46
|
+
import type { AnnieRendererArgs } from ".";
|
|
47
|
+
|
|
48
|
+
// 1. Define props schema
|
|
49
|
+
export const propsSchema = z.object({
|
|
50
|
+
text: z.string(),
|
|
51
|
+
frameCount: z.number().int().min(1).optional(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
type MyAnimationProps = z.infer<typeof propsSchema>;
|
|
55
|
+
|
|
56
|
+
// 2. Define preview props (used by preview page)
|
|
57
|
+
export const previewProps: MyAnimationProps = {
|
|
58
|
+
text: "Hello!",
|
|
59
|
+
frameCount: 60,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// 3. Export async generator that yields PNG frames
|
|
63
|
+
export async function* renderer({
|
|
64
|
+
props: { text, frameCount = 60 },
|
|
65
|
+
width,
|
|
66
|
+
height,
|
|
67
|
+
}: AnnieRendererArgs<MyAnimationProps>): AsyncGenerator<Buffer> {
|
|
68
|
+
const fonts = await loadFonts([myFont]);
|
|
69
|
+
|
|
70
|
+
yield* tween(frameCount, async ({ lower: progress }) => {
|
|
71
|
+
const scale = 1 + 0.3 * easeOutQuad(progress);
|
|
72
|
+
|
|
73
|
+
return pngFromSatori(
|
|
74
|
+
<div style={{
|
|
75
|
+
width, height,
|
|
76
|
+
display: "flex",
|
|
77
|
+
alignItems: "center",
|
|
78
|
+
justifyContent: "center",
|
|
79
|
+
fontSize: 72,
|
|
80
|
+
transform: `scale(${scale})`,
|
|
81
|
+
}}>
|
|
82
|
+
{text}
|
|
83
|
+
</div>,
|
|
84
|
+
{ width, height, fonts }
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The Annie will automatically appear on the homepage and be accessible at:
|
|
91
|
+
|
|
92
|
+
- **Preview:** `/pan/my-animation`
|
|
93
|
+
- **TAR stream:** `/an/{signed-segment}?w=1080&h=1080`
|
|
94
|
+
|
|
95
|
+
## Creating Effies
|
|
96
|
+
|
|
97
|
+
Effies are video compositions that combine Annies, images, audio, and effects. Create a file at `app/effies/*.effie.tsx`:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
// app/effies/my-video.effie.tsx
|
|
101
|
+
import { z } from "zod";
|
|
102
|
+
import { effieData, effieSegment } from "@effing/effie";
|
|
103
|
+
import { annieUrl } from "~/urls.server";
|
|
104
|
+
import type { EffieRendererArgs } from ".";
|
|
105
|
+
|
|
106
|
+
// 1. Define props schema
|
|
107
|
+
export const propsSchema = z.object({
|
|
108
|
+
title: z.string(),
|
|
109
|
+
imageUrl: z.string().url(),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
type MyVideoProps = z.infer<typeof propsSchema>;
|
|
113
|
+
|
|
114
|
+
// 2. Define preview props
|
|
115
|
+
export const previewProps: MyVideoProps = {
|
|
116
|
+
title: "My Video",
|
|
117
|
+
imageUrl: "https://picsum.photos/1080/1920",
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// 3. Export async function that returns EffieData
|
|
121
|
+
export async function renderer({
|
|
122
|
+
props: { title, imageUrl },
|
|
123
|
+
width,
|
|
124
|
+
height,
|
|
125
|
+
}: EffieRendererArgs<MyVideoProps>) {
|
|
126
|
+
return effieData({
|
|
127
|
+
width,
|
|
128
|
+
height,
|
|
129
|
+
fps: 30,
|
|
130
|
+
cover: imageUrl,
|
|
131
|
+
background: { type: "color", color: "black" },
|
|
132
|
+
segments: [
|
|
133
|
+
effieSegment({
|
|
134
|
+
duration: 5,
|
|
135
|
+
layers: [
|
|
136
|
+
{
|
|
137
|
+
type: "animation",
|
|
138
|
+
source: await annieUrl({
|
|
139
|
+
annieId: "text-typewriter",
|
|
140
|
+
props: { text: title, fontSize: 72 },
|
|
141
|
+
width,
|
|
142
|
+
height,
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
}),
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The Effie will automatically appear on the homepage and be accessible at:
|
|
153
|
+
|
|
154
|
+
- **Preview:** `/pff/my-video`
|
|
155
|
+
- **JSON:** `/ff/{signed-segment}?ratio=1:1`
|
|
156
|
+
|
|
157
|
+
## Routes
|
|
158
|
+
|
|
159
|
+
### Homepage (`/`)
|
|
160
|
+
|
|
161
|
+
Lists all available Annies and Effies with links to their preview pages.
|
|
162
|
+
|
|
163
|
+
### Annie Preview (`/pan/:annieId`)
|
|
164
|
+
|
|
165
|
+
Displays an interactive preview of an Annie using `@effing/annie-player`. Shows:
|
|
166
|
+
|
|
167
|
+
- Playable animation with load/play/pause controls
|
|
168
|
+
- Direct URL to the TAR stream
|
|
169
|
+
|
|
170
|
+
### Effie Preview (`/pff/:effieId`)
|
|
171
|
+
|
|
172
|
+
Comprehensive preview of an Effie composition using `@effing/effie-preview`. Shows:
|
|
173
|
+
|
|
174
|
+
- Cover image (or rendered video after clicking "Render it FFS")
|
|
175
|
+
- Background preview
|
|
176
|
+
- All segments with their layers
|
|
177
|
+
- Render button to generate video via FFS
|
|
178
|
+
|
|
179
|
+
### Annie Stream (`/an/:segment`)
|
|
180
|
+
|
|
181
|
+
Serves Annie TAR archives. The segment is a signed payload containing:
|
|
182
|
+
|
|
183
|
+
- `annieId` — Which Annie to render
|
|
184
|
+
- Props — Animation parameters
|
|
185
|
+
|
|
186
|
+
### Effie JSON (`/ff/:segment`)
|
|
187
|
+
|
|
188
|
+
Serves Effie JSON. The segment is a signed payload containing:
|
|
189
|
+
|
|
190
|
+
- `effieId` — Which Effie to render
|
|
191
|
+
- Props — Composition parameters
|
|
192
|
+
|
|
193
|
+
## CDN Caching
|
|
194
|
+
|
|
195
|
+
Both the `/an/:segment` and `/ff/:segment` routes can easily be placed behind a CDN in production. Since the segment contains signed, deterministic parameters, the same URL always produces the same output, making them ideal cache keys.
|
|
196
|
+
|
|
197
|
+
Note also that CDN timeouts are not a concern for the annies, even though they might take a while to generate in practice, because they are streamed frame by frame. The CDN receives data continuously and won't time out waiting for the first byte.
|
|
198
|
+
|
|
199
|
+
## Environment Variables
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
# Required: Base URL for the application
|
|
203
|
+
BASE_URL=http://localhost:3839
|
|
204
|
+
# Required: Secret for signing URL segments
|
|
205
|
+
SECRET_KEY=your-secret-key
|
|
206
|
+
|
|
207
|
+
# Optional: FFS rendering service
|
|
208
|
+
FFS_BASE_URL=http://localhost:2000
|
|
209
|
+
FFS_API_KEY=your-ffs-api-key
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## URL Generation
|
|
213
|
+
|
|
214
|
+
The `urls.server.ts` module provides helpers for generating URLs to be used in effies:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
import { annieUrl, pngUrlFromSatori } from "~/urls.server";
|
|
218
|
+
|
|
219
|
+
// Generate signed Annie URL
|
|
220
|
+
const url = await annieUrl({
|
|
221
|
+
annieId: "text-typewriter",
|
|
222
|
+
props: { text: "Hello", fontSize: 72 },
|
|
223
|
+
width: 1080,
|
|
224
|
+
height: 1920,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Generate data URL from JSX
|
|
228
|
+
const coverUrl = await pngUrlFromSatori(
|
|
229
|
+
<div>Cover Image</div>,
|
|
230
|
+
{ width: 1080, height: 1920, fonts }
|
|
231
|
+
);
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Rendering Videos
|
|
235
|
+
|
|
236
|
+
The preview page can render videos if FFS is configured:
|
|
237
|
+
|
|
238
|
+
1. Set `FFS_BASE_URL` and `FFS_API_KEY` environment variables
|
|
239
|
+
2. Open an Effie preview page (`/pff/:effieId`)
|
|
240
|
+
3. Click "Render it FFS" to send the composition to the rendering service
|
|
241
|
+
4. The rendered video appears in place of the cover image
|
|
242
|
+
|
|
243
|
+
You can also render at different scales (33%, 67%, 100%, 200%) for faster previews or higher quality output.
|
|
244
|
+
|
|
245
|
+
In production, you'd use an FFS server directly to render effies. Point FFS at your `/ff/:segment` endpoint and it will fetch the effie JSON, resolve all annie URLs, and produce the final video.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import invariant from "tiny-invariant";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Arguments passed to annie renderer functions
|
|
6
|
+
*/
|
|
7
|
+
export type AnnieRendererArgs<PropsType> = {
|
|
8
|
+
props: PropsType;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type AnnieModule = {
|
|
14
|
+
propsSchema: z.ZodSchema<unknown>;
|
|
15
|
+
previewProps: object;
|
|
16
|
+
renderer: (args: AnnieRendererArgs<unknown>) => AsyncGenerator<Buffer>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Dynamically load all annie modules
|
|
20
|
+
const modules = Object.fromEntries(
|
|
21
|
+
Object.entries(import.meta.glob("./*.annie.tsx")).map(([key, value]) => {
|
|
22
|
+
const id = key.split("/").slice(-1)[0].replace(".annie.tsx", "");
|
|
23
|
+
return [id, value];
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export type AnnieId = string;
|
|
28
|
+
|
|
29
|
+
export function isAnnieId(annieId: string): annieId is AnnieId {
|
|
30
|
+
return annieId in modules;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getAnnieIds(): string[] {
|
|
34
|
+
return Object.keys(modules);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function getAnnie(annieId: string): Promise<AnnieModule> {
|
|
38
|
+
invariant(isAnnieId(annieId), `no annie found for annieId '${annieId}'`);
|
|
39
|
+
const module = (await modules[annieId]()) as AnnieModule;
|
|
40
|
+
return {
|
|
41
|
+
renderer: module.renderer,
|
|
42
|
+
previewProps: module.previewProps,
|
|
43
|
+
propsSchema: module.propsSchema,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { tween, easeOutQuad } from "@effing/tween";
|
|
4
|
+
import type { AnnieRendererArgs } from ".";
|
|
5
|
+
|
|
6
|
+
export const propsSchema = z.object({
|
|
7
|
+
imageUrl: z.string().url(),
|
|
8
|
+
frameCount: z.number().int().min(1).optional(),
|
|
9
|
+
zoomLevel: z.number().optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export type PhotoZoomProps = z.infer<typeof propsSchema>;
|
|
13
|
+
|
|
14
|
+
export const previewProps: PhotoZoomProps = {
|
|
15
|
+
imageUrl: "https://picsum.photos/1200/1200",
|
|
16
|
+
frameCount: 120,
|
|
17
|
+
zoomLevel: 0.2,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function* renderer({
|
|
21
|
+
props: { imageUrl, frameCount = 90, zoomLevel = 0.2 },
|
|
22
|
+
width,
|
|
23
|
+
height,
|
|
24
|
+
}: AnnieRendererArgs<PhotoZoomProps>): AsyncGenerator<Buffer> {
|
|
25
|
+
// Fetch and decode the source image
|
|
26
|
+
const image = await fetch(imageUrl);
|
|
27
|
+
const imageBuffer = await image.arrayBuffer();
|
|
28
|
+
const { data: originalImageData, info: originalImageInfo } = await sharp(
|
|
29
|
+
Buffer.from(imageBuffer),
|
|
30
|
+
)
|
|
31
|
+
.raw()
|
|
32
|
+
.toBuffer({ resolveWithObject: true });
|
|
33
|
+
|
|
34
|
+
const imageSharp = sharp(originalImageData, {
|
|
35
|
+
raw: {
|
|
36
|
+
width: originalImageInfo.width,
|
|
37
|
+
height: originalImageInfo.height,
|
|
38
|
+
channels: originalImageInfo.channels,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Generate frames with zoom effect
|
|
43
|
+
yield* tween(frameCount, async ({ lower: p }) => {
|
|
44
|
+
const zoomValue = 1 + zoomLevel * easeOutQuad(p);
|
|
45
|
+
const ow = Math.round(width * zoomValue);
|
|
46
|
+
const oh = Math.round(height * zoomValue);
|
|
47
|
+
const left = Math.floor((ow - width) / 2);
|
|
48
|
+
const top = Math.floor((oh - height) / 2);
|
|
49
|
+
return imageSharp
|
|
50
|
+
.clone()
|
|
51
|
+
.resize(ow, oh, { fit: "cover" })
|
|
52
|
+
.extract({ left, top, width, height })
|
|
53
|
+
.jpeg()
|
|
54
|
+
.toBuffer();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { interBold, loadFonts } from "~/fonts.server";
|
|
3
|
+
import { pngFromSatori } from "@effing/satori";
|
|
4
|
+
import { tween } from "@effing/tween";
|
|
5
|
+
import type { AnnieRendererArgs } from ".";
|
|
6
|
+
|
|
7
|
+
export const propsSchema = z.object({
|
|
8
|
+
text: z.string(),
|
|
9
|
+
fontSize: z.number().int().min(1),
|
|
10
|
+
fontFamily: z.enum(["Inter", "Roboto", "Open Sans"]).optional(),
|
|
11
|
+
fontColor: z.string().optional(),
|
|
12
|
+
typingFrameCount: z.number().int().min(10).optional(),
|
|
13
|
+
blinkingFrameCount: z.number().int().min(0).optional(),
|
|
14
|
+
horizontalAlignment: z.enum(["left", "center", "right"]).optional(),
|
|
15
|
+
verticalAlignment: z.enum(["top", "center", "bottom"]).optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type TextTypewriterProps = z.infer<typeof propsSchema>;
|
|
19
|
+
|
|
20
|
+
export const previewProps: TextTypewriterProps = {
|
|
21
|
+
text: "Hello World!",
|
|
22
|
+
fontSize: 72,
|
|
23
|
+
fontFamily: "Inter",
|
|
24
|
+
fontColor: "#ffffff",
|
|
25
|
+
horizontalAlignment: "center",
|
|
26
|
+
verticalAlignment: "center",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export async function* renderer({
|
|
30
|
+
props: {
|
|
31
|
+
text,
|
|
32
|
+
fontSize,
|
|
33
|
+
fontFamily = "Inter",
|
|
34
|
+
fontColor = "#ffffff",
|
|
35
|
+
typingFrameCount,
|
|
36
|
+
blinkingFrameCount = 60,
|
|
37
|
+
horizontalAlignment = "center",
|
|
38
|
+
verticalAlignment = "center",
|
|
39
|
+
},
|
|
40
|
+
width,
|
|
41
|
+
height,
|
|
42
|
+
}: AnnieRendererArgs<TextTypewriterProps>): AsyncGenerator<Buffer> {
|
|
43
|
+
const fonts = await loadFonts([interBold]);
|
|
44
|
+
|
|
45
|
+
if (!typingFrameCount) {
|
|
46
|
+
typingFrameCount = text.length * 3;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Typing phase
|
|
50
|
+
yield* tween(typingFrameCount, async ({ lower: p }) => {
|
|
51
|
+
const charsShown = Math.floor(p * text.length);
|
|
52
|
+
const textToShow = text.slice(0, charsShown);
|
|
53
|
+
return pngFromSatori(
|
|
54
|
+
<TextTypewriterOverlay
|
|
55
|
+
text={textToShow}
|
|
56
|
+
fontSize={fontSize}
|
|
57
|
+
fontFamily={fontFamily}
|
|
58
|
+
fontColor={fontColor}
|
|
59
|
+
horizontalAlignment={horizontalAlignment}
|
|
60
|
+
verticalAlignment={verticalAlignment}
|
|
61
|
+
cursorShown={true}
|
|
62
|
+
/>,
|
|
63
|
+
{ width, height, fonts },
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Blinking cursor phase
|
|
68
|
+
yield* tween(blinkingFrameCount, async ({ lower: p }) => {
|
|
69
|
+
const cursorShown = Math.floor(p * 5) % 2 === 1;
|
|
70
|
+
return pngFromSatori(
|
|
71
|
+
<TextTypewriterOverlay
|
|
72
|
+
text={text}
|
|
73
|
+
fontSize={fontSize}
|
|
74
|
+
fontFamily={fontFamily}
|
|
75
|
+
fontColor={fontColor}
|
|
76
|
+
horizontalAlignment={horizontalAlignment}
|
|
77
|
+
verticalAlignment={verticalAlignment}
|
|
78
|
+
cursorShown={cursorShown}
|
|
79
|
+
/>,
|
|
80
|
+
{ width, height, fonts },
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function TextTypewriterOverlay({
|
|
86
|
+
text,
|
|
87
|
+
fontFamily = "Inter",
|
|
88
|
+
fontSize,
|
|
89
|
+
fontColor = "#ffffff",
|
|
90
|
+
horizontalAlignment = "center",
|
|
91
|
+
verticalAlignment = "center",
|
|
92
|
+
cursorShown,
|
|
93
|
+
}: {
|
|
94
|
+
text: string;
|
|
95
|
+
fontFamily?: string;
|
|
96
|
+
fontSize: number;
|
|
97
|
+
fontColor?: string;
|
|
98
|
+
horizontalAlignment?: "left" | "center" | "right";
|
|
99
|
+
verticalAlignment?: "top" | "center" | "bottom";
|
|
100
|
+
cursorShown: boolean;
|
|
101
|
+
}) {
|
|
102
|
+
return (
|
|
103
|
+
<div
|
|
104
|
+
style={{
|
|
105
|
+
display: "flex",
|
|
106
|
+
position: "absolute",
|
|
107
|
+
top: 0,
|
|
108
|
+
left: 0,
|
|
109
|
+
right: 0,
|
|
110
|
+
bottom: 0,
|
|
111
|
+
alignItems: { top: "flex-start", center: "center", bottom: "flex-end" }[
|
|
112
|
+
verticalAlignment
|
|
113
|
+
],
|
|
114
|
+
justifyContent: {
|
|
115
|
+
left: "flex-start",
|
|
116
|
+
center: "center",
|
|
117
|
+
right: "flex-end",
|
|
118
|
+
}[horizontalAlignment],
|
|
119
|
+
padding: 40,
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<div
|
|
123
|
+
style={{
|
|
124
|
+
fontFamily,
|
|
125
|
+
fontSize: fontSize,
|
|
126
|
+
fontWeight: "bold",
|
|
127
|
+
color: fontColor,
|
|
128
|
+
display: "flex",
|
|
129
|
+
flexDirection: "row",
|
|
130
|
+
alignItems: "center",
|
|
131
|
+
justifyContent: "center",
|
|
132
|
+
textAlign: horizontalAlignment,
|
|
133
|
+
whiteSpace: "nowrap",
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
{text}
|
|
137
|
+
<span
|
|
138
|
+
style={{
|
|
139
|
+
height: fontSize,
|
|
140
|
+
width: 3,
|
|
141
|
+
display: "block",
|
|
142
|
+
backgroundColor: fontColor,
|
|
143
|
+
marginLeft: 4,
|
|
144
|
+
verticalAlign: "middle",
|
|
145
|
+
opacity: Number(cursorShown),
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|