diff --git a/marketing/product-film/scripts/capture-video.mjs b/marketing/product-film/scripts/capture-video.mjs index 64c183d..75eac2b 100644 --- a/marketing/product-film/scripts/capture-video.mjs +++ b/marketing/product-film/scripts/capture-video.mjs @@ -34,6 +34,7 @@ async function main() { // Order: public scenes first (no login), then group by role to log in once each. const order = [...clips].sort((a, b) => roleRank(a.role) - roleRank(b.role)); + const failures = []; for (const clip of order) { const scene = await loadScene(clip.id); if (!scene) { @@ -42,51 +43,62 @@ async function main() { } // Fresh per-clip context so each recording is its own .webm. const tmpVideoDir = join(outDir, `_rec_${clip.id}`); - await rm(tmpVideoDir, { recursive: true, force: true }); - const context = await browser.newContext({ - viewport: VIEWPORT, - deviceScaleFactor: 1, // recordVideo records CSS px; keep 1 for exact 1080p - recordVideo: { dir: tmpVideoDir, size: VIEWPORT }, - }); - const tCtx = Date.now(); // recording starts at context creation; measure the lead-in - await context.addInitScript("try{localStorage.setItem('theme','dark')}catch(e){}"); - await context.addInitScript(CURSOR_INIT_SCRIPT); - const page = await context.newPage(); - const cursor = makeCursor(page); + let context = null; + try { + await rm(tmpVideoDir, { recursive: true, force: true }); + context = await browser.newContext({ + viewport: VIEWPORT, + deviceScaleFactor: 1, // recordVideo records CSS px; keep 1 for exact 1080p + recordVideo: { dir: tmpVideoDir, size: VIEWPORT }, + }); + const tCtx = Date.now(); // recording starts at context creation; measure the lead-in + await context.addInitScript("try{localStorage.setItem('theme','dark')}catch(e){}"); + await context.addInitScript(CURSOR_INIT_SCRIPT); + const page = await context.newPage(); + const cursor = makeCursor(page); - if (clip.role !== "public") { - await login(page, BASE_URL, clip.role); - await spaNavigate(page, clip.route); - } else { - await page.goto(`${BASE_URL}${clip.route}`, { waitUntil: "networkidle" }); + if (clip.role !== "public") { + await login(page, BASE_URL, clip.role); + await spaNavigate(page, clip.route); + } else { + await page.goto(`${BASE_URL}${clip.route}`, { waitUntil: "networkidle" }); + } + await page.waitForTimeout(800); + + const leadInSec = Math.max(0, (Date.now() - tCtx) / 1000 - 0.3); // trim login+nav, keep 0.3s pad + console.log(`▶ recording ${clip.id} (${clip.role}) ... (trimming ${leadInSec.toFixed(1)}s lead-in)`); + await scene(page, cursor, { + baseUrl: BASE_URL, + spaNavigate: (route) => spaNavigate(page, route), + login: (role) => login(page, BASE_URL, role), + role: clip.role, + waitSettle: (ms = 1500) => page.waitForTimeout(ms), + }); + + await context.close(); // flush the .webm + context = null; + const webm = await newestWebm(tmpVideoDir); + const mp4 = join(outDir, `${clip.id}.mp4`); + await webmToMp4(webm, mp4, FPS, leadInSec); + await rm(tmpVideoDir, { recursive: true, force: true }); + + const seconds = await probeSeconds(mp4); + clip.durationFrames = secondsToFrames(seconds, FPS); + console.log(`✓ ${clip.id} → ${mp4} (${seconds.toFixed(2)}s = ${clip.durationFrames}f)`); + } catch (e) { + // One bad scene must not abort the whole run or lose the manifest. + failures.push({ id: clip.id, error: e.message }); + console.error(`✗ ${clip.id} FAILED: ${e.message}`); + try { if (context) await context.close(); } catch {} + await rm(tmpVideoDir, { recursive: true, force: true }).catch(() => {}); } - await page.waitForTimeout(800); - - const leadInSec = Math.max(0, (Date.now() - tCtx) / 1000 - 0.3); // trim login+nav, keep 0.3s pad - console.log(`▶ recording ${clip.id} (${clip.role}) ... (trimming ${leadInSec.toFixed(1)}s lead-in)`); - await scene(page, cursor, { - baseUrl: BASE_URL, - spaNavigate, - login: (role) => login(page, BASE_URL, role), - role: clip.role, - waitSettle: (ms = 1500) => page.waitForTimeout(ms), - }); - - await context.close(); // flush the .webm - const webm = await newestWebm(tmpVideoDir); - const mp4 = join(outDir, `${clip.id}.mp4`); - await webmToMp4(webm, mp4, FPS, leadInSec); - await rm(tmpVideoDir, { recursive: true, force: true }); - - const seconds = await probeSeconds(mp4); - clip.durationFrames = secondsToFrames(seconds, FPS); - console.log(`✓ ${clip.id} → ${mp4} (${seconds.toFixed(2)}s = ${clip.durationFrames}f)`); } await browser.close(); - // Persist updated durations. + // Persist updated durations (for every clip that succeeded). await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n"); - console.log(`\nDone. Captured ${clips.length} clip(s); manifest durations updated.`); + console.log(`\nDone. ${clips.length - failures.length}/${clips.length} clip(s) captured; manifest updated.`); + if (failures.length) console.log(`Failed: ${failures.map((f) => f.id).join(", ")}`); } function roleRank(role) { diff --git a/marketing/product-film/scripts/lib/cursor.js b/marketing/product-film/scripts/lib/cursor.js index 0b376ab..4c3055a 100644 --- a/marketing/product-film/scripts/lib/cursor.js +++ b/marketing/product-film/scripts/lib/cursor.js @@ -64,8 +64,10 @@ export const CURSOR_INIT_SCRIPT = ` export function makeCursor(page) { let pos = { x: 960, y: 540 }; const apply = async (x, y) => { - await page.evaluate(([px, py]) => window.__cursorMove?.(px, py), [x, y]); - await page.mouse.move(x, y); + // Tolerate a navigation/re-render landing mid-move: a destroyed execution context + // must never throw out of the scene (this previously aborted whole captures). + await page.evaluate(([px, py]) => window.__cursorMove?.(px, py), [x, y]).catch(() => {}); + await page.mouse.move(x, y).catch(() => {}); }; return { async moveTo(x, y, ms = 600) { @@ -78,8 +80,8 @@ export function makeCursor(page) { }, async click(x, y, ms = 600) { if (x != null) await this.moveTo(x, y, ms); - await page.evaluate(([px, py]) => window.__cursorPulse?.(px, py), [pos.x, pos.y]); - await page.mouse.click(pos.x, pos.y); + await page.evaluate(([px, py]) => window.__cursorPulse?.(px, py), [pos.x, pos.y]).catch(() => {}); + await page.mouse.click(pos.x, pos.y).catch(() => {}); }, async clickLocator(locator, ms = 600) { const box = await locator.boundingBox(); diff --git a/marketing/product-film/scripts/scenes/crane-close.mjs b/marketing/product-film/scripts/scenes/crane-close.mjs index 5146d77..6baa2d3 100644 --- a/marketing/product-film/scripts/scenes/crane-close.mjs +++ b/marketing/product-film/scripts/scenes/crane-close.mjs @@ -1,58 +1,51 @@ // crane-close.mjs — PUBLIC, route "/" (~22s). The swinging-crane CTA closer. -// Scroll to the end "Ready to Build?" CTA card (it hangs from a blueprint crane and -// swings like a pendulum, class .crane-swing), let the swing settle, then ease the -// cursor onto the "Schedule Demo" CTA and HOLD while it swings. Do NOT navigate away. +// Show the FULL tower crane first (apex / jib / mast — the blueprint SVG), THEN pan DOWN +// to the "Ready to Build?" card that hangs from it and swings (.crane-swing). Hover the +// CTA and hold; never click (must not navigate to /login). export default async (page, cursor, ctx) => { const dwell = (ms) => page.waitForTimeout(ms); await page.waitForLoadState("networkidle").catch(() => {}); - await dwell(1000); + await dwell(800); - // 1) Scroll to the very end (the crane CTA sits just before the footer). - await page - .evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" })) - .catch(() => {}); - // Wait for the smooth scroll + swing animation to begin/settle. - await dwell(2500); - - // 2) Wait for the swinging CTA card to be visible; scroll it into view to be safe. - // The card carries the unique "Ready to Build?" headline. + // 1) Bring the crane SECTION's TOP into frame so the apex + jib + mast are visible + // (scrolling to document bottom previously cut the crane off, showing only the card). + const craneSvg = page.locator('svg[viewBox="0 0 800 460"]').first(); const craneCard = page.locator(".crane-swing").first(); - const heading = page.getByRole("heading", { name: /ready to build/i }).first(); - const anchor = (await craneCard.count()) ? craneCard : heading; + const anchor = (await craneSvg.count()) ? craneSvg : craneCard; if (await anchor.count()) { - await anchor.scrollIntoViewIfNeeded().catch(() => {}); - await anchor.waitFor({ state: "visible", timeout: 12000 }).catch(() => {}); + await anchor + .evaluate((el) => el.scrollIntoView({ behavior: "smooth", block: "start" })) + .catch(() => {}); + } else { + await page + .evaluate(() => window.scrollTo({ top: document.body.scrollHeight * 0.8, behavior: "smooth" })) + .catch(() => {}); } - await dwell(2500); // hold while the pendulum swings + await dwell(3200); // hold on the full crane (apex / counter-jib / jib / lattice mast) - // 3) Ease the cursor onto the CTA button ("Schedule Demo") inside the swinging card - // and hold — hover only, never click (we must not navigate to /login). - const cta = page - .getByRole("link", { name: /schedule demo/i }) - .last(); // the crane card's CTA is the last on the page (hero has the first) + // 2) Pan DOWN to reveal the card swinging on its cable beneath the jib. + await page.evaluate(() => window.scrollBy({ top: 340, behavior: "smooth" })).catch(() => {}); + await dwell(4000); // hold while the card swings like a pendulum + + // 3) Ease the cursor onto the card's CTA and hold — HOVER ONLY, never click. + const cta = page.getByRole("link", { name: /schedule demo/i }).last(); if (await cta.count()) { - const box = await cta.boundingBox().catch(() => null); - if (box) { - await cursor.moveTo(box.x + box.width / 2, box.y + box.height / 2, 1000); + const b = await cta.boundingBox().catch(() => null); + if (b) { + await cursor.moveTo(b.x + b.width / 2, b.y + b.height / 2, 1000); + await dwell(2500); } - } else if (await heading.count()) { - // Fallback: drift onto the headline region of the card. - const hb = await heading.boundingBox().catch(() => null); - if (hb) await cursor.moveTo(hb.x + hb.width / 2, hb.y + hb.height / 2, 1000); } - await dwell(3500); - - // 4) Small secondary drift to the "Start Free Trial" CTA to show both, still no click. - const cta2 = page.getByRole("link", { name: /start free trial/i }).first(); + const cta2 = page.getByRole("link", { name: /start free trial/i }).last(); if (await cta2.count()) { const b2 = await cta2.boundingBox().catch(() => null); if (b2) { await cursor.moveTo(b2.x + b2.width / 2, b2.y + b2.height / 2, 900); - await dwell(2500); + await dwell(2000); } } - // 5) Final hold on the swinging card — the closing brand beat. + // 4) Final hold on the full crane + swinging card — the closing brand beat. await dwell(3000); }; diff --git a/marketing/product-film/scripts/scenes/territory-map.mjs b/marketing/product-film/scripts/scenes/territory-map.mjs index caeddf1..b6368f2 100644 --- a/marketing/product-film/scripts/scenes/territory-map.mjs +++ b/marketing/product-film/scripts/scenes/territory-map.mjs @@ -1,15 +1,11 @@ -// territory-map.mjs — OWNER, route "/owner/maps" (~55s, HERO). -// Full territory walkthrough on the Leaflet map: -// load map+tiles → click an EMPTY spot to drop a new property pin (reverse-geocode → -// detail drawer opens in create/edit mode) → step through the drawer (set Canvassing -// Status — the agent/canvasser-style assignment control) → then click an EXISTING -// parcel polygon to reveal its populated detail panel. End on a populated drawer. -// Everything is guarded so a missing element never crashes the scene. +// territory-map.mjs — OWNER, route "/owner/maps" (~60s, HERO). +// LEAD with an EXISTING HOT LEAD: click its red parcel to reveal the fully-populated +// property detail (view mode, all info) — the data-rich payoff. THEN show creating a NEW +// entry by dropping a pin on empty map (reverse-geocode → set Hot Lead). All guarded. export default async (page, cursor, ctx) => { const dwell = (ms) => page.waitForTimeout(ms); - // 1) Wait for the map. The page shows a full-screen "Loading Territory Map..." loader - // (~1s) before the Leaflet container mounts. + // 1) Wait for the map (full-screen "Loading Territory Map..." loader, then Leaflet mounts). await page .getByText(/loading territory map/i) .first() @@ -17,90 +13,72 @@ export default async (page, cursor, ctx) => { .catch(() => {}); const map = page.locator(".leaflet-container").first(); await map.waitFor({ state: "visible", timeout: 20000 }); - // Let the OSM tiles settle. await page.waitForLoadState("networkidle").catch(() => {}); await dwell(2000); // open on the loaded territory map - const box = await map.boundingBox(); - if (!box) { - await dwell(3000); - return; - } + // 2) Click an EXISTING HOT LEAD parcel. Leaflet renders each property Polygon as an SVG + // path; Hot Leads are stroked red (#ef4444). Clicking fires onSelect → the + // PropertyDetailDrawer opens in VIEW mode populated with that lead's full data. + let hot = page.locator('.leaflet-overlay-pane path[stroke="#ef4444"]').first(); + if (!(await hot.count())) hot = page.locator(".leaflet-interactive").first(); // fallback: any parcel + if (await hot.count()) { + await cursor.clickLocator(hot, 900); + const drawer = page.getByRole("dialog", { name: /property details/i }).first(); + await drawer.waitFor({ state: "visible", timeout: 12000 }).catch(() => {}); + await dwell(3500); // hold on the fully-populated hot-lead detail (owner, value, status…) - // 2) Drop a new property pin on an EMPTY patch of map. Clicking empty map triggers a - // reverse-geocode (Nominatim) then opens the PropertyDetailDrawer in create mode. - // Pick a spot offset from existing parcels (upper-left quadrant). - const drop = { x: box.x + box.width * 0.32, y: box.y + box.height * 0.30 }; - const waitGeocode = page - .waitForResponse((r) => /nominatim\.openstreetmap\.org\/reverse/.test(r.url()), { timeout: 20000 }) - .catch(() => null); - await cursor.click(drop.x, drop.y, 900); - await waitGeocode; + // Reveal the "Edit Details" affordance on the populated panel. + const edit = drawer.getByRole("button", { name: /edit details/i }).first(); + if (await edit.count()) { + const eb = await edit.boundingBox().catch(() => null); + if (eb) { + await cursor.moveTo(eb.x + eb.width / 2, eb.y + eb.height / 2, 900); + await dwell(2500); + } + } - // Drawer opens (role=dialog "Property Details"). Wait for the address to resolve - // ("Fetching Address..."/"Resolving Location..." → real content). - const drawer = page.getByRole("dialog", { name: /property details/i }).first(); - await drawer.waitFor({ state: "visible", timeout: 12000 }).catch(() => {}); - await page - .getByText(/resolving location|fetching address/i) - .first() - .waitFor({ state: "hidden", timeout: 15000 }) - .catch(() => {}); - await dwell(2800); // hold on the new-entry drawer - - // 3) Set a Canvassing Status — this is the assignment-style control for a new lead. - // In create mode the drawer opens already editing, so the status pills are present. - const hotLead = drawer.getByRole("button", { name: /^hot lead$/i }).first(); - if (await hotLead.count()) { - await cursor.clickLocator(hotLead, 800); - await dwell(2500); - } - - // 4) Show the Create Entry action (the populated drawer footer). Just reveal/hover it — - // we don't need to actually persist, the payoff is the rich detail panel. - const createBtn = drawer.getByRole("button", { name: /create entry|save changes/i }).first(); - if (await createBtn.count()) { - const cb = await createBtn.boundingBox().catch(() => null); - if (cb) { - await cursor.moveTo(cb.x + cb.width / 2, cb.y + cb.height / 2, 800); - await dwell(2000); + // Close it before showing the create flow. + const close = drawer.getByRole("button", { name: /close property details/i }).first(); + if (await close.count()) { + await cursor.clickLocator(close, 700); + await dwell(1500); } } - // 5) Close the new-entry drawer, then click an EXISTING parcel polygon to reveal its - // populated detail panel (the data-rich payoff). - const closeBtn = drawer.getByRole("button", { name: /close property details/i }).first(); - if (await closeBtn.count()) { - await cursor.clickLocator(closeBtn, 700); - await dwell(1500); - } - - // Click over a dense central area where colored parcels live. Leaflet polygon clicks - // open the drawer in view mode (with an "Edit Details" footer button). - const parcel = { x: box.x + box.width * 0.55, y: box.y + box.height * 0.55 }; - await cursor.click(parcel.x, parcel.y, 900); - await dwell(1500); - - // If that opened a NEW-entry geocode drawer instead of selecting a parcel, that's still - // a valid populated panel; if it selected a parcel we get "Edit Details". - const drawer2 = page.getByRole("dialog", { name: /property details/i }).first(); - if (await drawer2.count()) { + // 3) Now show CREATING a NEW entry: click an empty patch → reverse-geocode → new drawer. + const box = await map.boundingBox(); + if (box) { + const drop = { x: box.x + box.width * 0.3, y: box.y + box.height * 0.28 }; + const waitGeocode = page + .waitForResponse((r) => /nominatim\.openstreetmap\.org\/reverse/.test(r.url()), { timeout: 20000 }) + .catch(() => null); + await cursor.click(drop.x, drop.y, 900); + await waitGeocode; + const drawer2 = page.getByRole("dialog", { name: /property details/i }).first(); await drawer2.waitFor({ state: "visible", timeout: 12000 }).catch(() => {}); await page .getByText(/resolving location|fetching address/i) .first() - .waitFor({ state: "hidden", timeout: 12000 }) + .waitFor({ state: "hidden", timeout: 15000 }) .catch(() => {}); - await dwell(2500); + await dwell(2500); // hold on the new-entry drawer with the resolved address - // Reveal the edit affordance on the selected parcel, if present. - const editBtn = drawer2.getByRole("button", { name: /edit details/i }).first(); - if (await editBtn.count()) { - await cursor.clickLocator(editBtn, 800); - await dwell(2500); + // Set the new entry's Canvassing Status to Hot Lead. + const hotBtn = drawer2.getByRole("button", { name: /^hot lead$/i }).first(); + if (await hotBtn.count()) { + await cursor.clickLocator(hotBtn, 800); + await dwell(2000); + } + // Reveal the Create Entry action (hover; we don't need to persist). + const createBtn = drawer2.getByRole("button", { name: /create entry|save changes/i }).first(); + if (await createBtn.count()) { + const cb = await createBtn.boundingBox().catch(() => null); + if (cb) { + await cursor.moveTo(cb.x + cb.width / 2, cb.y + cb.height / 2, 800); + await dwell(2000); + } } } - // 6) Final hold on the populated property detail panel. - await dwell(3000); + await dwell(2500); // final hold }; diff --git a/marketing/product-film/src/clips.manifest.json b/marketing/product-film/src/clips.manifest.json index 85a2ba2..317e03f 100644 --- a/marketing/product-film/src/clips.manifest.json +++ b/marketing/product-film/src/clips.manifest.json @@ -9,9 +9,20 @@ "role": "public", "route": "/", "act": "Open", - "durationFrames": 360, - "keyMoments": [{ "frame": 110, "zoom": 1.07 }], - "captions": [{ "fromFrame": 45, "toFrame": 300, "text": "The operating system for modern roofing." }] + "durationFrames": 547, + "keyMoments": [ + { + "frame": 110, + "zoom": 1.07 + } + ], + "captions": [ + { + "fromFrame": 45, + "toFrame": 300, + "text": "The operating system for modern roofing." + } + ] }, { "id": "storm-intel", @@ -20,9 +31,20 @@ "route": "/owner/storm-intel", "act": "Win the work", "pillarTitle": "Storm Intelligence", - "durationFrames": 1650, - "keyMoments": [{ "frame": 495, "zoom": 1.07 }], - "captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Drop a pin and see every hail event in the territory." }] + "durationFrames": 1501, + "keyMoments": [ + { + "frame": 495, + "zoom": 1.07 + } + ], + "captions": [ + { + "fromFrame": 60, + "toFrame": 540, + "text": "Drop a pin and see every hail event in the territory." + } + ] }, { "id": "territory-map", @@ -31,9 +53,20 @@ "route": "/owner/maps", "act": "Win the work", "pillarTitle": "Territory Command", - "durationFrames": 1500, - "keyMoments": [{ "frame": 450, "zoom": 1.06 }], - "captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Map your turf, work the streets that pay." }] + "durationFrames": 1800, + "keyMoments": [ + { + "frame": 450, + "zoom": 1.06 + } + ], + "captions": [ + { + "fromFrame": 60, + "toFrame": 540, + "text": "Map your turf, work the streets that pay." + } + ] }, { "id": "lead-create", @@ -42,9 +75,20 @@ "route": "/emp/fa/leads/new", "act": "Win the work", "pillarTitle": "Lead Creation", - "durationFrames": 1800, - "keyMoments": [{ "frame": 540, "zoom": 1.06 }], - "captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Capture a lead from the doorstep in seconds." }] + "durationFrames": 1512, + "keyMoments": [ + { + "frame": 540, + "zoom": 1.06 + } + ], + "captions": [ + { + "fromFrame": 60, + "toFrame": 540, + "text": "Capture a lead from the doorstep in seconds." + } + ] }, { "id": "lead-verify", @@ -53,8 +97,14 @@ "route": "/lead-verification", "act": "Win the work", "pillarTitle": "Lead Verification", - "durationFrames": 900, - "captions": [{ "fromFrame": 60, "toFrame": 420, "text": "Verify the address before you roll a truck." }] + "durationFrames": 1618, + "captions": [ + { + "fromFrame": 60, + "toFrame": 420, + "text": "Verify the address before you roll a truck." + } + ] }, { "id": "dispatch", @@ -63,9 +113,20 @@ "route": "/owner/dispatch", "act": "Run the operation", "pillarTitle": "LynkDispatch", - "durationFrames": 1650, - "keyMoments": [{ "frame": 495, "zoom": 1.07 }], - "captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Dispatch the right crew to the right roof." }] + "durationFrames": 1769, + "keyMoments": [ + { + "frame": 495, + "zoom": 1.07 + } + ], + "captions": [ + { + "fromFrame": 60, + "toFrame": 540, + "text": "Dispatch the right crew to the right roof." + } + ] }, { "id": "kanban", @@ -74,9 +135,20 @@ "route": "/owner/kanban", "act": "Run the operation", "pillarTitle": "Pipeline in Motion", - "durationFrames": 1500, - "keyMoments": [{ "frame": 450, "zoom": 1.06 }], - "captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Drag every job from lead to paid." }] + "durationFrames": 833, + "keyMoments": [ + { + "frame": 450, + "zoom": 1.06 + } + ], + "captions": [ + { + "fromFrame": 60, + "toFrame": 540, + "text": "Drag every job from lead to paid." + } + ] }, { "id": "sub-tasks", @@ -85,8 +157,14 @@ "route": "/owner/subcontractor-tasks", "act": "Run the operation", "pillarTitle": "Subcontractor Tasks", - "durationFrames": 1050, - "captions": [{ "fromFrame": 60, "toFrame": 480, "text": "Hand off the punch list to your subs." }] + "durationFrames": 700, + "captions": [ + { + "fromFrame": 60, + "toFrame": 480, + "text": "Hand off the punch list to your subs." + } + ] }, { "id": "pro-canvas", @@ -95,9 +173,20 @@ "route": "/owner/pro-canvas", "act": "Estimate & sell", "pillarTitle": "Pro-Canvas", - "durationFrames": 1800, - "keyMoments": [{ "frame": 540, "zoom": 1.07 }], - "captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Build the proposal your homeowner signs on the spot." }] + "durationFrames": 2407, + "keyMoments": [ + { + "frame": 540, + "zoom": 1.07 + } + ], + "captions": [ + { + "fromFrame": 60, + "toFrame": 540, + "text": "Build the proposal your homeowner signs on the spot." + } + ] }, { "id": "estimates", @@ -106,8 +195,14 @@ "route": "/owner/estimates", "act": "Estimate & sell", "pillarTitle": "Estimates", - "durationFrames": 1050, - "captions": [{ "fromFrame": 60, "toFrame": 480, "text": "Price the job with margins that hold." }] + "durationFrames": 932, + "captions": [ + { + "fromFrame": 60, + "toFrame": 480, + "text": "Price the job with margins that hold." + } + ] }, { "id": "owner-snapshot", @@ -116,9 +211,20 @@ "route": "/owner/snapshot", "act": "See the business", "pillarTitle": "Owner Snapshot", - "durationFrames": 1650, - "keyMoments": [{ "frame": 495, "zoom": 1.06 }], - "captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Your whole company on one screen." }] + "durationFrames": 1624, + "keyMoments": [ + { + "frame": 495, + "zoom": 1.06 + } + ], + "captions": [ + { + "fromFrame": 60, + "toFrame": 540, + "text": "Your whole company on one screen." + } + ] }, { "id": "operator-dashboard", @@ -127,8 +233,14 @@ "route": "/emp/fa/dashboard", "act": "See the business", "pillarTitle": "Operator Dashboard", - "durationFrames": 900, - "captions": [{ "fromFrame": 60, "toFrame": 420, "text": "Every rep knows their numbers by 8am." }] + "durationFrames": 1419, + "captions": [ + { + "fromFrame": 60, + "toFrame": 420, + "text": "Every rep knows their numbers by 8am." + } + ] }, { "id": "people-skills", @@ -137,9 +249,20 @@ "route": "/owner/people", "act": "Manage the team", "pillarTitle": "People & Skills", - "durationFrames": 1500, - "keyMoments": [{ "frame": 450, "zoom": 1.06 }], - "captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Track crews, skills and certifications in one place." }] + "durationFrames": 1481, + "keyMoments": [ + { + "frame": 450, + "zoom": 1.06 + } + ], + "captions": [ + { + "fromFrame": 60, + "toFrame": 540, + "text": "Track crews, skills and certifications in one place." + } + ] }, { "id": "project-detail", @@ -148,9 +271,20 @@ "route": "/owner/projects", "act": "Manage the team", "pillarTitle": "Project Details", - "durationFrames": 1650, - "keyMoments": [{ "frame": 495, "zoom": 1.06 }], - "captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Every photo, doc and update on the job." }] + "durationFrames": 1838, + "keyMoments": [ + { + "frame": 495, + "zoom": 1.06 + } + ], + "captions": [ + { + "fromFrame": 60, + "toFrame": 540, + "text": "Every photo, doc and update on the job." + } + ] }, { "id": "documents", @@ -159,8 +293,14 @@ "route": "/owner/documents", "act": "Manage the team", "pillarTitle": "Documents", - "durationFrames": 750, - "captions": [{ "fromFrame": 60, "toFrame": 420, "text": "Contracts and warranties, signed and stored." }] + "durationFrames": 690, + "captions": [ + { + "fromFrame": 60, + "toFrame": 420, + "text": "Contracts and warranties, signed and stored." + } + ] }, { "id": "org-access", @@ -169,9 +309,20 @@ "route": "/owner/settings", "act": "Control & trust", "pillarTitle": "Access Control", - "durationFrames": 1650, - "keyMoments": [{ "frame": 495, "zoom": 1.06 }], - "captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Give every role exactly the access they need." }] + "durationFrames": 1259, + "keyMoments": [ + { + "frame": 495, + "zoom": 1.06 + } + ], + "captions": [ + { + "fromFrame": 60, + "toFrame": 540, + "text": "Give every role exactly the access they need." + } + ] }, { "id": "role-perspective", @@ -180,8 +331,14 @@ "route": "/owner/snapshot", "act": "Control & trust", "pillarTitle": "Role Perspective", - "durationFrames": 1050, - "captions": [{ "fromFrame": 60, "toFrame": 480, "text": "See the app the way your team sees it." }] + "durationFrames": 1789, + "captions": [ + { + "fromFrame": 60, + "toFrame": 480, + "text": "See the app the way your team sees it." + } + ] }, { "id": "ai-assistant", @@ -190,9 +347,20 @@ "route": "/chat-assistant", "act": "Intelligence", "pillarTitle": "AI Assistant", - "durationFrames": 1050, - "keyMoments": [{ "frame": 315, "zoom": 1.07 }], - "captions": [{ "fromFrame": 60, "toFrame": 480, "text": "Ask anything about your business, get an answer." }] + "durationFrames": 585, + "keyMoments": [ + { + "frame": 315, + "zoom": 1.07 + } + ], + "captions": [ + { + "fromFrame": 60, + "toFrame": 480, + "text": "Ask anything about your business, get an answer." + } + ] }, { "id": "breadth-flash", @@ -200,8 +368,14 @@ "role": "owner", "route": "/owner/snapshot", "act": "Close", - "durationFrames": 540, - "captions": [{ "fromFrame": 30, "toFrame": 480, "text": "One platform. Every part of the job." }] + "durationFrames": 1636, + "captions": [ + { + "fromFrame": 30, + "toFrame": 480, + "text": "One platform. Every part of the job." + } + ] }, { "id": "crane-close", @@ -209,9 +383,20 @@ "role": "public", "route": "/", "act": "Close", - "durationFrames": 660, - "keyMoments": [{ "frame": 200, "zoom": 1.08 }], - "captions": [{ "fromFrame": 60, "toFrame": 600, "text": "LynkedUp Pro. Build the roof, run the company." }] + "durationFrames": 714, + "keyMoments": [ + { + "frame": 200, + "zoom": 1.08 + } + ], + "captions": [ + { + "fromFrame": 60, + "toFrame": 600, + "text": "LynkedUp Pro. Build the roof, run the company." + } + ] } ] }