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
2 changes: 1 addition & 1 deletion frontend/e2e/journey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ async function walkJourney(page: Page, opts: WalkOpts): Promise<void> {

// The server-side aggregate re-verification receipt also renders: a text
// summary (never colour alone) plus the individual named checks.
await expect(page.getByText(/checks passed all verified/i)).toBeVisible();
await expect(page.getByText(/checks passed\. all verified/i)).toBeVisible();
await expect(page.getByText(/reel bytes match the sealed hash/i)).toBeVisible();
await assertNoHorizontalOverflow(page, `${opts.label} result (verified)`);
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
<meta name="theme-color" content="#08080a" />
<meta
name="description"
content="Cinemory turns your photos into a cinematic memory reel scored, stitched, and sealed with cryptographic provenance on Backblaze B2."
content="Cinemory turns your photos into a cinematic memory reel: scored, stitched, and sealed with cryptographic provenance on Backblaze B2."
/>
<title>Cinemory your memories, made into film</title>
<title>Cinemory: your memories, made into film</title>

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ExampleReel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function ExampleReel() {
return (
<div
role="img"
aria-label="Example Cinemory reel a cinematic sequence generated from photos"
aria-label="Example Cinemory reel: a cinematic sequence generated from photos"
className="letterbox relative aspect-video w-full overflow-hidden rounded-2xl border border-white/[0.06] bg-ink-900 shadow-film"
>
{frames.map((src, i) => (
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export function Footer() {
return (
<footer className="border-t border-white/[0.05] py-10">
<div className="container flex flex-col items-center gap-4 text-sm text-zinc-400 sm:flex-row sm:justify-between">
<p>© {new Date().getFullYear()} Cinemory — your memories, made into film.</p>
<p>© {new Date().getFullYear()} Cinemory. Your memories, made into film.</p>
{/* The repo link is a standalone control (not buried mid-sentence) so it
is a real >=44px tap target on touch, tightening on >=sm. */}
<div className="flex flex-col items-center gap-3 sm:flex-row sm:gap-5">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/HashChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function HashChip({
type="button"
onClick={copy}
disabled={!hash}
title={hash ? `${label ? label + ": " : ""}${hash} click to copy` : "unavailable"}
title={hash ? `${label ? label + ": " : ""}${hash} (click to copy)` : "unavailable"}
className={cn(
"group inline-flex min-h-11 items-center gap-1.5 rounded-md border border-white/[0.08] bg-ink-900/70 px-2 py-1 font-mono text-xs text-zinc-300 transition-colors hover:border-gold-400/40 hover:text-gold-200 disabled:opacity-50 sm:min-h-0",
className,
Expand Down
12 changes: 6 additions & 6 deletions frontend/src/components/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ const features = [
{
icon: Film,
title: "Cinematic by default",
body: "Music-driven cuts, chapter bridges and title cards an editor’s eye, automated.",
body: "Music-driven cuts, chapter bridges and title cards: an editor’s eye, automated.",
},
{
icon: Sparkles,
title: "Occasion-aware",
body: "Anniversary, wedding, graduation, year-in-review each with its own pacing and score.",
body: "Anniversary, wedding, graduation, year-in-review: each with its own pacing and score.",
},
{
icon: ShieldCheck,
title: "Provenance-sealed",
body: "Every asset is SHA-256 hashed and the manifest is sealed verifiable, tamper-evident.",
body: "Every asset is SHA-256 hashed and the manifest is sealed: verifiable, tamper-evident.",
},
];

Expand Down Expand Up @@ -83,7 +83,7 @@ export function Hero({
className="mt-6 max-w-xl text-balance text-lg text-zinc-400"
>
Cinemory turns a handful of photos into a scored, stitched cinematic
reel and seals it with cryptographic provenance you can verify.
reel, and seals it with cryptographic provenance you can verify.
</motion.p>

<motion.p
Expand All @@ -92,7 +92,7 @@ export function Hero({
transition={{ duration: 0.7, delay: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="mt-3 max-w-lg text-sm text-zinc-400"
>
Made for families, couples and small teams — no video editor required.
Made for families, couples and small teams. No video editor required.
</motion.p>

<motion.div
Expand Down Expand Up @@ -123,7 +123,7 @@ export function Hero({
)}
</motion.div>
<span className="mt-4 text-sm text-zinc-400">
No account · No watermark · ~30 seconds
No account · No watermark
</span>

{/* A live, muted, looping example of the generative output. */}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/HowItWorks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const STEPS = [
{
icon: ImagePlus,
title: "Add your photos",
body: "Drop in a handful of moments — the order you choose becomes the edit.",
body: "Drop in a handful of moments. The order you choose becomes the edit.",
},
{
icon: Sparkles,
Expand All @@ -15,7 +15,7 @@ const STEPS = [
{
icon: ShieldCheck,
title: "Get a sealed reel",
body: "A cinematic reel you can play, share and cryptographically verify in seconds.",
body: "A cinematic reel you can play, share and cryptographically verify in seconds.",
},
];

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ProvenancePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ describe("<ProvenancePanel /> — server-side aggregate re-verification", () =>
await userEvent.click(screen.getByRole("button", { name: /verify provenance/i }));

expect(
await screen.findByText(/2\/2 checks passed all verified/i),
await screen.findByText(/2\/2 checks passed\. all verified/i),
).toBeInTheDocument();
expect(screen.getByText(/reel bytes match the sealed hash/i)).toBeInTheDocument();
// Non-colour-only: an explicit textual "Passed" label per check.
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/ProvenancePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function ProvenancePanel({ reel }: { reel: ReelResponse }) {
</div>
<p className="mt-1 text-sm text-zinc-400">
Every asset is content-addressed by SHA-256 and the manifest is
sealed — tampering with any field breaks the hash.
sealed. Tampering with any field breaks the hash.
</p>
</div>
<SealBadge verify={verify} />
Expand Down Expand Up @@ -138,7 +138,7 @@ export function ProvenancePanel({ reel }: { reel: ReelResponse }) {
</span>
</div>
<p className="mt-2 truncate font-mono text-[11px] text-zinc-400">
{reel.reel_url ?? ""}
{reel.reel_url ?? "(none)"}
</p>
</div>
</div>
Expand Down Expand Up @@ -312,7 +312,7 @@ function ServerRecheck({ receipt }: { receipt: ReceiptUiState }) {
}
>
{passed}/{checks.length} checks passed
{allPassed ? " — all verified" : " — tamper detected"}
{allPassed ? ". All verified." : ". Tamper detected."}
</span>
</div>
<ul className="space-y-2">
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/ShareBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,13 @@ export function ShareBar({ reel }: { reel: ReelResponse }) {
try {
const file = await fetchReelFile(playbackUrl, filename);
const outcome = await shareReel(file, {
title: `Cinemory ${reel.reel_name}`,
title: `Cinemory: ${reel.reel_name}`,
text: "A cinematic memory reel, sealed with verifiable provenance.",
pageUrl,
});
setStatus(
outcome === "unsupported"
? "Native share isn’t available here — use Download or Copy link."
? "Native share isn’t available here. Use Download or Copy link instead."
: `Share ${outcome}.`,
);
} catch (e) {
Expand Down Expand Up @@ -89,7 +89,7 @@ export function ShareBar({ reel }: { reel: ReelResponse }) {
if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);
copiedTimer.current = window.setTimeout(() => setCopied(false), 2000);
} catch {
setStatus("Copy failed — copy the address from the address bar instead.");
setStatus("Copy failed. Copy the address from the address bar instead.");
}
};

Expand Down Expand Up @@ -135,7 +135,7 @@ export function ShareBar({ reel }: { reel: ReelResponse }) {
{!fetchable && (
<p className="mt-4 text-xs text-zinc-400">
This run was rendered by the built-in offline generator, so there is no
playable video to share yet — the reel is still stored and sealed at{" "}
playable video to share yet. The reel is still stored and sealed at{" "}
<span className="font-mono text-zinc-400">{reel.reel_url ?? "(none)"}</span>.
Share &amp; Download activate on live AI-generated reels.
</p>
Expand Down
24 changes: 23 additions & 1 deletion frontend/src/components/steps/GenerateReel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { GenerateReel } from "./GenerateReel";
import { useReelStore } from "@/store/useReelStore";
import { cinemoryApi, type Occasion, type ReelResponse } from "@/lib/api";
import { ApiError, cinemoryApi, type Occasion, type ReelResponse } from "@/lib/api";

const REEL: ReelResponse = {
reel_name: "cinemory-reel",
Expand Down Expand Up @@ -127,6 +127,28 @@ describe("<GenerateReel /> — failure handling", () => {
await userEvent.click(screen.getByRole("button", { name: /back/i }));
expect(useReelStore.getState().step).toBe("occasion");
});

it("reads a gateway timeout as an honest 'taking longer than expected', with the raw status kept as a secondary detail", async () => {
useReelStore.getState().setOccasion("anniversary");
vi.spyOn(cinemoryApi, "createReel").mockRejectedValue(
new ApiError("Request to /reels failed (504).", 504),
);

renderGenerate();
await waitFor(() =>
expect(
screen.getByRole("heading", { name: /didn’t finish/i }),
).toBeInTheDocument(),
);

// The headline is the honest, reassuring copy. Never the raw error.
expect(
screen.getByText(/taking longer than expected/i),
).toBeInTheDocument();
// The raw technical detail (path + status) stays available, just not
// as the headline.
expect(screen.getByText(/failed \(504\)/i)).toBeInTheDocument();
});
});

describe("<GenerateReel /> — in-flight render", () => {
Expand Down
32 changes: 24 additions & 8 deletions frontend/src/components/steps/GenerateReel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { StepHeading } from "./PhotoUpload";
import { useCreateReel, useOccasions, useUploadReel } from "@/lib/queries";
import { deriveReelShape, useReelStore } from "@/store/useReelStore";
import { generationProgressPct } from "@/lib/progress";
import type { ReelResponse } from "@/lib/api";
import { ApiError, type ReelResponse } from "@/lib/api";
import { cn } from "@/lib/utils";

const STAGES = [
Expand Down Expand Up @@ -115,21 +115,33 @@ export function GenerateReel({
});

if (mutation.isError) {
// Honest-degrade for the headline: a gateway timeout on a multi-minute
// live render is not a hard failure, so it gets its own reassuring copy.
// The raw ApiError message (path + HTTP status) stays visible as a small
// technical line underneath, never as the headline.
const err = mutation.error;
const status = err instanceof ApiError ? err.status : undefined;
const isTimeout =
status === 504 || status === 502 || status === 503 || status === 408;
const friendlyDetail = isTimeout
? "This is taking longer than expected. Please try again in a moment."
: "We couldn’t finish this one. Please try again.";
const rawDetail = err instanceof Error ? err.message : null;

return (
<div className="animate-fade-up">
<StepHeading
title="The reel didn’t finish"
subtitle="Something interrupted the render. Your photos and occasion are safe try again."
subtitle="Your photos and occasion are safe. Just try again."
/>
<div className="glass mx-auto max-w-md rounded-2xl p-8 text-center">
<span className="mx-auto grid h-12 w-12 place-items-center rounded-full bg-ember-500/15 text-ember-400">
<AlertTriangle className="h-6 w-6" />
</span>
<p className="mt-4 text-sm text-zinc-400">
{mutation.error instanceof Error
? mutation.error.message
: "Unknown error."}
</p>
<p className="mt-4 text-sm text-zinc-400">{friendlyDetail}</p>
{rawDetail && (
<p className="mt-2 text-[11px] text-zinc-400">{rawDetail}</p>
)}
<div className="mt-6 flex justify-center gap-3">
<Button variant="ghost" onClick={() => goTo("occasion")}>
Back
Expand All @@ -150,10 +162,14 @@ export function GenerateReel({
title="Rolling…"
subtitle={
occasion
? `Editing a ${occasion.label.toLowerCase()} reel ${occasion.music_style}.`
? `Editing a ${occasion.label.toLowerCase()} reel: ${occasion.music_style}.`
: "Your cinematic reel is being composed."
}
/>
<p className="-mt-5 mb-8 text-center text-sm text-zinc-400">
A live reel can take a few minutes to render. No need to refresh,
this updates on its own.
</p>

<div className="mx-auto max-w-lg">
{/* Reel animation */}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/steps/OccasionPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function OccasionPicker() {
<div className="animate-fade-up">
<StepHeading
title="Set the mood"
subtitle="Each occasion has its own score, pacing and title style — the whole edit changes with your choice."
subtitle="Each occasion has its own score, pacing and title style. The whole edit changes with your choice."
/>

{isLoading && (
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/steps/PhotoUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function PhotoUpload() {
<div className="animate-fade-up">
<StepHeading
title="Bring your memories"
subtitle="Drop the photos you want in the reel. Reorder them to set the story — the order becomes the edit."
subtitle="Drop the photos you want in the reel. Reorder them to set the story. The order becomes the edit."
/>

{/* Dropzone */}
Expand Down Expand Up @@ -211,7 +211,7 @@ export function PhotoUpload() {

<div className="mt-10 flex items-start justify-between gap-4">
<span className="text-xs text-zinc-400">
Tip: 412 photos make the richest reel.
Tip: 4 to 12 photos make the richest reel.
</span>
<div className="flex flex-col items-end gap-1.5">
<Button
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/steps/ReelResult.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function ReelResult({ reel }: { reel: ReelResponse }) {
)}
{photos.length > 0 && (
<p className="absolute inset-x-0 bottom-[11%] z-20 px-6 text-center text-xs text-white/70">
Preview slideshow of your photos — the reel file plays here
Preview slideshow of your photos. The reel file plays here
when it comes from live AI generation.
</p>
)}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ async function request<S extends z.ZodTypeAny>(
...init,
});
} catch (err) {
throw new ApiError("Network unreachable — check your connection.", undefined, err);
throw new ApiError("Network unreachable. Check your connection and try again.", undefined, err);
}
if (!res.ok) {
throw new ApiError(`Request to ${path} failed (${res.status}).`, res.status);
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/lib/provenance-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ export async function verifyReelProvenance(
} catch {
return {
state: "unavailable",
detail: "The manifest couldn't be fetched — check your connection and retry.",
detail: "The manifest couldn't be fetched. Check your connection and retry.",
};
}
if (!res.ok) {
Expand All @@ -309,7 +309,7 @@ export async function verifyReelProvenance(
return {
state: "failed",
detail:
"Recomputed SHA-256 does NOT match the sealed manifest_hash — the manifest bytes have changed.",
"Recomputed SHA-256 does NOT match the sealed manifest_hash. The manifest bytes have changed.",
claimedHash,
computedHash,
};
Expand All @@ -326,7 +326,7 @@ export async function verifyReelProvenance(
return {
state: "verified",
detail:
"Recomputed the canonical SHA-256 in your browser from the re-fetched manifest — it matches the sealed manifest_hash.",
"Recomputed the canonical SHA-256 in your browser from the re-fetched manifest. It matches the sealed manifest_hash.",
claimedHash,
computedHash,
};
Expand Down Expand Up @@ -410,7 +410,7 @@ export async function fetchReelReceipt(
} catch {
return {
state: "unavailable",
detail: "The verification receipt couldn't be fetched — check your connection and retry.",
detail: "The verification receipt couldn't be fetched. Check your connection and retry.",
};
}
if (!res.ok) {
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/lib/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ describe("shortHash", () => {
const h = "a".repeat(20) + "b".repeat(20);
expect(shortHash(h)).toBe("aaaaaaaa…bbbbbbbb");
});
it("returns em dash for empty", () => {
expect(shortHash(null)).toBe("");
it("returns N/A for empty", () => {
expect(shortHash(null)).toBe("N/A");
});
it("leaves short hashes intact", () => {
expect(shortHash("abcd")).toBe("abcd");
Expand All @@ -23,7 +23,7 @@ describe("formatBytes", () => {
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
});
it("handles nullish", () => {
expect(formatBytes(null)).toBe("");
expect(formatBytes(null)).toBe("N/A");
});
});

Expand Down
Loading
Loading