mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
## What Studio's feedback prompt now fires when a render finishes or fails, instead of on a session counter, and the reports it collects carry enough context to act on. - Replaces the 32px inline bar with a card in the existing toast stack - Adds `studio_feedback_shown` / `studio_feedback_dismissed` / `studio_feedback_interview_click`, so the funnel is visible - A failed export and a crash skip the 0-10 score and ask what happened - Adds a crash prompt to the error boundary - Attaches a breadcrumb trail, render settings and outcome, and how the project was created ## Before / after <img width="1500" alt="Before: a 32px feedback strip pinned under the preview. After: the feedback card in the toast stack, with one-tap answers, and the red variant for a failed export." src="https://github.com/user-attachments/assets/29e2373f-ee96-42c4-b006-ab9a0e56a60a" /> The old bar's rating numbers are `neutral-600` on `neutral-900/80`, which is why it reads as a disabled row rather than a control. **In the running Studio** — bottom-right, sharing the toast stack, hovering a chip explains it on the line above the input: <img width="1500" alt="The feedback card in the bottom-right of the running Studio, with the rotated follow-up question and a hovered chip explained inline." src="https://github.com/user-attachments/assets/3969da50-cc7a-466a-bbf9-b151a1bc1d5d" /> **On the crash screen** — the prompt the error boundary renders, asking what the user was doing rather than what went wrong, since the stack trace already covers the latter: <img width="1200" alt="The Studio crash screen with the feedback card below the Try again and Reload Studio buttons." src="https://github.com/user-attachments/assets/4d287011-9c19-4b94-99d0-07a0388fb39c" /> | | Before | After | |---|---|---| | **Trigger** | Every 10th session | A render finishing, failing, or a crash | | **Placement** | 32px inline bar, pushes preview up | Card in the toast stack, no layout shift | | **Rating targets** | 11 bare buttons, `neutral-600` | Native radios, resting fill, `neutral-400` | | **Follow-up** | Free text or nothing | One rotated question, four to seven one-tap answers | | **Option help** | None | Inline hint on hover and focus | | **Press feedback** | None | `active:scale-[0.97]`, 150ms ease-out | | **Keyboard** | No exit path | Escape closes, Enter sends, arrows move the rating | | **Auto-dismiss** | 20s, always | 30s, cancelled the moment you interact | | **Failure case** | Same NPS question | Its own question, no score, error quoted back | | **Crash case** | Nothing | Prompt on the crash screen | | **Visibility** | Submissions only | Shown / dismissed (with reason) / submitted / interview click | | **Report content** | Rating, comment | Plus breadcrumbs, render settings and outcome, project provenance | ## Why The old bar fired on a session count, so it interrupted at a moment with no subject: nothing the user had just done, nothing to have an opinion about. It also emitted nothing when it appeared or when it was dismissed, which made the collection rate impossible to diagnose. A prompt nobody answers and a prompt that never renders looked identical from the outside. Visually it read as a disabled row: 11 buttons at 11px in `neutral-600` on a dark strip, with no resting affordance. And appearing mid-task pushed the whole preview stack up, which the old code carried a comment apologising for. Separately, the reports it did collect were not actionable. A comment says what went wrong; it almost never says how to get there. ## How **One trigger, one owner.** `feedbackTrigger` owns eligibility and nothing else does: once per tab, thirty days after an answer, seven after a dismissal, never when telemetry is off (prompting someone whose response we would then drop wastes their attention). `VITE_HYPERFRAMES_NO_FEEDBACK=1` still disables it entirely. **One hook, every failure path.** The trigger watches the render job list rather than each of the four places a render can finish (server rejection, unreachable server, SSE terminal event, SSE connection drop), so paths added later are covered without touching the trigger. Renders loaded from disk history never fire it. **Reuses what exists.** The card wears `StudioToast`'s glass treatment and joins its stack, so there is no second visual language and no new CSS. The rating row is native radios, which gives arrow-key navigation, grouping and labels for free. **One question each, rotated across users.** A corner card that asks three things gets answered by nobody. Each person gets one follow-up with one-tap answers, explained on a reserved line rather than a floating tooltip (the card is 340px in a corner; a bubble above the chips lands on the question, below lands on the input). Detractors are never given a rotated question, because they already have a specific complaint. Every option was checked against the code: an option naming a feature Studio already has would collect taps meaning "I could not find it", which is indistinguishable afterwards from "it does not exist". **Breadcrumbs cost one line.** Every studio event already flows through `trackEvent`, so recording the trail there needs no new instrumentation and stays correct as events are added. **Provenance lives outside React.** A crash unmounts the tree, so it is captured when the project loads and read from module scope when the crash prompt renders. ### Privacy Breadcrumbs and provenance carry names, enums and counts only. Values are copied from a fixed allowlist of short keys, and anything longer than a slug is dropped rather than truncated, so comments, file paths, stack traces and project titles cannot reach them even if a future event carries one. Tests assert this. ### Where these responses land Studio feedback goes to PostHog and nowhere else, which is what it did before this change too. Worth stating because the CLI behaves differently: `hyperframes feedback` also forwards to the backend feedback endpoint via `submitFeedback`, on top of its PostHog event. Studio has never used that path, before or after this PR, so if you read CLI feedback anywhere other than PostHog, Studio responses will not show up there. Nothing here changes that either way. Whether the two surfaces should share a delivery path is a product question, not a defect in this change, and closing it would need a field on the backend DTO: it is shaped around `cli_version`, and Studio reports from a crash or a failed export deliberately carry no rating. ## Test plan - [x] Unit tests added/updated - [x] Manual testing performed - [ ] Documentation updated (if applicable) **Unit** — 39 new tests: trigger eligibility and cooldowns, the detractor override, rotation, preset shape and the no-brands rule, breadcrumb rolling and privacy, provenance parsing and its failure modes, and the crash boundary rendering the prompt with no rating input. **Live** — both render paths driven end to end against a running Studio on a production bundle, with real renders. Every PostHog request was intercepted and dropped, so nothing reached the project. Verified the emitted payload for a finished render, a failed export, the rotated follow-ups, each chip's hint, and the interview link. **Not covered** — no live capture of a spontaneous crash. Three attempts to force one failed because Studio's guards held and it kept rendering, so the crash path is verified by component tests rather than by driving it. Touch devices see chip labels without hints, since the hint is revealed on hover and focus.
449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
import type { CanvasResolution } from "@hyperframes/parsers";
|
|
import { trackStudioRenderStart } from "../../telemetry/events";
|
|
import { getAnonymousId } from "../../telemetry/config";
|
|
import { browserTelemetryAllowed } from "../../telemetry/policy";
|
|
import { generateId } from "../../utils/generateId";
|
|
import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger";
|
|
|
|
export interface RenderJob {
|
|
id: string;
|
|
status: "rendering" | "complete" | "failed" | "cancelled";
|
|
progress: number;
|
|
stage?: string;
|
|
error?: string;
|
|
filename: string;
|
|
createdAt: number;
|
|
durationMs?: number;
|
|
}
|
|
|
|
// The CLI consumes this same source through @hyperframes/core's re-export.
|
|
// Importing from the browser-safe parsers package avoids the core barrel's
|
|
// Node-only transitive modules without duplicating the preset union in Studio.
|
|
export type ResolutionPreset = CanvasResolution;
|
|
|
|
export interface StartRenderOptions {
|
|
fps?: number;
|
|
quality?: "draft" | "standard" | "high";
|
|
format?: "mp4" | "webm" | "mov";
|
|
/** `"auto"` (default) renders at the composition's authored dimensions. */
|
|
resolution?: ResolutionPreset | "auto";
|
|
/** Render a specific composition file instead of index.html. */
|
|
composition?: string;
|
|
/**
|
|
* Composition-variable overrides ({variableId: value}), forwarded to the
|
|
* render route and injected as window.__hfVariables — the same channel
|
|
* `hyperframes render --variables` uses.
|
|
*/
|
|
variables?: Record<string, unknown>;
|
|
}
|
|
|
|
// "Hide" (formerly "Clear") is a view operation, not a delete: hidden ids are
|
|
// remembered here so hidden renders don't resurrect from the on-disk history
|
|
// on the next load. Per-project key so projects don't hide each other's rows.
|
|
function hiddenIdsKey(projectId: string): string {
|
|
return `hf-studio-hidden-renders:${projectId}`;
|
|
}
|
|
|
|
function readHiddenIds(projectId: string): Set<string> {
|
|
try {
|
|
const raw = window.localStorage.getItem(hiddenIdsKey(projectId));
|
|
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
|
return new Set(Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string") : []);
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
function writeHiddenIds(projectId: string, ids: Set<string>): void {
|
|
try {
|
|
// Cap the list so it doesn't grow unbounded across months of renders.
|
|
window.localStorage.setItem(hiddenIdsKey(projectId), JSON.stringify([...ids].slice(-200)));
|
|
} catch {
|
|
/* localStorage may be unavailable or full */
|
|
}
|
|
}
|
|
|
|
export function useRenderQueue(projectId: string | null) {
|
|
const [jobs, setJobs] = useState<RenderJob[]>([]);
|
|
// History fetch failure — distinguished from "no renders yet" so the panel
|
|
// never shows a false empty state.
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
// Failure of a user action (delete/cancel), surfaced inline in the panel.
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
const activeJobRef = useRef<string | null>(null);
|
|
// Renders started in THIS tab, mapped to the settings they ran with.
|
|
// `loadRenders` also injects finished jobs from disk history, and those must
|
|
// never trigger a feedback prompt — the user did not just watch them happen.
|
|
const sessionJobs = useRef(new Map<string, FeedbackContext>());
|
|
const promptedJobIds = useRef(new Set<string>());
|
|
|
|
/**
|
|
* The one way a render started here enters the list. Every start path — the
|
|
* happy one and all three failure shortcuts — goes through here, so both
|
|
* "this render belongs to this session" and "these are the settings it ran
|
|
* with" have a single owner. A report about a render is only actionable if
|
|
* it arrives with the settings that produced it.
|
|
*/
|
|
const addSessionJob = useCallback((job: RenderJob, settings: FeedbackContext) => {
|
|
sessionJobs.current.set(job.id, settings);
|
|
setJobs((prev) => [...prev, job]);
|
|
}, []);
|
|
|
|
const closeActiveEventSource = useCallback((jobId?: string) => {
|
|
if (jobId && activeJobRef.current !== jobId) return;
|
|
eventSourceRef.current?.close();
|
|
eventSourceRef.current = null;
|
|
activeJobRef.current = null;
|
|
}, []);
|
|
|
|
// Load completed renders from the server
|
|
const loadRenders = useCallback(async () => {
|
|
if (!projectId) return;
|
|
try {
|
|
const res = await fetch(`/api/projects/${projectId}/renders`);
|
|
if (!res.ok) {
|
|
setLoadError(`Couldn't load render history (server error ${res.status}).`);
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
setLoadError(null);
|
|
if (Array.isArray(data.renders)) {
|
|
const hidden = readHiddenIds(projectId);
|
|
setJobs((prev) => {
|
|
const existing = new Set(prev.map((j) => j.id));
|
|
const fromServer: RenderJob[] = data.renders
|
|
.filter((r: { id: string }) => !existing.has(r.id) && !hidden.has(r.id))
|
|
.map(
|
|
(r: {
|
|
id: string;
|
|
filename: string;
|
|
createdAt: number;
|
|
size: number;
|
|
status?: string;
|
|
durationMs?: number;
|
|
}) => ({
|
|
id: r.id,
|
|
status: (r.status === "failed" ? "failed" : "complete") as "complete" | "failed",
|
|
progress: 100,
|
|
filename: r.filename,
|
|
createdAt: r.createdAt,
|
|
durationMs: r.durationMs,
|
|
}),
|
|
);
|
|
return [...prev, ...fromServer];
|
|
});
|
|
}
|
|
} catch {
|
|
setLoadError("Couldn't load render history. Is the studio server running?");
|
|
}
|
|
}, [projectId]);
|
|
|
|
useEffect(() => {
|
|
loadRenders();
|
|
}, [loadRenders]);
|
|
|
|
// Start a render and track progress via SSE
|
|
// Pre-existing branchy fetch/poll flow — the variables passthrough added one branch.
|
|
const startRender = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
async (opts: StartRenderOptions = {}) => {
|
|
if (!projectId) return;
|
|
|
|
const fps = opts.fps ?? 30;
|
|
const quality = opts.quality ?? "standard";
|
|
const format = opts.format ?? "mp4";
|
|
const resolution = opts.resolution;
|
|
const composition = opts.composition;
|
|
|
|
trackStudioRenderStart({
|
|
fps,
|
|
quality,
|
|
format,
|
|
resolution,
|
|
composition,
|
|
});
|
|
|
|
const startTime = Date.now();
|
|
// Travels with any feedback about this render. Settings only: the
|
|
// composition path is a name the user chose, not file contents.
|
|
const settings: FeedbackContext = {
|
|
render_format: format,
|
|
render_quality: quality,
|
|
render_fps: fps,
|
|
render_resolution: resolution ?? "auto",
|
|
render_composition: composition ?? "index.html",
|
|
render_has_variables: Boolean(opts.variables && Object.keys(opts.variables).length > 0),
|
|
};
|
|
// "auto" / undefined means "render at the composition's authored size".
|
|
// Omit the field entirely — sending "auto" would trip the route's
|
|
// enum validation set.
|
|
const body: {
|
|
fps: number;
|
|
quality: string;
|
|
format: string;
|
|
resolution?: string;
|
|
composition?: string;
|
|
variables?: Record<string, unknown>;
|
|
telemetryDistinctId?: string;
|
|
telemetryOptOut?: boolean;
|
|
} = {
|
|
fps,
|
|
quality,
|
|
format,
|
|
};
|
|
// The id is MINTED by getAnonymousId(), so calling it unconditionally
|
|
// created a telemetry identity for a profile that had opted out — and
|
|
// then shipped it to the server. The server's own policy cannot see this
|
|
// browser's localStorage or DoNotTrack, so it has to be told: an
|
|
// explicit `telemetryOptOut` suppresses the render outcome, which
|
|
// omitting the id alone does NOT (an old client omits it too, and that
|
|
// falls back to the install id).
|
|
if (browserTelemetryAllowed()) {
|
|
// So the server-emitted render_complete/render_error is attributed to
|
|
// this browser user (same id studio_* events use), making the render
|
|
// funnel joinable. Matches studio_render_start fired just above.
|
|
body.telemetryDistinctId = getAnonymousId();
|
|
} else {
|
|
body.telemetryOptOut = true;
|
|
}
|
|
if (resolution && resolution !== "auto") body.resolution = resolution;
|
|
if (composition) body.composition = composition;
|
|
if (opts.variables && Object.keys(opts.variables).length > 0) {
|
|
body.variables = opts.variables;
|
|
}
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`/api/projects/${projectId}/render`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch {
|
|
const failedJob: RenderJob = {
|
|
id: generateId(),
|
|
status: "failed",
|
|
progress: 0,
|
|
error: "Could not reach render server. Use `hyperframes render` from the CLI instead.",
|
|
filename: "Export failed",
|
|
createdAt: startTime,
|
|
};
|
|
addSessionJob(failedJob, settings);
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
const failedJob: RenderJob = {
|
|
id: generateId(),
|
|
status: "failed",
|
|
progress: 0,
|
|
error: `Server error (${res.status}). Check the terminal for details.`,
|
|
filename: "Export failed",
|
|
createdAt: startTime,
|
|
};
|
|
addSessionJob(failedJob, settings);
|
|
return;
|
|
}
|
|
const { jobId } = await res.json();
|
|
|
|
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
|
|
const ext = FORMAT_EXT[format] ?? ".mp4";
|
|
const job: RenderJob = {
|
|
id: jobId,
|
|
status: "rendering",
|
|
progress: 0,
|
|
filename: `${jobId}${ext}`,
|
|
createdAt: startTime,
|
|
};
|
|
addSessionJob(job, settings);
|
|
activeJobRef.current = jobId;
|
|
|
|
// Track progress via SSE
|
|
const es = new EventSource(`/api/render/${jobId}/progress`);
|
|
eventSourceRef.current = es;
|
|
|
|
es.addEventListener("progress", (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
const terminal =
|
|
data.status === "complete" || data.status === "failed" || data.status === "cancelled";
|
|
setJobs((prev) =>
|
|
prev.map((j) =>
|
|
j.id === jobId
|
|
? {
|
|
...j,
|
|
progress: data.progress ?? j.progress,
|
|
stage: data.stage ?? data.message ?? j.stage,
|
|
status: terminal ? (data.status as RenderJob["status"]) : j.status,
|
|
durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
|
|
error: data.error ?? j.error,
|
|
}
|
|
: j,
|
|
),
|
|
);
|
|
if (terminal) {
|
|
closeActiveEventSource(jobId);
|
|
}
|
|
} catch {
|
|
// ignore parse errors
|
|
}
|
|
});
|
|
|
|
es.onerror = () => {
|
|
es.close();
|
|
setJobs((prev) =>
|
|
prev.map((j) =>
|
|
j.id === jobId && j.status === "rendering"
|
|
? {
|
|
...j,
|
|
status: "failed" as const,
|
|
error: "Connection lost. Is the render server running?",
|
|
}
|
|
: j,
|
|
),
|
|
);
|
|
activeJobRef.current = null;
|
|
};
|
|
|
|
return jobId;
|
|
},
|
|
[projectId, closeActiveEventSource, addSessionJob],
|
|
);
|
|
|
|
// Cancel an in-flight render. The job row stays (as "cancelled") so the
|
|
// user sees the outcome; the SSE stream is closed either way.
|
|
const cancelRender = useCallback(
|
|
async (jobId: string) => {
|
|
setActionError(null);
|
|
closeActiveEventSource(jobId);
|
|
setJobs((prev) =>
|
|
prev.map((j) =>
|
|
j.id === jobId && j.status === "rendering" ? { ...j, status: "cancelled" } : j,
|
|
),
|
|
);
|
|
try {
|
|
const res = await fetch(`/api/render/${jobId}/cancel`, { method: "POST" });
|
|
if (!res.ok && res.status !== 404) {
|
|
setActionError("Couldn't cancel on the server — the render may still be running.");
|
|
return;
|
|
}
|
|
// Reconcile with the status the route reports: if the render actually
|
|
// finished (or failed) before the cancel landed, don't leave the row
|
|
// stuck on the optimistic "cancelled" — reload to pick up the real
|
|
// outcome (and the finished file's metadata).
|
|
if (res.ok) {
|
|
const body = (await res.json().catch(() => null)) as { status?: string } | null;
|
|
if (body?.status && body.status !== "cancelled") {
|
|
void loadRenders();
|
|
}
|
|
}
|
|
} catch {
|
|
setActionError("Couldn't reach the server to cancel — the render may still be running.");
|
|
}
|
|
},
|
|
[closeActiveEventSource, loadRenders],
|
|
);
|
|
|
|
const deleteRender = useCallback(
|
|
async (jobId: string) => {
|
|
setActionError(null);
|
|
closeActiveEventSource(jobId);
|
|
try {
|
|
const res = await fetch(`/api/render/${jobId}`, { method: "DELETE" });
|
|
if (!res.ok) {
|
|
setActionError("Couldn't delete the render — it's still on disk.");
|
|
return;
|
|
}
|
|
} catch {
|
|
setActionError("Couldn't reach the server to delete the render.");
|
|
return;
|
|
}
|
|
setJobs((prev) => prev.filter((j) => j.id !== jobId));
|
|
},
|
|
[closeActiveEventSource],
|
|
);
|
|
|
|
// Hide finished rows from the list (view-only — files stay on disk and can
|
|
// be recovered from the renders/ directory). Remembered per project so the
|
|
// rows don't resurrect from history on reload.
|
|
const clearCompleted = useCallback(() => {
|
|
setJobs((prev) => {
|
|
const finished = prev.filter((j) => j.status !== "rendering");
|
|
if (projectId && finished.length > 0) {
|
|
const hidden = readHiddenIds(projectId);
|
|
for (const j of finished) hidden.add(j.id);
|
|
writeHiddenIds(projectId, hidden);
|
|
}
|
|
return prev.filter((j) => j.status === "rendering");
|
|
});
|
|
}, [projectId]);
|
|
|
|
const dismissActionError = useCallback(() => setActionError(null), []);
|
|
|
|
// Ask for feedback the moment a render this tab started reaches its outcome.
|
|
// Watching the list (rather than each of the four places a job can finish)
|
|
// keeps one trigger for every path, including SSE drops and cancels-that-
|
|
// finished-anyway. `requestStudioFeedback` decides whether to actually ask.
|
|
useEffect(() => {
|
|
for (const job of jobs) {
|
|
if (job.status === "rendering" || job.status === "cancelled") continue;
|
|
const settings = sessionJobs.current.get(job.id);
|
|
if (!settings || promptedJobIds.current.has(job.id)) continue;
|
|
promptedJobIds.current.add(job.id);
|
|
requestStudioFeedback({
|
|
reason: job.status === "complete" ? "render_complete" : "render_failed",
|
|
renderId: job.id,
|
|
detail: job.error,
|
|
context: {
|
|
...settings,
|
|
// How far it got and how long it took separate "died on frame one"
|
|
// from "died during encode", which need different fixes.
|
|
render_progress: job.progress,
|
|
render_duration_ms: job.durationMs ?? Date.now() - job.createdAt,
|
|
render_stage: job.stage,
|
|
render_error: job.error,
|
|
// Earlier renders this session: a first-render failure and a
|
|
// failure after nine successes are different bugs.
|
|
renders_this_session: sessionJobs.current.size,
|
|
},
|
|
});
|
|
}
|
|
}, [jobs]);
|
|
|
|
// Clean up EventSource on unmount or projectId change
|
|
useEffect(() => {
|
|
return () => {
|
|
eventSourceRef.current?.close();
|
|
eventSourceRef.current = null;
|
|
};
|
|
}, [projectId]);
|
|
|
|
const isRendering = jobs.some((j) => j.status === "rendering");
|
|
return useMemo(
|
|
() => ({
|
|
jobs,
|
|
isRendering,
|
|
loadError,
|
|
actionError,
|
|
dismissActionError,
|
|
reloadRenders: loadRenders,
|
|
deleteRender,
|
|
cancelRender,
|
|
clearCompleted,
|
|
startRender: startRender as (options: unknown) => Promise<void>,
|
|
}),
|
|
[
|
|
jobs,
|
|
isRendering,
|
|
loadError,
|
|
actionError,
|
|
dismissActionError,
|
|
loadRenders,
|
|
deleteRender,
|
|
cancelRender,
|
|
clearCompleted,
|
|
startRender,
|
|
],
|
|
);
|
|
}
|