@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,48 @@
|
|
|
1
|
+
import invariant from "tiny-invariant";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import type { EffieData, EffieSources } from "@effing/effie";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Arguments passed to effie renderer functions
|
|
7
|
+
*/
|
|
8
|
+
export type EffieRendererArgs<PropsType> = {
|
|
9
|
+
props: PropsType;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type EffieModule = {
|
|
15
|
+
propsSchema: z.ZodSchema<unknown>;
|
|
16
|
+
previewProps: object;
|
|
17
|
+
renderer: (
|
|
18
|
+
args: EffieRendererArgs<unknown>,
|
|
19
|
+
) => Promise<EffieData<EffieSources>>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Dynamically load all effie modules
|
|
23
|
+
const modules = Object.fromEntries(
|
|
24
|
+
Object.entries(import.meta.glob("./*.effie.tsx")).map(([key, value]) => {
|
|
25
|
+
const id = key.split("/").slice(-1)[0].replace(".effie.tsx", "");
|
|
26
|
+
return [id, value];
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export type EffieId = string;
|
|
31
|
+
|
|
32
|
+
export function isEffieId(effieId: string): effieId is EffieId {
|
|
33
|
+
return effieId in modules;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getEffieIds(): string[] {
|
|
37
|
+
return Object.keys(modules);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getEffie(effieId: string): Promise<EffieModule> {
|
|
41
|
+
invariant(isEffieId(effieId), `no effie found for effieId '${effieId}'`);
|
|
42
|
+
const module = (await modules[effieId]()) as EffieModule;
|
|
43
|
+
return {
|
|
44
|
+
renderer: module.renderer,
|
|
45
|
+
previewProps: module.previewProps,
|
|
46
|
+
propsSchema: module.propsSchema,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { effieData, effieSegment } from "@effing/effie";
|
|
3
|
+
import { annieUrl, pngUrlFromSatori } from "~/urls.server";
|
|
4
|
+
import type { EffieRendererArgs } from ".";
|
|
5
|
+
import { loadFonts, interSemiBold } from "~/fonts.server";
|
|
6
|
+
import type { PhotoZoomProps } from "~/annies/photo-zoom.annie";
|
|
7
|
+
import {
|
|
8
|
+
TextTypewriterOverlay,
|
|
9
|
+
type TextTypewriterProps,
|
|
10
|
+
} from "~/annies/text-typewriter.annie";
|
|
11
|
+
|
|
12
|
+
export const propsSchema = z.object({
|
|
13
|
+
slides: z.array(
|
|
14
|
+
z.object({
|
|
15
|
+
text: z.string(),
|
|
16
|
+
imageUrl: z.string().url(),
|
|
17
|
+
duration: z.number().positive(),
|
|
18
|
+
}),
|
|
19
|
+
),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
type SimpleSlideshowProps = z.infer<typeof propsSchema>;
|
|
23
|
+
|
|
24
|
+
export const previewProps: SimpleSlideshowProps = {
|
|
25
|
+
slides: [
|
|
26
|
+
{
|
|
27
|
+
text: "How effing awesome is this?",
|
|
28
|
+
imageUrl: "https://picsum.photos/seed/slide1/1080/1920",
|
|
29
|
+
duration: 6,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
text: "Create beautiful videos 🤩",
|
|
33
|
+
imageUrl: "https://picsum.photos/seed/slide2/1080/1920",
|
|
34
|
+
duration: 5,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
text: "With total ease 😎",
|
|
38
|
+
imageUrl: "https://picsum.photos/seed/slide3/1080/1920",
|
|
39
|
+
duration: 4,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export async function renderer({
|
|
45
|
+
props: { slides },
|
|
46
|
+
width,
|
|
47
|
+
height,
|
|
48
|
+
}: EffieRendererArgs<SimpleSlideshowProps>) {
|
|
49
|
+
const fonts = await loadFonts([interSemiBold]);
|
|
50
|
+
|
|
51
|
+
// Generate cover from first slide
|
|
52
|
+
const cover = await pngUrlFromSatori(
|
|
53
|
+
<div
|
|
54
|
+
style={{
|
|
55
|
+
width,
|
|
56
|
+
height,
|
|
57
|
+
display: "flex",
|
|
58
|
+
backgroundImage: `url(${slides[0].imageUrl})`,
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<TextTypewriterOverlay
|
|
62
|
+
text={slides[0].text}
|
|
63
|
+
fontSize={Math.round(width * 0.06)}
|
|
64
|
+
fontColor={"#ffffff"}
|
|
65
|
+
horizontalAlignment="center"
|
|
66
|
+
verticalAlignment="center"
|
|
67
|
+
cursorShown={false}
|
|
68
|
+
/>
|
|
69
|
+
</div>,
|
|
70
|
+
{ width, height, fonts },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return effieData({
|
|
74
|
+
width,
|
|
75
|
+
height,
|
|
76
|
+
fps: 30,
|
|
77
|
+
cover,
|
|
78
|
+
background: { type: "color", color: "black" },
|
|
79
|
+
segments: await Promise.all(
|
|
80
|
+
slides.map(async (slide, i) => {
|
|
81
|
+
const direction = (["left", "right"] as const)[i % 2];
|
|
82
|
+
|
|
83
|
+
return effieSegment({
|
|
84
|
+
duration: slide.duration,
|
|
85
|
+
transition:
|
|
86
|
+
i > 0
|
|
87
|
+
? {
|
|
88
|
+
type: "slide",
|
|
89
|
+
direction,
|
|
90
|
+
duration: 1,
|
|
91
|
+
}
|
|
92
|
+
: undefined,
|
|
93
|
+
layers: [
|
|
94
|
+
{
|
|
95
|
+
type: "animation",
|
|
96
|
+
source: await annieUrl<PhotoZoomProps>({
|
|
97
|
+
annieId: "photo-zoom",
|
|
98
|
+
props: {
|
|
99
|
+
imageUrl: slide.imageUrl,
|
|
100
|
+
frameCount: slide.duration * 30,
|
|
101
|
+
zoomLevel: 0.2,
|
|
102
|
+
},
|
|
103
|
+
width: width * 1.2,
|
|
104
|
+
height,
|
|
105
|
+
}),
|
|
106
|
+
effects: [
|
|
107
|
+
{
|
|
108
|
+
type: "scroll",
|
|
109
|
+
direction,
|
|
110
|
+
duration: slide.duration,
|
|
111
|
+
distance: 0.2,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
type: "animation",
|
|
117
|
+
source: await annieUrl<TextTypewriterProps>({
|
|
118
|
+
annieId: "text-typewriter",
|
|
119
|
+
props: {
|
|
120
|
+
text: slide.text,
|
|
121
|
+
fontSize: Math.round(width * 0.06),
|
|
122
|
+
fontColor: "#ffffff",
|
|
123
|
+
typingFrameCount: Math.min(slide.text.length * 3, 60),
|
|
124
|
+
blinkingFrameCount: Math.round((slide.duration - 2) * 30),
|
|
125
|
+
horizontalAlignment: "center",
|
|
126
|
+
verticalAlignment: "center",
|
|
127
|
+
},
|
|
128
|
+
width,
|
|
129
|
+
height,
|
|
130
|
+
}),
|
|
131
|
+
delay: 1,
|
|
132
|
+
effects:
|
|
133
|
+
i === slides.length - 1
|
|
134
|
+
? [
|
|
135
|
+
{
|
|
136
|
+
type: "fade-out",
|
|
137
|
+
duration: 0.5,
|
|
138
|
+
start: slide.duration - 0.5,
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
: undefined,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { FontData } from "@effing/satori";
|
|
2
|
+
|
|
3
|
+
export type { FontData };
|
|
4
|
+
|
|
5
|
+
export type Font = () => Promise<FontData>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Load multiple fonts in parallel
|
|
9
|
+
*/
|
|
10
|
+
export async function loadFonts(fonts: Font[]): Promise<FontData[]> {
|
|
11
|
+
return Promise.all(fonts.map((font) => font()));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Font cache to avoid re-fetching
|
|
15
|
+
const fontCache = new Map<string, Promise<ArrayBuffer>>();
|
|
16
|
+
|
|
17
|
+
async function fetchFont(url: string): Promise<ArrayBuffer> {
|
|
18
|
+
if (!fontCache.has(url)) {
|
|
19
|
+
fontCache.set(
|
|
20
|
+
url,
|
|
21
|
+
fetch(url).then((res) => {
|
|
22
|
+
if (!res.ok) throw new Error(`Failed to fetch font: ${url}`);
|
|
23
|
+
return res.arrayBuffer();
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return fontCache.get(url)!;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Google Fonts URLs - fetched from Google Fonts CSS2 API
|
|
31
|
+
const GOOGLE_FONTS_BASE = "https://fonts.gstatic.com/s";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Inter Semi-Bold (600)
|
|
35
|
+
*/
|
|
36
|
+
export const interSemiBold: Font = async () => ({
|
|
37
|
+
name: "Inter",
|
|
38
|
+
data: await fetchFont(
|
|
39
|
+
`${GOOGLE_FONTS_BASE}/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZg.ttf`,
|
|
40
|
+
),
|
|
41
|
+
weight: 600,
|
|
42
|
+
style: "normal",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Inter Bold (700)
|
|
47
|
+
*/
|
|
48
|
+
export const interBold: Font = async () => ({
|
|
49
|
+
name: "Inter",
|
|
50
|
+
data: await fetchFont(
|
|
51
|
+
`${GOOGLE_FONTS_BASE}/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYMZg.ttf`,
|
|
52
|
+
),
|
|
53
|
+
weight: 700,
|
|
54
|
+
style: "normal",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Roboto Regular (400)
|
|
59
|
+
*/
|
|
60
|
+
export const robotoRegular: Font = async () => ({
|
|
61
|
+
name: "Roboto",
|
|
62
|
+
data: await fetchFont(
|
|
63
|
+
`${GOOGLE_FONTS_BASE}/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbWmT.ttf`,
|
|
64
|
+
),
|
|
65
|
+
weight: 400,
|
|
66
|
+
style: "normal",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Roboto Bold (700)
|
|
71
|
+
*/
|
|
72
|
+
export const robotoBold: Font = async () => ({
|
|
73
|
+
name: "Roboto",
|
|
74
|
+
data: await fetchFont(
|
|
75
|
+
`${GOOGLE_FONTS_BASE}/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjammT.ttf`,
|
|
76
|
+
),
|
|
77
|
+
weight: 700,
|
|
78
|
+
style: "normal",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Open Sans Regular (400)
|
|
83
|
+
*/
|
|
84
|
+
export const openSansRegular: Font = async () => ({
|
|
85
|
+
name: "Open Sans",
|
|
86
|
+
data: await fetchFont(
|
|
87
|
+
`${GOOGLE_FONTS_BASE}/opensans/v44/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0C4n.ttf`,
|
|
88
|
+
),
|
|
89
|
+
weight: 400,
|
|
90
|
+
style: "normal",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Open Sans Semi-Bold (600)
|
|
95
|
+
*/
|
|
96
|
+
export const openSansSemiBold: Font = async () => ({
|
|
97
|
+
name: "Open Sans",
|
|
98
|
+
data: await fetchFont(
|
|
99
|
+
`${GOOGLE_FONTS_BASE}/opensans/v44/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsgH1y4n.ttf`,
|
|
100
|
+
),
|
|
101
|
+
weight: 600,
|
|
102
|
+
style: "normal",
|
|
103
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { MetaFunction } from "react-router";
|
|
2
|
+
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
|
|
3
|
+
|
|
4
|
+
export const meta: MetaFunction = () => [
|
|
5
|
+
{ title: "Effing Starter" },
|
|
6
|
+
{ name: "description", content: "Starter project for annies and effies" },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export default function App() {
|
|
10
|
+
return (
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<head>
|
|
13
|
+
<meta charSet="utf-8" />
|
|
14
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
15
|
+
<Meta />
|
|
16
|
+
<Links />
|
|
17
|
+
</head>
|
|
18
|
+
<body
|
|
19
|
+
style={{
|
|
20
|
+
margin: 0,
|
|
21
|
+
fontFamily: "system-ui, sans-serif",
|
|
22
|
+
backgroundColor: "white",
|
|
23
|
+
}}
|
|
24
|
+
>
|
|
25
|
+
<Outlet />
|
|
26
|
+
<ScrollRestoration />
|
|
27
|
+
<Scripts />
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Link, useLoaderData } from "react-router";
|
|
2
|
+
import { getAnnieIds } from "~/annies";
|
|
3
|
+
import { getEffieIds } from "~/effies";
|
|
4
|
+
|
|
5
|
+
export async function loader() {
|
|
6
|
+
return {
|
|
7
|
+
annieIds: getAnnieIds(),
|
|
8
|
+
effieIds: getEffieIds(),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function Index() {
|
|
13
|
+
const { annieIds, effieIds } = useLoaderData<typeof loader>();
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div style={{ padding: "2rem", maxWidth: 800, margin: "0 auto" }}>
|
|
17
|
+
<h1 style={{ marginTop: 0 }}>Effing Starter</h1>
|
|
18
|
+
<p style={{ color: "#666", fontSize: 18 }}>
|
|
19
|
+
A starter project for developing annies and effies using the @effing/*
|
|
20
|
+
packages.
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<section style={{ marginTop: "2rem" }}>
|
|
24
|
+
<h2>Annies</h2>
|
|
25
|
+
<p style={{ color: "#666" }}>
|
|
26
|
+
Frame-by-frame animations rendered as TAR archives of PNG/JPEG frames.
|
|
27
|
+
</p>
|
|
28
|
+
{annieIds.length === 0 ? (
|
|
29
|
+
<p>
|
|
30
|
+
No annies found. Create one at <code>app/annies/*.annie.tsx</code>
|
|
31
|
+
</p>
|
|
32
|
+
) : (
|
|
33
|
+
<ul style={{ listStyle: "none", padding: 0 }}>
|
|
34
|
+
{annieIds.map((id) => (
|
|
35
|
+
<li key={id} style={{ marginBottom: 8 }}>
|
|
36
|
+
<Link
|
|
37
|
+
to={`/pan/${id}`}
|
|
38
|
+
style={{
|
|
39
|
+
display: "inline-block",
|
|
40
|
+
padding: "8px 16px",
|
|
41
|
+
backgroundColor: "#f5f5f5",
|
|
42
|
+
borderRadius: 4,
|
|
43
|
+
textDecoration: "none",
|
|
44
|
+
color: "#333",
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{id} →
|
|
48
|
+
</Link>
|
|
49
|
+
</li>
|
|
50
|
+
))}
|
|
51
|
+
</ul>
|
|
52
|
+
)}
|
|
53
|
+
</section>
|
|
54
|
+
|
|
55
|
+
<section style={{ marginTop: "2rem" }}>
|
|
56
|
+
<h2>Effies</h2>
|
|
57
|
+
<p style={{ color: "#666" }}>
|
|
58
|
+
Video compositions defined as JSON, rendered by FFS (FFmpeg Service).
|
|
59
|
+
</p>
|
|
60
|
+
{effieIds.length === 0 ? (
|
|
61
|
+
<p>
|
|
62
|
+
No effies found. Create one at <code>app/effies/*.effie.tsx</code>
|
|
63
|
+
</p>
|
|
64
|
+
) : (
|
|
65
|
+
<ul style={{ listStyle: "none", padding: 0 }}>
|
|
66
|
+
{effieIds.map((id) => (
|
|
67
|
+
<li key={id} style={{ marginBottom: 8 }}>
|
|
68
|
+
<Link
|
|
69
|
+
to={`/pff/${id}`}
|
|
70
|
+
style={{
|
|
71
|
+
display: "inline-block",
|
|
72
|
+
padding: "8px 16px",
|
|
73
|
+
backgroundColor: "#f5f5f5",
|
|
74
|
+
borderRadius: 4,
|
|
75
|
+
textDecoration: "none",
|
|
76
|
+
color: "#333",
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
{id} →
|
|
80
|
+
</Link>
|
|
81
|
+
</li>
|
|
82
|
+
))}
|
|
83
|
+
</ul>
|
|
84
|
+
)}
|
|
85
|
+
</section>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { annieResponse } from "@effing/annie";
|
|
2
|
+
import { deserialize } from "@effing/serde";
|
|
3
|
+
import { getAnnie } from "~/annies";
|
|
4
|
+
import type { Route } from "./+types/an.$segment";
|
|
5
|
+
|
|
6
|
+
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
7
|
+
// Deserialize the signed URL segment
|
|
8
|
+
const payload = await deserialize<{ annieId: string }>(
|
|
9
|
+
params.segment,
|
|
10
|
+
process.env.SECRET_KEY!,
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const { annieId, ...props } = payload;
|
|
14
|
+
const { renderer, propsSchema } = await getAnnie(annieId);
|
|
15
|
+
|
|
16
|
+
// Validate props if schema exists
|
|
17
|
+
if (propsSchema) {
|
|
18
|
+
propsSchema.parse(props);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Get dimensions from query params
|
|
22
|
+
const url = new URL(request.url);
|
|
23
|
+
const width = parseInt(url.searchParams.get("w") || "1080", 10);
|
|
24
|
+
const height = parseInt(url.searchParams.get("h") || "1080", 10);
|
|
25
|
+
|
|
26
|
+
const frames = renderer({ props, width, height });
|
|
27
|
+
|
|
28
|
+
return annieResponse(frames, {
|
|
29
|
+
signal: request.signal,
|
|
30
|
+
filename: annieId,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { deserialize } from "@effing/serde";
|
|
2
|
+
import { effieResponse } from "@effing/effie";
|
|
3
|
+
import { getEffie } from "~/effies";
|
|
4
|
+
import type { Route } from "./+types/ff.$segment";
|
|
5
|
+
|
|
6
|
+
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
7
|
+
// Deserialize the signed URL segment
|
|
8
|
+
const { effieId, ...props } = await deserialize<{
|
|
9
|
+
effieId: string;
|
|
10
|
+
}>(params.segment, process.env.SECRET_KEY!);
|
|
11
|
+
|
|
12
|
+
const { renderer, propsSchema } = await getEffie(effieId);
|
|
13
|
+
|
|
14
|
+
// Validate props if schema exists
|
|
15
|
+
if (propsSchema) {
|
|
16
|
+
propsSchema.parse(props);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Get dimensions from query params
|
|
20
|
+
const url = new URL(request.url);
|
|
21
|
+
const width = parseInt(url.searchParams.get("w") || "1080", 10);
|
|
22
|
+
const height = parseInt(url.searchParams.get("h") || "1080", 10);
|
|
23
|
+
|
|
24
|
+
const effieData = await renderer({ props, width, height });
|
|
25
|
+
|
|
26
|
+
return effieResponse(effieData);
|
|
27
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useLoaderData } from "react-router";
|
|
2
|
+
import invariant from "tiny-invariant";
|
|
3
|
+
import { AnniePlayer } from "@effing/annie-player/react";
|
|
4
|
+
import { getAnnie } from "~/annies";
|
|
5
|
+
import { annieUrl } from "~/urls.server";
|
|
6
|
+
import type { Route } from "./+types/pan.$annieId";
|
|
7
|
+
|
|
8
|
+
const styles = {
|
|
9
|
+
pageContainer: {
|
|
10
|
+
padding: "2rem",
|
|
11
|
+
display: "flex",
|
|
12
|
+
flexDirection: "column",
|
|
13
|
+
gap: "2rem",
|
|
14
|
+
},
|
|
15
|
+
headerTitle: {
|
|
16
|
+
margin: 0,
|
|
17
|
+
},
|
|
18
|
+
preBlock: {
|
|
19
|
+
padding: "0.75rem 1rem",
|
|
20
|
+
backgroundColor: "#fafafa",
|
|
21
|
+
border: "1px solid #ddd",
|
|
22
|
+
borderRadius: 4,
|
|
23
|
+
overflow: "auto",
|
|
24
|
+
fontSize: "0.75rem",
|
|
25
|
+
margin: 0,
|
|
26
|
+
},
|
|
27
|
+
} as const satisfies Record<string, React.CSSProperties>;
|
|
28
|
+
|
|
29
|
+
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
30
|
+
const { previewProps, propsSchema } = await getAnnie(params.annieId);
|
|
31
|
+
invariant(
|
|
32
|
+
propsSchema.safeParse(previewProps).success,
|
|
33
|
+
"previewProps does not adhere to the propsSchema",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const searchParams = new URL(request.url).searchParams;
|
|
37
|
+
const width = parseInt(searchParams.get("w") || "1080", 10);
|
|
38
|
+
const height = parseInt(searchParams.get("h") || "1080", 10);
|
|
39
|
+
|
|
40
|
+
const url = await annieUrl({
|
|
41
|
+
annieId: params.annieId,
|
|
42
|
+
props: previewProps as Record<string, unknown>,
|
|
43
|
+
width,
|
|
44
|
+
height,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
annieId: params.annieId,
|
|
49
|
+
annieUrl: url,
|
|
50
|
+
width,
|
|
51
|
+
height,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function AnniePreviewPage() {
|
|
56
|
+
const { annieId, annieUrl, width, height } = useLoaderData<typeof loader>();
|
|
57
|
+
|
|
58
|
+
const scaledResolution = { width: Math.round((540 * width) / height), height: 540 };
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div style={styles.pageContainer}>
|
|
62
|
+
<h1 style={styles.headerTitle}>Annie Preview: {annieId}</h1>
|
|
63
|
+
|
|
64
|
+
<AnniePlayer
|
|
65
|
+
src={annieUrl}
|
|
66
|
+
height={scaledResolution.height}
|
|
67
|
+
// defaultWidth={scaledResolution.width}
|
|
68
|
+
autoLoad={true}
|
|
69
|
+
autoPlay={true}
|
|
70
|
+
fps={30}
|
|
71
|
+
/>
|
|
72
|
+
|
|
73
|
+
<div>
|
|
74
|
+
<h3>Direct URL</h3>
|
|
75
|
+
<pre style={styles.preBlock}>{annieUrl}</pre>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div>
|
|
79
|
+
<h3>Convert to Animated PNG</h3>
|
|
80
|
+
<pre style={styles.preBlock}>
|
|
81
|
+
{`curl '${annieUrl}' \\
|
|
82
|
+
| tar -xO | ffmpeg -f image2pipe -framerate 30 -i - -plays 0 -c:v apng -f apng ${annieId}.png`}
|
|
83
|
+
</pre>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|