From 39cde0d62086a264c52995ee18e5c35b17ee8e07 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 22 Jul 2026 13:55:00 +0800 Subject: [PATCH] feat: refresh demo seed and login device sessions --- apps/api/src/auth-session-devices.ts | 50 + apps/api/src/index.ts | 172 +- apps/mobile/src/lib/session.tsx | 13 + apps/web/src/lib/api.ts | 24 +- docs/openapi.json | 6 + migrations/0015_auth_session_devices.sql | 11 + packages/client/src/index.ts | 3 +- packages/shared/src/schemas.ts | 1 + tests/auth-session-devices.test.ts | 54 + .../.hyperframes/caption-skin.html | 223 + videos/edgeever-promo/.media/preferences.json | 20 + videos/edgeever-promo/AGENTS.md | 93 + videos/edgeever-promo/BRIEF.md | 36 + videos/edgeever-promo/CLAUDE.md | 93 + videos/edgeever-promo/STORYBOARD.md | 83 + videos/edgeever-promo/capture/.cursorrules | 24 + videos/edgeever-promo/capture/AGENTS.md | 24 + videos/edgeever-promo/capture/CLAUDE.md | 24 + .../screens/workspace-agent-mcp-dark.png | Bin 0 -> 86868 bytes .../assets/screens/workspace-agent-mcp.png | Bin 0 -> 95022 bytes .../assets/screens/workspace-editor.png | Bin 0 -> 102191 bytes .../capture/assets/svgs/contact-sheet.jpg | Bin 0 -> 21817 bytes .../capture/assets/svgs/logo-2409432b.svg | 1 + .../capture/assets/svgs/logo-b7752207.svg | 1 + .../capture/assets/svgs/svg-1b0e8c1e.svg | 1 + .../capture/extracted/animations.json | 12 + .../capture/extracted/asset-descriptions.md | 12 + .../capture/extracted/design-styles.json | 100 + .../capture/extracted/fonts-manifest.json | 9 + .../capture/extracted/page.html | 4354 +++++++++++++++++ .../capture/extracted/tokens.json | 163 + .../capture/extracted/visible-text.txt | 13 + videos/edgeever-promo/capture/meta.json | 4 + .../capture/screenshots/contact-sheet.jpg | Bin 0 -> 12750 bytes .../capture/screenshots/scroll-000.png | Bin 0 -> 467484 bytes videos/edgeever-promo/frame.md | 274 ++ videos/edgeever-promo/hyperframes.json | 12 + videos/edgeever-promo/index.html | 52 + videos/edgeever-promo/meta.json | 5 + videos/edgeever-promo/package.json | 11 + wrangler.toml | 2 +- 41 files changed, 5936 insertions(+), 44 deletions(-) create mode 100644 apps/api/src/auth-session-devices.ts create mode 100644 migrations/0015_auth_session_devices.sql create mode 100644 tests/auth-session-devices.test.ts create mode 100644 videos/edgeever-promo/.hyperframes/caption-skin.html create mode 100644 videos/edgeever-promo/.media/preferences.json create mode 100644 videos/edgeever-promo/AGENTS.md create mode 100644 videos/edgeever-promo/BRIEF.md create mode 100644 videos/edgeever-promo/CLAUDE.md create mode 100644 videos/edgeever-promo/STORYBOARD.md create mode 100644 videos/edgeever-promo/capture/.cursorrules create mode 100644 videos/edgeever-promo/capture/AGENTS.md create mode 100644 videos/edgeever-promo/capture/CLAUDE.md create mode 100644 videos/edgeever-promo/capture/assets/screens/workspace-agent-mcp-dark.png create mode 100644 videos/edgeever-promo/capture/assets/screens/workspace-agent-mcp.png create mode 100644 videos/edgeever-promo/capture/assets/screens/workspace-editor.png create mode 100644 videos/edgeever-promo/capture/assets/svgs/contact-sheet.jpg create mode 100644 videos/edgeever-promo/capture/assets/svgs/logo-2409432b.svg create mode 100644 videos/edgeever-promo/capture/assets/svgs/logo-b7752207.svg create mode 100644 videos/edgeever-promo/capture/assets/svgs/svg-1b0e8c1e.svg create mode 100644 videos/edgeever-promo/capture/extracted/animations.json create mode 100644 videos/edgeever-promo/capture/extracted/asset-descriptions.md create mode 100644 videos/edgeever-promo/capture/extracted/design-styles.json create mode 100644 videos/edgeever-promo/capture/extracted/fonts-manifest.json create mode 100644 videos/edgeever-promo/capture/extracted/page.html create mode 100644 videos/edgeever-promo/capture/extracted/tokens.json create mode 100644 videos/edgeever-promo/capture/extracted/visible-text.txt create mode 100644 videos/edgeever-promo/capture/meta.json create mode 100644 videos/edgeever-promo/capture/screenshots/contact-sheet.jpg create mode 100644 videos/edgeever-promo/capture/screenshots/scroll-000.png create mode 100644 videos/edgeever-promo/frame.md create mode 100644 videos/edgeever-promo/hyperframes.json create mode 100644 videos/edgeever-promo/index.html create mode 100644 videos/edgeever-promo/meta.json create mode 100644 videos/edgeever-promo/package.json diff --git a/apps/api/src/auth-session-devices.ts b/apps/api/src/auth-session-devices.ts new file mode 100644 index 00000000..5dd6b845 --- /dev/null +++ b/apps/api/src/auth-session-devices.ts @@ -0,0 +1,50 @@ +import type { LoginDeviceSession } from "@edgeever/shared"; + +export type LoginDeviceSessionRow = { + id: string; + device_id: string | null; + user_agent: string | null; + expires_at: string; + created_at: string; + last_seen_at: string | null; +}; + +export const resolveSessionDeviceId = ( + requestedDeviceId: string | undefined, + userAgent: string | null, + sessionId: string, +) => requestedDeviceId ?? (userAgent?.trim() ? `legacy-ua:${userAgent}` : `legacy-session:${sessionId}`); + +export const groupLoginDeviceSessions = ( + rows: LoginDeviceSessionRow[], + currentSessionId: string, +): LoginDeviceSession[] => { + const grouped = new Map< + string, + { representative: LoginDeviceSessionRow; createdAt: string; lastSeenAt: string } + >(); + + for (const row of rows) { + const key = row.device_id ?? `session:${row.id}`; + const lastSeenAt = row.last_seen_at ?? row.created_at; + const existing = grouped.get(key); + + if (!existing) { + grouped.set(key, { representative: row, createdAt: row.created_at, lastSeenAt }); + continue; + } + + if (row.created_at < existing.createdAt) existing.createdAt = row.created_at; + if (lastSeenAt > existing.lastSeenAt) existing.lastSeenAt = lastSeenAt; + if (row.id === currentSessionId) existing.representative = row; + } + + return [...grouped.values()].map(({ representative, createdAt, lastSeenAt }) => ({ + id: representative.id, + userAgent: representative.user_agent, + isCurrent: representative.id === currentSessionId, + createdAt, + lastSeenAt, + expiresAt: representative.expires_at, + })); +}; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 961c718c..dea1bb57 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -63,6 +63,11 @@ import { resolveDemoPasswordHash, shouldUpsertDemoSeedRecord, } from "./demo-mode"; +import { + groupLoginDeviceSessions, + resolveSessionDeviceId, + type LoginDeviceSessionRow, +} from "./auth-session-devices"; type Bindings = { DB: D1Database; @@ -399,6 +404,53 @@ const DEMO_SEED_MEMOS = [ markdown: "## 图片笔记示例\n\n笔记正文可以直接插入图片。上传后的图片会进入 R2,正文里保存的是资源 URL,API、MCP 和前端编辑器都能读取。\n\n![EdgeEver 图片资源示例](/api/v1/resources/res_demo_gallery_image/blob)\n\n**图注:** 一张图片和它的说明、结论放在同一条笔记里,回看时就不必猜测截图来自哪里。\n\n这类笔记适合保存截图、设计稿、读书摘图和临时资料。", }, + { + id: "memo_demo_table", + notebookId: "nb_demo_features", + title: "表格编辑:功能对比", + tags: ["table", "editor", "demo"], + isPinned: true, + markdown: + "## 表格编辑:功能对比\n\n这张表适合在 Web 或移动端直接编辑。可以点击单元格修改内容,也可以继续添加行列。\n\n| 能力 | Web | 移动端 | 适合场景 |\n| --- | --- | --- | --- |\n| 创建表格 | 支持 | 支持 | 会议记录 |\n| 编辑单元格 | 支持 | 支持 | 计划跟踪 |\n| 横向滚动 | 支持 | 支持 | 对比资料 |\n\n试着把「会议记录」改成一个真实项目,体验表格在桌面和手机上的一致性。", + }, + { + id: "memo_demo_character_count", + notebookId: "nb_demo_features", + title: "字符数统计:一段产品摘要", + tags: ["editor", "character-count", "demo"], + isPinned: false, + markdown: + "## 字符数统计:一段产品摘要\n\nEdgeEver 是一个开放、轻量、支持 AI Agent 的个人知识库。它用三栏工作流承载快速记录、集中整理和长期沉淀,让笔记既适合日常捕捉,也方便通过 API 和 MCP 继续加工。\n\n这条笔记故意保留一段完整文字。打开编辑器后,观察底部字符数随着输入、删除和粘贴实时变化。", + }, + { + id: "memo_demo_focus_mode", + notebookId: "nb_demo_features", + title: "桌面专注模式:产品发布提纲", + tags: ["focus-mode", "desktop", "demo"], + isPinned: false, + markdown: + "## 桌面专注模式:产品发布提纲\n\n这是一条适合长文编辑的示例。桌面端点击编辑器右上角的专注模式后,笔记会展开到更宽的工作区,减少笔记本树和列表的干扰。\n\n### 发布信息\n\n- 一句话定位:把零散材料变成可持续维护的知识库\n- 核心演示:快速记录、表格整理、Mermaid 流程和版本恢复\n- 现场操作:进入专注模式,补充一段发布说明,再退出回到三栏布局\n\n专注模式适合写方案、会议纪要、读书笔记和较长的项目总结。", + }, + { + id: "memo_demo_revision", + notebookId: "nb_demo_features", + title: "版本历史:会议决策记录", + tags: ["revision", "history", "demo"], + isPinned: true, + revision: 2, + markdown: + "## 版本历史:会议决策记录\n\n这条笔记预置了历史版本。打开「版本历史」后,可以查看旧版本、对比差异,并恢复选中的版本;恢复操作还会生成新的版本记录。\n\n### 当前结论\n\n- 首版先支持 Web 和移动端的表格编辑\n- Mermaid 示例覆盖流程、时序、状态和甘特图\n- 发布前用专注模式完成长文校对\n\n请先查看版本 1 的草稿,再回到当前版本,体验历史内容与最新结论之间的差异。", + }, +]; +const DEMO_SEED_REVISIONS = [ + { + id: "rev_demo_revision_1", + memoId: "memo_demo_revision", + revision: 1, + title: "版本历史:会议决策记录", + markdown: + "## 会议决策记录\n\n- 先完成编辑器和表格能力\n- 再补充 Mermaid 图表\n- 最后统一整理发布说明\n\n这是会议初稿,后续版本补充了验证结果。", + }, ]; const DEMO_SEED_RESOURCES = [ { @@ -535,32 +587,19 @@ app.get("/api/v1/auth/sessions", async (c) => { const now = isoNow(); const rows = await c.env.DB.prepare( - `SELECT id, user_agent, expires_at, created_at, last_seen_at + `SELECT id, device_id, user_agent, expires_at, created_at, last_seen_at FROM sessions WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ? ORDER BY COALESCE(last_seen_at, created_at) DESC - LIMIT 50` + LIMIT 200` ) .bind(auth.actorId, now) - .all<{ - id: string; - user_agent: string | null; - expires_at: string; - created_at: string; - last_seen_at: string | null; - }>(); + .all(); return c.json({ - sessions: rows.results.map((session) => ({ - id: session.id, - userAgent: session.user_agent, - isCurrent: session.id === auth.sessionId, - createdAt: session.created_at, - lastSeenAt: session.last_seen_at ?? session.created_at, - expiresAt: session.expires_at, - })), + sessions: groupLoginDeviceSessions(rows.results, auth.sessionId).slice(0, 50), }); }); @@ -598,18 +637,31 @@ app.delete("/api/v1/auth/sessions/:sessionId", async (c) => { const now = isoNow(); const session = await c.env.DB.prepare( - `SELECT id FROM sessions + `SELECT id, device_id FROM sessions WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > ?` ) .bind(sessionId, auth.actorId, now) - .first<{ id: string }>(); + .first<{ id: string; device_id: string | null }>(); if (!session) { return notFound(c, "Login session not found."); } + const currentSession = await c.env.DB.prepare(`SELECT device_id FROM sessions WHERE id = ? AND user_id = ?`) + .bind(auth.sessionId, auth.actorId) + .first<{ device_id: string | null }>(); + + if (session.device_id && currentSession?.device_id === session.device_id) { + return apiError(c, "current_session_cannot_be_revoked", "The current device cannot be revoked here.", 400); + } + await c.env.DB.batch([ - c.env.DB.prepare(`UPDATE sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`).bind(now, session.id), + session.device_id + ? c.env.DB.prepare( + `UPDATE sessions SET revoked_at = ? + WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL` + ).bind(now, auth.actorId, session.device_id) + : c.env.DB.prepare(`UPDATE sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`).bind(now, session.id), auditStatement(c.env.DB, "user", auth.actorId, "auth.session_revoke", "session", session.id, {}), ]); @@ -630,7 +682,7 @@ app.post("/api/v1/auth/login", zValidator("json", LoginSchema), async (c) => { } const workspace = await ensureUserWorkspace(c.env.DB, user.id, user.username); - const session = await createSession(c, user); + const session = await createSession(c, user, input.deviceId); setSessionCookie(c, session.token, session.maxAge); await c.env.DB.batch([ @@ -3792,31 +3844,28 @@ const createDefaultNotebookRows = (workspaceId: string, _now: string) => [ { id: `${workspaceId}_personal`, name: "生活个人", slug: "personal-life", color: "#ea580c", sortOrder: 50 }, ]; -const createSession = async (c: AppContext, user: UserRow) => { +const createSession = async (c: AppContext, user: UserRow, requestedDeviceId?: string) => { const token = randomToken(SESSION_TOKEN_BYTES); const id = createId("sess"); const now = isoNow(); const maxAge = getSessionMaxAge(c.env); const expiresAt = new Date(Date.now() + maxAge * 1000).toISOString(); + const userAgent = c.req.header("User-Agent") ?? null; + const deviceId = resolveSessionDeviceId(requestedDeviceId, userAgent, id); const ip = c.req.header("CF-Connecting-IP"); const ipHash = ip ? await sha256(ip) : null; - await c.env.DB.prepare( - `INSERT INTO sessions ( - id, user_id, token_hash, user_agent, ip_hash, expires_at, created_at, last_seen_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ) - .bind( - id, - user.id, - await sha256(token), - c.req.header("User-Agent") ?? null, - ipHash, - expiresAt, - now, - now - ) - .run(); + await c.env.DB.batch([ + c.env.DB.prepare( + `UPDATE sessions SET revoked_at = ? + WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL` + ).bind(now, user.id, deviceId), + c.env.DB.prepare( + `INSERT INTO sessions ( + id, user_id, token_hash, device_id, user_agent, ip_hash, expires_at, created_at, last_seen_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).bind(id, user.id, await sha256(token), deviceId, userAgent, ipHash, expiresAt, now, now), + ]); return { id, token, maxAge }; }; @@ -5443,16 +5492,25 @@ const ensureDemoSeed = async ( .prepare( `INSERT INTO memo_contents ( memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, 0, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(memo_id) DO UPDATE SET content_json = excluded.content_json, content_markdown = excluded.content_markdown, content_text = excluded.content_text, content_hash = excluded.content_hash, - revision = 0, + revision = excluded.revision, updated_at = excluded.updated_at` ) - .bind(memo.id, JSON.stringify(contentJson), memo.markdown, contentText, contentHash, now, now), + .bind( + memo.id, + JSON.stringify(contentJson), + memo.markdown, + contentText, + contentHash, + "revision" in memo ? memo.revision : 0, + now, + now, + ), db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(memo.id), db .prepare( @@ -5463,6 +5521,38 @@ const ensureDemoSeed = async ( ); } + for (const revision of DEMO_SEED_REVISIONS) { + const contentJson = markdownToDoc(revision.markdown); + const contentHash = await sha256(revision.markdown + JSON.stringify(contentJson)); + + statements.push( + db + .prepare( + `INSERT INTO memo_revisions ( + id, memo_id, revision, title, content_json, content_markdown, content_hash, + created_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'system', ?) + ON CONFLICT(id) DO UPDATE SET + memo_id = excluded.memo_id, + revision = excluded.revision, + title = excluded.title, + content_json = excluded.content_json, + content_markdown = excluded.content_markdown, + content_hash = excluded.content_hash` + ) + .bind( + revision.id, + revision.memoId, + revision.revision, + revision.title, + JSON.stringify(contentJson), + revision.markdown, + contentHash, + now, + ), + ); + } + const existingResourceIds = options.refreshResources || overwriteExisting ? new Set() : new Set( diff --git a/apps/mobile/src/lib/session.tsx b/apps/mobile/src/lib/session.tsx index 63010969..7de8cb99 100644 --- a/apps/mobile/src/lib/session.tsx +++ b/apps/mobile/src/lib/session.tsx @@ -5,6 +5,17 @@ import * as SecureStore from "expo-secure-store"; import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; const SESSION_KEY = "edgeever.mobile.session"; +const DEVICE_ID_KEY = "edgeever.mobile.device-id"; + +const getOrCreateDeviceId = async () => { + const existing = await SecureStore.getItemAsync(DEVICE_ID_KEY); + if (existing) return existing; + + const randomPart = `${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`; + const deviceId = `mobile-${Date.now().toString(36)}-${randomPart}`; + await SecureStore.setItemAsync(DEVICE_ID_KEY, deviceId); + return deviceId; +}; export type MobileSession = { baseUrl: string; @@ -67,10 +78,12 @@ export const SessionProvider = ({ children }: { children: ReactNode }) => { const signIn = useCallback(async (input: { baseUrl: string; username: string; password: string }) => { const baseUrl = normalizeInstanceUrl(input.baseUrl); + const deviceId = await getOrCreateDeviceId(); const loginClient = createEdgeEverClient({ baseUrl }); const authSession = await loginClient.login({ username: input.username, password: input.password, + deviceId, }); if (!authSession.authenticated || !authSession.sessionToken) { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 928cef3a..82342e51 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -52,6 +52,28 @@ type ListUsersResponse = { users: InstanceUser[] }; type UserResponse = { user: InstanceUser }; type ListLoginDeviceSessionsResponse = { sessions: LoginDeviceSession[] }; +const WEB_DEVICE_ID_STORAGE_KEY = "edgeever.web.device-id"; + +const createWebDeviceId = () => { + const uuid = globalThis.crypto?.randomUUID?.(); + return uuid + ? `web-${uuid}` + : `web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`; +}; + +const getOrCreateWebDeviceId = () => { + try { + const existing = window.localStorage.getItem(WEB_DEVICE_ID_STORAGE_KEY); + if (existing) return existing; + + const deviceId = createWebDeviceId(); + window.localStorage.setItem(WEB_DEVICE_ID_STORAGE_KEY, deviceId); + return deviceId; + } catch { + return createWebDeviceId(); + } +}; + type MemoResponse = { memo: MemoDetail; }; @@ -133,7 +155,7 @@ export const api = { login: (payload: { username: string; password: string }) => request("/api/v1/auth/login", { method: "POST", - body: JSON.stringify(payload), + body: JSON.stringify({ ...payload, deviceId: getOrCreateWebDeviceId() }), }), changePassword: (payload: { currentPassword: string; newPassword: string; confirmPassword: string }) => diff --git a/docs/openapi.json b/docs/openapi.json index 916bbbed..0534d303 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1462,6 +1462,12 @@ }, "password": { "type": "string" + }, + "deviceId": { + "type": "string", + "minLength": 16, + "maxLength": 160, + "description": "Stable client installation identifier used to replace older sessions from the same device" } }, "required": ["username", "password"] diff --git a/migrations/0015_auth_session_devices.sql b/migrations/0015_auth_session_devices.sql new file mode 100644 index 00000000..df14ce6a --- /dev/null +++ b/migrations/0015_auth_session_devices.sql @@ -0,0 +1,11 @@ +ALTER TABLE sessions ADD COLUMN device_id TEXT; + +UPDATE sessions +SET device_id = CASE + WHEN user_agent IS NULL OR trim(user_agent) = '' THEN 'legacy-session:' || id + ELSE 'legacy-ua:' || user_agent +END +WHERE device_id IS NULL; + +CREATE INDEX idx_sessions_user_device + ON sessions(user_id, device_id, revoked_at, expires_at); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 52003b74..6d8af73b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,6 +1,7 @@ import type { ApiToken, AuthSession, + LoginInput, LoginDeviceSession, InstanceUser, CreatedApiToken, @@ -181,7 +182,7 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => { revokeOtherLoginDeviceSessions: () => request<{ ok: true }>("/api/v1/auth/sessions", { method: "DELETE" }), - login: (payload: { username: string; password: string }) => + login: (payload: LoginInput) => request("/api/v1/auth/login", { method: "POST", body: JSON.stringify(payload), diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 449b4a9b..88b3c5d5 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -54,6 +54,7 @@ export const MergeMemosSchema = z.object({ export const LoginSchema = z.object({ username: z.string().trim().min(1).max(80), password: z.string().min(1).max(512), + deviceId: z.string().trim().min(16).max(160).optional(), }); export const ChangePasswordSchema = z diff --git a/tests/auth-session-devices.test.ts b/tests/auth-session-devices.test.ts new file mode 100644 index 00000000..f7b35088 --- /dev/null +++ b/tests/auth-session-devices.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { LoginSchema } from "@edgeever/shared"; +import { + groupLoginDeviceSessions, + resolveSessionDeviceId, + type LoginDeviceSessionRow, +} from "../apps/api/src/auth-session-devices"; + +describe("login device sessions", () => { + test("accepts an optional stable client device id", () => { + expect(LoginSchema.parse({ username: "owner", password: "secret", deviceId: "web-device-123456" }).deviceId) + .toBe("web-device-123456"); + expect(LoginSchema.parse({ username: "owner", password: "secret" }).deviceId).toBeUndefined(); + expect(() => LoginSchema.parse({ username: "owner", password: "secret", deviceId: "short" })).toThrow(); + }); + + test("falls back to a stable legacy user-agent device id", () => { + expect(resolveSessionDeviceId(undefined, "EdgeEver/1.4.0", "sess_new")) + .toBe("legacy-ua:EdgeEver/1.4.0"); + expect(resolveSessionDeviceId(undefined, null, "sess_new")).toBe("legacy-session:sess_new"); + }); + + test("groups repeated sessions from the same device and preserves the current session", () => { + const rows: LoginDeviceSessionRow[] = [ + { + id: "sess_latest", + device_id: "mobile-installation-1", + user_agent: "EdgeEver/1.4.0", + created_at: "2026-07-20T00:00:00.000Z", + last_seen_at: "2026-07-22T04:00:00.000Z", + expires_at: "2027-07-20T00:00:00.000Z", + }, + { + id: "sess_current", + device_id: "mobile-installation-1", + user_agent: "EdgeEver/1.4.0", + created_at: "2026-07-18T00:00:00.000Z", + last_seen_at: "2026-07-21T04:00:00.000Z", + expires_at: "2027-07-18T00:00:00.000Z", + }, + ]; + + expect(groupLoginDeviceSessions(rows, "sess_current")).toEqual([ + { + id: "sess_current", + userAgent: "EdgeEver/1.4.0", + isCurrent: true, + createdAt: "2026-07-18T00:00:00.000Z", + lastSeenAt: "2026-07-22T04:00:00.000Z", + expiresAt: "2027-07-18T00:00:00.000Z", + }, + ]); + }); +}); diff --git a/videos/edgeever-promo/.hyperframes/caption-skin.html b/videos/edgeever-promo/.hyperframes/caption-skin.html new file mode 100644 index 00000000..b3d28986 --- /dev/null +++ b/videos/edgeever-promo/.hyperframes/caption-skin.html @@ -0,0 +1,223 @@ + + + + + + + + +
+ +
+ + diff --git a/videos/edgeever-promo/.media/preferences.json b/videos/edgeever-promo/.media/preferences.json new file mode 100644 index 00000000..1f833c59 --- /dev/null +++ b/videos/edgeever-promo/.media/preferences.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "preferences": { + "destination": { + "value": "web-gif", + "confirmed_in": [ + "edgeever-promo" + ], + "updated_at": "2026-07-20T03:00:24.066Z" + }, + "style_preset.product-launch-video": { + "value": "editorial-forest", + "confirmed_in": [ + "edgeever-promo" + ], + "updated_at": "2026-07-20T03:00:24.144Z" + } + }, + "sightings": {} +} diff --git a/videos/edgeever-promo/AGENTS.md b/videos/edgeever-promo/AGENTS.md new file mode 100644 index 00000000..a63562ce --- /dev/null +++ b/videos/edgeever-promo/AGENTS.md @@ -0,0 +1,93 @@ +# HyperFrames Composition Project + +## Skills — USE THESE FIRST + +**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions. + +**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to: + +- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals. +- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer. +- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched. +- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.) +- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → 30-90s code-change explainer (changelog / feature reveal / fix / refactor). +- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`. +- `/music-to-video` — a **music track** (audio file, or video to pull audio from) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid. +- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video. +- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length. + +**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above. + +The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-keyframes`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-registry`, `/figma`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent. + +> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`. + +> **Skill missing or stale?** Run `npx hyperframes skills update ` to install/refresh +> the specific skill you need (the `/hyperframes` router does this automatically before +> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus +> everything already installed — neither pulls the full set. Restart the agent session so +> newly installed skills load. + +## Commands + +```bash +npm run dev # start the preview server (long-running — keep it alive in background) +npm run check # lint + runtime + layout + motion + contrast (one command) +npm run render # render to MP4 +npm run publish # publish and get a shareable link +npx hyperframes lint --verbose # include info-level findings +npx hyperframes lint --json # machine-readable output for CI +npx hyperframes docs # reference docs in terminal +``` + +> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped. +> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground +> command — it will time out and the server will die, breaking the browser preview. + +> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself. + +## Documentation + +**For quick reference**, use the local CLI docs command (no network required): + +```bash +npx hyperframes docs +``` + +Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `examples`, `troubleshooting` + +**For full documentation**, discover pages via the machine-readable index — do NOT guess URLs: + +``` +https://hyperframes.heygen.com/llms.txt +``` + +## Project Structure + +- `index.html` — main composition (root timeline) +- `compositions/` — sub-compositions referenced via `data-composition-src` +- `meta.json` — project metadata (id, name) +- `transcript.json` — whisper word-level transcript (if generated) + +## Linting — ALWAYS RUN AFTER CHANGES + +After creating or editing any `.html` composition, **always** run the full check before considering the task complete: + +```bash +npm run check +``` + +Fix all errors before presenting the result. Warnings should be reviewed before rendering. + +## Key Rules + +1. Every timed element needs `data-start`, `data-duration`, and `data-track-index` +2. Elements with timing **MUST** have `class="clip"` — the framework uses this for visibility control +3. Timelines must be paused and registered on `window.__timelines`: + ```js + window.__timelines = window.__timelines || {}; + window.__timelines["composition-id"] = gsap.timeline({ paused: true }); + ``` +4. Videos use `muted` with a separate `