11

I want to upload my custom image sizes in custom folders. The folder should have the name of the selected width. For example:

If I add these custom sizes...

add_image_size('custom-1', 300, 9999);
add_image_size('custom-2', 400, 9999);

It would be nice the uploaded images are uploaded like this:

http://www.my-site.com/wp-content/uploads/300/my-image.jpg
http://www.my-site.com/wp-content/uploads/400/my-image.jpg

Is this possible? I've only found that I can change the global upload folder with the upload_dir filter.

5 Answers 5

22
+50

Philipp, anything is possible if you set your mind to it. You can solve your issue by extending the WordPress image editor class.

Note I'm using WordPress 3.7 - I haven't checked any of the below code in earlier versions and in the latest 3.8 release.


Image Editor basics

WordPress has two built in classes that handle image manipulation:

  • WP_Image_Editor_GD (/wp-includes/class-wp-image-editor-gd.php)
  • WP_Image_Editor_Imagick (/wp-includes/class-wp-image-editor-imagick.php)

These two classes extend WP_Image_Editor because they both use a different image engine (GD and ImageMagick respectively) to load, resize, compress and save images.

By default WordPress will try to use the ImageMagick engine first, which needs a PHP extension, because it is generally preferred over PHP's default GD engine. Most shared servers don't have the ImageMagick extension enabled though.


Add an Image Editor

To decide which engine to use, WordPress calls an internal function __wp_image_editor_choose() (located in /wp-includes/media.php). This function loops through all engines to see which engine can handle the request.

The function also has a filter called wp_image_editors that allows you to add more image editors like so:

add_filter("wp_image_editors", "my_wp_image_editors");
function my_wp_image_editors($editors) {
    array_unshift($editors, "WP_Image_Editor_Custom");

    return $editors;
}

Note we're prepending our custom image editor class WP_Image_Editor_Custom so WordPress will check if our engine can handle resizing before testing other engines.


Creating our Image Editor

Now we're gonna write our own image editor so we can decide on filenames for ourselves. Filenaming is handled by the method WP_Image_Editor::generate_filename() (both engines inherit this method), so we should overwrite that in our custom class.

Since we only plan on changing filenames, we should extend one of the existing engines so we don't have to reinvent the wheel. I will extend WP_Image_Editor_GD in my example, as you probably don't have the ImageMagick extension enabled. The code is interchangeable for an ImageMagick setup though. You could add both if you're planning on using the theme on different setups.

// Include the existing classes first in order to extend them.
require_once ABSPATH.WPINC."/class-wp-image-editor.php";
require_once ABSPATH.WPINC."/class-wp-image-editor-gd.php";

class WP_Image_Editor_Custom extends WP_Image_Editor_GD {
    public function generate_filename($prefix = NULL, $dest_path = NULL, $extension = NULL) {
        // If empty, generate a prefix with the parent method get_suffix().
        if(!$prefix)
            $prefix = $this->get_suffix();

        // Determine extension and directory based on file path.
        $info = pathinfo($this->file);
        $dir  = $info['dirname'];
        $ext  = $info['extension'];

        // Determine image name.
        $name = wp_basename($this->file, ".$ext");

        // Allow extension to be changed via method argument.
        $new_ext = strtolower($extension ? $extension : $ext);

        // Default to $_dest_path if method argument is not set or invalid.
        if(!is_null($dest_path) && $_dest_path = realpath($dest_path))
            $dir = $_dest_path;

        // Return our new prefixed filename.
        return trailingslashit($dir)."{$prefix}/{$name}.{$new_ext}";
    }
}

Most of the code above was directly copied from the WP_Image_Editor class and commented for your convenience. The only actual change is that the suffix is now a prefix.

Alternatively, you could just call parent::generate_filename() and use an mb_str_replace() to change the suffix into a prefix, but I figured that would be more inclined to go wrong.


Saving new paths to metadata

After uploading image.jpg, the uploads folder looks like this:

  • 2013/12/150x150/image.jpg
  • 2013/12/300x300/image.jpg
  • 2013/12/image.jpg

So far so good. However, when calling basic functions like wp_get_attachment_image_src(), we'll notice all image sizes are stored as image.jpg without the new directory path.

We can work around this issue by saving the new folder structure to the image metadata (where the filenames are stored). The data runs through various filters (wp_generate_attachment_metadata among others) before being inserted into the database, but since we're already implementing a custom image editor, we can travel back to the source of image size metadata: WP_Image_Editor::multi_resize(). It generates arrays like this one:

Array (
    [thumbnail] => Array (
        [file]      => image.jpg
        [width]     => 150
        [height]    => 150
        [mime-type] => image/jpeg
    )

    [medium] => Array (
        [file]      => image.jpg
        [width]     => 300
        [height]    => 300
        [mime-type] => image/jpeg
    )
)

We'll overwrite the multi_resize() method in our custom class:

function multi_resize($sizes) {
    $sizes = parent::multi_resize($sizes);

    foreach($sizes as $slug => $data)
        $sizes[$slug]['file'] = $data['width']."x".$data['height']."/".$data['file'];

    return $sizes;
}

As you can see, I didn't bother replacing any of the code. I just call the parent method and let it generate the metadata. Then I loop through the resulting array and adjust the file value for each size.

Now wp_get_attachment_image_src($att_id, array(300, 300)) returns 2013/12/300x300/image.jpg. Hooray!


Final thoughts

I hope this provided a good basis for you to elaborate on. However, please note if an image is smaller than the specified size (e.g. 280x300), the generated suffix (prefix in our case) and image sizes are 280x300, not 300x300. If you upload a lot of smaller images, you'll get a lot of different folders.

A good solution would be to either use the size slug as a folder name (small, medium, et cetera) or expand the code to round sizes up to the nearest preferred image size.

You noted you want to use just the width as a directory name. Be warned though - plugins or themes could generate two different sizes with the same width but different heights.

Also, you can remove the year/month folders either by disabling 'Organize my uploads into month- and year-based folders' under Settings > Media or by manipulating generate_filename even further.

Hope this helps. Good luck!

9
  • 3
    What an answer! :D Nice man! Dec 13, 2013 at 14:41
  • 1
    You're welcome! I was assuming you have at least a bit of OOP and WP filter experience, but if there's anything you don't understand yet, feel free to ask. Thank you for the bounty!
    – Robbert
    Dec 13, 2013 at 14:43
  • 2
    @Robbert Frankly, this is brilliant. I was tearing my hair out at the lack of action and filter hooks in the media upload system. It seems obvious in retrospect, but it simply hadn't occurred to me to override the image editors entirely. Doing so solves so many problems in one fell swoop. Jul 3, 2014 at 8:59
  • 1
    @JonathanFingland Ha, I admit I had to go far down the rabbit hole for this one. Glad to be of help!
    – Robbert
    Jul 3, 2014 at 9:21
  • Small note - in last peace of code (public function multi_resize($sizes) ) keyword "public" makes website down. Just remove it and it's up again. 2k17 and your answer is still awesome, thank you!! Jan 25, 2017 at 16:07
3

@Robbert's answer was a divine resource in my efforts to store alternate sizes generated by WordPress in separate directories. My code also changes the upload directory to ./media so make sure to edit these lines if you don't want that. It's not an exact answer to the first poster's question, but offers an alternative solution to the same problem:

if ( !is_multisite() ) {
    update_option( 'upload_path', 'media' ); //to-do: add to options page
    define( 'UPLOADS', 'media' ); //define UPLOADS dir - REQUIRED
}
//don't “Organize my uploads into month- and year-based folders”
update_option( 'uploads_use_yearmonth_folders', '0' ); // to-do: add to options page

//create a custom WP_Image_Editor that handles the naming of files
function tect_image_editors($editors) {
    array_unshift( $editors, 'WP_Image_Editor_tect' );

    return $editors;
}

add_filter( 'wp_image_editors', 'tect_image_editors' );

require_once ABSPATH . WPINC . '/class-wp-image-editor.php';
require_once ABSPATH . WPINC . '/class-wp-image-editor-gd.php';

class WP_Image_Editor_tect extends WP_Image_Editor_GD {
    public function multi_resize($sizes) {
        $sizes = parent::multi_resize($sizes);

        $media_dir = trailingslashit( ABSPATH . UPLOADS );

        foreach($sizes as $slug => $data) {
            $default_name = $sizes[ $slug ]['file'];
            $new_name = $slug . '/' . preg_replace( '#-\d+x\d+\.#', '.', $data['file'] );

            if ( !is_dir( $media_dir . $slug ) ) {
                mkdir( $media_dir . $slug );
            }
            //move the thumbnail - perhaps not the smartest way to do it...
            rename ( $media_dir . $default_name, $media_dir . $new_name );

            $sizes[$slug]['file'] = $new_name;
        }

        return $sizes;
    }
}

Works without any problems according to my tests, although I haven't tried to check how it fares with popular gallery/media plugins.

related bonus: a raw utility to delete all WordPress generated thumbnails delete_deprecated_thumbs.php

2

The answer @robbert is a great, great answer. However, it's a little dated (I'm using WordPress 5.7) and I wanted to do this exact same thing, so here's a slightly updated answer with a few extras:

  1. I've used the slug size rather than the dimensions because this is a little cleaner and wanted to demonstrate it from the @robbert code.
  2. An extra filter for updating the post meta. This is so you can DELETE the image from the media library afterwards too. Otherwise, the thumbnails are going to stay lying around in the file system.
  3. Just for the sake of completion, I've added both GD and Imagick class extensions.

I placed these all in the same file, as a plugin.

Adding both of the new editors.

/**
 * Add custom image editor (which extends the GD / IMagick editor)
 */
add_filter("wp_image_editors", "my_wp_image_editors");
function my_wp_image_editors($editors) {
    array_unshift($editors, "WP_Image_Editor_Custom_GD");
    array_unshift($editors, "WP_Image_Editor_Custom_IM");
    return $editors;
}

The GD Class extension

/**
 * If GD Library being used. (default)
 */
class WP_Image_Editor_Custom_GD extends WP_Image_Editor_GD {



    /**
     * Add the folder prefix before saving to the database and generating
     */
    function multi_resize($sizes) {

        $sizes = parent::multi_resize($sizes);
    
        foreach($sizes as $slug => $data)
            // $sizes[$slug]['file'] = $data['width']."x".$data['height']."/".$data['file'];  // creates a /320x300/ folder.
            $sizes[$slug]['file'] = $slug."/".$data['file']; // creates a /large/ folder.
    
        return $sizes;
    }



    /**
     * Changes the suffix (300x300) to a directory prefix.
     */
    public function generate_filename($prefix = NULL, $dest_path = NULL, $extension = NULL) {
        // If empty, generate a prefix with the parent method get_suffix().
        if(!$prefix)
            $prefix = $this->get_suffix();

        // Determine extension and directory based on file path.
        $info = pathinfo($this->file);
        $dir  = $info['dirname'];
        $ext  = $info['extension'];

        $sizes  = wp_get_registered_image_subsizes();
        $dimen  = explode('x', $prefix);
        foreach($sizes as $name => $size)
        {
            if ($dimen[0] == $size['width']){
                $prefix = $name;
            }
        }

        // Determine image name.
        $name = wp_basename($this->file, ".$ext");

        // Allow extension to be changed via method argument.
        $new_ext = strtolower($extension ? $extension : $ext);

        // Default to $_dest_path if method argument is not set or invalid.
        if(!is_null($dest_path) && $_dest_path = realpath($dest_path))
            $dir = $_dest_path;

        // Return our new prefixed filename.
        return trailingslashit($dir)."{$prefix}/{$name}.{$new_ext}";
    }

}

The IMagick class extension

/**
 * If Imagemagick is being used.
 */
class WP_Image_Editor_Custom_IM extends WP_Image_Editor_Imagick {



    /**
     * Add the folder prefix before saving to the database and generating
     */
    function multi_resize($sizes) {

        $sizes = parent::multi_resize($sizes);
    
        foreach($sizes as $slug => $data)
            // $sizes[$slug]['file'] = $data['width']."x".$data['height']."/".$data['file'];  // creates a /320x300/ folder.
            $sizes[$slug]['file'] = $slug."/".$data['file']; // creates a /large/ folder.
    
        return $sizes;
    }




    /**
     * Changes the suffix (300x300) to a directory prefix.
     */
    public function generate_filename($prefix = NULL, $dest_path = NULL, $extension = NULL) {

        // If empty, generate a prefix with the parent method get_suffix().
        if(!$prefix)
            $prefix = $this->get_suffix();


        $sizes  = wp_get_registered_image_subsizes();
        $dimen  = explode('x', $prefix);
        foreach($sizes as $name => $size)
        {
            if ($dimen[0] == $size['width']){
                $prefix = $name;
            }
        }

        // Determine extension and directory based on file path.
        $info = pathinfo($this->file);
        $dir  = $info['dirname'];
        $ext  = $info['extension'];

        // Determine image name.
        $name = wp_basename($this->file, ".$ext");

        // Allow extension to be changed via method argument.
        $new_ext = strtolower($extension ? $extension : $ext);

        // Default to $_dest_path if method argument is not set or invalid.
        if(!is_null($dest_path) && $_dest_path = realpath($dest_path))
            $dir = $_dest_path;

        // Return our new prefixed filename.
        return trailingslashit($dir)."{$prefix}/{$name}.{$new_ext}";
    }


}

Update the attachment metadata

/**
 * We need to rewrite the post attachment metadata so that 
 * we include the new /slug/ directory. This is so we are able to 
 * delete / edit the thumbnail correctly.
 * 
 * see function wp_get_attachment_metadata() in wp-include/post.php
 * 
 */
add_filter('wp_get_attachment_metadata', 'include_slug_in_attachment_size', 10, 2);

function include_slug_in_attachment_size($data, $attachment_id)
{
    // Add the size slug to the front of the filename.
    foreach($data['sizes'] as $name => $size)
    {
        $file = $data['sizes'][$name]['file'];
        $data['sizes'][$name]['file'] = $name .'/'.$file;
    }

    return $data;
}

Now, sub-directories with the media size name will be created within the uploads folder, e.g. /uploads/photo.jpg will have /uploads/thumbnail/photo.jpg, /uploads/medium/photo.jpg, etc... But not only that, deletion will remove those attachments too. (Plus, if the folder becomes empty, that will be removed as well).

Link to Plugin

For the code: Github - IORoot

Details - No need to read

For those who are curious, the flow for the deletion will be:

  1. post.php will run wp_delete_attachment() function.
  2. That function will run the line:
$meta = wp_get_attachment_metadata( $post_id );
  1. The wp_get_attachment_metadata function in post.php will run the wp_get_attachment_metadata filter.
  2. That will run our custom filter function (declared above) that will add the slug to the front of the filename within the $data['sizes'] array and return it back.
  3. That new $data array is passed back to set $meta in wp_delete_attachment().
  4. The $meta array is then used at the bottom of the wp_delete_attachment() function, on the line:
wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file );
  1. The wp_delete_attachment_files() function will use this $meta array with a foreach() to loop through the sizes and delete each filename (which we updated to include the slug directory).
    // Remove intermediate and backup images if there are any.
    if ( isset( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) {
        $intermediate_dir = path_join( $uploadpath['basedir'], dirname( $file ) );

        foreach ( $meta['sizes'] as $size => $sizeinfo ) {
            $intermediate_file = str_replace( wp_basename( $file ), $sizeinfo['file'], $file );

            if ( ! empty( $intermediate_file ) ) {
                $intermediate_file = path_join( $uploadpath['basedir'], $intermediate_file );

                if ( ! wp_delete_file_from_directory( $intermediate_file, $intermediate_dir ) ) {
                    $deleted = false;
                }
            }
        }
    }

And that's it. This will see the updated $sizes array (within $meta) which we updated from:

'sizes => 
    'large' =>
        [
          'file' => 'photo.jpg',
          'width' => '800',
          'height' => '800',
          'mimetype' => 'image/jpeg',
        ],
    'medium' =>
        [
          'file' => 'photo.jpg',
          'width' => '640',
          'height' => '640',
          'mimetype' => 'image/jpeg',
        ],
...

to become:

'sizes => 
    'large' =>
        [
          'file' => 'large/photo.jpg',
          'width' => '800',
          'height' => '800',
          'mimetype' => 'image/jpeg',
        ],
    'medium' =>
        [
          'file' => 'medium/photo.jpg',
          'width' => '640',
          'height' => '640',
          'mimetype' => 'image/jpeg',
        ],
...
1
  • Magic! Is there a way to also move the original images into their own subfolder? (I think I could work around it by defining 'uploads' as the subfolder (e.g. /media/full) in the first place and then moving all others one level up, but it's cumbersome and may break things, maybe you know of a better/simpler way to do it)
    – lucian
    Dec 27, 2021 at 4:52
1

I've looked at these parts of WordPress code and I'm afraid I don't have any good news.

There are 2 classes:

  • WP_Image_Editor_GD
  • WP_Image_Editor_Imagick,

both extending abstract WP_Image_Editor class.

These classes are implementing multi_resize method, which is used to generate multiple images from uploaded one.

The really bad news is that there are no filter hooks in there, that we could use to modify destination path for newly created files.

5
  • That's a shame. I wanted to implement the nice Imager.js but that will probably not work without that. Dec 12, 2013 at 22:48
  • It looks like Imager.js makes images invisible to bots (Google, Facebook, etc), therefore I'd advise against using it (unless you also manually add a noscript tag)
    – fregante
    Dec 12, 2013 at 23:51
  • Hmm no I don't think so. You normally use img-tags with a src of an image. And you add additionally a data-tag with a syntax of other images-sizes: <img src="http://placehold.it/260" data-src="http://placehold.it/{width}" />. And then the script checks which size the img has and loads the best image size for that. Dec 13, 2013 at 8:59
  • @PhilippKühn I'm disappointed too. Your idea was pretty neat and I wanted to use it to tidy up uploads directory (deleting unused thumbnails after theme change is pain in the...) Dec 13, 2013 at 9:04
  • @KrzysiekDróżdż Hey I think I got it. Look at my answer below. With this solution you also could sort via ftp the images by file name and easily remove the unused image sizes. Dec 13, 2013 at 12:26
1

Ok, I think i got it! Not perfect but okay for this I wanted it for. For me only the width of an image is important. Height is useless for me. Especially for implementing Imager.js the height in the image url is disturbing.

add_filter('image_make_intermediate_size', 'custom_rename_images');

function custom_rename_images($image) {
    // Split the $image path
    $info = pathinfo($image);
    $dir = $info['dirname'] . '/';
    $ext = '.' . $info['extension'];
    $name = wp_basename($image, '$ext');

    // New Name
    $name_prefix = substr($name, 0, strrpos($name, '-'));
    $size_extension = substr($name, strrpos($name, '-') + 1);
    $image_sizes = explode('x', $size_extension);
    $image_width = $image_sizes[0];
    $new_name = $dir . $image_width . '-' . $name_prefix . $ext;

    // Rename the intermediate size
    $did_it = rename($image, $new_name);

    // Return if successful
    if ($did_it) return $new_name;

    // Return on fail
    return $image;
}

With this code the filenames are like:

http://www.my-site.com/wp-content/uploads/300-my-image.jpg
http://www.my-site.com/wp-content/uploads/400-my-image.jpg

It's not possible to add a subfolder to the filenames, because if I add images in a post/page always the original source will used instead. And removing these images on deleting will also not working. I'm not sure why.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.