@veolab/discoverylab 1.3.2 → 1.3.3

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.
@@ -4,20 +4,9 @@ import {
4
4
  createTextResult
5
5
  } from "./chunk-XKX6NBHF.js";
6
6
  import {
7
- createLoginFlow,
8
- createNavigationTestFlow,
9
- createOnboardingFlow,
10
- generateMaestroFlow,
11
- getAdbCommand,
12
7
  getAvailableTemplates,
13
- getMaestroVersion,
14
- isMaestroInstalled,
15
- isTemplatesInstalled,
16
- listMaestroDevices,
17
- runMaestroTest,
18
- runMaestroWithCapture,
19
- startMaestroStudio
20
- } from "./chunk-VEIZLLCI.js";
8
+ isTemplatesInstalled
9
+ } from "./chunk-HGWEHWKJ.js";
21
10
  import {
22
11
  createFormSubmissionScript,
23
12
  createLoginScript,
@@ -35,12 +24,18 @@ import {
35
24
  buildAppLabNetworkProfile
36
25
  } from "./chunk-LB3RNE3O.js";
37
26
  import {
27
+ LOCAL_ESVP_SERVER_URL,
38
28
  attachESVPNetworkTrace,
39
29
  captureESVPCheckpoint,
40
30
  clearESVPNetwork,
41
31
  configureESVPNetwork,
42
32
  createESVPSession,
33
+ createLoginFlow,
34
+ createNavigationTestFlow,
35
+ createOnboardingFlow,
43
36
  finishESVPSession,
37
+ generateMaestroFlow,
38
+ getAdbCommand,
44
39
  getESVPArtifactContent,
45
40
  getESVPConnection,
46
41
  getESVPHealth,
@@ -48,15 +43,21 @@ import {
48
43
  getESVPSession,
49
44
  getESVPSessionNetwork,
50
45
  getESVPTranscript,
46
+ getMaestroVersion,
51
47
  inspectESVPSession,
48
+ isMaestroInstalled,
52
49
  listESVPArtifacts,
53
50
  listESVPDevices,
54
51
  listESVPSessions,
52
+ listMaestroDevices,
55
53
  replayESVPSession,
56
54
  runESVPActions,
57
55
  runESVPPreflight,
56
+ runMaestroTest,
57
+ runMaestroWithCapture,
58
+ startMaestroStudio,
58
59
  validateESVPReplay
59
- } from "./chunk-GAKEFJ5T.js";
60
+ } from "./chunk-7EDIUVIO.js";
60
61
  import {
61
62
  DATA_DIR,
62
63
  EXPORTS_DIR,
@@ -6452,9 +6453,14 @@ function resolveProjectRecordingReplaySessionId(session) {
6452
6453
  }
6453
6454
  function resolveProjectRecordingESVPServerUrl(session) {
6454
6455
  const esvp = resolveProjectRecordingESVPState(session);
6455
- if (!esvp || typeof esvp.serverUrl !== "string") return void 0;
6456
- const serverUrl = esvp.serverUrl.trim();
6457
- return serverUrl || void 0;
6456
+ const serverUrl = typeof esvp?.serverUrl === "string" ? esvp.serverUrl.trim().replace(/\/+$/, "") : "";
6457
+ if (serverUrl === LOCAL_ESVP_SERVER_URL) {
6458
+ return LOCAL_ESVP_SERVER_URL;
6459
+ }
6460
+ if (!serverUrl && esvp?.connectionMode === "local") {
6461
+ return LOCAL_ESVP_SERVER_URL;
6462
+ }
6463
+ return void 0;
6458
6464
  }
6459
6465
  function resolveAppLabBaseUrl(appLabUrl) {
6460
6466
  const raw = String(appLabUrl || process.env.DISCOVERYLAB_APP_URL || "http://127.0.0.1:3847").trim();
@@ -0,0 +1,94 @@
1
+ import {
2
+ TEMPLATES_DIR
3
+ } from "./chunk-VVIOB362.js";
4
+
5
+ // src/core/templates/loader.ts
6
+ import { existsSync, readFileSync } from "fs";
7
+ import { join, dirname } from "path";
8
+ import { fileURLToPath } from "url";
9
+ var MANIFEST_FILE = "manifest.json";
10
+ var BUNDLE_DIR = "bundle";
11
+ var __dirname = dirname(fileURLToPath(import.meta.url));
12
+ var BUNDLED_TEMPLATES_DIR_CANDIDATES = [
13
+ join(__dirname, "templates"),
14
+ join(__dirname, "..", "templates"),
15
+ join(__dirname, "..", "..", "templates")
16
+ ];
17
+ var cachedManifest = null;
18
+ var cachedTemplatesDir = null;
19
+ var cachedAt = 0;
20
+ var CACHE_TTL = 3e4;
21
+ function resolveTemplatesDir() {
22
+ const explicitOverride = process.env.DISCOVERYLAB_TEMPLATE_DIR?.trim() || process.env.DISCOVERYLAB_TEMPLATE_SOURCE_DIR?.trim();
23
+ if (explicitOverride) {
24
+ const overrideManifest = join(explicitOverride, MANIFEST_FILE);
25
+ const overrideBundle = join(explicitOverride, BUNDLE_DIR);
26
+ if (existsSync(overrideManifest) && existsSync(overrideBundle)) {
27
+ return explicitOverride;
28
+ }
29
+ }
30
+ for (const candidate of BUNDLED_TEMPLATES_DIR_CANDIDATES) {
31
+ const bundledManifest = join(candidate, MANIFEST_FILE);
32
+ const bundledBundle = join(candidate, BUNDLE_DIR);
33
+ if (existsSync(bundledManifest) && existsSync(bundledBundle)) {
34
+ return candidate;
35
+ }
36
+ }
37
+ const localManifest = join(TEMPLATES_DIR, MANIFEST_FILE);
38
+ const localBundle = join(TEMPLATES_DIR, BUNDLE_DIR);
39
+ if (existsSync(localManifest) && existsSync(localBundle)) {
40
+ return TEMPLATES_DIR;
41
+ }
42
+ return null;
43
+ }
44
+ function loadManifest() {
45
+ const now = Date.now();
46
+ if (cachedManifest && now - cachedAt < CACHE_TTL) {
47
+ return cachedManifest;
48
+ }
49
+ const dir = resolveTemplatesDir();
50
+ if (!dir) {
51
+ cachedManifest = null;
52
+ cachedTemplatesDir = null;
53
+ cachedAt = now;
54
+ return null;
55
+ }
56
+ try {
57
+ const raw = readFileSync(join(dir, MANIFEST_FILE), "utf-8");
58
+ const manifest = JSON.parse(raw);
59
+ cachedManifest = manifest;
60
+ cachedTemplatesDir = dir;
61
+ cachedAt = now;
62
+ return manifest;
63
+ } catch {
64
+ cachedManifest = null;
65
+ cachedTemplatesDir = null;
66
+ cachedAt = now;
67
+ return null;
68
+ }
69
+ }
70
+ function isTemplatesInstalled() {
71
+ return loadManifest() !== null;
72
+ }
73
+ function getAvailableTemplates() {
74
+ const manifest = loadManifest();
75
+ return manifest?.templates ?? [];
76
+ }
77
+ function getTemplate(id) {
78
+ const templates = getAvailableTemplates();
79
+ return templates.find((t) => t.id === id) ?? null;
80
+ }
81
+ function getBundlePath() {
82
+ loadManifest();
83
+ if (!cachedTemplatesDir) return null;
84
+ const bundlePath = join(cachedTemplatesDir, BUNDLE_DIR);
85
+ if (!existsSync(bundlePath)) return null;
86
+ return bundlePath;
87
+ }
88
+
89
+ export {
90
+ isTemplatesInstalled,
91
+ getAvailableTemplates,
92
+ getTemplate,
93
+ getBundlePath
94
+ };
@@ -1,25 +1,9 @@
1
1
  import {
2
- findAndroidSdkPath,
3
- getAdbCommand,
4
2
  getAvailableTemplates,
5
3
  getBundlePath,
6
- getEmulatorPath,
7
- getMaestroRecorder,
8
4
  getTemplate,
9
- isIdbInstalled,
10
- isMaestroInstalled,
11
- isTemplatesInstalled,
12
- killZombieMaestroProcesses,
13
- listConnectedAndroidDevices,
14
- listMaestroDevices,
15
- parseMaestroActionsFromYaml,
16
- resolveAndroidDeviceSerial,
17
- runMaestroTest,
18
- tapViaIdb
19
- } from "./chunk-VEIZLLCI.js";
20
- import {
21
- APP_VERSION
22
- } from "./chunk-6EGBXRDK.js";
5
+ isTemplatesInstalled
6
+ } from "./chunk-HGWEHWKJ.js";
23
7
  import {
24
8
  runPlaywrightTest
25
9
  } from "./chunk-FIL7IWEL.js";
@@ -37,20 +21,37 @@ import {
37
21
  resolveLocalAppHttpTraceCollectorById,
38
22
  startLocalAppHttpTraceCollector,
39
23
  validateMaestroRecordingWithESVP
40
- } from "./chunk-N6JJ2RGV.js";
24
+ } from "./chunk-ZLHIHMSL.js";
41
25
  import {
42
26
  buildAppLabNetworkProfile
43
27
  } from "./chunk-LB3RNE3O.js";
44
28
  import {
29
+ LOCAL_ESVP_SERVER_URL,
45
30
  attachESVPNetworkTrace,
46
31
  configureESVPNetwork,
47
32
  createESVPSession,
33
+ findAndroidSdkPath,
34
+ getAdbCommand,
48
35
  getESVPReplayConsistency,
36
+ getEmulatorPath,
37
+ getMaestroRecorder,
49
38
  inspectESVPSession,
39
+ isIdbInstalled,
40
+ isMaestroInstalled,
41
+ killZombieMaestroProcesses,
42
+ listConnectedAndroidDevices,
43
+ listMaestroDevices,
44
+ parseMaestroActionsFromYaml,
50
45
  replayESVPSession,
46
+ resolveAndroidDeviceSerial,
51
47
  runESVPActions,
48
+ runMaestroTest,
49
+ tapViaIdb,
52
50
  validateESVPReplay
53
- } from "./chunk-GAKEFJ5T.js";
51
+ } from "./chunk-7EDIUVIO.js";
52
+ import {
53
+ APP_VERSION
54
+ } from "./chunk-6EGBXRDK.js";
54
55
  import {
55
56
  redactQuotedStringsInText,
56
57
  redactSensitiveTestInput
@@ -4589,9 +4590,20 @@ function resolveProjectESVPSessionId(esvp) {
4589
4590
  return validationId || networkId || directId || null;
4590
4591
  }
4591
4592
  function resolveProjectESVPServerUrl(esvp) {
4592
- if (!esvp || typeof esvp.serverUrl !== "string") return void 0;
4593
- const serverUrl = esvp.serverUrl.trim();
4594
- return serverUrl || void 0;
4593
+ return normalizePersistedLocalESVPServerUrl(esvp?.serverUrl, esvp?.connectionMode);
4594
+ }
4595
+ function normalizePersistedLocalESVPServerUrl(serverUrl, connectionMode) {
4596
+ const normalized = typeof serverUrl === "string" ? serverUrl.trim().replace(/\/+$/, "") : "";
4597
+ if (normalized === LOCAL_ESVP_SERVER_URL) {
4598
+ return LOCAL_ESVP_SERVER_URL;
4599
+ }
4600
+ if (!normalized && connectionMode === "local") {
4601
+ return LOCAL_ESVP_SERVER_URL;
4602
+ }
4603
+ return void 0;
4604
+ }
4605
+ function resolvePersistedLocalESVPServerUrl(serverUrl, esvp) {
4606
+ return normalizePersistedLocalESVPServerUrl(serverUrl, "local") || resolveProjectESVPServerUrl(esvp) || LOCAL_ESVP_SERVER_URL;
4595
4607
  }
4596
4608
  function isESVPReplayValidationSupported(result) {
4597
4609
  if (!result) return true;
@@ -6433,7 +6445,7 @@ app.post("/api/testing/mobile/recordings/:id/esvp/validate", async (c) => {
6433
6445
  let autoSynced = false;
6434
6446
  if (result.networkEntries.length === 0 && result.supported && result.sourceSessionId && ((result.networkState?.managed_proxy?.entry_count ?? 0) > 0 || (result.networkState?.trace_count ?? 0) > 0)) {
6435
6447
  try {
6436
- const { collectESVPSessionNetworkData: collectESVPSessionNetworkData2 } = await import("./esvp-mobile-GC7MAGMI.js");
6448
+ const { collectESVPSessionNetworkData: collectESVPSessionNetworkData2 } = await import("./esvp-mobile-GZ5EMYPG.js");
6437
6449
  const deferred = await collectESVPSessionNetworkData2(result.sourceSessionId, serverUrl);
6438
6450
  if (deferred.networkEntries.length > 0) {
6439
6451
  session.networkEntries = deferred.networkEntries;
@@ -6517,7 +6529,7 @@ app.post("/api/testing/mobile/recordings/:id/esvp/replay", async (c) => {
6517
6529
  session.esvp = {
6518
6530
  ...esvp || {},
6519
6531
  currentSessionId: sourceSessionId,
6520
- serverUrl: serverUrl || (typeof esvp?.serverUrl === "string" ? esvp.serverUrl : null),
6532
+ serverUrl: resolvePersistedLocalESVPServerUrl(serverUrl, esvp),
6521
6533
  executor,
6522
6534
  validation: {
6523
6535
  ...existingValidation,
@@ -6660,7 +6672,10 @@ app.post("/api/testing/mobile/recordings/:id/esvp/network/start", async (c) => {
6660
6672
  ...session.esvp && typeof session.esvp === "object" ? session.esvp : {},
6661
6673
  currentSessionId: sourceSessionId,
6662
6674
  connectionMode: typeof session?.esvp?.connectionMode === "string" ? session.esvp.connectionMode : "local",
6663
- serverUrl: serverUrl || (typeof session?.esvp?.serverUrl === "string" ? session.esvp.serverUrl : null),
6675
+ serverUrl: resolvePersistedLocalESVPServerUrl(
6676
+ serverUrl,
6677
+ session?.esvp && typeof session.esvp === "object" ? session.esvp : null
6678
+ ),
6664
6679
  executor,
6665
6680
  network: {
6666
6681
  ...existingNetwork || {},
@@ -9938,11 +9953,11 @@ app.post("/api/templates/props", async (c) => {
9938
9953
  if (!project) {
9939
9954
  return c.json({ error: "Project not found" }, 404);
9940
9955
  }
9941
- const props = assembleTemplateProps(project);
9942
- if (!props) {
9956
+ const templateState = getTemplateProjectState(project);
9957
+ if (!templateState) {
9943
9958
  return c.json({ error: "Project has no video to render" }, 400);
9944
9959
  }
9945
- return c.json({ props });
9960
+ return c.json(templateState);
9946
9961
  } catch (error) {
9947
9962
  const message = error instanceof Error ? error.message : "Unknown error";
9948
9963
  return c.json({ error: message }, 500);
@@ -10000,26 +10015,39 @@ app.put("/api/projects/:id/template-content", async (c) => {
10000
10015
  try {
10001
10016
  const id = c.req.param("id");
10002
10017
  const body = await c.req.json();
10003
- const { title, titleLines, terminalTabs, showcaseMode } = body;
10018
+ const { title, titleLines, terminalTabs, showcaseMode, deviceMockup } = body;
10004
10019
  const db = getDatabase();
10005
10020
  const project = db.select().from(projects).where(eq(projects.id, id)).get();
10006
10021
  if (!project) {
10007
10022
  return c.json({ error: "Project not found" }, 404);
10008
10023
  }
10024
+ const defaultTitle = sanitizeTemplateTitle(
10025
+ extractFirstSentence(project.aiSummary || project.name || "App Recording"),
10026
+ "App Recording"
10027
+ );
10028
+ const sanitizedTitle = sanitizeTemplateTitle(title, defaultTitle);
10029
+ const sanitizedTitleLines = sanitizeTemplateTitleLines(titleLines, sanitizedTitle);
10030
+ const sanitizedTerminalTabs = sanitizeTemplateTerminalTabs(terminalTabs);
10031
+ const sanitizedShowcaseMode = showcaseMode === "artistic" || showcaseMode === "terminal" ? showcaseMode : void 0;
10032
+ const platform = project.platform === "ios" || project.platform === "android" || project.platform === "web" ? project.platform : "web";
10033
+ const availableAndroidMockups = listAndroidDeviceMockupIds();
10034
+ const sanitizedDeviceMockup = platform === "android" ? resolveAndroidDeviceMockup(deviceMockup, availableAndroidMockups) : void 0;
10035
+ const savedContent = {
10036
+ title: sanitizedTitle,
10037
+ titleLines: sanitizedTitleLines,
10038
+ terminalTabs: sanitizedTerminalTabs,
10039
+ showcaseMode: sanitizedShowcaseMode,
10040
+ deviceMockup: sanitizedDeviceMockup,
10041
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
10042
+ };
10009
10043
  const { writeFileSync: writeFileSync5, mkdirSync: mkdirSync6 } = await import("fs");
10010
10044
  const projectDir = join5(PROJECTS_DIR, id);
10011
10045
  if (!existsSync5(projectDir)) {
10012
10046
  mkdirSync6(projectDir, { recursive: true });
10013
10047
  }
10014
10048
  const contentPath = join5(projectDir, "template-content.json");
10015
- writeFileSync5(contentPath, JSON.stringify({
10016
- title,
10017
- titleLines,
10018
- terminalTabs,
10019
- showcaseMode,
10020
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
10021
- }));
10022
- return c.json({ success: true, message: "Template content saved" });
10049
+ writeFileSync5(contentPath, JSON.stringify(savedContent));
10050
+ return c.json({ success: true, message: "Template content saved", content: savedContent });
10023
10051
  } catch (error) {
10024
10052
  const message = error instanceof Error ? error.message : "Unknown error";
10025
10053
  return c.json({ error: message }, 500);
@@ -10040,10 +10068,14 @@ app.post("/api/templates/render", async (c) => {
10040
10068
  if (!project) {
10041
10069
  return c.json({ error: "Project not found" }, 404);
10042
10070
  }
10043
- const props = assembleTemplateProps(project);
10044
- if (!props) {
10071
+ const templateState = getTemplateProjectState(project);
10072
+ if (!templateState) {
10045
10073
  return c.json({ error: "Project has no video to render" }, 400);
10046
10074
  }
10075
+ if (!templateState.eligibility.templatesAllowed) {
10076
+ return c.json({ error: templateState.eligibility.reason || `Templates are limited to videos up to ${TEMPLATE_MAX_DURATION_SECONDS} seconds.` }, 400);
10077
+ }
10078
+ const { props } = templateState;
10047
10079
  const job = await startRender(projectId, templateId, props, (progress) => {
10048
10080
  broadcastToClients({
10049
10081
  type: "templateRenderProgress",
@@ -10139,29 +10171,27 @@ app.get("/api/settings/template-preference", async (c) => {
10139
10171
  }
10140
10172
  });
10141
10173
  function assembleTemplateProps(project) {
10142
- const resolvedVideoPath = resolveVideoPath(project.videoPath);
10143
- if (!resolvedVideoPath || !existsSync5(resolvedVideoPath)) {
10174
+ const resolvedVideoPath = resolveTemplateVideoPath(project);
10175
+ if (!resolvedVideoPath) {
10144
10176
  return null;
10145
10177
  }
10146
- try {
10147
- if (statSync2(resolvedVideoPath).isDirectory()) {
10148
- return null;
10149
- }
10150
- } catch {
10151
- return null;
10152
- }
10153
- const defaultTitle = extractFirstSentence(project.aiSummary || project.name || "App Recording");
10178
+ const defaultTitle = sanitizeTemplateTitle(
10179
+ extractFirstSentence(project.aiSummary || project.name || "App Recording"),
10180
+ "App Recording"
10181
+ );
10154
10182
  const subtitle = project.name || void 0;
10155
10183
  const editedContent = loadEditedTemplateContent(project.id);
10184
+ const availableAndroidMockups = listAndroidDeviceMockupIds();
10156
10185
  let title = defaultTitle;
10157
10186
  let titleLines;
10158
10187
  let terminalTabs = [];
10159
10188
  let hasNetworkData = false;
10160
10189
  let showcaseMode;
10190
+ let deviceMockup;
10161
10191
  if (editedContent) {
10162
- title = editedContent.title || defaultTitle;
10163
- titleLines = editedContent.titleLines;
10164
- terminalTabs = editedContent.terminalTabs || [];
10192
+ title = sanitizeTemplateTitle(editedContent.title || defaultTitle, defaultTitle);
10193
+ titleLines = sanitizeTemplateTitleLines(editedContent.titleLines, title);
10194
+ terminalTabs = sanitizeTemplateTerminalTabs(editedContent.terminalTabs);
10165
10195
  hasNetworkData = terminalTabs.length > 0;
10166
10196
  showcaseMode = editedContent.showcaseMode;
10167
10197
  } else {
@@ -10171,8 +10201,14 @@ function assembleTemplateProps(project) {
10171
10201
  terminalTabs = groupNetworkIntoTabs(networkEntries);
10172
10202
  }
10173
10203
  }
10174
- const videoDuration = project.duration || 0;
10175
10204
  const platform = project.platform === "ios" || project.platform === "android" || project.platform === "web" ? project.platform : "web";
10205
+ if (platform === "android") {
10206
+ deviceMockup = resolveAndroidDeviceMockup(editedContent?.deviceMockup, availableAndroidMockups);
10207
+ }
10208
+ if (!titleLines && (!showcaseMode || showcaseMode === "artistic") && !hasNetworkData) {
10209
+ titleLines = splitTemplateTitleIntoLines(title);
10210
+ }
10211
+ const videoDuration = getActualTemplateVideoDuration(project, resolvedVideoPath);
10176
10212
  const videoUrl = `http://localhost:${currentServerPort}/api/file?path=${encodeURIComponent(resolvedVideoPath)}`;
10177
10213
  return {
10178
10214
  videoUrl,
@@ -10183,7 +10219,32 @@ function assembleTemplateProps(project) {
10183
10219
  subtitle,
10184
10220
  terminalTabs,
10185
10221
  hasNetworkData,
10186
- showcaseMode
10222
+ showcaseMode,
10223
+ deviceMockup
10224
+ };
10225
+ }
10226
+ var TEMPLATE_MAX_DURATION_SECONDS = 60;
10227
+ var DEFAULT_ANDROID_DEVICE_MOCKUP = "mockup-android-galaxy.png";
10228
+ var ANDROID_DEVICE_MOCKUP_FALLBACKS = [
10229
+ DEFAULT_ANDROID_DEVICE_MOCKUP,
10230
+ "mockup-android.png",
10231
+ "mockup-android-google-pixel-9-pro.png"
10232
+ ];
10233
+ function getTemplateProjectState(project) {
10234
+ const props = assembleTemplateProps(project);
10235
+ if (!props) {
10236
+ return null;
10237
+ }
10238
+ const eligibility = buildTemplateEligibility(props.videoDuration);
10239
+ const androidDeviceMockups = listAndroidDeviceMockupIds().map((id) => ({
10240
+ id,
10241
+ label: formatAndroidDeviceMockupLabel(id)
10242
+ }));
10243
+ return {
10244
+ props,
10245
+ eligibility,
10246
+ androidDeviceMockups,
10247
+ defaultAndroidDeviceMockup: resolveAndroidDeviceMockup(void 0, androidDeviceMockups.map((option) => option.id))
10187
10248
  };
10188
10249
  }
10189
10250
  function extractFirstSentence(text) {
@@ -10192,6 +10253,164 @@ function extractFirstSentence(text) {
10192
10253
  if (match) return match[0].trim();
10193
10254
  return text.substring(0, 80).trim();
10194
10255
  }
10256
+ function resolveTemplateVideoPath(project) {
10257
+ const resolvedVideoPath = resolveVideoPath(project.videoPath);
10258
+ if (!resolvedVideoPath || !existsSync5(resolvedVideoPath)) {
10259
+ return null;
10260
+ }
10261
+ try {
10262
+ if (statSync2(resolvedVideoPath).isDirectory()) {
10263
+ return null;
10264
+ }
10265
+ } catch {
10266
+ return null;
10267
+ }
10268
+ return resolvedVideoPath;
10269
+ }
10270
+ function getActualTemplateVideoDuration(project, resolvedVideoPath) {
10271
+ const probedDuration = probeVideoDurationSeconds(resolvedVideoPath);
10272
+ if (probedDuration && probedDuration > 0) {
10273
+ return probedDuration;
10274
+ }
10275
+ const projectDuration = Number(project?.duration);
10276
+ if (Number.isFinite(projectDuration) && projectDuration > 0) {
10277
+ return projectDuration;
10278
+ }
10279
+ return 0;
10280
+ }
10281
+ function probeVideoDurationSeconds(filePath) {
10282
+ try {
10283
+ if (!existsSync5(filePath)) return null;
10284
+ const output = execSync3(
10285
+ `ffprobe -v quiet -print_format json -show_format "${filePath}"`,
10286
+ { encoding: "utf-8", timeout: 1e4 }
10287
+ );
10288
+ const data = JSON.parse(output);
10289
+ const duration = parseFloat(data?.format?.duration || "0");
10290
+ return Number.isFinite(duration) && duration > 0 ? duration : null;
10291
+ } catch {
10292
+ return null;
10293
+ }
10294
+ }
10295
+ function buildTemplateEligibility(actualDurationSeconds) {
10296
+ const templatesAllowed = isTemplateDurationAllowed(actualDurationSeconds);
10297
+ return {
10298
+ templatesAllowed,
10299
+ maxTemplateDurationSeconds: TEMPLATE_MAX_DURATION_SECONDS,
10300
+ actualDurationSeconds,
10301
+ reason: templatesAllowed || actualDurationSeconds <= 0 ? void 0 : `Templates are limited to videos up to ${TEMPLATE_MAX_DURATION_SECONDS} seconds. This recording is ${formatTemplateDuration(actualDurationSeconds)}.`
10302
+ };
10303
+ }
10304
+ function isTemplateDurationAllowed(actualDurationSeconds) {
10305
+ if (!Number.isFinite(actualDurationSeconds) || actualDurationSeconds <= 0) {
10306
+ return true;
10307
+ }
10308
+ return Math.round(actualDurationSeconds * 1e3) <= TEMPLATE_MAX_DURATION_SECONDS * 1e3;
10309
+ }
10310
+ function formatTemplateDuration(actualDurationSeconds) {
10311
+ if (!Number.isFinite(actualDurationSeconds) || actualDurationSeconds <= 0) {
10312
+ return "unknown duration";
10313
+ }
10314
+ const totalSeconds = Math.max(1, Math.round(actualDurationSeconds));
10315
+ const minutes = Math.floor(totalSeconds / 60);
10316
+ const seconds = totalSeconds % 60;
10317
+ return minutes > 0 ? `${minutes}m ${seconds}s` : `${totalSeconds}s`;
10318
+ }
10319
+ function sanitizeTemplateTitle(value, fallback = "App Recording") {
10320
+ const normalized = normalizeTemplateTitle(value);
10321
+ if (normalized) return normalized;
10322
+ const safeFallback = normalizeTemplateTitle(fallback);
10323
+ return safeFallback || "App Recording";
10324
+ }
10325
+ function normalizeTemplateTitle(value) {
10326
+ if (typeof value !== "string") return "";
10327
+ const withoutMarkdown = value.replace(/[#*_`~>\-[\]{}()<>\\/|]+/g, " ").replace(/&/g, " and ").replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
10328
+ if (!withoutMarkdown) return "";
10329
+ const limitedWords = withoutMarkdown.split(" ").filter(Boolean).slice(0, 7).join(" ");
10330
+ if (!limitedWords) return "";
10331
+ if (limitedWords.length <= 48) return limitedWords;
10332
+ const truncated = limitedWords.slice(0, 48).trim();
10333
+ return truncated.replace(/\s+\S*$/, "").trim() || truncated;
10334
+ }
10335
+ function sanitizeTemplateTitleLines(value, fallbackTitle) {
10336
+ if (!Array.isArray(value)) {
10337
+ return void 0;
10338
+ }
10339
+ const cleaned = value.map((line) => sanitizeTemplateTitle(line, "")).filter(Boolean).slice(0, 4);
10340
+ if (cleaned.length > 0) {
10341
+ return cleaned;
10342
+ }
10343
+ return splitTemplateTitleIntoLines(fallbackTitle);
10344
+ }
10345
+ function splitTemplateTitleIntoLines(title) {
10346
+ const words = title.split(" ").filter(Boolean);
10347
+ if (words.length === 0) return void 0;
10348
+ if (words.length <= 2) return [title];
10349
+ const lines = [];
10350
+ for (let index = 0; index < words.length && lines.length < 4; index += 2) {
10351
+ lines.push(words.slice(index, index + 2).join(" "));
10352
+ }
10353
+ return lines;
10354
+ }
10355
+ function sanitizeTemplateTerminalTabs(value) {
10356
+ if (!Array.isArray(value)) return [];
10357
+ return value.map((tab) => {
10358
+ if (!tab || typeof tab !== "object") return null;
10359
+ const record = tab;
10360
+ const label = typeof record.label === "string" ? record.label.trim() : "";
10361
+ const methodFromLabel = label.split(" ")[0] || "GET";
10362
+ const routeFromLabel = label.split(" ").slice(1).join(" ") || "/";
10363
+ const method = typeof record.method === "string" && record.method.trim() ? record.method.trim().toUpperCase() : methodFromLabel.toUpperCase();
10364
+ const route = typeof record.route === "string" && record.route.trim() ? record.route.trim() : routeFromLabel;
10365
+ const content = typeof record.content === "string" ? record.content : "";
10366
+ if (!label && !content.trim()) return null;
10367
+ return {
10368
+ label: label || `${method} ${route}`.trim(),
10369
+ method: method || "GET",
10370
+ route: route || "/",
10371
+ content
10372
+ };
10373
+ }).filter((tab) => !!tab);
10374
+ }
10375
+ function listAndroidDeviceMockupIds() {
10376
+ const bundlePath = getBundlePath();
10377
+ if (!bundlePath) {
10378
+ return [...ANDROID_DEVICE_MOCKUP_FALLBACKS];
10379
+ }
10380
+ const publicDir = join5(bundlePath, "public");
10381
+ if (!existsSync5(publicDir)) {
10382
+ return [...ANDROID_DEVICE_MOCKUP_FALLBACKS];
10383
+ }
10384
+ const files = readdirSync2(publicDir).filter((file) => /^mockup-android.*\.png$/i.test(file));
10385
+ const unique = new Set(files.length > 0 ? files : ANDROID_DEVICE_MOCKUP_FALLBACKS);
10386
+ return [...unique].sort((left, right) => {
10387
+ const leftIndex = ANDROID_DEVICE_MOCKUP_FALLBACKS.indexOf(left);
10388
+ const rightIndex = ANDROID_DEVICE_MOCKUP_FALLBACKS.indexOf(right);
10389
+ if (leftIndex !== -1 || rightIndex !== -1) {
10390
+ if (leftIndex === -1) return 1;
10391
+ if (rightIndex === -1) return -1;
10392
+ return leftIndex - rightIndex;
10393
+ }
10394
+ return left.localeCompare(right);
10395
+ });
10396
+ }
10397
+ function resolveAndroidDeviceMockup(requested, available) {
10398
+ const candidate = typeof requested === "string" ? requested.trim() : "";
10399
+ if (candidate && available.includes(candidate)) {
10400
+ return candidate;
10401
+ }
10402
+ for (const fallback of ANDROID_DEVICE_MOCKUP_FALLBACKS) {
10403
+ if (available.includes(fallback)) {
10404
+ return fallback;
10405
+ }
10406
+ }
10407
+ return available[0] || DEFAULT_ANDROID_DEVICE_MOCKUP;
10408
+ }
10409
+ function formatAndroidDeviceMockupLabel(filename) {
10410
+ const base = filename.replace(/^mockup-android-?/i, "").replace(/\.png$/i, "");
10411
+ if (!base) return "Android";
10412
+ return base.split("-").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
10413
+ }
10195
10414
  function isTunnelLikeNetworkEntry(entry) {
10196
10415
  if (!entry || typeof entry !== "object") return false;
10197
10416
  const method = typeof entry.method === "string" ? entry.method.toUpperCase() : "";
@@ -10331,7 +10550,8 @@ function loadEditedTemplateContent(projectId) {
10331
10550
  title: data.title,
10332
10551
  titleLines: data.titleLines,
10333
10552
  terminalTabs: data.terminalTabs,
10334
- showcaseMode: data.showcaseMode
10553
+ showcaseMode: data.showcaseMode,
10554
+ deviceMockup: data.deviceMockup
10335
10555
  };
10336
10556
  } catch {
10337
10557
  return null;
@@ -15,7 +15,7 @@ import {
15
15
  inspectESVPSession,
16
16
  replayESVPSession,
17
17
  runESVPActions
18
- } from "./chunk-GAKEFJ5T.js";
18
+ } from "./chunk-7EDIUVIO.js";
19
19
  import {
20
20
  redactSensitiveTestInput
21
21
  } from "./chunk-SLNJEF32.js";