/src/support/z_media_preview.erl

https://code.google.com/p/zotonic/ · Erlang · 446 lines · 342 code · 46 blank · 58 comment · 19 complexity · 9cf8e3eeae6865686dc35ed938d322c9 MD5 · raw file

  1. %% @author Marc Worrell <marc@worrell.nl>
  2. %% @copyright 2009 Marc Worrell
  3. %% Date: 2009-03-02
  4. %% @doc Make still previews of media, using image manipulation functions. Resize, crop, gray, etc.
  5. %% This uses the command line imagemagick tools for all image manipulation.
  6. %% This code is adapted from PHP GD2 code, so the resize/crop could've been done more efficiently, but it works :-)
  7. %% Copyright 2009 Marc Worrell
  8. %%
  9. %% Licensed under the Apache License, Version 2.0 (the "License");
  10. %% you may not use this file except in compliance with the License.
  11. %% You may obtain a copy of the License at
  12. %%
  13. %% http://www.apache.org/licenses/LICENSE-2.0
  14. %%
  15. %% Unless required by applicable law or agreed to in writing, software
  16. %% distributed under the License is distributed on an "AS IS" BASIS,
  17. %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  18. %% See the License for the specific language governing permissions and
  19. %% limitations under the License.
  20. -module(z_media_preview).
  21. -author("Marc Worrell <marc@worrell.nl").
  22. %% interface functions
  23. -export([
  24. convert/4,
  25. size/3,
  26. can_generate_preview/1,
  27. out_mime/2,
  28. string2filter/2,
  29. cmd_args/3,
  30. calc_size/7
  31. ]).
  32. -define(MAX_WIDTH, 5000).
  33. -define(MAX_HEIGHT, 5000).
  34. -include_lib("zotonic.hrl").
  35. %% @spec convert(InFile, OutFile, Filters, Context) -> ok | {error, Reason}
  36. %% @doc Convert the Infile to an outfile with a still image using the filters.
  37. %% @todo Check if the conversion has been done
  38. %% @todo Check if the target /= source
  39. convert(InFile, OutFile, Filters, Context) ->
  40. case z_media_identify:identify(InFile, Context) of
  41. {ok, FileProps} ->
  42. {mime, Mime} = proplists:lookup(mime, FileProps),
  43. case can_generate_preview(Mime) of
  44. true ->
  45. OutMime = z_media_identify:guess_mime(OutFile),
  46. {EndWidth, EndHeight, CmdArgs} = cmd_args(FileProps, Filters, OutMime),
  47. z_utils:assert(EndWidth < ?MAX_WIDTH, image_too_wide),
  48. z_utils:assert(EndHeight < ?MAX_HEIGHT, image_too_high),
  49. Args1 = lists:flatten(z_utils:combine(32, CmdArgs)),
  50. Cmd = ["convert ", z_utils:os_filename(InFile++infile_suffix(Mime)), " ", Args1, " ", z_utils:os_filename(OutFile)],
  51. file:delete(OutFile),
  52. ok = filelib:ensure_dir(OutFile),
  53. Result = z_media_preview_server:exec(lists:flatten(Cmd), OutFile),
  54. case filelib:is_regular(OutFile) of
  55. true ->
  56. ok;
  57. false ->
  58. ?LOG("convert cmd ~p failed, result ~p", [Cmd, Result]),
  59. {error, "Error during convert."}
  60. end;
  61. false ->
  62. {error, "Can not convert "++Mime}
  63. end;
  64. {error, Reason} ->
  65. {error, Reason}
  66. end.
  67. %% Return the ImageMagick input-file suffix.
  68. %% @spec infile_suffix(Mime) -> Suffix::string()
  69. infile_suffix(<<"image/gif">>) -> [];
  70. infile_suffix(_) -> "[0]".
  71. %% @spec size(MediaRef, Filters, Context) -> {size, Width, Height, ResizedMime} | {error, Reason}
  72. %% MediaRef = Filename | MediaProps
  73. %% @doc Calculate the size of the resulting image.
  74. size([{_Prop, _Value}|_] = Props, Filters, _Context) ->
  75. size_props(Props, Filters);
  76. size(InFile, Filters, Context) ->
  77. case z_media_identify:identify(InFile, Context) of
  78. {ok, FileProps} ->
  79. size_props(FileProps, Filters);
  80. {error, Reason} ->
  81. {error, Reason}
  82. end.
  83. size_props(FileProps, Filters) ->
  84. {mime, Mime} = proplists:lookup(mime, FileProps),
  85. case can_generate_preview(Mime) of
  86. true ->
  87. {width, ImageWidth} = proplists:lookup(width, FileProps),
  88. {height, ImageHeight} = proplists:lookup(height, FileProps),
  89. {orientation, Orientation} = proplists:lookup(orientation, FileProps),
  90. ReqWidth = z_convert:to_integer(proplists:get_value(width, Filters)),
  91. ReqHeight = z_convert:to_integer(proplists:get_value(height, Filters)),
  92. {CropPar,_Filters1} = fetch_crop(Filters),
  93. {ResizeWidth,ResizeHeight,CropArgs} = calc_size(ReqWidth, ReqHeight, ImageWidth, ImageHeight, CropPar, Orientation, is_enabled(upscale, Filters)),
  94. case CropArgs of
  95. none ->
  96. case is_enabled(extent, Filters) of
  97. true when is_integer(ReqWidth) andalso is_integer(ReqHeight) ->
  98. {size, ReqWidth, ReqHeight, "image/jpeg"};
  99. _ ->
  100. {size, ResizeWidth, ResizeHeight, "image/jpeg"}
  101. end;
  102. {_CropL, _CropT, CropWidth, CropHeight} -> {size, CropWidth, CropHeight, "image/jpeg"}
  103. end;
  104. false ->
  105. {error, {no_preview_for_mimetype, Mime}}
  106. end.
  107. %% @spec can_generate_preview(Mime) -> true | false
  108. %% @doc Check if we can generate a preview image of the given mime type
  109. can_generate_preview(B) when is_binary(B) -> can_generate_preview(binary_to_list(B));
  110. can_generate_preview("image/" ++ _) -> true;
  111. can_generate_preview("application/pdf") -> true;
  112. can_generate_preview("application/postscript") -> true;
  113. can_generate_preview(_Mime) -> false.
  114. %% @doc Map filters to commandline options
  115. cmd_args(FileProps, Filters, OutMime) ->
  116. {width, ImageWidth} = proplists:lookup(width, FileProps),
  117. {height, ImageHeight} = proplists:lookup(height, FileProps),
  118. {mime, Mime0} = proplists:lookup(mime, FileProps),
  119. Mime = z_convert:to_list(Mime0),
  120. {orientation, Orientation} = proplists:lookup(orientation, FileProps),
  121. ReqWidth = proplists:get_value(width, Filters),
  122. ReqHeight = proplists:get_value(height, Filters),
  123. {CropPar,Filters1} = fetch_crop(Filters),
  124. {ResizeWidth,ResizeHeight,CropArgs} = calc_size(ReqWidth, ReqHeight, ImageWidth, ImageHeight, CropPar, Orientation, is_enabled(upscale, Filters)),
  125. Filters2 = [ {make_image, Mime},
  126. {correct_orientation, Orientation},
  127. {resize, ResizeWidth, ResizeHeight, is_enabled(upscale, Filters)},
  128. {crop, CropArgs},
  129. {colorspace, "RGB"} | Filters1],
  130. Filters3 = case {CropArgs,is_enabled(extent, Filters)} of
  131. {none,true} -> Filters2 ++ [{extent, ReqWidth, ReqHeight}];
  132. _ -> Filters2
  133. end,
  134. Filters4 = case is_blurred(Filters3) of
  135. true -> Filters3;
  136. false -> case Mime of
  137. "image/gif" -> Filters3;
  138. "image/png" -> Filters3;
  139. _ -> Filters3 ++ [sharpen_small]
  140. end
  141. end,
  142. Filters5 = case proplists:get_value(background, Filters4) of
  143. undefined -> default_background(OutMime) ++ Filters4;
  144. _ -> Filters4
  145. end,
  146. {EndWidth,EndHeight,Args} = lists:foldl(fun (Filter, {W,H,Acc}) ->
  147. {NewW,NewH,Arg} = filter2arg(Filter, W, H),
  148. {NewW,NewH,[Arg|Acc]}
  149. end,
  150. {ImageWidth,ImageHeight,[]},
  151. Filters5),
  152. {EndWidth, EndHeight, lists:reverse(Args)}.
  153. default_background("image/gif") -> [coalesce];
  154. default_background("image/png") -> [coalesce];
  155. default_background(_) -> [{background,"white"}, {layers,"flatten"}].
  156. %% @doc Check if there is a blurring filter that prevents us from sharpening the resulting image
  157. is_blurred([]) -> false;
  158. is_blurred([blur|_]) -> true;
  159. is_blurred([{blur, _}|_]) -> true;
  160. is_blurred([_|Rest]) -> is_blurred(Rest).
  161. is_enabled(_F, []) -> false;
  162. is_enabled(F, [F|_]) -> true;
  163. is_enabled(F, [{F, Val}|_]) -> z_convert:to_bool(Val);
  164. is_enabled(F, [_|R]) -> is_enabled(F, R).
  165. %% @spec out_mime(Mime, Options) -> {Mime, Extension}
  166. %% @doc Return the preferred mime type of the image generated by resizing an image of a certain type and size.
  167. out_mime("image/gif", _) ->
  168. %% gif is gif, daar kan je gif op innemen
  169. {"image/gif", ".gif"};
  170. out_mime(_Mime, Options) ->
  171. case lists:member("lossless", Options) orelse proplists:is_defined(lossless, Options) of
  172. false -> {"image/jpeg", ".jpg"};
  173. true -> {"image/png", ".png"}
  174. end.
  175. %% @spec filter2arg(Filter, Width, Height) -> {NewWidth, NewHeight, Filter::string}
  176. %% @doc Map filters to an ImageMagick argument
  177. filter2arg({make_image, "application/pdf"}, Width, Height) ->
  178. RArg = ["-resize ", integer_to_list(Width),$x,integer_to_list(Height)],
  179. {Width, Height, RArg};
  180. filter2arg(coalesce, Width, Height) ->
  181. {Width, Height, "-coalesce"};
  182. filter2arg({make_image, _Mime}, Width, Height) ->
  183. {Width, Height, []};
  184. filter2arg({correct_orientation, Orientation}, Width, Height) ->
  185. case Orientation of
  186. 2 -> {Width, Height, "-flip"};
  187. 3 -> {Width, Height, "-rotate 180"};
  188. 4 -> {Width, Height, "-flop"};
  189. 5 -> {Width, Height, "-transpose"};
  190. 6 -> {Width, Height, "-rotate 90"};
  191. 7 -> {Width, Height, "-transverse"};
  192. 8 -> {Width, Height, "-rotate 270"};
  193. _ -> {Width, Height, []}
  194. end;
  195. filter2arg({background, Color}, Width, Height) ->
  196. {Width, Height, ["-background ", $", z_utils:os_escape(Color), $"]};
  197. filter2arg({layers, Method}, Width, Height) ->
  198. {Width, Height, ["-layers ", $", z_utils:os_escape(Method), $"]};
  199. filter2arg({colorspace, Colorspace}, Width, Height) ->
  200. {Width, Height, ["-colorspace ", $", z_utils:os_escape(Colorspace), $"]};
  201. filter2arg({width, _}, Width, Height) ->
  202. {Width, Height, []};
  203. filter2arg({height, _}, Width, Height) ->
  204. {Width, Height, []};
  205. filter2arg({resize, Width, Height, _}, Width, Height) ->
  206. {Width, Height, []};
  207. filter2arg({resize, EndWidth, EndHeight, false}, Width, Height)
  208. when Width =< EndWidth andalso Height =< EndHeight ->
  209. % Prevent scaling up, perform an extent instead
  210. GArg = "-gravity West",
  211. EArg = ["-extent ", integer_to_list(EndWidth),$x,integer_to_list(EndHeight)],
  212. % Still thumbnail to remove extra info from the image
  213. RArg = ["-thumbnail ", z_utils:os_escape([integer_to_list(EndWidth),$x,integer_to_list(EndHeight),$!])],
  214. {EndWidth, EndHeight, [GArg, 32, EArg, 32, RArg]};
  215. filter2arg({resize, EndWidth, EndHeight, true}, Width, Height)
  216. when Width < EndWidth andalso Height < EndHeight ->
  217. % Scale up
  218. EArg = ["-resize ", integer_to_list(EndWidth),$x,integer_to_list(EndHeight)],
  219. RArg = ["-thumbnail ", z_utils:os_escape([integer_to_list(EndWidth),$x,integer_to_list(EndHeight),$!])],
  220. {EndWidth, EndHeight, [EArg, 32, RArg]};
  221. filter2arg({extent, EndWidth, EndHeight}, Width, Height) when EndWidth == undefined orelse EndHeight == undefined ->
  222. {Width, Height, []};
  223. filter2arg({extent, EndWidth, EndHeight}, Width, Height) when Width /= EndWidth orelse Height /= EndHeight ->
  224. GArg = "-gravity Center",
  225. EArg = ["-extent ", integer_to_list(EndWidth),$x,integer_to_list(EndHeight)],
  226. {EndWidth, EndHeight, [GArg, 32, EArg]};
  227. filter2arg({resize, EndWidth, EndHeight, _}, _Width, _Height) ->
  228. GArg = "-gravity NorthWest",
  229. RArg = ["-thumbnail ", z_utils:os_escape([integer_to_list(EndWidth),$x,integer_to_list(EndHeight),$!])],
  230. {EndWidth, EndHeight, [GArg, 32, RArg]};
  231. filter2arg({crop, none}, Width, Height) ->
  232. {Width, Height, []};
  233. filter2arg({crop, {CropL, CropT, CropWidth, CropHeight}}, _Width, _Height) ->
  234. GArg = "-gravity NorthWest",
  235. CArg = ["-crop ", integer_to_list(CropWidth),$x,integer_to_list(CropHeight),
  236. $+,integer_to_list(CropL),$+,integer_to_list(CropT)],
  237. RArg = "+repage",
  238. {CropWidth, CropHeight, [GArg,32,CArg,32,RArg]};
  239. filter2arg(grey, Width, Height) ->
  240. {Width, Height, "-colorspace Gray"};
  241. filter2arg(mono, Width, Height) ->
  242. {Width, Height, "-monochrome"};
  243. filter2arg(flip, Width, Height) ->
  244. {Width, Height, "-flip"};
  245. filter2arg(flop, Width, Height) ->
  246. {Width, Height, "-flop"};
  247. filter2arg(blur, Width, Height) ->
  248. filter2arg({blur, 10}, Width, Height);
  249. filter2arg({blur, Blur}, Width, Height) when is_integer(Blur) ->
  250. {Width, Height, ["-blur ", integer_to_list(Blur)]};
  251. filter2arg({blur, Blur}, Width, Height) when is_list(Blur) ->
  252. case string:tokens(Blur, "x") of
  253. [A,B] -> {Width, Height, ["-blur ", ensure_integer(A), $x, ensure_integer(B)]};
  254. [A] -> {Width, Height, ["-blur ", ensure_integer(A)]}
  255. end;
  256. filter2arg(sharpen_small, Width, Height) when Width < 400 andalso Height < 400 ->
  257. {Width, Height, "-unsharp 0.3x0.7 "}; % 6x3+1+0
  258. filter2arg(sharpen_small, Width, Height) ->
  259. {Width, Height, []};
  260. filter2arg(lossless, Width, Height) ->
  261. {Width, Height, []};
  262. filter2arg({quality, Q}, Width, Height) ->
  263. {Width,Height, ["-quality ",integer_to_list(Q)]};
  264. filter2arg({removebg, Fuzz}, Width, Height) ->
  265. {Width, Height, ["-matte -fill none -fuzz ", integer_to_list(Fuzz), "% ",
  266. "-draw 'matte 0,0 floodfill' ",
  267. "-draw 'matte 0,", integer_to_list(Height-1), " floodfill' ",
  268. "-draw 'matte ", integer_to_list(Width-1), ",0 floodfill' ",
  269. "-draw 'matte ", integer_to_list(Width-1), ",", integer_to_list(Height-1), " floodfill' "
  270. ]};
  271. % Ignore these (are already handled as other filter args)
  272. filter2arg(extent, Width, Height) ->
  273. {Width, Height, []};
  274. filter2arg({extent, _}, Width, Height) ->
  275. {Width, Height, []};
  276. filter2arg({extent, _, _}, Width, Height) ->
  277. {Width, Height, []};
  278. filter2arg(upscale, Width, Height) ->
  279. {Width, Height, []}.
  280. %% @spec fetch_crop(Filters) -> {Crop, Filters}
  281. %% @doc Split the filters into size/crop and image manipulation filters.
  282. fetch_crop(Filters) ->
  283. {Crop,OtherFilters} = lists:partition(
  284. fun (crop) -> true;
  285. (F) when is_tuple(F) -> element(1,F) == 'crop';
  286. (_) -> false
  287. end, Filters),
  288. CropPar = case Crop of
  289. [{crop,None}] when None == false; None == undefined; None == ""; None == <<>> -> none;
  290. [{crop,Gravity}] -> Gravity; % center or one of the wind directions
  291. _ -> none
  292. end,
  293. {CropPar,OtherFilters}.
  294. %%@doc Calculate the size of the resulting image, depends on the crop and the original image size
  295. calc_size(Width, Height, ImageWidth, ImageHeight, CropPar, Orientation, IsUpscale) when Orientation >= 5 ->
  296. calc_size(Width, Height, ImageHeight, ImageWidth, CropPar, 1, IsUpscale);
  297. calc_size(undefined, undefined, ImageWidth, ImageHeight, _CropPar, _Orientation, _IsUpscale) ->
  298. {ImageWidth, ImageHeight, none};
  299. calc_size(Width, undefined, ImageWidth, ImageHeight, CropPar, Orientation, IsUpscale) when CropPar /= none ->
  300. calc_size(Width, Width, ImageWidth, ImageHeight, CropPar, Orientation, IsUpscale);
  301. calc_size(undefined, Height, ImageWidth, ImageHeight, CropPar, Orientation, IsUpscale) when CropPar /= none ->
  302. calc_size(Height, Height, ImageWidth, ImageHeight, CropPar, Orientation, IsUpscale);
  303. calc_size(undefined, Height, ImageWidth, ImageHeight, none, 1, false) when ImageHeight < Height ->
  304. % Image will be extented
  305. {ImageWidth, Height, none};
  306. calc_size(undefined, Height, ImageWidth, ImageHeight, CropPar, Orientation, IsUpscale) ->
  307. Width = round((ImageWidth / ImageHeight) * Height),
  308. calc_size(Width, Height, ImageWidth, ImageHeight, CropPar, Orientation, IsUpscale);
  309. calc_size(Width, undefined, ImageWidth, ImageHeight, none, 1, false) when ImageWidth < Width ->
  310. % Image will be extented
  311. {Width, ImageHeight, none};
  312. calc_size(Width, undefined, ImageWidth, ImageHeight, CropPar, Orientation, IsUpscale) ->
  313. Height = round((ImageHeight / ImageWidth) * Width),
  314. calc_size(Width, Height, ImageWidth, ImageHeight, CropPar, Orientation, IsUpscale);
  315. calc_size(Width, Height, ImageWidth, ImageHeight, CropPar, _Orientation, false)
  316. when CropPar /= none, Width > ImageWidth, Height > ImageHeight ->
  317. {Width, Height, none};
  318. calc_size(Width, Height, ImageWidth, ImageHeight, CropPar, _Orientation, _IsUpscale) ->
  319. ImageAspect = ImageWidth / ImageHeight,
  320. Aspect = Width / Height,
  321. case CropPar of
  322. none ->
  323. case Aspect > ImageAspect of
  324. true -> {ceil(ImageAspect * Height), Height, none};
  325. false -> {Width, ceil(Width / ImageAspect), none}
  326. end;
  327. _ ->
  328. % When we are doing a crop then we have to calculate the
  329. % maximum inner bounding box, and not the maximum outer
  330. % bounding box for the image
  331. {W,H} = case Aspect > ImageAspect of
  332. true ->
  333. % width is the larger one
  334. {Width, Width / ImageAspect};
  335. false ->
  336. % height is the larger one
  337. {ImageAspect * Height, Height}
  338. end,
  339. CropL = case CropPar of
  340. X when X == north_west; X == west; X == south_west -> 0;
  341. X when X == north_east; X == east; X == south_east -> ceil(W - Width);
  342. _ -> ceil((W - Width) / 2)
  343. end,
  344. CropT = case CropPar of
  345. Y when Y == north_west; Y == north; Y == north_east -> 0;
  346. Y when Y == south_west; Y == south; Y == south_east -> ceil(H - Height);
  347. _ -> ceil((H - Height) / 2)
  348. end,
  349. % @todo Prevent scaleup of the image, but preserve the result size
  350. % The crop is relative to the original image
  351. {ceil(W), ceil(H), {CropL, CropT, Width, Height}}
  352. end.
  353. %% @spec string2filter(Filter, Arg) -> FilterTuple
  354. %% @doc Map the list of known filters and known args to atoms. Used when mapping preview urls back to filter args.
  355. string2filter("crop", []) ->
  356. {crop,center};
  357. string2filter("crop", Where) ->
  358. Dir = case Where of
  359. "north" -> north;
  360. "north_east" -> north_east;
  361. "east" -> east;
  362. "south_east" -> south_east;
  363. "south" -> south;
  364. "south_west" -> south_west;
  365. "west" -> west;
  366. "north_west" -> north_west;
  367. "center" -> center
  368. end,
  369. {crop,Dir};
  370. string2filter("grey",[]) ->
  371. grey;
  372. string2filter("mono",[]) ->
  373. mono;
  374. string2filter("flip",[]) ->
  375. flip;
  376. string2filter("flop",[]) ->
  377. flop;
  378. string2filter("extent",[]) ->
  379. extent;
  380. string2filter("upscale",[]) ->
  381. upscale;
  382. string2filter("blur",[]) ->
  383. blur;
  384. string2filter("blur",Arg) ->
  385. {blur,Arg};
  386. string2filter("quality", Arg) ->
  387. {quality,list_to_integer(Arg)};
  388. string2filter("background", Arg) ->
  389. {background,Arg};
  390. string2filter("lossless", []) ->
  391. lossless;
  392. string2filter("removebg", []) ->
  393. {removebg, 5};
  394. string2filter("removebg", Arg) ->
  395. {removebg, list_to_integer(Arg)}.
  396. % simple ceil for positive numbers
  397. ceil(A) -> round(A + 0.499999).
  398. %floor(A) -> round(A - 0.499999).
  399. ensure_integer(A) ->
  400. integer_to_list(list_to_integer(A)).