Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/extension/entrypoints/background.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
COUNTDOWN_SECONDS,
onMessage,
sendMessage,
type CaptureContext,
Expand Down Expand Up @@ -113,7 +114,6 @@ async function releaseCameraBubble(): Promise<void> {
// Tab hosting the invisible camera+mic grant frame (one combined prompt).
let grantTabId: number | undefined;

const COUNTDOWN_SECONDS = 3;
// Pending pre-roll resolver; the overlay's skip/cancel raw message settles it
// early, the worker's own clock settles it otherwise.
let countdownSettle: ((result: CountdownResult) => void) | undefined;
Expand All @@ -132,6 +132,7 @@ async function runPageCountdown(): Promise<CountdownResult> {
} catch {
return "go";
}
void sendMessage("countdownStarted", undefined).catch(() => {});
// The worker owns the clock; the overlay only displays it. First settle
// wins — a late page message after the timeout resolves into a no-op.
const result = await new Promise<CountdownResult>((resolve) => {
Expand Down
35 changes: 33 additions & 2 deletions apps/extension/entrypoints/offscreen/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { onMessage, sendMessage } from "@/lib/messaging";
import { COUNTDOWN_SECONDS, onMessage, sendMessage } from "@/lib/messaging";
import {
deleteActiveRecording,
pauseActiveRecording,
Expand All @@ -13,9 +13,40 @@ import {
* reason — the spec's user-activation requirement isn't enforced for offscreen
* docs.
*/
/*
* One beep per visual tick, played here rather than in the page: no autoplay
* gate, and cancelling on resolve guarantees nothing bleeds into the first
* recorded frame. Scheduled off countdownStarted (the overlay's actual mount)
* so the beeps line up with the numerals, not with the message round-trip.
*/
const beep = new Audio(chrome.runtime.getURL("/countdown-beep.mp3"));
let beepTimers: number[] = [];

function cancelBeeps(): void {
for (const timer of beepTimers) clearTimeout(timer);
beepTimers = [];
beep.pause();
}

onMessage("countdownStarted", () => {
cancelBeeps();
beepTimers = Array.from({ length: COUNTDOWN_SECONDS }, (_, i) =>
window.setTimeout(() => {
beep.currentTime = 0;
void beep.play().catch(() => {});
}, i * 1000),
);
});

onMessage("beginCapture", ({ data }) =>
recordAndUpload(data, {
onCountdown: () => sendMessage("runCountdown", undefined),
onCountdown: async () => {
try {
return await sendMessage("runCountdown", undefined);
} finally {
cancelBeeps();
}
},
onStatus: (status) => void sendMessage("recordingStatus", status),
onResult: (result) => void sendMessage("recordingResult", result),
onActiveUpload: (upload) => void sendMessage("activeUploadChanged", upload),
Expand Down
6 changes: 5 additions & 1 deletion apps/extension/lib/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type StartResult = { ok: true } | { ok: false; error: string };

export type CountdownResult = "go" | "cancel";

export const COUNTDOWN_SECONDS = 3;

export type CaptureContext = {
deviceId: string;
token: string;
Expand Down Expand Up @@ -51,8 +53,10 @@ type ProtocolMap = {
deleteRecording(): void;
beginCapture(ctx: CaptureContext): void;
// Offscreen docs can't inject page UI, so the SW renders the pre-roll
// countdown and reports how it ended.
// countdown and reports how it ended. countdownStarted marks the overlay's
// actual mount, so the offscreen beeps line up with the visual ticks.
runCountdown(): CountdownResult;
countdownStarted(): void;
stopCapture(): void;
pauseCapture(): void;
resumeCapture(): void;
Expand Down
4 changes: 4 additions & 0 deletions apps/extension/lib/overlay/countdown-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ export function mountCountdownOverlay(seconds: number, rootId: string): void {

const root = document.createElement("div");
root.id = rootId;
// Same backdrop as the recorder overlay's; inlined because the serialized
// injection can't reach module scope.
root.style.cssText =
"position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;" +
"justify-content:center;gap:40px;pointer-events:none;" +
"background:rgba(10,11,14,.45);" +
"backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);" +
"font-family:system-ui,-apple-system,sans-serif;";

const finish = (verdict: "cancel" | "skip" | null): void => {
Expand Down
2 changes: 1 addition & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@captureflow/extension",
"version": "0.1.2",
"version": "0.1.3",
"private": true,
"type": "module",
"description": "CaptureFlow browser screen recorder (WXT, MV3).",
Expand Down
Binary file added apps/extension/public/countdown-beep.mp3
Binary file not shown.
2 changes: 0 additions & 2 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { redirect } from "next/navigation";
import { loadSession } from "@/lib/session-guard";
import { getStarCount, formatStars } from "@/lib/github";
import { Nav } from "@/components/marketing/nav";
import { CornerBanner } from "@/components/marketing/corner-banner";
import { HeroSection } from "@/components/marketing/hero-section";
import { ModesIntro } from "@/components/marketing/modes-intro";
import { CollaborationSection } from "@/components/marketing/collaboration-section";
Expand Down Expand Up @@ -62,7 +61,6 @@ export default async function RootPage() {
<JsonLd
data={[WEBSITE_SCHEMA, ORGANIZATION_SCHEMA, APP_SCHEMA, FAQ_SCHEMA]}
/>
<CornerBanner />
<Nav stars={stars} />
<main>
<HeroSection />
Expand Down
189 changes: 36 additions & 153 deletions apps/web/components/marketing/recorder-mockup.tsx
Original file line number Diff line number Diff line change
@@ -1,172 +1,55 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { Text } from "./typography";
import { Tag } from "./ui";
import { useState } from "react";
import { TOKENS } from "./tokens";
import { Play, Pause, Check } from "lucide-react";
import { Play } from "lucide-react";

const SHARE_PATH = "captureflow.dev/r/8kx2pnq4";
const VIDEO_ID = "cAuDQoav608";

// Poster facade first; the YouTube iframe only loads once someone hits play.
export function RecorderMockup() {
const token = TOKENS;
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [overlayIcon, setOverlayIcon] = useState<"play" | "pause" | null>(null);
const [prevPlaying, setPrevPlaying] = useState(false);

useEffect(() => {
const v = videoRef.current;
if (!v) return;
const syncPlaying = () => setPlaying(!v.paused);
syncPlaying();
v.addEventListener("play", syncPlaying);
v.addEventListener("playing", syncPlaying);
v.addEventListener("pause", syncPlaying);
v.addEventListener("ended", syncPlaying);
return () => {
v.removeEventListener("play", syncPlaying);
v.removeEventListener("playing", syncPlaying);
v.removeEventListener("pause", syncPlaying);
v.removeEventListener("ended", syncPlaying);
};
}, []);

useEffect(() => {
if (overlayIcon === null) return;
const t = setTimeout(() => setOverlayIcon(null), 750);
return () => clearTimeout(t);
}, [overlayIcon]);

// Flash the new state's icon in the centre whenever playback toggles.
if (prevPlaying !== playing) {
setPrevPlaying(playing);
setOverlayIcon(playing ? "pause" : "play");
}

const togglePlay = () => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
if (v.ended || v.currentTime >= v.duration) v.currentTime = 0;
void v.play();
} else {
v.pause();
}
};

return (
<div className="relative mx-auto w-full max-w-[1172px] px-5 pb-24 pt-6 sm:px-8">
<div
className="relative aspect-[480/301] w-full overflow-hidden"
className="relative aspect-video w-full overflow-hidden"
style={{ borderRadius: token.borderRadiusLG }}
>
<video
ref={videoRef}
src="/demo.mp4"
poster="/demo-poster.jpg"
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full object-cover"
/>

<button
type="button"
onClick={togglePlay}
aria-label={playing ? "Pause demo" : "Play demo"}
className="group absolute inset-0 flex items-center justify-center"
>
<span
aria-hidden
className={`pointer-events-none absolute inset-0 bg-black/40 transition-opacity duration-300 ${
playing ? "opacity-0" : "opacity-100"
}`}
{playing ? (
<iframe
src={`https://www.youtube-nocookie.com/embed/${VIDEO_ID}?autoplay=1&rel=0`}
title="CaptureFlow demo"
allow="autoplay; encrypted-media; picture-in-picture"
allowFullScreen
className="absolute inset-0 h-full w-full border-0"
/>

<span
className={`relative flex h-24 w-24 items-center justify-center rounded-full bg-blue-600 shadow-2xl shadow-blue-950/40 ring-[10px] ring-blue-600/30 transition-[opacity,transform] duration-200 ease-out group-hover:scale-[1.06] sm:h-28 sm:w-28 ${
playing ? "scale-95 opacity-0" : "scale-100 opacity-100"
}`}
>
<Play className="size-10 translate-x-0.5 fill-white text-white sm:size-11" />
</span>
</button>

{/* Centre flash on toggle. Keyed so back-to-back toggles restart it. */}
{overlayIcon && playing ? (
<div
className="pointer-events-none"
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
key={overlayIcon}
className="animate-recording-icon-pop"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 124,
height: 124,
borderRadius: "50%",
background: "rgba(23, 23, 23, 0.9)",
color: "#fff",
}}
>
{overlayIcon === "pause" ? (
<Pause size={40} fill="currentColor" />
) : (
<Play
size={40}
fill="currentColor"
style={{ transform: "translateX(2px)" }}
/>
)}
</div>
</div>
) : null}

<div
className="pointer-events-none absolute right-4 top-4 hidden transition-opacity duration-300 sm:right-5 sm:top-5 sm:block"
style={{ opacity: playing ? 0 : 1 }}
>
<div
className="flex items-center gap-2.5 px-3 py-2 backdrop-blur-md"
style={{
borderRadius: token.borderRadiusLG,
backgroundColor: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
boxShadow: token.boxShadowSecondary,
}}
>
<Tag
color="processing"
style={{
margin: 0,
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
<Check size={12} />
Link copied
</Tag>
<Text
type="secondary"
style={{
fontSize: 10,
fontFamily: "var(--font-mono, monospace)",
}}
) : (
<>
<img
src="/demo-poster.jpg"
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>

<button
type="button"
onClick={() => setPlaying(true)}
aria-label="Play demo"
className="group absolute inset-0 flex items-center justify-center"
>
{SHARE_PATH}
</Text>
</div>
</div>
<span
aria-hidden
className="pointer-events-none absolute inset-0 bg-black/40"
/>

<span className="relative flex h-24 w-24 items-center justify-center rounded-full bg-blue-600 shadow-2xl shadow-blue-950/40 ring-[10px] ring-blue-600/30 transition-transform duration-200 ease-out group-hover:scale-[1.06] sm:h-28 sm:w-28">
<Play className="size-10 translate-x-0.5 fill-white text-white sm:size-11" />
</span>
</button>
</>
)}
</div>
</div>
);
Expand Down
Binary file modified apps/web/public/demo-poster.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed apps/web/public/demo.mp4
Binary file not shown.
Binary file added marketing/captureflow-final-v1.mp4
Binary file not shown.
Binary file added marketing/captureflow-final-v2.mp4
Binary file not shown.
Binary file added marketing/captureflow-final-v3.mp4
Binary file not shown.
Binary file added marketing/captureflow-final-v4.mp4
Binary file not shown.
Binary file added marketing/captureflow-final.mp4
Binary file not shown.
Binary file added marketing/captureflow.mp4
Binary file not shown.
Binary file added marketing/demo.mp4
Binary file not shown.
Binary file added marketing/soundtrack.wav
Binary file not shown.
46 changes: 46 additions & 0 deletions scripts/gen-store-assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,45 @@ def glyph(x: int, y: int, size: int, fill: str = "#ffffff", z: int = 6) -> str:
)


# The pre-roll overlay from apps/extension/lib/overlay/countdown-overlay.ts,
# scaled up so it reads at tile size; keep the two in step.
def countdown(cx: int, cy: int, hint_y: int, z: int = 6) -> str:
btn = (
"width:56px;height:56px;border-radius:50%;flex:none;"
"border:2px solid rgba(255,255,255,.9);background:rgba(10,11,14,.35);"
"backdrop-filter:blur(4px);display:flex;align-items:center;"
"justify-content:center;color:#fff;"
)
x_svg = (
"<svg width='20' height='20' viewBox='0 0 24 24' fill='none' "
"stroke='currentColor' stroke-width='2.5' stroke-linecap='round'>"
"<path d='M5 5l14 14M19 5L5 19'/></svg>"
)
skip_svg = (
"<svg width='20' height='20' viewBox='0 0 24 24' fill='none' "
"stroke='currentColor' stroke-width='2.5' stroke-linecap='round' "
"stroke-linejoin='round'><path d='M6 5l10 7-10 7V5z'/>"
"<path d='M19 5v14'/></svg>"
)
return (
f"<div style='position:absolute;left:{cx}px;top:{cy}px;z-index:{z};"
"transform:translate(-50%,-50%);display:flex;align-items:center;"
"gap:34px'>"
f"<div style='{btn}'>{x_svg}</div>"
"<div style='width:150px;height:150px;border-radius:50%;"
"background:#2563eb;box-shadow:0 0 0 9px rgba(37,99,235,.35),"
"0 10px 34px rgba(0,0,0,.45);display:flex;align-items:center;"
"justify-content:center'>"
"<div style='color:#fff;font-size:84px;font-weight:600;"
"line-height:1'>3</div></div>"
f"<div style='{btn}'>{skip_svg}</div></div>"
f"<div style='position:absolute;left:{cx}px;top:{hint_y}px;z-index:{z};"
"transform:translateX(-50%);color:#fff;background:rgba(10,11,14,.65);"
"border-radius:8px;padding:6px 12px;font-size:13px'>"
"Press Esc to cancel</div>"
)


def build(s: dict, w: int, h: int) -> str:
css = CSS.replace("__BG__", BLUE)
p = [
Expand Down Expand Up @@ -199,6 +238,13 @@ def screenshots() -> list:
"layers": [window(310, 232, 668), mark(212, 476, 128)],
"emoji": [("🔒", 936, 300, 68, -10), ("☁️", 898, 610, 60, 8)],
},
{ # window alone, the pre-roll countdown over the page
"title": "A 3-2-1 Countdown Before Capture",
"sub": "Three seconds to get set, with skip and cancel one click away.",
"title_at": 60, "sub_at": 130,
"layers": [window(310, 236, 668), countdown(644, 462, 596)],
"emoji": [("\u23f1\ufe0f", 932, 288, 68, -10), ("\U0001f6a6", 148, 592, 64, 8)],
},
]


Expand Down
Binary file added scripts/promo-video/brand-marquee.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/icon-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/tile-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/tile-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/tile-3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/tile-4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/tile-5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/tile-6.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/ui-countdown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/ui-dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/ui-panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/ui-popup-open.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/ui-recording.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/promo-video/ui-share.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading