Bisqwit's script for creating DVD menus

By Joel Yliluoma, June 2010

Introduction

This script creates a graphical DVD menu with background music. It is written in PHP.


Source code

Configuration code

This is your main code. It configures the script.

Save it as, for example, make-menu.php.

<?php
## Required programs:
##   ffmpeg (libavcodec mother project)
##   mplex  (from mjpegtools)
##   spumux (comes from dvdauthor)
## Required PHP extensions:
##   GD2
##   Freetype
## Required minimal PHP version:
##   4.0.3 (for escapeshellarg)
##   5     (for file_put_contents)

/* Where do you want the root menu mpeg to be stored */
$g_output_menu = 'root_menu.mpg';

/* A static backgournd image and a music for the main menu. You provide these. */
$g_background_image = 'backgroundim.jpg';
$g_background_music = 'kippur.ac3';
$g_background_music_length = 1191; // In seconds

/* Rendering settings */
$g_width  = 720; // Display resolution for the main menu
$g_height = 576; // Use 720x576 for PAL, 720x480 for NTSC.
$g_aspect = "4:3"; // Intended display aspect for the main menu

$g_margin_top    = 30;
$g_margin_bottom = 40;
$g_margin_left   = 40;
$g_margin_right  = 30;

$g_margin_title  = 50; // Space between the title and menu items

$g_max_title_fontsize = 24;
$g_max_prog_fontsize = 19.0;

/* A ttf file containing the font for the menu.
 * Up to you to ensure it exists in your system.
 */
$g_textfont = "arial.ttf";
$g_promptcolor = 0xFFF490;
$g_titlecolor  = 0xFFFF20;
$g_menucolor   = 0xFFFFFF;

/* Main title for the DVD */
$g_title =
  "Information package: Bible\n".
  "prophecies about end times,\n".
  "Islam, terrorism, and Antichrist";

/* List of main menu items */
$g_labels = Array
(
  // Program number => Program label
  4 => "Walid Shoebat in IPC 2007, abridged, ~1h25m",
  1 => "Walid Shoebat in SCPC 2008, part 1, ~1h0m",
  2 => "Walid Shoebat in SCPC 2008, part 2, ~1h7m",
  3 => "Walid Shoebat in SCPC 2008, part 3, ~1h17m\n(South California Prophecy Conference)",
  5 => "Avi Lipkin in PitN on GLC in 2010, ~0h30m",
  7 => "Avi Lipkin in LotSW on GLC in 2010-02-24, ~2h",
  8 => "Avi Lipkin in LotSW on GLC in 2009-03-11, ~2h",
  9 => "Walid Shoebat in LotSW on GLC in 2010-02-07, ~2h",
  6 => "Bonus: Jim Staley at God's Learning Channel (GLC)\n".
       "in 2010-02-07, speaking about pagan origins\n".
       "of Christmas and Easter, ~2h"
);

/* List of programs constituting the title */
/* Completely optional, but you can specify *
 * these if you like that the produced dvdauthor
 * script will be complete and ready to be used.
 */
$g_programs = Array
(
  // Program number => Program settings
  1 => Array('aspect' => '16:9',
             'files' => Array('parts/parta1.mpg',  // List of mpeg2 files that
                              'parts/parta2.mpg',  // you provide, which contain
                              'parts/parta3.mpg',  // the audiovisual content of
                              'parts/parta4.mpg',  // the particular program.
                              'parts/parta5.mpg',  // The will be concatenated
                              'parts/parta6.mpg',  // to form te program (each file
                              'parts/parta7.mpg')),// begins a new chapter though).
  2 => Array('aspect' => '16:9',
             'files' => Array('parts/partb1.mpg',
                              'parts/partb2.mpg',
                              'parts/partb3.mpg',
                              'parts/partb4.mpg',
                              'parts/partb5.mpg',
                              'parts/partb6.mpg',
                              'parts/partb7.mpg')),
  3 => Array('aspect' => '16:9',
             'files' => Array('parts/partc1.mpg',
                              'parts/partc2.mpg',
                              'parts/partc3.mpg',
                              'parts/partc4.mpg',
                              'parts/partc5.mpg',
                              'parts/partc6.mpg',
                              'parts/partc7.mpg',
                              'parts/partc8.mpg')),
  4 => Array('aspect' => '4:3',
             'files' => Array('parts/partd01.mpg',
                              'parts/partd02.mpg',
                              'parts/partd03.mpg',
                              'parts/partd04.mpg',
                              'parts/partd05.mpg',
                              'parts/partd06.mpg',
                              'parts/partd07.mpg',
                              'parts/partd08.mpg',
                              'parts/partd09.mpg',
                              'parts/partd10.mpg')),
  5 => Array('aspect' => '16:9',
             'chapters' => '0,15:0',
             'files' => Array('parts/parte.mpg')),
  6 => Array('aspect' => '4:3',
             'chapters' => '0,10:0,20:0,30:0,40:0,50:0,60:0,70:0,80:0,90:0,100:0,110:0,120:0',
             'files' => Array('parts/partst.mpg')),
  7 => Array('aspect' => '4:3',
             'chapters' => '0,10:0,20:0,30:0,40:0,50:0,60:0,70:0,80:0,90:0,100:0,110:0,120:0',
             'files' => Array('parts/partl1.mpg')),
  8 => Array('aspect' => '4:3',
             'chapters' => '0,10:0,20:0,30:0,40:0,50:0,60:0,70:0,80:0,90:0,100:0,110:0,120:0',
             'files' => Array('parts/partl2.mpg')),
  9 => Array('aspect' => '4:3',
             'chapters' => '0,10:0,20:0,30:0,40:0,50:0,60:0,70:0,80:0,90:0,100:0,110:0,120:0',
             'files' => Array('parts/partw1.mpg'))
);

/*************************************************/
// Temporary files used by the script. You can ignore this section.
$g_tmp_bgimage     = '/tmp/t_background.jpg';
$g_tmp_menumask    = '/tmp/t_menu.png';
$g_tmp_focusmask   = '/tmp/t_highlight.png';
$g_tmp_selectmask  = '/tmp/t_select.png';
$g_tmp_spuxml      = '/tmp/t_submux.xml';

/*************************************************/

require 'make-menu-common.php';
right To run it, issue the following command:
   php make-menu.php > dvdauthor.xml
To produce the DVD image, run DVDAuthor as such:
   mkdir DVD
   rm DVD/VIDEO_TS/*
   dvdauthor -o DVD -x dvdauthor.xml -m
   mkisofs -dvd-video -o DVD.iso DVD
Note that you also need the common code file, below.

On the right, you can see the result of this example menu (sans the cursor, which is a filled yellow circle).

Common code

Save this code as make-menu-common.php

<?php

/* Create DVDAuthor script */

$format = 'pal';
if($g_height == 480 || $g_height == 240) $format = 'ntsc';

print     "<dvdauthor>\n".
          " <vmgm>\n".
          "  <menus>\n".
          "   <audio lang=\"en\" />\n".
          "   <video format=\"$format\" aspect=\"$g_aspect\" />\n".
          "    <pgc entry=\"title\">\n";
foreach($g_labels as $labelno => $label)
  print   "     <button> jump title $labelno; </button>\n";
print     "     <vob file=\"".htmlspecialchars($g_output_menu)."\" pause=\"inf\" />\n".
          "    </pgc>\n".
          "  </menus>\n".
          " </vmgm>\n";
$prev_titleset_aspect = '';
foreach($g_programs as $progno => $progsets)
{ 
  if($prev_titleset_aspect != ''
  && $prev_titleset_aspect != $progsets['aspect'])
  {
    print "  </titles>\n".
          " </titleset>\n";
    $prev_titleset_aspect = '';
  }
  if($prev_titleset_aspect == '')
  {
    print " <titleset>\n".
          "  <menus>\n".
          "   <pgc entry=\"root\">\n".
          "    <pre> jump vmgm menu entry title; </pre>\n".
          "   </pgc>\n".
          "  </menus>\n".
          "  <titles>\n".
          "   <audio lang=\"en\" />\n".
          "   <video aspect=\"{$progsets['aspect']}\" />\n";
    $prev_titleset_aspect = $progsets['aspect'];
  }
  $chap = '';
  if(isset($progsets['chapters']))
    $chap = " chapters=\"{$progsets['chapters']}\"";
  print "   <pgc>\n";
  foreach($progsets['files'] as $fn) $lastfn=$fn;
  foreach($progsets['files'] as $fn)
  {
    $p = ($fn==$lastfn) ? ' pause="2"' : '';
    print "    <vob file=\"".htmlspecialchars($fn)."\"$chap $p/>\n";
  }
  print "    <post> call vmgm menu 1;</post>\n".
        "   </pgc>\n";
}
if($prev_titleset_aspect != '')
{
  print   "  </titles>\n".
          " </titleset>\n";
}
print     "</dvdauthor>\n";

#exit; -- enable this line if you want the xml only and not any mpegs

/* Create menu mpeg */
$im_sub = Array(); // menu, highlight, select
$im_colors = Array();
for($c=0; $c<3; ++$c)
{
  $im_sub[$c] = ImageCreate($g_width,$g_height);
  $im_colors[$c][0] = ImageColorAllocate($im_sub[$c], 255,255,255);
  $im_colors[$c][1] = ImageColorAllocate($im_sub[$c], 150,150,150);
  $im_colors[$c][2] = ImageColorAllocate($im_sub[$c], 255,140,30);
  $im_colors[$c][3] = ImageColorAllocate($im_sub[$c], 0,0,0);
  ImageColorTransparent($im_sub[$c], $im_colors[$c][3]);
  ImageFilledRectangle($im_sub[$c], 0,0, $g_width-1,$g_height-1, $im_colors[$c][3]);
}

/* Create the text layer for the background image */
$im_text = ImageCreateTrueColor($g_width,$g_height);
imageAlphaBlending($im_text, false);
ImageFilledRectangle($im_text, 0,0, $g_width-1, $g_height-1,
  ImageColorAllocateAlpha($im_text, 0,0,0, 127) );

$title_height = WrapText($g_title,
                         $g_margin_left, $g_margin_top,
                         $g_width - $g_margin_left - $g_margin_right,
                         $g_height,
                         8, $g_max_title_fontsize, max(8, $g_max_title_fontsize*0.9),
                         $g_titlecolor,
                         0);
$labels_begin_y = $g_margin_top + $title_height + $g_margin_title;
$labels_end_y   = $g_height - $g_margin_bottom;

WrapText('Please select program:',
          $g_margin_left, $g_margin_top+$title_height+16,
          $g_width-$g_margin_left-$g_margin_right, 30,
          1,15,15, $g_promptcolor, 0);

$remaining_label_lines = 0;
foreach($g_labels as $label)
  $remaining_label_lines += 1 + (count(explode("\n", $label))-1)*1.0;

$labels = Array();
$label_width = $g_width - ($g_margin_left + 30) - $g_margin_right;
$remaining_room = $labels_end_y - $labels_begin_y;
$mandatory_air  = (count($g_labels)-1)*3;
foreach($g_labels as $label)
{
  $nlines = 1 + (count(explode("\n", $label))-1)*1.0;
  $cell_height = ($remaining_room - $mandatory_air) * $nlines / $remaining_label_lines;
  $ptsize = DetermineLargestFontSize(
    $label,
    $label_width, $cell_height,
    9, $g_max_prog_fontsize, 12.0);
  $box = calculateTextBox($ptsize, 0, $g_textfont, $label);
  $label_height = $box['height'];

  $labels[] = Array
  (
    'text'   => $label,
    'ptsize' => $ptsize,
    'height' => $label_height
  );

  $remaining_room -= $label_height;
  $remaining_label_lines -= $nlines;
}

for($iterations = 1; $iterations <= 3; ++$iterations)
{
  foreach($labels as $index => &$label)
  {
    $remaining_room += $label['height'];
    $cell_height = $remaining_room - $mandatory_air;
    if($cell_height <= $label['height']) continue;
    $cell_height = $cell_height*0.7 + $label['height']*0.3;
    $ptsize = DetermineLargestFontSize(
      $label['text'],
      $label_width, $cell_height,
      $label['ptsize'], 26, $label['ptsize']);
    $box = calculateTextBox($ptsize, 0, $g_textfont, $label['text']);
    $label_height = $box['height'];
    $label['ptsize'] = $ptsize;
    $label['height'] = $label_height;
    $remaining_room -= $label_height;
  }
}
unset($label);

$total_text_height = 0;
foreach($labels as $label)
  $total_text_height += $label['height'];

$average_air = $remaining_room / (count($g_labels)-1);
$y = $labels_begin_y;

foreach($labels as $label)
{
  ImageLine($im_text, $g_margin_left,
                      $y-$average_air/2,
                      $g_width-$g_margin_right,
                      $y-$average_air/2,
                      ImageColorAllocateAlpha($im_text,
                        255,255,255, 96) );

  $label_height = WrapText(
    $label['text'],
    $g_margin_left + 30, $y,
    $label_width,
    $label['height'],
    $label['ptsize'],
    $label['ptsize'],
    $label['ptsize'],
    $g_menucolor);

  $dot_center_x = $g_margin_left+30;
  $dot_center_y = $y + $label_height / 2 + 10;
  
  DrawEllipse($im_text, $dot_center_x, $dot_center_y, 35, min(35,$label_height*1.0),
              0x000020, 0x000050); // in background
  DrawEllipse($im_sub[0], $dot_center_x, $dot_center_y, 30, min(30,$label_height*0.9),
              $im_colors[0][1], $im_colors[0][1]); // in menu
  DrawEllipse($im_sub[1], $dot_center_x, $dot_center_y, 30, min(30,$label_height*0.9),
              $im_colors[1][1], $im_colors[1][2]); // in highlight
  DrawEllipse($im_sub[2], $dot_center_x, $dot_center_y, 30, min(30,$label_height*0.9),
              $im_colors[2][1], $im_colors[2][1]); // in select

  $y += $label['height'];
  $y += $average_air;
}

/* Create the background image and blend the text into it */
$im_bg = ImageCreateTrueColor($g_width,$g_height);
$im = ImageCreateFromJpeg($g_background_image);
ImageCopyResampled($im_bg, $im, 0,0, 0,0, $g_width,$g_height, ImageSx($im),ImageSy($im));
ImageDestroy($im);
imageAlphaBlending($im_bg, true);
ImageCopy($im_bg, $im_text, 0,0, 0,0, $g_width,$g_height);

ImageJpeg($im_bg, $g_tmp_bgimage, 100);  ImageDestroy($im_bg);

ImageDestroy($im_text);
/* Save the mask images for spumux */
ImagePng($im_sub[0], $g_tmp_menumask);   ImageDestroy($im_sub[0]);
ImagePng($im_sub[1], $g_tmp_focusmask);  ImageDestroy($im_sub[1]);
ImagePng($im_sub[2], $g_tmp_selectmask); ImageDestroy($im_sub[2]);

file_put_contents($g_tmp_spuxml,
  "<subpictures><stream><spu start=\"00:00:1.0\" ".
    "image=\"".htmlspecialchars($g_tmp_menumask)."\" ".
    "highlight=\"".htmlspecialchars($g_tmp_focusmask)."\" ".
    "select=\"".htmlspecialchars($g_tmp_selectmask)."\" ".
    "autooutline=\"infer\" force=\"yes\" autoorder=\"rows\">".
  "</spu></stream></subpictures>"
);

$command =
  "ffmpeg -t ".escapeshellarg($g_background_music_length).
        " -loop_input -i ".escapeshellarg($g_tmp_bgimage).
        " -vcodec mpeg2video -qscale 1 -g 600 -threads 4".
        " -f mp2 -".
  " | ".
  "mplex -V -f 8 -o /dev/stdout /dev/stdin ".escapeshellarg($g_background_music).
  " | ".
  "spumux ".escapeshellarg($g_tmp_spuxml)." > ".escapeshellarg($g_output_menu);

exec($command);
/* All done. The rest of this PHP file are functions. */

/*************************************************/

/* Render text into the given region with given settings. */
function WrapText($text, $x,$y, $maxwidth,$maxheight,
                  $minptsize,$maxptsize, $guess_ptsize,
                  $color,
                  $align = -1)
{
  global $g_textfont, $im_text;
  $ptsize = DetermineLargestFontSize(
    $text, $maxwidth,$maxheight,
    $minptsize,$maxptsize, $guess_ptsize);
  $box = calculateTextBox($ptsize, 0, $g_textfont, $text);
  imageAlphaBlending($im_text, true);
/*
  imageFilledRectangle($im_text, $x-5,$y-5, $x+$maxwidth+5,$y+$box['height']+5,
    ImageColorAllocateAlpha($im_text, 0,0,0, 100) );
*/
  $bd = Array(ImageColorAllocateAlpha($im_text, 0,0,0, 0),
              ImageColorAllocateAlpha($im_text, 0xFF,0xFF,0, 80),
              ImageColorAllocateAlpha($im_text, 0x55,0x40,0, 110),
              ImageColorAllocateAlpha($im_text, 0,0,0, 90));
  switch($align)
  {
    case -1: // left-align: default
      for($xo=-3; $xo<=3; ++$xo)
      for($yo=$xo; $yo<=3; ++$yo)
        imageTTFtext($im_text, $ptsize,0, $x+$box['left']+$xo, $y+$box['top']+$yo,
            $bd[max(abs($xo),abs($yo))], $g_textfont, $text);
      imageTTFtext($im_text, $ptsize,0, $x+$box['left'], $y+$box['top'], $color, $g_textfont, $text);
      break;
    case 0: // center
      foreach(explode("\n", $text) as $line)
      {
        $box2 = calculateTextBox($ptsize,0, $g_textfont, $line);
        $xp = $box2['left'] + $x + ($maxwidth - $box2['width']) / 2;
        for($xo=-3; $xo<=3; ++$xo)
        for($yo=$xo; $yo<=3; ++$yo)
          imageTTFtext($im_text, $ptsize,0, $xp+$xo, $y+$box2['top']+$yo,
              $bd[max(abs($xo),abs($yo))], $g_textfont, $line);
        imageTTFtext($im_text, $ptsize,0, $xp, $y+$box2['top'], $color, $g_textfont, $line);
        $y += $box2['height'] + 3;
      }
      break;
    case 1: // right-align
      foreach(explode("\n", $text) as $line)
      {
        $box2 = calculateTextBox($ptsize,0, $g_textfont, $line);
        $xp = $box2['left'] + $x + ($maxwidth - $box2['width']);
        imageTTFtext($im_text, $ptsize,0, $xp, $y+$box2['top'], $color, $g_textfont, $line);
        $y += $box2['height'] + 3;
      }
      break;
  }
  return $box['height'];
}

/* Determine the largest font size that can be used to
 * render the given text, with the given font, into a
 * region with the given dimensions. The font size should
 * be between the given ranges, and the given initial
 * guess is to be used. Given given given.
 */
function DetermineLargestFontSize($text, $width,$height, $minptsize,$maxptsize, $guess_ptsize)
{
  global $g_textfont;
  $stderr = fopen('php://stderr', 'w');
  fprintf($stderr, "Fitting text in {$width}x{$height} ({$minptsize}..{$maxptsize}): ".str_replace("\n","\\n",$text)."\n");
  while($minptsize+0.01 < $maxptsize)
  {
    $middle = ($maxptsize + $minptsize) / 2.0;
    if(isset($guess_ptsize))
    {
      $middle = $guess_ptsize;
      $guess_ptsize = null;
    }
    
    fprintf($stderr, "Trying $middle ...");
    $box = calculateTextBox($middle, 0, $g_textfont, $text);
    fprintf($stderr, " Produces {$box['width']}x{$box['height']}\n");

    if($box['width'] > $width
    || $box['height'] > $height) // Too large?
    {
      $maxptsize = $middle;
      continue;
    }
    if($box['width'] < $width
    && $box['height'] < $height) // Could be larger?
    {
      $minptsize = $middle;
      continue;
    }
    $maxptsize = $middle;
    /*
    
    break;*/
  }
  fprintf($stderr, "Selected $minptsize\n");
  fclose($stderr);
  return $minptsize;
}

/* Determine dimensions required to render the given text. */
function calculateTextBox($font_size, $font_angle, $font_file, $text) {
  // This function created by  blackbart at simail dot it
  $box   = imagettfbbox($font_size, $font_angle, $font_file, $text);
  if( !$box )
    return false;
  $min_x = min( array($box[0], $box[2], $box[4], $box[6]) );
  $max_x = max( array($box[0], $box[2], $box[4], $box[6]) );
  $min_y = min( array($box[1], $box[3], $box[5], $box[7]) );
  $max_y = max( array($box[1], $box[3], $box[5], $box[7]) );
  $width  = ( $max_x - $min_x );
  $height = ( $max_y - $min_y );
  $left   = abs( $min_x ) + $width;
  $top    = abs( $min_y ) + $height;
  // to calculate the exact bounding box i write the text in a large image
  $img     = @imagecreatetruecolor( $width << 2, $height << 2 );
  $white   =  imagecolorallocate( $img, 255, 255, 255 );
  $black   =  imagecolorallocate( $img, 0, 0, 0 );
  imagefilledrectangle($img, 0, 0, imagesx($img), imagesy($img), $black);
  // for sure the text is completely in the image!
  imagettftext( $img, $font_size,
                $font_angle, $left, $top,
                $white, $font_file, $text);
  // start scanning (0=> black => empty)
  $rleft   = $w4 = $width<<2;
  $rright  = 0;
  $rbottom = 0;
  $rtop    = $h4 = $height<<2;
  
  for( $x = 0; $x < $w4; $x++ )
    for( $y = 0; $y < $h4; $y++ )
      if( imagecolorat( $img, $x, $y ) ){
        $rleft   = min( $rleft, $x );
        $rright  = max( $rright, $x );
        $rtop    = min( $rtop, $y );
        $rbottom = max( $rbottom, $y );
      }
  // destroy img and serve the result
  imagedestroy( $img );
  return array( "left"   => $left - $rleft,
                "top"    => $top  - $rtop,
                "width"  => $rright - $rleft + 1,
                "height" => $rbottom - $rtop + 1 );
}

function DrawEllipse(&$im, $centerx,$centery, $width,$height, $fillcolor,$edgecolor)
{
  ImageFilledEllipse($im, $centerx-$width/2, $centery-$height/2, $width,$height, $fillcolor);
  ImageEllipse($im,       $centerx-$width/2, $centery-$height/2, $width,$height, $edgecolor);
}

How to make mpeg2 files

To create a mpeg2 encoding of a program that you wish to include on the DVD, you can customize this shell script to launch ffmpeg:

 SETS="-flags +mv0+umv+qprd -cmp rd -subcmp rd -mbcmp rd -mbd rd -trellis 2 -directpred 3 -partitions +parti4x4+parti8x8+partp4x4+partp8x8+partb8x8 -me_method epzs -dia_size 4 -pre_dia_size 2 -subq 10 -qcomp 0.1 -bt 260000 -nr 2 -bf 2 -b_strategy 2"
 THREADS="-threads 2"

 MAXPROC=3
 for s in 01 02 03 04 05 06 07 08 09 10;do 
  while [ $(jobs -p|wc -l) -ge $MAXPROC ]; do sleep 1;done
  ffmpeg -y -i \
   originals/"Prophecy 101 Islam and Satan - Walid Shoebat ($s of 10).flv" \
   -target pal-dvd -vb 540k -ab 128k $THREADS  \
    $SETS -aspect 4:3 \
   parts/partd"$s".mpg &
 done
 wait

 # Above, the source files are in originals/ and the target files in parts/
It is a shell script that issues multiple jobs in parallel (multitasking / scheduling), while maintaining a limit of $MAXPROC simultaneous processes.

Note that this encoding script is a tad slow. It is optimized for creating a small file for fitting a lot of content on a DVD (more than 10 hours in this example case of mine). You can and should increase the vb and ab values to create better quality videos.

Change the pal-dvd to ntsc-dvd if you want to create a NTSC DVD instead of a PAL DVD. The differences between PAL and NTSC DVDs are:

Resolution Frame rate
PAL Better (720x576 / 704x576 / 352x576 / 352x288) Worse (25)
NTSC Worse (720x480 / 704x480 / 352x480 / 352x240) Better (29.97)

Both standards support the 4:3 and 16:9 aspect ratios. However, the 16:9 aspect ratio ("widescreen") is only available for video that is 720 pixels wide.

To choose between PAL and NTSC, use this rulebook:

  1. Is the original content NTSC? Yes → Use NTSC
  2. Is the original content PAL? Yes → Use PAL
  3. Is your source material low resolution? Yes → Use NTSC
  4. Use PAL

Relevant links

Want a copy?

Want a copy of the software described on this page? Just copy&paste it and it is yours!

Want a copy of this DVD? Contact me at: ek1cdejohd1uel.eHso496S.ylijluosma@eg01iki@pn.fi


Last edited at: 2010-06-11T12:41:13+03:00