@vfrey/vfrey 0.0.1 → 1.0.0-beta.1
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 +33 -0
- package/README.md +78 -3
- package/accounts.example.json +61 -0
- package/accounts.first-run.json +41 -0
- package/bin/vfrey.mjs +36 -0
- package/integration-tools/README.md +43 -0
- package/integration-tools/image/pillow.md +32 -0
- package/integration-tools/image/playwright.md +33 -0
- package/integration-tools/image/resvg.md +36 -0
- package/integration-tools/image/satori.md +36 -0
- package/integration-tools/image/sharp.md +38 -0
- package/integration-tools/video/blender.md +34 -0
- package/integration-tools/video/ffmpeg.md +41 -0
- package/integration-tools/video/manim.md +37 -0
- package/integration-tools/video/motion-canvas.md +37 -0
- package/integration-tools/video/remotion.md +50 -0
- package/integration-tools/video/revideo.md +32 -0
- package/package.json +74 -6
- package/skills/vfrey-orchestrator/SKILL.md +15 -0
- package/skills/vfrey-orchestrator/channel-video/SKILL.md +41 -0
- package/skills/vfrey-product/SKILL.md +63 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/framey/cook-one.svg +14 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/framey/cook-two.svg +22 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/framey/index.html +70 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/remotion/cook-one.svg +14 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/remotion/cook-two.svg +22 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/remotion/index.tsx +49 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/script.json +15 -0
- package/skills/vfrey-product/examples/octopus-facts/index.html +60 -0
- package/skills/vfrey-product/examples/octopus-facts/octopus.svg +8 -0
- package/skills/vfrey-product/examples/octopus-facts/script.json +14 -0
- package/skills/vfrey-product/push/SKILL.md +15 -0
- package/skills/vfrey-product/scenes/NOTICE +5 -0
- package/skills/vfrey-product/scenes/blocks.md +57 -0
- package/skills/vfrey-product/scenes/craft.md +118 -0
- package/skills/vfrey-product/scenes/motion.md +88 -0
- package/src/analytics/dashboard.ts +109 -0
- package/src/analytics/index.ts +260 -0
- package/src/api/client.ts +361 -0
- package/src/api/cloud-files.ts +197 -0
- package/src/api/cloud-runs.ts +75 -0
- package/src/api/cloud.ts +413 -0
- package/src/api/commands.ts +330 -0
- package/src/api/config.ts +34 -0
- package/src/api/creators.ts +463 -0
- package/src/api/credentials.ts +152 -0
- package/src/api/mail.ts +333 -0
- package/src/api/topics.ts +207 -0
- package/src/api/types.ts +273 -0
- package/src/cli.ts +468 -0
- package/src/cloud/bundle.ts +70 -0
- package/src/cloud/cloudrun-executor.ts +237 -0
- package/src/cloud/executor.ts +281 -0
- package/src/cloud/gcs-sign.ts +76 -0
- package/src/cloud/job.ts +106 -0
- package/src/cloud/remote-job.ts +67 -0
- package/src/cloud/render-job.ts +191 -0
- package/src/config.ts +309 -0
- package/src/dashboard-link.ts +180 -0
- package/src/dashboard-start.ts +103 -0
- package/src/desk/channel.ts +64 -0
- package/src/desk/cli.ts +216 -0
- package/src/desk/creators-route.ts +129 -0
- package/src/desk/fonts/JetBrainsMono-OFL.txt +93 -0
- package/src/desk/fonts/google-sans-flex-latin.woff2 +0 -0
- package/src/desk/fonts/jetbrains-mono-medium.woff2 +0 -0
- package/src/desk/http.ts +460 -0
- package/src/desk/mail-route.ts +103 -0
- package/src/desk/overlay.ts +112 -0
- package/src/desk/preview.ts +422 -0
- package/src/desk/screens.ts +149 -0
- package/src/desk/secrets.ts +143 -0
- package/src/desk/series.ts +127 -0
- package/src/desk/state.ts +77 -0
- package/src/desk/studio-route.ts +119 -0
- package/src/desk/tick-runner.ts +66 -0
- package/src/desk/tick.ts +56 -0
- package/src/desk/topics-route.ts +41 -0
- package/src/desk/ui/app.css +505 -0
- package/src/desk/ui/app.js +185 -0
- package/src/desk/ui/apple-icon.png +0 -0
- package/src/desk/ui/components.js +79 -0
- package/src/desk/ui/core.js +156 -0
- package/src/desk/ui/favicon.ico +0 -0
- package/src/desk/ui/icon.svg +1 -0
- package/src/desk/ui/icons.js +49 -0
- package/src/desk/ui/index.html +41 -0
- package/src/desk/ui/metrics-math.d.ts +22 -0
- package/src/desk/ui/metrics-math.js +135 -0
- package/src/desk/ui/theme.js +5 -0
- package/src/desk/ui/views-creators.js +209 -0
- package/src/desk/ui/views-keys.js +114 -0
- package/src/desk/ui/views-mail.js +188 -0
- package/src/desk/ui/views-misc.js +123 -0
- package/src/desk/ui/views-review.js +119 -0
- package/src/desk/ui/views-runs.js +350 -0
- package/src/desk/ui/views-screens.js +163 -0
- package/src/desk/ui/views-series.js +318 -0
- package/src/desk/ui/views-topics.js +87 -0
- package/src/desk/verbs.ts +361 -0
- package/src/doctor.ts +212 -0
- package/src/drain.ts +14 -0
- package/src/engage/index.ts +217 -0
- package/src/generate/index.ts +110 -0
- package/src/generate/script.ts +152 -0
- package/src/generate/title.ts +78 -0
- package/src/help.ts +252 -0
- package/src/home.ts +45 -0
- package/src/llm/client.ts +177 -0
- package/src/log.ts +7 -0
- package/src/oauth/tokens.ts +189 -0
- package/src/oauth/vault.ts +72 -0
- package/src/pipeline/review.ts +642 -0
- package/src/pipeline/run.ts +443 -0
- package/src/pipeline/series-guard.ts +26 -0
- package/src/pipeline/source-fetch.ts +69 -0
- package/src/pipeline/stages.ts +728 -0
- package/src/pipeline/topics-source.ts +90 -0
- package/src/product/commands.ts +296 -0
- package/src/product/config-file.ts +38 -0
- package/src/product/engine-choice.ts +89 -0
- package/src/product/engines.ts +103 -0
- package/src/product/insights.ts +50 -0
- package/src/product/keys.ts +67 -0
- package/src/product/paths.ts +18 -0
- package/src/product/probes.ts +88 -0
- package/src/product/push-flow.ts +90 -0
- package/src/product/render-video.ts +21 -0
- package/src/product/short.ts +62 -0
- package/src/product/update.ts +32 -0
- package/src/project/canonical.ts +14 -0
- package/src/project/commands.ts +177 -0
- package/src/project/dashboard-status.ts +28 -0
- package/src/project/discover.ts +19 -0
- package/src/project/index-file.ts +73 -0
- package/src/project/marker.ts +92 -0
- package/src/project/migrate.ts +201 -0
- package/src/project/names.ts +27 -0
- package/src/project/notice.ts +16 -0
- package/src/project/open.ts +88 -0
- package/src/project/scaffold.ts +112 -0
- package/src/project/templates.ts +42 -0
- package/src/project/videos.ts +64 -0
- package/src/publish/facebook.ts +91 -0
- package/src/publish/index.ts +12 -0
- package/src/publish/instagram.ts +97 -0
- package/src/publish/rate.ts +62 -0
- package/src/publish/session.ts +242 -0
- package/src/publish/tiktok.ts +202 -0
- package/src/publish/youtube.ts +177 -0
- package/src/queue/desk-ops.ts +1170 -0
- package/src/queue/seal.ts +3 -0
- package/src/queue/store.ts +1006 -0
- package/src/remotion/AiLabel.tsx +26 -0
- package/src/remotion/Root.tsx +73 -0
- package/src/remotion/defaults.ts +26 -0
- package/src/remotion/films/claude/ClaudeFilm.tsx +1014 -0
- package/src/remotion/films/engine/EngineMark.tsx +17 -0
- package/src/remotion/films/rea/ReaShort.tsx +552 -0
- package/src/remotion/films/rea/drawings.tsx +907 -0
- package/src/remotion/films/rea/timing.ts +144 -0
- package/src/remotion/index.ts +4 -0
- package/src/remotion/templates/EditorialSplit.tsx +39 -0
- package/src/remotion/templates/KineticType.tsx +39 -0
- package/src/remotion/templates/SignalBoard.tsx +50 -0
- package/src/remotion/timing.ts +22 -0
- package/src/render/chrome.ts +78 -0
- package/src/render/engine-mark.ts +29 -0
- package/src/render/framey-template.ts +130 -0
- package/src/render/html-engine.ts +149 -0
- package/src/render/node-crypto-stub.ts +11 -0
- package/src/render/probe.ts +29 -0
- package/src/render/props.ts +17 -0
- package/src/render/vertical-short.ts +35 -0
- package/src/render/webpack.ts +33 -0
- package/src/render/worker.ts +65 -0
- package/src/scene/assets/fonts/TitanOne-OFL.txt +94 -0
- package/src/scene/assets/fonts/titan-one.woff2 +0 -0
- package/src/scene/assets/gsap/LICENSE.txt +8 -0
- package/src/scene/assets/gsap/gsap.min.js +11 -0
- package/src/scene/audio.ts +54 -0
- package/src/scene/blocks/LICENSE +190 -0
- package/src/scene/blocks/NOTICE +5 -0
- package/src/scene/blocks/bar-chart-race/bar-chart-race.html +585 -0
- package/src/scene/blocks/beat-freeze-cut/beat-freeze-cut.html +874 -0
- package/src/scene/blocks/camcorder-hud/camcorder-hud.html +164 -0
- package/src/scene/blocks/camera-dolly-zoom/camera-dolly-zoom.html +355 -0
- package/src/scene/blocks/code-diff/code-diff.html +817 -0
- package/src/scene/blocks/code-snippet-apple-terminal-pro/code-snippet-apple-terminal-pro.html +248 -0
- package/src/scene/blocks/code-typing/code-typing.html +764 -0
- package/src/scene/blocks/data-chart/data-chart.html +452 -0
- package/src/scene/blocks/flowchart-vertical/flowchart-vertical.html +454 -0
- package/src/scene/blocks/hw-scribble-transition/hw-scribble-transition.html +153 -0
- package/src/scene/blocks/index.json +824 -0
- package/src/scene/blocks/lt-accent-underline/lt-accent-underline.html +107 -0
- package/src/scene/blocks/lt-bold-block/lt-bold-block.html +109 -0
- package/src/scene/blocks/lt-clean-bar/lt-clean-bar.html +112 -0
- package/src/scene/blocks/lt-dark-card/lt-dark-card.html +111 -0
- package/src/scene/blocks/lt-kicker-name/lt-kicker-name.html +114 -0
- package/src/scene/blocks/lt-mask-reveal/lt-mask-reveal.html +120 -0
- package/src/scene/blocks/lt-neon-border/lt-neon-border.html +391 -0
- package/src/scene/blocks/lt-stack-bars/lt-stack-bars.html +109 -0
- package/src/scene/blocks/macos-notification/macos-notification.html +194 -0
- package/src/scene/blocks/mk-background/mk-background.html +236 -0
- package/src/scene/blocks/mk-callout-highlight/mk-callout-highlight.html +183 -0
- package/src/scene/blocks/mk-clone-wall-transition/mk-clone-wall-transition.html +226 -0
- package/src/scene/blocks/mk-line-graph/mk-line-graph.html +293 -0
- package/src/scene/blocks/mk-progress-stat/mk-progress-stat.html +186 -0
- package/src/scene/blocks/mk-specs-list/mk-specs-list.html +223 -0
- package/src/scene/blocks/news-ticker/news-ticker.html +409 -0
- package/src/scene/blocks/organic-light-leak-overlay/organic-light-leak-overlay.html +126 -0
- package/src/scene/blocks/oscilloscope-trace/oscilloscope-trace.html +519 -0
- package/src/scene/blocks/snow/snow.html +205 -0
- package/src/scene/blocks/split-flap-board/split-flap-board.html +347 -0
- package/src/scene/blocks/spotify-card/spotify-card.html +294 -0
- package/src/scene/blocks/starfield/starfield.html +188 -0
- package/src/scene/blocks/thread-message-stack/thread-message-stack.html +239 -0
- package/src/scene/blocks/week-in-merges/week-in-merges.html +470 -0
- package/src/scene/blocks/x-post/x-post.html +437 -0
- package/src/scene/blocks/yt-comment-card/yt-comment-card.html +319 -0
- package/src/scene/blocks/yt-lcd-background/yt-lcd-background.html +307 -0
- package/src/scene/blocks/yt-prism-title/yt-prism-title.html +205 -0
- package/src/scene/blocks.ts +229 -0
- package/src/scene/command.ts +272 -0
- package/src/scene/figure.ts +181 -0
- package/src/scene/glyphs.ts +107 -0
- package/src/scene/init.ts +86 -0
- package/src/scene/inspect/css-tree.d.ts +6 -0
- package/src/scene/inspect/page-scripts.ts +272 -0
- package/src/scene/inspect/report.ts +405 -0
- package/src/scene/inspect/run.ts +200 -0
- package/src/scene/inspect/source.ts +236 -0
- package/src/scene/label.ts +131 -0
- package/src/scene/net-guard.ts +74 -0
- package/src/scene/render.ts +292 -0
- package/src/scene/runtime/browser.ts +199 -0
- package/src/scene/runtime/core.ts +181 -0
- package/src/scene/runtime/react.tsx +115 -0
- package/src/scene/runtime/transitions.ts +133 -0
- package/src/scene/sanitize.ts +181 -0
- package/src/scene/scene.ts +152 -0
- package/src/scene/studio-command.ts +48 -0
- package/src/scene/studio-skin.ts +59 -0
- package/src/scene/studio.ts +337 -0
- package/src/scene/supply.ts +111 -0
- package/src/scene/timing.ts +288 -0
- package/src/scene/transitions/LICENSE +190 -0
- package/src/scene/transitions/NOTICE +5 -0
- package/src/scene/transitions/SOURCE.json +24 -0
- package/src/scene/transitions/transitions.global.js +38 -0
- package/src/scene/transitions.ts +26 -0
- package/src/service/all-projects.ts +153 -0
- package/src/service/commands.ts +256 -0
- package/src/service/due.ts +82 -0
- package/src/service/lock.ts +101 -0
- package/src/service/log-file.ts +56 -0
- package/src/service/state.ts +41 -0
- package/src/service/units.ts +90 -0
- package/src/service/worker.ts +51 -0
- package/src/skill-agents.ts +74 -0
- package/src/skill-install.ts +334 -0
- package/src/sync-write.ts +21 -0
- package/src/tick-report.ts +205 -0
- package/src/tts.ts +124 -0
- package/vendor/@vfrey/contracts/dist/account.d.ts +97 -0
- package/vendor/@vfrey/contracts/dist/account.js +27 -0
- package/vendor/@vfrey/contracts/dist/checks.d.ts +8 -0
- package/vendor/@vfrey/contracts/dist/checks.js +1 -0
- package/vendor/@vfrey/contracts/dist/credentials/crypto.d.ts +4 -0
- package/vendor/@vfrey/contracts/dist/credentials/crypto.js +38 -0
- package/vendor/@vfrey/contracts/dist/domain/advice-persona.d.ts +10 -0
- package/vendor/@vfrey/contracts/dist/domain/advice-persona.js +24 -0
- package/vendor/@vfrey/contracts/dist/domain/backoff.d.ts +13 -0
- package/vendor/@vfrey/contracts/dist/domain/backoff.js +27 -0
- package/vendor/@vfrey/contracts/dist/domain/cadence.d.ts +29 -0
- package/vendor/@vfrey/contracts/dist/domain/cadence.js +95 -0
- package/vendor/@vfrey/contracts/dist/domain/creators.d.ts +146 -0
- package/vendor/@vfrey/contracts/dist/domain/creators.js +2 -0
- package/vendor/@vfrey/contracts/dist/domain/disclosure.d.ts +127 -0
- package/vendor/@vfrey/contracts/dist/domain/disclosure.js +250 -0
- package/vendor/@vfrey/contracts/dist/domain/errors.d.ts +34 -0
- package/vendor/@vfrey/contracts/dist/domain/errors.js +68 -0
- package/vendor/@vfrey/contracts/dist/domain/redact.d.ts +1 -0
- package/vendor/@vfrey/contracts/dist/domain/redact.js +21 -0
- package/vendor/@vfrey/contracts/dist/domain/revenue.d.ts +13 -0
- package/vendor/@vfrey/contracts/dist/domain/revenue.js +16 -0
- package/vendor/@vfrey/contracts/dist/domain/safety.d.ts +20 -0
- package/vendor/@vfrey/contracts/dist/domain/safety.js +61 -0
- package/vendor/@vfrey/contracts/dist/domain/similarity.d.ts +13 -0
- package/vendor/@vfrey/contracts/dist/domain/similarity.js +100 -0
- package/vendor/@vfrey/contracts/dist/domain/templates.d.ts +18 -0
- package/vendor/@vfrey/contracts/dist/domain/templates.js +67 -0
- package/vendor/@vfrey/contracts/dist/domain/tiktok-chunks.d.ts +19 -0
- package/vendor/@vfrey/contracts/dist/domain/tiktok-chunks.js +52 -0
- package/vendor/@vfrey/contracts/dist/domain/topics.d.ts +78 -0
- package/vendor/@vfrey/contracts/dist/domain/topics.js +4 -0
- package/vendor/@vfrey/contracts/dist/domain/types.d.ts +216 -0
- package/vendor/@vfrey/contracts/dist/domain/types.js +1 -0
- package/vendor/@vfrey/contracts/dist/domain/variation.d.ts +57 -0
- package/vendor/@vfrey/contracts/dist/domain/variation.js +278 -0
- package/vendor/@vfrey/contracts/dist/events.d.ts +20 -0
- package/vendor/@vfrey/contracts/dist/events.js +1 -0
- package/vendor/@vfrey/contracts/dist/http.d.ts +11 -0
- package/vendor/@vfrey/contracts/dist/http.js +37 -0
- package/vendor/@vfrey/contracts/dist/index.d.ts +22 -0
- package/vendor/@vfrey/contracts/dist/index.js +22 -0
- package/vendor/@vfrey/contracts/dist/log.d.ts +2 -0
- package/vendor/@vfrey/contracts/dist/log.js +10 -0
- package/vendor/@vfrey/contracts/dist/mail.d.ts +55 -0
- package/vendor/@vfrey/contracts/dist/mail.js +1 -0
- package/vendor/@vfrey/contracts/package.json +18 -0
- package/vendor/@vfrey/ingest/dist/index.d.ts +41 -0
- package/vendor/@vfrey/ingest/dist/index.js +221 -0
- package/vendor/@vfrey/ingest/dist/parse.d.ts +5 -0
- package/vendor/@vfrey/ingest/dist/parse.js +165 -0
- package/vendor/@vfrey/ingest/package.json +21 -0
- package/vendor/framey/LICENSE +190 -0
- package/vendor/framey/NOTICE +15 -0
- package/vendor/framey/SOURCE.json +24 -0
- package/vendor/framey/STUDIO-SOURCE.json +9 -0
- package/vendor/framey/bin/framey-localize-fonts.mjs +12 -0
- package/vendor/framey/bin/framey.mjs +16 -0
- package/vendor/framey/dist/add-VMJD5KBS.js +75 -0
- package/vendor/framey/dist/animatedGifPrep-QPKGJ6PM.js +43 -0
- package/vendor/framey/dist/api.d.ts +211 -0
- package/vendor/framey/dist/api.js +76 -0
- package/vendor/framey/dist/appHistory-WW56FZTT.js +31 -0
- package/vendor/framey/dist/asset-WQKGOELE.js +26 -0
- package/vendor/framey/dist/audio-RSJWVWRA.js +1936 -0
- package/vendor/framey/dist/batchRender-3HPICDCT.js +366 -0
- package/vendor/framey/dist/beats-HL2BDUZI.js +273 -0
- package/vendor/framey/dist/benchmark-RWCWHI5Z.js +230 -0
- package/vendor/framey/dist/browser-ECYET3Q2.js +206 -0
- package/vendor/framey/dist/bundleWithLocalizedFonts-ALYBQEQT.js +52 -0
- package/vendor/framey/dist/captureCompositionFrame-4DA6SEH2.js +94 -0
- package/vendor/framey/dist/catalog-5JVDEUWK.js +979 -0
- package/vendor/framey/dist/catch-up-NEBCL5NO.js +106 -0
- package/vendor/framey/dist/check-F5N544ML.js +111 -0
- package/vendor/framey/dist/checkBrowser-LZ66CMJG.js +110 -0
- package/vendor/framey/dist/chunk-2K7Z2HVP.js +372 -0
- package/vendor/framey/dist/chunk-2UZQHDM7.js +323 -0
- package/vendor/framey/dist/chunk-2X4PQEYM.js +34 -0
- package/vendor/framey/dist/chunk-3422KMTD.js +168 -0
- package/vendor/framey/dist/chunk-3FYD4YEK.js +72 -0
- package/vendor/framey/dist/chunk-3GXQPCJC.js +682 -0
- package/vendor/framey/dist/chunk-3LM6YPBR.js +179 -0
- package/vendor/framey/dist/chunk-442JNXRM.js +20 -0
- package/vendor/framey/dist/chunk-4BNN5FGX.js +182 -0
- package/vendor/framey/dist/chunk-4P7NX5BM.js +11532 -0
- package/vendor/framey/dist/chunk-4UMPIPCQ.js +88 -0
- package/vendor/framey/dist/chunk-4YYJOFKS.js +28 -0
- package/vendor/framey/dist/chunk-54HJ63Q2.js +29 -0
- package/vendor/framey/dist/chunk-5ETJX6WM.js +89 -0
- package/vendor/framey/dist/chunk-5SG54JRS.js +68 -0
- package/vendor/framey/dist/chunk-634HSJ3J.js +58 -0
- package/vendor/framey/dist/chunk-66LREXGS.js +48 -0
- package/vendor/framey/dist/chunk-6BEFEWFB.js +25 -0
- package/vendor/framey/dist/chunk-6BNTGNGD.js +286 -0
- package/vendor/framey/dist/chunk-6DDPOHMK.js +140 -0
- package/vendor/framey/dist/chunk-6GV5XSOR.js +214 -0
- package/vendor/framey/dist/chunk-6JR2XUEE.js +127 -0
- package/vendor/framey/dist/chunk-73QVATIM.js +362 -0
- package/vendor/framey/dist/chunk-7FMUTGOH.js +279 -0
- package/vendor/framey/dist/chunk-7JQFJ6JJ.js +291 -0
- package/vendor/framey/dist/chunk-AHPGAESX.js +113 -0
- package/vendor/framey/dist/chunk-AHX34EFP.js +15 -0
- package/vendor/framey/dist/chunk-AJ5CSAEZ.js +487 -0
- package/vendor/framey/dist/chunk-B7RJV74O.js +5047 -0
- package/vendor/framey/dist/chunk-BL4U6U52.js +254 -0
- package/vendor/framey/dist/chunk-BSQ7XANM.js +210 -0
- package/vendor/framey/dist/chunk-BZ56MTDX.js +6 -0
- package/vendor/framey/dist/chunk-C5BO7PMT.js +282 -0
- package/vendor/framey/dist/chunk-CFUCHO4T.js +358 -0
- package/vendor/framey/dist/chunk-CZGHFATT.js +31 -0
- package/vendor/framey/dist/chunk-DLA44CMS.js +5649 -0
- package/vendor/framey/dist/chunk-DPVXBVLL.js +31292 -0
- package/vendor/framey/dist/chunk-DQBY7FRR.js +397 -0
- package/vendor/framey/dist/chunk-EAMLBAHA.js +154 -0
- package/vendor/framey/dist/chunk-EOVYQ3IG.js +143 -0
- package/vendor/framey/dist/chunk-EVMY7IPL.js +612 -0
- package/vendor/framey/dist/chunk-FG4BBFZI.js +122 -0
- package/vendor/framey/dist/chunk-FI4I7CME.js +56 -0
- package/vendor/framey/dist/chunk-FLREUEHW.js +2081 -0
- package/vendor/framey/dist/chunk-FUFP7A7S.js +1126 -0
- package/vendor/framey/dist/chunk-FV3PIKGH.js +56 -0
- package/vendor/framey/dist/chunk-FXMKHHPO.js +123 -0
- package/vendor/framey/dist/chunk-GGZAM5VH.js +1187 -0
- package/vendor/framey/dist/chunk-GKZEVJIB.js +39 -0
- package/vendor/framey/dist/chunk-GMECXFAN.js +57 -0
- package/vendor/framey/dist/chunk-GNEFDLFB.js +621 -0
- package/vendor/framey/dist/chunk-GPMLSOEG.js +19274 -0
- package/vendor/framey/dist/chunk-GPYVT5EE.js +59 -0
- package/vendor/framey/dist/chunk-H6IEMZEV.js +40 -0
- package/vendor/framey/dist/chunk-HEFQCIXR.js +170 -0
- package/vendor/framey/dist/chunk-HFQ4CWHA.js +118 -0
- package/vendor/framey/dist/chunk-HPDTCOKE.js +304 -0
- package/vendor/framey/dist/chunk-HTUHQKDB.js +548 -0
- package/vendor/framey/dist/chunk-HU2SPFNG.js +103 -0
- package/vendor/framey/dist/chunk-HVW7ABXD.js +19 -0
- package/vendor/framey/dist/chunk-IG7NDKXO.js +26 -0
- package/vendor/framey/dist/chunk-II3J3WXK.js +246 -0
- package/vendor/framey/dist/chunk-IR35BCLK.js +13 -0
- package/vendor/framey/dist/chunk-ISN5GRYI.js +20 -0
- package/vendor/framey/dist/chunk-IUONB55J.js +669 -0
- package/vendor/framey/dist/chunk-IVSUQR2B.js +161 -0
- package/vendor/framey/dist/chunk-IXU6PQSW.js +259 -0
- package/vendor/framey/dist/chunk-IZCW2NKT.js +29 -0
- package/vendor/framey/dist/chunk-JGXJU5PF.js +68 -0
- package/vendor/framey/dist/chunk-JHOCK3DG.js +27 -0
- package/vendor/framey/dist/chunk-JVBRJMOP.js +121 -0
- package/vendor/framey/dist/chunk-K4MJX7SR.js +17 -0
- package/vendor/framey/dist/chunk-K4WKGXRC.js +18 -0
- package/vendor/framey/dist/chunk-KGPLIH7B.js +127 -0
- package/vendor/framey/dist/chunk-KLIKCX6Q.js +63 -0
- package/vendor/framey/dist/chunk-KXTKKBOC.js +61 -0
- package/vendor/framey/dist/chunk-KZI44OPA.js +185 -0
- package/vendor/framey/dist/chunk-LEKZNRKI.js +45 -0
- package/vendor/framey/dist/chunk-LFGJWOVA.js +61 -0
- package/vendor/framey/dist/chunk-LLMSX6V6.js +17 -0
- package/vendor/framey/dist/chunk-LTGTN2TH.js +195 -0
- package/vendor/framey/dist/chunk-M2WBOQ2Q.js +54 -0
- package/vendor/framey/dist/chunk-MI5FTLYK.js +399 -0
- package/vendor/framey/dist/chunk-MLQP2QUF.js +1809 -0
- package/vendor/framey/dist/chunk-MMQ52NIZ.js +956 -0
- package/vendor/framey/dist/chunk-ND46XKK3.js +742 -0
- package/vendor/framey/dist/chunk-NYEXNSHM.js +504 -0
- package/vendor/framey/dist/chunk-O4YRB27L.js +57 -0
- package/vendor/framey/dist/chunk-O7BGSQ5F.js +92 -0
- package/vendor/framey/dist/chunk-OFQWTKPW.js +117 -0
- package/vendor/framey/dist/chunk-P5ZBLXJ6.js +91 -0
- package/vendor/framey/dist/chunk-P6LJ4Y32.js +4662 -0
- package/vendor/framey/dist/chunk-PKZ7RK5N.js +281 -0
- package/vendor/framey/dist/chunk-PURHAXFL.js +15 -0
- package/vendor/framey/dist/chunk-Q5X2B5WT.js +29 -0
- package/vendor/framey/dist/chunk-QAMKOG67.js +248 -0
- package/vendor/framey/dist/chunk-R6YBK4XX.js +514 -0
- package/vendor/framey/dist/chunk-RGLQURIX.js +2997 -0
- package/vendor/framey/dist/chunk-RNV3DM4S.js +3573 -0
- package/vendor/framey/dist/chunk-ROEW726S.js +4638 -0
- package/vendor/framey/dist/chunk-RQVDTPGD.js +6 -0
- package/vendor/framey/dist/chunk-RVZHGOGR.js +26 -0
- package/vendor/framey/dist/chunk-RXATE6FZ.js +385 -0
- package/vendor/framey/dist/chunk-S3S4TGEH.js +424 -0
- package/vendor/framey/dist/chunk-SDYDNHEE.js +11202 -0
- package/vendor/framey/dist/chunk-SSWBU67I.js +173 -0
- package/vendor/framey/dist/chunk-ST35C4K6.js +28 -0
- package/vendor/framey/dist/chunk-SZNTYTIV.js +990 -0
- package/vendor/framey/dist/chunk-T2EUWCJE.js +29 -0
- package/vendor/framey/dist/chunk-T6AGUTZJ.js +444 -0
- package/vendor/framey/dist/chunk-THJALHFJ.js +11131 -0
- package/vendor/framey/dist/chunk-TIQB63MX.js +18128 -0
- package/vendor/framey/dist/chunk-U242RTCT.js +134 -0
- package/vendor/framey/dist/chunk-UJZREAV6.js +3901 -0
- package/vendor/framey/dist/chunk-UMD3V7CU.js +264 -0
- package/vendor/framey/dist/chunk-UOFAE5RF.js +42 -0
- package/vendor/framey/dist/chunk-UTEIUGAZ.js +29 -0
- package/vendor/framey/dist/chunk-VAS6HQX3.js +138 -0
- package/vendor/framey/dist/chunk-VC76R6W2.js +27 -0
- package/vendor/framey/dist/chunk-VFZ7W5W7.js +226 -0
- package/vendor/framey/dist/chunk-VHNLZAE2.js +348 -0
- package/vendor/framey/dist/chunk-VMC4WSH5.js +278 -0
- package/vendor/framey/dist/chunk-VORQIH64.js +108 -0
- package/vendor/framey/dist/chunk-VTXFRIHO.js +186 -0
- package/vendor/framey/dist/chunk-W2LGJSEN.js +182 -0
- package/vendor/framey/dist/chunk-WKBM7NDG.js +32 -0
- package/vendor/framey/dist/chunk-WMQNIQDJ.js +2057 -0
- package/vendor/framey/dist/chunk-WN4XZC5F.js +504 -0
- package/vendor/framey/dist/chunk-WQ6ZXHFQ.js +133 -0
- package/vendor/framey/dist/chunk-X7HBSTCZ.js +53 -0
- package/vendor/framey/dist/chunk-X7MTZ4T2.js +6 -0
- package/vendor/framey/dist/chunk-XPTOJZJF.js +308 -0
- package/vendor/framey/dist/chunk-XT6MXC7A.js +6 -0
- package/vendor/framey/dist/chunk-XT7X6QCP.js +6 -0
- package/vendor/framey/dist/chunk-XTMDWNEC.js +2820 -0
- package/vendor/framey/dist/chunk-XUHOIDKG.js +6 -0
- package/vendor/framey/dist/chunk-XX7LTW5Y.js +138 -0
- package/vendor/framey/dist/chunk-Y3YGTZEG.js +382 -0
- package/vendor/framey/dist/chunk-Y5SBSGN7.js +346 -0
- package/vendor/framey/dist/chunk-YCMVI4MZ.js +625 -0
- package/vendor/framey/dist/chunk-YOR4CTCE.js +261 -0
- package/vendor/framey/dist/chunk-YPRBGITP.js +2748 -0
- package/vendor/framey/dist/chunk-ZKDMAOFU.js +32 -0
- package/vendor/framey/dist/chunk-ZW6DURPF.js +1318 -0
- package/vendor/framey/dist/clean-ZJOGDKDM.js +336 -0
- package/vendor/framey/dist/cli.js +439 -0
- package/vendor/framey/dist/cloudrun-HGJ3B6ZV.js +767 -0
- package/vendor/framey/dist/commands/contrast-audit.browser.js +643 -0
- package/vendor/framey/dist/commands/layout-audit.browser.js +2914 -0
- package/vendor/framey/dist/commands/motion-sample.browser.js +74 -0
- package/vendor/framey/dist/commands/motion-signature.browser.js +475 -0
- package/vendor/framey/dist/compare-W7UY3BAZ.js +463 -0
- package/vendor/framey/dist/compiler-TM3W6GYF.js +136 -0
- package/vendor/framey/dist/component-QF5VGYMU.js +176 -0
- package/vendor/framey/dist/compositions-XT3PMNDF.js +238 -0
- package/vendor/framey/dist/contactSheet-7W6GYHBQ.js +24 -0
- package/vendor/framey/dist/deploy-6FJPWKF2.js +299 -0
- package/vendor/framey/dist/destroy-HE2RIPKZ.js +57 -0
- package/vendor/framey/dist/deterministicFonts-RADD3BSX.js +50 -0
- package/vendor/framey/dist/dist-4S2FGGVY.js +422 -0
- package/vendor/framey/dist/dist-BE4B2EZE.js +32 -0
- package/vendor/framey/dist/dist-EWXJGN63.js +129 -0
- package/vendor/framey/dist/docs-VKIRFEEX.js +131 -0
- package/vendor/framey/dist/doctor-2UNJ2G7U.js +74 -0
- package/vendor/framey/dist/dom-37K6YOH4.js +14 -0
- package/vendor/framey/dist/dotEnv-7PZQTVIM.js +54 -0
- package/vendor/framey/dist/events-ACY3P2PA.js +76 -0
- package/vendor/framey/dist/events-IHB3B2MK.js +69 -0
- package/vendor/framey/dist/ffmpeg-DR5LGGSK.js +63 -0
- package/vendor/framey/dist/figma-BODVUPHB.js +63 -0
- package/vendor/framey/dist/fontData.generated-NXAOI4GH.js +13 -0
- package/vendor/framey/dist/fontLocalizeCli.js +142 -0
- package/vendor/framey/dist/gpuPolicy-XLEXORZY.js +49 -0
- package/vendor/framey/dist/grade-compare-GAXFQ74C.js +661 -0
- package/vendor/framey/dist/gsapParser-IEUSLYDJ.js +2885 -0
- package/vendor/framey/dist/gsapParserAcorn-LZJKCFQI.js +30 -0
- package/vendor/framey/dist/help-VCB5X2HS.js +184 -0
- package/vendor/framey/dist/history-ENGZEVFT.js +471 -0
- package/vendor/framey/dist/hyperframe.manifest.json +22 -0
- package/vendor/framey/dist/hyperframe.runtime.iife.js +1132 -0
- package/vendor/framey/dist/hyperframes-player.global.js +478 -0
- package/vendor/framey/dist/hyperframes-slideshow.global.js +148 -0
- package/vendor/framey/dist/info-WPCJHBMU.js +182 -0
- package/vendor/framey/dist/init-G4WGLYGD.js +1176 -0
- package/vendor/framey/dist/inspect-5IQ6CNEQ.js +106 -0
- package/vendor/framey/dist/installer-ML4HWUV7.js +40 -0
- package/vendor/framey/dist/keyframes-Q6XJO7VW.js +2755 -0
- package/vendor/framey/dist/lambda-A7JC74JF.js +468 -0
- package/vendor/framey/dist/layout-YET6XNXA.js +95 -0
- package/vendor/framey/dist/lint-I7FZN2VJ.js +162 -0
- package/vendor/framey/dist/lintProject-PDB3UN72.js +30 -0
- package/vendor/framey/dist/manager-CD6LJ7ZN.js +45 -0
- package/vendor/framey/dist/manager-WA4F2GST.js +67 -0
- package/vendor/framey/dist/media-treatment-BXYS4WVA.js +1030 -0
- package/vendor/framey/dist/media-use-LWH3MANK.js +113 -0
- package/vendor/framey/dist/models-K363FYM4.js +195 -0
- package/vendor/framey/dist/motionShot-GPAVWXLH.js +640 -0
- package/vendor/framey/dist/normalize-FASIPBUM.js +24 -0
- package/vendor/framey/dist/normalize-audio-VDWYRHN6.js +268 -0
- package/vendor/framey/dist/open-E3TB6P6J.js +128 -0
- package/vendor/framey/dist/parakeetRunner-UP34UOI2.js +58 -0
- package/vendor/framey/dist/pipeline-VXIUYEBU.js +64 -0
- package/vendor/framey/dist/play-BUTDQ4DV.js +353 -0
- package/vendor/framey/dist/policies-OLDYGFK5.js +309 -0
- package/vendor/framey/dist/preflight-2KZ2GWAZ.js +55 -0
- package/vendor/framey/dist/present-GXYSM7NX.js +353 -0
- package/vendor/framey/dist/preview-EKZ33JOJ.js +1910 -0
- package/vendor/framey/dist/producer-SKU5EV3J.js +13 -0
- package/vendor/framey/dist/progress-ZWB4Z4ET.js +76 -0
- package/vendor/framey/dist/projectConfig-3MZAYVTS.js +60 -0
- package/vendor/framey/dist/publication-DTKWNF74.js +33 -0
- package/vendor/framey/dist/remove-background-OY5L7XN2.js +265 -0
- package/vendor/framey/dist/render-CDV4GDFX.js +187 -0
- package/vendor/framey/dist/render-YE5YAMOD.js +2620 -0
- package/vendor/framey/dist/render-batch-ZAMFPCLL.js +274 -0
- package/vendor/framey/dist/renderSetupWorker.js +120 -0
- package/vendor/framey/dist/resolver-D5BIPSEW.js +42 -0
- package/vendor/framey/dist/runtimeVersion.js +18 -0
- package/vendor/framey/dist/sam-WFZXI4F7.js +23 -0
- package/vendor/framey/dist/shaderTransitionWorker.js +44 -0
- package/vendor/framey/dist/sherpa-RQWLV7I3.js +79 -0
- package/vendor/framey/dist/sherpaWorker.js +84 -0
- package/vendor/framey/dist/sites-AMOVABTK.js +54 -0
- package/vendor/framey/dist/skill-ZHVLH7KX.js +15 -0
- package/vendor/framey/dist/slideshow-SJGK4RQS.js +24 -0
- package/vendor/framey/dist/snapshot-TUXL6LBQ.js +1515 -0
- package/vendor/framey/dist/src-JHQGB2S6.js +215 -0
- package/vendor/framey/dist/src-Z5PGEMXB.js +532 -0
- package/vendor/framey/dist/state-B6H3WHCF.js +28 -0
- package/vendor/framey/dist/staticProjectServer-DFVZOUGI.js +63 -0
- package/vendor/framey/dist/studio/assets/hyperframes-player-DLaKjw0L.js +478 -0
- package/vendor/framey/dist/studio/assets/index-BX3KHhGX.js +71 -0
- package/vendor/framey/dist/studio/assets/index-CYbvOVp-.css +1 -0
- package/vendor/framey/dist/studio/assets/index-Dp3QEUGL.js +402 -0
- package/vendor/framey/dist/studio/assets/index-qrTAXbqc.js +531 -0
- package/vendor/framey/dist/studio/favicon.svg +14 -0
- package/vendor/framey/dist/studio/icons/timeline/audio.svg +7 -0
- package/vendor/framey/dist/studio/icons/timeline/captions.svg +5 -0
- package/vendor/framey/dist/studio/icons/timeline/composition.svg +12 -0
- package/vendor/framey/dist/studio/icons/timeline/image.svg +18 -0
- package/vendor/framey/dist/studio/icons/timeline/music.svg +10 -0
- package/vendor/framey/dist/studio/icons/timeline/text.svg +3 -0
- package/vendor/framey/dist/studio/index.html +23 -0
- package/vendor/framey/dist/studio-api-2NECGRMB.js +179 -0
- package/vendor/framey/dist/studioSelectionClient-IUUPKUHZ.js +25 -0
- package/vendor/framey/dist/studioServer-N2OPUMOQ.js +1569 -0
- package/vendor/framey/dist/synthesize-PKTB5ATG.js +183 -0
- package/vendor/framey/dist/system-7F3UEHQT.js +27 -0
- package/vendor/framey/dist/telemetry-DQGPEKM6.js +145 -0
- package/vendor/framey/dist/telemetry-UN55N6U6.js +77 -0
- package/vendor/framey/dist/timeline-LHUUMK63.js +1295 -0
- package/vendor/framey/dist/tokens-OLECWDFK.js +92 -0
- package/vendor/framey/dist/transcribe-O7HQKMIV.js +331 -0
- package/vendor/framey/dist/transcribe-UJESPBT5.js +76 -0
- package/vendor/framey/dist/tts-YCCQHZD4.js +234 -0
- package/vendor/framey/dist/urlDownloader-H2LXLG4U.js +48 -0
- package/vendor/framey/dist/usage-HG4WMM7A.js +2167 -0
- package/vendor/framey/dist/validate-F2XMN6EV.js +87 -0
- package/vendor/framey/dist/vendor/npm/gsap@3.15.0/GSAP-LICENSE.txt +8 -0
- package/vendor/framey/dist/vendor/npm/gsap@3.15.0/dist/CustomEase.min.js +11 -0
- package/vendor/framey/dist/vendor/npm/gsap@3.15.0/dist/MotionPathPlugin.min.js +11 -0
- package/vendor/framey/dist/vendor/npm/gsap@3.15.0/dist/gsap.min.js +11 -0
- package/vendor/framey/dist/warmCheck.js +220 -0
- package/vendor/framey/dist/warmCheckClient.js +139 -0
- package/vendor/framey/dist/workspaceBuildCheck-XYD2YTFW.js +133 -0
- package/vendor/framey-player/LICENSE +190 -0
- package/vendor/framey-player/NOTICE +16 -0
- package/vendor/framey-player/SOURCE.json +6 -0
- package/vendor/framey-player/hyperframes-player.global.js +478 -0
- package/vendor/framey.pin +1 -0
- package/bin/index.js +0 -3
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { packFolder } from "./bundle.js";
|
|
4
|
+
import { containerName, ExecutorError, synthesized, type Executor, type ExecutorJob, type ExecutorResult } from "./executor.js";
|
|
5
|
+
import { signUrl, type FetchLike, type Signer, type TokenProvider } from "./gcs-sign.js";
|
|
6
|
+
import type { RenderJobResult } from "./job.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `cloud-run-jobs` executor (spec 019, section C2): one Cloud Run Job execution per render, over plain REST.
|
|
10
|
+
*
|
|
11
|
+
* Cloud Run Jobs https://docs.cloud.google.com/run/docs/reference/rest/v2/projects.locations.jobs/run
|
|
12
|
+
* Executions https://docs.cloud.google.com/run/docs/reference/rest/v2/projects.locations.jobs.executions
|
|
13
|
+
* Storage JSON https://docs.cloud.google.com/storage/docs/json_api
|
|
14
|
+
*
|
|
15
|
+
* The flow for a job id `J`:
|
|
16
|
+
* 1. the input folder is packed and uploaded to `gs://BUCKET/PREFIX/J/in.bundle.gz`;
|
|
17
|
+
* 2. one GET signed URL (the bundle) and one PUT signed URL per output file are made;
|
|
18
|
+
* 3. `jobs.run` starts an execution with overrides: args `render-job --remote`, the signed URLs in env, `timeout`;
|
|
19
|
+
* 4. the execution is polled until it completes (or the wall-clock limit passes and it is cancelled);
|
|
20
|
+
* 5. `result.json` and the other outputs are read from `gs://BUCKET/PREFIX/J/out/` into the output folder.
|
|
21
|
+
*
|
|
22
|
+
* The job's own service account holds NO bucket permission; it works only through the signed URLs. The job is deployed
|
|
23
|
+
* with Direct VPC egress (all traffic) into a VPC that has no Cloud NAT, so the scene has no route to the internet.
|
|
24
|
+
* `start`, `poll` and `collect` are the three steps for a caller (the console's cron) that cannot wait; `run` does all.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const OUTPUT_FILES = ["result.json", "video.mp4", "transcript.txt", "checks.json"] as const;
|
|
28
|
+
export const REMOTE_ARGS = ["render-job", "--remote"] as const;
|
|
29
|
+
|
|
30
|
+
export interface CloudRunOptions {
|
|
31
|
+
projectId: string;
|
|
32
|
+
/** For example `us-central1`. */
|
|
33
|
+
region: string;
|
|
34
|
+
/** The Cloud Run Job's name. It must run the render-worker image with `--command=vfrey`. */
|
|
35
|
+
jobName: string;
|
|
36
|
+
bucket: string;
|
|
37
|
+
/** An OAuth access token with the cloud-platform scope. Injected: a service account, or workload identity federation. */
|
|
38
|
+
token: TokenProvider;
|
|
39
|
+
/** Signs the URLs the container uses. */
|
|
40
|
+
signer: Signer;
|
|
41
|
+
fetch?: FetchLike;
|
|
42
|
+
/** Object prefix. Default `render-jobs`. */
|
|
43
|
+
prefix?: string;
|
|
44
|
+
/** Wall-clock limit for one execution, in seconds. Default 600. Sent as the task timeout. */
|
|
45
|
+
timeoutSec?: number;
|
|
46
|
+
/** Extra environment for the render. Only these (and the signed URLs) reach the container. */
|
|
47
|
+
env?: Record<string, string>;
|
|
48
|
+
/** The container's name in the job, when it has more than one. */
|
|
49
|
+
containerName?: string;
|
|
50
|
+
pollIntervalMs?: number;
|
|
51
|
+
/** Extra seconds past `timeoutSec` before the executor gives up on Cloud Run and cancels. Default 120. */
|
|
52
|
+
graceSec?: number;
|
|
53
|
+
sleep?: (ms: number) => Promise<void>;
|
|
54
|
+
now?: () => Date;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CloudRunHandle {
|
|
58
|
+
jobId: string;
|
|
59
|
+
/** `projects/P/locations/R/jobs/J/executions/E`. */
|
|
60
|
+
executionName: string;
|
|
61
|
+
startedAt: number;
|
|
62
|
+
timeoutSec: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type CloudRunPoll =
|
|
66
|
+
| { state: "running" }
|
|
67
|
+
| { state: "done"; succeeded: boolean; timedOut: boolean; renderSeconds: number; message: string };
|
|
68
|
+
|
|
69
|
+
interface Execution {
|
|
70
|
+
name?: string;
|
|
71
|
+
startTime?: string;
|
|
72
|
+
completionTime?: string;
|
|
73
|
+
succeededCount?: number;
|
|
74
|
+
failedCount?: number;
|
|
75
|
+
cancelledCount?: number;
|
|
76
|
+
conditions?: { type?: string; state?: string; reason?: string; message?: string }[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const GCS_ID = /^[a-z0-9][a-z0-9._-]{1,220}$/;
|
|
80
|
+
const round1 = (n: number) => Math.round(n * 10) / 10;
|
|
81
|
+
|
|
82
|
+
export class CloudRunExecutor implements Executor {
|
|
83
|
+
readonly kind = "cloud-run-jobs";
|
|
84
|
+
private readonly fetchImpl: FetchLike;
|
|
85
|
+
private readonly timeoutSec: number;
|
|
86
|
+
|
|
87
|
+
constructor(private readonly options: CloudRunOptions) {
|
|
88
|
+
for (const [field, value] of [["projectId", options.projectId], ["region", options.region], ["jobName", options.jobName], ["bucket", options.bucket]] as const) {
|
|
89
|
+
if (!value || !/^[A-Za-z0-9._-]+$/.test(value)) throw new ExecutorError(`${field} must be set to a plain name, not "${value}".`);
|
|
90
|
+
}
|
|
91
|
+
if (!GCS_ID.test(options.bucket)) throw new ExecutorError(`bucket "${options.bucket}" is not a valid bucket name.`);
|
|
92
|
+
this.timeoutSec = options.timeoutSec ?? 600;
|
|
93
|
+
if (!(this.timeoutSec > 0 && this.timeoutSec <= 3600)) throw new ExecutorError(`timeoutSec must be between 1 and 3600, not ${this.timeoutSec}.`);
|
|
94
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private objectPrefix(jobId: string): string {
|
|
98
|
+
containerName(jobId); // validates the id: letters, digits, dot, dash, underscore
|
|
99
|
+
return `${(this.options.prefix ?? "render-jobs").replace(/^\/+|\/+$/g, "")}/${jobId}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async api(url: string, init: RequestInit = {}): Promise<Response> {
|
|
103
|
+
const headers = new Headers(init.headers);
|
|
104
|
+
headers.set("authorization", `Bearer ${await this.options.token()}`);
|
|
105
|
+
return this.fetchImpl(url, { ...init, headers });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async json<T>(url: string, init: RequestInit, what: string): Promise<T> {
|
|
109
|
+
const response = await this.api(url, init);
|
|
110
|
+
if (!response.ok) throw new ExecutorError(`${what} failed with HTTP ${response.status}.`);
|
|
111
|
+
return (await response.json()) as T;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private objectUrl(object: string): string {
|
|
115
|
+
return `https://storage.googleapis.com/storage/v1/b/${this.options.bucket}/o/${encodeURIComponent(object)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async putObject(object: string, data: Buffer, type: string): Promise<void> {
|
|
119
|
+
const url = `https://storage.googleapis.com/upload/storage/v1/b/${this.options.bucket}/o?uploadType=media&name=${encodeURIComponent(object)}`;
|
|
120
|
+
const response = await this.api(url, { method: "POST", headers: { "content-type": type }, body: new Uint8Array(data) });
|
|
121
|
+
if (!response.ok) throw new ExecutorError(`Uploading ${object} failed with HTTP ${response.status}.`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async getObject(object: string): Promise<Buffer | null> {
|
|
125
|
+
const response = await this.api(`${this.objectUrl(object)}?alt=media`);
|
|
126
|
+
if (response.status === 404) return null;
|
|
127
|
+
if (!response.ok) throw new ExecutorError(`Reading ${object} failed with HTTP ${response.status}.`);
|
|
128
|
+
return Buffer.from(await response.arrayBuffer());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Stages the inputs and starts the execution. Returns at once with a handle to poll. */
|
|
132
|
+
async start(job: ExecutorJob): Promise<CloudRunHandle> {
|
|
133
|
+
const { projectId, region, jobName, bucket } = this.options;
|
|
134
|
+
const prefix = this.objectPrefix(job.jobId);
|
|
135
|
+
const input = await stat(job.inputDir).catch(() => null);
|
|
136
|
+
if (!input?.isDirectory()) throw new ExecutorError(`inputDir "${job.inputDir}" is not a folder.`);
|
|
137
|
+
await this.putObject(`${prefix}/in.bundle.gz`, await packFolder(job.inputDir), "application/gzip");
|
|
138
|
+
|
|
139
|
+
const now = this.options.now?.() ?? new Date();
|
|
140
|
+
const expiresSec = this.timeoutSec + (this.options.graceSec ?? 120) + 900;
|
|
141
|
+
const inUrl = await signUrl(this.options.signer, { method: "GET", bucket, object: `${prefix}/in.bundle.gz`, expiresSec, now });
|
|
142
|
+
const outUrls: Record<string, string> = {};
|
|
143
|
+
for (const name of OUTPUT_FILES) outUrls[name] = await signUrl(this.options.signer, { method: "PUT", bucket, object: `${prefix}/out/${name}`, expiresSec, now });
|
|
144
|
+
|
|
145
|
+
const env = { ...(this.options.env ?? {}), VFREY_JOB_IN_URL: inUrl, VFREY_JOB_OUT_URLS: JSON.stringify(outUrls) };
|
|
146
|
+
const override: Record<string, unknown> = {
|
|
147
|
+
args: [...REMOTE_ARGS],
|
|
148
|
+
env: Object.entries(env).map(([name, value]) => ({ name, value })),
|
|
149
|
+
};
|
|
150
|
+
if (this.options.containerName) override.name = this.options.containerName;
|
|
151
|
+
const jobPath = `projects/${projectId}/locations/${region}/jobs/${jobName}`;
|
|
152
|
+
const operation = await this.json<{ name?: string; metadata?: { name?: string } }>(
|
|
153
|
+
`https://run.googleapis.com/v2/${jobPath}:run`,
|
|
154
|
+
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ overrides: { containerOverrides: [override], taskCount: 1, timeout: `${this.timeoutSec}s` } }) },
|
|
155
|
+
"Starting the Cloud Run job",
|
|
156
|
+
);
|
|
157
|
+
// The run operation's metadata is the Execution. If it is not there yet, ask the operation for it.
|
|
158
|
+
let executionName = operation.metadata?.name;
|
|
159
|
+
for (let attempt = 0; !executionName && operation.name && attempt < 10; attempt += 1) {
|
|
160
|
+
await this.sleep(500);
|
|
161
|
+
const again = await this.json<{ metadata?: { name?: string } }>(`https://run.googleapis.com/v2/${operation.name}`, {}, "Reading the Cloud Run operation");
|
|
162
|
+
executionName = again.metadata?.name;
|
|
163
|
+
}
|
|
164
|
+
if (!executionName || !executionName.startsWith(`${jobPath}/executions/`)) throw new ExecutorError("Cloud Run did not return an execution name.");
|
|
165
|
+
return { jobId: job.jobId, executionName, startedAt: (this.options.now?.() ?? new Date()).getTime(), timeoutSec: this.timeoutSec };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private sleep(ms: number): Promise<void> {
|
|
169
|
+
return this.options.sleep ? this.options.sleep(ms) : new Promise((resolve) => setTimeout(resolve, ms));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One look at the execution. */
|
|
173
|
+
async poll(handle: CloudRunHandle): Promise<CloudRunPoll> {
|
|
174
|
+
const execution = await this.json<Execution>(`https://run.googleapis.com/v2/${handle.executionName}`, {}, "Reading the execution");
|
|
175
|
+
const elapsed = ((this.options.now?.() ?? new Date()).getTime() - handle.startedAt) / 1000;
|
|
176
|
+
const completed = execution.conditions?.find((c) => c.type === "Completed");
|
|
177
|
+
const finished = Boolean(execution.completionTime) || completed?.state === "CONDITION_SUCCEEDED" || completed?.state === "CONDITION_FAILED";
|
|
178
|
+
if (!finished) {
|
|
179
|
+
if (elapsed > handle.timeoutSec + (this.options.graceSec ?? 120)) {
|
|
180
|
+
// Cloud Run should have stopped it by now. Cancel, and report a timeout.
|
|
181
|
+
await this.api(`https://run.googleapis.com/v2/${handle.executionName}:cancel`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).catch(() => undefined);
|
|
182
|
+
return { state: "done", succeeded: false, timedOut: true, renderSeconds: round1(elapsed), message: "Cloud Run did not stop the execution in time and it was cancelled." };
|
|
183
|
+
}
|
|
184
|
+
return { state: "running" };
|
|
185
|
+
}
|
|
186
|
+
const start = execution.startTime ? Date.parse(execution.startTime) : NaN;
|
|
187
|
+
const end = execution.completionTime ? Date.parse(execution.completionTime) : NaN;
|
|
188
|
+
const renderSeconds = round1(Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, (end - start) / 1000) : elapsed);
|
|
189
|
+
const message = (completed?.message ?? completed?.reason ?? "").slice(0, 300);
|
|
190
|
+
const succeeded = (execution.succeededCount ?? 0) > 0 && (execution.failedCount ?? 0) === 0 && (execution.cancelledCount ?? 0) === 0;
|
|
191
|
+
// A task that passed its timeout is failed by Cloud Run; its condition says so.
|
|
192
|
+
const timedOut = !succeeded && /timed? ?out|deadline|exceeded the (maximum|task)/i.test(`${completed?.reason ?? ""} ${completed?.message ?? ""}`);
|
|
193
|
+
return { state: "done", succeeded, timedOut, renderSeconds, message };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Reads the outputs into `outputDir` and maps everything to the shared result shape. Then removes the job's objects. */
|
|
197
|
+
async collect(handle: CloudRunHandle, finished: Extract<CloudRunPoll, { state: "done" }>, outputDir: string): Promise<ExecutorResult> {
|
|
198
|
+
const prefix = this.objectPrefix(handle.jobId);
|
|
199
|
+
await mkdir(outputDir, { recursive: true });
|
|
200
|
+
let parsed: RenderJobResult | null = null;
|
|
201
|
+
if (!finished.timedOut) {
|
|
202
|
+
for (const name of OUTPUT_FILES) {
|
|
203
|
+
const data = await this.getObject(`${prefix}/out/${name}`);
|
|
204
|
+
if (data) await writeFile(path.join(outputDir, name), data);
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
parsed = JSON.parse(await readFile(path.join(outputDir, "result.json"), "utf8")) as RenderJobResult;
|
|
208
|
+
} catch {
|
|
209
|
+
parsed = null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
await this.cleanup(handle);
|
|
213
|
+
const tail = finished.message;
|
|
214
|
+
const result: RenderJobResult =
|
|
215
|
+
finished.timedOut
|
|
216
|
+
? synthesized("timeout", "render_timeout", `The render ran past its ${handle.timeoutSec} s limit and was stopped.`, finished.renderSeconds)
|
|
217
|
+
: parsed ?? synthesized("failed", "worker_crashed", `The Cloud Run job ended without a result.${tail ? ` ${tail}` : ""}`, finished.renderSeconds);
|
|
218
|
+
return { result, renderSeconds: finished.renderSeconds, exitCode: finished.succeeded ? 0 : 1, timedOut: finished.timedOut, stderrTail: tail };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Best effort: the bucket's lifecycle rule is the backstop. */
|
|
222
|
+
async cleanup(handle: CloudRunHandle): Promise<void> {
|
|
223
|
+
const prefix = this.objectPrefix(handle.jobId);
|
|
224
|
+
for (const object of [`${prefix}/in.bundle.gz`, ...OUTPUT_FILES.map((name) => `${prefix}/out/${name}`)]) {
|
|
225
|
+
await this.api(this.objectUrl(object), { method: "DELETE" }).catch(() => undefined);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async run(job: ExecutorJob): Promise<ExecutorResult> {
|
|
230
|
+
const handle = await this.start(job);
|
|
231
|
+
for (;;) {
|
|
232
|
+
const state = await this.poll(handle);
|
|
233
|
+
if (state.state === "done") return this.collect(handle, state, job.outputDir);
|
|
234
|
+
await this.sleep(this.options.pollIntervalMs ?? 5000);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { RenderJobResult } from "./job.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Executors run one render job in isolation (spec 019, section C). Agent-written scenes are untrusted code, and an
|
|
9
|
+
* executor is where they run: no network, a read-only root, resource limits, a wall-clock timeout.
|
|
10
|
+
*
|
|
11
|
+
* THE SEAM. The console's `RenderJob` queue (a Postgres table with a status and a lease) is what calls this:
|
|
12
|
+
* 1. a dispatcher leases a queued job and lays out two folders: an input folder (`job.json`, the scene, the voice
|
|
13
|
+
* that was synthesised before this step) and an empty output folder;
|
|
14
|
+
* 2. it calls `executor.run({ jobId, inputDir, outputDir })`;
|
|
15
|
+
* 3. it reads the outputs (`video.mp4`, `transcript.txt`, `checks.json`, `result.json`), uploads them, and records
|
|
16
|
+
* `renderSeconds` for metering and credits.
|
|
17
|
+
* Either the console calls this module in-process, or a thin worker process polls the queue and calls it. Nothing in
|
|
18
|
+
* here knows about the queue or the database. `local-docker` runs `docker run` on this machine; `cloud-run-jobs` (./cloudrun-executor.ts, slice C2) implements the same interface with Google Cloud Run Jobs.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface ExecutorJob {
|
|
22
|
+
/** A short id for the container name and the logs: letters, digits, dot, dash, underscore. */
|
|
23
|
+
jobId: string;
|
|
24
|
+
/** Absolute path of the folder with `job.json`. Mounted read-only. */
|
|
25
|
+
inputDir: string;
|
|
26
|
+
/** Absolute path of the folder the outputs are written to. Mounted read-write. */
|
|
27
|
+
outputDir: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ExecutorResult {
|
|
31
|
+
/** The worker's `result.json`, or a synthesised one when the container died, timed out, or wrote nothing. */
|
|
32
|
+
result: RenderJobResult;
|
|
33
|
+
/** Wall-clock seconds the container ran. This is what the job is metered by. */
|
|
34
|
+
renderSeconds: number;
|
|
35
|
+
exitCode: number | null;
|
|
36
|
+
timedOut: boolean;
|
|
37
|
+
/** The last lines the container printed to stderr, for a failure message. */
|
|
38
|
+
stderrTail: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Executor {
|
|
42
|
+
readonly kind: string;
|
|
43
|
+
run(job: ExecutorJob): Promise<ExecutorResult>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LocalDockerOptions {
|
|
47
|
+
/** Image name and tag. Default: `VFREY_RENDER_IMAGE`, else `vfrey-render-worker:latest`. */
|
|
48
|
+
image?: string;
|
|
49
|
+
/** Wall-clock limit for one job, in seconds. The container is killed when it passes. Default 600. */
|
|
50
|
+
timeoutSec?: number;
|
|
51
|
+
cpus?: number;
|
|
52
|
+
/** Docker size string for the memory limit, for example `4g`. */
|
|
53
|
+
memory?: string;
|
|
54
|
+
/** Size of the /tmp tmpfs (the render's work folder). It counts against the memory limit. */
|
|
55
|
+
tmpfsSize?: string;
|
|
56
|
+
shmSize?: string;
|
|
57
|
+
pidsLimit?: number;
|
|
58
|
+
/** The user the container runs as, `uid` or `uid:gid`. Default: the caller's own, so the output folder is writable. */
|
|
59
|
+
user?: string;
|
|
60
|
+
/** Environment for the render, for example `TTS_PROVIDER=none`. Only these (and the fixed ones) reach the container. */
|
|
61
|
+
env?: Record<string, string>;
|
|
62
|
+
/** The docker binary. Default `docker`. */
|
|
63
|
+
docker?: string;
|
|
64
|
+
/** Replaces the worker command. For tests that probe the sandbox; the default is `vfrey render-job`. */
|
|
65
|
+
command?: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const DEFAULT_IMAGE = "vfrey-render-worker:latest";
|
|
69
|
+
export const LIMIT_DEFAULTS = { timeoutSec: 600, cpus: 2, memory: "4g", tmpfsSize: "2g", shmSize: "256m", pidsLimit: 512 } as const;
|
|
70
|
+
|
|
71
|
+
export class ExecutorError extends Error {
|
|
72
|
+
constructor(message: string) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "ExecutorError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const SIZE = /^[1-9]\d{0,5}[kmg]?$/i;
|
|
79
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
80
|
+
// Docker splits -v on ":", and a newline or NUL has no place in a path we mount.
|
|
81
|
+
const BAD_PATH_CHARS = /[:\0\r\n,]/;
|
|
82
|
+
const IMAGE = /^[a-z0-9][a-z0-9._\-/:@]{0,255}$/i;
|
|
83
|
+
|
|
84
|
+
/** A mount path: absolute, normalised, and free of the characters docker parses. */
|
|
85
|
+
export function mountPath(value: string, field: string): string {
|
|
86
|
+
if (!path.isAbsolute(value)) throw new ExecutorError(`${field} must be an absolute path, not "${value}".`);
|
|
87
|
+
if (BAD_PATH_CHARS.test(value)) throw new ExecutorError(`${field} must not contain ":", ",", or a control character.`);
|
|
88
|
+
const clean = path.resolve(value);
|
|
89
|
+
if (clean === path.parse(clean).root) throw new ExecutorError(`${field} must not be the filesystem root.`);
|
|
90
|
+
return clean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function containerName(jobId: string): string {
|
|
94
|
+
if (!NAME.test(jobId)) throw new ExecutorError(`jobId must be letters, digits, ".", "-" or "_" (up to 64), not "${jobId}".`);
|
|
95
|
+
return `vfrey-render-${jobId}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function defaultUser(): string {
|
|
99
|
+
return typeof process.getuid === "function" ? `${process.getuid()}:${process.getgid?.() ?? process.getuid()}` : "10001:10001";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The `docker run` argument list, without the docker binary. Pure: it reads nothing and runs nothing.
|
|
104
|
+
*
|
|
105
|
+
* --network none no network device but loopback; nothing leaves the container
|
|
106
|
+
* --read-only the root filesystem cannot be written; /tmp is a tmpfs, /job/out the only other write
|
|
107
|
+
* --cap-drop ALL, --security-opt no-new-privileges, --user <uid> no root, no capabilities, no privilege gain
|
|
108
|
+
* --cpus, --memory (swap = memory), --pids-limit, --shm-size, --tmpfs size resource limits
|
|
109
|
+
* -v <in>:/job/in:ro -v <out>:/job/out:rw the only host paths the container sees
|
|
110
|
+
*/
|
|
111
|
+
export function buildDockerArgs(job: ExecutorJob, options: LocalDockerOptions = {}): string[] {
|
|
112
|
+
const image = options.image ?? process.env.VFREY_RENDER_IMAGE ?? DEFAULT_IMAGE;
|
|
113
|
+
if (!IMAGE.test(image) || image.startsWith("-")) throw new ExecutorError(`The image name "${image}" is not valid.`);
|
|
114
|
+
const inputDir = mountPath(job.inputDir, "inputDir");
|
|
115
|
+
const outputDir = mountPath(job.outputDir, "outputDir");
|
|
116
|
+
if (inputDir === outputDir || inputDir.startsWith(outputDir + path.sep) || outputDir.startsWith(inputDir + path.sep)) {
|
|
117
|
+
throw new ExecutorError("inputDir and outputDir must be separate folders, neither inside the other.");
|
|
118
|
+
}
|
|
119
|
+
const name = containerName(job.jobId);
|
|
120
|
+
const cpus = options.cpus ?? LIMIT_DEFAULTS.cpus;
|
|
121
|
+
if (!(cpus > 0 && cpus <= 64)) throw new ExecutorError(`cpus must be between 0 and 64, not ${cpus}.`);
|
|
122
|
+
const memory = options.memory ?? LIMIT_DEFAULTS.memory;
|
|
123
|
+
const tmpfs = options.tmpfsSize ?? LIMIT_DEFAULTS.tmpfsSize;
|
|
124
|
+
const shm = options.shmSize ?? LIMIT_DEFAULTS.shmSize;
|
|
125
|
+
for (const [field, value] of [["memory", memory], ["tmpfsSize", tmpfs], ["shmSize", shm]] as const) {
|
|
126
|
+
if (!SIZE.test(value)) throw new ExecutorError(`${field} must be a size such as 512m or 4g, not "${value}".`);
|
|
127
|
+
}
|
|
128
|
+
const pids = options.pidsLimit ?? LIMIT_DEFAULTS.pidsLimit;
|
|
129
|
+
if (!Number.isInteger(pids) || pids < 16) throw new ExecutorError(`pidsLimit must be an integer of at least 16, not ${pids}.`);
|
|
130
|
+
const user = options.user ?? defaultUser();
|
|
131
|
+
if (!/^\d+(:\d+)?$/.test(user) || /^0(:|$)/.test(user)) throw new ExecutorError(`user must be a non-root numeric uid or uid:gid, not "${user}".`);
|
|
132
|
+
const env = { HOME: "/tmp/home", TMPDIR: "/tmp", ...(options.env ?? {}) };
|
|
133
|
+
const envArgs: string[] = [];
|
|
134
|
+
for (const [key, value] of Object.entries(env)) {
|
|
135
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(key) || /[\0\r\n]/.test(value)) throw new ExecutorError(`The environment entry "${key}" is not valid.`);
|
|
136
|
+
envArgs.push("--env", `${key}=${value}`);
|
|
137
|
+
}
|
|
138
|
+
return [
|
|
139
|
+
"run",
|
|
140
|
+
"--rm",
|
|
141
|
+
"--pull", "never",
|
|
142
|
+
"--name", name,
|
|
143
|
+
"--init",
|
|
144
|
+
"--network", "none",
|
|
145
|
+
"--read-only",
|
|
146
|
+
"--tmpfs", `/tmp:rw,exec,nosuid,nodev,size=${tmpfs},mode=1777`,
|
|
147
|
+
"--shm-size", shm,
|
|
148
|
+
"--cpus", String(cpus),
|
|
149
|
+
"--memory", memory,
|
|
150
|
+
"--memory-swap", memory,
|
|
151
|
+
"--pids-limit", String(pids),
|
|
152
|
+
"--cap-drop", "ALL",
|
|
153
|
+
"--security-opt", "no-new-privileges",
|
|
154
|
+
"--user", user,
|
|
155
|
+
"-v", `${inputDir}:/job/in:ro`,
|
|
156
|
+
"-v", `${outputDir}:/job/out:rw`,
|
|
157
|
+
...envArgs,
|
|
158
|
+
image,
|
|
159
|
+
...(options.command ?? ["vfrey", "render-job", "--input", "/job/in", "--output", "/job/out"]),
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function synthesized(status: "failed" | "timeout", code: string, message: string, seconds: number): RenderJobResult {
|
|
164
|
+
return {
|
|
165
|
+
version: 1,
|
|
166
|
+
status,
|
|
167
|
+
engine: null,
|
|
168
|
+
code,
|
|
169
|
+
message,
|
|
170
|
+
durations: { totalSec: seconds, renderSec: null, checksSec: null },
|
|
171
|
+
video: null,
|
|
172
|
+
sizes: { videoBytes: 0, transcriptBytes: 0 },
|
|
173
|
+
voice: "none",
|
|
174
|
+
warnings: [],
|
|
175
|
+
failedChecks: [],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function run(binary: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
|
180
|
+
return new Promise((resolve) => {
|
|
181
|
+
const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
182
|
+
let stdout = "";
|
|
183
|
+
let stderr = "";
|
|
184
|
+
child.stdout.on("data", (c) => (stdout = (stdout + c).slice(-8000)));
|
|
185
|
+
child.stderr.on("data", (c) => (stderr = (stderr + c).slice(-8000)));
|
|
186
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
187
|
+
child.on("error", (error) => {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
resolve({ code: null, stdout, stderr: `${stderr}${error.message}` });
|
|
190
|
+
});
|
|
191
|
+
child.on("close", (code) => {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
resolve({ code, stdout, stderr });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Runs each job as one `docker run` on this machine. For development, tests, and a single-host deployment. */
|
|
199
|
+
export class LocalDockerExecutor implements Executor {
|
|
200
|
+
readonly kind = "local-docker";
|
|
201
|
+
constructor(private readonly options: LocalDockerOptions = {}) {}
|
|
202
|
+
|
|
203
|
+
async run(job: ExecutorJob): Promise<ExecutorResult> {
|
|
204
|
+
const docker = this.options.docker ?? "docker";
|
|
205
|
+
const timeoutSec = this.options.timeoutSec ?? LIMIT_DEFAULTS.timeoutSec;
|
|
206
|
+
if (!(timeoutSec > 0)) throw new ExecutorError(`timeoutSec must be positive, not ${timeoutSec}.`);
|
|
207
|
+
// Validate everything before touching the disk or docker.
|
|
208
|
+
buildDockerArgs(job, this.options);
|
|
209
|
+
const input = await stat(job.inputDir).catch(() => null);
|
|
210
|
+
if (!input?.isDirectory()) throw new ExecutorError(`inputDir "${job.inputDir}" is not a folder.`);
|
|
211
|
+
await mkdir(job.outputDir, { recursive: true });
|
|
212
|
+
// A symlinked mount path is resolved so the path that was validated is the path that is mounted.
|
|
213
|
+
const real = { ...job, inputDir: await realpath(job.inputDir), outputDir: await realpath(job.outputDir) };
|
|
214
|
+
const final = buildDockerArgs(real, this.options);
|
|
215
|
+
|
|
216
|
+
const name = containerName(job.jobId);
|
|
217
|
+
const started = Date.now();
|
|
218
|
+
let timedOut = false;
|
|
219
|
+
const outcome = await new Promise<{ code: number | null; stderr: string }>((resolve) => {
|
|
220
|
+
const child = spawn(docker, final, { stdio: ["ignore", "ignore", "pipe"] });
|
|
221
|
+
let stderr = "";
|
|
222
|
+
child.stderr.on("data", (c) => (stderr = (stderr + c).slice(-8000)));
|
|
223
|
+
const timer = setTimeout(async () => {
|
|
224
|
+
timedOut = true;
|
|
225
|
+
// Killing the container ends `docker run` too; --rm removes it. The client is killed as a backstop.
|
|
226
|
+
await run(docker, ["kill", name], 20_000);
|
|
227
|
+
await run(docker, ["rm", "-f", name], 20_000);
|
|
228
|
+
child.kill("SIGKILL");
|
|
229
|
+
}, timeoutSec * 1000);
|
|
230
|
+
child.on("error", (error) => {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
resolve({ code: null, stderr: `${stderr}${error.message}` });
|
|
233
|
+
});
|
|
234
|
+
child.on("close", (code) => {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
resolve({ code, stderr });
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
const renderSeconds = Math.round(((Date.now() - started) / 1000) * 10) / 10;
|
|
240
|
+
const tail = outcome.stderr.trim().split(/\r?\n/).slice(-8).join("\n");
|
|
241
|
+
if (timedOut) {
|
|
242
|
+
// Make sure nothing is left running or on disk, whatever the kill raced with.
|
|
243
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
244
|
+
await run(docker, ["rm", "-f", name], 20_000);
|
|
245
|
+
if ((await run(docker, ["inspect", "--type", "container", name], 20_000)).code !== 0) break;
|
|
246
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
result: synthesized("timeout", "render_timeout", `The render ran past its ${timeoutSec} s limit and was stopped.`, renderSeconds),
|
|
250
|
+
renderSeconds,
|
|
251
|
+
exitCode: outcome.code,
|
|
252
|
+
timedOut: true,
|
|
253
|
+
stderrTail: tail,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
let parsed: RenderJobResult | null = null;
|
|
257
|
+
try {
|
|
258
|
+
parsed = JSON.parse(await readFile(path.join(job.outputDir, "result.json"), "utf8")) as RenderJobResult;
|
|
259
|
+
} catch {
|
|
260
|
+
parsed = null;
|
|
261
|
+
}
|
|
262
|
+
const result =
|
|
263
|
+
parsed ??
|
|
264
|
+
synthesized(
|
|
265
|
+
"failed",
|
|
266
|
+
outcome.code === 137 ? "render_oom" : "worker_crashed",
|
|
267
|
+
outcome.code === 137
|
|
268
|
+
? "The render was killed, most likely because it ran out of memory."
|
|
269
|
+
: `The worker exited with code ${outcome.code ?? "none"} and wrote no result.${tail ? ` ${tail.split("\n").slice(-2).join(" ")}` : ""}`,
|
|
270
|
+
renderSeconds,
|
|
271
|
+
);
|
|
272
|
+
return { result, renderSeconds, exitCode: outcome.code, timedOut: false, stderrTail: tail };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The executor for `kind`. `cloud-run-jobs` needs a project, a bucket and credentials: build it with `CloudRunExecutor` (./cloudrun-executor.ts). */
|
|
277
|
+
export function createExecutor(kind: string, options: LocalDockerOptions = {}): Executor {
|
|
278
|
+
if (kind === "local-docker") return new LocalDockerExecutor(options);
|
|
279
|
+
if (kind === "cloud-run-jobs") throw new ExecutorError('The "cloud-run-jobs" executor needs a project, region, job, bucket and credentials. Build it with new CloudRunExecutor(options).');
|
|
280
|
+
throw new ExecutorError(`There is no "${kind}" executor. Available: local-docker, cloud-run-jobs.`);
|
|
281
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createHash, createSign } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cloud Storage V4 signed URLs, with no SDK (spec 019, section C2).
|
|
5
|
+
* https://docs.cloud.google.com/storage/docs/access-control/signed-urls
|
|
6
|
+
* https://docs.cloud.google.com/storage/docs/authentication/signatures
|
|
7
|
+
*
|
|
8
|
+
* Why signed URLs: the Cloud Run job container needs NO permission on the bucket. It is handed one GET URL for its input
|
|
9
|
+
* bundle and one PUT URL per output file, each good for one object and a short time. A scene that took the container's
|
|
10
|
+
* credentials could therefore not read another job's files. The signer is either a service account key (`keySigner`) or,
|
|
11
|
+
* with no key on the machine, the IAM Credentials `signBlob` call (`iamSigner`), which works with workload identity federation.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Signs the string to sign and returns the signature as lower-case hex. */
|
|
15
|
+
export type Signer = { email: string; sign(stringToSign: string): Promise<string> };
|
|
16
|
+
export type TokenProvider = () => Promise<string>;
|
|
17
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
18
|
+
|
|
19
|
+
const HOST = "storage.googleapis.com";
|
|
20
|
+
const MAX_EXPIRES_SEC = 7 * 24 * 3600;
|
|
21
|
+
|
|
22
|
+
/** Signs locally with the private key of a service account (PEM, as in the key file's `private_key`). */
|
|
23
|
+
export function keySigner(email: string, privateKeyPem: string): Signer {
|
|
24
|
+
return {
|
|
25
|
+
email,
|
|
26
|
+
async sign(stringToSign) {
|
|
27
|
+
return createSign("RSA-SHA256").update(stringToSign).sign(privateKeyPem, "hex");
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Signs through `iamcredentials.googleapis.com` as `email`. The caller needs roles/iam.serviceAccountTokenCreator on it. */
|
|
33
|
+
export function iamSigner(email: string, token: TokenProvider, fetchImpl: FetchLike = fetch): Signer {
|
|
34
|
+
return {
|
|
35
|
+
email,
|
|
36
|
+
async sign(stringToSign) {
|
|
37
|
+
const response = await fetchImpl(`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(email)}:signBlob`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { authorization: `Bearer ${await token()}`, "content-type": "application/json" },
|
|
40
|
+
body: JSON.stringify({ payload: Buffer.from(stringToSign, "utf8").toString("base64") }),
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok) throw new Error(`signBlob failed with HTTP ${response.status}.`);
|
|
43
|
+
const body = (await response.json()) as { signedBlob?: string };
|
|
44
|
+
if (!body.signedBlob) throw new Error("signBlob returned no signature.");
|
|
45
|
+
return Buffer.from(body.signedBlob, "base64").toString("hex");
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const encode = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
51
|
+
const encodePath = (value: string) => value.split("/").map(encode).join("/");
|
|
52
|
+
const stamp = (now: Date) => now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
53
|
+
|
|
54
|
+
/** The V4 string to sign for a GET or PUT on one object. Exported for tests. */
|
|
55
|
+
export function v4Request(opts: { method: "GET" | "PUT"; bucket: string; object: string; email: string; expiresSec: number; now: Date }) {
|
|
56
|
+
if (!(opts.expiresSec > 0 && opts.expiresSec <= MAX_EXPIRES_SEC)) throw new Error("A signed URL lives 1 second to 7 days.");
|
|
57
|
+
const datetime = stamp(opts.now);
|
|
58
|
+
const scope = `${datetime.slice(0, 8)}/auto/storage/goog4_request`;
|
|
59
|
+
const query: [string, string][] = [
|
|
60
|
+
["X-Goog-Algorithm", "GOOG4-RSA-SHA256"],
|
|
61
|
+
["X-Goog-Credential", `${opts.email}/${scope}`],
|
|
62
|
+
["X-Goog-Date", datetime],
|
|
63
|
+
["X-Goog-Expires", String(Math.floor(opts.expiresSec))],
|
|
64
|
+
["X-Goog-SignedHeaders", "host"],
|
|
65
|
+
];
|
|
66
|
+
const canonicalQuery = query.map(([k, v]) => `${encode(k)}=${encode(v)}`).sort().join("&");
|
|
67
|
+
const resource = `/${opts.bucket}/${encodePath(opts.object)}`;
|
|
68
|
+
const canonical = [opts.method, resource, canonicalQuery, `host:${HOST}`, "", "host", "UNSIGNED-PAYLOAD"].join("\n");
|
|
69
|
+
const stringToSign = ["GOOG4-RSA-SHA256", datetime, scope, createHash("sha256").update(canonical).digest("hex")].join("\n");
|
|
70
|
+
return { canonical, stringToSign, canonicalQuery, resource };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function signUrl(signer: Signer, opts: { method: "GET" | "PUT"; bucket: string; object: string; expiresSec: number; now?: Date }): Promise<string> {
|
|
74
|
+
const { stringToSign, canonicalQuery, resource } = v4Request({ ...opts, email: signer.email, now: opts.now ?? new Date() });
|
|
75
|
+
return `https://${HOST}${resource}?${canonicalQuery}&X-Goog-Signature=${await signer.sign(stringToSign)}`;
|
|
76
|
+
}
|