From 9fcd9cef119e68bdac1c79a0781c7be79fa6e267 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Mon, 26 Jan 2026 22:45:54 -0800 Subject: [PATCH] feat: add timeline export to OTIO and FCP XML formats - add varg export CLI command with placeholder and rendered modes - support OTIO format (DaVinci, Nuke, Premiere via plugin) - support FCP XML format (Premiere, DaVinci, FCPX) - placeholder mode generates colored bars with prompt text for planning - rendered mode generates AI content before export - includes tree walker to extract timeline structure from JSX --- bun.lock | 71 +++++- package.json | 4 +- src/cli/commands/export.tsx | 260 ++++++++++++++++++++ src/cli/commands/index.ts | 1 + src/cli/index.ts | 4 + src/react/examples/export-test.tsx | 24 ++ src/react/index.ts | 7 + src/react/timeline/fcpxml.ts | 102 ++++++++ src/react/timeline/index.ts | 64 +++++ src/react/timeline/otio.ts | 187 ++++++++++++++ src/react/timeline/types.ts | 91 +++++++ src/react/timeline/walker.ts | 375 +++++++++++++++++++++++++++++ 12 files changed, 1181 insertions(+), 9 deletions(-) create mode 100644 src/cli/commands/export.tsx create mode 100644 src/react/examples/export-test.tsx create mode 100644 src/react/timeline/fcpxml.ts create mode 100644 src/react/timeline/index.ts create mode 100644 src/react/timeline/otio.ts create mode 100644 src/react/timeline/types.ts create mode 100644 src/react/timeline/walker.ts diff --git a/bun.lock b/bun.lock index 2836fd00..5258c206 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "react-dom": "^19.2.0", "remotion": "^4.0.377", "replicate": "^1.4.0", + "sharp": "^0.34.5", "vargai": "^0.4.0-alpha11", "zod": "^4.2.1", }, @@ -227,6 +228,8 @@ "@elevenlabs/elevenlabs-js": ["@elevenlabs/elevenlabs-js@2.28.0", "", { "dependencies": { "command-exists": "^1.2.9", "node-fetch": "^2.7.0", "ws": "^8.18.3" } }, "sha512-58Qt61zZYa1jBDdkAFqEYBAIDQUVlj1OLkha27yr7RIxg+cwS6+gOsKNhBMy7U5nQ4Ue9H4Kpgz6trdg94xumw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.0", "", { "os": "android", "cpu": "arm" }, "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g=="], @@ -283,6 +286,56 @@ "@higgsfield/client": ["@higgsfield/client@0.1.2", "", { "dependencies": { "axios": "^1.6.5", "form-data": "^4.0.0" } }, "sha512-pywkc7RyPt+yfUWNPX9Zjln846HRknt3KWH9nZxm1hy8qanPWMEp98RLG3TrA7VC79DYhLD5gi+Vwj2QwoPVAw=="], + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@inkjs/ui": ["@inkjs/ui@2.0.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-spinners": "^3.0.0", "deepmerge": "^4.3.1", "figures": "^6.1.0" }, "peerDependencies": { "ink": ">=5" } }, "sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg=="], "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], @@ -687,6 +740,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], "dotenv": ["dotenv@9.0.2", "", {}, "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg=="], @@ -1099,10 +1154,12 @@ "schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="], - "semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1255,8 +1312,6 @@ "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@commitlint/is-ignored/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@commitlint/read/minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "@fal-ai/client/eventsource-parser": ["eventsource-parser@1.1.2", "", {}, "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA=="], @@ -1269,8 +1324,12 @@ "@remotion/renderer/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "@remotion/studio/semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="], + "@remotion/studio/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + "@remotion/studio-server/semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="], + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.4", "", { "dependencies": { "@ai-sdk/provider": "3.0.2", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VxhX0B/dWGbpNHxrKCWUAJKXIXV015J4e7qYjdIU9lLWeptk0KMLGcqkB4wFxff5Njqur8dt8wRi1MN9lZtDqg=="], "ajv-keywords/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -1283,6 +1342,8 @@ "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "css-loader/semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="], + "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], "extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], @@ -1337,10 +1398,6 @@ "@ai-sdk/fal/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - "@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/google/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], - "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], diff --git a/package.json b/package.json index 53b774ad..fe45627b 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,9 @@ "dependencies": { "@ai-sdk/fal": "^1.0.23", "@ai-sdk/fireworks": "^2.0.16", - "@ai-sdk/groq": "^3.0.12", "@ai-sdk/google": "^3.0.13", + "@ai-sdk/groq": "^3.0.12", "@ai-sdk/openai": "^3.0.9", - "@google/genai": "^1.0.0", "@ai-sdk/provider": "^3.0.2", "@ai-sdk/provider-utils": "^4.0.4", "@ai-sdk/replicate": "^2.0.5", @@ -51,6 +50,7 @@ "@aws-sdk/s3-request-presigner": "^3.937.0", "@elevenlabs/elevenlabs-js": "^2.28.0", "@fal-ai/client": "^1.7.2", + "@google/genai": "^1.0.0", "@higgsfield/client": "^0.1.2", "@inkjs/ui": "^2.0.0", "@remotion/cli": "^4.0.377", diff --git a/src/cli/commands/export.tsx b/src/cli/commands/export.tsx new file mode 100644 index 00000000..f3cd9636 --- /dev/null +++ b/src/cli/commands/export.tsx @@ -0,0 +1,260 @@ +/** @jsxImportSource react */ + +import { existsSync, mkdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { defineCommand } from "citty"; +import { Box, Text } from "ink"; +import type { ExportMode, TimelineFormat } from "../../react/timeline"; +import { exportTimeline } from "../../react/timeline"; +import type { VargElement } from "../../react/types"; +import { Header, HelpBlock, VargBox, VargText } from "../ui/index.ts"; +import { renderStatic } from "../ui/render.ts"; + +const AUTO_IMPORTS = `/** @jsxImportSource vargai */ +import { Captions, Clip, Image, Music, Overlay, Packshot, Render, Slider, Speech, Split, Subtitle, Swipe, TalkingHead, Title, Video, Grid, SplitLayout } from "vargai/react"; +import { fal, elevenlabs, replicate } from "vargai/ai"; +`; + +async function loadComponent(filePath: string): Promise { + const resolvedPath = resolve(filePath); + const source = await Bun.file(resolvedPath).text(); + + const hasVargaiImport = + source.includes("from 'vargai") || + source.includes('from "vargai') || + source.includes("@jsxImportSource vargai"); + + const hasRelativeImport = + source.includes("from './") || source.includes('from "./'); + + const pkgDir = new URL("../../..", import.meta.url).pathname; + const tmpDir = `${pkgDir}/.cache/varg-export`; + + if (!existsSync(tmpDir)) { + mkdirSync(tmpDir, { recursive: true }); + } + + if (hasRelativeImport) { + const mod = await import(resolvedPath); + return mod.default; + } + + if (hasVargaiImport) { + const tmpFile = `${tmpDir}/${Date.now()}.tsx`; + await Bun.write(tmpFile, source); + + try { + const mod = await import(tmpFile); + return mod.default; + } finally { + (await Bun.file(tmpFile).exists()) && (await Bun.write(tmpFile, "")); + } + } + + const hasAnyImport = source.includes(" from "); + if (hasAnyImport) { + const mod = await import(resolvedPath); + return mod.default; + } + + const tmpFile = `${tmpDir}/${Date.now()}.tsx`; + await Bun.write(tmpFile, AUTO_IMPORTS + source); + + try { + const mod = await import(tmpFile); + return mod.default; + } finally { + (await Bun.file(tmpFile).exists()) && (await Bun.write(tmpFile, "")); + } +} + +export const exportCmd = defineCommand({ + meta: { + name: "export", + description: "export jsx to timeline format (otio, fcpxml)", + }, + args: { + file: { + type: "positional" as const, + description: "component file (.tsx)", + required: true, + }, + output: { + type: "string" as const, + alias: "o", + description: "output path", + }, + format: { + type: "string" as const, + alias: "f", + description: "output format: otio, fcpxml (default: otio)", + default: "otio", + }, + mode: { + type: "string" as const, + alias: "m", + description: + "export mode: placeholders, rendered (default: placeholders)", + default: "placeholders", + }, + cache: { + type: "string" as const, + alias: "c", + description: "cache directory for assets", + default: ".cache/timeline", + }, + quiet: { + type: "boolean" as const, + alias: "q", + description: "minimal output", + default: false, + }, + }, + async run({ args }) { + const file = args.file as string; + + if (!file) { + console.error( + "usage: varg export [-o output.otio] [-f otio|fcpxml] [-m placeholders|rendered]", + ); + process.exit(1); + } + + const component = await loadComponent(file); + + if (!component || component.type !== "render") { + console.error("error: default export must be a element"); + process.exit(1); + } + + const format = args.format as string as TimelineFormat; + const mode = args.mode as string as ExportMode; + const ext = format === "otio" ? "otio" : "fcpxml"; + const basename = file + .replace(/\.tsx?$/, "") + .split("/") + .pop(); + const outputPath = (args.output as string) ?? `output/${basename}.${ext}`; + + if (!args.quiet) { + console.log( + `exporting ${file} → ${outputPath} (${format}, ${mode} mode)`, + ); + } + + const result = await exportTimeline(component as VargElement<"render">, { + format, + mode, + output: outputPath, + cache: args.cache as string, + quiet: args.quiet as boolean, + }); + + if (!args.quiet) { + console.log( + `\ndone! exported ${result.summary.clips} clips, ${result.summary.duration.toFixed(1)}s total`, + ); + if (result.summary.placeholders > 0) { + console.log( + ` ${result.summary.placeholders} placeholder assets in ${args.cache}`, + ); + } + console.log(` timeline: ${result.timelinePath}`); + } + }, +}); + +function ExportHelpView() { + const examples = [ + { + command: "varg export video.tsx", + description: "export to output/video.otio with placeholders", + }, + { + command: "varg export video.tsx -f fcpxml", + description: "export to FCP XML format", + }, + { + command: "varg export video.tsx -m rendered", + description: "generate AI assets first, then export", + }, + { + command: "varg export video.tsx -o timeline.otio", + description: "custom output path", + }, + ]; + + return ( + + + + export jsx compositions to NLE timeline formats (premiere, davinci, + fcpx). + + + +
USAGE
+ + + varg export {""} [options] + + + +
OPTIONS
+ + + -o, --output output path + (default: output/{""}.otio) + + + -f, --format otio | fcpxml + (default: otio) + + + -m, --mode placeholders | + rendered (default: placeholders) + + + -c, --cache cache directory + (default: .cache/timeline) + + + -q, --quiet minimal output + + + +
FORMATS
+ + + otio OpenTimelineIO - works with + DaVinci, Premiere (plugin), Nuke + + + fcpxml Final Cut Pro XML - works + with Premiere, DaVinci, FCPX + + + +
MODES
+ + + placeholders colored bars with + prompt text (instant, for planning) + + + rendered generate AI content + first (slower, final assets) + + + +
EXAMPLES
+ + + +
+ ); +} + +export function showExportHelp() { + renderStatic(); +} diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index ad14d46f..a2e842be 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,3 +1,4 @@ +export { exportCmd, showExportHelp } from "./export.tsx"; export { findCmd, showFindHelp } from "./find.tsx"; export { frameCmd, showFrameHelp } from "./frame.tsx"; export { helloCmd } from "./hello.ts"; diff --git a/src/cli/index.ts b/src/cli/index.ts index 1b42e12a..e3fe8ecb 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -12,6 +12,7 @@ import { defineCommand, runMain } from "citty"; import { registry } from "../core/registry"; import { allDefinitions } from "../definitions"; import { + exportCmd, findCmd, frameCmd, helloCmd, @@ -21,6 +22,7 @@ import { previewCmd, renderCmd, runCmd, + showExportHelp, showFindHelp, showFrameHelp, showHelp, @@ -59,6 +61,7 @@ const subcommandHelp: Record void> = { run: showRunHelp, render: showRenderHelp, preview: showPreviewHelp, + export: showExportHelp, frame: showFrameHelp, storyboard: showStoryboardHelp, init: showInitHelp, @@ -120,6 +123,7 @@ const main = defineCommand({ init: initCmd, render: renderCmd, preview: previewCmd, + export: exportCmd, frame: frameCmd, storyboard: storyboardCmd, studio: studioCmd, diff --git a/src/react/examples/export-test.tsx b/src/react/examples/export-test.tsx new file mode 100644 index 00000000..dc28f1cf --- /dev/null +++ b/src/react/examples/export-test.tsx @@ -0,0 +1,24 @@ +import { Clip, Image, Music, Render, Title, Video } from ".."; + +export default ( + + + + + + + + + Our Vision + + + + + +); diff --git a/src/react/index.ts b/src/react/index.ts index 56e12b32..427d58d0 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -19,6 +19,13 @@ export { } from "./elements"; export { Grid, Slot, SplitLayout } from "./layouts"; export { render, renderStream } from "./render"; +export type { + ExportMode, + TimelineExportOptions, + TimelineExportResult, + TimelineFormat, +} from "./timeline"; +export { exportTimeline } from "./timeline"; export type { CaptionsProps, ClipProps, diff --git a/src/react/timeline/fcpxml.ts b/src/react/timeline/fcpxml.ts new file mode 100644 index 00000000..d61e8f3b --- /dev/null +++ b/src/react/timeline/fcpxml.ts @@ -0,0 +1,102 @@ +import type { Timeline, TimelineAsset, TimelineClipItem } from "./types"; + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function secondsToFCPTime(seconds: number, fps: number): string { + const frames = Math.round(seconds * fps); + return `${frames}/${fps}s`; +} + +function getFormatName(width: number, height: number, fps: number): string { + if (width === 1920 && height === 1080) return `FFVideoFormat1080p${fps}`; + if (width === 1280 && height === 720) return `FFVideoFormat720p${fps}`; + if (width === 3840 && height === 2160) return `FFVideoFormat2160p${fps}`; + if (width === 1080 && height === 1920) + return `FFVideoFormatVertical1080p${fps}`; + return `FFVideoFormat${height}p${fps}`; +} + +function generateAssetXml( + asset: TimelineAsset, + index: number, + formatId: string, + fps: number, +): string { + const id = `r${index * 2 + 1}`; + const formatRef = `r${index * 2 + 2}`; + const duration = asset.duration ?? 10; + const hasVideo = asset.type === "video" || asset.type === "image" ? 1 : 0; + const hasAudio = asset.type === "video" || asset.type === "audio" ? 1 : 0; + + return ` + `; +} + +function generateAssetClipXml( + item: TimelineClipItem, + asset: TimelineAsset, + assetIndex: number, + fps: number, +): string { + const ref = `r${assetIndex * 2 + 1}`; + const start = secondsToFCPTime(item.trimStart ?? 0, fps); + const duration = secondsToFCPTime(item.duration, fps); + const name = escapeXml(asset.prompt ?? `Clip ${assetIndex + 1}`); + const audioRole = asset.type === "audio" ? "dialogue" : "dialogue"; + + return ` `; +} + +export function exportFCPXML(timeline: Timeline): string { + const formatName = getFormatName( + timeline.width, + timeline.height, + timeline.fps, + ); + + const assetMap = new Map(); + let assetIndex = 0; + for (const asset of timeline.assets) { + assetMap.set(asset.id, { asset, index: assetIndex++ }); + } + + const assetsXml = timeline.assets + .map((asset, i) => generateAssetXml(asset, i, formatName, timeline.fps)) + .join("\n"); + + const allVideoItems = timeline.videoTracks.flatMap((t) => t.items); + const clipsXml = allVideoItems + .map((item) => { + const entry = assetMap.get(item.assetId); + if (!entry) return ""; + return generateAssetClipXml(item, entry.asset, entry.index, timeline.fps); + }) + .filter(Boolean) + .join("\n"); + + return ` + + + +${assetsXml} + + + + + + +${clipsXml} + + + + + +`; +} diff --git a/src/react/timeline/index.ts b/src/react/timeline/index.ts new file mode 100644 index 00000000..422cb4bf --- /dev/null +++ b/src/react/timeline/index.ts @@ -0,0 +1,64 @@ +import { mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import type { VargElement } from "../types"; +import { exportFCPXML } from "./fcpxml"; +import { exportOTIO } from "./otio"; +import type { TimelineExportOptions, TimelineExportResult } from "./types"; +import { walkTree } from "./walker"; + +export type { + ExportMode, + Timeline, + TimelineExportOptions, + TimelineExportResult, + TimelineFormat, +} from "./types"; + +export async function exportTimeline( + element: VargElement<"render">, + options: TimelineExportOptions, +): Promise { + const cacheDir = options.cache ?? ".cache/timeline"; + await mkdir(cacheDir, { recursive: true }); + + const outputDir = dirname(resolve(options.output)); + await mkdir(outputDir, { recursive: true }); + + if (!options.quiet) { + console.log(`extracting timeline (${options.mode} mode)...`); + } + + const timeline = await walkTree(element, options.mode, cacheDir); + timeline.metadata.sourceFile = options.output; + + let content: string; + if (options.format === "otio") { + content = exportOTIO(timeline); + } else { + content = exportFCPXML(timeline); + } + + const outputPath = resolve(options.output); + await Bun.write(outputPath, content); + + if (!options.quiet) { + console.log(`exported ${options.format} → ${outputPath}`); + } + + const placeholderCount = timeline.assets.filter( + (a) => a.isPlaceholder, + ).length; + + return { + timelinePath: outputPath, + format: options.format, + assets: timeline.assets, + summary: { + clips: timeline.videoTracks.reduce((sum, t) => sum + t.items.length, 0), + audioTracks: timeline.audioTracks.length, + transitions: timeline.transitions.length, + placeholders: placeholderCount, + duration: timeline.duration, + }, + }; +} diff --git a/src/react/timeline/otio.ts b/src/react/timeline/otio.ts new file mode 100644 index 00000000..51c37c32 --- /dev/null +++ b/src/react/timeline/otio.ts @@ -0,0 +1,187 @@ +import type { Timeline, TimelineAsset, TimelineClipItem } from "./types"; + +interface OTIORationalTime { + OTIO_SCHEMA: "RationalTime.1"; + rate: number; + value: number; +} + +interface OTIOTimeRange { + OTIO_SCHEMA: "TimeRange.1"; + start_time: OTIORationalTime; + duration: OTIORationalTime; +} + +interface OTIOExternalReference { + OTIO_SCHEMA: "ExternalReference.1"; + available_range: OTIOTimeRange | null; + metadata: Record; + target_url: string; + name: string; +} + +interface OTIOClip { + OTIO_SCHEMA: "Clip.1"; + effects: unknown[]; + markers: unknown[]; + enabled: boolean; + media_reference: OTIOExternalReference; + metadata: Record; + name: string; + source_range: OTIOTimeRange | null; +} + +interface OTIOTrack { + OTIO_SCHEMA: "Track.1"; + children: OTIOClip[]; + effects: unknown[]; + kind: "Video" | "Audio"; + markers: unknown[]; + enabled: boolean; + metadata: Record; + name: string; + source_range: null; +} + +interface OTIOStack { + OTIO_SCHEMA: "Stack.1"; + children: OTIOTrack[]; + effects: unknown[]; + markers: unknown[]; + enabled: boolean; + metadata: Record; + name: string; + source_range: null; +} + +interface OTIOTimeline { + OTIO_SCHEMA: "Timeline.1"; + metadata: Record; + name: string; + tracks: OTIOStack; +} + +function secondsToFrames(seconds: number, fps: number): number { + return Math.round(seconds * fps); +} + +function createRationalTime(seconds: number, fps: number): OTIORationalTime { + return { + OTIO_SCHEMA: "RationalTime.1", + rate: fps, + value: secondsToFrames(seconds, fps), + }; +} + +function createTimeRange( + startSeconds: number, + durationSeconds: number, + fps: number, +): OTIOTimeRange { + return { + OTIO_SCHEMA: "TimeRange.1", + start_time: createRationalTime(startSeconds, fps), + duration: createRationalTime(durationSeconds, fps), + }; +} + +function createClip( + item: TimelineClipItem, + asset: TimelineAsset, + fps: number, + clipIndex: number, +): OTIOClip { + const availableRange = asset.duration + ? createTimeRange(0, asset.duration, fps) + : null; + + const sourceRange = createTimeRange(item.trimStart ?? 0, item.duration, fps); + + return { + OTIO_SCHEMA: "Clip.1", + effects: [], + markers: [], + enabled: true, + media_reference: { + OTIO_SCHEMA: "ExternalReference.1", + available_range: availableRange, + metadata: asset.prompt + ? { prompt: asset.prompt, isPlaceholder: asset.isPlaceholder } + : {}, + target_url: asset.path, + name: `Media-${String(clipIndex + 1).padStart(3, "0")}`, + }, + metadata: {}, + name: `Clip-${String(clipIndex + 1).padStart(3, "0")}`, + source_range: sourceRange, + }; +} + +function createTrack( + name: string, + kind: "Video" | "Audio", + items: TimelineClipItem[], + assets: Map, + fps: number, +): OTIOTrack { + const clips: OTIOClip[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]!; + const asset = assets.get(item.assetId); + if (!asset) continue; + clips.push(createClip(item, asset, fps, i)); + } + + return { + OTIO_SCHEMA: "Track.1", + children: clips, + effects: [], + kind, + markers: [], + enabled: true, + metadata: {}, + name, + source_range: null, + }; +} + +export function toOTIO(timeline: Timeline): OTIOTimeline { + const assetMap = new Map(timeline.assets.map((a) => [a.id, a])); + + const videoTracks: OTIOTrack[] = timeline.videoTracks.map((track) => + createTrack(track.name, "Video", track.items, assetMap, timeline.fps), + ); + + const audioTracks: OTIOTrack[] = timeline.audioTracks.map((track) => + createTrack(track.name, "Audio", track.items, assetMap, timeline.fps), + ); + + return { + OTIO_SCHEMA: "Timeline.1", + metadata: { + varg: { + exportMode: timeline.metadata.exportMode, + exportedAt: timeline.metadata.exportedAt, + sourceFile: timeline.metadata.sourceFile, + resolution: { width: timeline.width, height: timeline.height }, + }, + }, + name: timeline.name, + tracks: { + OTIO_SCHEMA: "Stack.1", + children: [...videoTracks, ...audioTracks], + effects: [], + markers: [], + enabled: true, + metadata: {}, + name: "tracks", + source_range: null, + }, + }; +} + +export function exportOTIO(timeline: Timeline): string { + const otio = toOTIO(timeline); + return JSON.stringify(otio, null, 2); +} diff --git a/src/react/timeline/types.ts b/src/react/timeline/types.ts new file mode 100644 index 00000000..9fc233df --- /dev/null +++ b/src/react/timeline/types.ts @@ -0,0 +1,91 @@ +export type TimelineFormat = "otio" | "fcpxml"; + +export type ExportMode = "rendered" | "placeholders"; + +export interface TimelineExportOptions { + format: TimelineFormat; + mode: ExportMode; + output: string; + cache?: string; + quiet?: boolean; +} + +export interface TimelineAsset { + id: string; + type: "video" | "image" | "audio"; + path: string; + prompt?: string; + isPlaceholder: boolean; + duration?: number; + width?: number; + height?: number; +} + +export interface TimelineClipItem { + id: string; + assetId: string; + startTime: number; + duration: number; + trimStart?: number; + trimEnd?: number; + volume?: number; + position?: { x: number | string; y: number | string }; + size?: { width: number | string; height: number | string }; + zoom?: "in" | "out" | "left" | "right"; + resizeMode?: string; +} + +export interface TimelineTransition { + type: string; + duration: number; + afterClipIndex: number; +} + +export interface TimelineTextItem { + id: string; + type: "title" | "subtitle"; + text: string; + startTime: number; + duration: number; + position?: string; + color?: string; + backgroundColor?: string; +} + +export interface TimelineTrack { + id: string; + name: string; + type: "video" | "audio"; + items: TimelineClipItem[]; +} + +export interface Timeline { + name: string; + fps: number; + width: number; + height: number; + duration: number; + videoTracks: TimelineTrack[]; + audioTracks: TimelineTrack[]; + textItems: TimelineTextItem[]; + transitions: TimelineTransition[]; + assets: TimelineAsset[]; + metadata: { + exportMode: ExportMode; + exportedAt: string; + sourceFile?: string; + }; +} + +export interface TimelineExportResult { + timelinePath: string; + format: TimelineFormat; + assets: TimelineAsset[]; + summary: { + clips: number; + audioTracks: number; + transitions: number; + placeholders: number; + duration: number; + }; +} diff --git a/src/react/timeline/walker.ts b/src/react/timeline/walker.ts new file mode 100644 index 00000000..d189ea73 --- /dev/null +++ b/src/react/timeline/walker.ts @@ -0,0 +1,375 @@ +import { resolve } from "node:path"; +import { generatePlaceholder } from "../../ai-sdk/middleware/placeholder"; +import type { + ClipProps, + ImageProps, + MusicProps, + RenderProps, + SpeechProps, + SubtitleProps, + TitleProps, + VargElement, + VargNode, + VideoProps, +} from "../types"; +import type { + ExportMode, + Timeline, + TimelineAsset, + TimelineClipItem, + TimelineTextItem, + TimelineTrack, + TimelineTransition, +} from "./types"; + +interface WalkerContext { + mode: ExportMode; + width: number; + height: number; + fps: number; + cacheDir: string; + assets: Map; + assetCounter: number; +} + +function generateAssetId(ctx: WalkerContext): string { + return `asset_${++ctx.assetCounter}`; +} + +function resolveSrcPath(src: string): string { + if (src.startsWith("http://") || src.startsWith("https://")) { + return src; + } + return `file://${resolve(src)}`; +} + +async function createPlaceholderAsset( + ctx: WalkerContext, + type: "video" | "image" | "audio", + prompt: string, + duration?: number, +): Promise { + const id = generateAssetId(ctx); + const ext = type === "audio" ? "mp3" : type === "image" ? "png" : "mp4"; + const placeholderPath = `${ctx.cacheDir}/placeholder_${id}.${ext}`; + + const result = await generatePlaceholder({ + type, + prompt, + duration: duration ?? 3, + width: ctx.width, + height: ctx.height, + }); + + await Bun.write(placeholderPath, result.data); + + return { + id, + type, + path: `file://${resolve(placeholderPath)}`, + prompt, + isPlaceholder: true, + duration, + width: type !== "audio" ? ctx.width : undefined, + height: type !== "audio" ? ctx.height : undefined, + }; +} + +function extractPrompt( + props: ImageProps | VideoProps | MusicProps | SpeechProps, +): string | undefined { + if ("prompt" in props && props.prompt) { + if (typeof props.prompt === "string") return props.prompt; + if (typeof props.prompt === "object" && "text" in props.prompt) { + return props.prompt.text; + } + } + if ("children" in props && typeof props.children === "string") { + return props.children; + } + return undefined; +} + +async function processMediaElement( + ctx: WalkerContext, + element: VargElement, + defaultDuration?: number, +): Promise { + const props = element.props as + | ImageProps + | VideoProps + | MusicProps + | SpeechProps; + + if ("src" in props && props.src) { + const id = generateAssetId(ctx); + const type = + element.type === "music" || element.type === "speech" + ? "audio" + : (element.type as "video" | "image"); + const asset: TimelineAsset = { + id, + type, + path: resolveSrcPath(props.src), + isPlaceholder: false, + duration: defaultDuration, + }; + ctx.assets.set(id, asset); + return asset; + } + + const prompt = extractPrompt(props); + if (prompt) { + const type = + element.type === "music" || element.type === "speech" + ? "audio" + : (element.type as "video" | "image"); + const asset = await createPlaceholderAsset( + ctx, + type, + prompt, + defaultDuration, + ); + ctx.assets.set(asset.id, asset); + return asset; + } + + return null; +} + +function processTextElement( + element: VargElement<"title" | "subtitle">, + startTime: number, + duration: number, +): TimelineTextItem { + const props = element.props as TitleProps | SubtitleProps; + const text = Array.isArray(element.children) + ? element.children.filter((c) => typeof c === "string").join("") + : typeof element.children === "string" + ? element.children + : ""; + + return { + id: `text_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + type: element.type as "title" | "subtitle", + text, + startTime, + duration, + position: + "position" in props && typeof props.position === "string" + ? props.position + : undefined, + color: "color" in props ? props.color : undefined, + backgroundColor: + "backgroundColor" in props + ? (props as SubtitleProps).backgroundColor + : undefined, + }; +} + +async function processClip( + ctx: WalkerContext, + clipElement: VargElement<"clip">, + startTime: number, +): Promise<{ + videoItems: TimelineClipItem[]; + audioItems: TimelineClipItem[]; + textItems: TimelineTextItem[]; + duration: number; + transition?: TimelineTransition; +}> { + const props = clipElement.props as ClipProps; + const duration = typeof props.duration === "number" ? props.duration : 3; + + const videoItems: TimelineClipItem[] = []; + const audioItems: TimelineClipItem[] = []; + const textItems: TimelineTextItem[] = []; + + for (const child of clipElement.children) { + if (!child || typeof child !== "object" || !("type" in child)) continue; + const childElement = child as VargElement; + + switch (childElement.type) { + case "image": + case "video": { + const asset = await processMediaElement(ctx, childElement, duration); + if (asset) { + const childProps = childElement.props as ImageProps | VideoProps; + videoItems.push({ + id: `clip_${asset.id}`, + assetId: asset.id, + startTime, + duration, + trimStart: "cutFrom" in childProps ? childProps.cutFrom : undefined, + trimEnd: "cutTo" in childProps ? childProps.cutTo : undefined, + volume: "volume" in childProps ? childProps.volume : undefined, + zoom: "zoom" in childProps ? childProps.zoom : undefined, + resizeMode: "resize" in childProps ? childProps.resize : undefined, + }); + } + break; + } + + case "speech": + case "music": { + const asset = await processMediaElement(ctx, childElement, duration); + if (asset) { + const childProps = childElement.props as SpeechProps | MusicProps; + audioItems.push({ + id: `clip_${asset.id}`, + assetId: asset.id, + startTime, + duration, + trimStart: "cutFrom" in childProps ? childProps.cutFrom : undefined, + trimEnd: "cutTo" in childProps ? childProps.cutTo : undefined, + volume: childProps.volume, + }); + } + break; + } + + case "title": + case "subtitle": { + textItems.push( + processTextElement( + childElement as VargElement<"title" | "subtitle">, + startTime, + duration, + ), + ); + break; + } + } + } + + let transition: TimelineTransition | undefined; + if (props.transition) { + transition = { + type: props.transition.name ?? "fade", + duration: props.transition.duration ?? 0.5, + afterClipIndex: -1, + }; + } + + return { videoItems, audioItems, textItems, duration, transition }; +} + +export async function walkTree( + element: VargElement<"render">, + mode: ExportMode, + cacheDir: string, +): Promise { + const props = element.props as RenderProps; + + const ctx: WalkerContext = { + mode, + width: props.width ?? 1920, + height: props.height ?? 1080, + fps: props.fps ?? 30, + cacheDir, + assets: new Map(), + assetCounter: 0, + }; + + const videoTrack: TimelineTrack = { + id: "V1", + name: "Video 1", + type: "video", + items: [], + }; + + const audioTrack: TimelineTrack = { + id: "A1", + name: "Audio 1", + type: "audio", + items: [], + }; + + const musicTrack: TimelineTrack = { + id: "A2", + name: "Music", + type: "audio", + items: [], + }; + + const textItems: TimelineTextItem[] = []; + const transitions: TimelineTransition[] = []; + + let currentTime = 0; + let clipIndex = 0; + + for (const child of element.children) { + if (!child || typeof child !== "object" || !("type" in child)) continue; + const childElement = child as VargElement; + + if (childElement.type === "clip") { + const result = await processClip( + ctx, + childElement as VargElement<"clip">, + currentTime, + ); + + videoTrack.items.push(...result.videoItems); + audioTrack.items.push(...result.audioItems); + textItems.push(...result.textItems); + + if (result.transition) { + result.transition.afterClipIndex = clipIndex + 1; + transitions.push(result.transition); + currentTime += result.duration - result.transition.duration; + } else { + currentTime += result.duration; + } + + clipIndex++; + } else if (childElement.type === "music") { + const musicProps = childElement.props as MusicProps; + const asset = await processMediaElement(ctx, childElement); + if (asset) { + musicTrack.items.push({ + id: `music_${asset.id}`, + assetId: asset.id, + startTime: musicProps.start ?? 0, + duration: currentTime, + trimStart: musicProps.cutFrom, + trimEnd: musicProps.cutTo, + volume: musicProps.volume, + }); + } + } else if (childElement.type === "speech") { + const asset = await processMediaElement(ctx, childElement); + if (asset) { + const speechProps = childElement.props as SpeechProps; + audioTrack.items.push({ + id: `speech_${asset.id}`, + assetId: asset.id, + startTime: 0, + duration: currentTime, + volume: speechProps.volume, + }); + } + } + } + + const audioTracks = [audioTrack]; + if (musicTrack.items.length > 0) { + audioTracks.push(musicTrack); + } + + return { + name: "Varg Export", + fps: ctx.fps, + width: ctx.width, + height: ctx.height, + duration: currentTime, + videoTracks: [videoTrack], + audioTracks, + textItems, + transitions, + assets: Array.from(ctx.assets.values()), + metadata: { + exportMode: mode, + exportedAt: new Date().toISOString(), + }, + }; +}