diff --git a/.babelrc b/.babelrc index c576f9a..3555410 100644 --- a/.babelrc +++ b/.babelrc @@ -1,9 +1,14 @@ { - "presets": ["next/babel"], "env": { + "development": { + "presets": ["next/babel"] + }, + "production": { + "presets": ["next/babel"] + }, "test": { "presets": [ - ["@babel/preset-env", { "targets": { "node": "current" } }], + ["@babel/preset-env", { "targets": { "node": "current" }, "modules": "commonjs" }], ["@babel/preset-react", { "runtime": "automatic" }], "@babel/preset-typescript" ], diff --git a/.claude/skills/pre-push-audit/SKILL.md b/.claude/skills/pre-push-audit/SKILL.md index 4d42d14..e4b1ba1 100644 --- a/.claude/skills/pre-push-audit/SKILL.md +++ b/.claude/skills/pre-push-audit/SKILL.md @@ -511,7 +511,6 @@ Print the full PR body so the agent or developer can copy it: ## Test plan -🤖 Generated with [Claude Code](https://claude.com/claude-code) ------- (end of template) ------- ``` diff --git a/.eslintrc b/.eslintrc index 8b70281..f51ba6b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,7 @@ "plugins": ["@remotion"], "overrides": [ { - "files": ["remotion/*.{ts,tsx}"], + "files": ["remotion/**/*.{ts,tsx}"], "extends": ["plugin:@remotion/recommended"] } ] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..dea7c61 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,34 @@ +## Summary + + + +- +- + +## How to review + + + +| File | What to check | +|------|---------------| +| | | + +## Test plan + +- [ ] `npm test` passes +- [ ] `npm run test:react` passes (if React/Remotion components changed) +- [ ] `npm run test:e2e` passes (if Phase 8 user-facing flows changed) + + + +Manual verification: +- [ ] +- [ ] + +## Checklist + +- [ ] Behaviour parity — relevant `npm run` scripts or Remotion compositions smoke-tested +- [ ] `tsc --noEmit` passes +- [ ] No new hardcoded paths in `scripts/`; no new duplicated timing constants in `remotion/` +- [ ] Type shapes match spec in `docs/PRODUCTION_REFACTOR_PLAN.md` (if types changed) +- [ ] Scope discipline — only files listed in the implementation doc were touched diff --git a/.gitignore b/.gitignore index d2dcb14..59dc703 100644 --- a/.gitignore +++ b/.gitignore @@ -57,9 +57,11 @@ public/transcribe/output/ # raw Whisper JSON + VTT public/thumbnail/ # candidate frames, cutouts, manifest public/renders/ # final rendered .mp4 files public/output/ # carousel exports +public/shorts/ +public/sync/output # ── Editable pipeline outputs (text/JSON) ───────────────────────────────────── -# public/edit/ → transcript.doc.txt, transcript.json, SRT exports +public/edit/ → transcript.doc.txt, transcript.json, SRT exports public/camera/ → camera-profiles.json, frame snapshots, detections # # These are committed by default so collaborators (and coding agents) can read diff --git a/docs/PRODUCTION_REFACTOR_PLAN.md b/docs/PRODUCTION_REFACTOR_PLAN.md index 09e7351..d495d9c 100644 --- a/docs/PRODUCTION_REFACTOR_PLAN.md +++ b/docs/PRODUCTION_REFACTOR_PLAN.md @@ -320,6 +320,7 @@ export type Brand = { audio: { introOutroMusic: string; backgroundMusic: string; + hookMusic?: string; }; background: { episodeGridAssets: string[]; diff --git a/docs/review-findings/2026-05-13-refactor-s1-brand-loader.md b/docs/review-findings/2026-05-13-refactor-s1-brand-loader.md new file mode 100644 index 0000000..51a24a1 --- /dev/null +++ b/docs/review-findings/2026-05-13-refactor-s1-brand-loader.md @@ -0,0 +1,64 @@ +# Review: refactor/s1-brand-loader +Date: 2026-05-13 +Reviewer: AI (review-pr skill) — session bias: CLEAN +PR: NONE (description provided inline) + +## Verdict +CHANGES REQUESTED + +## Summary +This PR adds `brand.audio.hookMusic` resolution to `calculateMetadata` / `calculateShortMetadata`, replaces the `@remotion/sfx` CDN whoosh with a local file, and fixes the Babel/Jest test environment. The core logic is sound and tests pass cleanly. Two issues block merge: the `hookMusic?` field was added to `Brand.audio` without updating the canonical spec, and the new `brand.json` references a non-existent audio file (`background-music.mp3`). + +## Blockers (must fix before merge) + +### B1 — `hookMusic?` in Brand type diverges from spec +- **Type:** QUALITY +- **File:** `remotion/types/brand.ts` line 77 + `docs/PRODUCTION_REFACTOR_PLAN.md` +- **Finding:** `docs/PRODUCTION_REFACTOR_PLAN.md` defines `Brand.audio` as `{ introOutroMusic: string; backgroundMusic: string; }` with no `hookMusic` field. This PR adds `hookMusic?: string` without updating the spec. Per the CLAUDE.md convention: "If the spec must change, update it first and get agreement before diverging in code. Downstream phase steps depend on specific field names by reference." +- **Fix:** Add `hookMusic?: string` to the `audio` block in the extended Brand type in `docs/PRODUCTION_REFACTOR_PLAN.md` Phase 0.5. The spec update should precede or accompany the implementation change. + +### B2 — `brand.json` references non-existent audio file +- **Type:** QUALITY +- **File:** `public/brands/ragtech/brand.json` line 67 +- **Finding:** `"backgroundMusic": "/sounds/background-music.mp3"` points to a file that does not exist in `public/sounds/`. The directory contains `intro-outro-music.mp3`, `jazz-cafe-music.mp3`, and `whoosh.wav` — no `background-music.mp3`. While no component currently reads `brand.backgroundMusic`, this brand.json is being introduced as the canonical config and the broken path will silently fail when Phase 0.5 wires up the audio fields. +- **Fix:** Change to `"/sounds/jazz-cafe-music.mp3"` (the existing background music track per CLAUDE.md), or add the actual file and document it. + +## Warnings (should address) + +### W1 — Vacuous `eslint-disable-next-line @typescript-eslint/no-explicit-any` +- **Type:** QUALITY +- **File:** `tests/setup.react.ts` line 25 +- **Finding:** `@typescript-eslint/no-explicit-any` is not present in the project's ESLint config (`eslint-config-next` does not enable it; confirmed by grepping `node_modules/eslint-config-next`). The disable comment suppresses nothing and adds noise. This matches the known "eslint-disable comments added for rules not active in the project ESLint config" pattern. +- **Suggestion:** Remove the comment. If explicit-any is a concern, type the factory as `React.ComponentPropsWithRef<'img'>`. + +### W2 — Saloni missing from `hosts` array in `brand.json` +- **Type:** QUALITY +- **File:** `public/brands/ragtech/brand.json` (hosts array) +- **Finding:** CLAUDE.md lists three cohosts: Natasha, Saloni, Victoria. The new `brand.json` hosts array only contains Natasha and Victoria. No component consumes `brand.hosts` yet (confirmed by grep), but this file is being introduced as the canonical brand source of truth; Saloni will be absent when Phase 0.5 consumes it. +- **Suggestion:** Add Saloni's entry: `{ "name": "Saloni", "role": "Software Developer", "imgSrc": "/assets/team/saloni.PNG", "nameBgColor": "" }`. + +### W3 — Team image paths use lowercase `.png` vs. actual `.PNG` filenames +- **Type:** QUALITY +- **File:** `public/brands/ragtech/brand.json` (hosts[*].imgSrc) +- **Finding:** Actual files on disk are `natasha.PNG`, `saloni.PNG`, `victoria.PNG` (uppercase extension). The brand.json uses `/assets/team/natasha.png` (lowercase). This works on macOS (case-insensitive) but will break on Linux (Docker, CI). +- **Suggestion:** Align `imgSrc` values to use `.PNG`, or rename the asset files to `.png` for portability. + +## Suggestions (optional improvements) + +- `remotion/Composition.tsx` and `remotion/ShortFormClip.tsx` both define `const normalizeStaticPath = ...` identically. This duplication predates this PR but the brand-fetch block added here uses it in both files. Consider extracting to `remotion/lib/utils.ts` in the Phase 5/6 cleanup. + +## Test plan verification + +| Item | Status | Notes | +|------|--------|-------| +| `npm test` passes | PASS | 264 passed, 2 skipped | +| `npm run test:react` passes | PASS | 20 passed, 5 suites | +| `tsc --noEmit` clean | PASS | No errors | +| `npm run test:e2e` | SKIPPED | No Phase 8 browser flows changed | +| [REMOTION-VISUAL] ShortFormClip hook music from brand | NOT RUN | Developer self-attested ✓ | +| [REMOTION-VISUAL] Transition whoosh from local file | NOT RUN | Developer self-attested ✓ | +| [REMOTION-VISUAL] ragTechVodcast hook section renders | NOT RUN | Developer self-attested ✓ | + +## Patterns observed +- B1 matches known pattern: **Implementation diverges from documented spec without updating the spec** +- W1 matches known pattern: **eslint-disable comments added for rules not active in the project ESLint config** diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..f0ae0a2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,12 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + files: ["remotion/**/*.{ts,tsx}"], + rules: { + "@next/next/no-img-element": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/package-lock.json b/package-lock.json index ecf64fc..33e3440 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "babel-jest": "^30.2.0", + "babel-jest": "^29.7.0", "babel-plugin-transform-import-meta": "^2.3.3", "eslint": "^9", "eslint-config-next": "16.2.6", @@ -3210,55 +3210,6 @@ } } }, - "node_modules/@jest/core/node_modules/babel-jest": { - "version": "29.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/@jest/core/node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core/node_modules/babel-preset-jest": { - "version": "29.6.3", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/@jest/core/node_modules/jest-config": { "version": "29.7.0", "dev": true, @@ -3638,26 +3589,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern/node_modules/jest-regex-util": { - "version": "30.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/reporters": { "version": "29.7.0", "dev": true, @@ -6232,11 +6163,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "dev": true, - "license": "ISC" - }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.11.1", "cpu": [ @@ -6783,236 +6709,25 @@ } }, "node_modules/babel-jest": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", - "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.3.0", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.3.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-0" - } - }, - "node_modules/babel-jest/node_modules/@jest/schemas": { - "version": "30.0.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-jest/node_modules/@jest/transform": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", - "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/types": "30.3.0", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", - "chalk": "^4.1.2", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", - "pirates": "^4.0.7", - "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-jest/node_modules/@jest/types": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", - "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-jest/node_modules/@sinclair/typebox": { - "version": "0.34.48", - "dev": true, - "license": "MIT" - }, - "node_modules/babel-jest/node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "dev": true, - "license": "BSD-3-Clause", - "workspaces": [ - "test/babel-8" - ], - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/babel-jest/node_modules/ci-info": { - "version": "4.4.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-jest/node_modules/jest-haste-map": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", - "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.3.0", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", - "jest-worker": "30.3.0", - "picomatch": "^4.0.3", - "walker": "^1.0.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" - } - }, - "node_modules/babel-jest/node_modules/jest-regex-util": { - "version": "30.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-jest/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.3.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.3" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-jest/node_modules/jest-worker": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", - "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.3.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-jest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/babel-jest/node_modules/signal-exit": { - "version": "4.1.0", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/babel-jest/node_modules/supports-color": { - "version": "8.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/babel-jest/node_modules/write-file-atomic": { - "version": "5.0.1", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "@babel/core": "^7.8.0" } }, "node_modules/babel-plugin-istanbul": { @@ -7046,16 +6761,19 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", - "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", "dev": true, "license": "MIT", "dependencies": { - "@types/babel__core": "^7.20.5" + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/babel-plugin-polyfill-corejs2": { @@ -7134,20 +6852,20 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", - "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.3.0", - "babel-preset-current-node-syntax": "^1.2.0" + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + "@babel/core": "^7.0.0" } }, "node_modules/balanced-match": { @@ -7813,55 +7531,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/create-jest/node_modules/babel-jest": { - "version": "29.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/create-jest/node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/babel-preset-jest": { - "version": "29.6.3", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/create-jest/node_modules/jest-config": { "version": "29.7.0", "dev": true, @@ -11027,55 +10696,6 @@ } } }, - "node_modules/jest-cli/node_modules/babel-jest": { - "version": "29.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/jest-cli/node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-cli/node_modules/babel-preset-jest": { - "version": "29.6.3", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/jest-cli/node_modules/jest-config": { "version": "29.7.0", "dev": true, diff --git a/package.json b/package.json index 9d4f0cb..a4ed3ae 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "babel-jest": "^30.2.0", + "babel-jest": "^29.7.0", "babel-plugin-transform-import-meta": "^2.3.3", "eslint": "^9", "eslint-config-next": "16.2.6", diff --git a/public/brands/ragtech/brand.json b/public/brands/ragtech/brand.json new file mode 100644 index 0000000..30a4a2f --- /dev/null +++ b/public/brands/ragtech/brand.json @@ -0,0 +1,82 @@ +{ + "colors": { + "primary": "#eebf89", + "secondary": "#9cd2d0", + "accent": "#ffa3a6", + "background": "#fff3c2", + "surface": "#1c1006", + "text": { + "primary": "#FFFFFF", + "secondary": "#B0B0CC", + "onPrimary": "#0F0F1A" + }, + "palette": ["#fff3c2", "#9cd2d0", "#ffa3a6", "#eebf89"] + }, + "typography": { + "fontFamily": "Nunito", + "fontSrc": "/fonts/Nunito-VariableFont_wght.ttf", + "fontSrcItalic": "/fonts/Nunito-Italic-VariableFont_wght.ttf", + "weights": { + "regular": 400, + "semiBold": 600, + "bold": 700, + "extraBold": 800, + "black": 900 + } + }, + "logo": "/assets/logo/transparent-bg-logo.png", + "shape": { + "borderRadius": 12, + "borderRadiusSmall": 6 + }, + "identity": { + "name": "RAG Tech", + "terminalPath": "~/ragtech", + "socialHandle": "@ragtechdev", + "website": "https://ragtech.dev" + }, + "hosts": [ + { + "name": "Natasha", + "role": "Software Engineer", + "imgSrc": "/assets/team/natasha.PNG", + "nameBgColor": "#eebf89" + }, + { + "name": "Saloni", + "role": "Software Developer", + "imgSrc": "/assets/team/saloni.PNG", + "nameBgColor": "#ffa3a6" + }, + { + "name": "Victoria", + "role": "Solutions Engineer", + "imgSrc": "/assets/team/victoria.PNG", + "nameBgColor": "#9cd2d0" + } + ], + "mascot": { + "enabled": true, + "name": "Techybara", + "assets": { + "holdingMic": "/assets/logo/techybara-holding-mic.png", + "teacher": "/assets/logo/techybara-teacher.png", + "raisingHand": "/assets/logo/techybara-raising-hand.png", + "holdingLaptop": "/assets/logo/techybara-holding-laptop.png", + "holdingLaptop2": "/assets/logo/techybara-holding-laptop-2.png", + "sparkleEyes": "/assets/logo/techybara-sparkle-eyes.png" + } + }, + "audio": { + "introOutroMusic": "/sounds/intro-outro-music.mp3", + "backgroundMusic": "/sounds/jazz-cafe-music.mp3", + "hookMusic": "/sounds/jazz-cafe-music.mp3" + }, + "background": { + "episodeGridAssets": [ + "/assets/episodes/episode-1.png", + "/assets/episodes/episode-2.png", + "/assets/episodes/episode-3.png" + ] + } +} diff --git a/public/sounds/whoosh.wav b/public/sounds/whoosh.wav new file mode 100644 index 0000000..06f8189 Binary files /dev/null and b/public/sounds/whoosh.wav differ diff --git a/remotion/Composition.test.tsx b/remotion/Composition.test.tsx new file mode 100644 index 0000000..a38d5ea --- /dev/null +++ b/remotion/Composition.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; + +jest.mock('remotion', () => ({ + useCurrentFrame: jest.fn(() => 0), + useVideoConfig: jest.fn(() => ({ fps: 60, durationInFrames: 3600, width: 1920, height: 1080 })), + staticFile: (path: string) => `/static/${path}`, + delayRender: jest.fn(() => 'handle'), + continueRender: jest.fn(), + OffthreadVideo: () => null, + Audio: () => null, + Loop: ({ children }: { children: React.ReactNode }) => <>{children}, + Sequence: ({ children }: { children: React.ReactNode }) => <>{children}, + AbsoluteFill: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('@remotion/media-utils', () => ({ + getAudioDurationInSeconds: jest.fn(), +})); + +jest.mock('./components/SegmentPlayer', () => ({ + SegmentPlayer: () => null, + buildSections: () => [], + buildMainSubClips: () => [], +})); +jest.mock('./components/CameraPlayer', () => ({ CameraPlayer: () => null })); +jest.mock('./components/HookOverlay', () => ({ HookOverlay: () => null })); +jest.mock('./components/OverlayRenderer', () => ({ OverlayRenderer: () => null })); +jest.mock('./components/PodcastIntro', () => ({ + PodcastIntroComposition: () => null, + INTRO_DURATION_FRAMES: 420, +})); +jest.mock('./components/PodcastOutro', () => ({ + PodcastOutroComposition: () => null, + OUTRO_DURATION_FRAMES: 360, +})); +jest.mock('./loadFonts', () => ({ loadNunito: jest.fn().mockResolvedValue(undefined) })); + +import { calculateMetadata } from './Composition'; +import { getAudioDurationInSeconds } from '@remotion/media-utils'; + +const minimalTranscript = { meta: { fps: 60 }, segments: [] }; + +describe('calculateMetadata', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn(); + }); + + it('returns fallback when transcriptSrc is not provided', async () => { + const result = await calculateMetadata({ props: { src: 'video.mp4' } } as never); + expect(result.durationInFrames).toBe(300); + expect(result.fps).toBe(60); + }); + + it('resolves hookMusicSrc from brand.audio.hookMusic when brandId is set', async () => { + const mockBrand = { audio: { hookMusic: 'sounds/jazz-cafe-music.mp3' } }; + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(minimalTranscript) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockBrand) }); + (getAudioDurationInSeconds as jest.Mock).mockResolvedValue(30); + + const result = await calculateMetadata({ + props: { src: 'v.mp4', transcriptSrc: 'edit/transcript.json', brandId: 'ragtech' }, + } as never); + + expect(result.props?.hookMusicSrc).toBe('sounds/jazz-cafe-music.mp3'); + expect(result.props?.hookMusicDurationSecs).toBe(30); + expect(getAudioDurationInSeconds).toHaveBeenCalledWith( + expect.stringContaining('jazz-cafe-music.mp3'), + ); + }); + + it('uses explicit hookMusicSrc without fetching brand', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, json: () => Promise.resolve(minimalTranscript), + }); + (getAudioDurationInSeconds as jest.Mock).mockResolvedValue(45); + + await calculateMetadata({ + props: { + src: 'v.mp4', + transcriptSrc: 'edit/transcript.json', + hookMusicSrc: 'sounds/custom.mp3', + brandId: 'ragtech', + }, + } as never); + + // Only one fetch: transcript. Brand fetch is skipped because hookMusicSrc was explicit. + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('skips hook music when neither hookMusicSrc nor brand hook music is available', async () => { + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(minimalTranscript) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ audio: {} }) }); + + const result = await calculateMetadata({ + props: { src: 'v.mp4', transcriptSrc: 'edit/transcript.json', brandId: 'ragtech' }, + } as never); + + expect(getAudioDurationInSeconds).not.toHaveBeenCalled(); + expect(result.props?.hookMusicSrc).toBeUndefined(); + }); +}); diff --git a/remotion/Composition.tsx b/remotion/Composition.tsx index 1fa652f..065e026 100644 --- a/remotion/Composition.tsx +++ b/remotion/Composition.tsx @@ -32,11 +32,9 @@ type MyCompositionProps = { cameraProfilesSrc?: string; /** Path to brand.json relative to /public. Defaults to "brand.json". */ brandSrc?: string; - /** - * Path to hook intro music relative to /public. Defaults to "sounds/hook-music.mp3". - * Place your audio file there (e.g. the "Euphoric" track from Remotion's asset library). - * Set to empty string "" to disable hook music. - */ + /** Brand ID to load from brands/{brandId}/brand.json. Takes precedence over brandSrc if provided. */ + brandId?: string; + /** Path to hook music relative to /public. Falls back to brand.audio.hookMusic when omitted. */ hookMusicSrc?: string; /** Duration of the hook music track in seconds — set by calculateMetadata for looping. */ hookMusicDurationSecs?: number; @@ -129,11 +127,17 @@ export const calculateMetadata: CalculateMetadataFunction = let overrideProps: MyCompositionProps = transcript.meta.videoSrc ? { ...props, src: transcript.meta.videoSrc } : { ...props }; - if (props.hookMusicSrc) { + let hookMusicSrc = props.hookMusicSrc; + if (!hookMusicSrc && (props.brandId || props.brandSrc)) { + const brandPath = props.brandId ? `brands/${props.brandId}/brand.json` : props.brandSrc!; + const brand = await fetchJson(brandPath).catch(() => null); + hookMusicSrc = brand?.audio?.hookMusic; + } + if (hookMusicSrc) { const hookMusicDurationSecs = await getAudioDurationInSeconds( - staticFile(normalizeStaticPath(props.hookMusicSrc)), + staticFile(normalizeStaticPath(hookMusicSrc)), ).catch(() => 0); - overrideProps = { ...overrideProps, hookMusicDurationSecs }; + overrideProps = { ...overrideProps, hookMusicSrc, hookMusicDurationSecs }; } return { durationInFrames, fps, width: 1920, height: 1080, props: overrideProps }; } catch { @@ -265,13 +269,17 @@ export const MyComposition = ({ transcriptSrc, cameraProfilesSrc, brandSrc = 'brand.json', - hookMusicSrc = 'sounds/hook-music.mp3', + brandId, + hookMusicSrc, hookMusicDurationSecs = 0, }: MyCompositionProps) => { const { fps } = useVideoConfig(); const audioStartFromFrames = Math.max(0, Math.round(audioStartFrom * fps)); const resolvedSrc = staticFile(normalizeStaticPath(src)); + // Resolve brand source: brandId takes precedence over brandSrc + const resolvedBrandSrc = brandId ? `brands/${brandId}/brand.json` : brandSrc; + const [transcript, setTranscript] = useState(null); const [cameraProfiles, setCameraProfiles] = useState(null); const [brand, setBrand] = useState(null); @@ -279,7 +287,7 @@ export const MyComposition = ({ const [transcriptHandle] = useState(() => transcriptSrc ? delayRender('Loading transcript') : null); const [cameraHandle] = useState(() => cameraProfilesSrc ? delayRender('Loading camera profiles') : null); - const [brandHandle] = useState(() => brandSrc ? delayRender('Loading brand') : null); + const [brandHandle] = useState(() => resolvedBrandSrc ? delayRender('Loading brand') : null); const [fontHandle] = useState(() => delayRender('Loading Nunito font')); useEffect(() => { @@ -298,11 +306,11 @@ export const MyComposition = ({ }, [cameraProfilesSrc, cameraHandle]); useEffect(() => { - if (!brandSrc || !brandHandle) return; - fetchJson(brandSrc) + if (!resolvedBrandSrc || !brandHandle) return; + fetchJson(resolvedBrandSrc) .then(data => { setBrand(data); continueRender(brandHandle!); }) .catch(err => { console.warn('Brand not loaded:', err.message); continueRender(brandHandle!); }); - }, [brandSrc, brandHandle]); + }, [resolvedBrandSrc, brandHandle]); useEffect(() => { loadNunito().finally(() => continueRender(fontHandle)); diff --git a/remotion/Root.tsx b/remotion/Root.tsx index 63be751..72a1847 100644 --- a/remotion/Root.tsx +++ b/remotion/Root.tsx @@ -57,8 +57,7 @@ export const RemotionRoot: React.FC = () => { src: 'sync/output/synced-output-1.mp4', transcriptSrc: `shorts/${shortId}/transcript.json`, cameraProfilesSrc: 'shorts/camera-profiles.json', - brandSrc: 'brand.json', - hookMusicSrc: 'sounds/hook-music.mp3', + brandId: 'ragtech', }} calculateMetadata={calculateShortMetadata} /> diff --git a/remotion/ShortFormClip.test.tsx b/remotion/ShortFormClip.test.tsx new file mode 100644 index 0000000..0135b4a --- /dev/null +++ b/remotion/ShortFormClip.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; + +jest.mock('remotion', () => ({ + useCurrentFrame: jest.fn(() => 0), + useVideoConfig: jest.fn(() => ({ fps: 60, durationInFrames: 1800, width: 1080, height: 1920 })), + staticFile: (path: string) => `/static/${path}`, + delayRender: jest.fn(() => 'handle'), + continueRender: jest.fn(), + Audio: () => null, + Loop: ({ children }: { children: React.ReactNode }) => <>{children}, + Sequence: ({ children }: { children: React.ReactNode }) => <>{children}, + AbsoluteFill: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('@remotion/media-utils', () => ({ + getAudioDurationInSeconds: jest.fn(), +})); + +jest.mock('./components/SegmentPlayer', () => ({ + SegmentPlayer: () => null, + buildSections: () => [], + buildMainSubClips: () => [], +})); +jest.mock('./components/CameraPlayer', () => ({ CameraPlayer: () => null })); +jest.mock('./components/CaptionOverlay', () => ({ CaptionOverlay: () => null })); +jest.mock('./components/OverlayRenderer', () => ({ OverlayRenderer: () => null })); +jest.mock('./components/overlays/EpisodePill', () => ({ EpisodePill: () => null })); +jest.mock('./components/overlays/HookTitle', () => ({ HookTitle: () => null })); +jest.mock('./components/overlays/ShortFormOutro', () => ({ ShortFormOutro: () => null })); +jest.mock('./components/Transition', () => ({ Transition: () => null })); +jest.mock('./loadFonts', () => ({ loadNunito: jest.fn().mockResolvedValue(undefined) })); + +import { calculateShortMetadata } from './ShortFormClip'; +import { getAudioDurationInSeconds } from '@remotion/media-utils'; + +const minimalTranscript = { meta: { fps: 60 }, segments: [] }; + +describe('calculateShortMetadata', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn(); + }); + + it('returns fallback when transcriptSrc is not provided', async () => { + const result = await calculateShortMetadata({ props: { src: 'video.mp4' } } as never); + expect(result.durationInFrames).toBe(300); + expect(result.fps).toBe(60); + expect(result.width).toBe(1080); + expect(result.height).toBe(1920); + }); + + it('resolves hookMusicSrc from brand.audio.hookMusic when brandId is set', async () => { + const mockBrand = { audio: { hookMusic: 'sounds/jazz-cafe-music.mp3' } }; + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(minimalTranscript) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockBrand) }); + (getAudioDurationInSeconds as jest.Mock).mockResolvedValue(30); + + const result = await calculateShortMetadata({ + props: { src: 'v.mp4', transcriptSrc: 'shorts/s1/transcript.json', brandId: 'ragtech' }, + } as never); + + expect(result.props?.hookMusicSrc).toBe('sounds/jazz-cafe-music.mp3'); + expect(result.props?.hookMusicDurationSecs).toBe(30); + expect(getAudioDurationInSeconds).toHaveBeenCalledWith( + expect.stringContaining('jazz-cafe-music.mp3'), + ); + }); + + it('uses explicit hookMusicSrc without fetching brand', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, json: () => Promise.resolve(minimalTranscript), + }); + (getAudioDurationInSeconds as jest.Mock).mockResolvedValue(45); + + await calculateShortMetadata({ + props: { + src: 'v.mp4', + transcriptSrc: 'shorts/s1/transcript.json', + hookMusicSrc: 'sounds/custom.mp3', + brandId: 'ragtech', + }, + } as never); + + // Only one fetch: transcript. Brand fetch is skipped because hookMusicSrc was explicit. + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('skips hook music when brand has no hookMusic field', async () => { + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(minimalTranscript) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ audio: {} }) }); + + const result = await calculateShortMetadata({ + props: { src: 'v.mp4', transcriptSrc: 'shorts/s1/transcript.json', brandId: 'ragtech' }, + } as never); + + expect(getAudioDurationInSeconds).not.toHaveBeenCalled(); + expect(result.props?.hookMusicSrc).toBeUndefined(); + }); +}); diff --git a/remotion/ShortFormClip.tsx b/remotion/ShortFormClip.tsx index 2aeed3e..012a638 100644 --- a/remotion/ShortFormClip.tsx +++ b/remotion/ShortFormClip.tsx @@ -30,6 +30,8 @@ type ShortFormClipProps = { transcriptSrc?: string; cameraProfilesSrc?: string; brandSrc?: string; + /** Brand ID to load from brands/{brandId}/brand.json. Takes precedence over brandSrc if provided. */ + brandId?: string; hookMusicSrc?: string; hookMusicDurationSecs?: number; }; @@ -121,11 +123,17 @@ export const calculateShortMetadata: CalculateMetadataFunction(brandPath).catch(() => null); + hookMusicSrc = brand?.audio?.hookMusic; + } + if (hookMusicSrc) { const hookMusicDurationSecs = await getAudioDurationInSeconds( - staticFile(normalizeStaticPath(props.hookMusicSrc)), + staticFile(normalizeStaticPath(hookMusicSrc)), ).catch(() => 0); - overrideProps = { ...overrideProps, hookMusicDurationSecs }; + overrideProps = { ...overrideProps, hookMusicSrc, hookMusicDurationSecs }; } return { durationInFrames, fps, width: 1080, height: 1920, props: overrideProps }; } catch { @@ -274,13 +282,17 @@ export const ShortFormClip = ({ transcriptSrc, cameraProfilesSrc, brandSrc = 'brand.json', - hookMusicSrc = 'sounds/hook-music.mp3', + brandId, + hookMusicSrc, hookMusicDurationSecs = 0, }: ShortFormClipProps) => { const { fps } = useVideoConfig(); const audioStartFromFrames = Math.max(0, Math.round(audioStartFrom * fps)); const resolvedSrc = staticFile(normalizeStaticPath(src)); + // Resolve brand source: brandId takes precedence over brandSrc + const resolvedBrandSrc = brandId ? `brands/${brandId}/brand.json` : brandSrc; + const [transcript, setTranscript] = useState(null); const [cameraProfiles, setCameraProfiles] = useState(null); const [brand, setBrand] = useState(null); @@ -288,7 +300,7 @@ export const ShortFormClip = ({ const [transcriptHandle] = useState(() => transcriptSrc ? delayRender('Loading transcript') : null); const [cameraHandle] = useState(() => cameraProfilesSrc ? delayRender('Loading camera profiles') : null); - const [brandHandle] = useState(() => brandSrc ? delayRender('Loading brand') : null); + const [brandHandle] = useState(() => resolvedBrandSrc ? delayRender('Loading brand') : null); const [fontHandle] = useState(() => delayRender('Loading Nunito font')); useEffect(() => { @@ -307,11 +319,11 @@ export const ShortFormClip = ({ }, [cameraProfilesSrc, cameraHandle]); useEffect(() => { - if (!brandSrc || !brandHandle) return; - fetchJson(brandSrc) + if (!resolvedBrandSrc || !brandHandle) return; + fetchJson(resolvedBrandSrc) .then(data => { setBrand(data); continueRender(brandHandle!); }) .catch(err => { console.warn('Brand not loaded:', err.message); continueRender(brandHandle!); }); - }, [brandSrc, brandHandle]); + }, [resolvedBrandSrc, brandHandle]); useEffect(() => { loadNunito().finally(() => continueRender(fontHandle)); diff --git a/remotion/components/Transition.test.tsx b/remotion/components/Transition.test.tsx new file mode 100644 index 0000000..30a8519 --- /dev/null +++ b/remotion/components/Transition.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +const mockUseCurrentFrame = jest.fn(() => 0); + +jest.mock('remotion', () => ({ + useCurrentFrame: () => mockUseCurrentFrame(), + interpolate: jest.fn(() => 0), + AbsoluteFill: ({ children }: { children: React.ReactNode }) =>
{children}
, + staticFile: (path: string) => `/static/${path}`, + Audio: ({ src }: { src?: string }) => , + Sequence: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +import { Transition } from './Transition'; + +describe('Transition', () => { + beforeEach(() => { + mockUseCurrentFrame.mockReturnValue(0); + }); + + it('renders nothing when frame is before startFrame', () => { + mockUseCurrentFrame.mockReturnValue(10); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when frame is after the transition window', () => { + mockUseCurrentFrame.mockReturnValue(61); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders content during the transition window', () => { + mockUseCurrentFrame.mockReturnValue(45); + const { container } = render(); + expect(container.firstChild).not.toBeNull(); + }); + + it('uses a local static path for the whoosh sound, not an external URL', () => { + mockUseCurrentFrame.mockReturnValue(45); + const { getByTestId } = render(); + const audio = getByTestId('audio'); + expect(audio.dataset.src).toContain('sounds/whoosh.wav'); + expect(audio.dataset.src).not.toMatch(/^https?:\/\//); + }); + + it('renders without throwing at the boundary frame', () => { + mockUseCurrentFrame.mockReturnValue(30); + expect(() => render()).not.toThrow(); + }); +}); diff --git a/remotion/components/Transition.tsx b/remotion/components/Transition.tsx index 7878fb3..c3ac0eb 100644 --- a/remotion/components/Transition.tsx +++ b/remotion/components/Transition.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { useCurrentFrame, useVideoConfig, interpolate, AbsoluteFill, staticFile, Audio, Sequence } from 'remotion'; -import { whoosh } from '@remotion/sfx'; +import { useCurrentFrame, interpolate, AbsoluteFill, staticFile, Audio, Sequence } from 'remotion'; + +const whoosh = staticFile('sounds/whoosh.wav'); interface TransitionProps { /** Frame at which the transition starts (end of hooks, start of main) */ @@ -13,7 +14,6 @@ export const Transition: React.FC = ({ startFrame, durationInFrames = 30, }) => { - const { fps } = useVideoConfig(); const frame = useCurrentFrame(); // Only render Sequence during transition window for better performance diff --git a/remotion/types/brand.ts b/remotion/types/brand.ts index e1f1dbb..aafc435 100644 --- a/remotion/types/brand.ts +++ b/remotion/types/brand.ts @@ -74,6 +74,7 @@ export type Brand = { audio: { introOutroMusic: string; backgroundMusic: string; + hookMusic?: string; }; background: { episodeGridAssets: string[]; diff --git a/tests/setup.react.ts b/tests/setup.react.ts index 61c823f..9785b67 100644 --- a/tests/setup.react.ts +++ b/tests/setup.react.ts @@ -22,5 +22,9 @@ jest.mock('next/navigation', () => ({ // Mock next/image — factory must not reference document (hoisted before jsdom) jest.mock('next/image', () => ({ __esModule: true, - default: jest.fn(), + default: jest.fn(({ src, alt, ...rest }: { src: string; alt: string; [key: string]: unknown }) => ({ + src, + alt, + ...rest, + })), }));