@hyperframes/studio 0.7.105 → 0.7.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-Jx07OQgw.js → hyperframes-player-BLrfwq5o.js} +1 -1
- package/dist/assets/{index-CryZ0PGi.js → index-BA9yhzfR.js} +1 -1
- package/dist/assets/{index-Dqi2GmOp.js → index-BSBk5srs.js} +1 -1
- package/dist/assets/{index-DuxKGsHZ.js → index-dF-CmLZu.js} +218 -218
- package/dist/assets/index-zQ4JFwwB.css +1 -0
- package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
- package/dist/chunk-AZYHQC6V.js.map +1 -0
- package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +5335 -3954
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/EditorShell.tsx +0 -2
- package/src/components/StudioErrorBoundary.test.tsx +97 -0
- package/src/components/StudioErrorBoundary.tsx +7 -0
- package/src/components/StudioOverlays.tsx +15 -13
- package/src/components/editor/DomEditOverlay.tsx +36 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +119 -31
- package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
- package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
- package/src/components/editor/InlineTextToolbar.tsx +281 -0
- package/src/components/editor/OffCanvasIndicators.tsx +38 -0
- package/src/components/editor/domEditInlineText.test.ts +86 -0
- package/src/components/editor/domEditInlineText.ts +99 -0
- package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +136 -2
- package/src/components/editor/domEditOverlayCrop.ts +99 -15
- package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
- package/src/components/editor/domEditOverlayGeometry.ts +13 -10
- package/src/components/editor/domEditOverlayTransform.ts +88 -0
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.ts +7 -6
- package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
- package/src/components/editor/inlineTextStyleRange.ts +593 -0
- package/src/components/editor/inlineTextStyleRead.ts +47 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useInlineTextEditing.tsx +119 -0
- package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
- package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
- package/src/components/feedback/feedbackTrigger.test.ts +168 -0
- package/src/components/feedback/feedbackTrigger.ts +269 -0
- package/src/components/feedback/projectProvenance.test.ts +120 -0
- package/src/components/feedback/projectProvenance.ts +83 -0
- package/src/components/renders/useRenderQueue.ts +62 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/domEditPersistFailure.ts +0 -7
- package/src/hooks/useAppHotkeys.ts +4 -4
- package/src/hooks/useDomEditCommits.test.tsx +103 -21
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +110 -11
- package/src/hooks/useFileTree.ts +8 -3
- package/src/hooks/useInlineTextEdit.test.tsx +555 -0
- package/src/hooks/useInlineTextEdit.ts +320 -0
- package/src/player/lib/playbackShortcuts.ts +5 -4
- package/src/telemetry/breadcrumbs.test.ts +68 -0
- package/src/telemetry/breadcrumbs.ts +70 -0
- package/src/telemetry/client.ts +5 -0
- package/src/telemetry/events.ts +84 -3
- package/src/utils/sourcePatcher.ts +5 -1
- package/src/utils/studioHelpers.ts +2 -5
- package/src/utils/timelineDiscovery.ts +3 -24
- package/src/utils/typingTarget.test.ts +54 -0
- package/src/utils/typingTarget.ts +43 -0
- package/dist/assets/index-tBPidglp.css +0 -1
- package/dist/chunk-OBAG3GWK.js.map +0 -1
- package/src/components/StudioFeedbackBar.tsx +0 -217
- /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Single owner of "should Studio ask for feedback right now, and about what".
|
|
3
|
+
//
|
|
4
|
+
// The prompt used to fire on a session counter (every 10th tab). That collected
|
|
5
|
+
// 5 responses in three months, because it asked at an arbitrary moment with no
|
|
6
|
+
// subject. It now fires on a render outcome — the moment the user has just
|
|
7
|
+
// formed an opinion — which is the same moment the CLI asks.
|
|
8
|
+
//
|
|
9
|
+
// Producers call `requestStudioFeedback`; the card subscribes. Eligibility
|
|
10
|
+
// lives here and nowhere else, so no caller has to know the cooldown rules.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
import { browserTelemetryAllowed } from "../../telemetry/policy";
|
|
14
|
+
|
|
15
|
+
export type FeedbackReason = "render_complete" | "render_failed" | "crash";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Everything except a finished render is a problem report: no 0-10 score (you
|
|
19
|
+
* cannot rate an export you never got, and a fabricated rating poisons the
|
|
20
|
+
* average) and the card wears its error styling.
|
|
21
|
+
*/
|
|
22
|
+
export function isProblemReason(reason: FeedbackReason): boolean {
|
|
23
|
+
return reason !== "render_complete";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A one-tap answer. `label` has to stand on its own — a chip that only makes
|
|
28
|
+
* sense once you hover it is a badly named chip, and most people never hover.
|
|
29
|
+
* `hint` carries the nuance a two-word label cannot, for the people who do.
|
|
30
|
+
*/
|
|
31
|
+
export interface FollowUpPreset {
|
|
32
|
+
label: string;
|
|
33
|
+
hint: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The follow-up asked after a score. One question per person, rotated across
|
|
38
|
+
* people: a corner card that asks three things gets answered by nobody, but the
|
|
39
|
+
* same card asking one thing of every third user covers the same ground.
|
|
40
|
+
*/
|
|
41
|
+
export interface FollowUpQuestion {
|
|
42
|
+
/** Sent with the response, so answers stay separable instead of collapsing
|
|
43
|
+
* into one undifferentiated comment field. */
|
|
44
|
+
id: string;
|
|
45
|
+
prompt: string;
|
|
46
|
+
placeholder: string;
|
|
47
|
+
/**
|
|
48
|
+
* Typing into a corner card is a cost most people decline, so the common
|
|
49
|
+
* answers are buttons and the field is the escape hatch. Keep these mutually
|
|
50
|
+
* distinct: two chips a user could reasonably read the same way collect a
|
|
51
|
+
* number that means nothing.
|
|
52
|
+
*/
|
|
53
|
+
presets: readonly FollowUpPreset[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The options below are not invented, and none of them is a brand.
|
|
57
|
+
//
|
|
58
|
+
// `fix` mirrors the themes that actually dominate written feedback: audio and
|
|
59
|
+
// encoding first by a wide margin, then media and fonts, then export-versus-
|
|
60
|
+
// preview mismatches, then render time. `workaround` mirrors the steps people
|
|
61
|
+
// report finishing in other tools. `remove` offers the surfaces that see the
|
|
62
|
+
// least real use, so the question contains genuine deletion candidates instead
|
|
63
|
+
// of a list nobody would pick from. `detractor` mirrors the failures Studio
|
|
64
|
+
// actually emits.
|
|
65
|
+
//
|
|
66
|
+
// Seven is the ceiling, not a target: past that the chips stop being scannable
|
|
67
|
+
// in a corner card and start being a form. Re-derive all of these when the
|
|
68
|
+
// shape of the feedback changes, because a stale option list quietly steers
|
|
69
|
+
// every answer it collects.
|
|
70
|
+
const FOLLOW_UPS: readonly FollowUpQuestion[] = [
|
|
71
|
+
{
|
|
72
|
+
id: "remove",
|
|
73
|
+
prompt: "What would you cut from Studio?",
|
|
74
|
+
placeholder: "The part you never use",
|
|
75
|
+
presets: [
|
|
76
|
+
{ label: "Layers panel", hint: "The layer tree on the right" },
|
|
77
|
+
{ label: "Variables panel", hint: "Composition variables on the right" },
|
|
78
|
+
{ label: "Blocks browser", hint: "The block library in the sidebar" },
|
|
79
|
+
{ label: "Storyboard mode", hint: "The storyboard view next to Preview" },
|
|
80
|
+
{ label: "Caption editing", hint: "Editing caption words and presets in Studio" },
|
|
81
|
+
{ label: "Render history", hint: "The list of past exports" },
|
|
82
|
+
{ label: "Nothing", hint: "It all earns its place" },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
// Replaces an earlier "which editor gets this right?". Brand names are a
|
|
87
|
+
// popularity vote, not an instruction: "CapCut" does not say what to build.
|
|
88
|
+
// What someone had to leave HyperFrames to do names the missing feature
|
|
89
|
+
// exactly, and written feedback is full of these workarounds already.
|
|
90
|
+
//
|
|
91
|
+
// Every option here is a step Studio genuinely cannot do today. Offering
|
|
92
|
+
// one it CAN do (trimming, voiceover, ducking, compositing, grading) would
|
|
93
|
+
// collect taps that mean "I could not find it", and there is no way to tell
|
|
94
|
+
// those apart from "it does not exist" afterwards. Check before adding.
|
|
95
|
+
id: "workaround",
|
|
96
|
+
prompt: "What did you have to do outside Studio?",
|
|
97
|
+
placeholder: "The step you finished somewhere else",
|
|
98
|
+
presets: [
|
|
99
|
+
{ label: "Fix the exported file", hint: "Remuxing or re-encoding the output by hand" },
|
|
100
|
+
{ label: "Convert source media", hint: "Re-encoding footage before it would import cleanly" },
|
|
101
|
+
{ label: "Match loudness", hint: "Normalising levels across the finished audio" },
|
|
102
|
+
{ label: "Make images or graphics", hint: "Creating visual assets in another tool" },
|
|
103
|
+
{ label: "Record footage", hint: "Capturing screen or camera video" },
|
|
104
|
+
{ label: "Nothing, it was all here", hint: "No outside tools needed" },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "fix",
|
|
109
|
+
prompt: "What should we fix first?",
|
|
110
|
+
placeholder: "The thing that slowed you down",
|
|
111
|
+
presets: [
|
|
112
|
+
{ label: "Audio mixing", hint: "Levels, ducking or the balance in the exported mix" },
|
|
113
|
+
{ label: "Export encoding", hint: "The render fails, or the finished file is wrong" },
|
|
114
|
+
{ label: "Export matches preview", hint: "The exported video differs from the preview" },
|
|
115
|
+
{ label: "Media and fonts", hint: "Adding video, images or fonts to a project" },
|
|
116
|
+
{ label: "Render speed", hint: "Exports take too long to finish" },
|
|
117
|
+
{ label: "Re-rendering everything", hint: "A small change means rendering the whole thing" },
|
|
118
|
+
{
|
|
119
|
+
label: "Checks and warnings",
|
|
120
|
+
hint: "Lint flags things that are fine, or misses real ones",
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
/** Detractors are not asked a rotated question; the complaint is the answer. */
|
|
127
|
+
const DETRACTOR_FOLLOW_UP: FollowUpQuestion = {
|
|
128
|
+
id: "detractor",
|
|
129
|
+
prompt: "What went wrong?",
|
|
130
|
+
placeholder: "The thing that made that a low score",
|
|
131
|
+
presets: [
|
|
132
|
+
{ label: "Export failed", hint: "The render did not finish" },
|
|
133
|
+
{ label: "Output looked wrong", hint: "It rendered, but not the way I built it" },
|
|
134
|
+
{ label: "Crashed or froze", hint: "The editor stopped responding" },
|
|
135
|
+
{ label: "Lost my edits", hint: "Changes did not save, or came back changed" },
|
|
136
|
+
{ label: "Too slow", hint: "Waiting on renders, or the editor itself lagging" },
|
|
137
|
+
{ label: "Could not figure it out", hint: "Could not find or understand something" },
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const DETRACTOR_MAX_RATING = 6;
|
|
142
|
+
|
|
143
|
+
export function followUpFor(rating: number): FollowUpQuestion {
|
|
144
|
+
if (rating <= DETRACTOR_MAX_RATING) return DETRACTOR_FOLLOW_UP;
|
|
145
|
+
// Rotation only has to spread across users, not be unguessable, so any
|
|
146
|
+
// per-prompt jitter works. Studio's determinism rules cover rendered
|
|
147
|
+
// compositions, not the editor chrome.
|
|
148
|
+
return FOLLOW_UPS[Math.floor(Math.random() * FOLLOW_UPS.length)];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Facts about the moment being reported on, sent with the response so a
|
|
153
|
+
* complaint arrives with the conditions that produced it instead of needing a
|
|
154
|
+
* round trip to ask. Producers fill this; the trigger never inspects it.
|
|
155
|
+
*
|
|
156
|
+
* Names and enums only, no free text: this rides to the same place the comment
|
|
157
|
+
* does, and the user consented to a comment, not to their project's contents.
|
|
158
|
+
*/
|
|
159
|
+
export interface FeedbackContext {
|
|
160
|
+
[key: string]: string | number | boolean | undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface FeedbackRequest {
|
|
164
|
+
reason: FeedbackReason;
|
|
165
|
+
/** Render job the prompt is about — joins the response to the render event. */
|
|
166
|
+
renderId?: string;
|
|
167
|
+
/** Failure text shown to the user, sent verbatim so reports are actionable. */
|
|
168
|
+
detail?: string;
|
|
169
|
+
/** Reproduction context: settings, counts, outcomes. See FeedbackContext. */
|
|
170
|
+
context?: FeedbackContext;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const STORAGE_KEYS = {
|
|
174
|
+
/** Epoch ms of the last prompt the user dismissed or ignored. */
|
|
175
|
+
dismissedAt: "hyperframes-studio:feedbackDismissedAt",
|
|
176
|
+
/** Epoch ms of the last prompt the user actually answered. */
|
|
177
|
+
answeredAt: "hyperframes-studio:feedbackAnsweredAt",
|
|
178
|
+
} as const;
|
|
179
|
+
|
|
180
|
+
/** One prompt per tab, so a batch of renders can't turn into a batch of asks. */
|
|
181
|
+
const SESSION_ASKED_KEY = "hyperframes-studio:feedbackAskedThisSession";
|
|
182
|
+
|
|
183
|
+
const DAY_MS = 86_400_000;
|
|
184
|
+
const ANSWERED_COOLDOWN_MS = 30 * DAY_MS;
|
|
185
|
+
const DISMISSED_COOLDOWN_MS = 7 * DAY_MS;
|
|
186
|
+
|
|
187
|
+
type Listener = (request: FeedbackRequest) => void;
|
|
188
|
+
|
|
189
|
+
let listener: Listener | null = null;
|
|
190
|
+
|
|
191
|
+
function isDisabled(): boolean {
|
|
192
|
+
try {
|
|
193
|
+
return import.meta.env.VITE_HYPERFRAMES_NO_FEEDBACK === "1";
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readTimestamp(key: string): number {
|
|
200
|
+
try {
|
|
201
|
+
return parseInt(window.localStorage.getItem(key) || "0", 10) || 0;
|
|
202
|
+
} catch {
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function writeTimestamp(key: string, now: number): void {
|
|
208
|
+
try {
|
|
209
|
+
window.localStorage.setItem(key, String(now));
|
|
210
|
+
} catch {
|
|
211
|
+
/* localStorage may be unavailable or full */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function askedThisSession(): boolean {
|
|
216
|
+
try {
|
|
217
|
+
return window.sessionStorage.getItem(SESSION_ASKED_KEY) === "1";
|
|
218
|
+
} catch {
|
|
219
|
+
// Without sessionStorage there is no way to bound the asks, so don't ask.
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function markAskedThisSession(): void {
|
|
225
|
+
try {
|
|
226
|
+
window.sessionStorage.setItem(SESSION_ASKED_KEY, "1");
|
|
227
|
+
} catch {
|
|
228
|
+
/* sessionStorage may be unavailable */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isEligible(now: number): boolean {
|
|
233
|
+
if (isDisabled()) return false;
|
|
234
|
+
// Prompting a user whose telemetry is off would collect a response we then
|
|
235
|
+
// drop on the floor. Ask only the people we can actually hear.
|
|
236
|
+
if (!browserTelemetryAllowed()) return false;
|
|
237
|
+
if (askedThisSession()) return false;
|
|
238
|
+
if (now - readTimestamp(STORAGE_KEYS.answeredAt) < ANSWERED_COOLDOWN_MS) return false;
|
|
239
|
+
if (now - readTimestamp(STORAGE_KEYS.dismissedAt) < DISMISSED_COOLDOWN_MS) return false;
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Ask for feedback about `request` if the user is due. Safe to call on every
|
|
245
|
+
* render outcome: ineligible calls are dropped without touching any state, so
|
|
246
|
+
* a user who just answered stays quiet rather than burning their next window.
|
|
247
|
+
*/
|
|
248
|
+
export function requestStudioFeedback(request: FeedbackRequest): void {
|
|
249
|
+
if (!listener) return;
|
|
250
|
+
if (!isEligible(Date.now())) return;
|
|
251
|
+
markAskedThisSession();
|
|
252
|
+
listener(request);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Subscribe the card. Single-subscriber by design — there is one card. */
|
|
256
|
+
export function subscribeToFeedbackRequests(next: Listener): () => void {
|
|
257
|
+
listener = next;
|
|
258
|
+
return () => {
|
|
259
|
+
if (listener === next) listener = null;
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function markFeedbackAnswered(): void {
|
|
264
|
+
writeTimestamp(STORAGE_KEYS.answeredAt, Date.now());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function markFeedbackDismissed(): void {
|
|
268
|
+
writeTimestamp(STORAGE_KEYS.dismissedAt, Date.now());
|
|
269
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
captureProjectProvenance,
|
|
6
|
+
projectProvenance,
|
|
7
|
+
resetProjectProvenance,
|
|
8
|
+
} from "./projectProvenance";
|
|
9
|
+
|
|
10
|
+
const CONFIG = "hyperframes.json";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The real `/files/*` route answers with an envelope, not the file. Mocking the
|
|
14
|
+
* raw config here instead let a broken parse pass the suite and fail live, so
|
|
15
|
+
* this helper is deliberately shaped like the server's actual response.
|
|
16
|
+
*/
|
|
17
|
+
function mockConfigResponse(fileContent: string, ok = true) {
|
|
18
|
+
vi.stubGlobal(
|
|
19
|
+
"fetch",
|
|
20
|
+
vi.fn(async () => ({
|
|
21
|
+
ok,
|
|
22
|
+
json: async () => ({ filename: CONFIG, content: fileContent, version: "abc" }),
|
|
23
|
+
})),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
resetProjectProvenance();
|
|
29
|
+
vi.unstubAllGlobals();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("captureProjectProvenance", () => {
|
|
33
|
+
it("reads the authoring workflow that built the project", async () => {
|
|
34
|
+
mockConfigResponse(JSON.stringify({ authoringSkill: "faceless-explainer" }));
|
|
35
|
+
await captureProjectProvenance("p1", [CONFIG, "index.html"], ["index.html"]);
|
|
36
|
+
expect(projectProvenance()).toMatchObject({
|
|
37
|
+
project_scaffolded: true,
|
|
38
|
+
project_authoring_skill: "faceless-explainer",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("counts compositions and media, which is what actually reproduces a bug", async () => {
|
|
43
|
+
mockConfigResponse("{}");
|
|
44
|
+
await captureProjectProvenance(
|
|
45
|
+
"p1",
|
|
46
|
+
[CONFIG, "index.html", "a.html", "assets/clip.mp4", "assets/vo.wav", "assets/logo.png"],
|
|
47
|
+
["index.html", "a.html"],
|
|
48
|
+
);
|
|
49
|
+
expect(projectProvenance()).toMatchObject({
|
|
50
|
+
project_composition_count: 2,
|
|
51
|
+
project_media_count: 3,
|
|
52
|
+
project_file_count: 6,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("flags a project that init never scaffolded, and skips the config fetch", async () => {
|
|
57
|
+
const fetchSpy = vi.fn();
|
|
58
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
59
|
+
await captureProjectProvenance("p1", ["index.html"], ["index.html"]);
|
|
60
|
+
expect(projectProvenance().project_scaffolded).toBe(false);
|
|
61
|
+
expect(projectProvenance().project_authoring_skill).toBeUndefined();
|
|
62
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Every way the config can fail to yield a skill must still leave the counts
|
|
66
|
+
// behind: a partial report beats no report.
|
|
67
|
+
it.each([
|
|
68
|
+
["the config is corrupt", () => mockConfigResponse("{ not json")],
|
|
69
|
+
[
|
|
70
|
+
"the envelope has no content field",
|
|
71
|
+
() =>
|
|
72
|
+
vi.stubGlobal(
|
|
73
|
+
"fetch",
|
|
74
|
+
vi.fn(async () => ({ ok: true, json: async () => ({ error: "not found" }) })),
|
|
75
|
+
),
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
"the fetch fails outright",
|
|
79
|
+
() =>
|
|
80
|
+
vi.stubGlobal(
|
|
81
|
+
"fetch",
|
|
82
|
+
vi.fn(async () => {
|
|
83
|
+
throw new Error("offline");
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
],
|
|
87
|
+
["the route answers non-ok", () => mockConfigResponse("{}", false)],
|
|
88
|
+
])("keeps the counts when %s", async (_case, arrange) => {
|
|
89
|
+
arrange();
|
|
90
|
+
await expect(
|
|
91
|
+
captureProjectProvenance("p1", [CONFIG, "index.html"], ["index.html"]),
|
|
92
|
+
).resolves.toBeUndefined();
|
|
93
|
+
expect(projectProvenance().project_scaffolded).toBe(true);
|
|
94
|
+
expect(projectProvenance().project_composition_count).toBe(1);
|
|
95
|
+
expect(projectProvenance().project_authoring_skill).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("privacy", () => {
|
|
99
|
+
it("drops a hand-edited skill that is not a slug, so no free text escapes", async () => {
|
|
100
|
+
mockConfigResponse(
|
|
101
|
+
JSON.stringify({ authoringSkill: "my client's secret campaign /Users/me/x" }),
|
|
102
|
+
);
|
|
103
|
+
await captureProjectProvenance("p1", [CONFIG], []);
|
|
104
|
+
expect(projectProvenance().project_authoring_skill).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("never copies file names, paths or the project title", async () => {
|
|
108
|
+
mockConfigResponse(JSON.stringify({ authoringSkill: "slideshow", title: "Secret Launch" }));
|
|
109
|
+
await captureProjectProvenance(
|
|
110
|
+
"p1",
|
|
111
|
+
[CONFIG, "assets/unreleased-product-hero.mp4"],
|
|
112
|
+
["index.html"],
|
|
113
|
+
);
|
|
114
|
+
const flat = JSON.stringify(projectProvenance());
|
|
115
|
+
expect(flat).not.toContain("unreleased");
|
|
116
|
+
expect(flat).not.toContain("Secret");
|
|
117
|
+
expect(flat).not.toContain("assets/");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// How this project came to exist, and roughly what shape it is.
|
|
3
|
+
//
|
|
4
|
+
// A crash report says what broke. It does not say what the user was working ON,
|
|
5
|
+
// and "a project made by /faceless-explainer with 14 compositions and 60 assets"
|
|
6
|
+
// reproduces a crash that "Cannot read properties of undefined" never will.
|
|
7
|
+
//
|
|
8
|
+
// Held at module scope on purpose: a crash unmounts the React tree, so anything
|
|
9
|
+
// living in component state is gone by the time the crash prompt renders. This
|
|
10
|
+
// survives, because it was captured when the project loaded.
|
|
11
|
+
//
|
|
12
|
+
// PRIVACY: counts, a skill slug, and booleans. No file names, no paths, no
|
|
13
|
+
// project title. The user consented to a comment, not to an inventory of their
|
|
14
|
+
// work.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
import type { FeedbackContext } from "./feedbackTrigger";
|
|
18
|
+
|
|
19
|
+
/** Written by `hyperframes init`; absent in a hand-made or copied project. */
|
|
20
|
+
const CONFIG_FILE = "hyperframes.json";
|
|
21
|
+
|
|
22
|
+
/** Matches the CLI's own slug gate, so a hand-edited value cannot leak text. */
|
|
23
|
+
const SKILL_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
24
|
+
|
|
25
|
+
const MEDIA_EXTENSIONS = /\.(mp4|mov|webm|m4v|mp3|wav|m4a|aac|ogg|png|jpe?g|gif|webp|svg|avif)$/i;
|
|
26
|
+
|
|
27
|
+
let snapshot: FeedbackContext = {};
|
|
28
|
+
|
|
29
|
+
function countMedia(files: string[]): number {
|
|
30
|
+
return files.filter((f) => MEDIA_EXTENSIONS.test(f)).length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Record what we can see from the project listing, then fill in the authoring
|
|
35
|
+
* skill from `hyperframes.json` if the project has one. Failure is silent and
|
|
36
|
+
* partial: a report with the counts but no skill still beats no report.
|
|
37
|
+
*/
|
|
38
|
+
export async function captureProjectProvenance(
|
|
39
|
+
projectId: string,
|
|
40
|
+
files: string[],
|
|
41
|
+
compositions: string[],
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const scaffolded = files.includes(CONFIG_FILE);
|
|
44
|
+
snapshot = {
|
|
45
|
+
// False means the project was copied, hand-written, or predates init.
|
|
46
|
+
// Those reproduce differently from a scaffolded one, so the flag matters
|
|
47
|
+
// even when the skill below is missing.
|
|
48
|
+
project_scaffolded: scaffolded,
|
|
49
|
+
project_composition_count: compositions.length,
|
|
50
|
+
project_file_count: files.length,
|
|
51
|
+
project_media_count: countMedia(files),
|
|
52
|
+
};
|
|
53
|
+
if (!scaffolded) return;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`/api/projects/${projectId}/files/${encodeURIComponent(CONFIG_FILE)}`);
|
|
57
|
+
if (!res.ok) return;
|
|
58
|
+
// The route answers with an envelope, not the file: {filename, content,
|
|
59
|
+
// version}. The config is the `content` string inside it.
|
|
60
|
+
const envelope: unknown = await res.json();
|
|
61
|
+
const raw = (envelope as { content?: unknown }).content;
|
|
62
|
+
if (typeof raw !== "string") return;
|
|
63
|
+
const parsed: unknown = JSON.parse(raw);
|
|
64
|
+
if (typeof parsed !== "object" || parsed === null) return;
|
|
65
|
+
const skill = (parsed as { authoringSkill?: unknown }).authoringSkill;
|
|
66
|
+
// The workflow that built this project: which of the creation skills the
|
|
67
|
+
// agent ran. This is the closest thing to "how was it made" we can get.
|
|
68
|
+
if (typeof skill === "string" && SKILL_SLUG.test(skill)) {
|
|
69
|
+
snapshot.project_authoring_skill = skill;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Missing, unreadable or corrupt config: keep the counts, drop the skill.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function projectProvenance(): FeedbackContext {
|
|
77
|
+
return snapshot;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Test seam. */
|
|
81
|
+
export function resetProjectProvenance(): void {
|
|
82
|
+
snapshot = {};
|
|
83
|
+
}
|
|
@@ -4,6 +4,7 @@ import { trackStudioRenderStart } from "../../telemetry/events";
|
|
|
4
4
|
import { getAnonymousId } from "../../telemetry/config";
|
|
5
5
|
import { browserTelemetryAllowed } from "../../telemetry/policy";
|
|
6
6
|
import { generateId } from "../../utils/generateId";
|
|
7
|
+
import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger";
|
|
7
8
|
|
|
8
9
|
export interface RenderJob {
|
|
9
10
|
id: string;
|
|
@@ -72,6 +73,23 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
72
73
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
73
74
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
74
75
|
const activeJobRef = useRef<string | null>(null);
|
|
76
|
+
// Renders started in THIS tab, mapped to the settings they ran with.
|
|
77
|
+
// `loadRenders` also injects finished jobs from disk history, and those must
|
|
78
|
+
// never trigger a feedback prompt — the user did not just watch them happen.
|
|
79
|
+
const sessionJobs = useRef(new Map<string, FeedbackContext>());
|
|
80
|
+
const promptedJobIds = useRef(new Set<string>());
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The one way a render started here enters the list. Every start path — the
|
|
84
|
+
* happy one and all three failure shortcuts — goes through here, so both
|
|
85
|
+
* "this render belongs to this session" and "these are the settings it ran
|
|
86
|
+
* with" have a single owner. A report about a render is only actionable if
|
|
87
|
+
* it arrives with the settings that produced it.
|
|
88
|
+
*/
|
|
89
|
+
const addSessionJob = useCallback((job: RenderJob, settings: FeedbackContext) => {
|
|
90
|
+
sessionJobs.current.set(job.id, settings);
|
|
91
|
+
setJobs((prev) => [...prev, job]);
|
|
92
|
+
}, []);
|
|
75
93
|
|
|
76
94
|
const closeActiveEventSource = useCallback((jobId?: string) => {
|
|
77
95
|
if (jobId && activeJobRef.current !== jobId) return;
|
|
@@ -148,6 +166,16 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
148
166
|
});
|
|
149
167
|
|
|
150
168
|
const startTime = Date.now();
|
|
169
|
+
// Travels with any feedback about this render. Settings only: the
|
|
170
|
+
// composition path is a name the user chose, not file contents.
|
|
171
|
+
const settings: FeedbackContext = {
|
|
172
|
+
render_format: format,
|
|
173
|
+
render_quality: quality,
|
|
174
|
+
render_fps: fps,
|
|
175
|
+
render_resolution: resolution ?? "auto",
|
|
176
|
+
render_composition: composition ?? "index.html",
|
|
177
|
+
render_has_variables: Boolean(opts.variables && Object.keys(opts.variables).length > 0),
|
|
178
|
+
};
|
|
151
179
|
// "auto" / undefined means "render at the composition's authored size".
|
|
152
180
|
// Omit the field entirely — sending "auto" would trip the route's
|
|
153
181
|
// enum validation set.
|
|
@@ -201,7 +229,7 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
201
229
|
filename: "Export failed",
|
|
202
230
|
createdAt: startTime,
|
|
203
231
|
};
|
|
204
|
-
|
|
232
|
+
addSessionJob(failedJob, settings);
|
|
205
233
|
return;
|
|
206
234
|
}
|
|
207
235
|
if (!res.ok) {
|
|
@@ -213,7 +241,7 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
213
241
|
filename: "Export failed",
|
|
214
242
|
createdAt: startTime,
|
|
215
243
|
};
|
|
216
|
-
|
|
244
|
+
addSessionJob(failedJob, settings);
|
|
217
245
|
return;
|
|
218
246
|
}
|
|
219
247
|
const { jobId } = await res.json();
|
|
@@ -227,7 +255,7 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
227
255
|
filename: `${jobId}${ext}`,
|
|
228
256
|
createdAt: startTime,
|
|
229
257
|
};
|
|
230
|
-
|
|
258
|
+
addSessionJob(job, settings);
|
|
231
259
|
activeJobRef.current = jobId;
|
|
232
260
|
|
|
233
261
|
// Track progress via SSE
|
|
@@ -279,7 +307,7 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
279
307
|
|
|
280
308
|
return jobId;
|
|
281
309
|
},
|
|
282
|
-
[projectId, closeActiveEventSource],
|
|
310
|
+
[projectId, closeActiveEventSource, addSessionJob],
|
|
283
311
|
);
|
|
284
312
|
|
|
285
313
|
// Cancel an in-flight render. The job row stays (as "cancelled") so the
|
|
@@ -352,6 +380,36 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
352
380
|
|
|
353
381
|
const dismissActionError = useCallback(() => setActionError(null), []);
|
|
354
382
|
|
|
383
|
+
// Ask for feedback the moment a render this tab started reaches its outcome.
|
|
384
|
+
// Watching the list (rather than each of the four places a job can finish)
|
|
385
|
+
// keeps one trigger for every path, including SSE drops and cancels-that-
|
|
386
|
+
// finished-anyway. `requestStudioFeedback` decides whether to actually ask.
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
for (const job of jobs) {
|
|
389
|
+
if (job.status === "rendering" || job.status === "cancelled") continue;
|
|
390
|
+
const settings = sessionJobs.current.get(job.id);
|
|
391
|
+
if (!settings || promptedJobIds.current.has(job.id)) continue;
|
|
392
|
+
promptedJobIds.current.add(job.id);
|
|
393
|
+
requestStudioFeedback({
|
|
394
|
+
reason: job.status === "complete" ? "render_complete" : "render_failed",
|
|
395
|
+
renderId: job.id,
|
|
396
|
+
detail: job.error,
|
|
397
|
+
context: {
|
|
398
|
+
...settings,
|
|
399
|
+
// How far it got and how long it took separate "died on frame one"
|
|
400
|
+
// from "died during encode", which need different fixes.
|
|
401
|
+
render_progress: job.progress,
|
|
402
|
+
render_duration_ms: job.durationMs ?? Date.now() - job.createdAt,
|
|
403
|
+
render_stage: job.stage,
|
|
404
|
+
render_error: job.error,
|
|
405
|
+
// Earlier renders this session: a first-render failure and a
|
|
406
|
+
// failure after nine successes are different bugs.
|
|
407
|
+
renders_this_session: sessionJobs.current.size,
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}, [jobs]);
|
|
412
|
+
|
|
355
413
|
// Clean up EventSource on unmount or projectId change
|
|
356
414
|
useEffect(() => {
|
|
357
415
|
return () => {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./AgentChatMessageButton";
|
|
12
12
|
import { FRAME_STATUS_META } from "./frameStatus";
|
|
13
13
|
import type { CommentsSubmitState } from "./useFrameComments";
|
|
14
|
+
import { isTypingTarget } from "../../utils/typingTarget";
|
|
14
15
|
|
|
15
16
|
export interface StoryboardFrameFocusProps {
|
|
16
17
|
projectId: string;
|
|
@@ -140,8 +141,7 @@ export function StoryboardFrameFocus({
|
|
|
140
141
|
useEffect(() => {
|
|
141
142
|
// fallow-ignore-next-line complexity
|
|
142
143
|
const onKey = (e: KeyboardEvent) => {
|
|
143
|
-
|
|
144
|
-
if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) return;
|
|
144
|
+
if (isTypingTarget(document.activeElement)) return;
|
|
145
145
|
if (e.key === "Escape") handleBack();
|
|
146
146
|
else if (e.key === "ArrowLeft" && frame.index > 1) handleNavigate(-1);
|
|
147
147
|
else if (e.key === "ArrowRight" && frame.index < frameCount) handleNavigate(1);
|
|
@@ -26,6 +26,7 @@ vi.mock("./FramePoster", () => ({
|
|
|
26
26
|
}));
|
|
27
27
|
|
|
28
28
|
const onSelectComposition = vi.fn();
|
|
29
|
+
const onNavigate = vi.fn();
|
|
29
30
|
|
|
30
31
|
function TestApp() {
|
|
31
32
|
const viewMode = useViewModeState();
|
|
@@ -48,9 +49,9 @@ function TestApp() {
|
|
|
48
49
|
narrative: "",
|
|
49
50
|
extra: {},
|
|
50
51
|
}}
|
|
51
|
-
frameCount={
|
|
52
|
+
frameCount={2}
|
|
52
53
|
onBack={vi.fn()}
|
|
53
|
-
onNavigate={
|
|
54
|
+
onNavigate={onNavigate}
|
|
54
55
|
onSaved={vi.fn()}
|
|
55
56
|
onSelectComposition={onSelectComposition}
|
|
56
57
|
scriptExists={false}
|
|
@@ -109,6 +110,7 @@ beforeEach(() => {
|
|
|
109
110
|
window.history.replaceState({ entry: "timeline" }, "", "/");
|
|
110
111
|
window.history.pushState({ entry: "storyboard" }, "", "/?view=storyboard");
|
|
111
112
|
onSelectComposition.mockReset();
|
|
113
|
+
onNavigate.mockReset();
|
|
112
114
|
});
|
|
113
115
|
|
|
114
116
|
afterEach(() => {
|
|
@@ -168,3 +170,18 @@ describe("dirty storyboard voiceover view-mode guard", () => {
|
|
|
168
170
|
act(() => root.unmount());
|
|
169
171
|
});
|
|
170
172
|
});
|
|
173
|
+
|
|
174
|
+
describe("storyboard frame keyboard navigation", () => {
|
|
175
|
+
it("leaves arrow keys with contenteditable text", () => {
|
|
176
|
+
const { host, root } = renderApp();
|
|
177
|
+
const editor = document.createElement("div");
|
|
178
|
+
editor.contentEditable = "true";
|
|
179
|
+
host.append(editor);
|
|
180
|
+
editor.focus();
|
|
181
|
+
|
|
182
|
+
act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" })));
|
|
183
|
+
|
|
184
|
+
expect(onNavigate).not.toHaveBeenCalled();
|
|
185
|
+
act(() => root.unmount());
|
|
186
|
+
});
|
|
187
|
+
});
|