diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 073835d..0000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Build and Publish Docker Image - -on: - push: - branches: [main, master] - paths: # run only when this file changed at all - - "unshackle/core/__init__.py" - pull_request: {} # optional – delete if you don’t build on PRs - workflow_dispatch: {} # manual override - -jobs: - detect-version-change: - runs-on: ubuntu-latest - outputs: - changed: ${{ steps.vdiff.outputs.changed }} - version: ${{ steps.vdiff.outputs.version }} - - steps: - - uses: actions/checkout@v4 - with: { fetch-depth: 2 } # we need the previous commit :contentReference[oaicite:1]{index=1} - - - name: Extract & compare version - id: vdiff - shell: bash - run: | - current=$(grep -oP '__version__ = "\K[^"]+' unshackle/core/__init__.py) - prev=$(git show HEAD^:unshackle/core/__init__.py \ - | grep -oP '__version__ = "\K[^"]+' || echo '') - echo "version=$current" >>"$GITHUB_OUTPUT" - echo "changed=$([ "$current" != "$prev" ] && echo true || echo false)" >>"$GITHUB_OUTPUT" - echo "Current=$current Previous=$prev" - - build-and-push: - needs: detect-version-change - if: needs.detect-version-change.outputs.changed == 'true' # only run when bumped :contentReference[oaicite:2]{index=2} - runs-on: ubuntu-latest - permissions: { contents: read, packages: write } - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Extract version from __init__.py - id: version - run: | - VERSION=$(grep -oP '__version__ = "\K[^"]+' unshackle/core/__init__.py) - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "major_minor=$(echo $VERSION | cut -d. -f1-2)" >> $GITHUB_OUTPUT - echo "major=$(echo $VERSION | cut -d. -f1)" >> $GITHUB_OUTPUT - echo "Extracted version: $VERSION" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Container Registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest,enable={{is_default_branch}} - type=raw,value=v${{ steps.version.outputs.version }},enable={{is_default_branch}} - type=raw,value=${{ steps.version.outputs.version }},enable={{is_default_branch}} - type=raw,value=${{ steps.version.outputs.major_minor }},enable={{is_default_branch}} - type=raw,value=${{ steps.version.outputs.major }},enable={{is_default_branch}} - - - name: Show planned tags - run: | - echo "Planning to create the following tags:" - echo "${{ steps.meta.outputs.tags }}" - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Test Docker image - if: github.event_name != 'pull_request' - run: | - docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest env check diff --git a/unshackle/core/binaries.py b/unshackle/core/binaries.py index 287bdb7..ccc5267 100644 --- a/unshackle/core/binaries.py +++ b/unshackle/core/binaries.py @@ -52,6 +52,7 @@ N_m3u8DL_RE = find("N_m3u8DL-RE", "n-m3u8dl-re") MKVToolNix = find("mkvmerge") Mkvpropedit = find("mkvpropedit") DoviTool = find("dovi_tool") +HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool") __all__ = ( @@ -69,5 +70,6 @@ __all__ = ( "MKVToolNix", "Mkvpropedit", "DoviTool", + "HDR10PlusTool", "find", ) diff --git a/unshackle/core/tracks/hybrid.py b/unshackle/core/tracks/hybrid.py index 6f8ee43..7100c6f 100644 --- a/unshackle/core/tracks/hybrid.py +++ b/unshackle/core/tracks/hybrid.py @@ -8,7 +8,7 @@ from pathlib import Path from rich.padding import Padding from rich.rule import Rule -from unshackle.core.binaries import DoviTool +from unshackle.core.binaries import DoviTool, HDR10PlusTool from unshackle.core.config import config from unshackle.core.console import console @@ -20,6 +20,7 @@ class Hybrid: """ Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks. It will then attempt to inject the Dolby Vision metadata layer to the HDR10(+) stream. + If no DV track is available but HDR10+ is present, it will convert HDR10+ to DV. """ global directories from unshackle.core.tracks import Video @@ -29,10 +30,14 @@ class Hybrid: self.rpu_file = "RPU.bin" self.hdr_type = "HDR10" self.hevc_file = f"{self.hdr_type}-DV.hevc" + self.hdr10plus_to_dv = False + self.hdr10plus_file = "HDR10Plus.json" # Get resolution info from HDR10 track for display hdr10_track = next((v for v in videos if v.range == Video.Range.HDR10), None) - self.resolution = f"{hdr10_track.height}p" if hdr10_track and hdr10_track.height else "Unknown" + hdr10p_track = next((v for v in videos if v.range == Video.Range.HDR10P), None) + track_for_res = hdr10_track or hdr10p_track + self.resolution = f"{track_for_res.height}p" if track_for_res and track_for_res.height else "Unknown" console.print(Padding(Rule(f"[rule.text]HDR10+DV Hybrid ({self.resolution})"), (1, 2))) @@ -40,10 +45,20 @@ class Hybrid: if not video.path or not os.path.exists(video.path): self.log.exit(f" - Video track {video.id} was not downloaded before injection.") - if not any(video.range == Video.Range.DV for video in self.videos) or not any( - video.range == Video.Range.HDR10 for video in self.videos - ): - self.log.exit(" - Two VideoTracks available but one of them is not DV nor HDR10(+).") + # Check if we have DV track available + has_dv = any(video.range == Video.Range.DV for video in self.videos) + has_hdr10 = any(video.range == Video.Range.HDR10 for video in self.videos) + has_hdr10p = any(video.range == Video.Range.HDR10P for video in self.videos) + + if not has_hdr10: + self.log.exit(" - No HDR10 track available for hybrid processing.") + + # If we have HDR10+ but no DV, we can convert HDR10+ to DV + if not has_dv and has_hdr10p: + self.log.info("✓ No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.") + self.hdr10plus_to_dv = True + elif not has_dv: + self.log.exit(" - No DV track available and no HDR10+ to convert.") if os.path.isfile(config.directories.temp / self.hevc_file): self.log.info("✓ Already Injected") @@ -57,17 +72,28 @@ class Hybrid: if video.range == Video.Range.HDR10: self.extract_stream(save_path, "HDR10") + elif video.range == Video.Range.HDR10P: + self.extract_stream(save_path, "HDR10") + self.hdr_type = "HDR10+" elif video.range == Video.Range.DV: self.extract_stream(save_path, "DV") - self.extract_rpu([video for video in videos if video.range == Video.Range.DV][0]) - if os.path.isfile(config.directories.temp / "RPU_UNT.bin"): - self.rpu_file = "RPU_UNT.bin" - self.level_6() - # Mode 3 conversion already done during extraction when not untouched - elif os.path.isfile(config.directories.temp / "RPU.bin"): - # RPU already extracted with mode 3 - pass + if self.hdr10plus_to_dv: + # Extract HDR10+ metadata and convert to DV + hdr10p_video = next(v for v in videos if v.range == Video.Range.HDR10P) + self.extract_hdr10plus(hdr10p_video) + self.convert_hdr10plus_to_dv() + else: + # Regular DV extraction + dv_video = next(v for v in videos if v.range == Video.Range.DV) + self.extract_rpu(dv_video) + if os.path.isfile(config.directories.temp / "RPU_UNT.bin"): + self.rpu_file = "RPU_UNT.bin" + self.level_6() + # Mode 3 conversion already done during extraction when not untouched + elif os.path.isfile(config.directories.temp / "RPU.bin"): + # RPU already extracted with mode 3 + pass self.injecting() @@ -75,9 +101,9 @@ class Hybrid: if self.source == ("itunes" or "appletvplus"): Path.unlink(config.directories.temp / "hdr10.mkv") Path.unlink(config.directories.temp / "dv.mkv") - Path.unlink(config.directories.temp / "DV.hevc") - Path.unlink(config.directories.temp / "HDR10.hevc") - Path.unlink(config.directories.temp / f"{self.rpu_file}") + Path.unlink(config.directories.temp / "HDR10.hevc", missing_ok=True) + Path.unlink(config.directories.temp / "DV.hevc", missing_ok=True) + Path.unlink(config.directories.temp / f"{self.rpu_file}", missing_ok=True) def ffmpeg_simple(self, save_path, output): """Simple ffmpeg execution without progress tracking""" @@ -188,17 +214,25 @@ class Hybrid: self.log.info(f"+ Injecting Dolby Vision metadata into {self.hdr_type} stream") + inject_cmd = [ + str(DoviTool), + "inject-rpu", + "-i", + config.directories.temp / "HDR10.hevc", + "--rpu-in", + config.directories.temp / self.rpu_file, + ] + + # If we converted from HDR10+, optionally remove HDR10+ metadata during injection + # Default to removing HDR10+ metadata since we're converting to DV + if self.hdr10plus_to_dv: + inject_cmd.append("--drop-hdr10plus") + self.log.info(" - Removing HDR10+ metadata during injection") + + inject_cmd.extend(["-o", config.directories.temp / self.hevc_file]) + inject = subprocess.run( - [ - str(DoviTool), - "inject-rpu", - "-i", - config.directories.temp / f"{self.hdr_type}.hevc", - "--rpu-in", - config.directories.temp / self.rpu_file, - "-o", - config.directories.temp / self.hevc_file, - ], + inject_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -206,3 +240,80 @@ class Hybrid: if inject.returncode: Path.unlink(config.directories.temp / self.hevc_file) self.log.exit("x Failed injecting Dolby Vision metadata into HDR10 stream") + + def extract_hdr10plus(self, _video): + """Extract HDR10+ metadata from the video stream""" + if os.path.isfile(config.directories.temp / self.hdr10plus_file): + return + + if not HDR10PlusTool: + self.log.exit("x HDR10Plus_tool not found. Please install it to use HDR10+ to DV conversion.") + + self.log.info("+ Extracting HDR10+ metadata") + + # HDR10Plus_tool needs raw HEVC stream + extraction = subprocess.run( + [ + str(HDR10PlusTool), + "extract", + str(config.directories.temp / "HDR10.hevc"), + "-o", + str(config.directories.temp / self.hdr10plus_file), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + if extraction.returncode: + self.log.exit("x Failed extracting HDR10+ metadata") + + # Check if the extracted file has content + if os.path.getsize(config.directories.temp / self.hdr10plus_file) == 0: + self.log.exit("x No HDR10+ metadata found in the stream") + + def convert_hdr10plus_to_dv(self): + """Convert HDR10+ metadata to Dolby Vision RPU""" + if os.path.isfile(config.directories.temp / "RPU.bin"): + return + + self.log.info("+ Converting HDR10+ metadata to Dolby Vision") + + # First create the extra metadata JSON for dovi_tool + extra_metadata = { + "cm_version": "V29", + "length": 0, # dovi_tool will figure this out + "level6": { + "max_display_mastering_luminance": 1000, + "min_display_mastering_luminance": 1, + "max_content_light_level": 0, + "max_frame_average_light_level": 0, + }, + } + + with open(config.directories.temp / "extra.json", "w") as f: + json.dump(extra_metadata, f, indent=2) + + # Generate DV RPU from HDR10+ metadata + conversion = subprocess.run( + [ + str(DoviTool), + "generate", + "-j", + str(config.directories.temp / "extra.json"), + "--hdr10plus-json", + str(config.directories.temp / self.hdr10plus_file), + "-o", + str(config.directories.temp / "RPU.bin"), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + if conversion.returncode: + self.log.exit("x Failed converting HDR10+ to Dolby Vision") + + self.log.info("✓ HDR10+ successfully converted to Dolby Vision Profile 8") + + # Clean up temporary files + Path.unlink(config.directories.temp / "extra.json") + Path.unlink(config.directories.temp / self.hdr10plus_file)