Author: wencoo
Blog:https://wencoo.blog.csdn.net/
Date: 08/12/2023
Email: jianwen056@aliyun.com
Wechat:wencoo824
QQ:1419440391
Details:
int main(int argc, char *argv[])
{
int frame_w = 1280;
int frame_h = 720;
if (argc != 4 && argc != 6) {
printf("usage: %s <image file> <subtitle file> <time> "
"[<storage width> <storage height>]\n",
argv[0] ? argv[0] : "test");
exit(1);
}
char *imgfile = argv[1];
char *subfile = argv[2];
double tm = strtod(argv[3], 0);
if (argc == 6) {
frame_w = atoi(argv[4]);
frame_h = atoi(argv[5]);
if (frame_w <= 0 || frame_h <= 0) {
printf("storage size must be non-zero and positive!\n");
exit(1);
}
}
print_font_providers(ass_library);
init(frame_w, frame_h);
ASS_Track *track = ass_read_file(ass_library, subfile, NULL);
if (!track) {
printf("track init failed!\n");
return 1;
}
ASS_Image *img =
ass_render_frame(ass_renderer, track, (int) (tm * 1000), NULL);
image_t *frame = gen_image(frame_w, frame_h);
blend(frame, img);
ass_free_track(track);
ass_renderer_done(ass_renderer);
ass_library_done(ass_library);
write_png(imgfile, frame);
free(frame->buffer);
free(frame);
return 0;
}
可以看到,重点函数只有3个,我们一个一个查看就可以了。
init实现如下:
static void init(int frame_w, int frame_h)
{
ass_library = ass_library_init();
if (!ass_library) {
printf("ass_library_init failed!\n");
exit(1);
}
ass_set_message_cb(ass_library, msg_callback, NULL);
ass_set_extract_fonts(ass_library, 1);
ass_renderer = ass_renderer_init(ass_library);
if (!ass_renderer) {
printf("ass_renderer_init failed!\n");
exit(1);
}
ass_set_storage_size(ass_renderer, frame_w, frame_h);
ass_set_frame_size(ass_renderer, frame_w, frame_h);
ass_set_fonts(ass_renderer, NULL, "sans-serif",
ASS_FONTPROVIDER_AUTODETECT, NULL, 1);
}
ass_read_file实现如下:
/**
* \brief Read subtitles from file.
* \param library libass library object
* \param fname file name
* \param codepage recode buffer contents from given codepage
* \return newly allocated track
*/
ASS_Track *ass_read_file(ASS_Library *library, char *fname,
char *codepage)
{
char *buf;
ASS_Track *track;
size_t bufsize;
//读取ass文件内容,存入buf,并且把ass内容转换成utf-8格式
buf = read_file_recode(library, fname, codepage, &bufsize);
if (!buf)
return 0;
//重点:解析内存中的数据
track = parse_memory(library, buf);
free(buf);
if (!track)
return 0;
//将名字赋值给name
track->name = strdup(fname);
ass_msg(library, MSGL_INFO,
"Added subtitle file: '%s' (%d styles, %d events)",
fname, track->n_styles, track->n_events);
return track;
}
parse_memory实现如下:
/*
* \param buf pointer to subtitle text in utf-8
*/
static ASS_Track *parse_memory(ASS_Library *library, char *buf)
{
ASS_Track *track;
int i;
//创建ass轨道,申请了内存
track = ass_new_track(library);
if (!track)
return NULL;
// process header
// 解析文件内容,
process_text(track, buf);
// external SSA/ASS subs does not have ReadOrder field 外部SSA/ASS sub没有ReadOrder字段
for (i = 0; i < track->n_events; ++i)
track->events[i].ReadOrder = i;
if (track->track_type == TRACK_TYPE_UNKNOWN) {
ass_free_track(track);
return 0;
}
ass_process_force_style(track);
return track;
}
process_text函数中过滤了空行等,有效数据进入process_line函数进行进一步处理。process_line实现如下:
/**
* \brief Parse a header line
* \param track track
* \param str string to parse, zero-terminated
*/
static int process_line(ASS_Track *track, char *str)
{
skip_spaces(&str);
if (!ass_strncasecmp(str, "[Script Info]", 13)) {
track->parser_priv->state = PST_INFO;
} else if (!ass_strncasecmp(str, "[V4 Styles]", 11)) {
track->parser_priv->state = PST_STYLES;
track->track_type = TRACK_TYPE_SSA;
} else if (!ass_strncasecmp(str, "[V4+ Styles]", 12)) {
track->parser_priv->state = PST_STYLES;
track->track_type = TRACK_TYPE_ASS;
} else if (!ass_strncasecmp(str, "[Events]", 8)) {
track->parser_priv->state = PST_EVENTS;
} else if (!ass_strncasecmp(str, "[Fonts]", 7)) {
track->parser_priv->state = PST_FONTS;
} else {
switch (track->parser_priv->state) {
case PST_INFO:
process_info_line(track, str);
break;
case PST_STYLES:
process_styles_line(track, str);
break;
case PST_EVENTS:
process_events_line(track, str);
break;
case PST_FONTS:
process_fonts_line(track, str);
break;
default:
break;
}
}
return 0;
}
process_events_line函数开始具体处理Event的逻辑。
static int process_events_line(ASS_Track *track, char *str)
{
if (!strncmp(str, "Format:", 7)) {
char *p = str + 7;
skip_spaces(&p);
free(track->event_format);
track->event_format = strdup(p);
if (!track->event_format)
return -1;
ass_msg(track->library, MSGL_DBG2, "Event format: %s", track->event_format);
if (track->track_type == TRACK_TYPE_ASS)
custom_format_line_compatibility(track, p, ass_event_format);
else
custom_format_line_compatibility(track, p, ssa_event_format);
// Guess if we are dealing with legacy ffmpeg subs and change accordingly
//猜测我们是否在处理遗留的ffmpeg sub并进行相应的更改
// If file has no event format it was probably not created by ffmpeg/libav
//如果文件没有事件格式,它可能不是由ffmpeg/libav创建的
if (detect_legacy_conv_subs(track)) {
track->ScaledBorderAndShadow = 1;
ass_msg(track->library, MSGL_INFO,
"Track treated as legacy ffmpeg sub.");
}
} else if (!strncmp(str, "Dialogue:", 9)) {
// This should never be reached for embedded subtitles. 嵌入式字幕永远不应该达到这个要求。
// They have slightly different format and are parsed in ass_process_chunk,
//它们的格式略有不同,并在ass_process_chunk中解析,直接从demuxer调用
// called directly from demuxer
int eid;
ASS_Event *event;
// We can't parse events without event_format 如果没有event_format,我们就无法解析事件
if (!track->event_format) {
event_format_fallback(track);
if (!track->event_format)
return -1;
}
str += 9;
skip_spaces(&str);
eid = ass_alloc_event(track);
if (eid < 0)
return -1;
event = track->events + eid;
int ret = process_event_tail(track, event, str, 0);
if (!ret)
return 0;
// If something went wrong, discard the useless Event 如果出现问题,丢弃无用的Event
ass_free_event(track, eid);
track->n_events--;
return ret;
} else {
ass_msg(track->library, MSGL_V, "Not understood: '%.30s'", str);
}
return 0;
}
通过调试,走到process_event_tail函数中
/**
* \brief Parse the tail of Dialogue line
* \param track track
* \param event parsed data goes here
* \param str string to parse, zero-terminated
* \param n_ignored number of format options to skip at the beginning
*/
static int process_event_tail(ASS_Track *track, ASS_Event *event,
char *str, int n_ignored)
{
char *token;
char *tname;
char *p = str;
int i;
ASS_Event *target = event;
char *format = strdup(track->event_format);
if (!format)
return -1;
char *q = format; // format scanning pointer
for (i = 0; i < n_ignored; ++i) {
NEXT(q, tname);
}
while (1) {
NEXT(q, tname);
if (ass_strcasecmp(tname, "Text") == 0) {
event->Text = strdup(p);
if (event->Text && *event->Text != 0) {
char *end = event->Text + strlen(event->Text);
while (end > event->Text &&
(end[-1] == '\r' || end[-1] == '\t' || end[-1] == ' '))
*--end = 0;
}
event->Duration -= event->Start;
free(format);
return event->Text ? 0 : -1; // "Text" is always the last
}
NEXT(p, token);
ALIAS(End, Duration) // temporarily store end timecode in event->Duration
ALIAS(Actor, Name) // both variants are used in files
PARSE_START
INTVAL(Layer)
STYLEVAL(Style)
STRVAL(Name)
STRVAL(Effect)
INTVAL(MarginL)
INTVAL(MarginR)
INTVAL(MarginV)
TIMEVAL(Start)
TIMEVAL(Duration)
PARSE_END
}
free(format);
return 1;
}
在这个函数中,tname是Event项中的Format中的一项,token对应Event项中的Dialogue中的tname的对应项数值。
在该函数中,有很多的宏定义,下面我们就看一看这些宏定义的作用,参考libass分析3-源码分析-libass中的宏定义分析。
所以,该函数就是将ass文件内容解析,然后全部赋值给ASS_Track结构。然后继续往下看main函数中接下来的函数ass_render_frame
实现:
/**
* \brief render a frame 渲染一个帧
* \param priv library handle
* \param track track
* \param now current video timestamp (ms) 当前视频时间戳(毫秒)
* \param detect_change a value describing how the new images differ from the previous ones will be written here:
* 0 if identical, 1 if different positions, 2 if different content.
* Can be NULL, in that case no detection is performed.
一个描述新图像与之前图像的不同之处的值将写在这里:
*相同为0,位置不同为1,内容不同为2。
*可以为NULL,在这种情况下不执行检测
*/
ASS_Image *ass_render_frame(ASS_Renderer *priv, ASS_Track *track,
long long now, int *detect_change)
{
// init frame
if (!ass_start_frame(priv, track, now)) {
if (detect_change)
*detect_change = 2;
return NULL;
}
// render events separately 分别渲染事件
int cnt = 0;
for (int i = 0; i < track->n_events; i++) {
ASS_Event *event = track->events + i;
if ((event->Start <= now)
&& (now < (event->Start + event->Duration))) {
if (cnt >= priv->eimg_size) {
priv->eimg_size += 100;
priv->eimg =
realloc(priv->eimg,
priv->eimg_size * sizeof(EventImages));
}
if (ass_render_event(&priv->state, event, priv->eimg + cnt))
cnt++;
}
}
// sort by layer 按层排序
if (cnt > 0)
qsort(priv->eimg, cnt, sizeof(EventImages), cmp_event_layer);
// call fix_collisions for each group of events with the same layer
// 对同一层的每一组事件调用fix_collisions
EventImages *last = priv->eimg;
for (int i = 1; i < cnt; i++)
if (last->event->Layer != priv->eimg[i].event->Layer) {
fix_collisions(priv, last, priv->eimg + i - last);
last = priv->eimg + i;
}
if (cnt > 0)
fix_collisions(priv, last, priv->eimg + cnt - last);
// concat lists concat列表
ASS_Image **tail = &priv->images_root;
for (int i = 0; i < cnt; i++) {
ASS_Image *cur = priv->eimg[i].imgs;
while (cur) {
*tail = cur;
tail = &cur->next;
cur = cur->next;
}
}
ass_frame_ref(priv->images_root);
if (detect_change)
*detect_change = ass_detect_change(priv);
// free the previous image list
ass_frame_unref(priv->prev_images_root);
priv->prev_images_root = NULL;
return priv->images_root;
}
第一个逻辑就是初始化一个帧,看看初始化帧内容有什么东西。
实现:
/**
* \brief Start a new frame
*/
static bool
ass_start_frame(ASS_Renderer *render_priv, ASS_Track *track,
long long now)
{
if (!render_priv->settings.frame_width
&& !render_priv->settings.frame_height)
return false; // library not initialized
if (!render_priv->fontselect)
return false;
if (render_priv->library != track->library)
return false;
if (track->n_events == 0)
return false; // nothing to do
render_priv->track = track;
render_priv->time = now;
//为渲染准备轨道,对轨道参数先设定一些预设值,分辨率的设置
ass_lazy_track_init(render_priv->library, render_priv->track);
if (render_priv->library->num_fontdata != render_priv->num_emfonts) {
assert(render_priv->library->num_fontdata > render_priv->num_emfonts);
render_priv->num_emfonts = ass_update_embedded_fonts(
render_priv->fontselect, render_priv->num_emfonts);
}
setup_shaper(render_priv->state.shaper, render_priv);
// PAR correction
double par = render_priv->settings.par;
bool lr_track = track->LayoutResX > 0 && track->LayoutResY > 0;
if (par == 0. || lr_track) {
if (render_priv->frame_content_width && render_priv->frame_content_height && (lr_track ||
(render_priv->settings.storage_width && render_priv->settings.storage_height))) {
double dar = ((double) render_priv->frame_content_width) /
render_priv->frame_content_height;
ASS_Vector layout_res = ass_layout_res(render_priv);
double sar = ((double) layout_res.x) / layout_res.y;
par = dar / sar;
} else
par = 1.0;
}
render_priv->par_scale_x = par;
render_priv->prev_images_root = render_priv->images_root;
render_priv->images_root = NULL;
check_cache_limits(render_priv, &render_priv->cache);
return true;
}
融合渲染一帧的实现:
/**
* \brief Main ass rendering function, glues everything together 主ass渲染功能,将所有内容粘合在一起
* \param event event to render 渲染事件
* \param event_images struct containing resulting images, will also be initialized
* Process event, appending resulting ASS_Image's to images_root.
包含结果图像的结构,也将被初始化处理事件,将结果ASS_Image's附加到images_root
*/
static bool
ass_render_event(RenderContext *state, ASS_Event *event,
EventImages *event_images)
{
ASS_Renderer *render_priv = state->renderer;
if (event->Style >= render_priv->track->n_styles) {
ass_msg(render_priv->library, MSGL_WARN, "No style found");
return false;
}
if (!event->Text) {
ass_msg(render_priv->library, MSGL_WARN, "Empty event");
return false;
}
free_render_context(state);
init_render_context(state, event);
if (!parse_events(state, event))
return false;
TextInfo *text_info = state->text_info;
if (text_info->length == 0) {
// no valid symbols in the event; this can be smth like {comment}
free_render_context(state);
return false;
}
split_style_runs(state);
// Find shape runs and shape text
ass_shaper_set_base_direction(state->shaper,
ass_resolve_base_direction(state->font_encoding));
ass_shaper_find_runs(state->shaper, render_priv, text_info->glyphs,
text_info->length);
if (!ass_shaper_shape(state->shaper, text_info)) {
ass_msg(render_priv->library, MSGL_ERR, "Failed to shape text");
free_render_context(state);
return false;
}
retrieve_glyphs(state);
preliminary_layout(state);
int valign = state->alignment & 12;
int MarginL =
(event->MarginL) ? event->MarginL : state->style->MarginL;
int MarginR =
(event->MarginR) ? event->MarginR : state->style->MarginR;
int MarginV =
(event->MarginV) ? event->MarginV : state->style->MarginV;
// calculate max length of a line
double max_text_width =
x2scr_right(state, render_priv->track->PlayResX - MarginR) -
x2scr_left(state, MarginL);
// wrap lines
wrap_lines_smart(state, max_text_width);
// depends on glyph x coordinates being monotonous within runs, so it should be done before reorder
ass_process_karaoke_effects(state);
reorder_text(state);
align_lines(state, max_text_width);
// determing text bounding box
ASS_DRect bbox;
compute_string_bbox(text_info, &bbox);
apply_baseline_shear(state);
// determine device coordinates for text
double device_x = 0;
double device_y = 0;
// handle positioned events first: an event can be both positioned and
// scrolling, and the scrolling effect overrides the position on one axis
if (state->evt_type & EVENT_POSITIONED) {
double base_x = 0;
double base_y = 0;
get_base_point(&bbox, state->alignment, &base_x, &base_y);
device_x =
x2scr_pos(render_priv, state->pos_x) - base_x;
device_y =
y2scr_pos(render_priv, state->pos_y) - base_y;
}
// x coordinate
if (state->evt_type & EVENT_HSCROLL) {
if (state->scroll_direction == SCROLL_RL)
device_x =
x2scr_pos(render_priv,
render_priv->track->PlayResX -
state->scroll_shift);
else if (state->scroll_direction == SCROLL_LR)
device_x =
x2scr_pos(render_priv, state->scroll_shift) -
(bbox.x_max - bbox.x_min);
} else if (!(state->evt_type & EVENT_POSITIONED)) {
device_x = x2scr_left(state, MarginL);
}
// y coordinate
if (state->evt_type & EVENT_VSCROLL) {
if (state->scroll_direction == SCROLL_TB)
device_y =
y2scr(state,
state->scroll_y0 +
state->scroll_shift) -
bbox.y_max;
else if (state->scroll_direction == SCROLL_BT)
device_y =
y2scr(state,
state->scroll_y1 -
state->scroll_shift) -
bbox.y_min;
} else if (!(state->evt_type & EVENT_POSITIONED)) {
if (valign == VALIGN_TOP) { // toptitle
device_y =
y2scr_top(state,
MarginV) + text_info->lines[0].asc;
} else if (valign == VALIGN_CENTER) { // midtitle
double scr_y =
y2scr(state, render_priv->track->PlayResY / 2.0);
device_y = scr_y - (bbox.y_max + bbox.y_min) / 2.0;
} else { // subtitle
double line_pos = state->explicit ?
0 : render_priv->settings.line_position;
double scr_top, scr_bottom, scr_y0;
if (valign != VALIGN_SUB)
ass_msg(render_priv->library, MSGL_V,
"Invalid valign, assuming 0 (subtitle)");
scr_bottom =
y2scr_sub(state,
render_priv->track->PlayResY - MarginV);
scr_top = y2scr_top(state, 0); //xxx not always 0?
device_y = scr_bottom + (scr_top - scr_bottom) * line_pos / 100.0;
device_y -= text_info->height;
device_y += text_info->lines[0].asc;
// clip to top to avoid confusion if line_position is very high,
// turning the subtitle into a toptitle
// also, don't change behavior if line_position is not used
scr_y0 = scr_top + text_info->lines[0].asc;
if (device_y < scr_y0 && line_pos > 0) {
device_y = scr_y0;
}
}
}
// fix clip coordinates
if (state->explicit || !render_priv->settings.use_margins) {
state->clip_x0 =
x2scr_pos_scaled(render_priv, state->clip_x0);
state->clip_x1 =
x2scr_pos_scaled(render_priv, state->clip_x1);
state->clip_y0 =
y2scr_pos(render_priv, state->clip_y0);
state->clip_y1 =
y2scr_pos(render_priv, state->clip_y1);
if (state->explicit) {
// we still need to clip against screen boundaries
double zx = x2scr_pos_scaled(render_priv, 0);
double zy = y2scr_pos(render_priv, 0);
double sx = x2scr_pos_scaled(render_priv, render_priv->track->PlayResX);
double sy = y2scr_pos(render_priv, render_priv->track->PlayResY);
state->clip_x0 = FFMAX(state->clip_x0, zx);
state->clip_y0 = FFMAX(state->clip_y0, zy);
state->clip_x1 = FFMIN(state->clip_x1, sx);
state->clip_y1 = FFMIN(state->clip_y1, sy);
}
} else {
// no \clip (explicit==0) and use_margins => only clip to screen with margins
state->clip_x0 = 0;
state->clip_y0 = 0;
state->clip_x1 = render_priv->settings.frame_width;
state->clip_y1 = render_priv->settings.frame_height;
}
if (state->evt_type & EVENT_VSCROLL) {
double y0 = y2scr_pos(render_priv, state->scroll_y0);
double y1 = y2scr_pos(render_priv, state->scroll_y1);
state->clip_y0 = FFMAX(state->clip_y0, y0);
state->clip_y1 = FFMIN(state->clip_y1, y1);
}
calculate_rotation_params(state, &bbox, device_x, device_y);
render_and_combine_glyphs(state, device_x, device_y);
memset(event_images, 0, sizeof(*event_images));
// VSFilter does *not* shift lines with a border > margin to be within the
// frame, so negative values for top and left may occur
event_images->top = device_y - text_info->lines[0].asc - text_info->border_top;
event_images->height =
text_info->height + text_info->border_bottom + text_info->border_top;
event_images->left =
(device_x + bbox.x_min) * render_priv->par_scale_x - text_info->border_x + 0.5;
event_images->width =
(bbox.x_max - bbox.x_min) * render_priv->par_scale_x
+ 2 * text_info->border_x + 0.5;
event_images->detect_collisions = state->detect_collisions;
event_images->shift_direction = (valign == VALIGN_SUB) ? -1 : 1;
event_images->event = event;
event_images->imgs = render_text(state);
if (state->border_style == 4)
add_background(state, event_images);
ass_shaper_cleanup(state->shaper, text_info);
free_render_context(state);
return true;
}
ass_apply_transition_effects(state);
state->explicit = state->evt_type != EVENT_NORMAL ||
ass_event_has_hard_overrides(event->Text);
ass_reset_render_context(state, NULL);
// Return 1 if the event contains tags that will apply overrides the selective
// style override code should not touch. Return 0 otherwise.
int ass_event_has_hard_overrides(char *str)
{
// look for \pos and \move tags inside {...}
// mirrors ass_get_next_char, but is faster and doesn't change any global state
while (*str) {
if (str[0] == '\\' && str[1] != '\0') {
str += 2;
} else if (str[0] == '{') {
str++;
while (*str && *str != '}') {
if (*str == '\\') {
char *p = str + 1;
if (mystrcmp(&p, "pos") || mystrcmp(&p, "move") ||
mystrcmp(&p, "clip") || mystrcmp(&p, "iclip") ||
mystrcmp(&p, "org") || mystrcmp(&p, "pbo") ||
mystrcmp(&p, "p"))
return 1;
}
str++;
}
} else {
str++;
}
}
return 0;
}
void ass_apply_transition_effects(RenderContext *state)
{
ASS_Renderer *render_priv = state->renderer;
int v[4];
int cnt;
ASS_Event *event = state->event;
char *p = event->Effect;
if (!p || !*p)
return;
cnt = 0;
while (cnt < 4 && (p = strchr(p, ';'))) {
v[cnt++] = atoi(++p);
}
ASS_Vector layout_res = ass_layout_res(render_priv);
if (strncmp(event->Effect, "Banner;", 7) == 0) {
double delay;
if (cnt < 1) {
ass_msg(render_priv->library, MSGL_V,
"Error parsing effect: '%s'", event->Effect);
return;
}
if (cnt >= 2 && v[1]) // left-to-right
state->scroll_direction = SCROLL_LR;
else // right-to-left
state->scroll_direction = SCROLL_RL;
delay = v[0];
// VSF works in storage coordinates, but scales delay to PlayRes canvas
// before applying max(scaled_ delay, 1). This means, if scaled_delay < 1
// (esp. delay=0) we end up with 1 ms per _storage pixel_ without any
// PlayRes scaling.
// The way libass deals with delay, it is automatically relative to the
// PlayRes canvas, so we only want to "unscale" the small delay values.
//
// VSF also casts the scaled delay to int, which if not emulated leads to
// easily noticeable deviations from VSFilter as the effect goes on.
// To achieve both we need to keep our Playres-relative delay with high precision,
// but must temporarily convert to storage-relative and truncate and take the
// maxuimum there, before converting back.
double scale_x = ((double) layout_res.x) / render_priv->track->PlayResX;
delay = ((int) FFMAX(delay / scale_x, 1)) * scale_x;
state->scroll_shift =
(render_priv->time - event->Start) / delay;
state->evt_type |= EVENT_HSCROLL;
state->detect_collisions = 0;
state->wrap_style = 2;
return;
}
if (strncmp(event->Effect, "Scroll up;", 10) == 0) {
state->scroll_direction = SCROLL_BT;
} else if (strncmp(event->Effect, "Scroll down;", 12) == 0) {
state->scroll_direction = SCROLL_TB;
} else {
ass_msg(render_priv->library, MSGL_DBG2,
"Unknown transition effect: '%s'", event->Effect);
return;
}
// parse scroll up/down parameters
{
double delay;
int y0, y1;
if (cnt < 3) {
ass_msg(render_priv->library, MSGL_V,
"Error parsing effect: '%s'", event->Effect);
return;
}
delay = v[2];
// See explanation for Banner
double scale_y = ((double) layout_res.y) / render_priv->track->PlayResY;
delay = ((int) FFMAX(delay / scale_y, 1)) * scale_y;
state->scroll_shift =
(render_priv->time - event->Start) / delay;
if (v[0] < v[1]) {
y0 = v[0];
y1 = v[1];
} else {
y0 = v[1];
y1 = v[0];
}
state->scroll_y0 = y0;
state->scroll_y1 = y1;
state->evt_type |= EVENT_VSCROLL;
state->detect_collisions = 0;
}
}
由于笔者的水平有限, 加之编写的同时还要参与开发工作,文中难免会出现一些错误或者不准确的地方,恳请读者批评指正。如果读者有任何宝贵意见,可以加我微信 wencoo824。QQ:1419440391。
欢迎加微信,搜索"wencoo824",进行技术交流,备注”博客音视频技术交流“