vidspotai-shared 1.0.94 → 1.0.96
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/lib/globals/aiModels/enums.d.ts +2 -0
- package/lib/globals/aiModels/enums.d.ts.map +1 -1
- package/lib/globals/aiModels/enums.js +5 -0
- package/lib/globals/aiModels/index.d.ts.map +1 -1
- package/lib/globals/aiModels/index.js +2 -0
- package/lib/globals/aiModels/providers/hostedAvatars.d.ts +22 -0
- package/lib/globals/aiModels/providers/hostedAvatars.d.ts.map +1 -0
- package/lib/globals/aiModels/providers/hostedAvatars.js +129 -0
- package/lib/globals/types.d.ts +58 -1
- package/lib/globals/types.d.ts.map +1 -1
- package/lib/globals/types.js +61 -1
- package/lib/models/demoVideoProject.model.d.ts +244 -0
- package/lib/models/demoVideoProject.model.d.ts.map +1 -0
- package/lib/models/demoVideoProject.model.js +2 -0
- package/lib/models/githubConnection.model.d.ts +35 -0
- package/lib/models/githubConnection.model.d.ts.map +1 -0
- package/lib/models/githubConnection.model.js +2 -0
- package/lib/models/index.d.ts +3 -0
- package/lib/models/index.d.ts.map +1 -1
- package/lib/models/index.js +3 -0
- package/lib/models/outreach.model.d.ts +175 -0
- package/lib/models/outreach.model.d.ts.map +1 -0
- package/lib/models/outreach.model.js +2 -0
- package/lib/models/video.model.d.ts +4 -0
- package/lib/models/video.model.d.ts.map +1 -1
- package/lib/schemas/brandKit.schema.d.ts +4 -0
- package/lib/schemas/brandKit.schema.d.ts.map +1 -1
- package/lib/schemas/brandKit.schema.js +10 -0
- package/lib/schemas/demoQuestion.schema.d.ts +286 -0
- package/lib/schemas/demoQuestion.schema.d.ts.map +1 -0
- package/lib/schemas/demoQuestion.schema.js +210 -0
- package/lib/schemas/demoSpec.schema.d.ts +109 -6
- package/lib/schemas/demoSpec.schema.d.ts.map +1 -1
- package/lib/schemas/demoSpec.schema.js +95 -7
- package/lib/schemas/index.d.ts +1 -0
- package/lib/schemas/index.d.ts.map +1 -1
- package/lib/schemas/index.js +1 -0
- package/lib/schemas/videoPlan.schema.d.ts +2 -2
- package/lib/schemas/videoPlan.schema.d.ts.map +1 -1
- package/lib/schemas/videoPlan.schema.js +11 -1
- package/lib/services/agent/providerFallback/chains.d.ts.map +1 -1
- package/lib/services/agent/providerFallback/chains.js +9 -1
- package/lib/services/agent/taskPoller.d.ts.map +1 -1
- package/lib/services/agent/taskPoller.js +17 -6
- package/lib/services/agent/tools/generateAvatarVideo.tool.d.ts +21 -9
- package/lib/services/agent/tools/generateAvatarVideo.tool.d.ts.map +1 -1
- package/lib/services/agent/tools/generateAvatarVideo.tool.js +135 -39
- package/lib/services/agent/tools/planVideo.tool.d.ts +2 -2
- package/lib/services/aiGen/aiGenFactory.service.d.ts +1 -1
- package/lib/services/aiGen/aiGenFactory.service.d.ts.map +1 -1
- package/lib/services/aiGen/aiGenFactory.service.js +10 -2
- package/lib/services/aiGen/helpers.d.ts.map +1 -1
- package/lib/services/aiGen/helpers.js +12 -2
- package/lib/services/aiGen/providers/kling/kling.service.d.ts.map +1 -1
- package/lib/services/aiGen/providers/kling/kling.service.js +47 -8
- package/lib/services/aiGen/providers/pixverse/pixverse.service.d.ts +8 -0
- package/lib/services/aiGen/providers/pixverse/pixverse.service.d.ts.map +1 -1
- package/lib/services/aiGen/providers/pixverse/pixverse.service.js +55 -2
- package/lib/services/avatarGen/IAvatarVideoProvider.d.ts +136 -0
- package/lib/services/avatarGen/IAvatarVideoProvider.d.ts.map +1 -0
- package/lib/services/avatarGen/IAvatarVideoProvider.js +42 -0
- package/lib/services/avatarGen/avatarProviderFactory.d.ts +18 -0
- package/lib/services/avatarGen/avatarProviderFactory.d.ts.map +1 -0
- package/lib/services/avatarGen/avatarProviderFactory.js +54 -0
- package/lib/services/avatarGen/index.d.ts +6 -0
- package/lib/services/avatarGen/index.d.ts.map +1 -0
- package/lib/services/avatarGen/index.js +21 -0
- package/lib/services/avatarGen/providers/heygenAvatarProvider.d.ts +45 -0
- package/lib/services/avatarGen/providers/heygenAvatarProvider.d.ts.map +1 -0
- package/lib/services/avatarGen/providers/heygenAvatarProvider.js +190 -0
- package/lib/services/avatarGen/providers/klingAvatarProvider.d.ts +32 -0
- package/lib/services/avatarGen/providers/klingAvatarProvider.d.ts.map +1 -0
- package/lib/services/avatarGen/providers/klingAvatarProvider.js +69 -0
- package/lib/services/avatarGen/providers/tavusAvatarProvider.d.ts +56 -0
- package/lib/services/avatarGen/providers/tavusAvatarProvider.d.ts.map +1 -0
- package/lib/services/avatarGen/providers/tavusAvatarProvider.js +183 -0
- package/lib/services/demo/demoAuthor.d.ts +45 -0
- package/lib/services/demo/demoAuthor.d.ts.map +1 -0
- package/lib/services/demo/demoAuthor.js +75 -0
- package/lib/services/demo/demoAuthorNormalize.d.ts +49 -0
- package/lib/services/demo/demoAuthorNormalize.d.ts.map +1 -0
- package/lib/services/demo/demoAuthorNormalize.js +327 -0
- package/lib/services/demo/demoAuthorPrompt.d.ts +102 -0
- package/lib/services/demo/demoAuthorPrompt.d.ts.map +1 -0
- package/lib/services/demo/demoAuthorPrompt.js +243 -0
- package/lib/services/demo/demoCredits.d.ts +20 -0
- package/lib/services/demo/demoCredits.d.ts.map +1 -0
- package/lib/services/demo/demoCredits.js +51 -0
- package/lib/services/demo/demoMediaGuard.d.ts +43 -0
- package/lib/services/demo/demoMediaGuard.d.ts.map +1 -0
- package/lib/services/demo/demoMediaGuard.js +207 -0
- package/lib/services/demo/demoMusic.d.ts +13 -0
- package/lib/services/demo/demoMusic.d.ts.map +1 -0
- package/lib/services/demo/demoMusic.js +93 -0
- package/lib/services/demo/demoPrefill.d.ts +76 -0
- package/lib/services/demo/demoPrefill.d.ts.map +1 -0
- package/lib/services/demo/demoPrefill.js +435 -0
- package/lib/services/demo/demoRevisePrompt.d.ts +17 -0
- package/lib/services/demo/demoRevisePrompt.d.ts.map +1 -0
- package/lib/services/demo/demoRevisePrompt.js +85 -0
- package/lib/services/demo/demoReviser.d.ts +63 -0
- package/lib/services/demo/demoReviser.d.ts.map +1 -0
- package/lib/services/demo/demoReviser.js +81 -0
- package/lib/services/demo/demoScriptPrompt.d.ts +63 -0
- package/lib/services/demo/demoScriptPrompt.d.ts.map +1 -0
- package/lib/services/demo/demoScriptPrompt.js +234 -0
- package/lib/services/demo/demoScriptWriter.d.ts +65 -0
- package/lib/services/demo/demoScriptWriter.d.ts.map +1 -0
- package/lib/services/demo/demoScriptWriter.js +211 -0
- package/lib/services/demo/demoTriage.d.ts +65 -0
- package/lib/services/demo/demoTriage.d.ts.map +1 -0
- package/lib/services/demo/demoTriage.js +130 -0
- package/lib/services/demo/githubOAuth.d.ts +78 -0
- package/lib/services/demo/githubOAuth.d.ts.map +1 -0
- package/lib/services/demo/githubOAuth.js +209 -0
- package/lib/services/demo/githubOAuthState.d.ts +10 -0
- package/lib/services/demo/githubOAuthState.d.ts.map +1 -0
- package/lib/services/demo/githubOAuthState.js +63 -0
- package/lib/services/demo/index.d.ts +24 -0
- package/lib/services/demo/index.d.ts.map +1 -0
- package/lib/services/demo/index.js +39 -0
- package/lib/services/firestore.service.d.ts +11 -0
- package/lib/services/firestore.service.d.ts.map +1 -1
- package/lib/services/firestore.service.js +24 -0
- package/lib/services/index.d.ts +2 -0
- package/lib/services/index.d.ts.map +1 -1
- package/lib/services/index.js +2 -0
- package/lib/services/tts/providers/elevenlabs.service.d.ts.map +1 -1
- package/lib/services/tts/providers/elevenlabs.service.js +16 -8
- package/lib/utils/index.d.ts +1 -0
- package/lib/utils/index.d.ts.map +1 -1
- package/lib/utils/index.js +1 -0
- package/lib/utils/outreachTemplate.d.ts +46 -0
- package/lib/utils/outreachTemplate.d.ts.map +1 -0
- package/lib/utils/outreachTemplate.js +174 -0
- package/package.json +1 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_DURATION_BUDGET = exports.DURATION_BUDGETS = void 0;
|
|
4
|
+
exports.budgetForLength = budgetForLength;
|
|
5
|
+
exports.buildDemoAuthorSystemPrompt = buildDemoAuthorSystemPrompt;
|
|
6
|
+
exports.buildDemoAuthorUserText = buildDemoAuthorUserText;
|
|
7
|
+
exports.DURATION_BUDGETS = {
|
|
8
|
+
// ~20–30s teaser — kinetic, few beats, one hero surface.
|
|
9
|
+
teaser: { min: 18000, target: 26000, max: 32000, sceneHint: "4–6 short scenes" },
|
|
10
|
+
// ~45–60s walkthrough — the default; intro → 2–3 features → CTA.
|
|
11
|
+
walkthrough: { min: 42000, target: 54000, max: 66000, sceneHint: "6–9 scenes" },
|
|
12
|
+
// ~90s detailed — full narrative, more feature surfaces.
|
|
13
|
+
detailed: { min: 78000, target: 92000, max: 108000, sceneHint: "9–13 scenes" },
|
|
14
|
+
};
|
|
15
|
+
/** Default when the user left targetLength on "decide for me". */
|
|
16
|
+
exports.DEFAULT_DURATION_BUDGET = exports.DURATION_BUDGETS.walkthrough;
|
|
17
|
+
function budgetForLength(targetLength) {
|
|
18
|
+
return targetLength ? exports.DURATION_BUDGETS[targetLength] : exports.DEFAULT_DURATION_BUDGET;
|
|
19
|
+
}
|
|
20
|
+
// ─── system prompt ───────────────────────────────────────────────────────────
|
|
21
|
+
const SCENE_VOCABULARY = `
|
|
22
|
+
SCENE TYPES (choose per beat; each is a Layer-A template that renders deterministically):
|
|
23
|
+
- "logoReveal" — brand sting to open/close. { tagline? }.
|
|
24
|
+
- "bigStatement" — large kinetic words, text-led value-prop. { lines[] (each line may mark ONE emphasized span with **double asterisks**), kicker? }. Best for kinetic/energetic openers + punchy value beats.
|
|
25
|
+
- "problem" — the pain, headline + scattered context chips. { headline, motifs[] (short pain phrases) }.
|
|
26
|
+
- "featureShowcase" — a fake, brand-themed product screen (app chrome + sidebar + topbar) with an animated cursor operating it. { title, surface (one of: search|form|table|detail|chat|editor|analysis|marketplace|dashboard), cursorTypes? (text the cursor types), activeNav?, cards[]{title,subtitle?,badge?,badgeKind?,icon?}, screenshotUrl?, videoUrl? }. THE clean-ui-walkthrough centerpiece. Use when you can plausibly synthesize the UI OR embed a real screenshot/clip (videoUrl plays a real clip in the content region).
|
|
27
|
+
- "splitFeature" — bold headline on one side, an ISOLATED UI panel/screenshot on the other, numbered "STEP n —" eyebrow. { headline, body?, stepLabel?, side (left|right), screenshotUrl? OR videoUrl? OR cards[] }. The split-headline style; no app chrome, no cursor.
|
|
28
|
+
- "deviceMockup" — real screenshot(s) OR a real video clip composited into a 3D-perspective device frame with a camera move. { device (browser|laptop|phone|tablet), screenshotUrls[] (image assets), videoUrl? (a clip plays inside the frame — screenshots optional when set), motion (tilt|parallax|scroll|zoom-in|orbit), headline? }. REQUIRES real media — only use when screenshots OR a video asset are provided.
|
|
29
|
+
- "mediaShot" — ONE full-bleed image or clip fills the whole frame (cover-fit) under a slow camera move, with an optional SHORT headline over a readability scrim. { imageUrl? OR videoUrl?, headline?, motion (slow-zoom|drift|static), scrim (default true) }. The home for lifestyle/atmosphere/emotional media — a user photo/b-roll or a generated asset. Deliberately chrome-free: this is contextual imagery, NEVER a product-UI stand-in.
|
|
30
|
+
- "chat" — an AI-assistant conversation surface. { title, turns[]{role(user|assistant),text,chips?[]} }.
|
|
31
|
+
- "dashboard" — metrics + a simple chart. { title, stats[]{label,value?(number for count-up)|valueText?,suffix?,icon?}, chart[] (0..1 normalized bars) }.
|
|
32
|
+
- "docAnalysis" — a document being scanned, insight rows revealed. { title, insights[]{title,subtitle?,badge?,badgeKind?} }.
|
|
33
|
+
- "cardGrid" — a grid of feature cards. { title, cards[] (>=1) }.
|
|
34
|
+
- "stats" — a metric wall (count-up numbers). { headline?, stats[] (>=1) }.
|
|
35
|
+
- "cta" — closing call-to-action. { headline, buttonText, url?, stats[]? }.
|
|
36
|
+
|
|
37
|
+
EVERY scene also takes: id (stable slug), durationMs (int), caption?{title,sub?} (VO-ready lower-third), narrationHint? (seed for the later script stage).`;
|
|
38
|
+
/**
|
|
39
|
+
* Custom-scene guidance. Appended to the scene vocabulary only when the Layer-B
|
|
40
|
+
* codegen agent is enabled on the worker (`allowCustomScenes`). When it is off we
|
|
41
|
+
* emit the hard prohibition instead, so a spec never carries a `custom` scene the
|
|
42
|
+
* pipeline can't author — behavior is identical to before Phase 9 until the flag
|
|
43
|
+
* flips. When on, the planner may PROPOSE a custom scene by writing an `intent`
|
|
44
|
+
* brief; a specialist codegen agent (Layer B) authors the actual TSX afterward.
|
|
45
|
+
*/
|
|
46
|
+
const CUSTOM_SCENE_ALLOWED = `
|
|
47
|
+
CUSTOM SCENES (choose on FIT, not as a last resort): The Layer-A templates above are the fast path, but they only cover common beats. QUALITY COMES FIRST — for each beat, pick the template that represents it WELL, and if the best-fit template would only APPROXIMATE it (forcing the concept into the wrong structure, dropping/misusing content, or reading as a generic panel), author a type:"custom" scene instead. Do NOT stretch a template to fit — a beat rendered in the wrong template is a worse outcome than the extra time/tokens a custom scene costs.
|
|
48
|
+
|
|
49
|
+
Poor template fits that SHOULD usually be custom (illustrative, not exhaustive): a numbered step-by-step / how-it-works flow with connected steps; a side-by-side comparison ("us vs them", before/after); a bespoke data-visualization (timeline, funnel, map, graph beyond the dashboard's simple bars); a pricing/tier layout; an integrations/logo constellation; an unusual kinetic-typography composition; a novel arrangement of a product surface the templates don't model.
|
|
50
|
+
|
|
51
|
+
For a custom scene set only: type:"custom", a stable id, durationMs, an optional caption/narrationHint, optional props, and — REQUIRED — a rich \`intent\`: a precise natural-language brief describing what the scene shows, how it animates across its duration (beats/motion over time), which Layer-0 primitives to lean on, and the on-brand look. Do NOT write any code; a specialist codegen agent authors the TSX from your intent.
|
|
52
|
+
|
|
53
|
+
Budget: use as many custom scenes as the story genuinely needs — there is NO fixed cap — but each is authored individually, so choose deliberately (a typical video has 1–3). The opener/closer are well served by logoReveal/cta; keep those as templates unless the brand truly needs a bespoke sting.`;
|
|
54
|
+
const CUSTOM_SCENE_DISALLOWED = `
|
|
55
|
+
Do NOT emit type:"custom" — that path is authored by a different agent.`;
|
|
56
|
+
/**
|
|
57
|
+
* Generated-media guidance. The planner may REQUEST AI-generated images/clips
|
|
58
|
+
* (declared in `generatedAssets`, cited as `gen://<id>`) and a deterministic
|
|
59
|
+
* pipeline stage materializes them before render. The PLANNING is the point:
|
|
60
|
+
* deciding when generation genuinely serves the product's story, what exactly
|
|
61
|
+
* to generate, and where it sits — most demos should request nothing.
|
|
62
|
+
* `DEMO_GEN_ASSETS_DISABLED` swaps in the prohibition as an emergency brake.
|
|
63
|
+
*/
|
|
64
|
+
const GENERATED_MEDIA_ALLOWED = `
|
|
65
|
+
GENERATED MEDIA (you may commission AI-generated images/clips — decide like a creative director):
|
|
66
|
+
Declare each request in top-level generatedAssets[]: { id (stable slug), kind ("image"|"video"), prompt (the full generation prompt), purpose (which beat it serves + why), aspect? (defaults to the video's), durationSec? (video, 2–8) }, then cite it from a scene media slot as the literal string "gen://<id>". A pipeline stage generates every request and swaps in the real URL; a failed generation simply degrades the scene, so never build a beat that is EMPTY without its asset.
|
|
67
|
+
|
|
68
|
+
WHEN to commission (a decision ladder — real assets always outrank generated ones):
|
|
69
|
+
- The product/service has no capturable UI (a physical product, a service, an API/CLI, a marketplace of physical goods) and the video needs a hero or in-context visual of it.
|
|
70
|
+
- A beat's emotional register needs atmosphere the templates can't code — the problem's messy real world, the outcome's calm, the audience's environment (a kitchen, a job site, a clinic).
|
|
71
|
+
- A cinematic/kinetic beat would be elevated by an abstract branded motion backdrop (video kind, cited from a mediaShot).
|
|
72
|
+
DO NOT commission: fake product-UI "screenshots" (synthetic UI must be CODED featureShowcase/chat/dashboard surfaces — visibly designed, never photoreal fakes); anything a provided screenshot/asset already covers; generic decoration on beats a template already serves. MOST SaaS demos need ZERO generated assets — commission because THIS product's story demands it, not because the capability exists.
|
|
73
|
+
|
|
74
|
+
WHERE "gen://<id>" may be cited: mediaShot.imageUrl / mediaShot.videoUrl (its primary home), splitFeature.screenshotUrl / splitFeature.videoUrl (the isolated side visual), and custom-scene props. NOT on deviceMockup or featureShowcase — those present media as the product's real screen.
|
|
75
|
+
|
|
76
|
+
HOW to write the generation prompt (you are prompting a top image/video model):
|
|
77
|
+
- Concrete subject + composition + lighting/mood + color grade, in one dense paragraph. Anchor it to the brand: name the palette hex codes as accent colors and match style.visual (cinematic → filmic shallow depth; minimal-calm → negative space, soft light; energetic-punchy → bold color, hard light).
|
|
78
|
+
- ABSOLUTELY no readable text, words, letters, logos, or UI in the output — models render text badly and fake UI is banned; all text lives in overlays.
|
|
79
|
+
- video: describe ONE continuous shot with explicit motion (what moves, how fast, camera behavior), no cuts, loop-friendly (ends near where it starts when possible).
|
|
80
|
+
Caps: at most 4 images + 1 video per spec (requests beyond the cap are dropped in declaration order — declare the most important first).`;
|
|
81
|
+
const GENERATED_MEDIA_DISALLOWED = `
|
|
82
|
+
Do NOT declare generatedAssets and do NOT cite any "gen://" URL — asset generation is disabled; use only provided media.`;
|
|
83
|
+
const STYLE_TAXONOMY = `
|
|
84
|
+
STYLE (top-level, cross-cutting — re-skins EVERY scene; this is the anti-overfit axis):
|
|
85
|
+
- visual: clean-ui-walkthrough | split-headline | kinetic-typography | cinematic | minimal-calm | energetic-punchy
|
|
86
|
+
- pace: fast | medium | slow
|
|
87
|
+
- colorMood: light | dark | brand-forward
|
|
88
|
+
- audioIntent: captions | voiceover | music (controls ON-SCREEN TEXT DENSITY: captions=headline-led, voiceover=sparse text, music=minimal text)
|
|
89
|
+
- motion: subtle | balanced | dynamic
|
|
90
|
+
|
|
91
|
+
narrative (drives scene ordering + tone): problem-solution | feature-showcase | day-in-the-life | hero-workflow | value-prop
|
|
92
|
+
aspect: 16:9 | 9:16 | 1:1 | 4:5 fps: 30
|
|
93
|
+
|
|
94
|
+
STYLE→SCENE affinity (guidance, not a straitjacket — pick what fits the product):
|
|
95
|
+
- clean-ui-walkthrough → lean on featureShowcase (cursor operating the app) + chat/dashboard/docAnalysis; logoReveal open, cta close.
|
|
96
|
+
- split-headline → splitFeature beats with STEP labels + isolated panels; minimal chrome.
|
|
97
|
+
- kinetic-typography / energetic-punchy → bigStatement openers, deviceMockup (if screenshots) or featureShowcase, snappy short beats, stats, punchy cta.
|
|
98
|
+
- cinematic / minimal-calm → slower bigStatement + one hero surface, lots of whitespace, glow/rings decor.`;
|
|
99
|
+
function buildDemoAuthorSystemPrompt(opts = {}) {
|
|
100
|
+
const customGuidance = opts.allowCustomScenes
|
|
101
|
+
? CUSTOM_SCENE_ALLOWED
|
|
102
|
+
: CUSTOM_SCENE_DISALLOWED;
|
|
103
|
+
const generatedMediaGuidance = opts.allowGeneratedAssets !== false
|
|
104
|
+
? GENERATED_MEDIA_ALLOWED
|
|
105
|
+
: GENERATED_MEDIA_DISALLOWED;
|
|
106
|
+
return `You are the lead motion designer + creative director for VidSpotAI's autonomous SaaS product-demo generator. You author a single DemoSpec (returned via the provided tool) that a Remotion engine renders to a broadcast-quality MP4 with ZERO further human effort. Your bar is "better than a hand-made Product Hunt launch video."
|
|
107
|
+
|
|
108
|
+
You are given a product's facts, its brand tokens, optional real product screenshots (as images you can SEE), and the user's answers to a style wizard. Produce a complete, ordered DemoSpec.
|
|
109
|
+
|
|
110
|
+
${SCENE_VOCABULARY}
|
|
111
|
+
${customGuidance}
|
|
112
|
+
${generatedMediaGuidance}
|
|
113
|
+
|
|
114
|
+
${STYLE_TAXONOMY}
|
|
115
|
+
|
|
116
|
+
HARD RULES (violating these breaks the render or the brand):
|
|
117
|
+
1. NEVER invent a media URL. Only use screenshot URLs from the "Available screenshots" list (on featureShowcase.screenshotUrl / splitFeature.screenshotUrl / deviceMockup.screenshotUrls) and video URLs from the "Featured media" list (on featureShowcase.videoUrl / splitFeature.videoUrl / deviceMockup.videoUrl), each copied VERBATIM. The ONE exception: "gen://<id>" refs to your OWN declared generatedAssets, where the GENERATED MEDIA rules allow them. If no screenshots are provided, do NOT emit a screenshot-only deviceMockup, and author featureShowcase surfaces as fully synthetic (coded) UI.
|
|
118
|
+
5. FEATURED MEDIA the user attached (the "Featured media" list) MUST appear in the video — give each asset at least one scene: a video clip on a deviceMockup/splitFeature/featureShowcase videoUrl, an image on a screenshotUrl. Honor any placement note the user gave.
|
|
119
|
+
2. Use the brand's colors as given — do not restyle them. Copy is yours; palette is theirs.
|
|
120
|
+
3. The productName is overlay text; never bury it inside a screenshot description or expect the engine to draw it into a fake UI illegibly.
|
|
121
|
+
4. Respect any wizard answer the user explicitly set (visual/pace/colorMood/audioIntent/narrative/aspect/features). Only choose freely for the axes they left unset ("decide for me").
|
|
122
|
+
|
|
123
|
+
QUALITY BAR (observed in the reference demos — hit all of these):
|
|
124
|
+
- Invent realistic, domain-specific data (names, rows, metrics, chat copy) — never lorem/placeholder.
|
|
125
|
+
- Match on-screen text density to audioIntent (music → almost no text; captions → a headline per beat).
|
|
126
|
+
- Give featureShowcase scenes a concrete cursorTypes string + a sensible activeNav so the walkthrough feels operated, not static.
|
|
127
|
+
- Open with identity (logoReveal or a bigStatement value-prop), pay off with a cta. Order scenes to tell the chosen narrative.
|
|
128
|
+
- Write a caption{title,sub?} for beats that carry a narration point (skip captions on pure kinetic/music beats). Seed narrationHint on feature beats for the later voiceover stage.
|
|
129
|
+
- Prefer deviceMockup / splitFeature.screenshotUrl for authenticated apps you cannot convincingly fake; prefer coded featureShowcase when you want a cursor walkthrough and can plausibly synthesize the surface.
|
|
130
|
+
|
|
131
|
+
DISTINCTIVENESS (every video must feel authored for THIS product, not filled into a house template):
|
|
132
|
+
- Derive the arc from the product's actual story. "logoReveal → problem → bigStatement → features → stats → cta" is ONE possible skeleton, not the format — reorder, drop, or swap beats whenever the product suggests it: open on the product surface for a visually striking tool, lead with the workflow for hero-workflow, open on a bold claim for value-prop, skip the problem beat for a product whose value is self-evident.
|
|
133
|
+
- Before returning, check your scene-type sequence: if it matches the generic skeleton out of inertia rather than choice, rework the weakest beat into a different type — or a custom scene.
|
|
134
|
+
- When custom scenes are allowed, the product's ONE signature moment (its "aha" interaction or differentiator) is the first custom-scene candidate — bespoke motion pays off most there.
|
|
135
|
+
- Numbers presented as the product's traction/social proof (user counts, ratings, "X% faster") must come from the provided facts (site copy, user input, screenshots). If none exist, do NOT fabricate adoption claims — build the stats beat from capability facts (languages supported, tools included, time-to-set-up) or skip it. Invented data belongs INSIDE synthetic UI surfaces, never in claims about the product itself.
|
|
136
|
+
|
|
137
|
+
DURATION: size each scene's durationMs so the TOTAL lands near the target you are told, roughly 2–4s per beat (faster for kinetic/energetic, slower for cinematic/minimal-calm). Integer milliseconds.
|
|
138
|
+
|
|
139
|
+
Return ONLY the DemoSpec via the tool. Author every field you can; the engine applies defaults for anything you omit.`;
|
|
140
|
+
}
|
|
141
|
+
// ─── user message ────────────────────────────────────────────────────────────
|
|
142
|
+
/** Screenshots enumerated with their real URLs so the author can cite them verbatim. */
|
|
143
|
+
function screenshotSection(urls) {
|
|
144
|
+
if (!urls?.length) {
|
|
145
|
+
return `Available screenshots: NONE. Do not emit deviceMockup; author any product UI as fully synthetic featureShowcase surfaces.`;
|
|
146
|
+
}
|
|
147
|
+
const lines = urls.map((u, i) => ` [${i}] ${u}`).join("\n");
|
|
148
|
+
return `Available screenshots (${urls.length}) — the ONLY URLs you may use, copied verbatim, on screenshotUrl/screenshotUrls:\n${lines}\nThe same images are attached to this message for you to see.`;
|
|
149
|
+
}
|
|
150
|
+
/** User-attached media that must be FEATURED in the video, with placement hints. */
|
|
151
|
+
function mediaAssetSection(assets) {
|
|
152
|
+
if (!assets?.length)
|
|
153
|
+
return "";
|
|
154
|
+
const lines = assets.map((a, i) => {
|
|
155
|
+
const bits = [` [${i}] ${a.kind.toUpperCase()} ${a.url}`];
|
|
156
|
+
if (a.label)
|
|
157
|
+
bits.push(`label: "${a.label}"`);
|
|
158
|
+
if (a.intent)
|
|
159
|
+
bits.push(`use: "${a.intent}"`);
|
|
160
|
+
if (a.kind === "video" && a.durationMs) {
|
|
161
|
+
bits.push(`~${Math.round(a.durationMs / 1000)}s`);
|
|
162
|
+
}
|
|
163
|
+
return bits.join(" · ");
|
|
164
|
+
});
|
|
165
|
+
return [
|
|
166
|
+
`Featured media (${assets.length}) — user-attached assets that MUST appear in the video, copied VERBATIM:`,
|
|
167
|
+
...lines,
|
|
168
|
+
"Place each VIDEO url on a deviceMockup/splitFeature/featureShowcase videoUrl (a real clip playing inside the frame); place each IMAGE url on a screenshotUrl. Give every asset at least one scene, and honor any stated use.",
|
|
169
|
+
].join("\n");
|
|
170
|
+
}
|
|
171
|
+
/** The agent's dynamic clarifying answers — authoritative steering the author must honor. */
|
|
172
|
+
function clarifyAnswersSection(answers) {
|
|
173
|
+
const entries = Object.entries(answers ?? {});
|
|
174
|
+
if (entries.length === 0)
|
|
175
|
+
return "";
|
|
176
|
+
const lines = entries.map(([id, v]) => {
|
|
177
|
+
const val = Array.isArray(v) ? v.join(", ") : String(v);
|
|
178
|
+
return ` - ${id}: ${val}`;
|
|
179
|
+
});
|
|
180
|
+
return ["User clarifications (honor these — they answered them just now):", ...lines].join("\n");
|
|
181
|
+
}
|
|
182
|
+
/** Which wizard axes the user pinned vs left for you to decide. */
|
|
183
|
+
function wizardSection(answers) {
|
|
184
|
+
const a = answers ?? {};
|
|
185
|
+
const pin = (label, v) => v === undefined || v === null || (Array.isArray(v) && v.length === 0)
|
|
186
|
+
? ` ${label}: decide for me`
|
|
187
|
+
: ` ${label}: ${Array.isArray(v) ? v.join(", ") : String(v)} (LOCKED — must honor)`;
|
|
188
|
+
return [
|
|
189
|
+
"Wizard answers (LOCKED axes must be honored exactly; 'decide for me' is yours to choose):",
|
|
190
|
+
pin("visual style", a.visualStyle),
|
|
191
|
+
pin("pace", a.pace),
|
|
192
|
+
pin("target length", a.targetLength),
|
|
193
|
+
pin("narrative", a.narrative),
|
|
194
|
+
pin("aspect", a.aspect),
|
|
195
|
+
pin("color mood", a.colorMood),
|
|
196
|
+
pin("audio intent", a.audioIntent),
|
|
197
|
+
pin("features to highlight", a.features),
|
|
198
|
+
].join("\n");
|
|
199
|
+
}
|
|
200
|
+
/** Build the user-turn text (images are attached separately as imageUrls). */
|
|
201
|
+
function buildDemoAuthorUserText(input) {
|
|
202
|
+
const budget = budgetForLength(input.answers?.targetLength);
|
|
203
|
+
const brandColors = input.brand.colors ?? { palette: [] };
|
|
204
|
+
const identity = input.brand.identity ?? {};
|
|
205
|
+
const fonts = input.brand.fonts ?? {};
|
|
206
|
+
const facts = [
|
|
207
|
+
`Product name: ${input.productName}`,
|
|
208
|
+
input.tagline ? `Tagline: ${input.tagline}` : null,
|
|
209
|
+
input.description ? `Description: ${input.description}` : null,
|
|
210
|
+
input.features?.length ? `Features: ${input.features.join(", ")}` : null,
|
|
211
|
+
input.audience ? `Audience: ${input.audience}` : null,
|
|
212
|
+
input.websiteUrl ? `Website: ${input.websiteUrl}` : null,
|
|
213
|
+
input.pageSummary
|
|
214
|
+
? `From the website (context, not verbatim): ${input.pageSummary}`
|
|
215
|
+
: null,
|
|
216
|
+
input.repoNotes ? `Notes from the author: ${input.repoNotes}` : null,
|
|
217
|
+
]
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
.join("\n");
|
|
220
|
+
const brand = [
|
|
221
|
+
`Brand name: ${identity.brandName ?? input.productName}`,
|
|
222
|
+
identity.wordmark ? `Wordmark: ${identity.wordmark}` : null,
|
|
223
|
+
identity.logoUrl ? `Logo URL: ${identity.logoUrl}` : null,
|
|
224
|
+
`Primary color: ${brandColors.primary ?? "(unset — pick an on-brand default)"}`,
|
|
225
|
+
`Accent color: ${brandColors.accent ?? "(unset — pick a complementary accent)"}`,
|
|
226
|
+
brandColors.palette?.length ? `Palette: ${brandColors.palette.join(", ")}` : null,
|
|
227
|
+
fonts.heading ? `Heading font: ${fonts.heading}` : null,
|
|
228
|
+
]
|
|
229
|
+
.filter(Boolean)
|
|
230
|
+
.join("\n");
|
|
231
|
+
return [
|
|
232
|
+
`PRODUCT\n${facts}`,
|
|
233
|
+
`BRAND (use colors verbatim)\n${brand}`,
|
|
234
|
+
wizardSection(input.answers),
|
|
235
|
+
clarifyAnswersSection(input.clarifyAnswers),
|
|
236
|
+
screenshotSection(input.screenshotUrls),
|
|
237
|
+
mediaAssetSection(input.mediaAssets),
|
|
238
|
+
`TARGET DURATION: ~${Math.round(budget.target / 1000)}s total (stay within ${Math.round(budget.min / 1000)}–${Math.round(budget.max / 1000)}s), about ${budget.sceneHint}.`,
|
|
239
|
+
`Now author the DemoSpec. Set productName="${input.productName}". Fill brand.colors from the brand block above. Choose freely only for wizard axes marked "decide for me".`,
|
|
240
|
+
]
|
|
241
|
+
.filter((s) => s && s.trim().length > 0)
|
|
242
|
+
.join("\n\n");
|
|
243
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { IDemoVideoWizardAnswers } from "../../models/demoVideoProject.model";
|
|
2
|
+
export interface DemoCreditEstimate {
|
|
3
|
+
amountUsd: number;
|
|
4
|
+
amountCredits: number;
|
|
5
|
+
breakdown: Record<string, number>;
|
|
6
|
+
/** Total seconds of output the estimate assumed (from the target-length budget). */
|
|
7
|
+
assumedSeconds: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Estimate the credits to charge upfront for a demo-video job.
|
|
11
|
+
*
|
|
12
|
+
* `withVoiceover` toggles the Phase-5 script+TTS+final-render legs. Phase 4
|
|
13
|
+
* ships draft-only (silent) so the default is false — the draft IS the
|
|
14
|
+
* deliverable until the audio pipeline lands.
|
|
15
|
+
*/
|
|
16
|
+
export declare function estimateDemoVideoCredits(args: {
|
|
17
|
+
answers?: IDemoVideoWizardAnswers;
|
|
18
|
+
withVoiceover?: boolean;
|
|
19
|
+
}): DemoCreditEstimate;
|
|
20
|
+
//# sourceMappingURL=demoCredits.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"demoCredits.d.ts","sourceRoot":"","sources":["../../../src/services/demo/demoCredits.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,MAAM,qCAAqC,CAAC;AA4B9E,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,oFAAoF;IACpF,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE;IAC7C,OAAO,CAAC,EAAE,uBAAuB,CAAC;IAClC,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,GAAG,kBAAkB,CAqBrB"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.estimateDemoVideoCredits = estimateDemoVideoCredits;
|
|
4
|
+
const creditUnit_1 = require("../../globals/creditUnit");
|
|
5
|
+
const demoAuthorPrompt_1 = require("./demoAuthorPrompt");
|
|
6
|
+
/**
|
|
7
|
+
* Cost model for the SaaS Demo Video generator (plan §8).
|
|
8
|
+
*
|
|
9
|
+
* Components: LLM planning (one structured+vision call) + server-side Remotion
|
|
10
|
+
* render compute (dominant, scales with output length) + — once Phase 5 lands —
|
|
11
|
+
* a script LLM pass + ElevenLabs TTS + a second (final) render.
|
|
12
|
+
*
|
|
13
|
+
* Rates are conservative upper bounds used to DEDUCT upfront (deduct-first, like
|
|
14
|
+
* image/video). The worker reconciles to actual at finalize (refunds the unused
|
|
15
|
+
* remainder). USD → credits via the single CREDITS_PER_USD source of truth.
|
|
16
|
+
*/
|
|
17
|
+
// One Claude structured-output call with a few screenshots as vision.
|
|
18
|
+
const PLANNING_USD = 0.04;
|
|
19
|
+
// Our Remotion render compute, per second of OUTPUT video. Draft = half-res
|
|
20
|
+
// (cheaper); standard/final = full-res. Coarse — we own the box, but credits
|
|
21
|
+
// must track the compute value delivered.
|
|
22
|
+
const RENDER_USD_PER_SEC_DRAFT = 0.01;
|
|
23
|
+
const RENDER_USD_PER_SEC_FINAL = 0.02;
|
|
24
|
+
// Script LLM pass (Phase 5) — small structured call over the known timeline.
|
|
25
|
+
const SCRIPT_USD = 0.02;
|
|
26
|
+
// ElevenLabs TTS (Phase 5). ~14 chars/sec of speech is a safe upper bound.
|
|
27
|
+
const TTS_USD_PER_1K_CHARS = 0.18;
|
|
28
|
+
const TTS_CHARS_PER_SEC = 14;
|
|
29
|
+
/**
|
|
30
|
+
* Estimate the credits to charge upfront for a demo-video job.
|
|
31
|
+
*
|
|
32
|
+
* `withVoiceover` toggles the Phase-5 script+TTS+final-render legs. Phase 4
|
|
33
|
+
* ships draft-only (silent) so the default is false — the draft IS the
|
|
34
|
+
* deliverable until the audio pipeline lands.
|
|
35
|
+
*/
|
|
36
|
+
function estimateDemoVideoCredits(args) {
|
|
37
|
+
const seconds = Math.round((0, demoAuthorPrompt_1.budgetForLength)(args.answers?.targetLength).target / 1000);
|
|
38
|
+
const withVo = args.withVoiceover ?? false;
|
|
39
|
+
const breakdown = {
|
|
40
|
+
planning: PLANNING_USD,
|
|
41
|
+
renderDraft: seconds * RENDER_USD_PER_SEC_DRAFT,
|
|
42
|
+
};
|
|
43
|
+
if (withVo) {
|
|
44
|
+
breakdown.script = SCRIPT_USD;
|
|
45
|
+
breakdown.voiceover = ((seconds * TTS_CHARS_PER_SEC) / 1000) * TTS_USD_PER_1K_CHARS;
|
|
46
|
+
breakdown.renderFinal = seconds * RENDER_USD_PER_SEC_FINAL;
|
|
47
|
+
}
|
|
48
|
+
const amountUsd = Object.values(breakdown).reduce((s, v) => s + v, 0);
|
|
49
|
+
const amountCredits = Math.max(creditUnit_1.MIN_CREDITS_PER_PAID_OP, Math.ceil(amountUsd * creditUnit_1.CREDITS_PER_USD));
|
|
50
|
+
return { amountUsd, amountCredits, breakdown, assumedSeconds: seconds };
|
|
51
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { DemoSpec } from "../../schemas/demoSpec.schema";
|
|
2
|
+
export type DroppedMediaKind = "image" | "video";
|
|
3
|
+
export interface DroppedMedia {
|
|
4
|
+
sceneId: string;
|
|
5
|
+
sceneType: string;
|
|
6
|
+
field: string;
|
|
7
|
+
url: string;
|
|
8
|
+
kind: DroppedMediaKind;
|
|
9
|
+
reason: string;
|
|
10
|
+
}
|
|
11
|
+
export interface MediaSanitizeResult {
|
|
12
|
+
/** A deep-cloned spec with all unreachable media fields removed. */
|
|
13
|
+
spec: DemoSpec;
|
|
14
|
+
/** Everything that was dropped, for observability/logging. */
|
|
15
|
+
dropped: DroppedMedia[];
|
|
16
|
+
}
|
|
17
|
+
export interface MediaGuardOptions {
|
|
18
|
+
/** Per-request timeout (ms). Default 8000. */
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
/** Injectable fetch (tests). Defaults to global `fetch`. */
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Preflight a single media URL: is it reachable AND does it look like the
|
|
25
|
+
* expected kind (image/video), i.e. NOT an HTML/XML error page and not the wrong
|
|
26
|
+
* media type. Exported so other stages (e.g. website logo extraction) reuse the
|
|
27
|
+
* exact same "is this a real image/video URL" test instead of re-implementing it
|
|
28
|
+
* — one source of truth for reachability. Non-http(s) URLs (data:/blob:) are
|
|
29
|
+
* trusted (`{ ok: true }`).
|
|
30
|
+
*/
|
|
31
|
+
export declare function checkMediaReachable(url: string, kind: DroppedMediaKind, options?: MediaGuardOptions): Promise<{
|
|
32
|
+
ok: true;
|
|
33
|
+
} | {
|
|
34
|
+
ok: false;
|
|
35
|
+
reason: string;
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* Preflight every media URL in `spec` and return a cloned spec with the
|
|
39
|
+
* unreachable ones removed. Reachability of all URLs is checked concurrently;
|
|
40
|
+
* order of `dropped` follows scene/field order for stable logs.
|
|
41
|
+
*/
|
|
42
|
+
export declare function sanitizeDemoSpecMedia(spec: DemoSpec, options?: MediaGuardOptions): Promise<MediaSanitizeResult>;
|
|
43
|
+
//# sourceMappingURL=demoMediaGuard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"demoMediaGuard.d.ts","sourceRoot":"","sources":["../../../src/services/demo/demoMediaGuard.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,QAAQ,EAAE,MAAM,+BAA+B,CAAC;AA+BpE,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,OAAO,CAAC;AAEjD,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,gBAAgB,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,oEAAoE;IACpE,IAAI,EAAE,QAAQ,CAAC;IACf,8DAA8D;IAC9D,OAAO,EAAE,YAAY,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,8CAA8C;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AASD;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,gBAAgB,EACtB,OAAO,GAAE,iBAAsB,GAC9B,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAMvD;AAmDD;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,QAAQ,EACd,OAAO,GAAE,iBAAsB,GAC9B,OAAO,CAAC,mBAAmB,CAAC,CAyG9B"}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkMediaReachable = checkMediaReachable;
|
|
4
|
+
exports.sanitizeDemoSpecMedia = sanitizeDemoSpecMedia;
|
|
5
|
+
/**
|
|
6
|
+
* Media reachability guard for the SaaS Demo Video pipeline.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS — Remotion's `<Video>` (render path) loads a clip's duration
|
|
9
|
+
* behind a `delayRender()` handle that only clears on the media element's
|
|
10
|
+
* `loadedmetadata` event. A broken/expired/blocked video URL never fires that
|
|
11
|
+
* event, and an in-component `onError` re-render is NOT flushed while
|
|
12
|
+
* `renderStill`/`renderMedia` is blocked waiting on the handle — so the handle
|
|
13
|
+
* times out and the ENTIRE render fails (verified against remotion@4.0.236; the
|
|
14
|
+
* `deviceMockup-broken` / `splitFeature-broken` stress fixtures reproduce it).
|
|
15
|
+
* Unlike `<Img>` (whose `onError` degrades to a gradient), a 404 video cannot be
|
|
16
|
+
* salvaged inside the render — there is simply no frame to draw.
|
|
17
|
+
*
|
|
18
|
+
* The correct-by-construction fix is therefore UPSTREAM: preflight every media
|
|
19
|
+
* URL in a spec and DROP the ones that don't resolve to real media BEFORE the
|
|
20
|
+
* spec reaches the renderer. The templates already degrade a missing
|
|
21
|
+
* screenshot/video to their neutral coded surface, so a dropped URL renders
|
|
22
|
+
* cleanly instead of crashing. (The in-template `onError` paths remain as
|
|
23
|
+
* defense-in-depth — they cover the Player/preview and image assets.)
|
|
24
|
+
*
|
|
25
|
+
* Node-only (uses global `fetch`); called by the demo worker before render and
|
|
26
|
+
* by the template stress harness so both exercise the same sanitization.
|
|
27
|
+
*/
|
|
28
|
+
/** Per-scene media fields we validate, keyed by scene type at call sites. */
|
|
29
|
+
const IMAGE_SINGLE_FIELDS = ["screenshotUrl", "imageUrl"];
|
|
30
|
+
const IMAGE_ARRAY_FIELDS = ["screenshotUrls"];
|
|
31
|
+
const VIDEO_FIELDS = ["videoUrl"];
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
33
|
+
/** Only http(s) URLs are network-checked; data:/blob:/empty are trusted as-is. */
|
|
34
|
+
function isCheckableUrl(url) {
|
|
35
|
+
return typeof url === "string" && /^https?:\/\//i.test(url.trim());
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Preflight a single media URL: is it reachable AND does it look like the
|
|
39
|
+
* expected kind (image/video), i.e. NOT an HTML/XML error page and not the wrong
|
|
40
|
+
* media type. Exported so other stages (e.g. website logo extraction) reuse the
|
|
41
|
+
* exact same "is this a real image/video URL" test instead of re-implementing it
|
|
42
|
+
* — one source of truth for reachability. Non-http(s) URLs (data:/blob:) are
|
|
43
|
+
* trusted (`{ ok: true }`).
|
|
44
|
+
*/
|
|
45
|
+
async function checkMediaReachable(url, kind, options = {}) {
|
|
46
|
+
if (!isCheckableUrl(url))
|
|
47
|
+
return { ok: true };
|
|
48
|
+
return reachable(url, kind, {
|
|
49
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
50
|
+
fetchImpl: options.fetchImpl ?? fetch,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async function reachable(url, kind, opts) {
|
|
54
|
+
const { timeoutMs, fetchImpl } = opts;
|
|
55
|
+
const doFetch = async (method) => {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
58
|
+
try {
|
|
59
|
+
return await fetchImpl(url, {
|
|
60
|
+
method,
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
// A 1-byte range keeps the GET fallback cheap (servers that reject HEAD).
|
|
63
|
+
...(method === "GET" ? { headers: { Range: "bytes=0-0" } } : {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
let res = await doFetch("HEAD");
|
|
72
|
+
// Some hosts don't implement HEAD — retry with a tiny ranged GET.
|
|
73
|
+
if (res.status === 405 || res.status === 501)
|
|
74
|
+
res = await doFetch("GET");
|
|
75
|
+
if (res.status < 200 || res.status >= 300) {
|
|
76
|
+
return { ok: false, reason: `status ${res.status}` };
|
|
77
|
+
}
|
|
78
|
+
const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
|
|
79
|
+
// Empty content-type (some CDNs on HEAD) is accepted — we only REJECT a
|
|
80
|
+
// positively-wrong type (e.g. an XML/HTML error page served as a screenshot,
|
|
81
|
+
// the exact 403-page-as-media defect we shipped).
|
|
82
|
+
if (contentType) {
|
|
83
|
+
const looksLikeError = /(text\/html|application\/xml|text\/xml)/.test(contentType);
|
|
84
|
+
if (looksLikeError)
|
|
85
|
+
return { ok: false, reason: `content-type ${contentType}` };
|
|
86
|
+
if (kind === "image" && contentType.startsWith("video/")) {
|
|
87
|
+
return { ok: false, reason: `content-type ${contentType} (expected image)` };
|
|
88
|
+
}
|
|
89
|
+
if (kind === "video" && contentType.startsWith("image/")) {
|
|
90
|
+
return { ok: false, reason: `content-type ${contentType} (expected video)` };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { ok: true };
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
return { ok: false, reason: e.message || "fetch failed" };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Preflight every media URL in `spec` and return a cloned spec with the
|
|
101
|
+
* unreachable ones removed. Reachability of all URLs is checked concurrently;
|
|
102
|
+
* order of `dropped` follows scene/field order for stable logs.
|
|
103
|
+
*/
|
|
104
|
+
async function sanitizeDemoSpecMedia(spec, options = {}) {
|
|
105
|
+
const opts = {
|
|
106
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
107
|
+
fetchImpl: options.fetchImpl ?? fetch,
|
|
108
|
+
};
|
|
109
|
+
const refs = [];
|
|
110
|
+
for (const scene of spec.scenes) {
|
|
111
|
+
const s = scene;
|
|
112
|
+
for (const field of IMAGE_SINGLE_FIELDS) {
|
|
113
|
+
if (isCheckableUrl(s[field]))
|
|
114
|
+
refs.push({ scene, field, url: s[field], kind: "image" });
|
|
115
|
+
}
|
|
116
|
+
for (const field of VIDEO_FIELDS) {
|
|
117
|
+
if (isCheckableUrl(s[field]))
|
|
118
|
+
refs.push({ scene, field, url: s[field], kind: "video" });
|
|
119
|
+
}
|
|
120
|
+
for (const field of IMAGE_ARRAY_FIELDS) {
|
|
121
|
+
const arr = s[field];
|
|
122
|
+
if (Array.isArray(arr)) {
|
|
123
|
+
arr.forEach((u, index) => {
|
|
124
|
+
if (isCheckableUrl(u))
|
|
125
|
+
refs.push({ scene, field, url: u, index, kind: "image" });
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// The brand logo (`brand.identity.logoUrl`) is rendered by <LogoRevealScene>'s
|
|
131
|
+
// <Img> — a bad one would fail that render exactly like a bad screenshot. It's
|
|
132
|
+
// not a scene field, so preflight it here too (any source: user/GitHub/website)
|
|
133
|
+
// and strip it from the cloned spec if it doesn't resolve to a real image.
|
|
134
|
+
const brandLogoUrl = spec.brand?.identity?.logoUrl;
|
|
135
|
+
const checkBrandLogo = isCheckableUrl(brandLogoUrl);
|
|
136
|
+
const [checks, brandLogoCheck] = await Promise.all([
|
|
137
|
+
Promise.all(refs.map((r) => reachable(r.url, r.kind, opts))),
|
|
138
|
+
checkBrandLogo
|
|
139
|
+
? reachable(brandLogoUrl, "image", opts)
|
|
140
|
+
: Promise.resolve({ ok: true }),
|
|
141
|
+
]);
|
|
142
|
+
// Build the set of (sceneId, field, index?) references to drop.
|
|
143
|
+
const dropped = [];
|
|
144
|
+
const dropBrandLogo = checkBrandLogo && !brandLogoCheck.ok;
|
|
145
|
+
if (dropBrandLogo && !brandLogoCheck.ok) {
|
|
146
|
+
dropped.push({
|
|
147
|
+
sceneId: "__brand__",
|
|
148
|
+
sceneType: "brand",
|
|
149
|
+
field: "identity.logoUrl",
|
|
150
|
+
url: brandLogoUrl,
|
|
151
|
+
kind: "image",
|
|
152
|
+
reason: brandLogoCheck.reason,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const dropSingle = new Set(); // key: `${sceneId}|${field}`
|
|
156
|
+
const dropArrayItem = new Set(); // key: `${sceneId}|${field}|${index}`
|
|
157
|
+
refs.forEach((r, i) => {
|
|
158
|
+
const check = checks[i];
|
|
159
|
+
if (check.ok)
|
|
160
|
+
return;
|
|
161
|
+
dropped.push({
|
|
162
|
+
sceneId: r.scene.id,
|
|
163
|
+
sceneType: r.scene.type,
|
|
164
|
+
field: r.field,
|
|
165
|
+
url: r.url,
|
|
166
|
+
kind: r.kind,
|
|
167
|
+
reason: check.reason,
|
|
168
|
+
});
|
|
169
|
+
if (r.index === undefined)
|
|
170
|
+
dropSingle.add(`${r.scene.id}|${r.field}`);
|
|
171
|
+
else
|
|
172
|
+
dropArrayItem.add(`${r.scene.id}|${r.field}|${r.index}`);
|
|
173
|
+
});
|
|
174
|
+
if (dropped.length === 0)
|
|
175
|
+
return { spec, dropped };
|
|
176
|
+
// Clone + strip. Structured deep clone keeps the spec immutable for the caller.
|
|
177
|
+
const cleanedScenes = spec.scenes.map((scene) => {
|
|
178
|
+
const clone = { ...scene };
|
|
179
|
+
for (const field of [...IMAGE_SINGLE_FIELDS, ...VIDEO_FIELDS]) {
|
|
180
|
+
if (dropSingle.has(`${scene.id}|${field}`))
|
|
181
|
+
delete clone[field];
|
|
182
|
+
}
|
|
183
|
+
for (const field of IMAGE_ARRAY_FIELDS) {
|
|
184
|
+
const arr = clone[field];
|
|
185
|
+
if (Array.isArray(arr)) {
|
|
186
|
+
clone[field] = arr.filter((_, index) => !dropArrayItem.has(`${scene.id}|${field}|${index}`));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return clone;
|
|
190
|
+
});
|
|
191
|
+
// Strip an unreachable brand logo so <LogoRevealScene> falls back to its
|
|
192
|
+
// wordmark instead of throwing on a dead <Img>.
|
|
193
|
+
const cleanedBrand = dropBrandLogo
|
|
194
|
+
? {
|
|
195
|
+
...spec.brand,
|
|
196
|
+
identity: (() => {
|
|
197
|
+
const id = { ...(spec.brand?.identity ?? {}) };
|
|
198
|
+
delete id.logoUrl;
|
|
199
|
+
return id;
|
|
200
|
+
})(),
|
|
201
|
+
}
|
|
202
|
+
: spec.brand;
|
|
203
|
+
return {
|
|
204
|
+
spec: { ...spec, brand: cleanedBrand, scenes: cleanedScenes },
|
|
205
|
+
dropped,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DemoSpec } from "../../schemas/demoSpec.schema";
|
|
2
|
+
export interface DemoMusicSelection {
|
|
3
|
+
url: string;
|
|
4
|
+
attribution?: string;
|
|
5
|
+
mood?: string;
|
|
6
|
+
bpm?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Pick a reachable background-music bed for a demo. Returns null (never throws)
|
|
10
|
+
* when nothing is reachable — the caller ships the video without a bed.
|
|
11
|
+
*/
|
|
12
|
+
export declare function selectDemoMusic(spec: DemoSpec): Promise<DemoMusicSelection | null>;
|
|
13
|
+
//# sourceMappingURL=demoMusic.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"demoMusic.d.ts","sourceRoot":"","sources":["../../../src/services/demo/demoMusic.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,+BAA+B,CAAC;AAsBzD,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAmDD;;;GAGG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAmBxF"}
|