mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(studio,cli): add an Edit with Framey button and hyperframes open for HyperFrames Studio
Point people from the CLI and the Studio preview to HyperFrames Studio, the desktop app. - Studio header: an "Edit with Framey" button with Framey, the app's cursor mascot (the eye follows the pointer, a wiggle on hover, honours reduced motion). Until the app opens handed-over projects (HANDOFF_READY), a press shows a "Meet Framey" card with the download. The button shows only where the preview server answers GET /api/open-in-desktop on macOS, so the desktop app's own embedded Studio never shows it. - `hyperframes open [dir]`: hands the project folder to the app with `open -b` (released app, then Canary) and points to the download when neither is installed. When run by Claude Code or Codex it leaves .hyperframes/agent-handoff.json (CLAUDE_CODE_SESSION_ID / CODEX_THREAD_ID) so the app's first chat can pick up that conversation. - render and preview print one line about the app: the download until HANDOFF_READY, then `hyperframes open` when the app is installed. Nothing for a draft render, a batch row, or a run inside the app (HYPERFRAMES_DESKTOP_PROJECT).
This commit is contained in:
@@ -152,6 +152,7 @@ const commandLoaders = {
|
||||
preview: () =>
|
||||
assertStudioWorkspaceBuilt().then(() => import("./commands/preview.js").then((m) => m.default)),
|
||||
publish: () => import("./commands/publish.js").then((m) => m.default),
|
||||
open: () => import("./commands/open.js").then((m) => m.default),
|
||||
render: () => import("./commands/render.js").then((m) => m.default),
|
||||
lint: () => import("./commands/lint.js").then((m) => m.default),
|
||||
check: () => import("./commands/check.js").then((m) => m.default),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineCommand } from "citty";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { openInDesktop } from "../utils/desktopApp.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Open this project in HyperFrames Studio", "hyperframes open"],
|
||||
["Open another project", "hyperframes open ./my-video"],
|
||||
["For agents", "hyperframes open --json"],
|
||||
];
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "open", description: "Open a project in HyperFrames Studio, the desktop app" },
|
||||
args: {
|
||||
dir: {
|
||||
type: "positional",
|
||||
description: "Project directory (default: current)",
|
||||
required: false,
|
||||
},
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
run({ args }) {
|
||||
const project = resolveProject(args.dir);
|
||||
const result = openInDesktop(project.dir);
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify({ project: project.dir, ...result }, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.opened) {
|
||||
console.log(`${c.success("◇")} Opening ${c.accent(project.name)} in HyperFrames Studio`);
|
||||
if (result.handedOver) {
|
||||
const agent = result.handedOver.engine === "claude" ? "Claude Code" : "Codex";
|
||||
console.log(` ${c.dim(`Its chat picks up this ${agent} conversation.`)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
result.reason === "not-installed"
|
||||
? `${c.warn("◇")} HyperFrames Studio isn't installed on this Mac.`
|
||||
: `${c.warn("◇")} Opening a project from the CLI works on macOS only for now.`,
|
||||
);
|
||||
console.log(` Download it: ${c.accent(result.downloadUrl)}`);
|
||||
},
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { desktopHint } from "../utils/desktopApp.js";
|
||||
import { isDevMode } from "../utils/env.js";
|
||||
import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js";
|
||||
import { buildNpxCommand } from "../utils/npxCommand.js";
|
||||
@@ -365,7 +366,7 @@ export default defineCommand({
|
||||
);
|
||||
return;
|
||||
}
|
||||
printStudioSummary(project.name, previewBaseUrl(status.port), {
|
||||
printStudioSummary(project.name, previewBaseUrl(status.port), project.dir, {
|
||||
details: [`Background preview running (PID ${status.pid}).`, `Log: ${status.logPath}`],
|
||||
});
|
||||
return;
|
||||
@@ -539,7 +540,7 @@ export default defineCommand({
|
||||
);
|
||||
} else {
|
||||
clack.intro(c.bold("hyperframes preview"));
|
||||
printStudioSummary(projectName, url, {
|
||||
printStudioSummary(projectName, url, dir, {
|
||||
details: [
|
||||
background.type === "reused"
|
||||
? "Reusing the background server already running for this project."
|
||||
@@ -1282,6 +1283,7 @@ function openStudioBrowser(url: string, projectName: string, options?: BrowserLa
|
||||
function printStudioSummary(
|
||||
projectName: string,
|
||||
serverUrl: string,
|
||||
projectDir: string,
|
||||
opts: { details?: string[]; footer?: string } = {},
|
||||
): void {
|
||||
const urls = studioSummaryUrls(projectName, serverUrl);
|
||||
@@ -1289,6 +1291,8 @@ function printStudioSummary(
|
||||
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
|
||||
console.log(` ${c.dim("Studio")} ${c.accent(urls.studioUrl)}`);
|
||||
console.log(` ${c.dim("Server")} ${c.accent(urls.serverUrl)}`);
|
||||
const hint = desktopHint(projectDir);
|
||||
if (hint) console.log(` ${c.dim("Desktop")} ${hint}`);
|
||||
console.log();
|
||||
for (const detail of opts.details ?? []) {
|
||||
console.log(` ${c.dim(detail)}`);
|
||||
@@ -1395,7 +1399,7 @@ function attachStudioReadyHandler(
|
||||
);
|
||||
} else {
|
||||
spinner.stop(c.success("Studio running"));
|
||||
printStudioSummary(projectName, url, {
|
||||
printStudioSummary(projectName, url, projectDir, {
|
||||
footer: "Press Ctrl+C to stop",
|
||||
});
|
||||
}
|
||||
@@ -1629,7 +1633,7 @@ async function runEmbeddedMode(
|
||||
);
|
||||
} else {
|
||||
s.stop(c.success("Already running"));
|
||||
printStudioSummary(pName, url, {
|
||||
printStudioSummary(pName, url, dir, {
|
||||
details: ["Reusing existing server. Use --force-new to start a fresh instance."],
|
||||
});
|
||||
}
|
||||
@@ -1647,7 +1651,7 @@ async function runEmbeddedMode(
|
||||
console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`);
|
||||
console.log();
|
||||
}
|
||||
printStudioSummary(pName, url, {
|
||||
printStudioSummary(pName, url, dir, {
|
||||
details: [
|
||||
"Edit with your AI agent — it has HyperFrames skills installed.",
|
||||
"Changes reload automatically in the studio.",
|
||||
|
||||
@@ -62,6 +62,7 @@ import { resolve, dirname, join, basename } from "node:path";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { loadProducer } from "../utils/producer.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { desktopHint } from "../utils/desktopApp.js";
|
||||
import {
|
||||
formatBytes,
|
||||
formatRenderSummaryDetail,
|
||||
@@ -524,6 +525,8 @@ export interface RenderOptions {
|
||||
throwOnError?: boolean;
|
||||
/** Skip the interactive feedback prompt after a successful render. */
|
||||
skipFeedback?: boolean;
|
||||
/** False for a batch row: one line about the desktop app per batch is noise, not a pointer. */
|
||||
desktopHint?: boolean;
|
||||
/**
|
||||
* OPT IN to managing the DE parallel-router circuit breaker
|
||||
* (`applyDeParallelRouterCircuitBreaker`) for this render. Default OFF —
|
||||
@@ -886,6 +889,8 @@ async function renderDocker(
|
||||
runPostRenderStep("printRenderComplete", () =>
|
||||
printRenderComplete({
|
||||
outputPath,
|
||||
projectDir,
|
||||
desktopHint: options.desktopHint !== false && options.quality !== "draft",
|
||||
elapsedMs: elapsed,
|
||||
quiet: options.quiet,
|
||||
format: options.format,
|
||||
@@ -1138,6 +1143,8 @@ async function executeLocalRender(
|
||||
runPostRenderStep("printRenderComplete", () =>
|
||||
printRenderComplete({
|
||||
outputPath,
|
||||
projectDir,
|
||||
desktopHint: options.desktopHint !== false && options.quality !== "draft",
|
||||
elapsedMs: elapsed,
|
||||
quiet: options.quiet,
|
||||
format: options.format,
|
||||
@@ -1900,6 +1907,9 @@ function readOutputFootprint(outputPath: string): { fileSize: string; isDirector
|
||||
|
||||
function printRenderComplete(input: {
|
||||
outputPath: string;
|
||||
projectDir: string;
|
||||
/** Print the desktop-app line: a delivered render, never a draft or a batch row. */
|
||||
desktopHint: boolean;
|
||||
elapsedMs: number;
|
||||
quiet: boolean;
|
||||
format: RenderFormat;
|
||||
@@ -1920,6 +1930,8 @@ function printRenderComplete(input: {
|
||||
console.log(c.success("\u25C7") + " " + c.accent(outputPath));
|
||||
console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + detail));
|
||||
if (perf) printRenderPipeline(perf, input.requestedGpuMode);
|
||||
const hint = input.desktopHint ? desktopHint(input.projectDir) : null;
|
||||
if (hint) console.log(" " + c.dim(hint));
|
||||
}
|
||||
|
||||
function printRenderPipeline(
|
||||
|
||||
@@ -365,6 +365,7 @@ async function executeBatchRender(
|
||||
exitAfterComplete: false,
|
||||
throwOnError: true,
|
||||
skipFeedback: true,
|
||||
desktopHint: false,
|
||||
manageDeParallelRouterBreaker: plan.batchConcurrency <= 1,
|
||||
};
|
||||
const manifest = await batchModule.runBatchRender({
|
||||
|
||||
@@ -27,6 +27,7 @@ const GROUPS: Group[] = [
|
||||
["present", "Open a slideshow deck in presenter mode (with audience sync)"],
|
||||
["publish", "Upload a project to a stable URL (private by default)"],
|
||||
["render", "Render a composition to MP4 or WebM"],
|
||||
["open", "Open a project in HyperFrames Studio, the desktop app"],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -966,6 +966,15 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
});
|
||||
});
|
||||
|
||||
// HyperFrames Studio, the desktop app: the header's "Open in app" asks this server, which runs on the person's Mac,
|
||||
// to hand the project folder over (`hyperframes open`'s `open -b`). The desktop's own Studio has no such route, so
|
||||
// the button stays hidden there.
|
||||
app.get("/api/open-in-desktop", (c) => c.json({ available: process.platform === "darwin" }));
|
||||
app.post("/api/open-in-desktop", async (c) => {
|
||||
const { openInDesktop } = await import("../utils/desktopApp.js");
|
||||
return c.json(openInDesktop(projectDir));
|
||||
});
|
||||
|
||||
// ── Pre-flight checks for render ────────────────────────────────────────
|
||||
// Intercept render requests before they reach the shared API so we can
|
||||
// fail fast with an actionable hint instead of burning through the entire
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AGENT_HANDOFF_FILE,
|
||||
DESKTOP_DOWNLOAD_URL,
|
||||
HANDOFF_READY,
|
||||
agentSession,
|
||||
desktopHint,
|
||||
desktopInstalled,
|
||||
openCommandFor,
|
||||
openInDesktop,
|
||||
} from "./desktopApp.js";
|
||||
|
||||
const NO_AGENT = {};
|
||||
|
||||
describe("openInDesktop", () => {
|
||||
it("hands the folder to the released app when it is installed", () => {
|
||||
const asked: string[] = [];
|
||||
const result = openInDesktop("/films/a", {
|
||||
platform: "darwin",
|
||||
open: (id, dir) => (asked.push(`${id} ${dir}`), true),
|
||||
env: NO_AGENT,
|
||||
});
|
||||
expect(result).toEqual({ opened: true, bundleId: "dev.hyperframes.desktop", handedOver: null });
|
||||
expect(asked).toEqual(["dev.hyperframes.desktop /films/a"]);
|
||||
});
|
||||
|
||||
it("falls back to Canary when only Canary is installed", () => {
|
||||
const result = openInDesktop("/films/a", {
|
||||
platform: "darwin",
|
||||
open: (id) => id.endsWith(".canary"),
|
||||
env: NO_AGENT,
|
||||
});
|
||||
expect(result).toMatchObject({ opened: true, bundleId: "dev.hyperframes.desktop.canary" });
|
||||
});
|
||||
|
||||
it("points to the download when no app is installed", () => {
|
||||
const result = openInDesktop("/films/a", { platform: "darwin", open: () => false });
|
||||
expect(result).toEqual({
|
||||
opened: false,
|
||||
reason: "not-installed",
|
||||
downloadUrl: DESKTOP_DOWNLOAD_URL,
|
||||
});
|
||||
});
|
||||
|
||||
it("never runs `open` off macOS", () => {
|
||||
const result = openInDesktop("C:\\films\\a", {
|
||||
platform: "win32",
|
||||
open: () => {
|
||||
throw new Error("must not run");
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({ opened: false, reason: "unsupported-platform" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent hand-off", () => {
|
||||
let dir: string | undefined;
|
||||
afterEach(() => dir && rmSync(dir, { recursive: true, force: true }));
|
||||
const handoff = () => join(dir!, AGENT_HANDOFF_FILE);
|
||||
|
||||
it("names the conversation the command runs in, Claude Code first", () => {
|
||||
expect(agentSession({ CLAUDE_CODE_SESSION_ID: "c-1", CODEX_THREAD_ID: "x-1" })).toEqual({
|
||||
engine: "claude",
|
||||
sessionId: "c-1",
|
||||
});
|
||||
expect(agentSession({ CODEX_THREAD_ID: "x-1" })).toEqual({ engine: "codex", sessionId: "x-1" });
|
||||
expect(agentSession({})).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves the app the conversation once it has the folder", () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-handoff-"));
|
||||
const result = openInDesktop(dir, {
|
||||
platform: "darwin",
|
||||
open: () => true,
|
||||
env: { CLAUDE_CODE_SESSION_ID: "0a8eed95-0869-45bf-83ec-5d71fcc5236c" },
|
||||
});
|
||||
expect(result).toMatchObject({ opened: true, handedOver: { engine: "claude" } });
|
||||
expect(JSON.parse(readFileSync(handoff(), "utf8"))).toEqual({
|
||||
engine: "claude",
|
||||
sessionId: "0a8eed95-0869-45bf-83ec-5d71fcc5236c",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes nothing when no app took the folder, or no agent runs the command", () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-handoff-"));
|
||||
openInDesktop(dir, { platform: "darwin", open: () => false, env: { CODEX_THREAD_ID: "x-1" } });
|
||||
openInDesktop(dir, { platform: "darwin", open: () => true, env: {} });
|
||||
expect(existsSync(handoff())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openCommandFor", () => {
|
||||
it("names the project relative to where the person is", () => {
|
||||
expect(openCommandFor("/work/films/a", "/work/films/a")).toBe("hyperframes open .");
|
||||
expect(openCommandFor("/work/films/a", "/work")).toBe("hyperframes open films/a");
|
||||
expect(openCommandFor("/work/my film", "/work")).toBe('hyperframes open "my film"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktopInstalled", () => {
|
||||
const nothing = {
|
||||
platform: "darwin",
|
||||
home: "/Users/a",
|
||||
exists: () => false,
|
||||
spotlight: () => false,
|
||||
};
|
||||
|
||||
it("finds the app where the DMG and the installer put it, Canary included", () => {
|
||||
expect(
|
||||
desktopInstalled({
|
||||
...nothing,
|
||||
exists: (p: string) => p === "/Applications/HyperFrames Canary.app",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
desktopInstalled({
|
||||
...nothing,
|
||||
exists: (p: string) => p === "/Users/a/Applications/HyperFrames.app",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("asks Spotlight for one installed anywhere else", () => {
|
||||
expect(
|
||||
desktopInstalled({ ...nothing, spotlight: (id: string) => id === "dev.hyperframes.desktop" }),
|
||||
).toBe(true);
|
||||
expect(desktopInstalled(nothing)).toBe(false);
|
||||
});
|
||||
|
||||
it("is never there off macOS", () => {
|
||||
expect(desktopInstalled({ ...nothing, platform: "linux", exists: () => true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktopHint", () => {
|
||||
const OUTSIDE = {};
|
||||
const download = `Edit it by chatting with Framey in HyperFrames Studio → ${DESKTOP_DOWNLOAD_URL}`;
|
||||
|
||||
it("points to the download when the app is missing", () => {
|
||||
expect(desktopHint(process.cwd(), { env: OUTSIDE, installed: false })).toBe(download);
|
||||
});
|
||||
|
||||
it("points an installed app to `hyperframes open` once the app opens handed-over projects", () => {
|
||||
expect(desktopHint(process.cwd(), { env: OUTSIDE, installed: true })).toBe(
|
||||
HANDOFF_READY ? "Edit it with Framey: hyperframes open ." : download,
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing inside the app, whose runs carry HYPERFRAMES_DESKTOP_PROJECT", () => {
|
||||
expect(
|
||||
desktopHint(process.cwd(), { env: { HYPERFRAMES_DESKTOP_PROJECT: "/p" }, installed: true }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, join, relative } from "node:path";
|
||||
|
||||
// HyperFrames Studio, the desktop app. macOS hands it a folder through `open -b`, which the app takes as a project to
|
||||
// add to Home and show (hyperframes-internal packages/desktop/main/folderOpen.mjs). The released app first, then Canary.
|
||||
const DESKTOP_BUNDLE_IDS = ["dev.hyperframes.desktop", "dev.hyperframes.desktop.canary"] as const;
|
||||
export const DESKTOP_DOWNLOAD_URL = "https://hyperframes.dev/studio/download";
|
||||
|
||||
/** The agent conversation a command runs in: Claude Code and Codex name it to the shells they start. */
|
||||
export interface AgentSession {
|
||||
engine: "claude" | "codex";
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// The app's first chat in the project picks this conversation up as context, once (hyperframes-internal
|
||||
// packages/desktop/main/agentHandoffFile.mjs).
|
||||
export const AGENT_HANDOFF_FILE = join(".hyperframes", "agent-handoff.json");
|
||||
|
||||
// ponytail: Claude Code first; a Codex started inside a Claude Code shell (or the reverse) carries both, unsorted.
|
||||
export function agentSession(env: NodeJS.ProcessEnv = process.env): AgentSession | null {
|
||||
if (env.CLAUDE_CODE_SESSION_ID)
|
||||
return { engine: "claude", sessionId: env.CLAUDE_CODE_SESSION_ID };
|
||||
if (env.CODEX_THREAD_ID) return { engine: "codex", sessionId: env.CODEX_THREAD_ID };
|
||||
return null;
|
||||
}
|
||||
|
||||
export type DesktopOpenResult =
|
||||
| { opened: true; bundleId: string; handedOver: AgentSession | null }
|
||||
| { opened: false; reason: "not-installed" | "unsupported-platform"; downloadUrl: string };
|
||||
|
||||
/** Runs `open -b <id> <dir>`; true when macOS found the app and handed it the folder. */
|
||||
type OpenWithBundle = (bundleId: string, dir: string) => boolean;
|
||||
|
||||
const openWithBundle: OpenWithBundle = (bundleId, dir) =>
|
||||
spawnSync("open", ["-b", bundleId, dir], { stdio: "ignore" }).status === 0;
|
||||
|
||||
// ponytail: macOS only; the Windows app needs its own hand-off (argv on second-instance) before this grows a branch.
|
||||
export function openInDesktop(
|
||||
dir: string,
|
||||
{ platform = process.platform, open = openWithBundle, env = process.env } = {},
|
||||
): DesktopOpenResult {
|
||||
if (platform !== "darwin")
|
||||
return { opened: false, reason: "unsupported-platform", downloadUrl: DESKTOP_DOWNLOAD_URL };
|
||||
const bundleId = DESKTOP_BUNDLE_IDS.find((id) => open(id, dir));
|
||||
if (!bundleId)
|
||||
return { opened: false, reason: "not-installed", downloadUrl: DESKTOP_DOWNLOAD_URL };
|
||||
// Read at the app's first chat send, well after this open, so it is written once the app has the folder.
|
||||
const handedOver = agentSession(env);
|
||||
if (handedOver) {
|
||||
mkdirSync(join(dir, ".hyperframes"), { recursive: true });
|
||||
writeFileSync(join(dir, AGENT_HANDOFF_FILE), JSON.stringify(handedOver));
|
||||
}
|
||||
return { opened: true, bundleId, handedOver };
|
||||
}
|
||||
|
||||
/** The `hyperframes open` line to print for a project, relative to where the person is. */
|
||||
export function openCommandFor(dir: string, cwd = process.cwd()): string {
|
||||
const shown = relative(cwd, dir) || ".";
|
||||
return `hyperframes open ${/\s/.test(shown) ? JSON.stringify(shown) : shown}`;
|
||||
}
|
||||
|
||||
const DESKTOP_APP_NAMES = ["HyperFrames.app", "HyperFrames Canary.app"];
|
||||
|
||||
/** Spotlight's copies of an app with this bundle id; an updater's hidden leftover (`.X.app.installing-…`) is none. */
|
||||
const spotlightFinds = (bundleId: string): boolean =>
|
||||
spawnSync("mdfind", [`kMDItemCFBundleIdentifier == '${bundleId}'`], { encoding: "utf8" })
|
||||
.stdout?.split("\n")
|
||||
.some((path) => path.endsWith(".app") && !basename(path).startsWith(".")) ?? false;
|
||||
|
||||
/** Whether this Mac has the desktop app, without launching it: where the DMG and the installer put it, else
|
||||
* Spotlight, which can miss an app for minutes after an update. Off macOS, never. */
|
||||
export function desktopInstalled({
|
||||
platform = process.platform,
|
||||
home = homedir(),
|
||||
exists = existsSync,
|
||||
spotlight = spotlightFinds,
|
||||
}: {
|
||||
platform?: string;
|
||||
home?: string;
|
||||
exists?: (path: string) => boolean;
|
||||
spotlight?: (bundleId: string) => boolean;
|
||||
} = {}): boolean {
|
||||
if (platform !== "darwin") return false;
|
||||
const folders = ["/Applications", join(home, "Applications")];
|
||||
if (folders.some((folder) => DESKTOP_APP_NAMES.some((name) => exists(join(folder, name)))))
|
||||
return true;
|
||||
return DESKTOP_BUNDLE_IDS.some(spotlight);
|
||||
}
|
||||
|
||||
// ponytail: the desktop app opens a handed-over folder only from its next release (hyperframes-internal
|
||||
// main/folderOpen.mjs); until then every hint points to the download, as Studio's button does. Flip with it.
|
||||
export const HANDOFF_READY = false;
|
||||
|
||||
/** The one line render and preview print about the desktop app: open the project in it, or get it. Null inside
|
||||
* the app itself, whose agent runs carry HYPERFRAMES_DESKTOP_PROJECT. */
|
||||
export function desktopHint(
|
||||
dir: string,
|
||||
{ env = process.env, installed }: { env?: NodeJS.ProcessEnv; installed?: boolean } = {},
|
||||
): string | null {
|
||||
if (env.HYPERFRAMES_DESKTOP_PROJECT) return null;
|
||||
return HANDOFF_READY && (installed ?? desktopInstalled())
|
||||
? `Edit it with Framey: ${openCommandFor(dir)}`
|
||||
: `Edit it by chatting with Framey in HyperFrames Studio → ${DESKTOP_DOWNLOAD_URL}`;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Ref } from "react";
|
||||
|
||||
// Framey, the desktop app's cursor mascot, as hyperframes-internal packages/nib draws it: a rounded triangle with a
|
||||
// white rim and one eye. The viewBox frames the body with its rim; the tip points up and left like a cursor.
|
||||
const BODY = "M77.55 48.44 L122.77 65.85 L99.3 89.41 Z";
|
||||
const EYE = { cx: 111.6, cy: 68.2, r: 11.5 };
|
||||
|
||||
/** `eyeRef` moves the pupils (a translate of a few px) without re-rendering. */
|
||||
export function FrameyGlyph({
|
||||
size,
|
||||
className,
|
||||
eyeRef,
|
||||
}: {
|
||||
size: number;
|
||||
className?: string;
|
||||
eyeRef?: Ref<SVGGElement>;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="58 30 82 76"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
overflow="visible"
|
||||
>
|
||||
<path
|
||||
d={BODY}
|
||||
fill="var(--color-white)"
|
||||
stroke="var(--color-white)"
|
||||
strokeWidth={24}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d={BODY}
|
||||
fill="var(--color-framey)"
|
||||
stroke="var(--color-framey)"
|
||||
strokeWidth={19}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx={EYE.cx} cy={EYE.cy} r={EYE.r} fill="var(--color-white)" />
|
||||
<g ref={eyeRef} className="hf-framey-eye">
|
||||
<path
|
||||
d={`M${EYE.cx - 2.6} ${EYE.cy - 2} V${EYE.cy + 2} M${EYE.cx + 2.6} ${EYE.cy - 2} V${EYE.cy + 2}`}
|
||||
stroke="var(--color-framey-ink)"
|
||||
strokeWidth={2.4}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
/**
|
||||
* "Open in app": shown only when the preview server can hand the project to the desktop app; a click opens it, or
|
||||
* offers the download when the app is missing.
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const showToast = vi.fn();
|
||||
vi.mock("../contexts/StudioContext", () => ({ useStudioShellContext: () => ({ showToast }) }));
|
||||
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
||||
|
||||
const { HANDOFF_READY, OpenInDesktopButton } = await import("./OpenInDesktopButton");
|
||||
|
||||
const DOWNLOAD = "https://hyperframes.dev/studio/download";
|
||||
let mounted: { root: Root; host: HTMLElement } | null = null;
|
||||
let posts: number;
|
||||
|
||||
function serve(get: Response | null, post?: unknown) {
|
||||
posts = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
posts += 1;
|
||||
return Response.json(post);
|
||||
}
|
||||
return get ?? new Response("Not found", { status: 404 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function mount(): Promise<HTMLElement> {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
mounted = { root, host };
|
||||
await act(async () => root.render(<OpenInDesktopButton />));
|
||||
return host;
|
||||
}
|
||||
|
||||
const downloadHref = () =>
|
||||
[...document.querySelectorAll("a")]
|
||||
.find((a) => a.textContent?.includes("Get HyperFrames Studio"))
|
||||
?.getAttribute("href");
|
||||
|
||||
const button = () =>
|
||||
document.querySelector<HTMLButtonElement>('[data-testid="header-open-in-desktop"]');
|
||||
|
||||
beforeEach(() => showToast.mockReset());
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
if (!mounted) return;
|
||||
const { root, host } = mounted;
|
||||
mounted = null;
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("stays hidden where the server has no hand-off (the desktop's own Studio)", async () => {
|
||||
serve(null);
|
||||
await mount();
|
||||
expect(button()).toBeNull();
|
||||
});
|
||||
|
||||
it("stays hidden when the server says it cannot open the app (not macOS)", async () => {
|
||||
serve(Response.json({ available: false }));
|
||||
await mount();
|
||||
expect(button()).toBeNull();
|
||||
});
|
||||
|
||||
it("before the app opens projects from here, introduces Framey with the download and opens nothing", async () => {
|
||||
serve(Response.json({ available: true }), { opened: true });
|
||||
await mount();
|
||||
await act(async () => button()!.click());
|
||||
if (HANDOFF_READY) return;
|
||||
expect(posts).toBe(0);
|
||||
expect(document.body.textContent).toContain("Meet Framey");
|
||||
expect(document.body.textContent).not.toContain("Coming soon");
|
||||
expect(downloadHref()).toBe(DOWNLOAD);
|
||||
});
|
||||
|
||||
it.skipIf(!HANDOFF_READY)("opens the project in the app and says so", async () => {
|
||||
serve(Response.json({ available: true }), { opened: true, bundleId: "dev.hyperframes.desktop" });
|
||||
await mount();
|
||||
await act(async () => button()!.click());
|
||||
expect(posts).toBe(1);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
"Framey is opening this project in HyperFrames Studio",
|
||||
"info",
|
||||
);
|
||||
expect(button()!.dataset.opening).toBe("true");
|
||||
expect(document.body.textContent).not.toContain("Meet Framey");
|
||||
});
|
||||
|
||||
it.skipIf(!HANDOFF_READY)(
|
||||
"introduces Framey and offers the download when the app is missing",
|
||||
async () => {
|
||||
serve(Response.json({ available: true }), {
|
||||
opened: false,
|
||||
reason: "not-installed",
|
||||
downloadUrl: DOWNLOAD,
|
||||
});
|
||||
await mount();
|
||||
await act(async () => button()!.click());
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Meet Framey");
|
||||
expect(downloadHref()).toBe(DOWNLOAD);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useStudioShellContext } from "../contexts/StudioContext";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import { FrameyGlyph } from "./FrameyGlyph";
|
||||
import { Button, buttonBase, buttonSizes, buttonVariants, cn, Popover } from "./ui";
|
||||
|
||||
// "Edit with Framey": hands this project to HyperFrames Studio, the desktop app, through the preview server
|
||||
// (`POST /api/open-in-desktop`, the same hand-off as `hyperframes open`). Shown only when that server says it can.
|
||||
const ROUTE = "/api/open-in-desktop";
|
||||
const DOWNLOAD_URL = "https://hyperframes.dev/studio/download";
|
||||
// ponytail: the desktop app opens a handed-over folder only from its next release; until then the button
|
||||
// introduces Framey and links the download. Flip once that release ships.
|
||||
export const HANDOFF_READY = false;
|
||||
/** How far the pupils travel towards the pointer, in the glyph's own units. */
|
||||
const GAZE = 2.2;
|
||||
/** Long enough for the flight to finish before the label comes back. */
|
||||
const OPENING_MS = 1600;
|
||||
|
||||
type OpenResult =
|
||||
| { opened: true }
|
||||
| { opened: false; reason: "not-installed" | "unsupported-platform"; downloadUrl: string };
|
||||
|
||||
function useDesktopHandoffAvailable(): boolean {
|
||||
const [available, setAvailable] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
fetch(ROUTE)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((body: { available?: unknown } | null) => {
|
||||
if (live) setAvailable(body?.available === true);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
return available;
|
||||
}
|
||||
|
||||
/** Framey looks at the pointer: one listener, one write per frame, no re-render. */
|
||||
function useGaze(
|
||||
anchor: React.RefObject<HTMLElement | null>,
|
||||
eye: React.RefObject<SVGGElement | null>,
|
||||
) {
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
const look = (event: PointerEvent) => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const box = anchor.current?.getBoundingClientRect();
|
||||
if (!box || !eye.current) return;
|
||||
const dx = event.clientX - (box.left + box.height / 2);
|
||||
const dy = event.clientY - (box.top + box.height / 2);
|
||||
const distance = Math.hypot(dx, dy) || 1;
|
||||
eye.current.style.transform = `translate(${(dx / distance) * GAZE}px, ${(dy / distance) * GAZE}px)`;
|
||||
});
|
||||
};
|
||||
window.addEventListener("pointermove", look);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("pointermove", look);
|
||||
};
|
||||
}, [anchor, eye]);
|
||||
}
|
||||
|
||||
export function OpenInDesktopButton() {
|
||||
const { showToast } = useStudioShellContext();
|
||||
const available = useDesktopHandoffAvailable();
|
||||
const [opening, setOpening] = useState(false);
|
||||
// The card under the button, introducing Framey with the app's download.
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const eye = useRef<SVGGElement>(null);
|
||||
useGaze(trigger, eye);
|
||||
if (!available) return null;
|
||||
|
||||
const open = async () => {
|
||||
if (!HANDOFF_READY) {
|
||||
trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
|
||||
setDownloadUrl(DOWNLOAD_URL);
|
||||
return;
|
||||
}
|
||||
if (opening) return;
|
||||
setOpening(true);
|
||||
trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
|
||||
try {
|
||||
const res = await fetch(ROUTE, { method: "POST" });
|
||||
const result = (await res.json()) as OpenResult;
|
||||
if (result.opened) {
|
||||
showToast("Framey is opening this project in HyperFrames Studio", "info");
|
||||
await new Promise((done) => setTimeout(done, OPENING_MS));
|
||||
} else setDownloadUrl(result.downloadUrl);
|
||||
} catch {
|
||||
showToast("Couldn't reach the preview server to open the app", "error");
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={downloadUrl !== null}
|
||||
// The popover owns the trigger's press: a press runs the hand-off, and the card shows only when there is
|
||||
// something to say (the download). A second handler on the button raced this one.
|
||||
onOpenChange={(next) => {
|
||||
if (next) void open();
|
||||
else setDownloadUrl(null);
|
||||
}}
|
||||
side="bottom"
|
||||
align="end"
|
||||
arrow
|
||||
aria-label="Meet Framey"
|
||||
className="w-72"
|
||||
trigger={
|
||||
<Button
|
||||
ref={trigger}
|
||||
variant="ghost"
|
||||
data-testid="header-open-in-desktop"
|
||||
data-opening={opening || undefined}
|
||||
title="Open this project in HyperFrames Studio, the desktop app, and edit it with Framey"
|
||||
className="hf-framey-trigger gap-1.5 pl-1.5"
|
||||
icon={<FrameyGlyph size={20} className="hf-framey" eyeRef={eye} />}
|
||||
>
|
||||
{opening ? "Opening in the app…" : "Edit with Framey"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FrameyGlyph size={36} />
|
||||
<p className="text-step-12 font-medium text-text-0">Meet Framey</p>
|
||||
</div>
|
||||
<p className="mt-2 text-text-3">
|
||||
I live in HyperFrames Studio, the free desktop app for macOS:
|
||||
</p>
|
||||
<ul className="mt-1.5 grid gap-1 text-text-1">
|
||||
<li>Chat to change anything in the video</li>
|
||||
<li>Draw on a frame to point at it</li>
|
||||
<li>Watch me act out each edit</li>
|
||||
</ul>
|
||||
<div className="mt-3 flex items-center justify-end gap-2 whitespace-nowrap">
|
||||
<Button variant="ghost" onClick={() => setDownloadUrl(null)}>
|
||||
Not now
|
||||
</Button>
|
||||
<a
|
||||
href={downloadUrl ?? undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonBase, buttonVariants.primary, buttonSizes.md)}
|
||||
onClick={() => trackStudioEvent("toolbar_action", { action: "get_desktop_app" })}
|
||||
>
|
||||
Get HyperFrames Studio
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,8 @@ vi.mock("../contexts/PanelLayoutContext", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
||||
// Probes the preview server on mount; covered in OpenInDesktopButton.dom.test.tsx.
|
||||
vi.mock("./OpenInDesktopButton", () => ({ OpenInDesktopButton: () => null }));
|
||||
|
||||
const { StudioHeader } = await import("./StudioHeader");
|
||||
const { ShowThemeToggle } = await import("./ThemeToggle");
|
||||
|
||||
@@ -6,6 +6,7 @@ import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui";
|
||||
import { Dock } from "./dock/Dock";
|
||||
import { InspectorIcon } from "./icons/InspectorIcon";
|
||||
import { OpenInDesktopButton } from "./OpenInDesktopButton";
|
||||
import { HyperframesLogo } from "./ui/HyperframesLogo";
|
||||
import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
@@ -151,6 +152,7 @@ export const StudioHeader = memo(function StudioHeader({
|
||||
</div>
|
||||
{showThemeToggle && <ThemeToggle />}
|
||||
<Dock.WindowMenu />
|
||||
<OpenInDesktopButton />
|
||||
<Tooltip
|
||||
label={
|
||||
ffmpegMissing
|
||||
|
||||
@@ -935,3 +935,54 @@
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Framey on the header's "Edit with Framey" (OpenInDesktopButton.tsx): a wiggle on hover, a flight towards
|
||||
* the app while it opens. The eye follows the pointer from script. */
|
||||
@keyframes hf-framey-wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-14deg);
|
||||
}
|
||||
60% {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hf-framey-fly {
|
||||
70% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(48px, -28px) rotate(25deg) scale(0.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hf-framey {
|
||||
transform-origin: 30% 30%;
|
||||
}
|
||||
|
||||
.hf-framey-eye {
|
||||
transition: transform 80ms linear;
|
||||
}
|
||||
|
||||
.hf-framey-trigger:hover .hf-framey {
|
||||
animation: hf-framey-wiggle 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.hf-framey-trigger[data-opening] .hf-framey {
|
||||
animation: hf-framey-fly 0.9s ease-in forwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hf-framey-trigger:hover .hf-framey,
|
||||
.hf-framey-trigger[data-opening] .hf-framey {
|
||||
animation: none;
|
||||
}
|
||||
.hf-framey-eye {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,6 +436,9 @@
|
||||
--color-violet-400: oklch(70.2% 0.183 293.541);
|
||||
--color-violet-500: oklch(60.6% 0.25 292.717);
|
||||
--color-white: #fff;
|
||||
/* Framey, the desktop app's cursor mascot (hyperframes-internal packages/nib): the same in both themes. */
|
||||
--color-framey: #3ce6ac;
|
||||
--color-framey-ink: #16121a;
|
||||
}
|
||||
|
||||
/* The colour-scheme picks each token's light or dark half: dark unless the document element
|
||||
|
||||
Reference in New Issue
Block a user