@anthusai/papyrus 1.0.0-next.2 → 1.0.0-next.21
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/amplify/auth/resource.js +17 -4
- package/amplify/auth/resource.ts +21 -4
- package/amplify/data/resource.js +52 -70
- package/amplify/data/resource.ts +57 -70
- package/amplify/data/schema.js +52 -58
- package/amplify/data/schema.ts +57 -58
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +149 -0
- package/amplify/functions/content-actions/requirements.txt +4 -0
- package/amplify/functions/content-actions/resource.js +40 -0
- package/amplify/functions/content-actions/resource.ts +54 -0
- package/amplify/functions/shared/python-bundle.js +17 -2
- package/amplify/functions/shared/python-bundle.ts +23 -2
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/site-backend-config.ts +12 -1
- package/amplify/site-backend.js +51 -27
- package/amplify/site-backend.ts +66 -34
- package/amplify/storage/resource.js +3 -0
- package/amplify/storage/resource.ts +3 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +4 -2
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/api/revalidate/route.ts +8 -7
- package/app/articles/[slug]/page.tsx +6 -4
- package/app/blog-rhythm.css +278 -0
- package/app/globals.css +22 -4
- package/app/layout.tsx +24 -12
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/app/page.tsx +12 -4
- package/app/robots.ts +11 -0
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +74 -18
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +247 -3
- package/components/blog-page-background.tsx +23 -6
- package/components/markus-ir-preview.tsx +300 -0
- package/components/news-desk-page.tsx +1 -4
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +66 -0
- package/components/newsroom-article-editor.tsx +741 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/settings-page.tsx +2 -1
- package/components/staging-banner.tsx +11 -0
- package/components/topic-steering-workspace.tsx +1 -59
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +285 -80
- package/infra/amplify-app-shell.ts +326 -113
- package/infra/build-specs.js +94 -0
- package/infra/build-specs.ts +141 -0
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +3 -0
- package/infra/site-config.js +273 -0
- package/infra/site-config.ts +336 -0
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +14 -1
- package/lib/category-repository.ts +1 -93
- package/lib/content-actions-client.ts +98 -0
- package/lib/content-actions-demo.ts +190 -0
- package/lib/content-repository.ts +6 -0
- package/lib/content-source-context.ts +26 -0
- package/lib/content-types.ts +4 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/empty-theme.css +1 -0
- package/lib/graphql-content-repository.ts +239 -43
- package/lib/layout-scenarios.ts +8 -8
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-media-backend.ts +142 -0
- package/lib/newsroom-media.ts +182 -0
- package/lib/newsroom-nav.ts +3 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-static-params.ts +8 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/site-brand-tenant.ts +3 -22
- package/lib/site-brand.ts +95 -42
- package/lib/site-env.ts +52 -0
- package/lib/staging-gate.ts +37 -0
- package/lib/staging-gated-path.ts +22 -0
- package/lib/staging-preview-object.ts +98 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +77 -0
- package/middleware.ts +56 -42
- package/package.json +40 -1
- package/renderers/pretext/article-page.tsx +40 -9
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +18 -112
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +37 -2
- package/scripts/videoml/build-browser-bundle.mjs +22 -0
- package/scripts/videoml/build-preview-bundle.mjs +50 -0
- package/scripts/videoml/bundle-common.mjs +129 -0
- package/scripts/videoml/preview/preview-entry.tsx +381 -0
- package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
- package/scripts/videoml/preview/preview-timing.ts +42 -0
- package/scripts/videoml/preview.html +77 -0
- package/scripts/videoml/standard-entry.tsx +46 -0
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.mjs +15 -2
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
- package/publications/anth_us/brand.ts +0 -68
- package/publications/anth_us/theme.css +0 -66
- package/publications/pilobol_us/brand.ts +0 -37
- package/publications/pilobol_us/theme.css +0 -140
- package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
- package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
- package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
- package/publications/threat_intelligence/brand.ts +0 -40
- package/publications/threat_intelligence/pictograms/art.tsx +0 -608
- package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
- package/publications/threat_intelligence/pictograms/registry.ts +0 -30
- package/publications/threat_intelligence/pictograms/system.tsx +0 -305
- package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
- package/publications/threat_intelligence/theme.css +0 -1440
- /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
|
|
6
|
+
const scriptsDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
const OPTIONAL_DYNAMIC_IMPORTS_OF_VIDEOML = ["p5", "lottie-web", "animejs"];
|
|
10
|
+
|
|
11
|
+
const REACT_GLOBALS_JSX_RUNTIME = `
|
|
12
|
+
var React = window.React;
|
|
13
|
+
function jsx(type, config, maybeKey) {
|
|
14
|
+
var props = config || {};
|
|
15
|
+
var key = maybeKey !== undefined ? maybeKey : props.key;
|
|
16
|
+
if (key !== undefined) {
|
|
17
|
+
props = Object.assign({}, props, { key: key });
|
|
18
|
+
}
|
|
19
|
+
return React.createElement(type, props);
|
|
20
|
+
}
|
|
21
|
+
exports.Fragment = React.Fragment;
|
|
22
|
+
exports.jsx = jsx;
|
|
23
|
+
exports.jsxs = jsx;
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const reactGlobalsPlugin = {
|
|
27
|
+
name: "react-globals",
|
|
28
|
+
setup(build) {
|
|
29
|
+
const globals = {
|
|
30
|
+
react: "module.exports = window.React",
|
|
31
|
+
"react-dom": "module.exports = window.ReactDOM",
|
|
32
|
+
"react-dom/client": "module.exports = window.ReactDOM",
|
|
33
|
+
"react/jsx-runtime": REACT_GLOBALS_JSX_RUNTIME,
|
|
34
|
+
"react/jsx-dev-runtime": REACT_GLOBALS_JSX_RUNTIME,
|
|
35
|
+
};
|
|
36
|
+
build.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, (args) =>
|
|
37
|
+
args.path in globals ? { path: args.path, namespace: "react-globals" } : undefined,
|
|
38
|
+
);
|
|
39
|
+
build.onLoad({ filter: /.*/, namespace: "react-globals" }, (args) => ({
|
|
40
|
+
contents: globals[args.path],
|
|
41
|
+
loader: "js",
|
|
42
|
+
}));
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function resolveSelectedBrandId(site, env) {
|
|
47
|
+
const requested = (env.PAPYRUS_SITE_BRAND ?? "").trim();
|
|
48
|
+
return requested || site.defaultBrand || "papyrus";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Loads the publication's `papyrus.config.ts` from the project root and returns the
|
|
53
|
+
* selected brand's `video` slot. Fails with a message naming the missing piece.
|
|
54
|
+
*/
|
|
55
|
+
export async function loadBrandVideoSlot(projectRoot, env = process.env) {
|
|
56
|
+
const configPath = path.join(projectRoot, "papyrus.config.ts");
|
|
57
|
+
if (!fs.existsSync(configPath)) {
|
|
58
|
+
throw new Error(`No papyrus.config.ts in ${projectRoot}. Run the VideoML bundle builder from the publication root.`);
|
|
59
|
+
}
|
|
60
|
+
const { tsImport } = await import("tsx/esm/api");
|
|
61
|
+
// Inside a Papyrus checkout, brand components import lib/site-brand, which imports this
|
|
62
|
+
// config: load site-brand first so the cycle resolves in the same order as in the app.
|
|
63
|
+
const checkoutSiteBrand = path.join(projectRoot, "lib/site-brand.ts");
|
|
64
|
+
if (fs.existsSync(checkoutSiteBrand)) await tsImport(pathToFileURL(checkoutSiteBrand).href, import.meta.url);
|
|
65
|
+
const loaded = await tsImport(pathToFileURL(configPath).href, import.meta.url);
|
|
66
|
+
const exported = loaded.default ?? loaded;
|
|
67
|
+
const site = exported.brands === undefined && exported.default ? exported.default : exported;
|
|
68
|
+
const brandId = resolveSelectedBrandId(site, env);
|
|
69
|
+
const brand = (site.brands ?? []).find((candidate) => candidate.id === brandId);
|
|
70
|
+
if (!brand) {
|
|
71
|
+
const registered = (site.brands ?? []).map((candidate) => candidate.id).join(", ") || "none";
|
|
72
|
+
throw new Error(`Brand '${brandId}' is not registered in papyrus.config.ts (registered: ${registered}).`);
|
|
73
|
+
}
|
|
74
|
+
if (!brand.video || typeof brand.video.bundleEntry !== "string" || !brand.video.bundleEntry) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Brand '${brandId}' has no brand.video.bundleEntry in papyrus.config.ts. ` +
|
|
77
|
+
"Set brand.video = { bundleEntry: \"<path to a TSX module that registers the brand's VideoML scene components>\" }.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const bundleEntry = path.resolve(projectRoot, brand.video.bundleEntry);
|
|
81
|
+
if (!fs.existsSync(bundleEntry)) {
|
|
82
|
+
throw new Error(`brand.video.bundleEntry for '${brandId}' does not exist: ${bundleEntry}`);
|
|
83
|
+
}
|
|
84
|
+
return { brandId, bundleEntry, video: brand.video };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveEsbuild(projectRoot) {
|
|
88
|
+
for (const base of [projectRoot, scriptsDirectory]) {
|
|
89
|
+
try {
|
|
90
|
+
return require(require.resolve("esbuild", { paths: [base] }));
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw new Error("esbuild not found. Install it in the publication (npm install --save-dev esbuild).");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Bundles [standard entry, optional extra entries, brand entry] into one IIFE.
|
|
100
|
+
* React comes from `window.React` / `window.ReactDOM` (the render shell loads React 18 UMD).
|
|
101
|
+
*/
|
|
102
|
+
export async function bundleVideoEntries({ projectRoot, entries, outfile }) {
|
|
103
|
+
const esbuild = resolveEsbuild(projectRoot);
|
|
104
|
+
fs.mkdirSync(path.dirname(outfile), { recursive: true });
|
|
105
|
+
const importLines = entries.map((entry) => `import ${JSON.stringify(entry)};`).join("\n");
|
|
106
|
+
await esbuild.build({
|
|
107
|
+
stdin: { contents: importLines, resolveDir: projectRoot, sourcefile: "papyrus-videoml-entry.ts", loader: "ts" },
|
|
108
|
+
bundle: true,
|
|
109
|
+
format: "iife",
|
|
110
|
+
platform: "browser",
|
|
111
|
+
jsx: "automatic",
|
|
112
|
+
outfile,
|
|
113
|
+
absWorkingDir: projectRoot,
|
|
114
|
+
nodePaths: [path.join(projectRoot, "node_modules")],
|
|
115
|
+
external: OPTIONAL_DYNAMIC_IMPORTS_OF_VIDEOML,
|
|
116
|
+
plugins: [reactGlobalsPlugin],
|
|
117
|
+
loader: { ".tsx": "tsx", ".ts": "ts" },
|
|
118
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
119
|
+
sourcemap: true,
|
|
120
|
+
logLevel: "warning",
|
|
121
|
+
});
|
|
122
|
+
const size = fs.statSync(outfile).size;
|
|
123
|
+
if (size === 0) throw new Error(`VideoML bundle is empty: ${outfile}`);
|
|
124
|
+
return { outfile, size };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const standardEntryPath = path.join(scriptsDirectory, "standard-entry.tsx");
|
|
128
|
+
export const previewEntryPath = path.join(scriptsDirectory, "preview/preview-entry.tsx");
|
|
129
|
+
export const previewHtmlTemplatePath = path.join(scriptsDirectory, "preview.html");
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { ComposableRenderer, RendererProvider } from "@videoml/toolchain/renderer";
|
|
3
|
+
import { dslToScriptData } from "@videoml/toolchain/shared";
|
|
4
|
+
import { executeVomXml } from "@videoml/player";
|
|
5
|
+
import { advanceScriptPreviewTime, computePreviewPlaybackState } from "./preview-timing";
|
|
6
|
+
import { computePreviewStageFit } from "./preview-stage-fit";
|
|
7
|
+
|
|
8
|
+
type PreviewTheme = "dark" | "light";
|
|
9
|
+
type ScriptData = ReturnType<typeof dslToScriptData>;
|
|
10
|
+
type PreviewRetheme = (xml: string, theme: PreviewTheme) => string;
|
|
11
|
+
|
|
12
|
+
type PreviewMountOptions = {
|
|
13
|
+
xml: string;
|
|
14
|
+
theme?: PreviewTheme;
|
|
15
|
+
autoPlay?: boolean;
|
|
16
|
+
audioSrc?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type PreviewPlayerProps = {
|
|
20
|
+
script: ScriptData;
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
autoPlay?: boolean;
|
|
24
|
+
audioSrc?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let rethemeXml: PreviewRetheme = (xml) => xml;
|
|
28
|
+
|
|
29
|
+
function registerPreviewRetheme(retheme: PreviewRetheme): void {
|
|
30
|
+
rethemeXml = retheme;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function scriptDuration(script: ScriptData): number {
|
|
34
|
+
const scenes = script.scenes ?? [];
|
|
35
|
+
if (!scenes.length) return 10;
|
|
36
|
+
const lastScene = scenes[scenes.length - 1];
|
|
37
|
+
return typeof lastScene.endSec === "number" && lastScene.endSec > 0 ? lastScene.endSec : 10;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function PreviewPlayer({ script, width, height, autoPlay = false, audioSrc }: PreviewPlayerProps) {
|
|
41
|
+
const [isPlaying, setIsPlaying] = useState(autoPlay);
|
|
42
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
43
|
+
const [audioDuration, setAudioDuration] = useState<number | null>(null);
|
|
44
|
+
const [audioBlocked, setAudioBlocked] = useState(false);
|
|
45
|
+
const [audioProgressObserved, setAudioProgressObserved] = useState(false);
|
|
46
|
+
const [viewportSize, setViewportSize] = useState({ width, height });
|
|
47
|
+
const animationFrameRef = useRef<number | undefined>(undefined);
|
|
48
|
+
const lastTimestampRef = useRef<number | undefined>(undefined);
|
|
49
|
+
const currentTimeRef = useRef(0);
|
|
50
|
+
const audioRef = useRef<HTMLAudioElement>(null);
|
|
51
|
+
const lastAudioTimeRef = useRef(0);
|
|
52
|
+
const viewportRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
const usedAudioClockRef = useRef(false);
|
|
54
|
+
|
|
55
|
+
const fps = script.fps ?? 30;
|
|
56
|
+
const scriptDerivedDuration = useMemo(() => scriptDuration(script), [script]);
|
|
57
|
+
const playbackState = useMemo(
|
|
58
|
+
() =>
|
|
59
|
+
computePreviewPlaybackState({
|
|
60
|
+
audioBlocked,
|
|
61
|
+
audioDuration,
|
|
62
|
+
audioProgressObserved,
|
|
63
|
+
audioSrc,
|
|
64
|
+
currentTime,
|
|
65
|
+
isPlaying,
|
|
66
|
+
scriptDerivedDuration,
|
|
67
|
+
}),
|
|
68
|
+
[audioBlocked, audioDuration, audioProgressObserved, audioSrc, currentTime, isPlaying, scriptDerivedDuration],
|
|
69
|
+
);
|
|
70
|
+
const { duration, hasAudioSource, useAudioClock, visualTime } = playbackState;
|
|
71
|
+
|
|
72
|
+
const currentFrame = Math.floor(visualTime * fps);
|
|
73
|
+
const sceneBackground = script.scenes?.[0]?.styles?.background;
|
|
74
|
+
const backgroundColor = typeof sceneBackground === "string" ? sceneBackground : "#000";
|
|
75
|
+
const fittedStage = useMemo(
|
|
76
|
+
() =>
|
|
77
|
+
computePreviewStageFit({
|
|
78
|
+
containerHeight: viewportSize.height,
|
|
79
|
+
containerWidth: viewportSize.width,
|
|
80
|
+
stageHeight: height,
|
|
81
|
+
stageWidth: width,
|
|
82
|
+
}),
|
|
83
|
+
[height, viewportSize.height, viewportSize.width, width],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const applyTime = useCallback(
|
|
87
|
+
(nextTime: number) => {
|
|
88
|
+
const clamped = Math.max(0, Math.min(nextTime, duration));
|
|
89
|
+
currentTimeRef.current = clamped;
|
|
90
|
+
setCurrentTime(clamped);
|
|
91
|
+
},
|
|
92
|
+
[duration],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const playAudio = useCallback(async () => {
|
|
96
|
+
const audio = audioRef.current;
|
|
97
|
+
if (!audio || !hasAudioSource) return true;
|
|
98
|
+
try {
|
|
99
|
+
await audio.play();
|
|
100
|
+
setAudioBlocked(false);
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
setAudioBlocked(true);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}, [hasAudioSource]);
|
|
107
|
+
|
|
108
|
+
const pauseAudio = useCallback(() => {
|
|
109
|
+
const audio = audioRef.current;
|
|
110
|
+
if (!audio || !hasAudioSource) return;
|
|
111
|
+
audio.pause();
|
|
112
|
+
}, [hasAudioSource]);
|
|
113
|
+
|
|
114
|
+
const seekAudio = useCallback(
|
|
115
|
+
(nextTime: number) => {
|
|
116
|
+
const audio = audioRef.current;
|
|
117
|
+
if (!audio || !hasAudioSource) return;
|
|
118
|
+
try {
|
|
119
|
+
audio.currentTime = nextTime;
|
|
120
|
+
} catch {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
[hasAudioSource],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const audio = audioRef.current;
|
|
129
|
+
setAudioDuration(null);
|
|
130
|
+
setAudioProgressObserved(false);
|
|
131
|
+
setAudioBlocked(false);
|
|
132
|
+
lastAudioTimeRef.current = 0;
|
|
133
|
+
if (!audio || !hasAudioSource) return;
|
|
134
|
+
|
|
135
|
+
const onLoadedMetadata = () => {
|
|
136
|
+
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
|
137
|
+
setAudioDuration(audio.duration);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const markAudioProgress = () => {
|
|
141
|
+
if (!Number.isFinite(audio.currentTime)) return;
|
|
142
|
+
if (audio.currentTime > 0 || audio.currentTime !== lastAudioTimeRef.current) {
|
|
143
|
+
setAudioProgressObserved(true);
|
|
144
|
+
setAudioBlocked(false);
|
|
145
|
+
}
|
|
146
|
+
lastAudioTimeRef.current = audio.currentTime;
|
|
147
|
+
};
|
|
148
|
+
const onEnded = () => setIsPlaying(false);
|
|
149
|
+
const onError = () => {
|
|
150
|
+
setAudioBlocked(true);
|
|
151
|
+
setAudioProgressObserved(false);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
audio.addEventListener("loadedmetadata", onLoadedMetadata);
|
|
155
|
+
audio.addEventListener("timeupdate", markAudioProgress);
|
|
156
|
+
audio.addEventListener("seeked", markAudioProgress);
|
|
157
|
+
audio.addEventListener("ended", onEnded);
|
|
158
|
+
audio.addEventListener("error", onError);
|
|
159
|
+
if (audio.readyState >= 1) onLoadedMetadata();
|
|
160
|
+
|
|
161
|
+
return () => {
|
|
162
|
+
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
|
|
163
|
+
audio.removeEventListener("timeupdate", markAudioProgress);
|
|
164
|
+
audio.removeEventListener("seeked", markAudioProgress);
|
|
165
|
+
audio.removeEventListener("ended", onEnded);
|
|
166
|
+
audio.removeEventListener("error", onError);
|
|
167
|
+
};
|
|
168
|
+
}, [audioSrc, hasAudioSource]);
|
|
169
|
+
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!autoPlay || !hasAudioSource) return;
|
|
172
|
+
void playAudio();
|
|
173
|
+
}, [audioSrc, autoPlay, hasAudioSource, playAudio]);
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
const viewport = viewportRef.current;
|
|
177
|
+
if (!viewport || typeof ResizeObserver === "undefined") return;
|
|
178
|
+
const updateViewport = () => {
|
|
179
|
+
const rect = viewport.getBoundingClientRect();
|
|
180
|
+
const nextWidth = Math.round(rect.width);
|
|
181
|
+
const nextHeight = Math.round(rect.height);
|
|
182
|
+
setViewportSize((current) => {
|
|
183
|
+
if (current.width === nextWidth && current.height === nextHeight) return current;
|
|
184
|
+
return { width: nextWidth > 0 ? nextWidth : width, height: nextHeight > 0 ? nextHeight : height };
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
updateViewport();
|
|
188
|
+
const observer = new ResizeObserver(updateViewport);
|
|
189
|
+
observer.observe(viewport);
|
|
190
|
+
return () => observer.disconnect();
|
|
191
|
+
}, [height, width]);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (usedAudioClockRef.current && !useAudioClock) {
|
|
195
|
+
currentTimeRef.current = visualTime;
|
|
196
|
+
setCurrentTime(visualTime);
|
|
197
|
+
}
|
|
198
|
+
usedAudioClockRef.current = useAudioClock;
|
|
199
|
+
}, [useAudioClock, visualTime]);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (!isPlaying) {
|
|
203
|
+
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
204
|
+
pauseAudio();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (hasAudioSource) void playAudio();
|
|
208
|
+
|
|
209
|
+
const tick = (timestamp: number) => {
|
|
210
|
+
if (!isPlaying) return;
|
|
211
|
+
if (useAudioClock) {
|
|
212
|
+
const audio = audioRef.current;
|
|
213
|
+
if (audio && Number.isFinite(audio.currentTime)) {
|
|
214
|
+
let nextTime = audio.currentTime;
|
|
215
|
+
if (nextTime >= duration && duration > 0) {
|
|
216
|
+
nextTime = 0;
|
|
217
|
+
audio.currentTime = 0;
|
|
218
|
+
}
|
|
219
|
+
applyTime(nextTime);
|
|
220
|
+
}
|
|
221
|
+
} else if (lastTimestampRef.current !== undefined) {
|
|
222
|
+
const delta = (timestamp - lastTimestampRef.current) / 1000;
|
|
223
|
+
applyTime(advanceScriptPreviewTime(currentTimeRef.current, delta, duration));
|
|
224
|
+
}
|
|
225
|
+
lastTimestampRef.current = timestamp;
|
|
226
|
+
animationFrameRef.current = requestAnimationFrame(tick);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
lastTimestampRef.current = undefined;
|
|
230
|
+
animationFrameRef.current = requestAnimationFrame(tick);
|
|
231
|
+
return () => {
|
|
232
|
+
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
233
|
+
};
|
|
234
|
+
}, [applyTime, duration, hasAudioSource, isPlaying, pauseAudio, playAudio, useAudioClock]);
|
|
235
|
+
|
|
236
|
+
const startPlayback = useCallback(async () => {
|
|
237
|
+
if (hasAudioSource) await playAudio();
|
|
238
|
+
setIsPlaying(true);
|
|
239
|
+
lastTimestampRef.current = undefined;
|
|
240
|
+
}, [hasAudioSource, playAudio]);
|
|
241
|
+
|
|
242
|
+
const stopPlayback = useCallback(() => {
|
|
243
|
+
setIsPlaying(false);
|
|
244
|
+
lastTimestampRef.current = undefined;
|
|
245
|
+
}, []);
|
|
246
|
+
|
|
247
|
+
const handleSeek = useCallback(
|
|
248
|
+
(nextTime: number) => {
|
|
249
|
+
applyTime(nextTime);
|
|
250
|
+
seekAudio(nextTime);
|
|
251
|
+
lastTimestampRef.current = undefined;
|
|
252
|
+
},
|
|
253
|
+
[applyTime, seekAudio],
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const lastBroadcastRef = useRef<{ currentTime: number; duration: number } | null>(null);
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (typeof window === "undefined" || window.parent === window) return;
|
|
259
|
+
const roundedTime = Math.round(currentTime * 10) / 10;
|
|
260
|
+
const roundedDuration = Math.round(duration * 10) / 10;
|
|
261
|
+
const previous = lastBroadcastRef.current;
|
|
262
|
+
if (previous && previous.currentTime === roundedTime && previous.duration === roundedDuration) return;
|
|
263
|
+
lastBroadcastRef.current = { currentTime: roundedTime, duration: roundedDuration };
|
|
264
|
+
window.parent.postMessage({ kind: "papyrus-video-time", currentTime: roundedTime, duration: roundedDuration }, "*");
|
|
265
|
+
}, [currentTime, duration]);
|
|
266
|
+
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
const onMessage = (event: MessageEvent) => {
|
|
269
|
+
const payload = event.data;
|
|
270
|
+
if (!payload || typeof payload !== "object") return;
|
|
271
|
+
if (payload.kind === "papyrus-video-play") {
|
|
272
|
+
void startPlayback();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (payload.kind === "papyrus-video-pause") {
|
|
276
|
+
stopPlayback();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (payload.kind === "papyrus-video-seek" && typeof payload.time === "number") {
|
|
280
|
+
handleSeek(payload.time);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
window.addEventListener("message", onMessage);
|
|
284
|
+
return () => window.removeEventListener("message", onMessage);
|
|
285
|
+
}, [handleSeek, startPlayback, stopPlayback]);
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<div style={{ background: "#000", height: "100%", overflow: "hidden", position: "relative", width: "100%" }}>
|
|
289
|
+
{hasAudioSource ? <audio ref={audioRef} preload="auto" src={audioSrc} style={{ display: "none" }} /> : null}
|
|
290
|
+
<div
|
|
291
|
+
ref={viewportRef}
|
|
292
|
+
style={{
|
|
293
|
+
alignItems: "center",
|
|
294
|
+
background: backgroundColor,
|
|
295
|
+
display: "flex",
|
|
296
|
+
inset: 0,
|
|
297
|
+
justifyContent: "center",
|
|
298
|
+
overflow: "hidden",
|
|
299
|
+
position: "absolute",
|
|
300
|
+
}}
|
|
301
|
+
>
|
|
302
|
+
<div
|
|
303
|
+
style={{
|
|
304
|
+
height,
|
|
305
|
+
left: "50%",
|
|
306
|
+
position: "absolute",
|
|
307
|
+
top: "50%",
|
|
308
|
+
transform: `translate(-50%, -50%) scale(${fittedStage.scale})`,
|
|
309
|
+
transformOrigin: "center center",
|
|
310
|
+
width,
|
|
311
|
+
}}
|
|
312
|
+
>
|
|
313
|
+
<RendererProvider
|
|
314
|
+
frame={currentFrame}
|
|
315
|
+
config={{ fps, width, height, durationFrames: Math.max(1, Math.floor(duration * fps)) }}
|
|
316
|
+
>
|
|
317
|
+
<ComposableRenderer script={script} />
|
|
318
|
+
</RendererProvider>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function parsePreviewScript(xml: string): ScriptData {
|
|
326
|
+
const result = executeVomXml(xml);
|
|
327
|
+
const composition = Array.isArray(result?.compositions) && result.compositions[0] ? result.compositions[0] : result;
|
|
328
|
+
const script = dslToScriptData(composition, { type: "cue-count", secondsPerCue: 3 });
|
|
329
|
+
|
|
330
|
+
const timeline: unknown[] = [];
|
|
331
|
+
const scenes = script.scenes ?? [];
|
|
332
|
+
let sceneIndex = 0;
|
|
333
|
+
|
|
334
|
+
const doc = new DOMParser().parseFromString(xml, "text/xml");
|
|
335
|
+
for (const element of Array.from(doc.documentElement.children)) {
|
|
336
|
+
if (element.tagName === "scene") {
|
|
337
|
+
const scene = scenes[sceneIndex];
|
|
338
|
+
if (scene) {
|
|
339
|
+
timeline.push(scene);
|
|
340
|
+
sceneIndex += 1;
|
|
341
|
+
}
|
|
342
|
+
} else if (element.tagName === "transition") {
|
|
343
|
+
const previousScene = sceneIndex > 0 ? scenes[sceneIndex - 1] : null;
|
|
344
|
+
const nextScene = sceneIndex < scenes.length ? scenes[sceneIndex] : null;
|
|
345
|
+
const durationAttribute = element.getAttribute("duration");
|
|
346
|
+
const durationFrames = durationAttribute ? parseFloat(durationAttribute.replace("f", "")) : 16;
|
|
347
|
+
const startSec = nextScene ? (nextScene.startSec ?? 0) : 0;
|
|
348
|
+
const propsAttribute = element.getAttribute("props");
|
|
349
|
+
timeline.push({
|
|
350
|
+
kind: "transition",
|
|
351
|
+
id: element.getAttribute("id") || `trans-${sceneIndex}`,
|
|
352
|
+
effect: element.getAttribute("effect") || "push",
|
|
353
|
+
durationFrames,
|
|
354
|
+
ease: element.getAttribute("ease"),
|
|
355
|
+
props: propsAttribute ? JSON.parse(propsAttribute) : undefined,
|
|
356
|
+
startSec,
|
|
357
|
+
endSec: startSec + durationFrames / (script.fps ?? 30),
|
|
358
|
+
fromSceneId: previousScene?.id,
|
|
359
|
+
toSceneId: nextScene?.id,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
(script as { timeline?: unknown[] }).timeline = timeline;
|
|
365
|
+
return script;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function mountPreview(container: HTMLElement, options: PreviewMountOptions): () => void {
|
|
369
|
+
const theme = options.theme ?? "light";
|
|
370
|
+
const script = parsePreviewScript(rethemeXml(options.xml, theme));
|
|
371
|
+
const width = script.meta?.width && script.meta.width > 0 ? script.meta.width : 1280;
|
|
372
|
+
const height = script.meta?.height && script.meta.height > 0 ? script.meta.height : 720;
|
|
373
|
+
const root = (window as unknown as { ReactDOM: { createRoot: (el: HTMLElement) => { render: (node: React.ReactNode) => void; unmount: () => void } } }).ReactDOM.createRoot(container);
|
|
374
|
+
root.render(
|
|
375
|
+
<PreviewPlayer audioSrc={options.audioSrc} autoPlay={options.autoPlay ?? false} height={height} script={script} width={width} />,
|
|
376
|
+
);
|
|
377
|
+
return () => root.unmount();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
(window as unknown as Record<string, unknown>).PapyrusVideo = { registerPreviewRetheme };
|
|
381
|
+
(window as unknown as Record<string, unknown>).mountPapyrusVideoPreview = mountPreview;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type PreviewStageFitInput = {
|
|
2
|
+
containerHeight: number;
|
|
3
|
+
containerWidth: number;
|
|
4
|
+
stageHeight: number;
|
|
5
|
+
stageWidth: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type PreviewStageFit = {
|
|
9
|
+
height: number;
|
|
10
|
+
scale: number;
|
|
11
|
+
width: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function computePreviewStageFit(input: PreviewStageFitInput): PreviewStageFit {
|
|
15
|
+
const stageWidth = input.stageWidth > 0 ? input.stageWidth : 1280;
|
|
16
|
+
const stageHeight = input.stageHeight > 0 ? input.stageHeight : 720;
|
|
17
|
+
const containerWidth = input.containerWidth > 0 ? input.containerWidth : stageWidth;
|
|
18
|
+
const containerHeight = input.containerHeight > 0 ? input.containerHeight : stageHeight;
|
|
19
|
+
|
|
20
|
+
const scale = Math.min(containerWidth / stageWidth, containerHeight / stageHeight);
|
|
21
|
+
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
height: stageHeight * safeScale,
|
|
25
|
+
scale: safeScale,
|
|
26
|
+
width: stageWidth * safeScale,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type PreviewPlaybackStateInput = {
|
|
2
|
+
audioBlocked: boolean;
|
|
3
|
+
audioDuration: number | null;
|
|
4
|
+
audioProgressObserved: boolean;
|
|
5
|
+
audioSrc?: string | null;
|
|
6
|
+
currentTime: number;
|
|
7
|
+
isPlaying: boolean;
|
|
8
|
+
scriptDerivedDuration: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type PreviewPlaybackState = {
|
|
12
|
+
audioReady: boolean;
|
|
13
|
+
controlLabel: string;
|
|
14
|
+
duration: number;
|
|
15
|
+
hasAudioSource: boolean;
|
|
16
|
+
useAudioClock: boolean;
|
|
17
|
+
visualTime: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function computePreviewPlaybackState(input: PreviewPlaybackStateInput): PreviewPlaybackState {
|
|
21
|
+
const hasAudioSource = Boolean(input.audioSrc);
|
|
22
|
+
const audioReady =
|
|
23
|
+
input.audioDuration != null && Number.isFinite(input.audioDuration) && input.audioDuration > 0;
|
|
24
|
+
const useAudioClock = hasAudioSource && audioReady && input.audioProgressObserved && !input.audioBlocked;
|
|
25
|
+
const duration = useAudioClock && input.audioDuration != null ? input.audioDuration : input.scriptDerivedDuration;
|
|
26
|
+
const visualTime =
|
|
27
|
+
useAudioClock && duration > 0 && input.scriptDerivedDuration > 0
|
|
28
|
+
? (input.currentTime / duration) * input.scriptDerivedDuration
|
|
29
|
+
: input.currentTime;
|
|
30
|
+
const controlLabel = input.isPlaying ? "Pause" : hasAudioSource && input.audioBlocked ? "Play (audio)" : "Play";
|
|
31
|
+
|
|
32
|
+
return { audioReady, controlLabel, duration, hasAudioSource, useAudioClock, visualTime };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function advanceScriptPreviewTime(currentTime: number, deltaSeconds: number, duration: number): number {
|
|
36
|
+
if (!(duration > 0)) return 0;
|
|
37
|
+
let nextTime = currentTime + Math.max(0, deltaSeconds);
|
|
38
|
+
if (nextTime >= duration) {
|
|
39
|
+
nextTime = 0;
|
|
40
|
+
}
|
|
41
|
+
return Math.max(0, Math.min(nextTime, duration));
|
|
42
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Video preview</title>
|
|
7
|
+
<style>
|
|
8
|
+
html,
|
|
9
|
+
body {
|
|
10
|
+
margin: 0;
|
|
11
|
+
width: 100%;
|
|
12
|
+
height: 100%;
|
|
13
|
+
background: #000;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#preview-root {
|
|
18
|
+
width: 100%;
|
|
19
|
+
height: 100%;
|
|
20
|
+
}
|
|
21
|
+
</style>
|
|
22
|
+
</head>
|
|
23
|
+
<body>
|
|
24
|
+
<div id="preview-root"></div>
|
|
25
|
+
<script src="./vendor/react.production.min.js"></script>
|
|
26
|
+
<script src="./vendor/react-dom.production.min.js"></script>
|
|
27
|
+
<script src="./preview-bundle.js"></script>
|
|
28
|
+
<script>
|
|
29
|
+
const root = document.getElementById("preview-root");
|
|
30
|
+
const params = new URLSearchParams(window.location.search);
|
|
31
|
+
let cleanup = null;
|
|
32
|
+
|
|
33
|
+
function showMessage(text) {
|
|
34
|
+
root.textContent = text;
|
|
35
|
+
root.style.cssText = "color:#eee;font:14px/1.4 system-ui,sans-serif;padding:24px;";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readPayload(overrides) {
|
|
39
|
+
const target = (overrides && overrides.target) || params.get("target") || "";
|
|
40
|
+
const theme = (overrides && overrides.theme) || params.get("theme") || "light";
|
|
41
|
+
const audioSrc = (overrides && overrides.audio) || params.get("audio") || "";
|
|
42
|
+
const xml = target ? sessionStorage.getItem("papyrus-video-preview:" + target) : null;
|
|
43
|
+
return { theme, xml, audioSrc };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mountPreview(payload) {
|
|
47
|
+
if (!payload || typeof payload.xml !== "string" || !payload.xml.trim()) {
|
|
48
|
+
showMessage("No VideoML DSL available for preview.");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (typeof cleanup === "function") {
|
|
52
|
+
cleanup();
|
|
53
|
+
cleanup = null;
|
|
54
|
+
}
|
|
55
|
+
root.textContent = "";
|
|
56
|
+
root.removeAttribute("style");
|
|
57
|
+
cleanup = window.mountPapyrusVideoPreview(root, {
|
|
58
|
+
xml: payload.xml,
|
|
59
|
+
theme: payload.theme || "light",
|
|
60
|
+
audioSrc: payload.audioSrc || undefined,
|
|
61
|
+
autoPlay: false,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
window.addEventListener("message", (event) => {
|
|
66
|
+
const payload = event.data;
|
|
67
|
+
if (payload && payload.kind === "papyrus-video-refresh") mountPreview(readPayload(payload));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
mountPreview(readPayload());
|
|
71
|
+
|
|
72
|
+
if (window.parent !== window) {
|
|
73
|
+
window.parent.postMessage({ kind: "papyrus-video-ready" }, "*");
|
|
74
|
+
}
|
|
75
|
+
</script>
|
|
76
|
+
</body>
|
|
77
|
+
</html>
|