{"id":13907028,"url":"https://github.com/kokorin/Jaffree","last_synced_at":"2025-07-18T04:33:33.902Z","repository":{"id":22519742,"uuid":"92283185","full_name":"kokorin/Jaffree","owner":"kokorin","description":"______ Stop the War in Ukraine!  _______ Java ffmpeg and ffprobe command-line wrapper","archived":false,"fork":false,"pushed_at":"2024-08-30T10:16:10.000Z","size":1566,"stargazers_count":461,"open_issues_count":20,"forks_count":77,"subscribers_count":7,"default_branch":"master","last_synced_at":"2024-08-30T17:17:26.231Z","etag":null,"topics":["ffmpeg","ffprobe","java","video","wrapper"],"latest_commit_sha":null,"homepage":"","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/kokorin.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":"publiccode.yml","codemeta":null}},"created_at":"2017-05-24T11:05:41.000Z","updated_at":"2024-08-29T16:12:15.000Z","dependencies_parsed_at":"2023-02-17T18:00:39.352Z","dependency_job_id":"2e0e9280-3dbe-40fb-a770-d912dc65a1b6","html_url":"https://github.com/kokorin/Jaffree","commit_stats":null,"previous_names":[],"tags_count":24,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kokorin%2FJaffree","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kokorin%2FJaffree/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kokorin%2FJaffree/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kokorin%2FJaffree/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/kokorin","download_url":"https://codeload.github.com/kokorin/Jaffree/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":226353549,"owners_count":17611721,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ffmpeg","ffprobe","java","video","wrapper"],"created_at":"2024-08-06T23:01:46.539Z","updated_at":"2024-11-25T15:31:05.903Z","avatar_url":"https://github.com/kokorin.png","language":"Java","funding_links":[],"categories":["HarmonyOS","General Tools"],"sub_categories":["Windows Manager","Command-line Utilities \u0026 Wrappers"],"readme":"[![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg)](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md)\n\n# Jaffree [![Sparkline](https://stars.medv.io/kokorin/Jaffree.svg)](https://stars.medv.io/kokorin/Jaffree)\n\nJaffree stands for JAva FFmpeg and FFprobe FREE command line wrapper. Jaffree supports programmatic video production and consumption (with transparency)\n\nIt integrates with ffmpeg via `java.lang.Process`.\n\nInspired by [ffmpeg-cli-wrapper](https://github.com/bramp/ffmpeg-cli-wrapper)\n\n## Tested with the help of [GitHub Actions](https://github.com/kokorin/Jaffree/blob/master/.github/workflows/tests.yml) \n\n![Tests](https://github.com/kokorin/Jaffree/workflows/Tests/badge.svg)\n[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=kokorin_Jaffree\u0026metric=coverage)](https://sonarcloud.io/dashboard?id=kokorin_Jaffree)\n\n**OS**: Ubuntu, MacOS, Windows\n\n**JDK**: 8, 11, 17\n\n# Usage \n\n[![Maven Central](https://img.shields.io/maven-central/v/com.github.kokorin.jaffree/jaffree.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:%22com.github.kokorin.jaffree%22%20AND%20a:%22jaffree%22)\n\n```xml\n\u003cdependency\u003e\n    \u003cgroupId\u003ecom.github.kokorin.jaffree\u003c/groupId\u003e\n    \u003cartifactId\u003ejaffree\u003c/artifactId\u003e\n    \u003cversion\u003e${jaffree.version}\u003c/version\u003e\n\u003c/dependency\u003e\n\n\u003c!--\n    You should also include slf4j into dependencies.\n    This is done intentionally to allow changing of slf4j version.\n  --\u003e\n\u003cdependency\u003e\n    \u003cgroupId\u003eorg.slf4j\u003c/groupId\u003e\n    \u003cartifactId\u003eslf4j-api\u003c/artifactId\u003e\n    \u003cversion\u003e1.7.25\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\n# Examples\n\n## Checking media streams with ffprobe\n\nSee whole example [here](src/test/java/examples/ShowStreamsExample.java).\n\n```java\nFFprobeResult result = FFprobe.atPath()\n    .setShowStreams(true)\n    .setInput(pathToVideo)\n    .execute();\n\nfor (Stream stream : result.getStreams()) {\n    System.out.println(\"Stream #\" + stream.getIndex()\n        + \" type: \" + stream.getCodecType()\n        + \" duration: \" + stream.getDuration() + \" seconds\");\n}\n```\n\n## Detecting exact media file duration\n\nSometimes ffprobe can't show exact duration, use ffmpeg trancoding to NULL output to get it.\n\nSee whole example [here](src/test/java/examples/ExactDurationExample.java).\n\n```java\nfinal AtomicLong durationMillis = new AtomicLong();\n\nFFmpegResult ffmpegResult = FFmpeg.atPath()\n    .addInput(\n        UrlInput.fromUrl(pathToVideo)\n    )\n    .addOutput(new NullOutput())\n    .setProgressListener(new ProgressListener() {\n        @Override\n        public void onProgress(FFmpegProgress progress) {\n            durationMillis.set(progress.getTimeMillis());\n        }\n    })\n    .execute();\n\nSystem.out.println(\"Exact duration: \" + durationMillis.get() + \" milliseconds\");\n```\n\n## Re-encode and track progress\n\nSee whole example [here](src/test/java/examples/ReEncodeExample.java).\n\n```java\nfinal AtomicLong duration = new AtomicLong();\nFFmpeg.atPath()\n    .addInput(UrlInput.fromUrl(pathToSrc))\n    .setOverwriteOutput(true)\n    .addOutput(new NullOutput())\n    .setProgressListener(new ProgressListener() {\n        @Override\n        public void onProgress(FFmpegProgress progress) {\n            duration.set(progress.getTimeMillis());\n        }\n    })\n    .execute();\n\nFFmpeg.atPath()\n    .addInput(UrlInput.fromUrl(pathToSrc))\n    .setOverwriteOutput(true)\n    .addArguments(\"-movflags\", \"faststart\")\n    .addOutput(UrlOutput.toUrl(pathToDst))\n    .setProgressListener(new ProgressListener() {\n        @Override\n        public void onProgress(FFmpegProgress progress) {\n            double percents = 100. * progress.getTimeMillis() / duration.get();\n            System.out.println(\"Progress: \" + percents + \"%\");\n        }\n    })\n    .execute();\n```\n\n## Cut and scale media file\n\nPay attention that arguments related to Input must be set at Input, not at FFmpeg.\n\nSee whole example [here](src/test/java/examples/CutAndScaleExample.java).\n\n```java\nFFmpeg.atPath()\n    .addInput(\n        UrlInput.fromUrl(pathToSrc)\n                .setPosition(10, TimeUnit.SECONDS)\n                .setDuration(42, TimeUnit.SECONDS)\n    )\n    .setFilter(StreamType.VIDEO, \"scale=160:-2\")\n    .setOverwriteOutput(true)\n    .addArguments(\"-movflags\", \"faststart\")\n    .addOutput(\n        UrlOutput.toUrl(pathToDst)\n                 .setPosition(10, TimeUnit.SECONDS)\n    )\n    .execute();\n```\n\n## Custom parsing of ffmpeg output\n\nSee whole example [here](src/test/java/examples/ParsingOutputExample.java).\n\n```java\n// StringBuffer - because it's thread safe\nfinal StringBuffer loudnormReport = new StringBuffer();\n\nFFmpeg.atPath()\n    .addInput(UrlInput.fromUrl(pathToVideo))\n    .addArguments(\"-af\", \"loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json\")\n    .addOutput(new NullOutput(false))\n    .setOutputListener(new OutputListener() {\n        @Override\n        public void onOutput(String line) {\n            loudnormReport.append(line);\n        }\n    })\n    .execute();\n\nSystem.out.println(\"Loudnorm report:\\n\" + loudnormReport);\n```\n\n## Supplying and consuming data with SeekableByteChannel\n\nAbility to interact with SeekableByteChannel is one of the features, which distinct Jaffree from \nsimilar libraries. Under the hood Jaffree uses tiny FTP server to interact with SeekableByteChannel.\n\nSee whole example [here](src/test/java/examples/UsingChannelsExample.java).\n```java\ntry (SeekableByteChannel inputChannel =\n         Files.newByteChannel(pathToSrc, StandardOpenOption.READ);\n     SeekableByteChannel outputChannel =\n         Files.newByteChannel(pathToDst, StandardOpenOption.CREATE,\n                 StandardOpenOption.WRITE, StandardOpenOption.READ,\n                 StandardOpenOption.TRUNCATE_EXISTING)\n) {\n    FFmpeg.atPath()\n        .addInput(ChannelInput.fromChannel(inputChannel))\n        .addOutput(ChannelOutput.toChannel(filename, outputChannel))\n        .execute();\n}\n```\n\n## Supplying and consuming data with InputStream and OutputStream\n\n**Notice** It's recommended to use `ChannelInput` \u0026 `ChannelOutput` since ffmpeg leverage seeking in input and \nrequires seekable output for many formats.\n\nUnder the hood pipes are not OS pipes, but TCP Sockets. This allows much higher bandwidth.\n\nSee whole example [here](src/test/java/examples/UsingStreamsExample.java).\n\n```java\ntry (InputStream inputStream =\n         Files.newInputStream(pathToSrc);\n     OutputStream outputStream =\n         Files.newOutputStream(pathToDst, StandardOpenOption.CREATE,\n                 StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)\n) {\n    FFmpeg.atPath()\n        .addInput(PipeInput.pumpFrom(inputStream))\n        .addOutput(\n                PipeOutput.pumpTo(outputStream)\n                        .setFormat(\"flv\")\n        )\n        .execute();\n}\n```\n\n## Live Stream Re-Streaming (HLS)\n\nSee whole example [here](src/test/java/examples/ReStreamWithHls.java).\n\n```java\nFFmpeg.atPath()\n    .addInput(\n        UrlInput.fromUrl(liveStream)\n    )\n    .addOutput(\n        UrlOutput.toPath(dir.resolve(\"index.m3u8\"))\n            .setFrameRate(30)\n            // check all available options: ffmpeg -help muxer=hls\n            .setFormat(\"hls\")\n            // enforce keyframe every 2s - see setFrameRate\n            .addArguments(\"-x264-params\", \"keyint=60\")\n            .addArguments(\"-hls_list_size\", \"5\")\n            .addArguments(\"-hls_delete_threshold\", \"5\")\n            .addArguments(\"-hls_time\", \"2\")\n            .addArguments(\"-hls_flags\", \"delete_segments\")\n    )\n    .setOverwriteOutput(true)\n    .execute();\n```\n\n## Screen Capture\n\nSee whole example [here](src/test/java/examples/ScreenCaptureExample.java).\n\n```java\nFFmpeg.atPath()\n    .addInput(CaptureInput\n            .captureDesktop()\n            .setCaptureFrameRate(30)\n            .setCaptureCursor(true)\n    )\n    .addOutput(UrlOutput\n            .toPath(pathToVideo)\n            // Record with ultrafast to lower CPU usage\n            .addArguments(\"-preset\", \"ultrafast\")\n            .setDuration(30, TimeUnit.SECONDS)\n    )\n    .setOverwriteOutput(true)\n    .execute();\n\n//Re-encode when record is completed to optimize file size \nPath pathToOptimized = pathToVideo.resolveSibling(\"optimized-\" + pathToVideo.getFileName());\nFFmpeg.atPath()\n    .addInput(UrlInput.fromPath(pathToVideo))\n    .addOutput(UrlOutput.toPath(pathToOptimized))\n    .execute();\n\nFiles.move(pathToOptimized, pathToVideo, StandardCopyOption.REPLACE_EXISTING);\n```\n\n## Produce Video in Pure Java Code\n\nSee whole example [here](src/test/java/examples/ProduceVideoExample.java). \nCheck also more [advanced example](src/test/java/examples/BouncingBallExample.java) which produce \nboth audio and video \n\n```java\nFrameProducer producer = new FrameProducer() {\n    private long frameCounter = 0;\n\n    @Override\n    public List\u003cStream\u003e produceStreams() {\n        return Collections.singletonList(new Stream()\n                .setType(Stream.Type.VIDEO)\n                .setTimebase(1000L)\n                .setWidth(320)\n                .setHeight(240)\n        );\n    }\n\n    @Override\n    public Frame produce() {\n        if (frameCounter \u003e 30) {\n            return null; // return null when End of Stream is reached\n        }\n\n        BufferedImage image = new BufferedImage(320, 240, BufferedImage.TYPE_3BYTE_BGR);\n        Graphics2D graphics = image.createGraphics();\n        graphics.setPaint(new Color(frameCounter * 1.0f / 30, 0, 0));\n        graphics.fillRect(0, 0, 320, 240);\n        long pts = frameCounter * 1000 / 10; // Frame PTS in Stream Timebase\n        Frame videoFrame = Frame.createVideoFrame(0, pts, image);\n        frameCounter++;\n\n        return videoFrame;\n    }\n};\n\nFFmpeg.atPath()\n    .addInput(FrameInput.withProducer(producer))\n    .addOutput(UrlOutput.toUrl(pathToVideo))\n    .execute();\n```\n\nHere is an output of the above example:\n\n![example output](src/test/resources/examples/programmatic.gif)\n\n### Consume Video in Pure Java Code\n\nSee whole example [here](src/test/java/examples/ExtractFramesExample.java).\n\n```java\nFFmpeg.atPath()\n        .addInput(UrlInput\n                .fromPath(pathToSrc)\n        )\n        .addOutput(FrameOutput\n                .withConsumer(\n                        new FrameConsumer() {\n                            private long num = 1;\n\n                            @Override\n                            public void consumeStreams(List\u003cStream\u003e streams) {\n                                // All stream type except video are disabled. just ignore\n                            }\n\n                            @Override\n                            public void consume(Frame frame) {\n                                // End of Stream\n                                if (frame == null) {\n                                    return;\n                                }\n\n                                try {\n                                    String filename = \"frame_\" + num++ + \".png\";\n                                    Path output = pathToDstDir.resolve(filename);\n                                    ImageIO.write(frame.getImage(), \"png\", output.toFile());\n                                } catch (Exception e) {\n                                    e.printStackTrace();\n                                }\n                            }\n                        }\n                )\n                // No more then 100 frames\n                .setFrameCount(StreamType.VIDEO, 100L)\n                // 1 frame every 10 seconds\n                .setFrameRate(0.1)\n                // Disable all streams except video\n                .disableStream(StreamType.AUDIO)\n                .disableStream(StreamType.SUBTITLE)\n                .disableStream(StreamType.DATA)\n        )\n        .execute();\n```\n\n## Managing errors\n\nJaffree will raise exceptions when a fatal error that causes a non-zero exit code occurs.\n\nIn some cases an error can occur but FFmpeg manages to catch it and exit correctly. This can be a \nconvenient case, although sometimes one would prefer an exception to be raised to Jaffree.\n\nTo do so, the [`-xerror`](https://ffmpeg.org/ffmpeg.html#Advanced-options) argument can be used to\ntell FFmpeg to exit the process with an error status when an error occurs.\n\n```java\nFFmpeg.atPath()\n      .addArgument(\"-xerror\")\n      // ...\n```\n\nPlease see [Issue 276](https://github.com/kokorin/Jaffree/issues/276) for more details on an actual\nusecase.\n\n## FFmpeg stop\n\nSee whole examples [here](src/test/java/examples/StopExample.java).\n\n### Grace stop\n\nStart ffmpeg with `FFmpeg#executeAsync` and stop it with `FFmpegResultFuture#graceStop` (ffmpeg only).\nThis will pass `q` symbol to ffmpeg's stdin.\n\n**Note** output media finalization may take some time - up to several seconds.\n\n```java\nFFmpegResultFuture future = ffmpeg.executeAsync();\n\nThread.sleep(5_000);\nfuture.graceStop();\n```\n\n### Force stop\n\nThere are 3 ways to stop ffmpeg forcefully.\n\n**Note**: ffmpeg may not (depending on output format) correctly finalize output. \nIt's very likely that produced media will be corrupted with force stop.\n\n* Throw an exception in `ProgressListener` (ffmpeg only)\n```java\nfinal AtomicBoolean stopped = new AtomicBoolean();\nffmpeg.setProgressListener(\n        new ProgressListener() {\n            @Override\n            public void onProgress(FFmpegProgress progress) {\n                if (stopped.get()) {\n                    throw new RuntimeException(\"Stopped with exception!\");\n                }\n            }\n        }\n);\n```\n\n* Start ffmpeg with `FFmpeg#executeAsync` and stop it with `FFmpegResultFuture#forceStop` (ffmpeg only)\n```java\nFFmpegResultFuture future = ffmpeg.executeAsync();\n\nThread.sleep(5_000);\nfuture.forceStop();\n```\n\n* Start ffmpeg with `FFmpeg#execute` (or ffprobe with `FFprobe#execute`) and interrupt thread\n```java\nThread thread = new Thread() {\n    @Override\n    public void run() {\n        ffmpeg.execute();\n    }\n};\nthread.start();\n\nThread.sleep(5_000);\nthread.interrupt();\n```\n\n## Java 8 Completion API\n\nSee whole examples [here](src/test/java/examples/CompletionExample.java).\n\n```java\nffmpeg.executeAsync().toCompletableFuture()\n    .thenAccept(res -\u003e {\n        // get the result of the operation when it is done\n    })\n    .exceptionally(ex -\u003e {\n        // handle exceptions produced during operation\n    });\n```\n\n## Complex Filtergraph (mosaic video)\n\nMore details about this example can be found on ffmpeg wiki:\n[Create a mosaic out of several input videos](https://trac.ffmpeg.org/wiki/Create%20a%20mosaic%20out%20of%20several%20input%20videos)\n\n```java\nFFmpegResult result = FFmpeg.atPath(BIN)\n        .addInput(UrlInput.fromPath(VIDEO1_MP4).setDuration(10, TimeUnit.SECONDS))\n        .addInput(UrlInput.fromPath(VIDEO2_MP4).setDuration(10, TimeUnit.SECONDS))\n        .addInput(UrlInput.fromPath(VIDEO3_MP4).setDuration(10, TimeUnit.SECONDS))\n        .addInput(UrlInput.fromPath(VIDEO4_MP4).setDuration(10, TimeUnit.SECONDS))\n\n        .setComplexFilter(FilterGraph.of(\n                FilterChain.of(\n                        Filter.withName(\"nullsrc\")\n                                .addArgument(\"size\", \"640x480\")\n                                .addOutputLink(\"base\")\n                ),\n                FilterChain.of(\n                        Filter.fromInputLink(StreamSpecifier.withInputIndexAndType(0, StreamType.ALL_VIDEO))\n                                .setName(\"setpts\")\n                                .addArgument(\"PTS-STARTPTS\"),\n                        Filter.withName(\"scale\")\n                                .addArgument(\"320x240\")\n                                .addOutputLink(\"upperleft\")\n                ),\n                FilterChain.of(\n                        Filter.fromInputLink(StreamSpecifier.withInputIndexAndType(1, StreamType.ALL_VIDEO))\n                                .setName(\"setpts\")\n                                .addArgument(\"PTS-STARTPTS\"),\n                        Filter.withName(\"scale\")\n                                .addArgument(\"320x240\")\n                                .addOutputLink(\"upperright\")\n                ),\n                FilterChain.of(\n                        Filter.fromInputLink(StreamSpecifier.withInputIndexAndType(2, StreamType.ALL_VIDEO))\n                                .setName(\"setpts\")\n                                .addArgument(\"PTS-STARTPTS\"),\n                        Filter.withName(\"scale\")\n                                .addArgument(\"320x240\")\n                                .addOutputLink(\"lowerleft\")\n                ),\n                FilterChain.of(\n                        Filter.fromInputLink(StreamSpecifier.withInputIndexAndType(3, StreamType.ALL_VIDEO))\n                                .setName(\"setpts\")\n                                .addArgument(\"PTS-STARTPTS\"),\n                        Filter.withName(\"scale\")\n                                .addArgument(\"320x240\")\n                                .addOutputLink(\"lowerright\")\n                ),\n                FilterChain.of(\n                        Filter.fromInputLink(\"base\")\n                                .addInputLink(\"upperleft\")\n                                .setName(\"overlay\")\n                                .addArgument(\"shortest\", \"1\")\n                                .addOutputLink(\"tmp1\")\n                ),\n                FilterChain.of(\n                        Filter.fromInputLink(\"tmp1\")\n                                .addInputLink(\"upperright\")\n                                .setName(\"overlay\")\n                                //.addArgument(\"shortest\", \"1\")\n                                .addArgument(\"x\", \"320\")\n                                .addOutputLink(\"tmp2\")\n                ),\n                FilterChain.of(\n                        Filter.fromInputLink(\"tmp2\")\n                                .addInputLink(\"lowerleft\")\n                                .setName(\"overlay\")\n                                //.addArgument(\"shortest\", \"1\")\n                                .addArgument(\"y\", \"240\")\n                                .addOutputLink(\"tmp3\")\n                ),\n                FilterChain.of(\n                        Filter.fromInputLink(\"tmp3\")\n                                .addInputLink(\"lowerright\")\n                                .setName(\"overlay\")\n                                //.addArgument(\"shortest\", \"1\")\n                                .addArgument(\"x\", \"320\")\n                                .addArgument(\"y\", \"240\")\n                )\n        ))\n\n        .addOutput(UrlOutput.toPath(outputPath))\n        .execute();\n```\n\n## Programmatic mosaic video creation\n\nJaffree allows simultaneous reading from several sources (with one instance per every source and target).\nYou can find details in  Mosaic [example](src/test/java/examples/MosaicExample.java).\n\n# Build \u0026 configuration\n\n## IntelliJ IDEA\n\nThis project uses [maven-git-versioning-extension](https://github.com/qoomon/maven-git-versioning-extension)\nto set project version automatically.\nCheck its [intellij-setup](https://github.com/qoomon/maven-git-versioning-extension#intellij-setup) documentation.\n\n## JDK\n\nJDK8 is required to compile this project. JDK9 is required to compile this project with Java 9 module support.\n\n### JDK8\n\n```shell\nmvn clean install\n```\n\n### JDK9\n\nMaven profile `J9-module` enables two pass compilation:\n\n1. Compile all sources with Java 9 target version (including `module-info.java`).\n2. Recompile all sources (except `module-info.java`) with Java 8 target version.\n   \nAfter this all classes will have Java 8 bytecode (version 52), while `module-info.class`\nwill have Java 9 bytecode (version 53).\n\n```shell\nmvn clean install -PJ9-module\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkokorin%2FJaffree","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fkokorin%2FJaffree","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkokorin%2FJaffree/lists"}