mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat: post-render and Studio feedback collection via PostHog surveys (#1101)
* feat(cli): prompt for render satisfaction after successful renders * feat: add text feedback, doctor context, and Studio render feedback UI * feat(studio): replace render feedback with session-based Studio experience bar Move the feedback prompt out of RenderQueueItem (where it triggered every 5th render) into a standalone StudioFeedbackBar mounted at the bottom of the preview area. The new bar is session-gated (shows after the 5th studio session), auto-dismisses after 20s, and respects a 30-day cooldown once dismissed or submitted. Renames telemetry to trackStudioFeedback with a "studio_experience" survey ID to reflect the broader scope. * feat(studio): attach browser doctor summary to feedback events * fix(studio): use recurring interval for feedback instead of one-time cooldown * fix(cli): skip feedback prompt when an agent runtime is detected * feat(cli): add hyperframes feedback command and agent render hint - New `hyperframes feedback --rating <1-5> --comment "..."` command for submitting anonymous render satisfaction feedback via telemetry. - When an AI agent runtime is detected after a render, print a dimmed hint to stdout so the agent can optionally call the command instead of silently skipping the readline prompt. - Export getDoctorSummary from telemetry/feedback.ts to share the system-info collector between the interactive prompt and the CLI command. - Register the command in cli.ts and help.ts under Settings. * fix(studio): align feedback interval to every 15 sessions * fix: show CLI feedback on first render, Studio every 10 sessions * feat: add env flags to disable feedback prompts * feat: env flags to configure feedback prompt frequency * fix: address review — agent hint reachability, cadence gate, session debounce, deprecated API
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import { memo, useState, useCallback, useRef, useEffect } from "react";
|
||||
import { trackStudioFeedback } from "../telemetry/events";
|
||||
|
||||
const DEFAULT_FEEDBACK_INTERVAL = 10;
|
||||
const AUTO_DISMISS_MS = 20_000;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function getFeedbackInterval(): number {
|
||||
try {
|
||||
const v = import.meta.env.VITE_HYPERFRAMES_FEEDBACK_INTERVAL as string | undefined;
|
||||
if (v) {
|
||||
const n = parseInt(v, 10);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
} catch {
|
||||
// import.meta.env unavailable
|
||||
}
|
||||
return DEFAULT_FEEDBACK_INTERVAL;
|
||||
}
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
sessionCount: "hyperframes-studio:feedbackSessionCount",
|
||||
lastPromptedAt: "hyperframes-studio:feedbackLastPromptedAt",
|
||||
} as const;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function shouldShowFeedback(): boolean {
|
||||
try {
|
||||
const count = parseInt(localStorage.getItem(STORAGE_KEYS.sessionCount) || "0", 10) || 0;
|
||||
const lastAt = parseInt(localStorage.getItem(STORAGE_KEYS.lastPromptedAt) || "0", 10) || 0;
|
||||
return count - lastAt >= getFeedbackInterval();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION_COUNTED_KEY = "hyperframes-studio:feedbackSessionCounted";
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function incrementSessionCount(): void {
|
||||
try {
|
||||
if (sessionStorage.getItem(SESSION_COUNTED_KEY)) return;
|
||||
sessionStorage.setItem(SESSION_COUNTED_KEY, "1");
|
||||
const count = parseInt(localStorage.getItem(STORAGE_KEYS.sessionCount) || "0", 10) || 0;
|
||||
localStorage.setItem(STORAGE_KEYS.sessionCount, String(count + 1));
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function markPrompted(): void {
|
||||
try {
|
||||
const count = localStorage.getItem(STORAGE_KEYS.sessionCount) || "0";
|
||||
localStorage.setItem(STORAGE_KEYS.lastPromptedAt, count);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [rating, setRating] = useState<number | null>(null);
|
||||
const [comment, setComment] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// On mount: increment session count, check if we should show
|
||||
useEffect(() => {
|
||||
incrementSessionCount();
|
||||
// Small delay so the bar doesn't flash on page load
|
||||
const showTimer = setTimeout(() => {
|
||||
if (shouldShowFeedback()) {
|
||||
setVisible(true);
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearTimeout(showTimer);
|
||||
}, []);
|
||||
|
||||
// Auto-dismiss timer — reset when user interacts (sets rating)
|
||||
useEffect(() => {
|
||||
if (!visible || rating !== null || submitted) return;
|
||||
dismissTimerRef.current = setTimeout(() => {
|
||||
handleDismiss();
|
||||
}, AUTO_DISMISS_MS);
|
||||
return () => {
|
||||
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, rating, submitted]);
|
||||
|
||||
// Focus text input when rating is selected
|
||||
useEffect(() => {
|
||||
if (rating !== null && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [rating]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setExiting(true);
|
||||
markPrompted();
|
||||
setTimeout(() => setVisible(false), 300);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (rating === null) return;
|
||||
trackStudioFeedback({
|
||||
rating,
|
||||
comment: comment.trim() || undefined,
|
||||
});
|
||||
setSubmitted(true);
|
||||
markPrompted();
|
||||
setTimeout(() => {
|
||||
setExiting(true);
|
||||
setTimeout(() => setVisible(false), 300);
|
||||
}, 1500);
|
||||
}, [rating, comment]);
|
||||
|
||||
const handleRating = useCallback((n: number) => {
|
||||
setRating(n);
|
||||
// Cancel auto-dismiss — user is engaged
|
||||
if (dismissTimerRef.current) {
|
||||
clearTimeout(dismissTimerRef.current);
|
||||
dismissTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"flex items-center gap-3 px-4 h-8 border-t border-neutral-800/50 bg-neutral-900/80 text-[11px] transition-all duration-300",
|
||||
exiting ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0",
|
||||
].join(" ")}
|
||||
>
|
||||
{submitted ? (
|
||||
<span className="text-neutral-500">Thanks for the feedback!</span>
|
||||
) : rating !== null ? (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
if (e.key === "Escape") handleDismiss();
|
||||
}}
|
||||
placeholder="Any details? (enter to send, esc to close)"
|
||||
className="flex-1 bg-transparent border-none text-[11px] text-neutral-300 placeholder-neutral-600 outline-none"
|
||||
maxLength={500}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="text-[11px] text-neutral-500 hover:text-neutral-300 transition-colors flex-shrink-0"
|
||||
>
|
||||
send
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-neutral-500 flex-shrink-0">How's the Studio experience?</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => handleRating(n)}
|
||||
className="w-6 h-6 rounded text-[11px] text-neutral-600 hover:text-neutral-200 hover:bg-neutral-700/50 transition-colors"
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-neutral-700 hover:text-neutral-400 transition-colors flex-shrink-0"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { NLELayout } from "./nle/NLELayout";
|
||||
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
|
||||
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
|
||||
import { DomEditOverlay } from "./editor/DomEditOverlay";
|
||||
import { StudioFeedbackBar } from "./StudioFeedbackBar";
|
||||
import type { TimelineElement } from "../player";
|
||||
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
|
||||
import {
|
||||
@@ -53,6 +54,7 @@ export interface StudioPreviewAreaProps {
|
||||
blockPreview?: BlockPreviewInfo | null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioPreviewArea({
|
||||
timelineToolbar,
|
||||
renderClipContent,
|
||||
@@ -102,100 +104,103 @@ export function StudioPreviewArea({
|
||||
} = useDomEditContext();
|
||||
|
||||
return (
|
||||
<div className="flex-1 relative min-w-0">
|
||||
<NLELayout
|
||||
projectId={projectId}
|
||||
refreshKey={refreshKey}
|
||||
activeCompositionPath={activeCompPath}
|
||||
timelineToolbar={timelineToolbar}
|
||||
renderClipContent={renderClipContent}
|
||||
onDeleteElement={handleTimelineElementDelete}
|
||||
onAssetDrop={handleTimelineAssetDrop}
|
||||
onBlockDrop={handleTimelineBlockDrop}
|
||||
onPreviewBlockDrop={handlePreviewBlockDrop}
|
||||
onFileDrop={handleTimelineFileDrop}
|
||||
onMoveElement={handleTimelineElementMove}
|
||||
onResizeElement={handleTimelineElementResize}
|
||||
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
||||
onSelectTimelineElement={handleTimelineElementSelect}
|
||||
onCompIdToSrcChange={setCompIdToSrc}
|
||||
onCompositionLoadingChange={setCompositionLoading}
|
||||
onCompositionChange={(compPath) => {
|
||||
// Sync activeCompPath when user drills down via timeline double-click
|
||||
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
|
||||
// Guard against no-op updates to prevent circular refresh cascades
|
||||
// between activeCompPath → compositionStack → onCompositionChange.
|
||||
if (compPath !== activeCompPath) {
|
||||
setActiveCompPath(compPath);
|
||||
refreshPreviewDocumentVersion();
|
||||
}
|
||||
}}
|
||||
onIframeRef={handlePreviewIframeRef}
|
||||
previewOverlay={
|
||||
blockPreview ? (
|
||||
<div className="absolute inset-0 z-30 bg-black pointer-events-none">
|
||||
{blockPreview.videoUrl ? (
|
||||
<video
|
||||
src={blockPreview.videoUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : blockPreview.posterUrl ? (
|
||||
<img
|
||||
src={blockPreview.posterUrl}
|
||||
alt={blockPreview.title}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : captionEditMode ? (
|
||||
<CaptionOverlay iframeRef={previewIframeRef} />
|
||||
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
|
||||
<DomEditOverlay
|
||||
iframeRef={previewIframeRef}
|
||||
activeCompositionPath={activeCompPath}
|
||||
hoverSelection={
|
||||
STUDIO_PREVIEW_SELECTION_ENABLED &&
|
||||
!captionEditMode &&
|
||||
!compositionLoading &&
|
||||
!isPlaying
|
||||
? domEditHoverSelection
|
||||
: null
|
||||
}
|
||||
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
||||
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
||||
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
|
||||
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
||||
onCanvasPointerMove={handlePreviewCanvasPointerMove}
|
||||
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
|
||||
onSelectionChange={applyDomSelection}
|
||||
onBlockedMove={handleBlockedDomMove}
|
||||
onManualDragStart={handleDomManualDragStart}
|
||||
onPathOffsetCommit={handleDomPathOffsetCommit}
|
||||
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
||||
onBoxSizeCommit={handleDomBoxSizeCommit}
|
||||
onRotationCommit={handleDomRotationCommit}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
timelineFooter={
|
||||
captionEditMode ? (
|
||||
<div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5">
|
||||
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
|
||||
Captions
|
||||
</span>
|
||||
<div className="flex-1 flex flex-col relative min-w-0">
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<NLELayout
|
||||
projectId={projectId}
|
||||
refreshKey={refreshKey}
|
||||
activeCompositionPath={activeCompPath}
|
||||
timelineToolbar={timelineToolbar}
|
||||
renderClipContent={renderClipContent}
|
||||
onDeleteElement={handleTimelineElementDelete}
|
||||
onAssetDrop={handleTimelineAssetDrop}
|
||||
onBlockDrop={handleTimelineBlockDrop}
|
||||
onPreviewBlockDrop={handlePreviewBlockDrop}
|
||||
onFileDrop={handleTimelineFileDrop}
|
||||
onMoveElement={handleTimelineElementMove}
|
||||
onResizeElement={handleTimelineElementResize}
|
||||
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
||||
onSelectTimelineElement={handleTimelineElementSelect}
|
||||
onCompIdToSrcChange={setCompIdToSrc}
|
||||
onCompositionLoadingChange={setCompositionLoading}
|
||||
onCompositionChange={(compPath) => {
|
||||
// Sync activeCompPath when user drills down via timeline double-click
|
||||
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
|
||||
// Guard against no-op updates to prevent circular refresh cascades
|
||||
// between activeCompPath → compositionStack → onCompositionChange.
|
||||
if (compPath !== activeCompPath) {
|
||||
setActiveCompPath(compPath);
|
||||
refreshPreviewDocumentVersion();
|
||||
}
|
||||
}}
|
||||
onIframeRef={handlePreviewIframeRef}
|
||||
previewOverlay={
|
||||
blockPreview ? (
|
||||
<div className="absolute inset-0 z-30 bg-black pointer-events-none">
|
||||
{blockPreview.videoUrl ? (
|
||||
<video
|
||||
src={blockPreview.videoUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : blockPreview.posterUrl ? (
|
||||
<img
|
||||
src={blockPreview.posterUrl}
|
||||
alt={blockPreview.title}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<CaptionTimeline pixelsPerSecond={100} />
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
timelineVisible={timelineVisible}
|
||||
onToggleTimeline={toggleTimelineVisibility}
|
||||
/>
|
||||
) : captionEditMode ? (
|
||||
<CaptionOverlay iframeRef={previewIframeRef} />
|
||||
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
|
||||
<DomEditOverlay
|
||||
iframeRef={previewIframeRef}
|
||||
activeCompositionPath={activeCompPath}
|
||||
hoverSelection={
|
||||
STUDIO_PREVIEW_SELECTION_ENABLED &&
|
||||
!captionEditMode &&
|
||||
!compositionLoading &&
|
||||
!isPlaying
|
||||
? domEditHoverSelection
|
||||
: null
|
||||
}
|
||||
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
||||
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
||||
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
|
||||
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
||||
onCanvasPointerMove={handlePreviewCanvasPointerMove}
|
||||
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
|
||||
onSelectionChange={applyDomSelection}
|
||||
onBlockedMove={handleBlockedDomMove}
|
||||
onManualDragStart={handleDomManualDragStart}
|
||||
onPathOffsetCommit={handleDomPathOffsetCommit}
|
||||
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
||||
onBoxSizeCommit={handleDomBoxSizeCommit}
|
||||
onRotationCommit={handleDomRotationCommit}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
timelineFooter={
|
||||
captionEditMode ? (
|
||||
<div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5">
|
||||
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
|
||||
Captions
|
||||
</span>
|
||||
</div>
|
||||
<CaptionTimeline pixelsPerSecond={100} />
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
timelineVisible={timelineVisible}
|
||||
onToggleTimeline={toggleTimelineVisibility}
|
||||
/>
|
||||
</div>
|
||||
<StudioFeedbackBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,3 +25,35 @@ export function trackStudioRenderStart(props: {
|
||||
composition: props.composition,
|
||||
});
|
||||
}
|
||||
|
||||
function getBrowserDoctorSummary(): string {
|
||||
try {
|
||||
const nav = navigator as Navigator & {
|
||||
deviceMemory?: number;
|
||||
connection?: { effectiveType?: string };
|
||||
userAgentData?: { platform?: string };
|
||||
};
|
||||
const platform = nav.userAgentData?.platform ?? navigator.platform ?? "unknown";
|
||||
const parts = [
|
||||
`ua=${platform}`,
|
||||
`screen=${screen.width}x${screen.height}@${devicePixelRatio}x`,
|
||||
`lang=${navigator.language}`,
|
||||
];
|
||||
if (nav.deviceMemory) parts.push(`mem=${nav.deviceMemory}GB`);
|
||||
if (nav.connection?.effectiveType) parts.push(`net=${nav.connection.effectiveType}`);
|
||||
if (navigator.hardwareConcurrency) parts.push(`cpu=${navigator.hardwareConcurrency}cores`);
|
||||
return parts.join(" ");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function trackStudioFeedback(props: { rating: number; comment?: string }): void {
|
||||
trackEvent("survey sent", {
|
||||
$survey_id: "studio_experience",
|
||||
$survey_response: props.rating,
|
||||
...(props.comment ? { $survey_response_2: props.comment } : {}),
|
||||
doctor_summary: getBrowserDoctorSummary(),
|
||||
source: "studio",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user