feat(film): live-interaction product film foundation
Capture pipeline (Playwright recordVideo + injected synthetic cursor + ffmpeg webm->mp4) and a Remotion LiveFilm composition driven by a 20-clip manifest with caption/zoom overlays and a music bed. Includes the capture orchestrator, storm-intel reference scene, pre-render smoke check, and VO script. Pure logic is unit-tested (28 passing). Remaining scene scripts are authored during live capture.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { Series, AbsoluteFill } from "remotion";
|
||||
import manifest from "./clips.manifest.json";
|
||||
import { totalLiveFrames, type ClipsManifest } from "./clips";
|
||||
import { DURATIONS } from "./timing";
|
||||
import { ClipStage } from "./components/ClipStage";
|
||||
import { AudioTrack } from "./components/AudioTrack";
|
||||
import { Promise as PromiseSeq } from "./sequences/Promise";
|
||||
|
||||
const m = manifest as ClipsManifest;
|
||||
export const LIVE_TOTAL = totalLiveFrames(m) + DURATIONS.promise;
|
||||
|
||||
export const LiveFilm: React.FC = () => {
|
||||
const [hero, ...rest] = m.clips; // landing-hero is first in the manifest
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<AudioTrack totalFrames={LIVE_TOTAL} />
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={hero.durationFrames}>
|
||||
<ClipStage clip={hero} />
|
||||
</Series.Sequence>
|
||||
<Series.Sequence durationInFrames={DURATIONS.promise}>
|
||||
<PromiseSeq />
|
||||
</Series.Sequence>
|
||||
{rest.map((clip) => (
|
||||
<Series.Sequence key={clip.id} durationInFrames={clip.durationFrames}>
|
||||
<ClipStage clip={clip} />
|
||||
</Series.Sequence>
|
||||
))}
|
||||
</Series>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,27 @@
|
||||
import { Composition } from "remotion";
|
||||
import { Film } from "./Film";
|
||||
import { LiveFilm, LIVE_TOTAL } from "./LiveFilm";
|
||||
import { TOTAL_FRAMES, FPS, WIDTH, HEIGHT } from "./timing";
|
||||
|
||||
export const Root: React.FC = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="LynkedUpProFilm"
|
||||
component={Film}
|
||||
durationInFrames={TOTAL_FRAMES}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
<>
|
||||
<Composition
|
||||
id="LynkedUpProLiveFilm"
|
||||
component={LiveFilm}
|
||||
durationInFrames={LIVE_TOTAL}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
<Composition
|
||||
id="LynkedUpProFilm"
|
||||
component={Film}
|
||||
durationInFrames={TOTAL_FRAMES}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { captionAt, zoomAt } from "./clipStage";
|
||||
|
||||
const caps = [
|
||||
{ fromFrame: 0, toFrame: 30, text: "A" },
|
||||
{ fromFrame: 30, toFrame: 60, text: "B" },
|
||||
];
|
||||
|
||||
describe("clipStage helpers", () => {
|
||||
it("returns the active caption for a frame (inclusive start, exclusive end)", () => {
|
||||
expect(captionAt(caps, 0)?.text).toBe("A");
|
||||
expect(captionAt(caps, 29)?.text).toBe("A");
|
||||
expect(captionAt(caps, 30)?.text).toBe("B");
|
||||
expect(captionAt(caps, 60)).toBeNull();
|
||||
});
|
||||
it("ramps zoom toward the nearest key moment and back to 1 away from it", () => {
|
||||
const km = [{ frame: 100, zoom: 1.1 }];
|
||||
expect(zoomAt(km, 100)).toBeCloseTo(1.1, 5);
|
||||
expect(zoomAt(km, 0)).toBeCloseTo(1.0, 2);
|
||||
expect(zoomAt([], 50)).toBe(1);
|
||||
expect(zoomAt(km, 70)).toBeGreaterThan(1); // ramping in
|
||||
expect(zoomAt(km, 70)).toBeLessThan(1.1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { CaptionCue, KeyMoment } from "./clips";
|
||||
|
||||
export function captionAt(captions: CaptionCue[], frame: number): CaptionCue | null {
|
||||
for (const c of captions) {
|
||||
if (frame >= c.fromFrame && frame < c.toFrame) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Triangular ramp into the nearest key moment over a 60-frame window on each side.
|
||||
const RAMP = 60;
|
||||
export function zoomAt(keyMoments: KeyMoment[] | undefined, frame: number): number {
|
||||
if (!keyMoments || keyMoments.length === 0) return 1;
|
||||
let best = 1;
|
||||
for (const km of keyMoments) {
|
||||
const dist = Math.abs(frame - km.frame);
|
||||
if (dist > RAMP) continue;
|
||||
const t = 1 - dist / RAMP; // 1 at the moment, 0 at the edge
|
||||
const z = 1 + (km.zoom - 1) * t;
|
||||
if (z > best) best = z;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
{
|
||||
"fps": 30,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"clips": [
|
||||
{
|
||||
"id": "landing-hero",
|
||||
"file": "clips/landing-hero.mp4",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "storm-intel",
|
||||
"file": "clips/storm-intel.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "territory-map",
|
||||
"file": "clips/territory-map.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "lead-create",
|
||||
"file": "clips/lead-create.mp4",
|
||||
"role": "agentCody",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "lead-verify",
|
||||
"file": "clips/lead-verify.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "dispatch",
|
||||
"file": "clips/dispatch.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "kanban",
|
||||
"file": "clips/kanban.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "sub-tasks",
|
||||
"file": "clips/sub-tasks.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "pro-canvas",
|
||||
"file": "clips/pro-canvas.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "estimates",
|
||||
"file": "clips/estimates.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/estimates",
|
||||
"act": "Estimate & sell",
|
||||
"pillarTitle": "Estimates",
|
||||
"durationFrames": 1050,
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 480, "text": "Price the job with margins that hold." }]
|
||||
},
|
||||
{
|
||||
"id": "owner-snapshot",
|
||||
"file": "clips/owner-snapshot.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "operator-dashboard",
|
||||
"file": "clips/operator-dashboard.mp4",
|
||||
"role": "agentCody",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "people-skills",
|
||||
"file": "clips/people-skills.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "project-detail",
|
||||
"file": "clips/project-detail.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "documents",
|
||||
"file": "clips/documents.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/documents",
|
||||
"act": "Manage the team",
|
||||
"pillarTitle": "Documents",
|
||||
"durationFrames": 750,
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 420, "text": "Contracts and warranties, signed and stored." }]
|
||||
},
|
||||
{
|
||||
"id": "org-access",
|
||||
"file": "clips/org-access.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "role-perspective",
|
||||
"file": "clips/role-perspective.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "ai-assistant",
|
||||
"file": "clips/ai-assistant.mp4",
|
||||
"role": "owner",
|
||||
"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." }]
|
||||
},
|
||||
{
|
||||
"id": "breadth-flash",
|
||||
"file": "clips/breadth-flash.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/snapshot",
|
||||
"act": "Close",
|
||||
"durationFrames": 540,
|
||||
"captions": [{ "fromFrame": 30, "toFrame": 480, "text": "One platform. Every part of the job." }]
|
||||
},
|
||||
{
|
||||
"id": "crane-close",
|
||||
"file": "clips/crane-close.mp4",
|
||||
"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." }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
validateClipsManifest,
|
||||
totalLiveFrames,
|
||||
type ClipsManifest,
|
||||
} from "./clips";
|
||||
|
||||
const base: ClipsManifest = {
|
||||
fps: 30,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
clips: [
|
||||
{
|
||||
id: "storm-intel",
|
||||
file: "clips/storm-intel.mp4",
|
||||
role: "owner",
|
||||
route: "/owner/storm-intel",
|
||||
act: "Win the work",
|
||||
pillarTitle: "Storm Intelligence",
|
||||
durationFrames: 1650,
|
||||
captions: [{ fromFrame: 60, toFrame: 240, text: "Drop a pin anywhere." }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("clips manifest validator", () => {
|
||||
it("accepts a valid manifest", () => {
|
||||
expect(validateClipsManifest(base)).toEqual([]);
|
||||
});
|
||||
it("rejects wrong fps/dimensions", () => {
|
||||
const errs = validateClipsManifest({ ...base, fps: 24, width: 1280 });
|
||||
expect(errs).toContain("fps must be 30");
|
||||
expect(errs).toContain("dimensions must be 1920x1080");
|
||||
});
|
||||
it("rejects duplicate ids", () => {
|
||||
const errs = validateClipsManifest({ ...base, clips: [base.clips[0], base.clips[0]] });
|
||||
expect(errs.some((e) => e.includes("duplicate id"))).toBe(true);
|
||||
});
|
||||
it("rejects bad file path and route", () => {
|
||||
const errs = validateClipsManifest({
|
||||
...base,
|
||||
clips: [{ ...base.clips[0], file: "storm.webm", route: "owner/x" }],
|
||||
});
|
||||
expect(errs.some((e) => e.includes("file must be clips/*.mp4"))).toBe(true);
|
||||
expect(errs.some((e) => e.includes('route must start with "/"'))).toBe(true);
|
||||
});
|
||||
it("rejects caption outside clip duration", () => {
|
||||
const errs = validateClipsManifest({
|
||||
...base,
|
||||
clips: [{ ...base.clips[0], captions: [{ fromFrame: 10, toFrame: 99999, text: "x" }] }],
|
||||
});
|
||||
expect(errs.some((e) => e.includes("caption out of range"))).toBe(true);
|
||||
});
|
||||
it("sums total frames", () => {
|
||||
expect(totalLiveFrames(base)).toBe(1650);
|
||||
});
|
||||
it("rejects an empty clips array", () => {
|
||||
expect(validateClipsManifest({ ...base, clips: [] })).toContain("manifest must have at least one clip");
|
||||
});
|
||||
it("rejects a clip missing its act", () => {
|
||||
const errs = validateClipsManifest({ ...base, clips: [{ ...base.clips[0], act: "" }] });
|
||||
expect(errs.some((e) => e.includes("missing act"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
import manifest from "./clips.manifest.json";
|
||||
|
||||
describe("seeded clips.manifest.json", () => {
|
||||
it("passes validation", () => {
|
||||
expect(validateClipsManifest(manifest as ClipsManifest)).toEqual([]);
|
||||
});
|
||||
it("has exactly 20 video clips with unique ids", () => {
|
||||
const m = manifest as ClipsManifest;
|
||||
expect(m.clips.length).toBe(20);
|
||||
expect(new Set(m.clips.map((c) => c.id)).size).toBe(20);
|
||||
});
|
||||
it("opens with landing-hero and ends with crane-close", () => {
|
||||
const m = manifest as ClipsManifest;
|
||||
expect(m.clips[0].id).toBe("landing-hero");
|
||||
expect(m.clips[m.clips.length - 1].id).toBe("crane-close");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
export type ClipRole = "owner" | "agentCody" | "subCarlos" | "public";
|
||||
|
||||
export interface CaptionCue {
|
||||
fromFrame: number;
|
||||
toFrame: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface KeyMoment {
|
||||
frame: number;
|
||||
zoom: number; // 1.0 = no zoom; e.g. 1.08 = subtle punch-in
|
||||
}
|
||||
|
||||
export interface Clip {
|
||||
id: string;
|
||||
file: string; // relative to public/, e.g. "clips/storm-intel.mp4"
|
||||
role: ClipRole;
|
||||
route: string;
|
||||
act: string;
|
||||
pillarTitle?: string;
|
||||
durationFrames: number; // seeded estimate; overwritten by ffprobe at capture
|
||||
keyMoments?: KeyMoment[];
|
||||
captions: CaptionCue[];
|
||||
}
|
||||
|
||||
export interface ClipsManifest {
|
||||
fps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
clips: Clip[];
|
||||
}
|
||||
|
||||
const VALID_ROLES: ClipRole[] = ["owner", "agentCody", "subCarlos", "public"];
|
||||
|
||||
export function validateClipsManifest(m: ClipsManifest): string[] {
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
if (m.clips.length === 0) errors.push("manifest must have at least one clip");
|
||||
if (m.fps !== 30) errors.push("fps must be 30");
|
||||
if (m.width !== 1920 || m.height !== 1080) errors.push("dimensions must be 1920x1080");
|
||||
for (const c of m.clips) {
|
||||
if (!c.id) errors.push("clip missing id");
|
||||
if (seen.has(c.id)) errors.push(`duplicate id: ${c.id}`);
|
||||
seen.add(c.id);
|
||||
if (!c.file?.startsWith("clips/") || !c.file.endsWith(".mp4"))
|
||||
errors.push(`${c.id}: file must be clips/*.mp4`);
|
||||
if (!c.route?.startsWith("/")) errors.push(`${c.id}: route must start with "/"`);
|
||||
if (!c.act) errors.push(`${c.id}: missing act`);
|
||||
if (!VALID_ROLES.includes(c.role)) errors.push(`${c.id}: unknown role "${c.role}"`);
|
||||
if (!Number.isInteger(c.durationFrames) || c.durationFrames <= 0)
|
||||
errors.push(`${c.id}: durationFrames must be a positive integer`);
|
||||
for (const cap of c.captions ?? []) {
|
||||
// toFrame is exclusive: a caption visible through the final rendered frame has
|
||||
// toFrame === durationFrames (valid). Reject only ranges that exceed the clip,
|
||||
// are non-positive, or are empty/inverted.
|
||||
if (cap.fromFrame < 0 || cap.toFrame <= 0 || cap.toFrame > c.durationFrames || cap.fromFrame >= cap.toFrame)
|
||||
errors.push(`${c.id}: caption out of range [${cap.fromFrame},${cap.toFrame}] within 0..${c.durationFrames}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function totalLiveFrames(m: ClipsManifest): number {
|
||||
return m.clips.reduce((acc, c) => acc + c.durationFrames, 0);
|
||||
}
|
||||
@@ -4,13 +4,15 @@ import { TOTAL_FRAMES } from "../timing";
|
||||
// Flip to true after dropping a royalty-free track at public/audio/music.mp3.
|
||||
export const HAS_MUSIC = false;
|
||||
|
||||
export const AudioTrack: React.FC = () => {
|
||||
export const AudioTrack: React.FC<{ totalFrames?: number }> = ({
|
||||
totalFrames = TOTAL_FRAMES,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
if (!HAS_MUSIC) return null;
|
||||
// fade in at start, fade out over the last 2s
|
||||
const volume = interpolate(
|
||||
frame,
|
||||
[0, 30, TOTAL_FRAMES - 60, TOTAL_FRAMES],
|
||||
[0, 30, totalFrames - 60, totalFrames],
|
||||
[0, 0.7, 0.7, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { OffthreadVideo, staticFile, useCurrentFrame, AbsoluteFill } from "remotion";
|
||||
import type { Clip } from "../clips";
|
||||
import { captionAt, zoomAt } from "../clipStage";
|
||||
import { Caption } from "./Caption";
|
||||
|
||||
export const ClipStage: React.FC<{ clip: Clip }> = ({ clip }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const zoom = zoomAt(clip.keyMoments, frame);
|
||||
const caption = captionAt(clip.captions, frame);
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0a0a0a" }}>
|
||||
<AbsoluteFill style={{ transform: `scale(${zoom})`, transformOrigin: "center center" }}>
|
||||
<OffthreadVideo src={staticFile(clip.file)} />
|
||||
</AbsoluteFill>
|
||||
{caption ? (
|
||||
<Caption
|
||||
text={caption.text}
|
||||
startFrame={caption.fromFrame}
|
||||
durationInFrames={caption.toFrame - caption.fromFrame}
|
||||
/>
|
||||
) : null}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user