@coywolf/emdash 0.0.0-stage → 0.35.13

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.
Files changed (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,187 @@
1
+ import { getPublicPluginApiRouteHandler } from "emdash/plugin-utils";
2
+
3
+ import { PLUGIN_ID, registerFeatures } from "../core/features.js";
4
+ import { noteReadFailure, routeFailed } from "../core/read-failures.js";
5
+ import type { PackMiddleware, PackModule } from "../core/module.js";
6
+ import { BLOCK_TYPE } from "./lib.js";
7
+ import { F, type VideosOptions, videosModule } from "./module.js";
8
+ // Registers the copied-posters setting before the first settings read (see poster.ts).
9
+ import "./poster.js";
10
+ import { STORAGE } from "./store.js";
11
+
12
+ export type { VideosOptions } from "./module.js";
13
+
14
+ const FEATURES = [
15
+ { id: F.main, label: "Videos", description: "Cloudflare Stream library, uploads, and the Coywolf Video block.", default: false },
16
+ { id: F.schema, label: "Video schema", description: "VideoObject structured data for every embedded video.", default: false },
17
+ { id: F.sitemap, label: "Video sitemap", description: "A Google video sitemap at /coywolf-video-sitemap.xml.", default: false },
18
+ { id: F.engagement, label: "Plays and likes", description: "Count plays and show a like button under videos.", default: false },
19
+ { id: F.captions, label: "Captions", description: "Upload or generate captions, and add them (with a transcript) to schema.", default: false },
20
+ { id: F.webhook, label: "Stream webhook", description: "Refresh video details as soon as Stream finishes processing.", default: false },
21
+ ];
22
+ registerFeatures(FEATURES);
23
+
24
+ const toggle = (action_id: string, label: string, initial_value: boolean) => ({ type: "toggle", action_id, label, initial_value });
25
+
26
+ /** Left/center/right, with the site default (Videos → Settings → Appearance) as the empty choice. */
27
+ const alignSelect = (action_id: string, label: string) => ({
28
+ type: "select",
29
+ action_id,
30
+ label,
31
+ options: [
32
+ { value: "", label: "Site default" },
33
+ { value: "left", label: "Left" },
34
+ { value: "center", label: "Center" },
35
+ { value: "right", label: "Right" },
36
+ ],
37
+ });
38
+
39
+ /**
40
+ * Show/hide with the site default (Videos → Settings) as the empty choice, so
41
+ * new blocks follow the settings. No initial value: a block only keeps a
42
+ * choice someone made. Older blocks may hold true/false (see lib.ts resolveShow).
43
+ */
44
+ const showSelect = (action_id: string, label: string) => ({
45
+ type: "select",
46
+ action_id,
47
+ label,
48
+ options: [
49
+ { value: "", label: "Site default" },
50
+ { value: "show", label: "Show" },
51
+ { value: "hide", label: "Hide" },
52
+ ],
53
+ });
54
+
55
+ export function videosPack(options: VideosOptions): PackModule {
56
+ const videos = videosModule(options);
57
+ return {
58
+ id: "videos",
59
+ label: "Videos",
60
+ features: FEATURES,
61
+ routes: videos.routes,
62
+ hooks: videos.hooks,
63
+ hookFeature: { "page:metadata": F.schema },
64
+ tasks: videos.tasks,
65
+ adminPages: [{ path: "/videos", label: "Videos", icon: "video" }],
66
+ storage: STORAGE,
67
+ capabilities: ["content:read", "schema:read"],
68
+ portableTextBlocks: [
69
+ {
70
+ type: BLOCK_TYPE,
71
+ label: "Coywolf Video",
72
+ icon: "video",
73
+ category: "Media",
74
+ description: "A Cloudflare Stream video with schema, captions, plays and likes",
75
+ fields: [
76
+ { type: "select", action_id: "uid", label: "Video", options: [], optionsRoute: "videos/options" },
77
+ {
78
+ type: "select",
79
+ action_id: "preset",
80
+ label: "Style",
81
+ options: [
82
+ { value: "standard", label: "Standard player" },
83
+ { value: "gif", label: "Loop like a GIF (muted, autoplay, no controls)" },
84
+ ],
85
+ initial_value: "standard",
86
+ },
87
+ { type: "text_input", action_id: "title", label: "Title (defaults to the video's name)" },
88
+ { type: "text_input", action_id: "caption", label: "Description", multiline: true },
89
+ { type: "number_input", action_id: "posterTime", label: "Poster frame (seconds into the video)", min: 0 },
90
+ { type: "media_picker", action_id: "posterImage", label: "Poster image (overrides the frame)", mime_type_filter: "image/" },
91
+ { type: "number_input", action_id: "startTime", label: "Start at (seconds)", min: 0 },
92
+ toggle("controls", "Show controls", true),
93
+ toggle("autoplay", "Autoplay (muted)", false),
94
+ toggle("loop", "Loop", false),
95
+ toggle("muted", "Muted", false),
96
+ {
97
+ type: "select",
98
+ action_id: "preload",
99
+ label: "Preload",
100
+ options: [
101
+ { value: "metadata", label: "Metadata" },
102
+ { value: "none", label: "None" },
103
+ { value: "auto", label: "Auto" },
104
+ ],
105
+ initial_value: "metadata",
106
+ },
107
+ {
108
+ type: "select",
109
+ action_id: "sizeMode",
110
+ label: "Size",
111
+ options: [
112
+ { value: "responsive", label: "Full width" },
113
+ { value: "maxwidth", label: "Maximum width" },
114
+ ],
115
+ initial_value: "responsive",
116
+ },
117
+ { type: "number_input", action_id: "maxWidth", label: "Maximum width (px)", min: 100, max: 4000 },
118
+ showSelect("showName", "Video name below the video"),
119
+ showSelect("showDescription", "Description below the video"),
120
+ showSelect("showPlays", "Number of views"),
121
+ showSelect("showLikes", "Like button"),
122
+ showSelect("showLikeCount", "Number of likes"),
123
+ showSelect("showDate", "Upload date"),
124
+ alignSelect("contentAlign", "Name & description alignment"),
125
+ alignSelect("metaAlign", "Like / views / date row alignment"),
126
+ { type: "number_input", action_id: "radius", label: "Corner radius (px; empty uses the site default)", min: 0, max: 48 },
127
+ showSelect("showBorder", "Border around the player"),
128
+ { type: "number_input", action_id: "borderWidth", label: "Border width (px; empty uses the site default)", min: 0, max: 20 },
129
+ { type: "text_input", action_id: "borderColor", label: "Border color (hex; empty uses the site default)", placeholder: "#eeeeee" },
130
+ ],
131
+ },
132
+ ],
133
+ };
134
+ }
135
+
136
+ // ── Site URLs ────────────────────────────────────────────────────
137
+
138
+ type Locals = Parameters<typeof getPublicPluginApiRouteHandler>[0];
139
+
140
+ async function callPublic(locals: unknown, origin: string, route: string, body: unknown): Promise<unknown> {
141
+ const handler = getPublicPluginApiRouteHandler(locals as Locals);
142
+ if (!handler) return null;
143
+ const request = new Request(new URL(`/_emdash/api/plugins/${PLUGIN_ID}/${route}`, origin), {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: JSON.stringify(body),
147
+ });
148
+ const res = (await handler(PLUGIN_ID, "POST", `/${route}`, request)) as { success?: boolean; data?: unknown; status?: number; error?: { code?: string } };
149
+ // A server error: whatever answers the URL instead (the site's own route) isn't edge-cached.
150
+ if (routeFailed(res)) noteReadFailure(route);
151
+ return res?.success ? res.data : null;
152
+ }
153
+
154
+ const SITEMAP_PATH = "/coywolf-video-sitemap.xml";
155
+ let sitemapMemo: { xml: string; at: number } | null = null;
156
+
157
+ export const videosSitemapMiddleware: PackMiddleware = {
158
+ module: "videos",
159
+ feature: F.sitemap,
160
+ handle: async (context) => {
161
+ if (context.url.pathname !== SITEMAP_PATH) return undefined;
162
+ if (!sitemapMemo || Date.now() - sitemapMemo.at > 5 * 60_000) {
163
+ const data = (await callPublic(context.locals, context.url.origin, "videos/sitemap", {})) as { xml?: string } | null;
164
+ if (!data?.xml) return undefined;
165
+ sitemapMemo = { xml: data.xml, at: Date.now() };
166
+ }
167
+ return new Response(context.request.method === "HEAD" ? null : sitemapMemo.xml, {
168
+ headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=900" },
169
+ });
170
+ },
171
+ };
172
+
173
+ const CAPTION_PATH = /^\/coywolf-video-captions\/([0-9a-f]{32})\/([A-Za-z0-9-]{2,24})\.vtt$/;
174
+
175
+ export const videosCaptionsMiddleware: PackMiddleware = {
176
+ module: "videos",
177
+ feature: F.captions,
178
+ handle: async (context) => {
179
+ const m = context.url.pathname.match(CAPTION_PATH);
180
+ if (!m) return undefined;
181
+ const data = (await callPublic(context.locals, context.url.origin, "videos/caption", { uid: m[1], lang: m[2] })) as { vtt?: string } | null;
182
+ if (!data?.vtt) return new Response("Not found", { status: 404 });
183
+ return new Response(data.vtt, {
184
+ headers: { "Content-Type": "text/vtt; charset=utf-8", "Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*" },
185
+ });
186
+ },
187
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pause/play for a Stream player that may not be ready yet (browser code, used
3
+ * by CoywolfVideo.astro). A press can come before Stream's SDK has loaded or
4
+ * before the player inside the iframe is ready to take commands, and an
5
+ * autoplaying video can start after a pause() that came too early. So the
6
+ * visitor's choice is recorded, applied once the player exists, and applied
7
+ * again whenever the player starts or becomes ready while the choice is
8
+ * "paused".
9
+ */
10
+
11
+ export interface ControllablePlayer {
12
+ addEventListener(event: string, listener: () => void): void;
13
+ play(): unknown;
14
+ pause(): unknown;
15
+ }
16
+
17
+ /** Player events after which a paused choice is enforced again. */
18
+ export const HOLD_EVENTS = ["loadeddata", "canplay", "play", "playing"] as const;
19
+
20
+ /** A setter for the wanted state (true: playing) of the player `player` resolves to. */
21
+ export function playerControl(player: Promise<ControllablePlayer | null>): (playing: boolean) => void {
22
+ let want: boolean | null = null;
23
+ let hooked = false;
24
+ const apply = (p: ControllablePlayer) => {
25
+ try {
26
+ void Promise.resolve(want ? p.play() : p.pause()).catch(() => undefined);
27
+ } catch {
28
+ // Not ready yet: the next player event applies it again.
29
+ }
30
+ };
31
+ return (playing) => {
32
+ want = playing;
33
+ void player.then((p) => {
34
+ if (!p) return;
35
+ if (!hooked) {
36
+ hooked = true;
37
+ for (const event of HOLD_EVENTS) p.addEventListener(event, () => want === false && apply(p));
38
+ }
39
+ apply(p);
40
+ });
41
+ };
42
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Video posters from the media host.
3
+ *
4
+ * Stream makes thumbnails on request: even cached ones take 120–450 ms to start
5
+ * arriving, a new one over a second, always as JPEG, and from another host the
6
+ * browser has to connect to first. So each poster is copied into the media
7
+ * bucket (one full-size JPEG per video and poster time) with stored WebP and
8
+ * AVIF sizes next to it (src/images/variants.ts), and pages use those from the
9
+ * media host like any other image. Without a media host, or until the copy
10
+ * exists, Stream's thumbnails are used. Posters copied before stored sizes
11
+ * existed (listed without "#v<version>") use the media host's /s/ resizing
12
+ * until the stored-sizes backfill makes theirs (upgradeListedPosters).
13
+ *
14
+ * Rendering never waits on the bucket or on Stream: the copied posters are
15
+ * listed in one plugin setting (read with the feature switches, so it costs no
16
+ * query), and a poster that isn't listed yet is rendered from Stream while it's
17
+ * copied (and listed) in the background. Such a page is marked (see
18
+ * markPendingMedia) so the pack middleware caches it for minutes, not days:
19
+ * the next render, once the copy is listed, uses the media host.
20
+ */
21
+ import { PLUGIN_ID, registerSiteSetting, rememberSiteSetting, siteFeatureOn, siteSetting } from "../core/features.js";
22
+ import { imageCdn } from "../images/lib.js";
23
+ import { markPendingMedia, pendingMediaRenders, renderedPendingMedia, resetPendingMedia, PENDING_MEDIA_LOCAL } from "../images/pending.js";
24
+ import { refreshMediaHost } from "../images/settings.js";
25
+ import { type ImagesBinding, VARIANTS_VERSION, type VariantBucket, generateVariants, variantKey } from "../images/variants.js";
26
+ import { afterResponse, workerEnv } from "../shared.js";
27
+ import { POSTER_WIDTHS, posterImage, posterUrl } from "./lib.js";
28
+
29
+ /** Width of the copy kept in the bucket: covers the largest poster on a 2x screen. */
30
+ export const SOURCE_WIDTH = 1600;
31
+ const FETCH_TIMEOUT_MS = 5000;
32
+ const MEDIA_BINDING = "MEDIA";
33
+ const IMAGES_BINDING = "IMAGES";
34
+
35
+ /** Plugin setting listing the poster keys already in the bucket. */
36
+ export const POSTERS_SETTING = "videosMirroredPosters";
37
+ export const POSTERS_OPTION = `plugin:${PLUGIN_ID}:settings:${POSTERS_SETTING}`;
38
+ /** Most keys the list keeps (about 60 bytes each); past it, new posters are still copied but found per isolate. */
39
+ export const MAX_LISTED_POSTERS = 3000;
40
+ registerSiteSetting(POSTERS_SETTING);
41
+
42
+ type PosterRef = { posterImage?: string; posterTime?: number };
43
+
44
+ interface Bucket {
45
+ head(key: string): Promise<unknown | null>;
46
+ put(key: string, value: ArrayBuffer, options?: { httpMetadata?: { contentType?: string; cacheControl?: string } }): Promise<unknown>;
47
+ }
48
+
49
+ /** The list entry of a poster whose stored sizes (current version) exist. */
50
+ export const posterEntry = (key: string) => `${key}#v${VARIANTS_VERSION}`;
51
+ /** Id of a poster's stored sizes: its key without the extension. */
52
+ const posterId = (key: string) => key.replace(/\.[a-z0-9]+$/i, "");
53
+
54
+ /** The slice of a D1 binding used to list a copied poster. */
55
+ interface Db {
56
+ prepare(sql: string): { bind(...values: unknown[]): { run(): Promise<unknown>; first<T = unknown>(): Promise<T | null> } };
57
+ }
58
+
59
+ /** Bucket key for a Stream thumbnail URL: flat, so the media host's /s/<w>/<file> rule resizes it. */
60
+ export async function posterKey(uid: string, source: string): Promise<string> {
61
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source));
62
+ const hash = [...new Uint8Array(digest).slice(0, 6)].map((b) => b.toString(16).padStart(2, "0")).join("");
63
+ return `cwposter-${uid}-${hash}.jpg`;
64
+ }
65
+
66
+ /** "copied": newly put in the bucket; "present": it was already there; false: the copy failed. */
67
+ export type MirrorResult = "copied" | "present" | false;
68
+
69
+ /** Per-isolate: copies under way or done. A failed copy is forgotten, so it's retried. */
70
+ const mirrored = new Map<string, Promise<MirrorResult>>();
71
+ /** Per-isolate: keys known to be in the bucket. */
72
+ const present = new Set<string>();
73
+
74
+ /** Copy `source` into the bucket at `key` unless it's there already. Truthy when the bucket has it. */
75
+ export function mirrorPoster(bucket: Bucket, key: string, source: string, fetcher: typeof fetch = fetch): Promise<MirrorResult> {
76
+ let pending = mirrored.get(key);
77
+ if (!pending) {
78
+ pending = (async (): Promise<MirrorResult> => {
79
+ if (await bucket.head(key)) return "present";
80
+ const res = await fetcher(source, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
81
+ const type = res.headers.get("content-type") ?? "";
82
+ if (!res.ok || !type.startsWith("image/")) return false;
83
+ await bucket.put(key, await res.arrayBuffer(), {
84
+ httpMetadata: { contentType: type, cacheControl: "public, max-age=31536000, immutable" },
85
+ });
86
+ return "copied";
87
+ })().catch(() => false as const);
88
+ mirrored.set(key, pending);
89
+ pending.then((ok) => (ok ? present.add(key) : mirrored.delete(key)));
90
+ }
91
+ return pending;
92
+ }
93
+
94
+ /** Request each URL as AVIF and as WebP so the media host resizes and caches them. */
95
+ export function warmSizes(urls: string[], fetcher: typeof fetch = fetch): Promise<unknown> {
96
+ return Promise.allSettled(
97
+ urls.flatMap((url) =>
98
+ ["image/avif", "image/webp"].map((accept) =>
99
+ fetcher(url, { headers: { accept }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }).then((r) => r.body?.cancel()),
100
+ ),
101
+ ),
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Add `key` to the listed posters in one statement (no read-modify-write, so
107
+ * renders in other isolates can't drop each other's keys). No-op when it's
108
+ * listed already or the list is full.
109
+ */
110
+ export async function listPoster(db: Db, key: string): Promise<void> {
111
+ await db
112
+ .prepare(
113
+ `INSERT INTO options (name, value) VALUES (?1, json_array(?2))
114
+ ON CONFLICT(name) DO UPDATE SET value = json_insert(CASE WHEN json_valid(options.value) AND json_type(options.value) = 'array' THEN options.value ELSE '[]' END, '$[#]', ?2)
115
+ WHERE NOT EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(options.value) THEN options.value ELSE '[]' END) WHERE json_each.value = ?2)
116
+ AND (NOT json_valid(options.value) OR json_type(options.value) <> 'array' OR json_array_length(options.value) < ?3)`,
117
+ )
118
+ .bind(POSTERS_OPTION, key, MAX_LISTED_POSTERS)
119
+ .run();
120
+ }
121
+
122
+ /** The listed keys as a set, rebuilt only when the cached list changes. */
123
+ let listedMemo: { list: unknown; keys: Set<string> } | null = null;
124
+ function listedKeys(list: unknown): Set<string> {
125
+ if (listedMemo && listedMemo.list === list) return listedMemo.keys;
126
+ const keys = new Set(Array.isArray(list) ? list.filter((k): k is string => typeof k === "string") : []);
127
+ listedMemo = { list, keys };
128
+ return keys;
129
+ }
130
+
131
+ /** Forget what this isolate has copied (tests). */
132
+ export function resetMirroredPosters(): void {
133
+ mirrored.clear();
134
+ present.clear();
135
+ madeHere.clear();
136
+ listedMemo = null;
137
+ resetPendingMedia();
138
+ }
139
+
140
+ /** Earlier names of the stopgap marking (now src/images/pending.ts, shared with images). */
141
+ export const PENDING_POSTER_LOCAL = PENDING_MEDIA_LOCAL;
142
+ export const markPendingPoster = markPendingMedia;
143
+ export const pendingPosterRenders = pendingMediaRenders;
144
+ export const renderedPendingPoster = renderedPendingMedia;
145
+
146
+ /** Make a copied poster's stored sizes. Throws when they can't be made. */
147
+ export function posterVariants(bucket: VariantBucket, images: ImagesBinding, key: string) {
148
+ return generateVariants({ bucket, images, key, id: posterId(key), width: SOURCE_WIDTH, widths: POSTER_WIDTHS, crops: [] });
149
+ }
150
+
151
+ export interface PosterDeps {
152
+ cdn: string;
153
+ bucket: Bucket & Partial<VariantBucket>;
154
+ /** Images binding, to make the stored sizes; without it new copies use /s/ resizing. */
155
+ images?: ImagesBinding;
156
+ /** Keys listed as copied (from the plugin setting). */
157
+ listed: Set<string>;
158
+ /** List a key once the bucket has it. */
159
+ list: (key: string) => Promise<void>;
160
+ /** Schedule work after the response. */
161
+ defer: (work: Promise<unknown>) => void;
162
+ fetcher?: typeof fetch;
163
+ }
164
+
165
+ export interface MediaPosterImage {
166
+ src: string;
167
+ srcset: string;
168
+ /** srcset of the AVIF copies (stored sizes only). */
169
+ avif?: string;
170
+ full: string;
171
+ }
172
+
173
+ /** Stored sizes of a poster: WebP and AVIF at POSTER_WIDTHS. */
174
+ function storedPoster(cdn: string, key: string): MediaPosterImage {
175
+ const at = (w: number, ext: "webp" | "avif") => `${cdn}/${variantKey(posterId(key), w, ext)}`;
176
+ const set = (ext: "webp" | "avif") => POSTER_WIDTHS.map((w) => `${at(w, ext)} ${w}w`).join(", ");
177
+ return { src: at(800, "webp"), srcset: set("webp"), avif: set("avif"), full: at(1200, "webp") };
178
+ }
179
+
180
+ /** A poster copied before stored sizes: resized by the media host (/s/). */
181
+ function resizedPoster(cdn: string, key: string): MediaPosterImage {
182
+ const at = (w: number) => `${cdn}/s/${w}/${key}`;
183
+ return { src: at(800), srcset: POSTER_WIDTHS.map((w) => `${at(w)} ${w}w`).join(", "), full: at(1200) };
184
+ }
185
+
186
+ /** Per-isolate: list entries of posters this isolate copied (and sized) itself. */
187
+ const madeHere = new Set<string>();
188
+
189
+ /**
190
+ * The media-host poster when the copy is known to exist (listed, or made by
191
+ * this isolate), else null right away. The copy starts at once (during this
192
+ * render, not after it), and `defer` only keeps it, its stored sizes and its
193
+ * listing alive past the response, so the next render a few minutes later
194
+ * finds it listed.
195
+ */
196
+ export async function mediaPoster(uid: string, source: string, deps: PosterDeps): Promise<MediaPosterImage | null> {
197
+ const key = await posterKey(uid, source);
198
+ if (deps.listed.has(posterEntry(key)) || madeHere.has(posterEntry(key))) return storedPoster(deps.cdn, key);
199
+ if (deps.listed.has(key) || madeHere.has(key)) return resizedPoster(deps.cdn, key);
200
+ if (!mirrored.has(key)) {
201
+ deps.defer(
202
+ mirrorPoster(deps.bucket, key, source, deps.fetcher).then(async (result) => {
203
+ if (!result) return;
204
+ const { images, bucket } = deps;
205
+ if (images && bucket.get && bucket.list && bucket.delete) {
206
+ const sized = await posterVariants(bucket as VariantBucket, images, key).then(
207
+ () => true,
208
+ (error) => (console.error("coywolf-pack videos: couldn't make the poster's stored sizes", key, error), false),
209
+ );
210
+ if (sized) {
211
+ madeHere.add(posterEntry(key));
212
+ await deps.list(posterEntry(key)).catch(() => undefined);
213
+ return;
214
+ }
215
+ }
216
+ madeHere.add(key);
217
+ await deps.list(key).catch(() => undefined);
218
+ // Just copied: have the media host make each size now (AVIF and WebP), so the first
219
+ // visitor doesn't wait for the resize. A poster already in the bucket was warmed then.
220
+ if (result === "copied") await warmSizes(POSTER_WIDTHS.map((w) => `${deps.cdn}/s/${w}/${key}`), deps.fetcher);
221
+ }),
222
+ );
223
+ }
224
+ return null;
225
+ }
226
+
227
+ /** Replace (or, with `to` null, remove) one entry of the listed posters in one statement. */
228
+ export async function relistPoster(db: Db, from: string, to: string | null): Promise<void> {
229
+ await db
230
+ .prepare(
231
+ `UPDATE options SET value = (
232
+ SELECT json_group_array(CASE WHEN j.value = ?2 THEN ?3 ELSE j.value END) FROM json_each(options.value) AS j WHERE ?3 IS NOT NULL OR j.value <> ?2
233
+ ) WHERE name = ?1 AND json_valid(value) AND json_type(value) = 'array'`,
234
+ )
235
+ .bind(POSTERS_OPTION, from, to)
236
+ .run();
237
+ }
238
+
239
+ /** Legacy posters upgraded per call: each is ~16 subrequests, and the call shares an invocation's 1,000 with the images made before it. */
240
+ export const POSTER_UPGRADES_PER_CALL = 10;
241
+
242
+ /**
243
+ * Make the stored sizes of posters listed before they existed (or with an
244
+ * older version), and list them as sized. A poster whose sizes can't be made
245
+ * is unlisted, so the next render copies it again (its JPEG is still in the
246
+ * bucket, so that's a head request and another try). True when none are left;
247
+ * false when `deadline` passed or POSTER_UPGRADES_PER_CALL were done first.
248
+ */
249
+ export async function upgradeListedPosters(db: Db, bucket: VariantBucket, images: ImagesBinding, deadline: number): Promise<boolean> {
250
+ const row = await db.prepare("SELECT value FROM options WHERE name = ?1").bind(POSTERS_OPTION).first<{ value: string }>();
251
+ let list: unknown = [];
252
+ try {
253
+ list = row?.value ? JSON.parse(row.value) : [];
254
+ } catch {
255
+ return true;
256
+ }
257
+ const legacy = (Array.isArray(list) ? list : []).filter((e): e is string => typeof e === "string" && !e.endsWith(`#v${VARIANTS_VERSION}`));
258
+ let done = 0;
259
+ for (const entry of legacy) {
260
+ if (Date.now() > deadline || done++ >= POSTER_UPGRADES_PER_CALL) return false;
261
+ const key = entry.split("#")[0];
262
+ try {
263
+ await posterVariants(bucket, images, key);
264
+ await relistPoster(db, entry, posterEntry(key));
265
+ } catch (error) {
266
+ console.error("coywolf-pack videos: couldn't make a listed poster's stored sizes", key, error);
267
+ await relistPoster(db, entry, null);
268
+ }
269
+ }
270
+ return true;
271
+ }
272
+
273
+ /**
274
+ * The poster as a responsive image from the media host when there is one,
275
+ * else Stream's thumbnails (see posterImage). `full` is a single large URL
276
+ * for the player's own poster. Pass the page's `Astro.locals` so a page shown
277
+ * with Stream's poster while the copy is made gets a short cache lifetime
278
+ * (without it, the middleware still notices through a per-isolate count).
279
+ */
280
+ export async function hostedPosterImage(
281
+ host: string | null,
282
+ uid: string,
283
+ ref: PosterRef,
284
+ fallback: PosterRef = {},
285
+ origin?: string | null,
286
+ /** Astro.locals, so only this page gets the stopgap lifetime. */
287
+ locals?: object | null,
288
+ ): Promise<{ src: string; srcset?: string; avif?: string; full: string }> {
289
+ const stream = posterImage(host, uid, ref, fallback, origin);
290
+ const source = posterUrl(host, uid, ref, fallback, SOURCE_WIDTH, origin);
291
+ // An explicit poster image is used as it is.
292
+ if (!stream.srcset) return { ...stream, full: stream.src };
293
+ try {
294
+ if (!(await siteFeatureOn("images"))) return { ...stream, full: source };
295
+ await refreshMediaHost();
296
+ const cdn = imageCdn();
297
+ const env = await workerEnv();
298
+ const bucket = env[MEDIA_BINDING] as Bucket | undefined;
299
+ if (!cdn || !bucket) return { ...stream, full: source };
300
+ const list = await siteSetting<unknown>(POSTERS_SETTING);
301
+ const db = env.DB as Db | undefined;
302
+ const media = await mediaPoster(uid, source, {
303
+ cdn,
304
+ bucket,
305
+ images: env[IMAGES_BINDING] as ImagesBinding | undefined,
306
+ listed: listedKeys(list),
307
+ list: async (key) => {
308
+ if (!db) return;
309
+ await listPoster(db, key);
310
+ // This isolate sees it now; others when their settings cache refreshes (FEATURES_TTL_MS).
311
+ const current = (await siteSetting<unknown>(POSTERS_SETTING)) ?? [];
312
+ if (Array.isArray(current) && !current.includes(key)) rememberSiteSetting(POSTERS_SETTING, [...current, key]);
313
+ },
314
+ defer: (work) => void afterResponse(work),
315
+ });
316
+ if (media) return media;
317
+ markPendingMedia(locals);
318
+ return { ...stream, full: source };
319
+ } catch {
320
+ // Stream's poster for now, like a copy still being made: cached for minutes, not days.
321
+ markPendingMedia(locals);
322
+ return { ...stream, full: source };
323
+ }
324
+ }