diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46a6ff5..f9a07d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [dev, main] pull_request: +permissions: + contents: read + jobs: verify: runs-on: macos-latest diff --git a/README.md b/README.md index 0239bd3..917f677 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,49 @@ What makes it different is the **Edit Agent**: a chat panel that sits beside the Nothing is uploaded on its own. Model providers are opt-in, connected one at a time with your own API key or sign-in, and the app works with none of them connected. +## Architecture + +```mermaid +flowchart LR + Creator[Creator] + + subgraph Renderer["Renderer — React UI"] + Editor["Timeline · Program Monitor"] + Agent["Edit Agent · approval UI"] + Studios["Voice · Image · Video studios"] + end + + Bridge["Preload — typed window.videoTool bridge"] + + subgraph Main["Electron main process"] + Policy["Validation · approval · provider policy"] + Projects["Local projects · assets · chats"] + Jobs["FFmpeg export · AI job manager"] + Secrets["safeStorage · OAuth tokens"] + Tools["TypeMCP tool surface"] + end + + subgraph Shared["Shared editing core"] + Timeline["Timeline rules · composition · validation"] + Planning["Shot planning · cost estimation"] + Contracts["IPC · provider contracts"] + end + + Local[("User-controlled local files")] + Providers["Connected providers\nonly after explicit approval"] + + Creator --> Renderer --> Bridge --> Main + Renderer <--> Shared + Main <--> Shared + Main <--> Local + Jobs --> Providers + Tools --> Policy +``` + +The **renderer** collects intent and renders editor state; it never receives raw IPC, FFmpeg execution paths or arguments, or stored provider credentials and OAuth tokens. The **preload** layer exposes only the typed `window.videoTool` bridge. The **main process** owns local projects, secrets, job lifecycle, local FFmpeg execution, and the TypeMCP tool surface. Editing rules, composition, validation, and generation planning live in the portable **shared core**, which desktop and mobile use together. + +Project folders, imports, generated results, chats, and exports remain local. A connected provider is contacted only for an operation you explicitly start: in a generation studio, that is the visible **Generate** action; for an agent-initiated mutation or job, the Edit Agent asks for approval before execution. The Program Monitor is a best-effort review surface; local FFmpeg MP4 export is the authoritative saved output. + ## The workspace Open a folder and you land in the workspace. One tab strip switches between editing and the two generation studios; the agent chat stays docked beside all three. @@ -127,7 +170,7 @@ The provider and model registry is generated from a snapshot of the [models.dev] - **OpenAI**: two login methods on one provider — an API key, or a ChatGPT sign-in (PKCE OAuth) for the model set that backend serves. Tokens stay in main-process safe storage; the renderer only learns whether you are connected. - **Generation**: 17 runnable video models across Google Veo, OpenAI Sora, Runway and Luma — Runway alone fronts Seedance, Veo 3.1, HappyHorse and Gemini Omni Flash on one key. Eight image models and seven voices. Providers without a real adapter stay listed but honestly unavailable rather than pretending to work, and every model says which it is. -API keys are written to Electron `safeStorage` in the main process and never reach the renderer. +A provider API key is entered in Settings and sent once through the typed bridge to Electron `safeStorage`; stored provider credentials are never returned to the renderer. ## Quick start @@ -171,7 +214,7 @@ Projects are folders you choose. Assets, chat history, and generated results are VIDEO_TOOL_RECORDINGS_DIR=/absolute/path/to/recordings npm run dev ``` -The renderer talks to the main process through a narrow typed `window.videoTool` bridge. Raw `ipcRenderer`, filesystem paths, FFmpeg arguments, API keys, and OAuth tokens stay outside it — a picked reference image, for example, crosses as bytes, never as a path. +The renderer talks to the main process through a narrow typed `window.videoTool` bridge. Raw `ipcRenderer`, FFmpeg executable paths and arguments, stored credentials, and OAuth tokens stay outside it. Some safe display paths and an API key entered in Settings cross through explicit typed operations; a picked reference image, for example, crosses as bytes, never as a path. - **No account, no telemetry.** No analytics, crash reporting, or usage tracking. - **No background network calls.** The app talks to a provider only when you ask it to, using a provider you connected. diff --git a/docs/hybrid-ai-editor-direction.md b/docs/hybrid-ai-editor-direction.md index cb3f5c3..68e04a2 100644 --- a/docs/hybrid-ai-editor-direction.md +++ b/docs/hybrid-ai-editor-direction.md @@ -1,9 +1,11 @@ # OpenScene hybrid AI editor direction -**Status:** Approved future product direction; no AI provider integration is currently shipped +**Status:** Historical direction plus future assisted-editing constraints. Provider-backed voice, image, video generation, and the approval-gated Edit Agent are now released; the AI-assisted editing proposals in §3.1 remain future work. **Date:** 2026-07-23 **Issue:** #12 +> **Current capability source:** [the root README](../README.md) is the public current-capability and release boundary. This document preserves the initial hybrid-AI direction and defines constraints for capabilities that remain unimplemented. + ## 1. Decision OpenScene will evolve into an open-source **hybrid AI video editor**. The local timeline remains the primary workspace and system of record. AI supports the creator with proposed edits and optional generated assets; it does not replace human review, local project ownership, or the existing local export path. @@ -125,9 +127,11 @@ Accounts, billing, analytics, crash reporting, cloud project sync, and hidden ne ## 7. Release and public-copy boundary -The current release provides local selected-window capture, local project/timeline editing, local MP4 export, and user-configured local Qwen TTS. It does **not** currently provide AI-assisted editing, AI generation, or connected external AI services. +The initial July MVP described local selected-window capture, local project/timeline editing, and local MP4 export. The current release boundary is broader: the approval-gated Edit Agent and provider-backed voice, image, and video generation are shipped. See [the root README](../README.md) for provider-specific availability and platform limits. + +The following capability groups remain future-facing until a separately reviewed implementation ships: AI-assisted edit suggestions (cuts, highlight sequences, reframing, captions), automated acceptance of suggestions into a saved timeline, cloud project sync, hosted rendering, accounts, analytics, and hidden network activity. -Until a separately reviewed implementation ships, README, renderer, product site, app metadata, and marketing materials must use future-facing language such as “planned”, “in development”, or “designed for” when mentioning these capabilities. They must not claim automated edits, generated assets, provider calls, or remote processing are available today. +For every shipped or future capability, README, renderer, product site, app metadata, and marketing materials must state whether processing is local or provider-connected, and must not present a proposed capability as current behavior. ## 8. Implementation sequence diff --git a/docs/planning.md b/docs/planning.md index 40342db..f749e7f 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -1,6 +1,8 @@ -# OpenScene Product Plan +# OpenScene Product Plan — Historical MVP Baseline -Status: Draft +> **Current capability source:** [the root README](../README.md) is the current product and release boundary. This document records the 2026-07-20 MVP baseline and its original implementation plan; its former “future” provider and voice statements are not a claim about the current `dev` branch. + +Status: Historical planning baseline Created: 2026-07-20 Audience: product decision-makers, implementers, and security reviewers @@ -10,7 +12,7 @@ OpenScene is an Electron video production app for selecting one desktop window, The user should be able to create work demos, lessons, product walkthroughs, and short presentation videos without setting up a full broadcast tool. The app should reduce accidental privacy exposure by recording a selected window instead of the entire screen. -Longer term, OpenScene will evolve into a hybrid AI video editor: the local recording and editing flow stays primary, while users can optionally use local models or explicitly selected external AI services for assisted editing and media generation. The future direction, data boundary, consent requirements, shared job architecture, and release-copy rules are defined in [`hybrid-ai-editor-direction.md`](hybrid-ai-editor-direction.md). Gemini Veo, OpenAI Sora, and ElevenLabs remain possible future provider targets only; they are not implemented in the current MVP. +The longer-term direction is still a hybrid AI editor: local recording and editing remain primary while optional connected services support the currently shipped generation workflows and future assisted-editing workflows. The historical direction, data boundary, consent requirements, shared job architecture, and remaining future constraints are in [`hybrid-ai-editor-direction.md`](hybrid-ai-editor-direction.md). For what is released on the current branch, including provider-specific availability, use [the root README](../README.md). ## 2. Core Users diff --git a/docs/planning/2026-08-03-issue-197-trust-planning.md b/docs/planning/2026-08-03-issue-197-trust-planning.md new file mode 100644 index 0000000..aaa1514 --- /dev/null +++ b/docs/planning/2026-08-03-issue-197-trust-planning.md @@ -0,0 +1,128 @@ +# Issue 197: Trust & Planning Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Document OpenScene's real security/process architecture, ensure legal shot planning is exact whenever possible, and remove the current MCP production audit finding. + +**Architecture:** README and historical planning documents name the renderer/preload/main/shared/local/provider boundaries. A bounded dynamic-programming planner selects the optimal legal shot sequence before the existing tool surfaces it. The direct TypeMCP version update regenerates npm's lockfile. + +**Tech Stack:** TypeScript, Vitest, npm, Electron/Vite, Mermaid Markdown. + +--- + +### Task 1: Lock the exact-first storyboard contract in tests + +**Objective:** Demonstrate that the existing greedy planner unnecessarily rounds a representable request. + +**Files:** +- Modify: `tests/videoStoryboardPlan.test.ts` + +**Step 1: Write failing test** + +Add a case for Google Gemini 10 seconds that expects `[6, 4]`, total `10`, and no `roundedFrom`. + +**Step 2: Run test to verify failure** + +Run: `npm test -- tests/videoStoryboardPlan.test.ts --reporter=dot` + +Expected: FAIL because the current longest-first loop returns 12 seconds (`[8, 4]`). + +**Step 3: Commit** + +Do not commit until the implementation and tests are green as one coherent feature slice. + +### Task 2: Implement bounded exact-first legal-duration planning + +**Objective:** Select the optimal legal duration sequence without exceeding the 24-shot cap. + +**Files:** +- Modify: `src/shared/videoStoryboardPlan.ts` +- Test: `tests/videoStoryboardPlan.test.ts` + +**Step 1: Implement minimal planner** + +Enumerate totals reachable by legal durations with at most `MAX_PLANNED_SHOTS`. Select an exact plan first; otherwise select the smallest absolute distance. Compare ties by fewer shots and lexicographically longer earlier durations. + +**Step 2: Run focused tests** + +Run: `npm test -- tests/videoStoryboardPlan.test.ts --reporter=dot` + +Expected: PASS. + +**Step 3: Run affected checks** + +Run: `npm run typecheck && npm test -- --reporter=dot` + +Expected: PASS. + +### Task 3: Update TypeMCP and validate production dependency safety + +**Objective:** Remove the current `@hono/node-server` moderate audit finding through a direct supported dependency upgrade. + +**Files:** +- Modify: `package.json` +- Modify: `package-lock.json` + +**Step 1: Upgrade** + +Run: `npm install @theorvane/type-mcp@0.3.1 --save-exact`. + +**Step 2: Verify resolution** + +Run: `npm ls @theorvane/type-mcp @modelcontextprotocol/sdk @hono/node-server --all && npm audit --omit=dev --audit-level=moderate` + +Expected: TypeMCP 0.3.1; no moderate-or-higher production audit result. + +**Step 3: Run affected checks** + +Run: `npm run typecheck && npm test -- --reporter=dot && npm run build` + +Expected: PASS. + +### Task 4: Publish architecture and capability-boundary documentation + +**Objective:** Make the current implementation legible and prevent stale future-only claims. + +**Files:** +- Modify: `README.md` +- Modify: `docs/planning.md` +- Modify: `docs/hybrid-ai-editor-direction.md` + +**Step 1: Add README architecture section** + +Add the approved Mermaid diagram after the product explanation and explain each process ownership and local/connected boundary. + +**Step 2: Reconcile historical documents** + +Mark the planning document's state accurately and add a current-capability pointer. Update the hybrid direction's status/release boundary to distinguish released generation/agent capabilities from still-future assisted-editing capabilities. + +**Step 3: Verify source documentation contract** + +Run: `npm test -- tests/openSceneBrandingSource.test.ts --reporter=dot && git diff --check` + +Expected: PASS. + +### Task 5: Complete cross-surface verification and delivery checkpoint + +**Objective:** Prove the shared-core change and release documentation are ready for review. + +**Files:** +- Review: all changed files + +**Step 1: Run full verification** + +Run: + +```bash +npm test -- --reporter=dot +npm run typecheck +npm run build +npm --prefix mobile run typecheck +npm audit --omit=dev --audit-level=moderate +git diff --check +git status --short --branch +``` + +**Step 2: Commit and push** + +Create one conventional commit, push `feat/197-trust-planning`, then open a PR against `dev` with `Closes #197`. diff --git a/docs/superpowers/specs/2026-08-03-open-scene-trust-planning-design.md b/docs/superpowers/specs/2026-08-03-open-scene-trust-planning-design.md new file mode 100644 index 0000000..0aac8a7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-open-scene-trust-planning-design.md @@ -0,0 +1,64 @@ +# OpenScene Trust & Planning Design + +**Issue:** #197 +**Status:** Approved for implementation +**Date:** 2026-08-03 + +## Goal + +Make OpenScene's public architecture and capability boundaries easy to understand, prevent valid requested video durations from being unnecessarily changed, and remove the known moderate MCP transitive dependency advisory. + +## Scope + +1. Add an architecture section to the root README, using a Mermaid diagram and concise ownership/boundary explanation. +2. Reconcile outdated planning and AI-direction documents with the capabilities that are actually implemented in the current product. +3. Replace the storyboard planner's greedy duration fill with an exact-first search over a bounded legal-duration set. +4. Upgrade `@theorvane/type-mcp` from `0.2.0` to `0.3.1`, bringing its MCP SDK dependency to a version that resolves the reported `@hono/node-server` advisory. + +## Architecture documentation + +The README diagram must show these real boundaries: + +- **Renderer:** React UI, editor, generation studios, agent approval UI. +- **Preload:** narrow typed `window.videoTool` bridge; no raw IPC. +- **Electron main process:** local project/assets/chats, credential storage, provider calls, FFmpeg export, jobs, and TypeMCP tools. +- **Shared core:** portable timeline/composition/validation/planning/contracts consumed by desktop and mobile. +- **Local data:** project folders and Electron user data remain local. +- **Connected providers:** contacted only for an explicitly chosen/approved operation. + +Program Monitor remains best-effort preview; local FFmpeg output remains authoritative. + +## Documentation truth model + +`README.md` is the public current-capability entry point. `docs/planning.md` and `docs/hybrid-ai-editor-direction.md` preserve historical decisions but must not contradict the current implementation. They will identify their historical/future content and link to README for current capability status. + +## Storyboard planning contract + +`planVideoStoryboard({ totalSeconds, providerId })` has a maximum of 24 shots and chooses only that provider's listed legal durations. + +1. Normalize the request to the existing nearest positive integer policy and bounded maximum duration. +2. Search feasible duration combinations within the shot cap. +3. Prefer an exact total. +4. Among equally exact plans, prefer fewer shots; for ties, prefer longer earlier shots. +5. If no exact plan exists, choose the legal result with the smallest absolute distance from the requested duration. Tie-break toward fewer shots, then longer earlier shots. +6. Preserve sequential `startSeconds`, all legal-duration guarantees, continuity keys, and disclosed `roundedFrom` when the selected total differs from the requested total. + +This makes a 10-second Google Gemini request use `[6, 4]` rather than the greedy `[8, 4]` (12 seconds), while an unrepresentable Sora 10-second request remains visibly rounded. + +## Dependency security + +The package-lock will be regenerated through npm after updating the direct TypeMCP dependency. The verification target is a clean `npm audit --omit=dev --audit-level=moderate` result, not a hand-edited lockfile. + +## Non-goals + +- New provider adapters or network calls. +- Automated timeline changes without approval. +- Cloud project storage, accounts, analytics, or UI redesign. +- Changes to persisted compatibility identifiers. + +## Verification + +- Focused planner and cost-gate tests show the new exact-first behavior. +- Full root tests, typecheck, build, and `git diff --check` pass. +- `mobile/` typecheck passes because the shared planning contract stays platform-neutral. +- Production dependency audit passes at the requested moderate threshold. diff --git a/package-lock.json b/package-lock.json index 0e2c2ec..ef3eeee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@langchain/langgraph": "^1.4.8", "@langchain/ollama": "^1.3.0", "@langchain/openai": "^1.5.5", - "@theorvane/type-mcp": "^0.2.0", + "@theorvane/type-mcp": "0.3.1", "@vitejs/plugin-react": "5.1.4", "electron-updater": "^6.8.9", "electron-vite": "^5.0.0", @@ -1090,12 +1090,12 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.15", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.15.tgz", - "integrity": "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -1400,12 +1400,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -1874,12 +1874,13 @@ } }, "node_modules/@theorvane/type-mcp": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@theorvane/type-mcp/-/type-mcp-0.2.0.tgz", - "integrity": "sha512-TKUmEuE4jTqztNZz2NY7pelXWwBlYfO1hpa1XwtZmLiEX3AGplAvrNi22sFj/4vLGl7mrxDEGo0w/HBvdy76oQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@theorvane/type-mcp/-/type-mcp-0.3.1.tgz", + "integrity": "sha512-GKzld+JQNpornITGBLH5UyaodzCooSzTUDFbY8nqYtnuzvWFJbwkaIp/jiejW+AtRZ9IlBu3HOBnKAk72xTjow==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "1.26.0", + "@hono/node-server": "2.0.12", + "@modelcontextprotocol/sdk": "1.30.0", "zod": "^4.4.3" }, "engines": { @@ -4190,9 +4191,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", - "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -4701,9 +4702,9 @@ } }, "node_modules/hono": { - "version": "4.12.32", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", - "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "version": "4.12.34", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.34.tgz", + "integrity": "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4846,9 +4847,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -4946,9 +4947,9 @@ } }, "node_modules/jose": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", - "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "version": "6.2.7", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.7.tgz", + "integrity": "sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" diff --git a/package.json b/package.json index 2f0e808..07e38f7 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@langchain/langgraph": "^1.4.8", "@langchain/ollama": "^1.3.0", "@langchain/openai": "^1.5.5", - "@theorvane/type-mcp": "^0.2.0", + "@theorvane/type-mcp": "0.3.1", "@vitejs/plugin-react": "5.1.4", "electron-updater": "^6.8.9", "electron-vite": "^5.0.0", diff --git a/src/shared/videoStoryboardPlan.ts b/src/shared/videoStoryboardPlan.ts index 647a27c..ad94338 100644 --- a/src/shared/videoStoryboardPlan.ts +++ b/src/shared/videoStoryboardPlan.ts @@ -85,16 +85,52 @@ export function planVideoStoryboard(input: { const requested = Math.max(shortest, Math.round(input.totalSeconds)); const capped = Math.min(requested, longest * MAX_PLANNED_SHOTS); - const durations: number[] = []; - let remaining = capped; - while (remaining > 0 && durations.length < MAX_PLANNED_SHOTS) { - // Prefer an exact finish; otherwise take the longest shot that fits, and - // fall back to the shortest legal length for the final remainder. - const exact = options.find((option) => option === remaining); - const fits = options.find((option) => option <= remaining); - const chosen = exact ?? fits ?? shortest; - durations.push(chosen); - remaining -= chosen; + // A longest-first greedy fill can miss an exact composition: Veo can make + // 10 seconds as 6 + 4, but greedy chooses 8 + 4 and turns it into 12. Search + // the bounded space instead. A result above this ceiling can never be closer + // than one of the legal durations at or below it. + const searchCeiling = capped + longest; + const candidates: number[][] = []; + let previousByTotal = new Map([[0, []]]); + + for (let shotCount = 1; shotCount <= MAX_PLANNED_SHOTS; shotCount += 1) { + const nextByTotal = new Map(); + for (const [total, previous] of previousByTotal) { + for (const duration of options) { + const nextTotal = total + duration; + if (nextTotal > searchCeiling) continue; + // Options are descending and each prior sequence is already the + // lexicographically longest for its total, so the first sequence for a + // total has the preferred longer-earlier order. + if (!nextByTotal.has(nextTotal)) nextByTotal.set(nextTotal, [...previous, duration]); + } + } + + for (const sequence of nextByTotal.values()) candidates.push([...sequence]); + previousByTotal = nextByTotal; + } + + const durations = candidates.reduce((best, candidate) => { + if (best === undefined) return candidate; + + const candidateDistance = Math.abs(candidate.reduce((sum, value) => sum + value, 0) - capped); + const bestDistance = Math.abs(best.reduce((sum, value) => sum + value, 0) - capped); + if (candidateDistance !== bestDistance) return candidateDistance < bestDistance ? candidate : best; + if (candidate.length !== best.length) return candidate.length < best.length ? candidate : best; + + for (let index = 0; index < candidate.length; index += 1) { + const candidateValue = candidate[index] ?? 0; + const bestValue = best[index] ?? 0; + if (candidateValue !== bestValue) return candidateValue > bestValue ? candidate : best; + } + return best; + }, undefined); + + // `requested` is at least the shortest legal duration, so the first planning + // layer always contributes a candidate. Keep this guard to make the contract + // explicit if a provider table is ever accidentally emptied. + if (durations === undefined) { + throw new Error(`No legal shot durations are configured for provider ${input.providerId}.`); } let startSeconds = 0; diff --git a/tests/openSourceGovernance.test.ts b/tests/openSourceGovernance.test.ts index 0bcd728..91830fe 100644 --- a/tests/openSourceGovernance.test.ts +++ b/tests/openSourceGovernance.test.ts @@ -11,6 +11,7 @@ describe('open-source CI contract', () => { expect(workflow).toMatch(/branches:\s*\[dev, main\]/); expect(workflow).toMatch(/^\s*pull_request:\s*$/m); + expect(workflow).toMatch(/^permissions:\s*\n\s+contents:\s+read\s*$/m); const ffmpegInstallIndex = workflow.indexOf('brew install ffmpeg'); const testIndex = workflow.indexOf('npm test'); diff --git a/tests/videoStoryboardPlan.test.ts b/tests/videoStoryboardPlan.test.ts index e8515d4..96df216 100644 --- a/tests/videoStoryboardPlan.test.ts +++ b/tests/videoStoryboardPlan.test.ts @@ -42,6 +42,18 @@ describe('storyboard planning', () => { expect(plan.shots.map((shot) => shot.index)).toEqual([1, 2, 3]); }); + it('uses an exact legal duration combination before rounding a representable request', () => { + // Given / When + // 10s is representable as 6s + 4s for Veo. A longest-first greedy fill + // instead selects 8s + 4s and silently adds two seconds. + const plan = planVideoStoryboard({ totalSeconds: 10, providerId: 'google_gemini' }); + + // Then + expect(plan.shots.map((shot) => shot.durationSeconds)).toEqual([6, 4]); + expect(plan.totalSeconds).toBe(10); + expect(plan.roundedFrom).toBeUndefined(); + }); + it('reports when it could not hit the requested length exactly', () => { // Given / When // 10s cannot be made from 4/8/12, so the plan overshoots and says so