diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cde4ac6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,10 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. + +In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. + +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 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. + +For more information, please refer to diff --git a/README.md b/README.md index c0a9e82..56d6594 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ The app looks for `ffmpeg` and `ffprobe` in this order: 3. Your `PATH` The release archives (`FFmpegWrapper--windows-x64.zip` / `-linux-x64.tar.gz`) already -include ffmpeg and ffprobe next to the executable. If you use the bare executable, install ffmpeg -(`sudo apt install ffmpeg`, `winget install ffmpeg`, ...) or point the app at it. +include ffmpeg and ffprobe next to the executable, with ffmpeg's license as `ffmpeg-LICENSE.txt`. +If you use the bare executable, install ffmpeg (`sudo apt install ffmpeg`, `winget install ffmpeg`, +...) or point the app at it. ## Running from source @@ -68,7 +69,7 @@ PyInstaller does not cross-compile. Build the Windows `.exe` on Windows and the ```bash pip install -r requirements-dev.txt python build.py # -> dist/FFmpegWrapper or dist/FFmpegWrapper.exe -python build.py --ffmpeg-dir ffmpeg/bin # also bundles ffmpeg + ffprobe into a .zip / .tar.gz +python build.py --ffmpeg-dir ffmpeg/bin # also bundles ffmpeg + ffprobe + their license into a .zip / .tar.gz python scripts/smoke_test.py dist/FFmpegWrapper # optional: headless start-up check ``` @@ -87,9 +88,11 @@ Forgejo Actions can run it too, but they need a Windows runner registered. - **PySide6 is pinned below 6.12.** PySide6 6.12.0 mis-counts references to Python's `None` (in `QComboBox.clear()`/`currentData()`), which crashes the app on Python 3.11. Python 3.12+ is not affected, but keep the pin until a fixed PySide6 ships. -- **Licensing:** the bundled ffmpeg builds come from - [BtbN/FFmpeg-Builds](https://github.com/BtbN/FFmpeg-Builds) and are GPL. Keep that in mind if you - redistribute the archives. +- **ffmpeg licensing:** the bundled ffmpeg builds come from + [BtbN/FFmpeg-Builds](https://github.com/BtbN/FFmpeg-Builds) and are GPLv3, so their license has to + ship with them. `scripts/fetch_ffmpeg.py` saves it as `ffmpeg/LICENSE.txt`, and + `build.py --ffmpeg-dir` copies it into the archive as `ffmpeg-LICENSE.txt`. It looks in the ffmpeg + folder and the folder above it, and stops with an error if the license isn't in either. - Settings (last options, window size, ffmpeg location) are saved per user through Qt's QSettings: the registry on Windows, `~/.config/ffwrap/FFmpegWrapper.conf` on Linux. @@ -106,3 +109,12 @@ build.py PyInstaller build + packaging scripts/ fetch_ffmpeg.py, smoke_test.py, make_icon.py tests/ unit tests + real-ffmpeg integration tests ``` + +## License + +FFmpeg Wrapper is public domain, released under [the Unlicense](LICENSE). Kopimi: copy it, change +it, share it, sell it. No permission needed. + +That covers this project's own code. The ffmpeg and ffprobe binaries in the release archives are +GPLv3 (license in `ffmpeg-LICENSE.txt`, source at ), and the Qt +libraries built into the executable are LGPLv3. diff --git a/build.py b/build.py index 5edd9d6..334211c 100644 --- a/build.py +++ b/build.py @@ -1,7 +1,8 @@ """Build a single-file GUI executable for the current OS with PyInstaller. python build.py # -> dist/FFmpegWrapper(.exe) - python build.py --ffmpeg-dir DIR # also copy ffmpeg + ffprobe from DIR next to the exe + python build.py --ffmpeg-dir DIR # also copy ffmpeg + ffprobe from DIR next to the exe, + # plus ffmpeg's LICENSE from DIR or its parent, # and pack everything into a .zip / .tar.gz PyInstaller cannot cross-compile: run this on Windows to get the .exe and on @@ -21,12 +22,29 @@ IS_WINDOWS = sys.platform.startswith("win") EXE = ".exe" if IS_WINDOWS else "" +def find_ffmpeg_license(ffmpeg_dir: Path) -> Path | None: + """ffmpeg's license file. BtbN and gyan.dev builds keep it one level above bin/.""" + for folder in (ffmpeg_dir, ffmpeg_dir.parent): + for name in ("LICENSE.txt", "LICENSE", "ffmpeg-LICENSE.txt"): + if (folder / name).is_file(): + return folder / name + return None + + def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--ffmpeg-dir", type=Path, help="folder containing ffmpeg and ffprobe to bundle") ap.add_argument("--onedir", action="store_true", help="build a folder instead of a single file (faster start-up)") args = ap.parse_args() + if args.ffmpeg_dir: + # Check before the slow PyInstaller run: ffmpeg is GPL, so its license must ship with it. + ffmpeg_license = find_ffmpeg_license(args.ffmpeg_dir) + if ffmpeg_license is None: + print(f"error: no ffmpeg license (LICENSE.txt or LICENSE) in {args.ffmpeg_dir} or its parent " + "folder. ffmpeg is GPL, so the license has to be packaged with it.", file=sys.stderr) + return 1 + import PyInstaller.__main__ from ffwrap import __version__ @@ -56,7 +74,7 @@ def main() -> int: print(f"\nBuilt {built}") if args.ffmpeg_dir: - # Stage + ffmpeg + ffprobe in one folder and archive it. + # Stage + ffmpeg + ffprobe + ffmpeg's license in one folder and archive it. stage = dist / "package" / NAME shutil.rmtree(stage.parent, ignore_errors=True) if args.onedir: @@ -70,7 +88,8 @@ def main() -> int: print(f"error: {src} not found", file=sys.stderr) return 1 shutil.copy2(src, stage) - plat = "windows-x64" if IS_WINDOWS else "linux-x64" + shutil.copy2(ffmpeg_license, stage / "ffmpeg-LICENSE.txt") + plat ="windows-x64" if IS_WINDOWS else "linux-x64" archive = shutil.make_archive( str(dist / f"{NAME}-{__version__}-{plat}"), "zip" if IS_WINDOWS else "gztar", root_dir=stage.parent, base_dir=NAME) diff --git a/scripts/fetch_ffmpeg.py b/scripts/fetch_ffmpeg.py index 94afb54..2106ee1 100644 --- a/scripts/fetch_ffmpeg.py +++ b/scripts/fetch_ffmpeg.py @@ -2,7 +2,8 @@ Uses the GPL builds from https://github.com/BtbN/FFmpeg-Builds (newest stable release branch). The app finds ./ffmpeg/bin automatically when run from source, -and build.py --ffmpeg-dir ffmpeg/bin bundles them with the executable. +and build.py --ffmpeg-dir ffmpeg/bin bundles them with the executable, together +with the build's license, which is saved as ./ffmpeg/LICENSE.txt. """ from __future__ import annotations @@ -52,27 +53,50 @@ def main() -> int: archive = Path(tmp) / asset["name"] with urllib.request.urlopen(asset["browser_download_url"], timeout=60) as r, open(archive, "wb") as f: shutil.copyfileobj(r, f) - DEST.mkdir(parents=True, exist_ok=True) - wanted = {f"ffmpeg{exe}", f"ffprobe{exe}"} - if archive.suffix == ".zip": - with zipfile.ZipFile(archive) as z: - for info in z.infolist(): - name = Path(info.filename).name - if name in wanted and "/bin/" in info.filename: - (DEST / name).write_bytes(z.read(info)) - else: - with tarfile.open(archive) as t: - for m in t.getmembers(): - name = Path(m.name).name - if name in wanted and "/bin/" in m.name: - with t.extractfile(m) as src, open(DEST / name, "wb") as out: - shutil.copyfileobj(src, out) - (DEST / name).chmod(0o755) - missing = [n for n in wanted if not (DEST / n).is_file()] + extract(archive, exe, DEST) + print(f"Installed ffmpeg and ffprobe into {DEST}, and their license as {DEST.parent / 'LICENSE.txt'}") + return 0 + + +def extract(archive: Path, exe: str, dest: Path) -> None: + """Copy ffmpeg + ffprobe from the archive's bin/ into dest, and its LICENSE.txt into dest's parent. + + A BtbN archive holds one top folder with bin/ and LICENSE.txt in it. ffmpeg is GPL, so the + license has to ship with the binaries; build.py picks it up from there. + """ + dest.mkdir(parents=True, exist_ok=True) + license_out = dest.parent / "LICENSE.txt" + license_out.unlink(missing_ok=True) + wanted = {f"ffmpeg{exe}", f"ffprobe{exe}"} + + def target(member: str) -> Path | None: + name = Path(member).name + if name in wanted and "/bin/" in member: + return dest / name + if member.strip("/").count("/") == 1 and name == "LICENSE.txt": + return license_out + return None + + if archive.suffix == ".zip": + with zipfile.ZipFile(archive) as z: + for info in z.infolist(): + out = target(info.filename) + if out: + out.write_bytes(z.read(info)) + else: + with tarfile.open(archive) as t: + for m in t.getmembers(): + out = target(m.name) if m.isfile() else None + if out: + with t.extractfile(m) as src, open(out, "wb") as f: + shutil.copyfileobj(src, f) + if out.name in wanted: + out.chmod(0o755) + missing = [n for n in sorted(wanted) if not (dest / n).is_file()] + if not license_out.is_file(): + missing.append("LICENSE.txt") if missing: raise SystemExit(f"Archive did not contain {missing}") - print(f"Installed ffmpeg and ffprobe into {DEST}") - return 0 if __name__ == "__main__":