fix(film): capture resilience + scene fixes; finalize 20-clip durations
Orchestrator: bind ctx.spaNavigate to the page (fixes breadth-flash/role-perspective doing nothing) and wrap each clip in try/catch so one bad scene can't abort the run or lose the manifest. Cursor: guard page.evaluate against navigation-mid-move (fixes storm-intel 'execution context destroyed'). territory-map now leads with an existing Hot-Lead parcel (full populated detail); crane-close shows the full crane (mast+cable) then pans to the swinging card. Manifest holds real ffprobe durations for all 20 clips (film = 14m56s).
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user