@anthusai/papyrus 1.0.0-next.51 → 1.0.0-next.52

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.
@@ -7,6 +7,8 @@ import { resolveThemedVideoSrc } from "../lib/themed-image";
7
7
  import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
8
8
  import { getSiteBrand } from "../lib/site-brand";
9
9
  import { normalizeDevPreviewDsl, type VideoScriptRef } from "../lib/video-script";
10
+ import { useNearViewport } from "./use-near-viewport";
11
+ import { planVideoLoading, videoElementKey } from "../lib/video-loading";
10
12
  import { resolveVideoMode, resolveVideoModeFromEnv, type VideoMode } from "../lib/video-mode";
11
13
 
12
14
  type ArticleVideoFigureProps = {
@@ -18,6 +20,7 @@ type ArticleVideoFigureProps = {
18
20
  };
19
21
 
20
22
  const PREVIEW_STORAGE_PREFIX = "papyrus-video-preview:";
23
+ const VIDEO_LOAD_ROOT_MARGIN = "200px 0px";
21
24
 
22
25
  export function ArticleVideoFigure({
23
26
  video,
@@ -34,6 +37,8 @@ export function ArticleVideoFigure({
34
37
  const [previewReady, setPreviewReady] = useState(false);
35
38
  const iframeRef = useRef<HTMLIFrameElement>(null);
36
39
  const videoRef = useRef<HTMLVideoElement>(null);
40
+ const figureRef = useRef<HTMLElement>(null);
41
+ const isNearViewport = useNearViewport(figureRef, VIDEO_LOAD_ROOT_MARGIN);
37
42
  const isFramed = getSiteBrand().videoPlayer === "framed";
38
43
 
39
44
  useEffect(() => {
@@ -44,6 +49,12 @@ export function ArticleVideoFigure({
44
49
  // Match SSR to the default dark MP4 in `video.src` until after hydration.
45
50
  const resolvedTheme = hasHydrated ? theme : "dark";
46
51
  const src = resolveThemedVideoSrc(video.src, video.themeVariants, resolvedTheme);
52
+ const loadingPlan = planVideoLoading({
53
+ hasHydrated,
54
+ isNearViewport,
55
+ hasPoster: Boolean(video.posterSrc),
56
+ });
57
+ const videoKey = videoElementKey(loadingPlan.attachSource, src);
47
58
  const usePreview = videoMode === "preview" && Boolean(videoScript?.dsl);
48
59
  const previewDsl = videoScript?.dsl
49
60
  ? normalizeDevPreviewDsl(videoScript.dsl, getSiteBrand().video?.sceneComponents)
@@ -191,6 +202,7 @@ export function ArticleVideoFigure({
191
202
  if (isFramed) {
192
203
  return (
193
204
  <figure
205
+ ref={figureRef}
194
206
  className={`${figureClassName} article-video--framed`}
195
207
  data-media-type="video"
196
208
  data-video-theme={resolvedTheme}
@@ -200,11 +212,11 @@ export function ArticleVideoFigure({
200
212
  <video
201
213
  ref={videoRef}
202
214
  playsInline
203
- preload="metadata"
215
+ preload={loadingPlan.preload}
204
216
  poster={video.posterSrc}
205
217
  aria-label={video.alt}
206
218
  className="article-video__player"
207
- key={hasHydrated ? src : "ssr"}
219
+ key={videoKey}
208
220
  onPlay={() => setIsPlaying(true)}
209
221
  onPause={() => setIsPlaying(false)}
210
222
  onEnded={() => setIsPlaying(false)}
@@ -214,7 +226,7 @@ export function ArticleVideoFigure({
214
226
  if (Number.isFinite(nextDuration) && nextDuration > 0) setDuration(nextDuration);
215
227
  }}
216
228
  >
217
- <source src={src} type="video/mp4" />
229
+ {loadingPlan.attachSource ? <source src={src} type="video/mp4" /> : null}
218
230
  </video>
219
231
  </div>
220
232
  {framedControls}
@@ -226,17 +238,17 @@ export function ArticleVideoFigure({
226
238
  }
227
239
 
228
240
  return (
229
- <figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
241
+ <figure ref={figureRef} className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
230
242
  <video
231
243
  controls
232
244
  playsInline
233
- preload="metadata"
245
+ preload={loadingPlan.preload}
234
246
  poster={video.posterSrc}
235
247
  aria-label={video.alt}
236
248
  className="article-video__player"
237
- key={hasHydrated ? src : "ssr"}
249
+ key={videoKey}
238
250
  >
239
- <source src={src} type="video/mp4" />
251
+ {loadingPlan.attachSource ? <source src={src} type="video/mp4" /> : null}
240
252
  </video>
241
253
  {video.caption ? <figcaption>{video.caption}</figcaption> : null}
242
254
  {video.credit ? <p className="article-video__credit">{video.credit}</p> : null}
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type RefObject } from "react";
4
+
5
+ export function useNearViewport(targetRef: RefObject<Element | null>, rootMargin: string): boolean {
6
+ const [isNearViewport, setIsNearViewport] = useState(false);
7
+
8
+ useEffect(() => {
9
+ if (isNearViewport) return;
10
+ const target = targetRef.current;
11
+ if (!target) return;
12
+ if (typeof IntersectionObserver === "undefined") {
13
+ setIsNearViewport(true);
14
+ return;
15
+ }
16
+ const observer = new IntersectionObserver(
17
+ (entries) => {
18
+ if (entries.some((entry) => entry.isIntersecting)) {
19
+ setIsNearViewport(true);
20
+ observer.disconnect();
21
+ }
22
+ },
23
+ { rootMargin },
24
+ );
25
+ observer.observe(target);
26
+ return () => observer.disconnect();
27
+ }, [isNearViewport, rootMargin, targetRef]);
28
+
29
+ return isNearViewport;
30
+ }
@@ -0,0 +1,22 @@
1
+ export type VideoPreload = "none" | "metadata";
2
+
3
+ export type VideoLoadingPlan = {
4
+ attachSource: boolean;
5
+ preload: VideoPreload;
6
+ };
7
+
8
+ export type VideoLoadingInput = {
9
+ hasHydrated: boolean;
10
+ isNearViewport: boolean;
11
+ hasPoster: boolean;
12
+ };
13
+
14
+ export function planVideoLoading({ hasHydrated, isNearViewport, hasPoster }: VideoLoadingInput): VideoLoadingPlan {
15
+ const attachSource = hasHydrated && isNearViewport;
16
+ const preload: VideoPreload = attachSource && !hasPoster ? "metadata" : "none";
17
+ return { attachSource, preload };
18
+ }
19
+
20
+ export function videoElementKey(attachSource: boolean, resolvedSource: string): string {
21
+ return attachSource ? resolvedSource : "unattached";
22
+ }
package/package.json CHANGED
@@ -127,6 +127,7 @@
127
127
  "./components/ui/tooltip": "./components/ui/tooltip.tsx",
128
128
  "./components/use-blog-header-obstacles": "./components/use-blog-header-obstacles.ts",
129
129
  "./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
130
+ "./components/use-near-viewport": "./components/use-near-viewport.ts",
130
131
  "./components/use-resolved-papyrus-theme": "./components/use-resolved-papyrus-theme.ts",
131
132
  "./components/use-rhythm-overlay": "./components/use-rhythm-overlay.ts",
132
133
  "./lib/amplify-outputs-guard": "./lib/amplify-outputs-guard.ts",
@@ -224,6 +225,7 @@
224
225
  "./lib/staging-preview-object": "./lib/staging-preview-object.ts",
225
226
  "./lib/themed-image": "./lib/themed-image.ts",
226
227
  "./lib/utils": "./lib/utils.ts",
228
+ "./lib/video-loading": "./lib/video-loading.ts",
227
229
  "./lib/video-mode": "./lib/video-mode.ts",
228
230
  "./lib/video-script": "./lib/video-script.ts",
229
231
  "./renderers/markus/index": "./renderers/markus/index.ts",
@@ -306,7 +308,7 @@
306
308
  "optional": true
307
309
  }
308
310
  },
309
- "version": "1.0.0-next.51",
311
+ "version": "1.0.0-next.52",
310
312
  "dependencies": {
311
313
  "@aws-amplify/adapter-nextjs": "^1.7.3",
312
314
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.51",
2
+ "version": "1.0.0-next.52",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",