diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0c9268e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# exclude deno.lock from git diff +deno.lock -diff diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4c0b562 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + + +permissions: + contents: read + + +env: + DENO_DIR: deno_dependency_cache + DENO_LOCKFILE: ${{ github.workspace }}/deno.lock + + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Setup Repo + uses: actions/checkout@v3 + + - name: Setup FFMPEG + uses: FedericoCarboni/setup-ffmpeg@v3 + id: setup-ffmpeg + with: + ffmpeg-version: 7.0.1 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + + - name: Cache Deno dependencies + uses: actions/cache@v4 + with: + path: ${{ env.DENO_DIR }} + key: ${{ hashFiles(env.DENO_LOCKFILE) }} + + # SSH into github machine for quick iterative debugging (note that this sort of sucks if you want to retrieve a file via ssh) + # - name: Setup tmate session + # uses: mxschmitt/action-tmate@v3 + + - name: Test + run: deno task test + + - name: run ffmpeg debug + if: always() + run: "./test/artifacts/render\\ image\\ with\\ zero\\ duration/ffmpeg.sh" + + - uses: actions/upload-artifact@v4 + if: always() + with: + path: "./test/artifacts/render image with zero duration/preview.jpg" diff --git a/.gitignore b/.gitignore index 81154dd..c57cff4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ +ffmpeg-templates-projects +test/artifacts samples +.vim +scratchwork diff --git a/README.md b/README.md index 8beed2d..d86128e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ## Installation Requires [ffmpeg](https://ffmpeg.org/download.html) and [deno](https://deno.land) >= 1.5.0 ```bash -deno install --allow-read --allow-run --unstable -f https://raw.githubusercontent.com/andykais/ffmpeg-templates/main/ffmpeg-templates.ts +deno install --allow-read --allow-run=ffmpeg,ffprobe --unstable -f https://raw.githubusercontent.com/andykais/ffmpeg-templates/main/ffmpeg-templates.ts ``` @@ -192,3 +192,15 @@ ffmpeg-music-video - audio visualizations to help with previews? https://www.youtube.com/watch?v=M58rc7cxl_s - template.layers or template.stack or template.order to separate the vertical ordering from the timeline? Not necessary, but possibly more clear - add CI testing (install ffmpeg via apt, or use a cache: https://stackoverflow.com/questions/59269850/caching-apt-packages-in-github-actions-workflow) +- make timeline a sort of query language, and give the clips a tag array field. timeline can have things like `:DIALOG` to string together a ton of font clips. This might also be solveable by using the "sub template" idea +- display tiny timeline strip in console reflecting timeline configuration. E.g. +``` + ____________________ +| VIDEO_LEFT | + ____________________ +| VIDEO_RIGHT | + __________ ________ +| TEXT_1 || TEXT_2 | + ________ + | TEXT_3 | +``` diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..f5cd527 --- /dev/null +++ b/deno.json @@ -0,0 +1,24 @@ +{ + "tasks": { + "test:watch": "deno test --watch --fail-fast --unstable-ffi --allow-read --allow-write --allow-run=ffprobe,ffmpeg --allow-env=DENO_SKIA_LOCAL,DENO_SKIA_PATH,DENO_DIR,XDG_CACHE_HOME,HOME,CANVAS_DISABLE_SYSTEM_FONTS --allow-ffi --allow-net=github.com,objects.githubusercontent.com,release-assets.githubusercontent.com" + "test": "deno test --fail-fast --unstable-ffi --allow-read --allow-write --allow-run=ffprobe,ffmpeg --allow-env=DENO_SKIA_LOCAL,DENO_SKIA_PATH,DENO_DIR,XDG_CACHE_HOME,HOME,CANVAS_DISABLE_SYSTEM_FONTS --allow-ffi --allow-net=github.com,objects.githubusercontent.com,release-assets.githubusercontent.com ./test/*.test.ts" + }, + "imports": { + "@detachhead/ts-helpers": "npm:@detachhead/ts-helpers@16.2.0", + "@gfx/canvas": "jsr:@gfx/canvas@^0.5.8", + "@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2", + "@opensrc/deno-open": "jsr:@opensrc/deno-open@^1.0.0", + "@std/assert": "jsr:@std/assert@^1.0.11", + "@std/crypto": "jsr:@std/crypto@^1.0.3", + "@std/encoding": "jsr:@std/encoding@^1.0.6", + "@std/flags": "jsr:@std/flags@^0.224.0", + "@std/fs": "jsr:@std/fs@^1.0.8", + "@std/io": "jsr:@std/io@^0.225.0", + "@std/path": "jsr:@std/path@^1.0.8", + "@std/streams": "jsr:@std/streams@^1.0.8", + "@std/yaml": "jsr:@std/yaml@^1.0.5", + "@types/culori": "npm:@types/culori@^2.1.1", + "culori": "npm:culori@^4.0.1", + "zod": "npm:zod@^3.24.1" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..99f4790 --- /dev/null +++ b/deno.lock @@ -0,0 +1,299 @@ +{ + "version": "4", + "specifiers": { + "jsr:@denosaurs/plug@1": "1.0.5", + "jsr:@denosaurs/plug@1.0.5": "1.0.5", + "jsr:@gfx/canvas-wasm@~0.4.2": "0.4.2", + "jsr:@gfx/canvas@~0.5.8": "0.5.8", + "jsr:@opensrc/deno-open@1": "1.0.0", + "jsr:@std/assert@0.214": "0.214.0", + "jsr:@std/assert@0.217": "0.217.0", + "jsr:@std/assert@0.224": "0.224.0", + "jsr:@std/assert@^1.0.11": "1.0.11", + "jsr:@std/assert@^1.0.9": "1.0.9", + "jsr:@std/assert@~0.218.2": "0.218.2", + "jsr:@std/bytes@^1.0.3": "1.0.4", + "jsr:@std/bytes@^1.0.4": "1.0.4", + "jsr:@std/crypto@^1.0.3": "1.0.3", + "jsr:@std/encoding@0.214": "0.214.0", + "jsr:@std/encoding@0.217.0": "0.217.0", + "jsr:@std/encoding@1.0.5": "1.0.5", + "jsr:@std/encoding@^1.0.6": "1.0.6", + "jsr:@std/flags@0.224": "0.224.0", + "jsr:@std/fmt@0.214": "0.214.0", + "jsr:@std/fs@0.214": "0.214.0", + "jsr:@std/fs@0.217.0": "0.217.0", + "jsr:@std/fs@^1.0.8": "1.0.8", + "jsr:@std/internal@^1.0.5": "1.0.5", + "jsr:@std/io@0.225": "0.225.1", + "jsr:@std/path@0.214": "0.214.0", + "jsr:@std/path@0.217": "0.217.0", + "jsr:@std/path@0.217.0": "0.217.0", + "jsr:@std/path@^1.0.8": "1.0.8", + "jsr:@std/path@~0.218.2": "0.218.2", + "jsr:@std/streams@^1.0.8": "1.0.8", + "jsr:@std/yaml@^1.0.5": "1.0.5", + "npm:@detachhead/ts-helpers@16.2.0": "16.2.0", + "npm:@types/culori@^2.1.1": "2.1.1", + "npm:culori@^4.0.1": "4.0.1", + "npm:tsafe@1.8.5": "1.8.5", + "npm:zod@^3.24.1": "3.24.1" + }, + "jsr": { + "@denosaurs/plug@1.0.5": { + "integrity": "04cd988da558adc226202d88c3a434d5fcc08146eaf4baf0cea0c2284b16d2bf", + "dependencies": [ + "jsr:@std/encoding@0.214", + "jsr:@std/fmt", + "jsr:@std/fs@0.214", + "jsr:@std/path@0.214" + ] + }, + "@gfx/canvas@0.5.8": { + "integrity": "a61c80292528e7433d428556b494a0ea496dd8e6abd4a338b8b25fc04e46ea3e", + "dependencies": [ + "jsr:@denosaurs/plug@1", + "jsr:@denosaurs/plug@1.0.5", + "jsr:@std/encoding@0.217.0", + "jsr:@std/fs@0.217.0", + "jsr:@std/path@0.217.0" + ] + }, + "@gfx/canvas-wasm@0.4.2": { + "integrity": "d653be3bd12cb2fa9bbe5d1b1f041a81b91d80b68502761204aaf60e4592532a", + "dependencies": [ + "jsr:@std/encoding@1.0.5" + ] + }, + "@opensrc/deno-open@1.0.0": { + "integrity": "590decada8cf2d50f4b35c7d3a8d642e18e45d6e47078ea02a4dbf0096bbf9fd", + "dependencies": [ + "jsr:@std/path@~0.218.2" + ] + }, + "@std/assert@0.214.0": { + "integrity": "55d398de76a9828fd3b1aa653f4dba3eee4c6985d90c514865d2be9bd082b140" + }, + "@std/assert@0.217.0": { + "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" + }, + "@std/assert@0.218.2": { + "integrity": "7f0a5a1a8cf86607cd6c2c030584096e1ffad27fc9271429a8cb48cfbdee5eaf" + }, + "@std/assert@0.224.0": { + "integrity": "8643233ec7aec38a940a8264a6e3eed9bfa44e7a71cc6b3c8874213ff401967f" + }, + "@std/assert@1.0.9": { + "integrity": "a9f0c611a869cc791b26f523eec54c7e187aab7932c2c8e8bea0622d13680dcd", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/assert@1.0.11": { + "integrity": "2461ef3c368fe88bc60e186e7744a93112f16fd110022e113a0849e94d1c83c1", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/bytes@1.0.4": { + "integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc" + }, + "@std/crypto@1.0.3": { + "integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f" + }, + "@std/encoding@0.214.0": { + "integrity": "30a8713e1db22986c7e780555ffd2fefd1d4f9374d734bb41f5970f6c3352af5" + }, + "@std/encoding@0.217.0": { + "integrity": "b03e8ff94c98d6b6a02c02c5cf8e5d203400155516248964fc4559abc04669dc" + }, + "@std/encoding@1.0.5": { + "integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04" + }, + "@std/encoding@1.0.6": { + "integrity": "ca87122c196e8831737d9547acf001766618e78cd8c33920776c7f5885546069" + }, + "@std/flags@0.224.0": { + "integrity": "d40eaf58c356b2e1313c6d4e62dc28b614aad2ddae6f5ff72a969e0b1f5ad689", + "dependencies": [ + "jsr:@std/assert@0.224" + ] + }, + "@std/fmt@0.214.0": { + "integrity": "40382cff88a0783b347b4d69b94cf931ab8e549a733916718cb866c08efac4d4" + }, + "@std/fs@0.214.0": { + "integrity": "bc880fea0be120cb1550b1ed7faf92fe071003d83f2456a1e129b39193d85bea", + "dependencies": [ + "jsr:@std/assert@0.214", + "jsr:@std/path@0.214" + ] + }, + "@std/fs@0.217.0": { + "integrity": "0bfff5f3618d68c385b28b4ffbf3a15c98293a0f1186444458b62e0111ce77b2", + "dependencies": [ + "jsr:@std/assert@0.217", + "jsr:@std/path@0.217" + ] + }, + "@std/fs@1.0.8": { + "integrity": "161c721b6f9400b8100a851b6f4061431c538b204bb76c501d02c508995cffe0", + "dependencies": [ + "jsr:@std/path@^1.0.8" + ] + }, + "@std/internal@1.0.5": { + "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" + }, + "@std/io@0.225.1": { + "integrity": "0a055fa523288d596f09ba11af5e006f17cecc75bab949b233ceba7eba40293e", + "dependencies": [ + "jsr:@std/bytes@^1.0.4" + ] + }, + "@std/path@0.214.0": { + "integrity": "d5577c0b8d66f7e8e3586d864ebdf178bb326145a3611da5a51c961740300285", + "dependencies": [ + "jsr:@std/assert@0.214" + ] + }, + "@std/path@0.217.0": { + "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", + "dependencies": [ + "jsr:@std/assert@0.217" + ] + }, + "@std/path@0.218.2": { + "integrity": "b568fd923d9e53ad76d17c513e7310bda8e755a3e825e6289a0ce536404e2662", + "dependencies": [ + "jsr:@std/assert@~0.218.2" + ] + }, + "@std/path@1.0.8": { + "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" + }, + "@std/streams@1.0.8": { + "integrity": "b41332d93d2cf6a82fe4ac2153b930adf1a859392931e2a19d9fabfb6f154fb3", + "dependencies": [ + "jsr:@std/bytes@^1.0.3" + ] + }, + "@std/yaml@1.0.5": { + "integrity": "71ba3d334305ee2149391931508b2c293a8490f94a337eef3a09cade1a2a2742" + } + }, + "npm": { + "@detachhead/ts-helpers@16.2.0": { + "integrity": "sha512-1kwJ1AgXWK9goECjtyRh6SMVf7YYkMt0yvUccxqo7qpDRYs3uDC0ot5MhIbDTbhyoQQio3wTTmA2m5ijdUehdA==", + "dependencies": [ + "@types/lodash", + "date-fns", + "is-ci", + "lodash", + "ordinal", + "throw-expression", + "ts-is-present", + "ts-toolbelt", + "tsdef", + "utility-types" + ] + }, + "@types/culori@2.1.1": { + "integrity": "sha512-NzLYD0vNHLxTdPp8+RlvGbR2NfOZkwxcYGFwxNtm+WH2NuUNV8785zv1h0sulFQ5aFQ9n/jNDUuJeo3Bh7+oFA==" + }, + "@types/lodash@4.14.195": { + "integrity": "sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==" + }, + "ci-info@2.0.0": { + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==" + }, + "culori@4.0.1": { + "integrity": "sha512-LSnjA6HuIUOlkfKVbzi2OlToZE8OjFi667JWN9qNymXVXzGDmvuP60SSgC+e92sd7B7158f7Fy3Mb6rXS5EDPw==" + }, + "date-fns@2.21.1": { + "integrity": "sha512-m1WR0xGiC6j6jNFAyW4Nvh4WxAi4JF4w9jRJwSI8nBmNcyZXPcP9VUQG+6gHQXAmqaGEKDKhOqAtENDC941UkA==" + }, + "is-ci@2.0.0": { + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dependencies": [ + "ci-info" + ] + }, + "lodash@4.17.21": { + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "ordinal@1.0.3": { + "integrity": "sha512-cMddMgb2QElm8G7vdaa02jhUNbTSrhsgAGUz1OokD83uJTwSUn+nKoNoKVVaRa08yF6sgfO7Maou1+bgLd9rdQ==" + }, + "throw-expression@1.0.2": { + "integrity": "sha512-y8cHYZgp0Vj8bRMQSWAA7pfHj1JPift81tZM9i/rHQuJoYEv9kewjEpRu2wRRuXRaClFWWzWQFTG3Q9rDysyNQ==" + }, + "ts-is-present@1.2.1": { + "integrity": "sha512-EYHn58qVRd/VHGkHSNGQJAxPXhF9wRiphYXl04VznngqqKjcT2jfHvAMPuzh8sRKdvifXw+dq6/Q3hG+nr3DRA==" + }, + "ts-toolbelt@9.6.0": { + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==" + }, + "tsafe@1.8.5": { + "integrity": "sha512-LFWTWQrW6rwSY+IBNFl2ridGfUzVsPwrZ26T4KUJww/py8rzaQ/SY+MIz6YROozpUCaRcuISqagmlwub9YT9kw==" + }, + "tsdef@0.0.14": { + "integrity": "sha512-UjMD4XKRWWFlFBfwKVQmGFT5YzW/ZaF8x6KpCDf92u9wgKeha/go3FU0e5WqDjXsCOdfiavCkfwfVHNDxRDGMA==" + }, + "utility-types@3.10.0": { + "integrity": "sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==" + }, + "zod@3.24.1": { + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==" + } + }, + "remote": { + "https://deno.land/std@0.193.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", + "https://deno.land/std@0.193.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", + "https://deno.land/std@0.193.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", + "https://deno.land/std@0.193.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", + "https://deno.land/std@0.193.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", + "https://deno.land/std@0.193.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", + "https://deno.land/std@0.193.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", + "https://deno.land/std@0.193.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", + "https://deno.land/std@0.193.0/path/mod.ts": "f065032a7189404fdac3ad1a1551a9ac84751d2f25c431e101787846c86c79ef", + "https://deno.land/std@0.193.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", + "https://deno.land/std@0.193.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", + "https://deno.land/std@0.193.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", + "https://deno.land/std@0.193.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.193.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.193.0/testing/asserts.ts": "056d571baaefc7f13af3e29ad6a66d4dbe5355d3cb2ae130e7d2a1b1e01085e3", + "https://deno.land/std@0.91.0/_util/assert.ts": "2f868145a042a11d5ad0a3c748dcf580add8a0dbc0e876eaa0026303a5488f58", + "https://deno.land/std@0.91.0/_util/os.ts": "e282950a0eaa96760c0cf11e7463e66babd15ec9157d4c9ed49cc0925686f6a7", + "https://deno.land/std@0.91.0/path/_constants.ts": "1247fee4a79b70c89f23499691ef169b41b6ccf01887a0abd131009c5581b853", + "https://deno.land/std@0.91.0/path/_interface.ts": "1fa73b02aaa24867e481a48492b44f2598cd9dfa513c7b34001437007d3642e4", + "https://deno.land/std@0.91.0/path/_util.ts": "2e06a3b9e79beaf62687196bd4b60a4c391d862cfa007a20fc3a39f778ba073b", + "https://deno.land/std@0.91.0/path/common.ts": "eaf03d08b569e8a87e674e4e265e099f237472b6fd135b3cbeae5827035ea14a", + "https://deno.land/std@0.91.0/path/glob.ts": "4a524c1c9da3e79a9fdabdc6e850cd9e41bdf31e442856ffa19c5b123268ca95", + "https://deno.land/std@0.91.0/path/mod.ts": "4465dc494f271b02569edbb4a18d727063b5dbd6ed84283ff906260970a15d12", + "https://deno.land/std@0.91.0/path/posix.ts": "f56c3c99feb47f30a40ce9d252ef6f00296fa7c0fcb6dd81211bdb3b8b99ca3b", + "https://deno.land/std@0.91.0/path/separator.ts": "8fdcf289b1b76fd726a508f57d3370ca029ae6976fcde5044007f062e643ff1c", + "https://deno.land/std@0.91.0/path/win32.ts": "77f7b3604e0de40f3a7c698e8a79e7f601dc187035a1c21cb1e596666ce112f8", + "https://deno.land/x/canvas@v1.3.0/src/types.ts": "cb7458e521cb60000d804b07065d1ae83958e2989795947a95a060d0bb66f158" + }, + "workspace": { + "dependencies": [ + "jsr:@gfx/canvas-wasm@~0.4.2", + "jsr:@gfx/canvas@~0.5.8", + "jsr:@opensrc/deno-open@1", + "jsr:@std/assert@^1.0.11", + "jsr:@std/crypto@^1.0.3", + "jsr:@std/encoding@^1.0.6", + "jsr:@std/flags@0.224", + "jsr:@std/fs@^1.0.8", + "jsr:@std/io@0.225", + "jsr:@std/path@^1.0.8", + "jsr:@std/streams@^1.0.8", + "jsr:@std/yaml@^1.0.5", + "npm:@detachhead/ts-helpers@16.2.0", + "npm:@types/culori@^2.1.1", + "npm:culori@^4.0.1", + "npm:zod@^3.24.1" + ] + } +} diff --git a/ffmpeg-templates.ts b/ffmpeg-templates.ts index 44c3ac3..6afd858 100644 --- a/ffmpeg-templates.ts +++ b/ffmpeg-templates.ts @@ -1,4 +1,4 @@ -import * as flags from 'https://deno.land/std@0.91.0/flags/mod.ts' +import * as flags from '@std/flags' import ffmpeg_templates from './lib/cli.ts' diff --git a/import_map.json b/import_map.json new file mode 100644 index 0000000..e2fb752 --- /dev/null +++ b/import_map.json @@ -0,0 +1,20 @@ +{ + "imports": { + "@detachhead/ts-helpers": "npm:@detachhead/ts-helpers@16.2.0", + "@gfx/canvas": "jsr:@gfx/canvas@^0.5.8", + "@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2", + "@opensrc/deno-open": "jsr:@opensrc/deno-open@^1.0.0", + "@std/assert": "jsr:@std/assert@^1.0.11", + "@std/crypto": "jsr:@std/crypto@^1.0.3", + "@std/encoding": "jsr:@std/encoding@^1.0.6", + "@std/flags": "jsr:@std/flags@^0.224.0", + "@std/fs": "jsr:@std/fs@^1.0.8", + "@std/io": "jsr:@std/io@^0.225.0", + "@std/path": "jsr:@std/path@^1.0.8", + "@std/streams": "jsr:@std/streams@^1.0.8", + "@std/yaml": "jsr:@std/yaml@^1.0.5", + "@types/culori": "npm:@types/culori@^2.1.1", + "culori": "npm:culori@^4.0.1", + "zod": "npm:zod@^3.24.1" + } +} diff --git a/lib/bindings/ffmpeg.ts b/lib/bindings/ffmpeg.ts index fb0fbd1..6d4958c 100644 --- a/lib/bindings/ffmpeg.ts +++ b/lib/bindings/ffmpeg.ts @@ -1,23 +1,30 @@ -import * as io from 'https://deno.land/std@0.91.0/io/mod.ts' +import {readlines} from '../util.ts' import { InputError, CommandError } from '../errors.ts' import { parse_duration } from '../parsers/duration.ts' -import type { ClipID } from '../template_input.ts' -import type * as template_parsed from '../parsers/template.ts' +import type { Context } from '../context.ts' import type { Timestamp } from '../template_input.ts' type OnReadLine = (line: string) => void async function exec(cmd: string[]) { const decoder = new TextDecoder() - const proc = Deno.run({ cmd, stdout: 'piped' }) - const result = await proc.status() - const output_buffer = await proc.output() - const output = decoder.decode(output_buffer) - await proc.stdout.close() - await proc.close() - if (result.success) { - return output - } else { - throw new CommandError(`Command "${cmd.join(' ')}" failed.\n\n${output}`) + // console.log(cmd) + const proc_cmd = new Deno.Command(cmd[0], { + args: cmd.slice(1), + stdout: 'piped', + stderr: 'piped', + }) + try { + const result = await proc_cmd.output() + // const output_buffer = await proc.output() + const output = decoder.decode(result.stdout) + if (result.success) { + return output + } else { + const stderr = decoder.decode(result.stderr) + throw new CommandError(`Command "${cmd.join(' ')}" failed.\n\n${output}\n$${stderr}`) + } + } catch (e) { + throw e } } @@ -29,30 +36,36 @@ type FfmpegProgress = { } type OnProgress = (percentage: number) => void async function ffmpeg( - template: template_parsed.Template, + context: Context, ffmpeg_cmd: (string | number)[], longest_duration: number, - progress_callback?: OnProgress ) { const ffmpeg_safe_cmd = ffmpeg_cmd.map((a) => a.toString()) - if (progress_callback) { + if (context.logger.can_log('info')) { ffmpeg_safe_cmd.push('-progress', 'pipe:1') - const proc = Deno.run({ cmd: ffmpeg_safe_cmd, stdout: 'piped', stdin: 'inherit' }) + const proc_cmd = new Deno.Command(ffmpeg_safe_cmd[0], { + args: ffmpeg_safe_cmd.slice(1), + stdout: 'piped', + stdin: 'inherit', + }) + const proc = proc_cmd.spawn() let progress: Partial = {} - for await (const line of io.readLines(proc.stdout!)) { - const [key, value] = line.split('=') + for await (const line of readlines(proc.stdout)) { + const split_index = line.indexOf('=') + const key = line.slice(0, split_index) + const value = line.slice(split_index + 1) + // const [key, value] = line.split('=') ;(progress as any)[key] = value if (key === 'progress') { - const ffmpeg_percentage = parse_duration(progress.out_time!, template) / longest_duration + const ffmpeg_percentage = parse_duration(context, progress.out_time!) / longest_duration const percentage = Math.max(0, Math.min(1, ffmpeg_percentage)) - progress_callback(percentage) + await context.logger.progress(context.execution_start_time, percentage) progress = {} } } - const result = await proc.status() - await proc.stdout.close() - await proc.close() - if (!result.success) { + const result = await proc.status + await context.logger.progress(context.execution_start_time, 1) + if (result.success === false) { throw new CommandError(`Command "${ffmpeg_safe_cmd.join(' ')}" failed.\n\n`) } } else { diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts new file mode 100644 index 0000000..ecf0b14 --- /dev/null +++ b/lib/bootstrap.ts @@ -0,0 +1,5 @@ +function bootstrap_template(output_folder: string) { + +} + +export { bootstrap_template } diff --git a/lib/builder/clip_base.ts b/lib/builder/clip_base.ts new file mode 100644 index 0000000..17afcac --- /dev/null +++ b/lib/builder/clip_base.ts @@ -0,0 +1,177 @@ +import { parse_unit } from '../parsers/unit.ts' +import type * as parsed from '../parsers/template.ts' +import type { TimelineClip } from '../timeline.ts' +import type { ComputedGeometry } from '../geometry.ts' +import type { ClipInfo } from '../probe.ts' + +import { type ClipBuilderData } from './ffmpeg_base.ts' + +export abstract class ClipBuilderBase { + protected pts_speed = '1*' + protected setpts_filter = '' + protected start_at = 0 + protected clip_trim_start = 0 + protected clip_duration = NaN + protected timeline_data: TimelineClip = { + clip_id: '', + z_index: 0, + start_at: 0, + speed: 1, + trim_start: 0, + duration: NaN, + } + private computed_geometry: ComputedGeometry | undefined + private video_input_filters: string[] = [] + private audio_input_filters: string[] = [] + + private compute_tempo(val: number) { + const numMultipliers = + val > 1 ? Math.ceil(Math.log(val) / Math.log(2)) : Math.ceil(Math.log(val) / Math.log(0.5)) + const multVal = Math.pow(Math.E, Math.log(val) / numMultipliers) + return Array(numMultipliers).fill(`atempo=${multVal}`).join(',') + } + + public constructor(protected clip: parsed.MediaClipParsed, protected probe_info: ClipInfo) { + const volume = parse_unit(clip.volume, { + percentage: v => v, + undefined: () => 1, + }) + this.audio_input_filters.push( + `asetpts=PTS-STARTPTS`, + // `atrim=0:${duration * speed}`, + // `adelay=${start_at * 1000}:all=1`, + `volume=${volume}`, // TODO use anullsink for audio_volume === 0 to avoid extra processing + ) + + } + + protected get_timing_start_at(timeline_data: TimelineClip) { + return timeline_data.start_at + } + protected get_timing_trim_start(timeline_data: TimelineClip) { + return timeline_data.trim_start + } + + public timing(timeline_data: TimelineClip) { + this.timeline_data = timeline_data + this.start_at = this.get_timing_start_at(timeline_data) + this.clip_trim_start = this.get_timing_trim_start(timeline_data) + this.clip_duration = timeline_data.duration + this.pts_speed = `${1 / timeline_data.speed}*` + + if (this.start_at === 0) this.setpts_filter = `setpts=${this.pts_speed}PTS-STARTPTS` + else this.setpts_filter = `setpts=${this.pts_speed}PTS-STARTPTS+${this.start_at}/TB` + + this.audio_input_filters.push(`adelay=${this.start_at * 1000}:all=1`) + const atempo = this.compute_tempo(timeline_data.speed) + // a.k.a. speed == 1 + // TODO it seems like theres some weird floating point math happening in some cases + if (atempo !== '') this.audio_input_filters.push(atempo) + return this + } + + public geometry(geometry: ComputedGeometry) { + this.computed_geometry = geometry + return this + .scale(geometry.scale) + .rotate(geometry.rotate) + .crop(geometry.crop) + .transform(this.clip) + .border(geometry) + } + + public chromakey(colorkey: string) { + this.video_input_filters.push(`colorkey=${colorkey}:0.3:`) + return this + } + + public scale(scale: { width: number; height: number }) { + this.video_input_filters.push(`scale=${scale.width}:${scale.height}:flags=bicubic`) + return this + } + + public border(geometry: ComputedGeometry) { + if (this.clip.border?.radius) { + const final_width = geometry?.crop?.width ?? geometry.scale.width + const radius = parse_unit(this.clip.border.radius, { + undefined: () => { throw new Error('unexpected code path') }, + pixels: () => { throw new Error('unexpected code path') }, + percentage: p => (final_width / 2) * p, + }) + const transparency = 255 + this.video_input_filters.push(`format=yuva420p`, `geq=lum='p(X,Y)':a='if(gt(abs(W/2-X),W/2-${radius})*gt(abs(H/2-Y),H/2-${radius}),if(lte(hypot(${radius}-(W/2-abs(W/2-X)),${radius}-(H/2-abs(H/2-Y))),${radius}),${transparency},0),${transparency})'`) + } + return this + } + + public rotate(rotate: ComputedGeometry['rotate']) { + if (rotate === undefined) return this + const { degrees, width, height } = rotate + this.video_input_filters.push(`rotate=${degrees}*PI/180:fillcolor=black@0:out_w=${width}:out_h=${height}`) + return this + } + + public transform(clip: parsed.MediaClipParsed) { + if (!clip.transform) return this + for (const transformation of clip.transform) { + if (transformation.flip) { + if (transformation.flip === 'horizontal') { + this.video_input_filters.push(`hflip`) + } + else if (transformation.flip === 'vertical') { + this.video_input_filters.push(`vflip`) + } else { + throw new Error(`unexpected transformation ${JSON.stringify(transformation)}`) + } + } else { + throw new Error(`unexpected transformation ${JSON.stringify(transformation)}`) + } + } + return this + } + + public crop(crop: ComputedGeometry['crop']) { + if (crop === undefined) return this + // TODO support zoompan + const crop_x = crop.x + const crop_y = crop.y + this.video_input_filters.push( + `crop=w=${crop.width}:h=${crop.height}:x='${crop_x}':y='${crop_y}':keep_aspect=1` + ) + return this + } + + public build(): ClipBuilderData { + const video_input_filters = [ + this.setpts_filter, + ...this.video_input_filters, + // 'format=pix_fmts=yuv420p', // NOTE this was an attempt to keep the pixel formats consistent, but it means that PNG's lose their transparency. We should consider a more robust solution if a need arises + ] + + let framerate = this.probe_info.framerate + if (this.clip.framerate) { + const { fps } = this.clip.framerate + framerate = fps + if (this.clip.framerate.smooth) { + video_input_filters.push(`minterpolate=fps=${fps}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1`) + // video_input_filters.push(`minterpolate='mi_mode=mci:mc_mode=aobmc:vsbmc=1:fps=${fps}'`) + } else { + video_input_filters.push(`fps=${fps}`) + } + } + return { + id: this.clip.id, + source: this.clip.source, + start_at: this.start_at, + trim_start: this.clip_trim_start, + duration: this.clip_duration, + timeline_data: this.timeline_data, + geometry: this.computed_geometry!, + framerate, + video_input_filters, + audio_input_filters: this.audio_input_filters, + overlay_filter: `overlay=x=${this.computed_geometry!.x}:y=${this.computed_geometry!.y}:eof_action=pass`, + probe_info: this.probe_info, + } + } +} diff --git a/lib/builder/clip_sample.ts b/lib/builder/clip_sample.ts new file mode 100644 index 0000000..97af31c --- /dev/null +++ b/lib/builder/clip_sample.ts @@ -0,0 +1,19 @@ +import type { TimelineClip } from '../timeline.ts' +import type { ClipInfo } from '../probe.ts' +import type * as parsed from '../parsers/template.ts' +import { ClipBuilderBase } from './clip_base.ts' + + +export class ClipSampleBuilder extends ClipBuilderBase { + public constructor(clip: parsed.MediaClipParsed, info: ClipInfo, public sample_frame: number) { + super(clip, info) + } + + protected override get_timing_start_at(timeline_data: TimelineClip) { + return 0 + } + protected override get_timing_trim_start(timeline_data: TimelineClip) { + return timeline_data.trim_start + this.sample_frame - timeline_data.start_at + } +} + diff --git a/lib/builder/clip_video.ts b/lib/builder/clip_video.ts new file mode 100644 index 0000000..c3f1a0c --- /dev/null +++ b/lib/builder/clip_video.ts @@ -0,0 +1,5 @@ +import { ClipBuilderBase } from './clip_base.ts' + +// we _may_ decide to refactor these into ClipVideoBuilder, ClipImageBuilder, ClipAudioBuilder +// and push the sample vs full output logic into the cmd builders above +export class ClipVideoBuilder extends ClipBuilderBase {} diff --git a/lib/builder/ffmpeg_base.ts b/lib/builder/ffmpeg_base.ts new file mode 100644 index 0000000..2701ec3 --- /dev/null +++ b/lib/builder/ffmpeg_base.ts @@ -0,0 +1,158 @@ +import * as errors from '../errors.ts' +import { Context } from '../context.ts' +import { relative_path } from '../util.ts' +import type * as inputs from '../template_input.ts' +import type * as parsed from '../parsers/template.ts' +import type { TimelineClip } from '../timeline.ts' +import type { ComputedGeometry } from '../geometry.ts' +import type { ClipInfo } from '../probe.ts' + +import { ClipBuilderBase } from './clip_base.ts' + +export interface ClipBuilderData { + id: string + source: string + start_at: number + trim_start: number + duration: number + timeline_data: TimelineClip + framerate: number + video_input_filters: string[] + audio_input_filters: string[] + overlay_filter: string + probe_info: ClipInfo + geometry: ComputedGeometry +} + +interface FfmpegInstructions { + total_duration: number | undefined + clips: Record +} + +export interface RenderData { + total_duration: number | undefined + clips: Record +} + +export abstract class FfmpegBuilderBase { + protected complex_filter_inputs: string[] = [] + protected complex_filter_overlays: string[] = [] + protected audio_links: string[] = [] + private ffmpeg_inputs: string[] = [] + private last_link: string | undefined = undefined + private verbosity_flag = 'error' + private input_index = 0 + private clip_data: ClipBuilderData[] = [] + private total_duration: number | undefined = undefined + + private output_framerate: undefined | number = undefined + + public abstract get_output_file(): string + + protected abstract get_vframe_flags(): string[] + + protected abstract input_audio(data: ClipBuilderData, complex_filter_inputs: string[], audio_links: string[], input_index: number): void + + protected abstract map_audio(complex_filter: string[]): string[] + + public abstract clip_builder(clip: parsed.MediaClipParsed, info: ClipInfo): ClipBuilderBase + + public serialize(): RenderData { + return { + total_duration: this.total_duration, + clips: this.clip_data.reduce((record, clip) => { + record[clip.id] = clip + return record + }, {} as Record) + } + } + + public constructor(protected context: Context) { + if (context.debug) { + this.verbosity_flag = 'debug' + } + } + + public clip_count() { return this.clip_data.length } + + public background_cmd(background_width: number, background_height: number, total_duration?: number, background_color?: string) { + background_color ??= 'black' + const link = '[base]' + const filter_inputs = [ + `color=s=${background_width}x${background_height}`, + `color=${background_color}`, + ] + this.total_duration = total_duration + if (total_duration !== undefined) filter_inputs.push(`duration=${total_duration}`) + // const filter_input = `color=s=${background_width}x${background_height}:color=${background_color}:duration=${total_duration}` + this.complex_filter_inputs.push(`${filter_inputs.join(':')}${link}`) + this.last_link = link + } + + public clip(clip_builder: ClipBuilderBase) { + const data = clip_builder.build() + this.clip_data.push(data) + switch(data.probe_info.type) { + case 'video': + this.ffmpeg_inputs.push( + '-ss', data.trim_start.toString(), + '-t', data.duration.toString(), + '-i', data.source, + ) + break + case 'image': + this.ffmpeg_inputs.push( + '-framerate', data.framerate.toString(), + '-loop', '1', + '-t', data.duration.toString(), + '-i', data.source + ) + break + case 'audio': + throw new errors.InputError('audio file type unsupported') + default: + throw new Error(`unknown clip type ${data.probe_info.type}`) + } + const current_link = `[v_out_${data.id}]` + this.complex_filter_inputs.push(`[${this.input_index}:v] ${data.video_input_filters.join(', ')} [v_in_${data.id}]`) + this.complex_filter_overlays.push(`${this.last_link}[v_in_${data.id}] ${data.overlay_filter} ${current_link}`) + this.last_link = current_link + + this.input_audio(data, this.complex_filter_inputs, this.audio_links, this.input_index) + + this.output_framerate = Math.max(this.output_framerate ?? 0, data.framerate) + + this.input_index++ + } + + build() { + if (this.last_link === undefined) throw new Error('at least one filter must be specified') + const complex_filter = [...this.complex_filter_inputs, ...this.complex_filter_overlays] + + const map_audio_flags = this.map_audio(complex_filter) + + return [ + 'ffmpeg', + '-loglevel', this.verbosity_flag, + ...this.ffmpeg_inputs, + ...this.get_vframe_flags(), + '-filter_complex', complex_filter.join(';\n'), + '-r', (this.output_framerate ?? 60).toString(), + '-map', this.last_link, + ...map_audio_flags, + '-bitexact', + this.get_output_file(), + '-y' + ] + } + + async write_ffmpeg_cmd(filepath: string) { + const ffmpeg_cmd = this.build() + const cmd_str = ffmpeg_cmd + .map((c) => c.toString()) + .map((c) => (/[ \/]/.test(c) ? `"${c}"` : c)) + .join(' \\\n ') + await Deno.writeTextFile(filepath, cmd_str, { mode: 0o777 }) + this.context.logger.info(`Saved ffmpeg command to ${relative_path(filepath)}`) + } +} diff --git a/lib/builder/ffmpeg_sample.ts b/lib/builder/ffmpeg_sample.ts new file mode 100644 index 0000000..2b61975 --- /dev/null +++ b/lib/builder/ffmpeg_sample.ts @@ -0,0 +1,56 @@ +import * as path from '@std/path' +import { parse_duration } from '../parsers/duration.ts' +import { Context } from '../context.ts' +import { relative_path } from '../util.ts' +import type * as inputs from '../template_input.ts' +import type * as parsed from '../parsers/template.ts' +import type { ClipInfo } from '../probe.ts' + +import { FfmpegBuilderBase } from './ffmpeg_base.ts' +import { ClipBuilderBase } from './clip_base.ts' +import { type ClipBuilderData } from './ffmpeg_base.ts' +import { ClipSampleBuilder } from './clip_sample.ts' + +export class FfmpegSampleBuilder extends FfmpegBuilderBase { + protected get_vframe_flags() { + return [ + '-frames:v', '1', + '-update', '1', + ] + } + protected sample_frame: number + + public constructor(context: Context) { + super(context) + this.sample_frame = parse_duration(context, context.template.preview) + } + public clip_builder(clip: parsed.MediaClipParsed, info: ClipInfo) { return new ClipSampleBuilder(clip, info, this.sample_frame) } + + protected input_audio(data: ClipBuilderData, complex_filter_inputs: string[], audio_links: string[], input_index: number) {} + protected map_audio(complex_filter: string[]) { return [] } + + public override background_cmd(background_width: number, background_height: number, total_duration?: number, background_color?: string) { + return super.background_cmd(background_width, background_height, undefined, background_color) + } + + public override clip(clip_builder: ClipBuilderBase) { + const data = clip_builder.build() + // ignore clips that start after or finish before the preview frame + // console.log(data.id) + // console.log(' data.start_at > this.sample_frame', data.start_at > this.sample_frame ) + // console.log(' data.start_at + data.duration < this.sample_frame', data.timeline_data.start_at, data.timeline_data.start_at + data.duration, '<', this.sample_frame, (data.timeline_data.start_at + data.duration) < this.sample_frame) + const not_present_in_sample_frame = data.timeline_data.start_at > this.sample_frame || (data.timeline_data.start_at + data.duration) < this.sample_frame + // console.log('not_present_in_sample_frame', not_present_in_sample_frame) + if (data.duration === 0) { + return super.clip(clip_builder) + } else if (not_present_in_sample_frame) { + return + } else { + return super.clip(clip_builder) + } + } + + public get_output_file() { + return path.join(this.context.output_folder, 'preview.jpg') + } +} diff --git a/lib/builder/ffmpeg_video.ts b/lib/builder/ffmpeg_video.ts new file mode 100644 index 0000000..80e4920 --- /dev/null +++ b/lib/builder/ffmpeg_video.ts @@ -0,0 +1,38 @@ +import * as path from '@std/path' +import { relative_path } from '../util.ts' +import type * as parsed from '../parsers/template.ts' +import type { ClipInfo } from '../probe.ts' + +import { FfmpegBuilderBase, type ClipBuilderData } from './ffmpeg_base.ts' +import { ClipBuilderBase } from './clip_base.ts' +import { ClipVideoBuilder } from './clip_video.ts' + +export class FfmpegVideoBuilder extends FfmpegBuilderBase { + protected get_vframe_flags() { return [] } + public clip_builder(clip: parsed.MediaClipParsed, info: ClipInfo) { return new ClipVideoBuilder(clip, info) } + + protected input_audio(data: ClipBuilderData, complex_filter_inputs: string[], audio_links: string[], input_index: number) { + if (data.probe_info.has_audio) { + complex_filter_inputs.push(`[${input_index}:a] ${data.audio_input_filters.join(', ')} [a_in_${data.id}]`) + audio_links.push(`[a_in_${data.id}]`) + } + } + + protected map_audio(complex_filter: string[]) { + const map_audio_flags = [] + if (this.audio_links.length === 0) { + // do not include audio + } else if (this.audio_links.length === 1) { + map_audio_flags.push('-map', this.audio_links[0]) + } else { + complex_filter.push(`${this.audio_links.join('')} amix=inputs=${this.audio_links.length} [audio]`) + map_audio_flags.push('-map', '[audio]') + } + + return map_audio_flags + } + + public get_output_file() { + return path.join(this.context.output_folder, 'output.mp4') + } +} diff --git a/lib/canvas/font.ts b/lib/canvas/font.ts new file mode 100644 index 0000000..36e6fbc --- /dev/null +++ b/lib/canvas/font.ts @@ -0,0 +1,227 @@ +import * as path from '@std/path' +import * as skia_canvas from '@gfx/canvas' +// @deno-types="@types/culori" +import * as culori from 'culori' +import { compute_size } from '../geometry.ts' +import type { Context } from '../context.ts' +import type { TextClipParsed, MediaClipParsed } from '../parsers/template.ts' + +// first in the list gets highest priority +const PRIORITIED_FONT_FAMILIES = [ + 'arial', + 'roboto', + 'helevetica', + 'courier', + 'comic sans', + 'verdana', + 'georgia', + 'garamond', + 'trebuchet', + 'calibri', +] + + +function auto_wrap_text( + context: Context, + canvas_context: skia_canvas.CanvasRenderingContext2D, + text_clip: TextClipParsed +) { + const max_size = compute_size(context, text_clip.layout) + + const text_lines_wrapped: string[] = [] + + for (const line of text_clip.text.split('\n')) { + let current_line = '' + const wrapped: string[] = [] + for (const word of line.split(' ')) { + // Measure the width of the current line plus the word to see if it fits within max_width + const test_line = current_line ? `${current_line} ${word}` : word; + const metrics = canvas_context.measureText(test_line); + + if (metrics.width <= max_size.width) { + // If it fits, add the word to the current line + current_line = test_line; + } else { + // If it doesn't fit, push the current line and start a new line + if (current_line === '') { + current_line = ' ' // canvas doesnt play nice with empty strings + } + wrapped.push(current_line); + current_line = word; // Start new line with current word + } + } + + // Push the last line if any + if (current_line) { + wrapped.push(current_line); + } + + text_lines_wrapped.push(...wrapped) + } + + return text_lines_wrapped +} + +async function create_text_image( + context: Context, + text_clip: TextClipParsed +): Promise { + const text_assets_folder = path.join(context.output_folder, 'text_assets') + await Deno.mkdir(text_assets_folder, { recursive: true }) + const { font } = text_clip + const { background_color, border_radius, padding } = font + const padding_horizontal = padding.left + padding.right + const padding_vertical = padding.top + padding.bottom + if (padding_horizontal || padding_vertical) { + throw new Error('unimplemented') + } + + const {width: max_width, height: max_height} = compute_size(context, text_clip.layout) + const text_clip_input = context.template_input.captions?.[text_clip.id] + if (text_clip_input === undefined) throw new Error(`unexpected code path. Input clip ${text_clip.id} does not exist`) + + const CUSTOM_FONT_ALIAS = 'custom-font-alias' + let font_buffer: Uint8Array | undefined + if (font.family) { + const font_path = path.resolve(context.cwd, font.family) + font_buffer = await Deno.readFile(font_path) + skia_canvas.Fonts.register(font_buffer, CUSTOM_FONT_ALIAS) + } + const text_color = culori.parse(font.color) + if (!text_color) { + throw new Error(`Failed to parse font color ${font.color}`) + } + const canvas_instance = skia_canvas.createCanvas(max_width, max_height) + const canvas_context = canvas_instance.getContext('2d') + + if (font.family) { + canvas_context.font = `${text_clip.font.size}px ${CUSTOM_FONT_ALIAS}` + } else { + + const ordered_font_families = skia_canvas.Fonts.families + .filter(family => PRIORITIED_FONT_FAMILIES.find(f => family.toLowerCase().includes(f))) + .sort((a, b) => { + const family_a = a.toLowerCase() + const family_b = b.toLowerCase() + const index_a = PRIORITIED_FONT_FAMILIES.findIndex(name => family_a.includes(name)) + const index_b = PRIORITIED_FONT_FAMILIES.findIndex(name => family_b.includes(name)) + + if (index_a !== -1 && index_b !== -1) { + // If both are known strings, sort them based on their order in knownStrings + if (index_a === index_b) { + // small little rule, if two fonts share the same family identifier, lets prefer fonts with shorter names, since those are typically the default + return family_a.length - family_b.length + } + return index_a - index_b; + } else if (index_a !== -1) { + // If a is a known string and b is not, a should come first + return -1; + } else if (index_b !== -1) { + // If b is a known string and a is not, b should come first + return 1; + } else { + // If neither are known strings, maintain their relative order + return 0; + } + }) + const default_font = ordered_font_families[0] + canvas_context.font = `${text_clip.font.size}px ${default_font}` + } + + canvas_context.textAlign = font.align + // canvas_context.letterSpacing = '10px' + const metrics = canvas_context.measureText(text_clip.text) + const formatted_text = auto_wrap_text(context, canvas_context, text_clip) + + if (background_color) { + let line_height = 0 + canvas_context.fillStyle = background_color + for (const formatted_line of formatted_text) { + const metrics = canvas_context.measureText(formatted_line) + let x = 0 + canvas_context.textAlign = font.align + if (font.align === 'left') { + x = 0 + } + if (font.align === 'right') { + x = Math.max(max_width - metrics.width, 0) + } + if (font.align === 'center') { + x = (max_width) / 2 - metrics.width / 2 + } + const y = line_height + line_height = y + metrics.emHeightAscent + canvas_context.beginPath() + canvas_context.roundRect(x, y, metrics.width, metrics.emHeightAscent + metrics.emHeightDescent, border_radius) + canvas_context.fill() + } + canvas_context.beginPath() + } + + if (font.outline_size) { + canvas_context.lineWidth = font.outline_size + canvas_context.strokeStyle = font.outline_color + + let line_height = 0 + for (const formatted_line of formatted_text) { + const metrics = canvas_context.measureText(formatted_line) + let x = 0 + canvas_context.textAlign = font.align + if (font.align === 'left') { + x = 0 + } + if (font.align === 'right') { + x = metrics.width + } + if (font.align === 'center') { + x = (max_width) / 2 + } + const y = line_height + metrics.emHeightAscent + line_height = y + canvas_context.strokeText(formatted_line, x, y) + } + } + + canvas_context.lineWidth = font.size + canvas_context.fillStyle = font.color + let line_height = 0 + for (const formatted_line of formatted_text) { + const metrics = canvas_context.measureText(formatted_line) + let x = 0 + canvas_context.textAlign = font.align + if (font.align === 'left') { + x = 0 + } + if (font.align === 'right') { + x = metrics.width + } + if (font.align === 'center') { + x = (max_width) / 2 + } + const y = line_height + metrics.emHeightAscent + line_height = y + canvas_context.fillText(formatted_line, x, y) + canvas_context.stroke() + } + + const text_image_asset = path.resolve(text_assets_folder, text_clip.id + '.png') + canvas_instance.save(text_image_asset) // NOTE this is a synchronous file write + context.logger.info(`Generated text asset for clip ${text_clip.id}`) + + return { + type: 'media', + // TODO, internally prefix media clips w/ "clip:" to ensure there are no overwrites with the id here + id: text_clip.id, + source: text_image_asset, + layout: { + ...text_clip.layout, + relative_to: text_clip.layout.relative_to ?? text_clip.id, + }, + duration: text_clip.duration, + keypoints: [], + volume: '100%', + speed: '100%', + } +} + +export { create_text_image } diff --git a/lib/cli.ts b/lib/cli.ts index efa2c63..c577887 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -1,182 +1,252 @@ -import * as path from 'https://deno.land/std@0.91.0/path/mod.ts' -import * as fs from 'https://deno.land/std@0.91.0/fs/mod.ts' -import * as flags from 'https://deno.land/std@0.91.0/flags/mod.ts' -import * as yaml from 'https://deno.land/std@0.91.0/encoding/yaml.ts' -import { opn } from "https://denopkg.com/hashrock/deno-opn/opn.ts"; +import * as path from '@std/path' +import * as fs from '@std/fs' +import * as flags from '@std/flags' +import * as yaml from '@std/yaml' +// import open from 'open' +import {open} from '@opensrc/deno-open' +// import { open } from 'https://deno.land/x/open@v0.0.2/index.ts' +// // import open from 'npm:open' import * as errors from './errors.ts' +import { parse_template } from './parsers/template.ts' +import { render_video, render_sample_frame } from './mod.ts' import { Logger } from './logger.ts' -import { render_video, render_sample_frame, get_output_locations } from './mod.ts' -import type { Template, RenderOptions, FfmpegProgress } from './mod.ts' - -const encoder = new TextEncoder() -const decoder = new TextDecoder() - -// TODO DRY this up when fonts are added to clips -async function create_loading_placeholder_preview(output_path: string) { - const proc = Deno.run({ - cmd: [ - 'magick', - '-size', - '500x500', - 'xc:', - '-gravity', - 'Center', - '-pointsize', - '24', - '-annotate', - '0', - 'Loading Preview...', - output_path, - ], - }) - await Deno.mkdir(path.dirname(output_path), { recursive: true }) - const result = await proc.status() - if (result.code !== 0) { - // console.error(await proc.output()) - throw new Error('Magick exception') - } - return proc -} +import { InstanceContext } from './context.ts' +import type * as inputs from './template_input.ts' +import type { ContextOptions } from './context.ts' -function construct_output_folder(args: flags.Args, template_filepath: string) { - const { dir, name } = path.parse(template_filepath) - const render_ext = args['preview'] ? '.jpg' : '.mp4' - return path.join(dir, 'ffmpeg-templates-projects', dir, `${name}`) + +export type Render = (context: {}) => inputs.Template +export interface ScriptModule { + render: Render } -function human_readable_duration(duration_seconds: number): string { - if (duration_seconds / 60 >= 100) return `${(duration_seconds / 60 / 60).toFixed(1)}h` - else if (duration_seconds >= 100) return `${(duration_seconds / 60).toFixed(1)}m` - else return `${duration_seconds.toFixed(0)}s` +abstract class Runner { + filepath!: string + args!: ReturnType + instance!: InstanceContext + context_options!: ContextOptions + + constructor(opts: { + filepath: string + args: ReturnType + instance: InstanceContext + context_options: ContextOptions + }) { + Object.assign(this, opts) + } + + abstract matches(filepath: string): boolean + + abstract run(opts: { + filepath: string + args: ReturnType + instance: InstanceContext + context_options: ContextOptions + }): Promise } -let writing_progress_bar = false -let queued_progress: { execution_start_time: number; percentage: number } | null = null -async function progress_callback(execution_start_time: number, percentage: number) { - if (writing_progress_bar) { - queued_progress = { execution_start_time, percentage } - return +export class YamlRunner extends Runner { + + matches(filepath: string) { + return filepath.endsWith('.yml') || filepath.endsWith('.yaml') } - writing_progress_bar = true - const console_width = await Deno.consoleSize(Deno.stdout.rid).columns - // const unicode_bar = '\u2588' - const unicode_bar = '#' - const execution_time_seconds = (performance.now() - execution_start_time) / 1000 - const prefix = `${human_readable_duration(execution_time_seconds).padStart(4)} [` - const suffix = `] ${(percentage * 100).toFixed(1)}%` - const total_bar_width = console_width - prefix.length - suffix.length - const bar = unicode_bar.repeat(Math.min(percentage, 1) * total_bar_width) - const message = `\r${prefix}${bar.padEnd(total_bar_width, '-')}${suffix}` - await Deno.writeAll(Deno.stdout, encoder.encode(message)) - writing_progress_bar = false - if (queued_progress) { - const args = queued_progress - queued_progress = null - progress_callback(args.execution_start_time, args.percentage) + + run: Runner['run'] = async ({ filepath, args, instance, context_options }) => { + const template_filepath = filepath + if (args.preview) { + // instance.launch_server() } -} -async function read_template(template_filepath: string): Promise