diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ed4ff5..cf678cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,29 +26,14 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: true - name: Setup Zig uses: mlugg/setup-zig@v1 with: version: 0.14.0 - - name: Setup FFmpeg (macOS) - if: runner.os == 'macOS' - run: brew install ffmpeg pkgconf coreutils - - - name: Setup FFmpeg (Linux) - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev pkg-config - - - name: Setup FFmpeg (Windows) - if: runner.os == 'Windows' - run: | - choco install -y ffmpeg-shared - set INCLUDE=%INCLUDE%;"C:\ProgramData\chocolatey\lib\ffmpeg-shared\tools\ffmpeg-7.1-full_build-shared\include" - set LIB=%LIB%;"C:\ProgramData\chocolatey\lib\ffmpeg-shared\tools\ffmpeg-7.1-full_build-shared\lib" - - name: Build run: | zig build -Doptimize=ReleaseFast -Dstrip=true diff --git a/.gitignore b/.gitignore index b3d5679..04fe3ce 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ zig-out/ /docgen_tmp/ images/ videos/ -bin/ **/*.DS_Store test_output/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..0f8761d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,8 @@ +[submodule "vendor/ffmpeg"] + path = vendor/ffmpeg + url = git@github.com:seatedro/ffmpeg.git + branch = main +[submodule "vendor/nv-codec-headers"] + path = vendor/nv-codec-headers + url = https://github.com/seatedro/nv-codec-headers.git + branch = master diff --git a/LICENSE b/LICENSE index 251b3b1..d60c31a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,340 @@ -MIT License - -Copyright (c) 2024 seatedro - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. diff --git a/build.zig b/build.zig index 35b62ee..4ccbe89 100644 --- a/build.zig +++ b/build.zig @@ -14,21 +14,21 @@ const BuildOptions = struct { libglyph: *Module, stb: *Module, term: *Module, - libav: *Module, - video: *Module, + libav: ?*Module, + video: ?*Module, img: *Module, version: Version, - av_root: []const u8, + av_enabled: bool, }; pub fn build(b: *std.Build) !void { const optimize = b.standardOptimizeOption(.{}); const strip = b.option(bool, "strip", "Omit debug information") orelse false; - const av_root = b.option([]const u8, "av_root", "Path to ffmpeg root") orelse "C:/ProgramData/chocolatey/lib/ffmpeg-shared/tools/ffmpeg-7.1-full_build-shared"; const target = b.standardTargetOptions(.{}); const dep_stb = b.dependency("stb", .{}); + const av_enabled = b.option(bool, "av", "Enable FFmpeg/AV support") orelse true; - const version = try Version.parse("1.0.11"); + const version = try Version.parse("1.1.0"); const stb_module = b.addModule("stb", .{ .root_source_file = b.path("vendor/stb.zig"), @@ -37,18 +37,26 @@ pub fn build(b: *std.Build) !void { }); stb_module.addIncludePath(dep_stb.path("")); stb_module.addCSourceFile(.{ .file = b.path("vendor/stb.c") }); - const av_module = b.addModule("av", .{ - .root_source_file = b.path("vendor/av.zig"), + + var av_module: ?*Module = null; + if (av_enabled) { + if (b.lazyDependency("ffmpeg", .{})) |ffmpeg_dep| { + av_module = ffmpeg_dep.module("av"); + } + } + const rescale_module = b.addModule("libglyphrescale", .{ + .root_source_file = b.path("src/rescale.zig"), .target = target, .optimize = optimize, }); - linkFfmpeg(b, target, av_module, av_root); + const libglyph = b.addModule("libglyph", .{ .root_source_file = b.path("src/core.zig"), .target = target, .optimize = optimize, .imports = &.{ .{ .name = "stb", .module = stb_module }, + .{ .name = "libglyphrescale", .module = rescale_module }, }, }); @@ -60,26 +68,36 @@ pub fn build(b: *std.Build) !void { .{ .name = "libglyph", .module = libglyph }, }, }); - const video_module = b.addModule("libglyphav", .{ - .root_source_file = b.path("src/video.zig"), - .target = target, - .optimize = optimize, - .imports = &.{ - .{ .name = "stb", .module = stb_module }, - .{ .name = "av", .module = av_module }, - .{ .name = "libglyph", .module = libglyph }, - .{ .name = "libglyphterm", .module = term_module }, - }, - }); + + var video_module: ?*Module = null; + if (av_enabled) { + video_module = b.addModule("libglyphav", .{ + .root_source_file = b.path("src/video.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "stb", .module = stb_module }, + .{ .name = "av", .module = av_module.? }, + .{ .name = "libglyph", .module = libglyph }, + .{ .name = "libglyphterm", .module = term_module }, + }, + }); + } + const image_module = b.addModule("libglyphimg", .{ .root_source_file = b.path("src/image.zig"), + .target = target, + .optimize = optimize, .imports = &.{ .{ .name = "stb", .module = stb_module }, - .{ .name = "av", .module = av_module }, .{ .name = "libglyph", .module = libglyph }, .{ .name = "libglyphterm", .module = term_module }, + .{ .name = "libglyphrescale", .module = rescale_module }, }, }); + if (av_enabled) { + image_module.addImport("av", av_module.?); + } const buildOpts = &BuildOptions{ .libglyph = libglyph, @@ -89,7 +107,7 @@ pub fn build(b: *std.Build) !void { .libav = av_module, .video = video_module, .version = version, - .av_root = av_root, + .av_enabled = av_enabled, }; try runZig( @@ -140,7 +158,27 @@ fn setupExecutable( exe.root_module.addImport("clap", clap.module("clap")); exe.root_module.addImport("libglyph", self.libglyph); exe.root_module.addImport("libglyphimg", self.img); - exe.root_module.addImport("libglyphav", self.video); + if (self.av_enabled) { + exe.root_module.addImport("libglyphav", self.video.?); + switch (target.result.os.tag) { + .macos => { + exe.linkFramework("CoreFoundation"); + exe.linkFramework("CoreMedia"); + exe.linkFramework("CoreVideo"); + exe.linkFramework("VideoToolbox"); + }, + .linux => { + exe.linkSystemLibrary("va"); + exe.linkSystemLibrary("cuda"); + exe.linkSystemLibrary("cudart"); + }, + .windows => { + // nvenc uses dynamic loading on Windows, no need to link CUDA libraries + exe.linkSystemLibrary("ws2_32"); // Windows sockets + }, + else => {}, + } + } exe.root_module.addImport("libglyphterm", self.term); return exe; @@ -166,65 +204,14 @@ fn setupTest( unit_test.root_module.addImport("clap", clap.module("clap")); unit_test.root_module.addImport("libglyph", self.libglyph); unit_test.root_module.addImport("libglyphimg", self.img); - unit_test.root_module.addImport("libglyphav", self.video); + if (self.av_enabled) { + unit_test.root_module.addImport("libglyphav", self.video.?); + } unit_test.root_module.addImport("libglyphterm", self.term); return unit_test; } -fn copyDlls(b: *std.Build, target: std.Build.ResolvedTarget, av_root: []const u8) void { - if (target.result.os.tag != .windows) return; - - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const ffmpeg_bin_path = std.fmt.bufPrint(&path_buf, "{s}/bin", .{av_root}) catch |err| { - std.debug.print("Failed to format ffmpeg bin path: {any}\n", .{err}); - return; - }; - - // We can't walk the directory due to permissions with winget packages. - // Instead, we'll copy the DLLs we know we need by name. - // These names are for FFmpeg 7.x. - const dlls = [_][]const u8{ - "avcodec-61.dll", - "avformat-61.dll", - "avutil-59.dll", - "swresample-5.dll", - "swscale-8.dll", - }; - - for (dlls) |dll_name| { - var dll_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const dll_path = std.fmt.bufPrint(&dll_path_buf, "{s}/{s}", .{ ffmpeg_bin_path, dll_name }) catch |err| { - std.debug.print("Failed to format src path: {any}\n", .{err}); - return; - }; - b.getInstallStep().dependOn(&b.addInstallBinFile(.{ .cwd_relative = dll_path }, dll_name).step); - } -} - -fn linkFfmpeg(b: *std.Build, target: std.Build.ResolvedTarget, lib: *Module, av_root: []const u8) void { - if (target.result.os.tag == .windows) { - const ffmpeg_include_path = std.fs.path.join(b.allocator, &.{ av_root, "include" }) catch unreachable; - const ffmpeg_lib_path = std.fs.path.join(b.allocator, &.{ av_root, "lib" }) catch unreachable; - defer b.allocator.free(ffmpeg_include_path); - defer b.allocator.free(ffmpeg_lib_path); - - lib.addLibraryPath(.{ .cwd_relative = ffmpeg_lib_path }); - lib.addIncludePath(.{ .cwd_relative = ffmpeg_include_path }); - lib.linkSystemLibrary("avformat", .{}); - lib.linkSystemLibrary("avcodec", .{}); - lib.linkSystemLibrary("avutil", .{}); - lib.linkSystemLibrary("swscale", .{}); - lib.linkSystemLibrary("swresample", .{}); - } else { - lib.linkSystemLibrary("libavformat", .{ .use_pkg_config = .force }); - lib.linkSystemLibrary("libavcodec", .{ .use_pkg_config = .force }); - lib.linkSystemLibrary("libavutil", .{ .use_pkg_config = .force }); - lib.linkSystemLibrary("libswscale", .{ .use_pkg_config = .force }); - lib.linkSystemLibrary("libswresample", .{ .use_pkg_config = .force }); - } -} - fn runZig( self: *const BuildOptions, b: *std.Build, @@ -232,8 +219,6 @@ fn runZig( optimize: std.builtin.OptimizeMode, strip: bool, ) !void { - copyDlls(b, target, self.av_root); - const exe = try setupExecutable( self, b, @@ -282,6 +267,7 @@ fn buildOptionsModule(self: *const BuildOptions, b: *std.Build) *Module { var opts = b.addOptions(); opts.addOption(std.SemanticVersion, "version", self.version); + opts.addOption(bool, "av", self.av_enabled); const mod = opts.createModule(); return mod; diff --git a/build.zig.zon b/build.zig.zon index b9de790..4be0caf 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -11,6 +11,10 @@ .url = "git+https://github.com/nothings/stb.git?ref=master#f75e8d1cad7d90d72ef7a4661f1b994ef78b4e31", .hash = "1220c4fe5a4c4ebec402f5cdef08bc264b56fb07f259107d2b01ba8d416d88624b50", }, + .ffmpeg = .{ + .path = "vendor/ffmpeg", + .lazy = true, + }, }, .paths = .{ "", diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3aa1ef8 --- /dev/null +++ b/flake.lock @@ -0,0 +1,147 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1756542300, + "narHash": "sha256-tlOn88coG5fzdyqz6R93SQL5Gpq+m/DsWpekNFhqPQk=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "d7600c775f877cd87b4f5a831c28aa94137377aa", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1708161998, + "narHash": "sha256-6KnemmUorCvlcAvGziFosAVkrlWZGIc6UNT9GUYr0jQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "84d981bae8b5e783b3b548de505b22880559515f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-23.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "zig-overlay": "zig-overlay" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "zig-overlay": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1756555914, + "narHash": "sha256-7yoSPIVEuL+3Wzf6e7NHuW3zmruHizRrYhGerjRHTLI=", + "owner": "mitchellh", + "repo": "zig-overlay", + "rev": "d0df3a2fd0f11134409d6d5ea0e510e5e477f7d6", + "type": "github" + }, + "original": { + "owner": "mitchellh", + "repo": "zig-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..2715e38 --- /dev/null +++ b/flake.nix @@ -0,0 +1,86 @@ +# flake needed to make glyph run on nixOS +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + zig-overlay.url = "github:mitchellh/zig-overlay"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + zig-overlay, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ zig-overlay.overlays.default ]; + config = { + allowUnfree = true; + }; + }; + + zig = pkgs.zigpkgs."0.14.1"; + + # FFmpeg dependencies - only on Linux + ffmpegDeps = pkgs.lib.optionals pkgs.stdenv.isLinux [ + # VAAPI support + pkgs.libva + pkgs.libva-utils + pkgs.libdrm + + # NVENC support + pkgs.cudatoolkit + pkgs.cudaPackages.cuda_cudart + pkgs.nv-codec-headers + ]; + + glyph = pkgs.stdenv.mkDerivation { + pname = "glyph"; + version = "1.1.0"; + + src = self; # root of this flake + + nativeBuildInputs = [ + zig + pkgs.pkg-config + pkgs.makeWrapper + ]; + + buildInputs = ffmpegDeps; + + buildPhase = '' + zig build + ''; + + installPhase = '' + mkdir -p $out/bin + cp zig-out/bin/glyph $out/bin/ + + wrapProgram $out/bin/glyph \ + --prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath ffmpegDeps}" + ''; + }; + in + { + packages.default = glyph; + + devShells.default = pkgs.mkShell { + inputsFrom = [ glyph ]; + packages = ffmpegDeps; + + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath ffmpegDeps; + + # Additional environment variables from vendor/ffmpeg/shell.nix (Linux only) + PKG_CONFIG_PATH = pkgs.lib.optionalString pkgs.stdenv.isLinux "${pkgs.libva.dev}/lib/pkgconfig:${pkgs.libdrm.dev}/lib/pkgconfig:${pkgs.nv-codec-headers}/lib/pkgconfig"; + C_INCLUDE_PATH = pkgs.lib.optionalString pkgs.stdenv.isLinux "${pkgs.libva.dev}/include:${pkgs.libdrm.dev}/include:${pkgs.cudatoolkit}/include:${pkgs.nv-codec-headers}/include"; + CUDA_PATH = pkgs.lib.optionalString pkgs.stdenv.isLinux "${pkgs.cudatoolkit}"; + CUDA_ROOT = pkgs.lib.optionalString pkgs.stdenv.isLinux "${pkgs.cudatoolkit}"; + }; + } + ); +} diff --git a/readme.md b/readme.md index b4eee82..4e670e3 100644 --- a/readme.md +++ b/readme.md @@ -1,138 +1,152 @@ -# glyph - ascii from media +# glyph — a modern ascii renderer -converts images/video to ascii art +Converts images and videos into ASCII art. +![shinto-ascii](https://github.com/user-attachments/assets/a6676a76-3bdf-4a63-8629-e121a5943b7d) -## Dependencies -these dependencies are only for the `av` library to output videos. This will be opt-in in the future. +## Installing -#### Linux: +### Homebrew ```bash -sudo apt-get install libavutil-dev libavformat-dev libavcodec-dev libswscale-dev +brew install glyph ``` -#### MacOS: +### Build from source ```bash -brew install ffmpeg pkgconf +zig build -Doptimize=ReleaseFast ``` +The executable is placed at `./zig-out/bin`. -#### Windows: +To disable compiling ffmpeg and remove the libglyphav module: ```bash -choco install ffmpeg-shared +zig build -Doptimize=ReleaseFast -Dav=false ``` -## Installing +Run directly with: +```bash +zig build run -Doptimize=ReleaseFast -- [options] +``` -#### Homebrew +## Usage + +Basic usage (default Zig install path is `./zig-out/bin`): +``` +/path/to/glyph [options] +``` + +- `-h, --help`: Print help and exit +- `-v, --version`: Print version and exit +- `-i, --input `: Input media file (image or video) +- `-o, --output `: Output file (png/gif/mp4/… or .txt). Omit to render in terminal +- `-c, --color`: Use color ASCII characters +- `-n, --invert_color`: Invert color values +- `-a, --auto_adjust`: Auto-adjust brightness/contrast +- `-s, --scale `: Scale factor (default 1.0). Values >1 downscale (e.g., 2 halves size) +- ` --symbols `: Character set: `ascii` or `block` (default: ascii) +- `-e, --detect_edges`: Enable edge detection +- ` --sigma1 `: DoG sigma1 (default 0.5) +- ` --sigma2 `: DoG sigma2 (default 1.0) +- `-b, --brightness_boost `: Brightness multiplier (default 1.0) +- ` --full_characters`: Use full character spectrum +- ` --ascii_chars `: Custom characters (default: " .:-=+*%#@") +- ` --disable_sort`: Don’t sort `--ascii_chars` by size +- ` --block_size `: ASCII block size (default 8) +- ` --threshold_disabled`: Disable threshold +- ` --codec `: Encoder (e.g., `libx264`, `libx265`, `hevc_nvenc`, `h264_videotoolbox`) +- ` --keep_audio`: Keep input audio in video output +- ` --stretched`: Fit render to terminal window (stdout mode) +- `-f, --frame_rate `: Target fps for video output (default: input fps) +- `-m, --mode `: `ascii` or `pixels` (default: ascii) +- `-d, --dither `: `none`, `floydstein`, `bayer4`, `bayer8`, `bayer16` (default: none) +- ` --dither_levels `: Levels for ordered dither (default: 2) +- ` --fg <#rrggbb>`: Foreground color (pixel mode) +- ` --bg <#rrggbb>`: Background color (pixel mode) + +> To render to the terminal, omit `--output`. +> To save ASCII text, set an output with `.txt` extension. + +### FFmpeg Codec Support + +Glyph includes FFmpeg with support for both software and hardware-accelerated codecs: + +#### Software Codecs +- **x264** (`libx264`) - H.264 video encoder +- **x265** (`libx265`) - HEVC/H.265 video encoder (decoder only) +- **libmp3lame** - MP3 audio encoder +- **libvorbis** - Vorbis audio encoder + +#### Hardware Codecs (Platform-dependent) +- **NVIDIA NVENC** (`h264_nvenc`, `hevc_nvenc`) - Available on Windows/Linux with NVIDIA GPUs +- **VideoToolbox** (`h264_videotoolbox`, `hevc_videotoolbox`) - Available on macOS + +Use the `--codec` parameter to specify the encoder. For example: ```bash -brew install glyph +# Software encoding +glyph -i input.mp4 -o output.mp4 --codec libx264 + +# Hardware encoding (NVIDIA) +glyph -i input.mp4 -o output.mp4 --codec h264_nvenc + +# Hardware encoding (macOS) +glyph -i input.mp4 -o output.mp4 --codec h264_videotoolbox ``` -### build from source -`zig build -Doptimize=ReleaseFast` +## Examples -the above command builds an executable found at `./zig-out/bin` +### Image -if you want to just directly run the executable, run: +Basic usage: +```bash +glyph -i input.jpg -o output.png +``` -`zig build run -Doptimize=ReleaseFast -- [options]` +Text output: +```bash +glyph -i input.jpg -o output.txt +``` -see below for explanations for available options +Color: +```bash +glyph -i input.png -o output.png -c +``` -## Usage +Edge detection, color, downscale: +```bash +glyph -i input.jpeg -o output.png -s 4 -e -c +``` + +Dithering +```bash +glyph -i input.png -o output.png -a -s 2 --mode pixels --dither bayer8 --dither_levels 2 +``` + + +Terminal output: +```bash +glyph -i input.jpg -e -c -b 1.5 +``` + +### Video + +Encode MP4 with NVENC, keep audio: +```bash +glyph -i /path/to/input.mp4 -o ascii.mp4 --codec hevc_nvenc --keep_audio +``` + +Render in terminal (fit to terminal): +```bash +glyph -i /path/to/input.mp4 --stretched -c +``` + +Custom encoder options (after `--`): +```bash +glyph -i /path/to/input.mp4 -o ascii.mp4 -c --codec libx264 -- --preset fast --crf 20 +``` + +GIF output: +```bash +glyph -i /path/to/input.mp4 -o out.gif -s 2 -f 12 +``` -run the program with the following options (the default zig install directory is `./zig-out/bin`): - ``` - /path/to/glyph [options] - ``` -1. options: - - `-h, --help`: print the help message and exit - - `-i, --input `: specify the input media file path (local path/URL) (required) - - `-o, --output `: specify the output media file (txt/img/vid) (required) - - `-c, --color`: use color ascii characters (optional) - - `-n, --invert_color`: Inverts the color values (optional) - - `-s, --scale `: set the downscale or upscale factor (optional, default: 1) - - `-e, --detect_edges`: enable edge detection (optional) - - ` --sigma1 `: set the sigma1 value for DoG filter (optional, default: 0.3) - - ` --sigma2 `: set the sigma2 value for DoG filter (optional, default: 1.0) - - ` --dither floydstein`: enable dithering (currently only supports floydstein algorithm) - - `-b, --brightness_boost `: increase/decrease perceived brightness (optional, default: 1.0) - advanced options: - - ` --full_characters`: Uses all ascii characters in generated output. - - ` --ascii_chars `: Use what characters you want to use in the generated output. (default: " .:-=+*%@#") - - ` --disable_sort`: Prevents sorting of the ascii_chars by size. - - ` --block_size `: Set the size of the blocks. (default: 8) - - ` --threshold_disabled`: Disables the threshold. - - ` --codec `: Set the encoder codec like "libx264" or "hevc_videotoolbox". (default: searches for encoders on your machine) - - ` --keep_audio`: Preserves audio from input video. - - ` --stretched`: Resizes media to fit terminal window - - `-f, --frame_rate`: Target frame rate for video output (default: matches input fps) - ->To render on the terminal directly, just omit the output option. - ->To output to a text file, use the .txt extension when setting the output option - -2. examples: - - ### Image - - basic usage: - ```bash - glyph -i input.jpg -o output.png - ``` - - text file output: - ```bash - glyph -i input.jpg -o output.txt - ``` - - using color: - ```bash - glyph -i input.png -o output.png -c - ``` - - with edge detection, color, and custom downscale: - ```bash - glyph -i input.jpeg -o output.png -s 4 -e -c - ``` - - with brightness boost and url input: - ```bash - # bonus (this is a sweet wallpaper) - glyph -i "https://w.wallhaven.cc/full/p9/wallhaven-p9gr2p.jpg" -o output.png -e -c -b 1.5 - ``` - - terminal output (just omit the output option): - ```bash - glyph -i "https://w.wallhaven.cc/full/p9/wallhaven-p9gr2p.jpg" -e -c -b 1.5 - ``` - - ### Video - - with an input video (no urls allowed): - ```bash - glyph -i /path/to/input/video.mp4 -o ascii.mp4 --codec hevc_nvenc --keep_audio - ``` - - with an input video and rendering on the terminal (stretched to fit terminal): - ```bash - glyph -i /path/to/input/video.mp4 --stretched -c - ``` - - with input video and custom ffmpeg encoder options: - ```bash - glyph -i /path/to/input/video.mp4 -o ascii.mp4 -c --codec libx264 --keep_audio-- -preset fast -crf 20 - ``` - - with input video and custom ffmpeg encoder options: - ```bash - glyph -i /path/to/input/video.mp4 -o ascii.mp4 -c --codec libx264 --keep_audio-- -preset fast -crf 20 - ``` - -3. the program will generate an ascii art version of your input media and save it as a new media file. - -for images: output file needs to be a `.png` since i saw some weird issues with jpegs. - -4. using the long arguments on windows may or may not work. please use the short arguments for now. diff --git a/src/core.zig b/src/core.zig index 91719d0..1923305 100644 --- a/src/core.zig +++ b/src/core.zig @@ -1,6 +1,7 @@ const std = @import("std"); pub const bitmap = @import("bitmap.zig"); pub const stb = @import("stb"); +const rescale = @import("libglyphrescale"); pub const OutputType = enum { Stdout, @@ -32,7 +33,9 @@ pub const EdgeData = struct { direction: []f32, }; -pub const DitherType = enum { FloydSteinberg, None }; +pub const DitherType = enum { FloydSteinberg, Bayer4, Bayer8, Bayer16, None }; + +pub const RenderType = enum { Ascii, Pixels }; pub const CoreParams = struct { input: []const u8, @@ -56,6 +59,8 @@ pub const CoreParams = struct { keep_audio: bool, codec: ?[]const u8, dither: ?DitherType, + render: RenderType = .Ascii, + dither_levels: u8 = 2, bg_color: ?[3]u8, fg_color: ?[3]u8, @@ -104,66 +109,63 @@ pub fn selectAsciiChar(block_info: BlockInfo, args: CoreParams) []const u8 { if (clamped_brightness == 0) return " "; - const char_index = (clamped_brightness * args.ascii_chars.len) / 256; + const char_index = (clamped_brightness * args.ascii_info.len) / 256; const selected_char = args.ascii_info[@min(char_index, args.ascii_info.len - 1)]; return args.ascii_chars[selected_char.start .. selected_char.start + selected_char.len]; } +fn brightnessToAsciiIndex(brightness: u8, ascii_count: usize) usize { + return (@as(usize, brightness) * ascii_count) / 256; +} + +fn buildBrightnessLUT(allocator: std.mem.Allocator, ascii_count: usize) !struct { + map: []u16, + quant: []u8, +} { + var map = try allocator.alloc(u16, 256); + var quant = try allocator.alloc(u8, 256); + for (0..256) |b| { + const idx = brightnessToAsciiIndex(@intCast(b), ascii_count); + map[b] = @intCast(@min(idx, if (ascii_count == 0) 0 else ascii_count - 1)); + const q: u32 = @as(u32, @intCast(map[b])) * (256 / @as(u32, @intCast(@max(ascii_count, 1)))); + quant[b] = @intCast(@min(q, 255)); + } + return .{ .map = map, .quant = quant }; +} + pub fn rgbToGrayScale(allocator: std.mem.Allocator, img: Image) ![]u8 { const grayscale_img = try allocator.alloc(u8, img.width * img.height); errdefer allocator.free(grayscale_img); - for (0..img.height) |y| { - for (0..img.width) |x| { - const i = (y * img.width + x) * img.channels; - if (i + 2 >= img.width * img.height * img.channels) { - continue; // Skip if accessing out of bounds - } - const r = img.data[i]; - const g = img.data[i + 1]; - const b = img.data[i + 2]; - grayscale_img[y * img.width + x] = @intFromFloat((0.299 * @as(f32, @floatFromInt(r)) + - 0.587 * @as(f32, @floatFromInt(g)) + - 0.114 * @as(f32, @floatFromInt(b)))); - } + // Integer luma approximation: Y ≈ (77*R + 150*G + 29*B) >> 8 + const total = img.width * img.height; + var p: usize = 0; + var o: usize = 0; + while (o < total and p + 2 < img.data.len) : (o += 1) { + const r: u32 = img.data[p + 0]; + const g: u32 = img.data[p + 1]; + const b: u32 = img.data[p + 2]; + const y8: u8 = @intCast(((77 * r + 150 * g + 29 * b) >> 8) & 0xFF); + grayscale_img[o] = y8; + p += img.channels; } + return grayscale_img; } pub fn resizeImage(allocator: std.mem.Allocator, img: Image, new_width: usize, new_height: usize) !Image { - // Safety checks if (img.width == 0 or img.height == 0 or new_width == 0 or new_height == 0) { return error.InvalidDimensions; } - const total_pixels = new_width * new_height; - const buffer_size = total_pixels * img.channels; - - const scaled_data = try allocator.alloc(u8, buffer_size); - errdefer allocator.free(scaled_data); - - const result = stb.stbir_resize_uint8_linear( - img.data.ptr, - @intCast(img.width), - @intCast(img.height), - 0, - scaled_data.ptr, - @intCast(new_width), - @intCast(new_height), - 0, - @intCast(img.channels), + return rescale.resizeImage( + Image, + allocator, + img, + new_width, + new_height, + rescale.FilterType.Lanczos3, ); - - if (result == 0) { - return error.ImageResizeFailed; - } - - return Image{ - .data = scaled_data, - .width = new_width, - .height = new_height, - .channels = img.channels, - }; } pub fn autoBrightnessContrast( @@ -174,36 +176,29 @@ pub fn autoBrightnessContrast( const gray = try rgbToGrayScale(allocator, img); defer allocator.free(gray); - // Calculate histogram / frequency distribution var hist = [_]usize{0} ** 256; for (gray) |px| { hist[px] += 1; } - // Cumulative distribution var accumulator = [_]usize{0} ** 256; accumulator[0] = hist[0]; for (1..256) |i| { accumulator[i] = accumulator[i - 1] + hist[i]; } - // Locate points to clip const max = accumulator[255]; const clip_hist_count = @as(usize, @intFromFloat(@as(f32, @floatFromInt(max)) * clip_hist_percent / 100.0 / 2.0)); - // Locate left cut var min_gray: usize = 0; while (accumulator[min_gray] < clip_hist_count) : (min_gray += 1) {} - // Locate right cut var max_gray: usize = 255; while (accumulator[max_gray] >= (max - clip_hist_count)) : (max_gray -= 1) {} - // Calculate alpha and beta values const alpha = 255.0 / @as(f32, @floatFromInt(max_gray - min_gray)); const beta = -@as(f32, @floatFromInt(min_gray)) * alpha; - // Apply brightness and contrast adjustment const len = img.width * img.height * img.channels; var res = try allocator.alloc(u8, len); for (0..len) |i| { @@ -300,11 +295,9 @@ pub fn applySobelFilter(allocator: std.mem.Allocator, img: Image) !SobelFilter { var dir = try allocator.alloc(f32, img.width * img.height); errdefer allocator.free(dir); - // Initialize arrays to avoid uninitialized memory @memset(mag, 0); @memset(dir, 0); - // Skip edge processing if image is too small if (img.width < 3 or img.height < 3) { return SobelFilter{ .magnitude = mag, @@ -312,11 +305,9 @@ pub fn applySobelFilter(allocator: std.mem.Allocator, img: Image) !SobelFilter { }; } - // Handle bounds to prevent integer overflow const height_max = if (img.height > 0) img.height - 1 else 0; const width_max = if (img.width > 0) img.width - 1 else 0; - // Process the inner part of the image (skip borders) var y: usize = 1; while (y < height_max) : (y += 1) { var x: usize = 1; @@ -370,7 +361,6 @@ pub fn detectEdges(allocator: std.mem.Allocator, img: Image, detect_edges: bool, return null; } - // Handle invalid image dimensions if (img.width == 0 or img.height == 0) { const empty_u8 = try allocator.alloc(u8, 0); const empty_f32_1 = try allocator.alloc(f32, 0); @@ -386,7 +376,6 @@ pub fn detectEdges(allocator: std.mem.Allocator, img: Image, detect_edges: bool, const grayscale_img = try rgbToGrayScale(allocator, img); errdefer allocator.free(grayscale_img); - // Validate grayscale image if (grayscale_img.len == 0) { const empty_f32_1 = try allocator.alloc(f32, 0); const empty_f32_2 = try allocator.alloc(f32, 0); @@ -402,7 +391,7 @@ pub fn detectEdges(allocator: std.mem.Allocator, img: Image, detect_edges: bool, .data = grayscale_img, .width = img.width, .height = img.height, - .channels = 1, // Important fix: grayscale is 1 channel, not `img.channels` + .channels = 1, }, sigma1, sigma2); defer allocator.free(dog_img); @@ -412,7 +401,6 @@ pub fn detectEdges(allocator: std.mem.Allocator, img: Image, detect_edges: bool, .height = img.height, .channels = 1, }); - // No defer free as these are returned in EdgeData return .{ .grayscale = grayscale_img, @@ -442,22 +430,22 @@ pub fn calculateBlockInfo( const block_w = @min(args.block_size, out_w - x); const block_h = @min(args.block_size, out_h - y); + // Fast integer luma accumulation (fallback when integrals are not available in this scope) + // Y ≈ (77*R + 150*G + 29*B) >> 8 for (0..block_h) |dy| { - for (0..block_w) |dx| { - const ix = x + dx; - const iy = y + dy; - if (ix >= img.width or iy >= img.height) { - continue; - } + const iy = y + dy; + if (iy >= img.height) break; + var ix: usize = x; + var dx: usize = 0; + while (dx < block_w) : (dx += 1) { + if (ix >= img.width) break; const pixel_index = (iy * img.width + ix) * img.channels; - if (pixel_index + 2 >= img.width * img.height * img.channels) { - continue; - } - const r = img.data[pixel_index]; - const g = img.data[pixel_index + 1]; - const b = img.data[pixel_index + 2]; - const gray: u64 = @intFromFloat(@as(f32, @floatFromInt(r)) * 0.3 + @as(f32, @floatFromInt(g)) * 0.59 + @as(f32, @floatFromInt(b)) * 0.11); - info.sum_brightness += gray; + if (pixel_index + 2 >= img.width * img.height * img.channels) break; + const r: u32 = img.data[pixel_index]; + const g: u32 = img.data[pixel_index + 1]; + const b: u32 = img.data[pixel_index + 2]; + const y8: u64 = @intCast(((77 * r + 150 * g + 29 * b) >> 8) & 0xFF); + info.sum_brightness += y8; if (args.color) { info.sum_color[0] += r; info.sum_color[1] += g; @@ -469,9 +457,132 @@ pub fn calculateBlockInfo( info.sum_dir += edge_result.?.direction[edge_index]; } info.pixel_count += 1; + ix += 1; + } + } + + return info; +} + +const IntegralPlanes = struct { + w: usize, + h: usize, + // (w+1)*(h+1) summed area for easy border handling + y: []u64, + r: ?[]u64, + g: ?[]u64, + b: ?[]u64, +}; + +fn integralIndex(width: usize, x: usize, y: usize) usize { + // integral array width is (w+1) + return (y * (width + 1)) + x; +} + +fn buildIntegralPlanes(allocator: std.mem.Allocator, img: Image, need_color: bool) !IntegralPlanes { + const w = img.width; + const h = img.height; + const int_size = (w + 1) * (h + 1); + var Iy = try allocator.alloc(u64, int_size); + @memset(Iy, 0); + var Ir: ?[]u64 = null; + var Ig: ?[]u64 = null; + var Ib: ?[]u64 = null; + if (need_color) { + Ir = try allocator.alloc(u64, int_size); + Ig = try allocator.alloc(u64, int_size); + Ib = try allocator.alloc(u64, int_size); + @memset(Ir.?, 0); + @memset(Ig.?, 0); + @memset(Ib.?, 0); + } + + var y_row: usize = 1; + while (y_row <= h) : (y_row += 1) { + var row_sum_y: u64 = 0; + var row_sum_r: u64 = 0; + var row_sum_g: u64 = 0; + var row_sum_b: u64 = 0; + var x_col: usize = 1; + while (x_col <= w) : (x_col += 1) { + const px = x_col - 1; + const py = y_row - 1; + const pidx = (py * w + px) * img.channels; + const r: u32 = img.data[pidx + 0]; + const g: u32 = img.data[pidx + 1]; + const b: u32 = img.data[pidx + 2]; + const y8: u64 = @intCast(((77 * r + 150 * g + 29 * b) >> 8) & 0xFF); + row_sum_y += y8; + const above_y = Iy[integralIndex(w, x_col, y_row - 1)]; + Iy[integralIndex(w, x_col, y_row)] = row_sum_y + above_y; + if (need_color) { + row_sum_r += r; + row_sum_g += g; + row_sum_b += b; + const idx = integralIndex(w, x_col, y_row); + Ir.?[idx] = row_sum_r + Ir.?[integralIndex(w, x_col, y_row - 1)]; + Ig.?[idx] = row_sum_g + Ig.?[integralIndex(w, x_col, y_row - 1)]; + Ib.?[idx] = row_sum_b + Ib.?[integralIndex(w, x_col, y_row - 1)]; + } } } + return .{ .w = w, .h = h, .y = Iy, .r = Ir, .g = Ig, .b = Ib }; +} + +fn freeIntegralPlanes(allocator: std.mem.Allocator, ip: *IntegralPlanes) void { + allocator.free(ip.y); + if (ip.r) |buf| allocator.free(buf); + if (ip.g) |buf| allocator.free(buf); + if (ip.b) |buf| allocator.free(buf); +} + +fn rectSum(intw: usize, I: []const u64, x0: usize, y0: usize, x1: usize, y1: usize) u64 { + // rectangle [x0,x1) x [y0,y1), coordinates in pixel space + const ax = x0; + const ay = y0; + const bx = x1; + const by = y1; + const A = I[integralIndex(intw, ax, ay)]; + const B = I[integralIndex(intw, bx, ay)]; + const C = I[integralIndex(intw, ax, by)]; + const D = I[integralIndex(intw, bx, by)]; + return D + A - B - C; // inclusion-exclusion +} + +fn calculateBlockInfoFast( + ip: *const IntegralPlanes, + edge_result: ?EdgeData, + x: usize, + y: usize, + block_w: usize, + block_h: usize, + use_color: bool, +) BlockInfo { + var info = BlockInfo{ .sum_brightness = 0, .sum_color = .{ 0, 0, 0 }, .pixel_count = 0, .sum_mag = 0, .sum_dir = 0 }; + const x1 = @min(x + block_w, ip.w); + const y1 = @min(y + block_h, ip.h); + if (x1 <= x or y1 <= y) return info; + const area: u64 = @intCast((x1 - x) * (y1 - y)); + const sumY = rectSum(ip.w, ip.y, x, y, x1, y1); + info.sum_brightness = sumY; + info.pixel_count = area; + if (use_color) { + info.sum_color[0] = rectSum(ip.w, ip.r.?, x, y, x1, y1); + info.sum_color[1] = rectSum(ip.w, ip.g.?, x, y, x1, y1); + info.sum_color[2] = rectSum(ip.w, ip.b.?, x, y, x1, y1); + } + if (edge_result != null) { + var yy: usize = y; + while (yy < y1) : (yy += 1) { + var xx: usize = x; + while (xx < x1) : (xx += 1) { + const ei = yy * ip.w + xx; + info.sum_mag += edge_result.?.magnitude[ei]; + info.sum_dir += edge_result.?.direction[ei]; + } + } + } return info; } @@ -501,54 +612,61 @@ fn convertToAscii( h: usize, x: usize, y: usize, - ascii_char: []const u8, + glyph_mask: *const [8]u8, color: [3]u8, block_size: u8, color_enabled: bool, args: CoreParams, ) !void { - const bm = &(try bitmap.getCharSet(ascii_char)); + const bm = glyph_mask; const block_w = @min(block_size, w - x); const block_h = @min(block_size, img.len / (w * 3) - y); - // Define new colors + // Determine foreground/background triplets const background_color = if (args.bg_color != null) args.bg_color.? else [3]u8{ 21, 9, 27 }; // Blackcurrant const text_color = if (args.fg_color != null) args.fg_color.? else [3]u8{ 211, 106, 111 }; // Indian Red + const fg: [3]u8 = if (color_enabled) color else text_color; + const bg: [3]u8 = if (color_enabled) .{ 0, 0, 0 } else background_color; + + // Precompute a mask to limit to visible columns (top bits are leftmost) + const top_mask: u8 = if (block_w >= 8) 0xFF else @as(u8, 0xFF) << @intCast(8 - block_w); var dy: usize = 0; while (dy < block_h) : (dy += 1) { - var dx: usize = 0; - while (dx < block_w) : (dx += 1) { - const img_x = x + dx; - const img_y = y + dy; - - if (img_x < w and img_y < h) { - const idx = (img_y * w + img_x) * 3; - const shift: u3 = @intCast(7 - dx); - const bit: u8 = @as(u8, 1) << shift; - if ((bm[dy] & bit) != 0) { - // Character pixel: use the original color - if (color_enabled) { - img[idx] = color[0]; - img[idx + 1] = color[1]; - img[idx + 2] = color[2]; - } else { - img[idx] = text_color[0]; - img[idx + 1] = text_color[1]; - img[idx + 2] = text_color[2]; - } - } else { - // not a character pixel: set to black - if (color_enabled) { - img[idx] = 0; - img[idx + 1] = 0; - img[idx + 2] = 0; - } else { - img[idx] = background_color[0]; - img[idx + 1] = background_color[1]; - img[idx + 2] = background_color[2]; - } - } + const img_y = y + dy; + if (img_y >= h) break; + + const row_start = (img_y * w + x) * 3; + const row_end = row_start + @as(usize, block_w) * 3; + var row_slice = img[row_start..row_end]; + + if (bg[0] == 0 and bg[1] == 0 and bg[2] == 0) { + @memset(row_slice, 0); + } else { + var ofs: usize = 0; + while (ofs < row_slice.len) : (ofs += 3) { + row_slice[ofs + 0] = bg[0]; + row_slice[ofs + 1] = bg[1]; + row_slice[ofs + 2] = bg[2]; + } + } + + const m: u8 = bm[dy] & top_mask; + if (m == 0) continue; + + var col: usize = 0; + while (col < block_w) : (col += 1) { + const bit: u8 = @as(u8, 1) << @intCast(7 - col); + if ((m & bit) == 0) continue; + const start = col; + while (col + 1 < block_w and ((m & (@as(u8, 1) << @intCast(7 - (col + 1)))) != 0)) : (col += 1) {} + const end = col + 1; + var j: usize = start * 3; + const j_end = end * 3; + while (j < j_end) : (j += 3) { + row_slice[j + 0] = fg[0]; + row_slice[j + 1] = fg[1]; + row_slice[j + 2] = fg[2]; } } } @@ -566,7 +684,6 @@ pub fn generateAsciiArt( out_w = @max(out_w, 1); out_h = @max(out_h, 1); - // Dithering error var curr_ditherr = if (args.dither != .None) try allocator.alloc(u32, out_w) else @@ -578,57 +695,130 @@ pub fn generateAsciiArt( defer if (curr_ditherr) |buf| allocator.free(buf); defer if (next_ditherr) |buf| allocator.free(buf); - // Initialize error buffers to 0 if they exist if (curr_ditherr) |buf| @memset(buf, 0); if (next_ditherr) |buf| @memset(buf, 0); const ascii_img = try allocator.alloc(u8, out_w * out_h * 3); - @memset(ascii_img, 0); - var y: usize = 0; - while (y < out_h) : (y += args.block_size) { - if (args.dither != .None) { - @memset(next_ditherr.?, 0); - } - var x: usize = 0; - while (x < out_w) : (x += args.block_size) { - var block_info = calculateBlockInfo(img, edge_result, x, y, out_w, out_h, args); - - if (args.dither != .None) { - const avg_brightness: u8 = @as(u8, @intCast(block_info.sum_brightness / block_info.pixel_count)); - - const adjusted_brightness = @as(u32, @intCast(avg_brightness)) + - (if (curr_ditherr) |buf| buf[x / args.block_size] else 0); - - const clamped_brightness = @as(u8, @intCast(std.math.clamp(adjusted_brightness, 0, 255))); - - const closest = findClosestBrightness(clamped_brightness, args.ascii_chars, args.ascii_info); + var glyph_masks = try allocator.alloc([8]u8, args.ascii_info.len); + defer allocator.free(glyph_masks); + var i_mask: usize = 0; + while (i_mask < args.ascii_info.len) : (i_mask += 1) { + const info = args.ascii_info[i_mask]; + const slice = args.ascii_chars[info.start .. info.start + info.len]; + glyph_masks[i_mask] = try bitmap.getCharSet(slice); + } - switch (args.dither.?) { - DitherType.FloydSteinberg => floydSteinberg( - curr_ditherr.?, - next_ditherr.?, - @as(u8, @intCast(x)) / args.block_size, - @as(u8, @intCast(out_w)) / args.block_size, - closest[1], - ), - DitherType.None => {}, + const lut = try buildBrightnessLUT(allocator, args.ascii_info.len); + defer allocator.free(lut.map); + defer allocator.free(lut.quant); + + var ip = try buildIntegralPlanes(allocator, img, args.color); + defer freeIntegralPlanes(allocator, &ip); + + const use_parallel = args.dither == .None and (out_h / args.block_size) >= 2; + if (use_parallel) { + const blocks_per_col: usize = out_h / args.block_size; + var pool: std.Thread.Pool = undefined; + try pool.init(.{ .allocator = allocator, .track_ids = false }); + defer pool.deinit(); + + var wg: std.Thread.WaitGroup = .{}; + + const n_jobs = @max(1, std.Thread.getCpuCount() catch 1); + const stripes = @min(n_jobs, blocks_per_col); + const blocks_per_stripe = (blocks_per_col + stripes - 1) / stripes; + + const Worker = struct { + fn run( + ascii_img_out: []u8, + out_w_loc: usize, + out_h_loc: usize, + ip_ptr: *const IntegralPlanes, + edge_res: ?EdgeData, + args_loc: CoreParams, + glyph_masks_loc: []const [8]u8, + lut_map: []const u16, + y0_blocks: usize, + y_blocks: usize, + ) void { + const y_start: usize = y0_blocks * args_loc.block_size; + const y_end: usize = @min(out_h_loc, y_start + y_blocks * args_loc.block_size); + var yb: usize = y_start; + while (yb < y_end) : (yb += args_loc.block_size) { + var x: usize = 0; + while (x < out_w_loc) : (x += args_loc.block_size) { + const bw = @min(args_loc.block_size, out_w_loc - x); + const bh = @min(args_loc.block_size, y_end - yb); + const block_info = calculateBlockInfoFast(ip_ptr, edge_res, x, yb, bw, bh, args_loc.color); + const avg_brightness: usize = @intCast(block_info.sum_brightness / block_info.pixel_count); + const boosted: usize = @intFromFloat(@as(f32, @floatFromInt(avg_brightness)) * args_loc.brightness_boost); + const clamped: u8 = @intCast(std.math.clamp(boosted, 0, 255)); + const idx = @min(@as(usize, lut_map[clamped]), glyph_masks_loc.len - 1); + const avg_color = calculateAverageColor(block_info, args_loc); + convertToAscii(ascii_img_out, out_w_loc, out_h_loc, x, yb, &glyph_masks_loc[idx], avg_color, args_loc.block_size, args_loc.color, args_loc) catch {}; + } } - - block_info.sum_brightness = @as(u64, closest[0]) * block_info.pixel_count; } + }; - const ascii_char = selectAsciiChar(block_info, args); - const avg_color = calculateAverageColor(block_info, args); - - try convertToAscii(ascii_img, out_w, out_h, x, y, ascii_char, avg_color, args.block_size, args.color, args); + var s: usize = 0; + while (s < stripes) : (s += 1) { + const y0 = s * blocks_per_stripe; + if (y0 >= blocks_per_col) break; + const rem = blocks_per_col - y0; + const take = @min(blocks_per_stripe, rem); + pool.spawnWg(&wg, Worker.run, .{ ascii_img, out_w, out_h, &ip, edge_result, args, glyph_masks, lut.map, y0, take }); } + pool.waitAndWork(&wg); + } else { + var y: usize = 0; + while (y < out_h) : (y += args.block_size) { + if (args.dither != .None) { + @memset(next_ditherr.?, 0); + } + var x: usize = 0; + while (x < out_w) : (x += args.block_size) { + const bw = @min(args.block_size, out_w - x); + const bh = @min(args.block_size, out_h - y); + var block_info = calculateBlockInfoFast(&ip, edge_result, x, y, bw, bh, args.color); + + if (args.dither != .None) { + const avg_brightness: u8 = @as(u8, @intCast(block_info.sum_brightness / block_info.pixel_count)); + const boosted_u32: u32 = @intCast(@min(@as(usize, @intFromFloat(@as(f32, @floatFromInt(avg_brightness)) * args.brightness_boost)), 255)); + const adjusted_brightness = boosted_u32 + + (if (curr_ditherr) |buf| buf[x / args.block_size] else 0); + + const clamped_brightness = @as(u8, @intCast(std.math.clamp(adjusted_brightness, 0, 255))); + const q = lut.quant[clamped_brightness]; + const closest = .{ q, @as(u32, @intCast(@as(u32, clamped_brightness) - q)) }; + switch (args.dither.?) { + .FloydSteinberg => floydSteinberg( + curr_ditherr.?, + next_ditherr.?, + @as(u8, @intCast(x)) / args.block_size, + @as(u8, @intCast(out_w)) / args.block_size, + closest[1], + ), + .None, .Bayer4, .Bayer8, .Bayer16 => {}, + } + block_info.sum_brightness = @as(u64, closest[0]) * block_info.pixel_count; + } + // Non-dither or post-dither: choose glyph index + const avg_brightness2: usize = @intCast(block_info.sum_brightness / block_info.pixel_count); + const boosted2: usize = @intFromFloat(@as(f32, @floatFromInt(avg_brightness2)) * args.brightness_boost); + const clamped2: u8 = @intCast(std.math.clamp(boosted2, 0, 255)); + const idx2 = @min(@as(usize, lut.map[clamped2]), glyph_masks.len - 1); + const avg_color = calculateAverageColor(block_info, args); + try convertToAscii(ascii_img, out_w, out_h, x, y, &glyph_masks[idx2], avg_color, args.block_size, args.color, args); + } - if (curr_ditherr != null and next_ditherr != null) { - const t = curr_ditherr; - curr_ditherr = next_ditherr; - next_ditherr = t; - if (next_ditherr) |buf| @memset(buf, 0); + if (curr_ditherr != null and next_ditherr != null) { + const t = curr_ditherr; + curr_ditherr = next_ditherr; + next_ditherr = t; + if (next_ditherr) |buf| @memset(buf, 0); + } } } @@ -675,3 +865,224 @@ fn floydSteinberg( } next[x] += (quant_error * 5) >> 4; } + +const bayer4: [16]u8 = .{ + 0, 8, 2, 10, + 12, 4, 14, 6, + 3, 11, 1, 9, + 15, 7, 13, 5, +}; + +const bayer8: [64]u8 = .{ + 0, 48, 12, 60, 3, 51, 15, 63, + 32, 16, 44, 28, 35, 19, 47, 31, + 8, 56, 4, 52, 11, 59, 7, 55, + 40, 24, 36, 20, 43, 27, 39, 23, + 2, 50, 14, 62, 1, 49, 13, 61, + 34, 18, 46, 30, 33, 17, 45, 29, + 10, 58, 6, 54, 9, 57, 5, 53, + 42, 26, 38, 22, 41, 25, 37, 21, +}; + +fn bayerThreshold(x: usize, y: usize, kind: DitherType) f32 { + return switch (kind) { + .Bayer4 => blk: { + const n: usize = 4; + const v: u8 = bayer4[(y % n) * n + (x % n)]; + break :blk @as(f32, @floatFromInt(v)) / @as(f32, @floatFromInt(n * n)); + }, + .Bayer8 => blk: { + const n: usize = 8; + const v: u8 = bayer8[(y % n) * n + (x % n)]; + break :blk @as(f32, @floatFromInt(v)) / @as(f32, @floatFromInt(n * n)); + }, + .Bayer16 => blk: { + //TODO: proper 16x16 can be added later. + const n: usize = 8; + const v: u8 = bayer8[(y % n) * n + (x % n)]; + break :blk @as(f32, @floatFromInt(v)) / 64.0; + }, + else => 0.0, + }; +} + +pub fn generatePixelDither( + allocator: std.mem.Allocator, + img: Image, + args: CoreParams, +) ![]u8 { + const out_w = img.width; + const out_h = img.height; + const out = try allocator.alloc(u8, out_w * out_h * 3); + + const levels_u8: u8 = if (args.dither_levels < 2) 2 else args.dither_levels; + const levels_u16: u16 = levels_u8; + const denom_u16: u16 = if (levels_u8 <= 1) 1 else (levels_u8 - 1); + + const boost_q8_8: u16 = @intFromFloat(@as(f32, args.brightness_boost) * 256.0); + + var trow = try allocator.alloc(u8, out_w); + defer allocator.free(trow); + + var r_row = try allocator.alloc(u16, out_w); + defer allocator.free(r_row); + var g_row = try allocator.alloc(u16, out_w); + defer allocator.free(g_row); + var b_row = try allocator.alloc(u16, out_w); + defer allocator.free(b_row); + + const LANES: usize = 16; // tune per target + + var y: usize = 0; + while (y < out_h) : (y += 1) { + var n: usize = 0; + var scale: u8 = 0; + var use_bayer8 = false; + switch (args.dither orelse .None) { + .Bayer4 => { + n = 4; + scale = 16; + }, + .Bayer8 => { + n = 8; + scale = 4; + }, + .Bayer16 => { + n = 8; + scale = 4; + use_bayer8 = true; + }, + else => { + n = 0; + }, + } + if (n == 0) { + @memset(trow, 0); + } else { + const ymod = y & (n - 1); + var xi: usize = 0; + var base_row: [16]u8 = undefined; // enough for 8 or 4 + while (xi < n) : (xi += 1) { + const v: u8 = if (use_bayer8 or n == 8) + bayer8[ymod * 8 + xi] + else + bayer4[ymod * 4 + xi]; + base_row[xi] = @as(u8, v * scale); + } + var xrep: usize = 0; + while (xrep < out_w) : (xrep += n) { + const take = @min(n, out_w - xrep); + @memcpy(trow[xrep .. xrep + take], base_row[0..take]); + } + } + + var x_build: usize = 0; + while (x_build < out_w) : (x_build += 1) { + const in_idx = (y * out_w + x_build) * img.channels; + var r8: u16 = img.data[in_idx + 0]; + var g8: u16 = img.data[in_idx + 1]; + var b8: u16 = img.data[in_idx + 2]; + if (args.invert_color) { + r8 = 255 - r8; + g8 = 255 - g8; + b8 = 255 - b8; + } + r_row[x_build] = @intCast(@min((@as(u32, r8) * boost_q8_8) >> 8, 255)); + g_row[x_build] = @intCast(@min((@as(u32, g8) * boost_q8_8) >> 8, 255)); + b_row[x_build] = @intCast(@min((@as(u32, b8) * boost_q8_8) >> 8, 255)); + } + + var x: usize = 0; + const x_end = out_w & ~@as(usize, LANES - 1); + const v_levels: @Vector(LANES, u16) = @splat(levels_u16); + const v_maxq: @Vector(LANES, u16) = @splat(levels_u16 - 1); + const v_255_u16: @Vector(LANES, u16) = @splat(255); + const v_denom: @Vector(LANES, u16) = @splat(denom_u16); + const v_half: @Vector(LANES, u16) = @splat(denom_u16 / 2); + + while (x < x_end) : (x += LANES) { + var rv: @Vector(LANES, u16) = undefined; + var gv: @Vector(LANES, u16) = undefined; + var bv: @Vector(LANES, u16) = undefined; + var tv: @Vector(LANES, u16) = undefined; + inline for (0..LANES) |i| { + rv[i] = r_row[x + i]; + gv[i] = g_row[x + i]; + bv[i] = b_row[x + i]; + tv[i] = trow[x + i]; + } + + if (args.color) { + var qr = @as(@Vector(LANES, u16), @intCast((rv * v_levels + tv) >> @as(@Vector(LANES, u4), @splat(8)))); + var qg = @as(@Vector(LANES, u16), @intCast((gv * v_levels + tv) >> @as(@Vector(LANES, u4), @splat(8)))); + var qb = @as(@Vector(LANES, u16), @intCast((bv * v_levels + tv) >> @as(@Vector(LANES, u4), @splat(8)))); + // clamp to levels-1 + qr = @min(qr, v_maxq); + qg = @min(qg, v_maxq); + qb = @min(qb, v_maxq); + // map to 0..255: (q*255 + denom/2)/denom + const rr = @divTrunc(qr * v_255_u16 + v_half, v_denom); + const gg = @divTrunc(qg * v_255_u16 + v_half, v_denom); + const bb = @divTrunc(qb * v_255_u16 + v_half, v_denom); + inline for (0..LANES) |i| { + const out_idx = (y * out_w + (x + i)) * 3; + out[out_idx + 0] = @intCast(rr[i]); + out[out_idx + 1] = @intCast(gg[i]); + out[out_idx + 2] = @intCast(bb[i]); + } + } else { + // luminance: (77*r + 150*g + 29*b) >> 8 + const rv32: @Vector(LANES, u32) = @intCast(rv); + const gv32: @Vector(LANES, u32) = @intCast(gv); + const bv32: @Vector(LANES, u32) = @intCast(bv); + const y32 = (rv32 * @as(@Vector(LANES, u32), @splat(77))) + + (gv32 * @as(@Vector(LANES, u32), @splat(150))) + + (bv32 * @as(@Vector(LANES, u32), @splat(29))); + const yv: @Vector(LANES, u16) = @intCast(@as(@Vector(LANES, u16), @intCast(y32 >> @as(@Vector(LANES, u5), @splat(8))))); + var q = @as(@Vector(LANES, u16), @intCast((yv * v_levels + tv) >> @as(@Vector(LANES, u4), @splat(8)))); + q = @min(q, v_maxq); + const vv = @divTrunc(q * v_255_u16 + v_half, v_denom); + inline for (0..LANES) |i| { + const out_idx = (y * out_w + (x + i)) * 3; + const v: u8 = @intCast(vv[i]); + out[out_idx + 0] = v; + out[out_idx + 1] = v; + out[out_idx + 2] = v; + } + } + } + + // Tail scalar for leftover pixels + while (x < out_w) : (x += 1) { + const t_scaled: u16 = trow[x]; + var or_: u8 = 0; + var og: u8 = 0; + var ob: u8 = 0; + if (args.color) { + var qr: u16 = @intCast((@as(u32, r_row[x]) * @as(u32, levels_u16) + t_scaled) >> 8); + var qg: u16 = @intCast((@as(u32, g_row[x]) * @as(u32, levels_u16) + t_scaled) >> 8); + var qb: u16 = @intCast((@as(u32, b_row[x]) * @as(u32, levels_u16) + t_scaled) >> 8); + if (qr >= levels_u16) qr = levels_u16 - 1; + if (qg >= levels_u16) qg = levels_u16 - 1; + if (qb >= levels_u16) qb = levels_u16 - 1; + or_ = @intCast((@as(u32, qr) * 255 + (denom_u16 / 2)) / denom_u16); + og = @intCast((@as(u32, qg) * 255 + (denom_u16 / 2)) / denom_u16); + ob = @intCast((@as(u32, qb) * 255 + (denom_u16 / 2)) / denom_u16); + } else { + const y8s: u16 = @intCast(((77 * r_row[x] + 150 * g_row[x] + 29 * b_row[x]) >> 8) & 0xFF); + var q: u16 = @intCast((@as(u32, y8s) * @as(u32, levels_u16) + t_scaled) >> 8); + if (q >= levels_u16) q = levels_u16 - 1; + const v: u8 = @intCast((@as(u32, q) * 255 + (denom_u16 / 2)) / denom_u16); + or_ = v; + og = v; + ob = v; + } + const out_idx = (y * out_w + x) * 3; + out[out_idx + 0] = or_; + out[out_idx + 1] = og; + out[out_idx + 2] = ob; + } + } + + return out; +} diff --git a/src/image.zig b/src/image.zig index fbd8fcf..cd1935a 100644 --- a/src/image.zig +++ b/src/image.zig @@ -3,6 +3,7 @@ const stb = @import("stb"); const core = @import("libglyph"); const bitmap = core.bitmap; const term = @import("libglyphterm"); +const rescale = @import("libglyphrescale"); // ----------------------- // IMAGE PROCESSING FUNCTIONS @@ -18,11 +19,9 @@ pub fn downloadImage(allocator: std.mem.Allocator, url: []const u8) ![]u8 { var req = try client.open(.GET, uri, .{ .server_header_buffer = &buf }); defer req.deinit(); - // Sending HTTP req headers try req.send(); try req.finish(); - // Wait for response try req.wait(); if (req.response.status != .ok) { @@ -66,13 +65,11 @@ pub fn loadImage(allocator: std.mem.Allocator, path: []const u8) !core.Image { defer stb.stbi_image_free(data); - // Make sure w, h, and chan are valid to prevent integer overflow if (w <= 0 or h <= 0 or chan <= 0) { std.debug.print("Invalid image dimensions: w={d}, h={d}, chan={d}\n", .{ w, h, chan }); return error.InvalidImageDimensions; } - // Validate buffer size to prevent overflow const total_pixels = @as(usize, @intCast(w)) * @as(usize, @intCast(h)); const pixel_size = @as(usize, @intCast(chan)); const buffer_size = total_pixels * (if (chan == 4) @as(usize, 3) else pixel_size); @@ -80,34 +77,22 @@ pub fn loadImage(allocator: std.mem.Allocator, path: []const u8) !core.Image { var rgb_data = try allocator.alloc(u8, buffer_size); errdefer allocator.free(rgb_data); - const ext = std.fs.path.extension(path); - if (std.mem.eql(u8, ext, ".png")) { - // If image has 4 channels (RGBA), strip the alpha channel - if (chan == 4) { - var i: usize = 0; - var j: usize = 0; - while (i < total_pixels * 4) : (i += 4) { - rgb_data[j] = data[i]; // R - rgb_data[j + 1] = data[i + 1]; // G - rgb_data[j + 2] = data[i + 2]; // B - j += 3; - } - - return core.Image{ - .data = rgb_data, - .width = @intCast(w), - .height = @intCast(h), - .channels = 3, - }; + // If image has 4 channels (RGBA), strip the alpha channel for consistency + if (chan == 4) { + var i: usize = 0; + var j: usize = 0; + while (i < total_pixels * 4) : (i += 4) { + rgb_data[j] = data[i]; // R + rgb_data[j + 1] = data[i + 1]; // G + rgb_data[j + 2] = data[i + 2]; // B + j += 3; } - @memcpy(rgb_data, data[0 .. total_pixels * @as(usize, @intCast(chan))]); - return core.Image{ .data = rgb_data, .width = @intCast(w), .height = @intCast(h), - .channels = @intCast(chan), + .channels = 3, }; } @@ -128,7 +113,6 @@ pub fn loadAndScaleImage(allocator: std.mem.Allocator, args: core.CoreParams) !c }; if (args.scale != 1.0 and args.scale > 0.0) { - // Need to free the original image data after scaling defer allocator.free(original_img.data); return scaleImage(allocator, original_img, args.scale); } else { @@ -143,38 +127,50 @@ pub fn scaleImage(allocator: std.mem.Allocator, img: core.Image, scale: f32) !co img_w = @max(img_w, 1); img_h = @max(img_h, 1); - const total_pixels = img_w * img_h; - const buffer_size = total_pixels * (if (img.channels == 4) @as(usize, 3) else img.channels); - - const scaled_data = try allocator.alloc(u8, buffer_size); - errdefer allocator.free(scaled_data); - - const scaled_img = stb.stbir_resize_uint8_linear( - img.data.ptr, - @intCast(img.width), - @intCast(img.height), - 0, - 0, - @intCast(img_w), - @intCast(img_h), - 0, - @intCast(img.channels), - ); - if (scaled_img == null) { - std.debug.print("Error downscaling image\n", .{}); - return error.ImageScaleFailed; - } + if (img.channels == 4) { + const total_pixels = img.width * img.height; + var rgb_data = try allocator.alloc(u8, total_pixels * 3); + errdefer allocator.free(rgb_data); + + var i: usize = 0; + var j: usize = 0; + while (i < total_pixels * 4) : (i += 4) { + rgb_data[j] = img.data[i]; // R + rgb_data[j + 1] = img.data[i + 1]; // G + rgb_data[j + 2] = img.data[i + 2]; // B + j += 3; + } + + const rgb_img = core.Image{ + .data = rgb_data, + .width = img.width, + .height = img.height, + .channels = 3, + }; - defer stb.stbi_image_free(scaled_img); + const result = try rescale.resizeImage( + core.Image, + allocator, + rgb_img, + img_w, + img_h, + rescale.FilterType.Lanczos3, + ); - @memcpy(scaled_data, scaled_img[0..buffer_size]); + // Free the temporary RGB buffer + allocator.free(rgb_data); - return core.Image{ - .data = scaled_data, - .width = img_w, - .height = img_h, - .channels = img.channels, - }; + return result; + } + + return rescale.resizeImage( + core.Image, + allocator, + img, + img_w, + img_h, + rescale.FilterType.Lanczos3, + ); } pub fn generateAsciiTxt( @@ -213,8 +209,15 @@ fn saveOutputTxt(ascii_text: []const u8, args: core.CoreParams) !void { } fn saveOutputImage(ascii_img: []u8, img: core.Image, args: core.CoreParams) !void { - var out_w = (img.width / args.block_size) * args.block_size; - var out_h = (img.height / args.block_size) * args.block_size; + var out_w: usize = undefined; + var out_h: usize = undefined; + if (args.render == .Pixels) { + out_w = img.width; + out_h = img.height; + } else { + out_w = (img.width / args.block_size) * args.block_size; + out_h = (img.height / args.block_size) * args.block_size; + } out_w = @max(out_w, 1); out_h = @max(out_h, 1); @@ -223,7 +226,7 @@ fn saveOutputImage(ascii_img: []u8, img: core.Image, args: core.CoreParams) !voi @ptrCast(args.output.?.ptr), @intCast(out_w), @intCast(out_h), - @intCast(img.channels), + 3, // ascii_img is RGB @ptrCast(ascii_img.ptr), @intCast(out_w * 3), ); @@ -259,13 +262,16 @@ pub fn processImage(allocator: std.mem.Allocator, args: core.CoreParams) !void { switch (args.output_type) { core.OutputType.Image => { - const ascii_img = try core.generateAsciiArt( - allocator, - adjusted_img, - edge_result, - args, - ); - try saveOutputImage(ascii_img, adjusted_img, args); + const out_img = if (args.render == .Pixels) + try core.generatePixelDither(allocator, adjusted_img, args) + else + try core.generateAsciiArt( + allocator, + adjusted_img, + edge_result, + args, + ); + try saveOutputImage(out_img, adjusted_img, args); }, core.OutputType.Stdout => { var t = try term.init(allocator, args.ascii_chars); @@ -336,5 +342,3 @@ pub fn processImage(allocator: std.mem.Allocator, args: core.CoreParams) !void { else => {}, } } - -// TESTS diff --git a/src/main.zig b/src/main.zig index 8122e85..9618c78 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,15 +3,24 @@ const builtin = @import("builtin"); const clap = @import("clap"); const core = @import("libglyph"); const image = @import("libglyphimg"); -const video = @import("libglyphav"); +const build_options = @import("build_options"); const term = @import("libglyphterm"); +const mime = @import("mime.zig"); const bitmap = core.bitmap; -const build_options = @import("build_options"); const version = build_options.version; const version_string = std.fmt.comptimePrint("{d}.{d}.{d}", .{ version.major, version.minor, version.patch }); +const video = if (build_options.av) @import("libglyphav") else struct { + pub fn isVideoFile(_: []const u8) bool { + return false; + } + pub fn processVideo(_: std.mem.Allocator, _: core.CoreParams) !void { + return error.AVDisabled; + } +}; + const default_block = " .:coPO?@█"; -const default_ascii = " .:-=+*%@#"; +const default_ascii = " .:coPO?@#"; const full_characters = " .-:=+iltIcsv1x%7aejorzfnuCJT3*69LYpqy25SbdgFGOVXkPhmw48AQDEHKUZR@B#NW0M"; fn getDefaultChars(symbols: ?[]const u8) []const u8 { @@ -77,6 +86,27 @@ fn hexToRgb(hex: []const u8) ![3]u8 { return .{ r, g, b }; } +pub fn isVideoFile(file_path: []const u8) bool { + const extension = std.fs.path.extension(file_path); + if (mime.extension_map.get(extension)) |mime_type| { + return switch (mime_type) { + .@"video/3gpp", + .@"video/3gpp2", + .@"video/mp2t", + .@"video/mp4", + .@"video/mpeg", + .@"video/ogg", + .@"video/quicktime", + .@"video/webm", + .@"video/x-msvideo", + .@"image/gif", + => true, + else => false, + }; + } + return false; +} + // ----------------------- // MAIN ENTRYPOINT AND HELPER FUNCTIONS // ----------------------- @@ -110,7 +140,9 @@ pub fn main() !void { \\ --keep_audio Keeps the audio if input is a video \\ --stretched Resizes media to fit terminal window \\-f, --frame_rate Target frame rate for video output (default: matches input fps) - \\-d, --dither Dithering, supported values: "floydstein" (default: "floydstein") + \\-m, --mode Render mode: "ascii" or "pixels" (default: ascii) + \\-d, --dither Dithering: "none", "floydstein", "bayer4", "bayer8", "bayer16" (default: none) + \\ --dither_levels Levels for ordered dither (default: 2) \\ --fg Enter a hex value like "#ffffff" for the foreground color (default: "#d36a6f") \\ --bg Enter a hex value like "#000000" for the background color (default: "#15091b") \\... @@ -145,13 +177,18 @@ pub fn main() !void { const ext = std.fs.path.extension(op); if (std.mem.eql(u8, ext, ".txt")) { break :blk core.OutputType.Text; - } else if (video.isVideoFile(op)) { + } else if (isVideoFile(op)) { break :blk core.OutputType.Video; } else { break :blk core.OutputType.Image; } } else core.OutputType.Stdout; + if (!build_options.av and output_type == core.OutputType.Video) { + std.debug.print("Error: Video output requested but AV support is disabled. Rebuild with -Dav.\n", .{}); + std.process.exit(2); + } + var ffmpeg_options = std.StringHashMap([]const u8).init(allocator); errdefer ffmpeg_options.deinit(); var pos: usize = 0; @@ -185,17 +222,25 @@ pub fn main() !void { const ascii_info = try core.initAsciiChars(allocator, ascii_chars); const dither = blk: { - if (res.args.dither != null) { - if (std.mem.eql(u8, res.args.dither.?, "floydstein")) { - break :blk core.DitherType.FloydSteinberg; - } else { - break :blk core.DitherType.None; - } - } else { - break :blk core.DitherType.None; + if (res.args.dither) |d| { + if (std.mem.eql(u8, d, "none")) break :blk core.DitherType.None; + if (std.mem.eql(u8, d, "floydstein")) break :blk core.DitherType.FloydSteinberg; + if (std.mem.eql(u8, d, "bayer4")) break :blk core.DitherType.Bayer4; + if (std.mem.eql(u8, d, "bayer8")) break :blk core.DitherType.Bayer8; + if (std.mem.eql(u8, d, "bayer16")) break :blk core.DitherType.Bayer16; } + break :blk core.DitherType.None; }; + const render: core.RenderType = blk: { + if (res.args.mode) |m| { + if (std.mem.eql(u8, m, "pixels")) break :blk .Pixels; + } + break :blk .Ascii; + }; + + const dither_levels: u8 = res.args.dither_levels orelse 2; + const fg_color = blk: { if (res.args.fg != null) { break :blk try hexToRgb(res.args.fg.?); @@ -230,11 +275,19 @@ pub fn main() !void { .frame_rate = res.args.frame_rate, .stretched = res.args.stretched != 0, .dither = dither, + .render = render, + .dither_levels = dither_levels, .fg_color = fg_color, .bg_color = bg_color, }; - if (video.isVideoFile(args.input)) { + const input_is_video = isVideoFile(args.input); + if (input_is_video and !build_options.av) { + std.debug.print("Error: Video input detected but AV support is disabled. Rebuild with -Dav.\n", .{}); + std.process.exit(2); + } + + if (input_is_video) { try video.processVideo(allocator, args); } else { try image.processImage(allocator, args); diff --git a/src/rescale.zig b/src/rescale.zig new file mode 100644 index 0000000..7fbfe18 --- /dev/null +++ b/src/rescale.zig @@ -0,0 +1,385 @@ +const std = @import("std"); + +pub const FilterType = enum { + Box, + Triangle, + Bell, + BSpline, + Sinc, + Lanczos3, + CatmullRom, + Mitchell, +}; + +fn resizeFilterBox(t: f32) f32 { + if (t > -0.5 and t <= 0.5) { + return 1.0; + } + return 0.0; +} + +fn resizeFilterTriangle(t: f32) f32 { + const f = @abs(t); + if (f < 1) return 1 - f; + return 0; +} + +fn resizeFilterBell(t: f32) f32 { + var f = @abs(t); + if (f < 0.5) { + return 0.75 - (f * f); + } + if (f < 1.5) { + f = f - 1.5; + return 0.5 * (f * f); + } + return 0.0; +} + +fn resizeFilterBSpline(t: f32) f32 { + var f = @abs(t); + if (f < 1) { + const tt = f * f; + return ((0.5 * tt * f) - tt + (2.0 / 3.0)); + } + if (f < 2) { + f = 2 - f; + return ((1.0 / 6.0) * (f * f * f)); + } + return 0.0; +} + +fn resizeFilterSinc(t: f32) f32 { + const f = t * std.math.pi; + if (f != 0) return @sin(f) / f; + return 1.0; +} + +fn resizeFilterLanczos3(t: f32) f32 { + const f = @abs(t); + if (f < 3) { + return resizeFilterSinc(f) * resizeFilterSinc(f / 3); + } + return 0.0; +} + +fn resizeFilterCatmullRom(t: f32) f32 { + const f = @abs(t); + if (f < 1) { + return 1 - f * f * (2.5 - (1.5 * f)); + } + if (f < 2) { + return 2 - f * (4 + f * (0.5 * f - 2.5)); + } + return 0.0; +} + +fn resizeFilterMitchell(t: f32) f32 { + const f = @abs(t); + if (f < 1) { + return (16 + f * f * (21 * f - 36)) / 18; + } + if (f < 2) { + return (32 + f * (-60 + f * (36 - 7 * f))) / 18; + } + return 0.0; +} + +const FilterSupport = struct { + comptime filter: fn (f32) f32 = resizeFilterLanczos3, + support: f32, +}; + +fn getFilterInfo(filter_type: FilterType) FilterSupport { + return switch (filter_type) { + .Box => .{ .filter = resizeFilterBox, .support = 0.5 }, + .Triangle => .{ .filter = resizeFilterTriangle, .support = 1.0 }, + .Bell => .{ .filter = resizeFilterBell, .support = 1.5 }, + .BSpline => .{ .filter = resizeFilterBSpline, .support = 2.0 }, + .Mitchell => .{ .filter = resizeFilterMitchell, .support = 2.0 }, + .CatmullRom => .{ .filter = resizeFilterCatmullRom, .support = 2.0 }, + .Sinc => .{ .filter = resizeFilterSinc, .support = 4.0 }, + .Lanczos3 => .{ .filter = resizeFilterLanczos3, .support = 3.0 }, + }; +} + +const ContribInfo = struct { + first: u16, + n: u16, +}; + +const ResizeWeights = struct { + weights: []f32, + contrib: []ContribInfo, + width: f32, + res: i32, + allocator: std.mem.Allocator, + + fn deinit(self: *ResizeWeights) void { + self.allocator.free(self.weights); + self.allocator.free(self.contrib); + } +}; + +fn makeWeights( + allocator: std.mem.Allocator, + from_res: i32, + to_res: i32, + comptime filter_fn: fn (f32) f32, + comptime support: f32, +) !ResizeWeights { + var result = ResizeWeights{ + .res = to_res, + .width = undefined, + .weights = undefined, + .contrib = undefined, + .allocator = allocator, + }; + + const scale = @as(f32, @floatFromInt(to_res)) / @as(f32, @floatFromInt(from_res)); + const delta = (0.5 / scale) - 0.5; + result.width = support; + result.width = if (scale < 1) result.width / scale else result.width; + + const weights_step = @as(usize, @intFromFloat(result.width * 2 + 1 + 1)); + result.weights = try allocator.alloc(f32, weights_step * @as(usize, @intCast(to_res))); + result.contrib = try allocator.alloc(ContribInfo, @as(usize, @intCast(to_res))); + + const min_weight: f32 = @as(f32, 1.0) / (1 << 10); + + if (scale < 1.0) { + var w_idx: usize = 0; + for (0..@as(usize, @intCast(to_res))) |i| { + const center = @as(f32, @floatFromInt(i)) / scale + delta; + var left = @as(i32, @intFromFloat(center - result.width + 1)); + if (left < 0) left = 0; + var right = @as(i32, @intFromFloat(center + result.width)); + if (right < 0) right = 0; + + var n: u16 = 0; + var first: i32 = std.math.maxInt(i32); + var total_weight: f32 = 0; + + for (@as(usize, @intCast(left))..@as(usize, @intCast(right)) + 1) |j_usize| { + const j = @as(i32, @intCast(j_usize)); + if (j >= 0 and j < from_res) { + var w = center - @as(f32, @floatFromInt(j)); + w = filter_fn(w * scale) * scale; + + if (j < first) first = j; + if (j == first and (w > -min_weight and w < min_weight)) { + first = std.math.maxInt(i32); // not first! + } else { + result.weights[w_idx] = w; + w_idx += 1; + n += 1; + total_weight += w; + } + } + } + + // Trim trailing low weights + while (n > 1 and @abs(result.weights[w_idx - 1]) < min_weight) { + n -= 1; + total_weight -= result.weights[w_idx - 1]; + w_idx -= 1; + } + + // Normalize weights + if (total_weight < (1 - min_weight)) { + const weight_scale = 1.0 / total_weight; + for (0..n) |j| { + result.weights[w_idx - n + j] *= weight_scale; + } + } + + result.contrib[i] = .{ .n = n, .first = @intCast(first) }; + } + } else { + var w_idx: usize = 0; + for (0..@as(usize, @intCast(to_res))) |i| { + const center = @as(f32, @floatFromInt(i)) / scale + delta; + var left = @as(i32, @intFromFloat(center - result.width + 1)); + if (left < 0) left = 0; + var right = @as(i32, @intFromFloat(center + result.width)); + if (right < 0) right = 0; + + var n: u16 = 0; + var first: i32 = std.math.maxInt(i32); + var total_weight: f32 = 0; + + for (@as(usize, @intCast(left))..@as(usize, @intCast(right)) + 1) |j_i32| { + const j = @as(i32, @intCast(j_i32)); + if (j >= 0 and j < from_res) { + var w = center - @as(f32, @floatFromInt(j)); + w = filter_fn(w); + + if (j < first) first = j; + if (j == first and (w > -min_weight and w < min_weight)) { + first = std.math.maxInt(i32); // not first! + } else { + result.weights[w_idx] = w; + w_idx += 1; + n += 1; + total_weight += w; + } + } + } + + // Trim trailing low weights + while (n > 1 and @abs(result.weights[w_idx - 1]) < min_weight) { + n -= 1; + total_weight -= result.weights[w_idx - 1]; + w_idx -= 1; + } + + // Normalize weights + if (total_weight < (1 - min_weight)) { + const weight_scale = 1.0 / total_weight; + for (0..n) |j| { + result.weights[w_idx - n + j] *= weight_scale; + } + } + + result.contrib[i] = .{ .n = n, .first = @intCast(first) }; + } + } + + return result; +} + +/// Generic resize function that works with any image type with +/// width, height, channels and data fields +pub fn resizeImage( + comptime Image: type, + allocator: std.mem.Allocator, + img: Image, + new_width: usize, + new_height: usize, + comptime filter_type: FilterType, +) !Image { + // Safety checks + if (img.width == 0 or img.height == 0 or new_width == 0 or new_height == 0) { + return error.InvalidDimensions; + } + + // Get filter information + const filter_info = comptime getFilterInfo(filter_type); + var wx = try makeWeights( + allocator, + @intCast(img.width), + @intCast(new_width), + filter_info.filter, + filter_info.support, + ); + defer wx.deinit(); + + var wy = try makeWeights( + allocator, + @intCast(img.height), + @intCast(new_height), + filter_info.filter, + filter_info.support, + ); + defer wy.deinit(); + + const from_max = @max(img.width, img.height); + const to_max = @max(new_width, new_height); + const all_max = @max(from_max, to_max); + + // Allocate temporary buffers + var ftmp = try allocator.alloc(f32, all_max * img.channels); + defer allocator.free(ftmp); + + const ring_size = @as(usize, @intFromFloat(wy.width * 2 + 1)); + var ring_buf = try allocator.alloc(f32, new_width * ring_size * img.channels); + defer allocator.free(ring_buf); + + // Allocate output buffer + const total_pixels = new_width * new_height; + const buffer_size = total_pixels * img.channels; + const output = try allocator.alloc(u8, buffer_size); + errdefer allocator.free(output); + + var scanline_h: usize = 0; + var scanline_v: usize = 0; + + while (scanline_v < new_height) { + if (scanline_h < img.height) { + // Process horizontal scanline + for (0..img.width) |x| { + for (0..img.channels) |c| { + ftmp[x * img.channels + c] = @floatFromInt(img.data[(scanline_h * img.width + x) * img.channels + c]); + } + } + + var h = ring_buf[(scanline_h % ring_size) * new_width * img.channels ..]; + var w_idx: usize = 0; + + for (0..img.channels) |c| { + var i: usize = 0; + var local_w_idx: usize = w_idx; + while (i < new_width) : (i += 1) { + const first = wx.contrib[i].first; + const n = wx.contrib[i].n; + var val: f32 = 0; + var j: usize = 0; + while (j < n) : (j += 1) { + const src_idx = first + j; + val += ftmp[src_idx * img.channels + c] * wx.weights[local_w_idx + j]; + } + h[i * img.channels + c] = val; + local_w_idx += n; + } + } + // advance global weight index once per output pixel + for (0..new_width) |i| { + w_idx += wx.contrib[i].n; + } + + scanline_h += 1; + } + + while (scanline_v < new_height and scanline_h >= (wy.contrib[scanline_v].first + wy.contrib[scanline_v].n)) { + // Process vertical scanline + const first = wy.contrib[scanline_v].first; + const n = wy.contrib[scanline_v].n; + var w_idx: usize = 0; + + @memset(ftmp, 0.5); + + for (0..n) |j| { + const src_idx = (first + j) % ring_size; + const weight = wy.weights[w_idx + j]; + const hrow = ring_buf[src_idx * new_width * img.channels ..]; + var i: usize = 0; + while (i < new_width) : (i += 1) { + var c: usize = 0; + while (c < img.channels) : (c += 1) { + const idx = i * img.channels + c; + ftmp[idx] += hrow[idx] * weight; + } + } + } + + var x_out: usize = 0; + while (x_out < new_width) : (x_out += 1) { + var c: usize = 0; + while (c < img.channels) : (c += 1) { + const val = std.math.clamp(ftmp[x_out * img.channels + c], 0, 255); + output[(scanline_v * new_width + x_out) * img.channels + c] = @intFromFloat(val); + } + } + + w_idx += n; + scanline_v += 1; + } + } + + return Image{ + .data = output, + .width = new_width, + .height = new_height, + .channels = img.channels, + }; +} diff --git a/src/tests.zig b/src/tests.zig index 27d0eef..bc62b5c 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -2,7 +2,8 @@ const std = @import("std"); pub const core = @import("libglyph"); pub const image = @import("libglyphimg"); pub const term = @import("libglyphterm"); -pub const video = @import("libglyphav"); +const build_options = @import("build_options"); +pub const video = if (build_options.av) @import("libglyphav") else struct {}; const stb = core.stb; test "loadImage - png" { diff --git a/src/video.zig b/src/video.zig index c232cd9..eca4d5f 100644 --- a/src/video.zig +++ b/src/video.zig @@ -2,12 +2,13 @@ const std = @import("std"); const builtin = @import("builtin"); const core = @import("libglyph"); const term = @import("libglyphterm"); -const mime = @import("mime.zig"); -const av = @import("av"); +const ffmpeg = @import("av"); +const av = ffmpeg.c; const stb = @import("stb"); const Thread = std.Thread; const Mutex = Thread.Mutex; const Condition = Thread.Condition; +const log = std.log.scoped(.video); pub fn FrameBuffer(comptime T: type) type { return struct { @@ -92,30 +93,116 @@ pub fn FrameBuffer(comptime T: type) type { }; } -// ----------------------- -// VIDEO PROCESSING FUNCTIONS -// ----------------------- - -pub fn isVideoFile(file_path: []const u8) bool { - const extension = std.fs.path.extension(file_path); - if (mime.extension_map.get(extension)) |mime_type| { - return switch (mime_type) { - .@"video/3gpp", - .@"video/3gpp2", - .@"video/mp2t", - .@"video/mp4", - .@"video/mpeg", - .@"video/ogg", - .@"video/quicktime", - .@"video/webm", - .@"video/x-msvideo", - => true, - else => false, - }; +fn isGifOutput(args: core.CoreParams) bool { + if (args.output) |output_path| { + return std.mem.eql(u8, std.fs.path.extension(output_path), ".gif"); } return false; } +const ScalerSpec = struct { + src_w: c_int, + src_h: c_int, + src_fmt: c_int, + dst_w: c_int, + dst_h: c_int, + dst_fmt: c_int, +}; + +fn makeScaler(spec: ScalerSpec) ?*av.struct_SwsContext { + return av.sws_getContext( + spec.src_w, + spec.src_h, + spec.src_fmt, + spec.dst_w, + spec.dst_h, + spec.dst_fmt, + av.SWS_BILINEAR, + null, + null, + null, + ); +} + +const GifFilter = struct { + graph: *av.AVFilterGraph, + src: *av.AVFilterContext, + sink: *av.AVFilterContext, + + fn init(src_w: c_int, src_h: c_int, dst_w: c_int, dst_h: c_int) !GifFilter { + const graph = av.avfilter_graph_alloc(); + if (graph == null) return error.FailedToCreateOutputCtx; + + const buffersrc = av.avfilter_get_by_name("buffer"); + const buffersink = av.avfilter_get_by_name("buffersink"); + if (buffersrc == null or buffersink == null) return error.FailedToCreateOutputCtx; + + var args_buf: [128]u8 = undefined; + const args_str = try std.fmt.bufPrintZ( + &args_buf, + "video_size={d}x{d}:pix_fmt={d}:time_base=1/100:pixel_aspect=1/1", + .{ src_w, src_h, @as(c_int, av.AV_PIX_FMT_RGB24) }, + ); + + var src_ctx_ptr: ?*av.AVFilterContext = null; + if (av.avfilter_graph_create_filter(&src_ctx_ptr, buffersrc, "in", args_str.ptr, null, graph) < 0) { + av.avfilter_graph_free(@constCast(@ptrCast(&graph))); + return error.FailedToCreateOutputCtx; + } + + var sink_ctx_ptr: ?*av.AVFilterContext = null; + if (av.avfilter_graph_create_filter(&sink_ctx_ptr, buffersink, "out", null, null, graph) < 0) { + av.avfilter_graph_free(@constCast(@ptrCast(&graph))); + return error.FailedToCreateOutputCtx; + } + + var graph_desc_buf: [256]u8 = undefined; + const graph_desc = try std.fmt.bufPrintZ( + &graph_desc_buf, + "[in]scale={d}:{d}:flags=lanczos,split[s0][s1];[s0]palettegen=stats_mode=full[p];[s1][p]paletteuse=dither=sierra2_4a:new=1[out]", + .{ dst_w, dst_h }, + ); + + const inputs = av.avfilter_inout_alloc(); + const outputs = av.avfilter_inout_alloc(); + if (inputs == null or outputs == null) { + av.avfilter_inout_free(@constCast(@ptrCast(&inputs))); + av.avfilter_inout_free(@constCast(@ptrCast(&outputs))); + av.avfilter_graph_free(@constCast(@ptrCast(&graph))); + return error.FailedToCreateOutputCtx; + } + defer av.avfilter_inout_free(@constCast(@ptrCast(&inputs))); + defer av.avfilter_inout_free(@constCast(@ptrCast(&outputs))); + + outputs.*.name = av.av_strdup("in"); + outputs.*.filter_ctx = src_ctx_ptr; + outputs.*.pad_idx = 0; + outputs.*.next = null; + + inputs.*.name = av.av_strdup("out"); + inputs.*.filter_ctx = sink_ctx_ptr; + inputs.*.pad_idx = 0; + inputs.*.next = null; + + if (av.avfilter_graph_parse_ptr(graph, graph_desc.ptr, @constCast(@ptrCast(&inputs)), @constCast(@ptrCast(&outputs)), null) < 0) { + av.avfilter_graph_free(@constCast(@ptrCast(&graph))); + return error.FailedToCreateOutputCtx; + } + if (av.avfilter_graph_config(graph, null) < 0) { + av.avfilter_graph_free(@constCast(@ptrCast(&graph))); + return error.FailedToCreateOutputCtx; + } + + return .{ .graph = graph, .src = src_ctx_ptr.?, .sink = sink_ctx_ptr.? }; + } + + fn deinit(self: *GifFilter) void { + var g: ?*av.AVFilterGraph = self.graph; + av.avfilter_graph_free(@constCast(@ptrCast(&g))); + self.* = undefined; + } +}; + fn openInputVideo(path: []const u8) !*av.AVFormatContext { var fmt_ctx: ?*av.AVFormatContext = null; if (av.avformat_open_input( @@ -185,7 +272,6 @@ fn createDecoder(stream: *av.AVStream) !*av.AVCodecContext { fn setEncoderOption(enc_ctx: *av.AVCodecContext, key: []const u8, value: []const u8) bool { var opt: ?*const av.AVOption = null; - // Try to find the option in AVCodecContext opt = av.av_opt_find(@ptrCast(enc_ctx), key.ptr, null, 0, 0); if (opt != null) { if (av.av_opt_set(enc_ctx, key.ptr, value.ptr, 0) >= 0) { @@ -193,7 +279,6 @@ fn setEncoderOption(enc_ctx: *av.AVCodecContext, key: []const u8, value: []const } } - // If not found or setting failed, try in priv_data if (enc_ctx.*.priv_data != null) { opt = av.av_opt_find(enc_ctx.*.priv_data, key.ptr, null, 0, 0); if (opt != null) { @@ -211,45 +296,87 @@ fn createEncoder( stream: *av.AVStream, args: core.CoreParams, ) !*av.AVCodecContext { - const encoder = if (args.codec) |codec| av.avcodec_find_encoder_by_name(codec.ptr) else av.avcodec_find_encoder_by_name("h264_nvenc") orelse - av.avcodec_find_encoder_by_name("hevc_amf") orelse - av.avcodec_find_encoder_by_name("hevc_qsv") orelse - av.avcodec_find_encoder_by_name("hevc_videotoolbox") orelse - av.avcodec_find_encoder_by_name("libx265") orelse - av.avcodec_find_encoder_by_name("h264_amf") orelse - av.avcodec_find_encoder_by_name("h264_qsv") orelse - av.avcodec_find_encoder_by_name("libx264") orelse - return error.EncoderNotFound; + const encoder = if (args.codec) |codec| av.avcodec_find_encoder_by_name(codec.ptr) orelse return error.EncoderNotFound else blk: { + if (args.output) |output_path| { + const ext = std.fs.path.extension(output_path); + if (std.mem.eql(u8, ext, ".gif")) { + break :blk av.avcodec_find_encoder_by_name("gif") orelse return error.EncoderNotFound; + } + } + + break :blk av.avcodec_find_encoder_by_name("libx265") orelse + av.avcodec_find_encoder_by_name("libx264") orelse + av.avcodec_find_encoder_by_name("h264_nvenc") orelse + av.avcodec_find_encoder_by_name("h264_vaapi") orelse + av.avcodec_find_encoder_by_name("h264_videotoolbox") orelse + av.avcodec_find_encoder_by_name("mpeg4") orelse + return error.EncoderNotFound; + }; const enc_ctx = av.avcodec_alloc_context3(encoder); if (enc_ctx == null) { return error.FailedToAllocCodecCtx; } - // Setting up encoding context enc_ctx.*.width = codec_ctx.width; enc_ctx.*.height = codec_ctx.height; - enc_ctx.*.pix_fmt = av.AV_PIX_FMT_YUV420P; - // enc_ctx.*.pix_fmt = encoder.*.pix_fmts[0]; - enc_ctx.*.time_base = stream.time_base; - enc_ctx.*.framerate = .{ - .num = codec_ctx.framerate.num, - .den = 1, - }; - enc_ctx.*.gop_size = 10; - enc_ctx.*.max_b_frames = 1; - enc_ctx.*.flags |= av.AV_CODEC_FLAG_GLOBAL_HEADER; - // Ensure the stride is aligned to 32 bytes - const stride = (enc_ctx.*.width + 31) & ~@as(c_int, 31); - _ = av.av_opt_set(enc_ctx, "stride", stride, 0); + const is_gif_encoder = if (args.output) |output_path| + std.mem.eql(u8, std.fs.path.extension(output_path), ".gif") + else + false; + + if (is_gif_encoder) { + // Use PAL8 (palettized) for gif encoder together with paletteuse filter output. + // Set a stable time_base for GIF in centiseconds (1/100), so each increment of PTS is 1 frame delay unit. + enc_ctx.*.pix_fmt = av.AV_PIX_FMT_PAL8; + enc_ctx.*.time_base = .{ .num = 1, .den = 100 }; + enc_ctx.*.framerate = .{ + .num = codec_ctx.framerate.num, + .den = codec_ctx.framerate.den, + }; + } else { + enc_ctx.*.pix_fmt = av.AV_PIX_FMT_YUV420P; + + const encoder_name = if (encoder.*.name) |name| std.mem.span(name) else ""; + if (std.mem.eql(u8, encoder_name, "mpeg4")) { + enc_ctx.*.time_base = .{ .num = 1, .den = 25000 }; + } else if (std.mem.eql(u8, encoder_name, "h264_nvenc") or std.mem.eql(u8, encoder_name, "hevc_nvenc")) { + enc_ctx.*.time_base = .{ .num = 1, .den = 90000 }; + _ = av.av_opt_set(enc_ctx, "preset", "llhq", 0); + _ = av.av_opt_set(enc_ctx, "delay", "0", 0); + _ = av.av_opt_set(enc_ctx, "b_frames", "0", 0); + _ = av.av_opt_set(enc_ctx, "zerolatency", "1", 0); + } else if (std.mem.eql(u8, encoder_name, "h264_vaapi") or std.mem.eql(u8, encoder_name, "hevc_vaapi")) { + // TODO(ro): Implement VAAPI hardware frames context setup + // VAAPI encoders require proper hardware device context and frames allocation + // See: https://ffmpeg.org/doxygen/trunk/vaapi__encode_8c_source.html + @panic("VAAPI encoders are not implemented yet - requires hardware frames context setup"); + } else { + enc_ctx.*.time_base = stream.time_base; + } + + enc_ctx.*.framerate = .{ + .num = codec_ctx.framerate.num, + .den = codec_ctx.framerate.den, + }; + enc_ctx.*.gop_size = 10; + enc_ctx.*.max_b_frames = 1; + enc_ctx.*.flags |= av.AV_CODEC_FLAG_GLOBAL_HEADER; + } + + // Ensure the stride is aligned to 32 bytes (not needed for GIF) + if (!is_gif_encoder) { + const stride = (enc_ctx.*.width + 31) & ~@as(c_int, 31); + _ = av.av_opt_set(enc_ctx, "stride", stride, 0); + } var it = args.ffmpeg_options.iterator(); while (it.next()) |entry| { const k = entry.key_ptr.*; const v = entry.value_ptr.*; if (!setEncoderOption(enc_ctx, k, v)) { - std.debug.print("Warning: Failed to set FFmpeg option: {s}={s}\n", .{ k, v }); + log.warn("Failed to set FFmpeg option: {s}={s}", .{ k, v }); } } @@ -280,7 +407,8 @@ fn createOutputCtx(output_path: []const u8, enc_ctx: *av.AVCodecContext, audio_s return error.FailedToSetCodecParams; } - // Create audio stream + video_stream.*.time_base = enc_ctx.*.time_base; + var audio_out_stream: ?*av.AVStream = null; if (audio_stream) |as| { audio_out_stream = av.avformat_new_stream(fmt_ctx, null); @@ -334,18 +462,32 @@ pub fn processVideo(allocator: std.mem.Allocator, args: core.CoreParams) !void { var enc_ctx = try createEncoder(dec_ctx, stream_info.stream, args); defer av.avcodec_free_context(@ptrCast(&enc_ctx)); - // Extract frame rate + if (args.output) |_| { + if (args.scale > 0 and args.scale != 1.0) { + const bs: usize = @intCast(args.block_size); + const base_w: usize = @intCast(dec_ctx.width); + const base_h: usize = @intCast(dec_ctx.height); + const scaled_w: usize = @intFromFloat(@round(@as(f32, @floatFromInt(base_w)) / args.scale)); + const scaled_h: usize = @intFromFloat(@round(@as(f32, @floatFromInt(base_h)) / args.scale)); + // Align to block size (and clamp to minimum 1) + const aligned_w: usize = @max((if (bs > 0) scaled_w / bs * bs else scaled_w), 1); + const aligned_h: usize = @max((if (bs > 0) scaled_h / bs * bs else scaled_h), 1); + enc_ctx.*.width = @intCast(aligned_w); + enc_ctx.*.height = @intCast(aligned_h); + } + } + const input_frame_rate = @as(f64, @floatFromInt(stream_info.stream.*.r_frame_rate.num)) / @as(f64, @floatFromInt(stream_info.stream.*.r_frame_rate.den)); const target_frame_rate = args.frame_rate orelse input_frame_rate; const frame_time_ns = @as(u64, @intFromFloat(1e9 / target_frame_rate)); - std.debug.print("Input FPS: {d}, Target FPS: {d}, FrameTime: {d}\n", .{ input_frame_rate, target_frame_rate, frame_time_ns }); + log.info("fps_in={d:.2} fps_out={d:.2} frame_ns={d}", .{ input_frame_rate, target_frame_rate, frame_time_ns }); var audio_stream_info: ?AVStream = null; if (args.keep_audio) { audio_stream_info = openAudioStream(input_ctx) catch |err| blk: { if (err == error.AudioStreamNotFound) { - std.debug.print("No audio stream found in input video. Continuing without audio.\n", .{}); + log.info("No audio stream; continue without audio", .{}); break :blk null; } else { return err; @@ -478,7 +620,6 @@ fn producerTask( t: term, args: core.CoreParams, ) !void { - // Get total frames var total_frames: usize = undefined; var progress: std.Progress.Node = undefined; var root_node: std.Progress.Node = undefined; @@ -500,56 +641,71 @@ fn producerTask( defer av.av_frame_free(&rgb_frame); const input_pix_fmt = dec_ctx.*.pix_fmt; - std.debug.print("Input pixel format: {s}\n", .{av.av_get_pix_fmt_name(input_pix_fmt)}); + if (builtin.mode == .Debug) { + log.debug("input_pix_fmt={s}", .{av.av_get_pix_fmt_name(input_pix_fmt)}); + } const output_pix_fmt = av.AV_PIX_FMT_RGB24; rgb_frame.*.format = output_pix_fmt; - rgb_frame.*.width = @max(@divFloor(dec_ctx.*.width, args.block_size) * args.block_size, 1); - rgb_frame.*.height = @max(@divFloor(dec_ctx.*.height, args.block_size) * args.block_size, 1); + // Determine processing size. If writing output, honor args.scale so GIF and non-GIF paths match. + if (op != null and args.scale > 0 and args.scale != 1.0) { + const bs: usize = @intCast(args.block_size); + const base_w_usize: usize = @intCast(dec_ctx.*.width); + const base_h_usize: usize = @intCast(dec_ctx.*.height); + const scaled_w_usize: usize = @intFromFloat(@round(@as(f32, @floatFromInt(base_w_usize)) / args.scale)); + const scaled_h_usize: usize = @intFromFloat(@round(@as(f32, @floatFromInt(base_h_usize)) / args.scale)); + const aligned_w_usize: usize = @max((if (bs > 0) scaled_w_usize / bs * bs else scaled_w_usize), 1); + const aligned_h_usize: usize = @max((if (bs > 0) scaled_h_usize / bs * bs else scaled_h_usize), 1); + rgb_frame.*.width = @intCast(aligned_w_usize); + rgb_frame.*.height = @intCast(aligned_h_usize); + } else { + rgb_frame.*.width = @max(@divFloor(dec_ctx.*.width, args.block_size) * args.block_size, 1); + rgb_frame.*.height = @max(@divFloor(dec_ctx.*.height, args.block_size) * args.block_size, 1); + } if (av.av_frame_get_buffer(rgb_frame, 0) < 0) { return error.FailedToAllocFrameBuf; } - var yuv_frame = av.av_frame_alloc(); - defer av.av_frame_free(&yuv_frame); + const is_gif_output = isGifOutput(args); - yuv_frame.*.format = av.AV_PIX_FMT_YUV420P; - yuv_frame.*.width = enc_ctx.*.width; - yuv_frame.*.height = enc_ctx.*.height; - if (av.av_frame_get_buffer(yuv_frame, 0) < 0) { - return error.FailedToAllocFrameBuf; + var output_frame: ?*av.AVFrame = null; + if (!is_gif_output) { + output_frame = av.av_frame_alloc(); + output_frame.?.*.format = enc_ctx.*.pix_fmt; // Use encoder's pixel format + output_frame.?.*.width = enc_ctx.*.width; + output_frame.?.*.height = enc_ctx.*.height; + if (av.av_frame_get_buffer(output_frame.?, 0) < 0) { + return error.FailedToAllocFrameBuf; + } + } + defer { + if (output_frame) |of| { + av.av_frame_free(@constCast(@ptrCast(&of))); + } } - const sws_ctx = av.sws_getContext( - dec_ctx.width, - dec_ctx.height, - input_pix_fmt, - dec_ctx.width, - dec_ctx.height, - output_pix_fmt, - av.SWS_BILINEAR, - null, - null, - null, - ); + const sws_ctx = makeScaler(.{ + .src_w = dec_ctx.width, + .src_h = dec_ctx.height, + .src_fmt = input_pix_fmt, + .dst_w = rgb_frame.*.width, + .dst_h = rgb_frame.*.height, + .dst_fmt = output_pix_fmt, + }); defer av.sws_freeContext(sws_ctx); var term_ctx: ?*av.struct_SwsContext = undefined; var term_frame: *av.struct_AVFrame = undefined; if (op == null) { - term_ctx = av.sws_getContext( - dec_ctx.width, - dec_ctx.height, - output_pix_fmt, - @intCast(t.size.w), - @intCast(t.size.h), - output_pix_fmt, - av.SWS_BILINEAR, - null, - null, - null, - ); + term_ctx = makeScaler(.{ + .src_w = dec_ctx.width, + .src_h = dec_ctx.height, + .src_fmt = output_pix_fmt, + .dst_w = @intCast(t.size.w), + .dst_h = @intCast(t.size.h), + .dst_fmt = output_pix_fmt, + }); term_frame = av.av_frame_alloc(); } defer { @@ -571,24 +727,46 @@ fn producerTask( (1 << 16) - 1, ); - const out_sws_ctx = av.sws_getContext( - rgb_frame.*.width, - rgb_frame.*.height, - @intCast(rgb_frame.*.format), - yuv_frame.*.width, - yuv_frame.*.height, - @intCast(yuv_frame.*.format), - av.SWS_BILINEAR, - null, - null, - null, - ); - defer av.sws_freeContext(out_sws_ctx); + // Only create output scaler for non-GIF formats + var out_sws_ctx: ?*av.struct_SwsContext = null; + if (!is_gif_output) { + out_sws_ctx = makeScaler(.{ + .src_w = rgb_frame.*.width, + .src_h = rgb_frame.*.height, + .src_fmt = @intCast(rgb_frame.*.format), + .dst_w = output_frame.?.*.width, + .dst_h = output_frame.?.*.height, + .dst_fmt = @intCast(output_frame.?.*.format), + }); + } + defer if (out_sws_ctx) |ctx| av.sws_freeContext(ctx); + + var gif_filter: ?GifFilter = null; + var gif_pts: i64 = 0; // running PTS in 1/100s for GIF + var gif_delay_ticks: i64 = 1; // default to 1 centisecond per frame + defer if (gif_filter) |*gf| gf.deinit(); + if (op != null and is_gif_output) { + gif_filter = try GifFilter.init( + @intCast(rgb_frame.*.width), + @intCast(rgb_frame.*.height), + enc_ctx.width, + enc_ctx.height, + ); + // Compute frame duration in encoder time_base units (1/100s) + // delay = max(floor(100 / fps), 1) + const fps_num: i64 = @intCast(if (enc_ctx.*.framerate.num != 0) enc_ctx.*.framerate.num else stream_info.stream.*.r_frame_rate.num); + const fps_den: i64 = @intCast(if (enc_ctx.*.framerate.den != 0) enc_ctx.*.framerate.den else stream_info.stream.*.r_frame_rate.den); + if (fps_num > 0) { + gif_delay_ticks = @max(@divFloor(100 * fps_den, fps_num), 1); + } else { + gif_delay_ticks = 5; + } + } var frame_count: usize = 0; const start_time = std.time.milliTimestamp(); var last_update_time = start_time; - const update_interval: i64 = 1000; // Update every 1 second + const update_interval: i64 = 1000; while (av.av_read_frame(input_ctx, packet) >= 0) { defer av.av_packet_unref(packet); @@ -610,41 +788,55 @@ fn producerTask( frame_count += 1; if (op) |output| { try convertFrameToAscii(allocator, rgb_frame, args); - _ = av.sws_scale( - out_sws_ctx, - &rgb_frame.*.data, - &rgb_frame.*.linesize, - 0, - rgb_frame.*.height, - &yuv_frame.*.data, - &yuv_frame.*.linesize, - ); - - yuv_frame.*.pts = frame.*.pts; - - var enc_packet = av.av_packet_alloc(); - defer av.av_packet_free(&enc_packet); - - if (av.avcodec_send_frame(enc_ctx, yuv_frame) >= 0) { - while (av.avcodec_receive_packet(enc_ctx, enc_packet) >= 0) { - enc_packet.*.stream_index = 0; - enc_packet.*.pts = av.av_rescale_q( - enc_packet.*.pts, - enc_ctx.*.time_base, - output.video_stream.*.time_base, - ); - enc_packet.*.dts = av.av_rescale_q( - enc_packet.*.dts, - enc_ctx.*.time_base, - output.video_stream.*.time_base, - ); - enc_packet.*.duration = av.av_rescale_q( - enc_packet.*.duration, - enc_ctx.*.time_base, - output.video_stream.*.time_base, - ); - - _ = av.av_interleaved_write_frame(output.ctx, enc_packet); + if (is_gif_output and gif_filter != null) { + // Feed ASCII-modified RGB24 frame into filtergraph + // Note: format should already be RGB24 from above + std.debug.assert(rgb_frame.*.format == av.AV_PIX_FMT_RGB24); + // Set monotonically increasing PTS in centiseconds + rgb_frame.*.pts = gif_pts; + if (av.av_buffersrc_write_frame(gif_filter.?.src, rgb_frame) < 0) { + return error.FailedToFeedFilterGraph; + } + var filt_frame = av.av_frame_alloc(); + defer av.av_frame_free(&filt_frame); + while (av.av_buffersink_get_frame(gif_filter.?.sink, filt_frame) >= 0) { + var enc_packet = av.av_packet_alloc(); + defer av.av_packet_free(&enc_packet); + if (av.avcodec_send_frame(enc_ctx, filt_frame) >= 0) { + while (av.avcodec_receive_packet(enc_ctx, enc_packet) >= 0) { + enc_packet.*.stream_index = 0; + enc_packet.*.pts = av.av_rescale_q(enc_packet.*.pts, enc_ctx.*.time_base, output.video_stream.*.time_base); + enc_packet.*.dts = av.av_rescale_q(enc_packet.*.dts, enc_ctx.*.time_base, output.video_stream.*.time_base); + enc_packet.*.duration = av.av_rescale_q(enc_packet.*.duration, enc_ctx.*.time_base, output.video_stream.*.time_base); + _ = av.av_interleaved_write_frame(output.ctx, enc_packet); + } + } + av.av_frame_unref(filt_frame); + } + gif_pts += gif_delay_ticks; + } else { + // Non-GIF: scale to encoder format and encode + const out_frame = output_frame.?; + _ = av.sws_scale( + out_sws_ctx.?, + &rgb_frame.*.data, + &rgb_frame.*.linesize, + 0, + rgb_frame.*.height, + &out_frame.*.data, + &out_frame.*.linesize, + ); + out_frame.*.pts = if (frame.*.pts != av.AV_NOPTS_VALUE) frame.*.pts else @intCast(frame_count - 1); + var enc_packet = av.av_packet_alloc(); + defer av.av_packet_free(&enc_packet); + if (av.avcodec_send_frame(enc_ctx, out_frame) >= 0) { + while (av.avcodec_receive_packet(enc_ctx, enc_packet) >= 0) { + enc_packet.*.stream_index = 0; + enc_packet.*.pts = av.av_rescale_q(enc_packet.*.pts, enc_ctx.*.time_base, output.video_stream.*.time_base); + enc_packet.*.dts = av.av_rescale_q(enc_packet.*.dts, enc_ctx.*.time_base, output.video_stream.*.time_base); + enc_packet.*.duration = av.av_rescale_q(enc_packet.*.duration, enc_ctx.*.time_base, output.video_stream.*.time_base); + _ = av.av_interleaved_write_frame(output.ctx, enc_packet); + } } } } else { @@ -694,6 +886,52 @@ fn producerTask( } } } + + // Flush filters (for GIF) then encoder to ensure all delayed frames are written + if (op) |output| { + if (is_gif_output and gif_filter != null) { + _ = av.av_buffersrc_add_frame_flags(gif_filter.?.src, null, 0); + var filt_frame = av.av_frame_alloc(); + defer av.av_frame_free(&filt_frame); + while (av.av_buffersink_get_frame(gif_filter.?.sink, filt_frame) >= 0) { + var pkt2 = av.av_packet_alloc(); + defer av.av_packet_free(&pkt2); + if (av.avcodec_send_frame(enc_ctx, filt_frame) >= 0) { + while (av.avcodec_receive_packet(enc_ctx, pkt2) >= 0) { + pkt2.*.stream_index = 0; + pkt2.*.pts = av.av_rescale_q(pkt2.*.pts, enc_ctx.*.time_base, output.video_stream.*.time_base); + pkt2.*.dts = av.av_rescale_q(pkt2.*.dts, enc_ctx.*.time_base, output.video_stream.*.time_base); + pkt2.*.duration = av.av_rescale_q(pkt2.*.duration, enc_ctx.*.time_base, output.video_stream.*.time_base); + _ = av.av_interleaved_write_frame(output.ctx, pkt2); + } + } + av.av_frame_unref(filt_frame); + } + } + + var flush_pkt = av.av_packet_alloc(); + defer av.av_packet_free(&flush_pkt); + _ = av.avcodec_send_frame(enc_ctx, null); + while (av.avcodec_receive_packet(enc_ctx, flush_pkt) >= 0) { + flush_pkt.*.stream_index = 0; + flush_pkt.*.pts = av.av_rescale_q( + flush_pkt.*.pts, + enc_ctx.*.time_base, + output.video_stream.*.time_base, + ); + flush_pkt.*.dts = av.av_rescale_q( + flush_pkt.*.dts, + enc_ctx.*.time_base, + output.video_stream.*.time_base, + ); + flush_pkt.*.duration = av.av_rescale_q( + flush_pkt.*.duration, + enc_ctx.*.time_base, + output.video_stream.*.time_base, + ); + _ = av.av_interleaved_write_frame(output.ctx, flush_pkt); + } + } } fn convertFrameToAscii(allocator: std.mem.Allocator, frame: *av.AVFrame, args: core.CoreParams) !void { @@ -720,7 +958,10 @@ fn convertFrameToAscii(allocator: std.mem.Allocator, frame: *av.AVFrame, args: c const edge_result = try core.detectEdges(allocator, adjusted_img, args.detect_edges, args.sigma1, args.sigma2); - const ascii_img = try core.generateAsciiArt(allocator, adjusted_img, edge_result, args); + const ascii_img = if (args.render == .Pixels) + try core.generatePixelDither(allocator, adjusted_img, args) + else + try core.generateAsciiArt(allocator, adjusted_img, edge_result, args); // Copy ascii art back to frame const out_w = (adjusted_img.width / args.block_size) * args.block_size; diff --git a/vendor/ffmpeg b/vendor/ffmpeg new file mode 160000 index 0000000..7cce4d8 --- /dev/null +++ b/vendor/ffmpeg @@ -0,0 +1 @@ +Subproject commit 7cce4d88bc74ffd8c417df22946893725f6740ac diff --git a/vendor/nv-codec-headers b/vendor/nv-codec-headers new file mode 160000 index 0000000..b24ba97 --- /dev/null +++ b/vendor/nv-codec-headers @@ -0,0 +1 @@ +Subproject commit b24ba97a1009aabb851ee938a74cd87128fa50c9